From c81191b46a0aa3442fd3d5d40baacdbb8a03bd83 Mon Sep 17 00:00:00 2001 From: BayinForge Date: Thu, 23 Jul 2026 07:40:54 +0800 Subject: [PATCH 01/26] fix(deploy): prevent Caddy compression from buffering SSE Replace the broad text response matcher with an explicit non-SSE MIME allowlist. Document proxy behavior and enforce the canonical Caddy compression policy in CI. --- .github/workflows/backend-ci.yml | 1 + deploy/Caddyfile | 9 +++- deploy/EDGE_SECURITY.md | 25 +++++++++++ deploy/test-caddyfile-cache.sh | 73 ++++++++++++++++++++++++++++++-- 4 files changed, 104 insertions(+), 4 deletions(-) diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 859946525..b91734495 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -16,6 +16,7 @@ jobs: run: | /bin/bash -n deploy/apple-container.sh /bin/bash deploy/tests/apple-container-test.sh + /bin/sh deploy/test-caddyfile-cache.sh test: runs-on: ubuntu-latest diff --git a/deploy/Caddyfile b/deploy/Caddyfile index 079b5f07e..77e1bf4c5 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -67,7 +67,14 @@ api.sub2api.com { gzip 6 minimum_length 256 match { - header Content-Type text/* + # 不使用 text/*,否则 text/event-stream 会被压缩并缓冲到流结束 + header Content-Type text/css* + header Content-Type text/csv* + header Content-Type text/html* + header Content-Type text/javascript* + header Content-Type text/markdown* + header Content-Type text/plain* + header Content-Type text/xml* header Content-Type application/json* header Content-Type application/javascript* header Content-Type application/xml* diff --git a/deploy/EDGE_SECURITY.md b/deploy/EDGE_SECURITY.md index 956507c4f..5e9ab10fb 100644 --- a/deploy/EDGE_SECURITY.md +++ b/deploy/EDGE_SECURITY.md @@ -128,6 +128,20 @@ server { } ``` +If Nginx gzip is enabled in the `http` block, keep `text/event-stream` out of +`gzip_types` and do not use `gzip_types *` for Sub2API. The +`proxy_buffering off` setting above prevents proxy buffering, but it does not +disable the gzip response filter. Use an explicit list for ordinary responses: + +```nginx +gzip on; +gzip_types text/plain text/css application/json application/javascript application/xml image/svg+xml; +``` + +If a shared global configuration cannot exclude SSE by content type, set +`gzip off;` in the locations serving streaming API routes. This leaves gzip +available for the web UI and static assets. + Do not use an incoming `$http_x_forwarded_for` value unless Nginx real-IP processing is restricted to explicit trusted proxy CIDRs. @@ -140,6 +154,17 @@ the TCP peer. It is therefore a direct-to-Caddy baseline. Do not use its attributed to a CDN egress address, collapsing rejection aggregation and the invalid-auth limiter onto unrelated users. +The bundled Caddy configuration leaves `flush_interval` unset so Caddy can +automatically flush `text/event-stream` responses while still propagating +client cancellation upstream. Do not set it globally: positive values can add +streaming latency, while Caddy 2.6.2's special `-1` mode also causes +reverse-proxied requests to continue after clients disconnect. The +configuration uses an explicit response content-type list for compression. Do +not replace that list with `text/*` or the shorthand `encode gzip zstd`: both +match `text/event-stream` and can buffer SSE until the response ends. Keep +streaming responses uncompressed while retaining compression for the web UI, +JSON, and static assets. + For a CDN deployment, first firewall the origin so only current CDN egress CIDRs can connect. Then configure those exact ranges as Caddy trusted proxies and derive upstream headers from Caddy's parsed `{client_ip}`. For example: diff --git a/deploy/test-caddyfile-cache.sh b/deploy/test-caddyfile-cache.sh index 789e48ee2..803231f4b 100755 --- a/deploy/test-caddyfile-cache.sh +++ b/deploy/test-caddyfile-cache.sh @@ -4,15 +4,82 @@ set -eu repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) caddyfile="$repo_root/deploy/Caddyfile" active_config=$(sed 's/[[:space:]]*#.*$//' "$caddyfile") +normalized_config=$(printf '%s\n' "$active_config" | awk ' + NF > 0 { + for (field = 1; field <= NF; field++) { + token = $field + sub(/^["`]/, "", token) + sub(/["`]$/, "", token) + printf "%s%s", field == 1 ? "" : " ", token + } + print "" + } +') -if printf '%s\n' "$active_config" | grep -Eiq 'Cache-Control.*immutable'; then +# Keep one canonical encode block instead of reimplementing Caddy matcher semantics. +expected_encode_block=$(cat <<'EOF' +encode { +zstd +gzip 6 +minimum_length 256 +match { +header Content-Type text/css* +header Content-Type text/csv* +header Content-Type text/html* +header Content-Type text/javascript* +header Content-Type text/markdown* +header Content-Type text/plain* +header Content-Type text/xml* +header Content-Type application/json* +header Content-Type application/javascript* +header Content-Type application/xml* +header Content-Type application/rss+xml* +header Content-Type image/svg+xml* +} +} +EOF +) + +if printf '%s\n' "$normalized_config" | grep -Eiq 'cache-control.*immutable'; then echo "Caddyfile must not force immutable caching; the backend owns asset cache policy" >&2 exit 1 fi -if ! printf '%s\n' "$active_config" | grep -Eq '^[[:space:]]*reverse_proxy[[:space:]]+localhost:8080'; then +if ! printf '%s\n' "$normalized_config" | grep -Eq '^reverse_proxy localhost:8080([[:space:]]|$)'; then echo "Caddyfile must continue proxying all application routes to localhost:8080" >&2 exit 1 fi -echo "Caddyfile preserves backend Cache-Control policy and reverse_proxy routing" +if printf '%s\n' "$normalized_config" | grep -Eq '^import([[:space:]]|$)'; then + echo "Caddyfile must not import configuration outside this canonical policy check" >&2 + exit 1 +fi + +if printf '%s\n' "$normalized_config" | grep -Eiq '^flush_interval([[:space:]]|$)'; then + echo "Caddyfile must leave flush_interval unset so SSE auto-flushing and client cancellation remain intact" >&2 + exit 1 +fi + +encode_directive_count=$(printf '%s\n' "$normalized_config" | awk '$1 == "encode" { count++ } END { print count + 0 }') +if [ "$encode_directive_count" -ne 1 ]; then + echo "Caddyfile must contain exactly one explicit encode block" >&2 + exit 1 +fi + +actual_encode_block=$(printf '%s\n' "$normalized_config" | awk ' + $1 == "encode" { in_block = 1 } + in_block { + print + for (field = 1; field <= NF; field++) { + if ($field == "{") depth++ + if ($field == "}") depth-- + } + if (depth == 0) exit + } +') +if [ "$actual_encode_block" != "$expected_encode_block" ]; then + echo "Caddyfile encode block must keep the canonical non-SSE compression policy" >&2 + exit 1 +fi + +echo "Caddyfile preserves backend cache policy, SSE streaming, and non-SSE compression" From e46d55bc576ded57219769956cb0ad5958dbe05e Mon Sep 17 00:00:00 2001 From: BayinForge Date: Thu, 23 Jul 2026 14:46:09 +0800 Subject: [PATCH 02/26] fix(ci): make Caddy check portable across awk implementations --- deploy/test-caddyfile-cache.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/test-caddyfile-cache.sh b/deploy/test-caddyfile-cache.sh index 803231f4b..41e5e255c 100755 --- a/deploy/test-caddyfile-cache.sh +++ b/deploy/test-caddyfile-cache.sh @@ -10,7 +10,7 @@ normalized_config=$(printf '%s\n' "$active_config" | awk ' token = $field sub(/^["`]/, "", token) sub(/["`]$/, "", token) - printf "%s%s", field == 1 ? "" : " ", token + printf "%s%s", (field == 1 ? "" : " "), token } print "" } From 386b57bb77b7d3ebf1af73c3003c70b9b5712ecf Mon Sep 17 00:00:00 2001 From: visa2 <1927401+visa2@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:41:52 +0800 Subject: [PATCH 03/26] fix(composite): pass the requested model through when a prefix route leaves upstream_model empty Composite model routes support a match_type: prefix mode with an optional upstream_model. Leaving upstream_model empty is meant to pass the concrete requested model through to the upstream, but every request matching the prefix collapsed to the route's public_model instead. A route with public_model=deepseek-v4, match_type=prefix and an empty upstream_model forwarded both deepseek-v4-flash and deepseek-v4-pro upstream as deepseek-v4. Root cause: normalizeCompositeRouteInput backfilled an empty upstream_model with public_model at save time, so CompositeRouteResolver.Resolve never reached its "upstreamModel == '' -> concrete requested model" fallback. - Skip the backfill for prefix routes only; an empty upstream_model stays empty and Resolve passes the concrete requested model through. - exact routes keep the backfill (model == public_model, so persisted/display contract is unchanged). - Explicit upstream_model still forwards that fixed model in every mode. - Frontend: add an upstream_model hint (zh/en) documenting empty = passthrough. --- .../service/admin_service_group_test.go | 26 ++++++++- .../internal/service/composite_model_route.go | 7 ++- .../service/composite_route_resolver_test.go | 57 +++++++++++++++++++ .../src/i18n/locales/en/admin/overview.ts | 1 + .../src/i18n/locales/zh/admin/overview.ts | 1 + frontend/src/views/admin/GroupsView.vue | 3 + 6 files changed, 93 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index cf1341787..5b28928f7 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -1584,7 +1584,8 @@ func TestAdminService_CreateCompositeRoute_NormalizesAndPersists(t *testing.T) { require.Equal(t, "router/gpt-", route.PublicModel) require.Equal(t, CompositeRouteMatchPrefix, route.MatchType) require.Equal(t, PlatformOpenAI, route.TargetPlatform) - require.Equal(t, "router/gpt-", route.UpstreamModel) + // prefix 路由留空 upstream_model 不再回填 public_model:留空表示透传原始请求模型。 + require.Equal(t, "", route.UpstreamModel) require.Equal(t, CompositeRouteEndpointResponses, route.Endpoint) require.Equal(t, 100, route.Priority) require.True(t, route.Enabled) @@ -1592,6 +1593,29 @@ func TestAdminService_CreateCompositeRoute_NormalizesAndPersists(t *testing.T) { require.Equal(t, route, routeRepo.created) } +// TestAdminService_CreateCompositeRoute_ExactEmptyUpstreamBackfillsPublicModel 锁定 +// 保守行为:exact 路由留空 upstream_model 仍回填 public_model(持久化/展示契约不变)。 +func TestAdminService_CreateCompositeRoute_ExactEmptyUpstreamBackfillsPublicModel(t *testing.T) { + groupRepo := &groupRepoStubForAdmin{ + getByID: &Group{ID: 7, Platform: PlatformComposite}, + } + routeRepo := &compositeRouteRepoStubForAdmin{nextID: 99} + svc := &adminServiceImpl{groupRepo: groupRepo, compositeRouteRepo: routeRepo} + + route, err := svc.CreateCompositeRoute(context.Background(), 7, CompositeRouteInput{ + PublicModel: "openrouter/gpt-5", + MatchType: CompositeRouteMatchExact, + TargetPlatform: PlatformOpenAI, + Endpoint: CompositeRouteEndpointResponses, + Enabled: true, + }) + + require.NoError(t, err) + require.NotNil(t, route) + require.Equal(t, CompositeRouteMatchExact, route.MatchType) + require.Equal(t, "openrouter/gpt-5", route.UpstreamModel) +} + func TestAdminService_UpdateAndDeleteCompositeRouteRequireRouteOwnership(t *testing.T) { groupRepo := &groupRepoStubForAdmin{ getByID: &Group{ID: 7, Platform: PlatformComposite}, diff --git a/backend/internal/service/composite_model_route.go b/backend/internal/service/composite_model_route.go index 2bae5c62d..a008a3e9f 100644 --- a/backend/internal/service/composite_model_route.go +++ b/backend/internal/service/composite_model_route.go @@ -118,7 +118,12 @@ func normalizeCompositeRouteInput(input CompositeRouteInput) CompositeRouteInput input.TargetPlatform = strings.TrimSpace(input.TargetPlatform) input.UpstreamModel = strings.TrimSpace(input.UpstreamModel) input.Endpoint = normalizeCompositeRouteEndpoint(input.Endpoint) - if input.UpstreamModel == "" { + // 仅对 exact 路由把空 upstream_model 回填成 public_model:exact 命中时请求模型 + // 恒等于 public_model,回填只影响持久化/后台展示,保留原有契约不变。 + // prefix 路由留空则不回填——Resolve 会回退到具体请求模型,从而透传原始模型 + // (否则 public=deepseek-v4 的前缀路由会把 deepseek-v4-flash / deepseek-v4-pro + // 都塌缩成固定的 deepseek-v4)。显式填写 upstream_model 时任何模式都原样固定转发。 + if input.UpstreamModel == "" && input.MatchType == CompositeRouteMatchExact { input.UpstreamModel = input.PublicModel } input.Notes = strings.TrimSpace(input.Notes) diff --git a/backend/internal/service/composite_route_resolver_test.go b/backend/internal/service/composite_route_resolver_test.go index ed6f6f085..35028934d 100644 --- a/backend/internal/service/composite_route_resolver_test.go +++ b/backend/internal/service/composite_route_resolver_test.go @@ -107,6 +107,63 @@ func TestCompositeRouteResolverPrefersEndpointSpecificLongestPrefix(t *testing.T require.Equal(t, int64(2), decision.Route.ID) } +// TestCompositeRouteResolverPrefixEmptyUpstreamPassesThroughRequestedModel 验证: +// 前缀匹配路由留空 upstream_model 时,转发的是具体请求模型(各自原样),而不是 +// 塌缩成 public_model。这是「留空 = 透传原始模型」语义的核心场景。 +func TestCompositeRouteResolverPrefixEmptyUpstreamPassesThroughRequestedModel(t *testing.T) { + resolver := NewCompositeRouteResolver(compositeRouteRepoStub{ + routes: []CompositeModelRoute{ + { + ID: 1, + GroupID: 7, + PublicModel: "deepseek-v4", + MatchType: CompositeRouteMatchPrefix, + TargetPlatform: PlatformOpenAI, + UpstreamModel: "", // 留空 = 透传 + Endpoint: CompositeRouteEndpointAny, + Priority: 100, + Enabled: true, + }, + }, + }) + + for _, model := range []string{"deepseek-v4-flash", "deepseek-v4-pro", "deepseek-v4"} { + decision, err := resolver.Resolve(context.Background(), 7, model, CompositeRouteEndpointChatCompletions) + require.NoError(t, err) + require.True(t, decision.Matched, "model %q should match prefix route", model) + require.Equal(t, CompositeRouteSourceExplicit, decision.Source) + require.Equal(t, PlatformOpenAI, decision.TargetPlatform) + require.Equal(t, model, decision.UpstreamModel, "model %q should pass through verbatim", model) + } +} + +// TestCompositeRouteResolverPrefixExplicitUpstreamStillFixed 验证:前缀匹配路由显式 +// 填写 upstream_model 时,所有命中请求仍转发同一个固定上游模型(行为不变)。 +func TestCompositeRouteResolverPrefixExplicitUpstreamStillFixed(t *testing.T) { + resolver := NewCompositeRouteResolver(compositeRouteRepoStub{ + routes: []CompositeModelRoute{ + { + ID: 1, + GroupID: 7, + PublicModel: "deepseek-v4", + MatchType: CompositeRouteMatchPrefix, + TargetPlatform: PlatformOpenAI, + UpstreamModel: "deepseek-chat", + Endpoint: CompositeRouteEndpointAny, + Priority: 100, + Enabled: true, + }, + }, + }) + + for _, model := range []string{"deepseek-v4-flash", "deepseek-v4-pro"} { + decision, err := resolver.Resolve(context.Background(), 7, model, CompositeRouteEndpointChatCompletions) + require.NoError(t, err) + require.True(t, decision.Matched) + require.Equal(t, "deepseek-chat", decision.UpstreamModel) + } +} + func TestCompositeRouteResolverIgnoresDisabledRoutesAndFallsBackToDetector(t *testing.T) { resolver := NewCompositeRouteResolver(compositeRouteRepoStub{ routes: []CompositeModelRoute{ diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 7782f5558..6495c0c71 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -1031,6 +1031,7 @@ export default { endpoint: 'Endpoint', targetPlatform: 'Target Platform', upstreamModel: 'Upstream Model', + upstreamModelHint: 'Leave empty to pass the original requested model through: under prefix match each matched model forwards verbatim (e.g. deepseek-v4-flash and deepseek-v4-pro each forwarded as-is); set a value to forward every matched request to that fixed model.', notes: 'Notes', enabled: 'Enabled', preview: 'Preview', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index 39cbe8a26..c6125b7dd 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -1028,6 +1028,7 @@ export default { endpoint: '端点', targetPlatform: '目标平台', upstreamModel: '上游模型', + upstreamModelHint: '留空表示透传原始请求模型:前缀匹配下每个命中模型各自原样转发(如 deepseek-v4-flash、deepseek-v4-pro 分别转发);填写则所有命中请求都固定转发该模型。', notes: '备注', enabled: '启用', preview: '预览', diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 6ebe04285..c9996f9f9 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -3809,6 +3809,9 @@ class="input" placeholder="gpt-5" /> +

+ {{ t("admin.groups.compositeRoutes.upstreamModelHint") }} +

From 0b5903d4589410fa2256ffc57a518d03e7510285 Mon Sep 17 00:00:00 2001 From: visa2 <1927401+visa2@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:19:05 +0800 Subject: [PATCH 04/26] fix(settings): keep fields a settings PUT never sent at their stored value PUT /api/v1/admin/settings is a whole-document write. The admin UI always sends the complete document, so saving from the settings page is unaffected both before and after this change. The bug is only reachable when an API client calls the endpoint directly and sends just the fields it wants to change, which is the natural assumption for a PUT on a settings resource. Value-typed fields of UpdateSettingsRequest bind to their zero value when the payload omits them, and buildSystemSettingsUpdates writes every key unconditionally, so such a caller has no way to say "leave this one alone". Omitting a field and explicitly clearing it are indistinguishable on the wire. A caller that sends only the field it wants to change, e.g. {"risk_control_enabled": true} sets that flag and clears every other unguarded field in the same request. Measured against a fully configured store, one such call empties site_name, site_subtitle, api_base_url, contact_info and doc_url, and turns registration_enabled, email_verify_enabled, invitation_code_enabled and turnstile_enabled off. turnstile_enabled alone gates the captcha on login, register, forgot-password and both verify-code endpoints, and email_verify_enabled is a precondition of IsPasswordResetEnabled. The damage is easy to miss. site_name has a built-in fallback, so getStringOrDefault renders the cleared value as the default product name and the login page visibly changes, while the toggles just go quiet. Reopening the settings page reads the already-cleared state back into the form, so correcting the one visible field and saving persists the rest of the damage. Fields that grew their own guard already survive this: the SMTP block falls back to the previous values when smtp_host arrives empty, secret fields are written only when non-empty, and 132 request fields are pointers whose handler merges an omitted field with the stored value. This generalizes that pattern rather than adding a fourth ad-hoc guard. The handler now decodes the payload a second time as a raw field map, resolves the setting key each absent field would have written, and hands that set to the service, which drops those keys before SetMultiple, so the stored value is never touched. Fields the payload does carry are written as before, giving the caller the partial-update semantics it was already assuming. The mapping is reflected off the request's json tags so new fields are covered without maintaining a list; smtp_from_email is the only field whose json name differs from its setting key and is aliased explicitly. Only value-typed fields are filtered. Pointer fields keep whole-document behaviour on purpose: forwarded_client_ip_headers and api_key_acl_trust_forwarded_ip depend on being rewritten on every save to re-normalize fail-closed state, which the malformed forwarded-client-IP header test pins down. An explicitly sent empty value is still a deliberate clear; only absent fields are preserved. A partial write refreshes the in-process caches from storage instead of from the request struct, which holds zero values for whatever the caller omitted. Co-Authored-By: Claude Opus 5 --- .../setting_handler_partial_payload_test.go | 67 +++++++++++++++++++ .../handler/admin/setting_handler_update.go | 66 +++++++++++++++++- backend/internal/service/setting_update.go | 63 ++++++++++++++--- 3 files changed, 185 insertions(+), 11 deletions(-) create mode 100644 backend/internal/handler/admin/setting_handler_partial_payload_test.go diff --git a/backend/internal/handler/admin/setting_handler_partial_payload_test.go b/backend/internal/handler/admin/setting_handler_partial_payload_test.go new file mode 100644 index 000000000..e39e664a3 --- /dev/null +++ b/backend/internal/handler/admin/setting_handler_partial_payload_test.go @@ -0,0 +1,67 @@ +//go:build unit + +package admin + +import ( + "net/http" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/stretchr/testify/require" +) + +// Saving settings is a whole-document PUT. A client that sends only the field it +// cares about must not reset everything else: a payload as small as +// `{"risk_control_enabled":true}` used to clear site_name, after which +// getStringOrDefault rendered the empty value as the built-in default and the +// login page silently changed name. + +func TestUpdateSettingsPartialPayloadKeepsUnsentKeys(t *testing.T) { + h, repo := newStepUpSwitchTestHandler(t, map[string]string{ + service.SettingKeySiteName: "Example Gateway", + service.SettingKeySiteSubtitle: "Example Gateway Platform", + service.SettingKeySMTPHost: "smtp.example.com", + service.SettingKeySMTPFrom: "noreply@example.com", + service.SettingKeyTurnstileEnabled: "true", + }) + + rec := doUpdateSettings(t, h, map[string]any{"risk_control_enabled": true}, nil) + require.Equal(t, http.StatusOK, rec.Code) + + require.Equal(t, "true", repo.values[service.SettingKeyRiskControlEnabled], + "the field the caller actually sent must be written") + + require.Equal(t, "Example Gateway", repo.values[service.SettingKeySiteName]) + require.Equal(t, "Example Gateway Platform", repo.values[service.SettingKeySiteSubtitle]) + require.Equal(t, "smtp.example.com", repo.values[service.SettingKeySMTPHost]) + require.Equal(t, "noreply@example.com", repo.values[service.SettingKeySMTPFrom]) + require.Equal(t, "true", repo.values[service.SettingKeyTurnstileEnabled]) +} + +// A full payload keeps whole-document semantics: fields explicitly set to their +// zero value are still cleared. +func TestUpdateSettingsFullPayloadStillClearsSentEmptyFields(t *testing.T) { + h, repo := newStepUpSwitchTestHandler(t, map[string]string{ + service.SettingKeySiteName: "Example Gateway", + }) + + rec := doUpdateSettings(t, h, map[string]any{"site_name": ""}, nil) + require.Equal(t, http.StatusOK, rec.Code) + + require.Equal(t, "", repo.values[service.SettingKeySiteName], + "an explicitly sent empty value is a deliberate clear, not an omission") +} + +// smtp_from_email is the one request field whose JSON name differs from its +// setting key; the alias keeps it from being treated as always-omitted. +func TestUpdateSettingsSMTPFromAliasIsWritable(t *testing.T) { + h, repo := newStepUpSwitchTestHandler(t, map[string]string{ + service.SettingKeySMTPFrom: "old@example.com", + }) + + rec := doUpdateSettings(t, h, map[string]any{"smtp_from_email": "new@example.com"}, nil) + require.Equal(t, http.StatusOK, rec.Code) + + require.Equal(t, "new@example.com", repo.values[service.SettingKeySMTPFrom]) +} diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index f852bb7e6..ec910b525 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -6,6 +6,7 @@ import ( "errors" "log/slog" "net/http" + "reflect" "strings" "github.com/Wei-Shaw/sub2api/internal/config" @@ -15,6 +16,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" ) // UpdateSettingsRequest 更新设置请求 @@ -375,12 +377,70 @@ func (h *SettingHandler) ensureActorTotpForStepUp(c *gin.Context) bool { return true } +// settingKeyJSONAliases covers the request fields whose JSON name differs from +// the setting key they persist to. Every other field of UpdateSettingsRequest +// is named after its setting key. +var settingKeyJSONAliases = map[string]string{ + "smtp_from_email": service.SettingKeySMTPFrom, +} + +// settingKeyByJSONName maps the value-typed top-level JSON fields of +// UpdateSettingsRequest to the setting key each one writes. Resolved once from +// the struct tags so new fields are covered without touching this file. +// +// Pointer-typed fields are deliberately excluded: they already carry their own +// "omitted = keep the stored value" merge in UpdateSettings, and some of them +// rely on being rewritten on every save to re-normalize fail-closed security +// state (see TestUpdateSettingsMalformedForwardedClientIPHeadersRemainFailClosedWhenOmitted). +// Only the value-typed fields are indistinguishable from a deliberate clear. +var settingKeyByJSONName = buildSettingKeyByJSONName() + +func buildSettingKeyByJSONName() map[string]string { + t := reflect.TypeOf(UpdateSettingsRequest{}) + out := make(map[string]string, t.NumField()) + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if field.Type.Kind() == reflect.Ptr { + continue + } + name, _, _ := strings.Cut(field.Tag.Get("json"), ",") + if name == "" || name == "-" { + continue + } + if alias, ok := settingKeyJSONAliases[name]; ok { + out[name] = alias + continue + } + out[name] = name + } + return out +} + +// omittedSettingKeys reports the setting keys this payload never mentioned. +// Saving settings is a whole-document PUT, so without this a client that sends +// only the one field it cares about resets every other field to a zero value. +func omittedSettingKeys(sentFields map[string]json.RawMessage) service.OmittedSettingKeys { + omitted := make(service.OmittedSettingKeys, len(settingKeyByJSONName)) + for jsonName, settingKey := range settingKeyByJSONName { + if _, sent := sentFields[jsonName]; !sent { + omitted[settingKey] = struct{}{} + } + } + return omitted +} + func (h *SettingHandler) UpdateSettings(c *gin.Context) { - var req UpdateSettingsRequest - if err := c.ShouldBindJSON(&req); err != nil { + var sentFields map[string]json.RawMessage + if err := c.ShouldBindBodyWith(&sentFields, binding.JSON); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } + var req UpdateSettingsRequest + if err := c.ShouldBindBodyWith(&req, binding.JSON); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + omitted := omittedSettingKeys(sentFields) previousSettings, err := h.settingService.GetAllSettings(c.Request.Context()) if err != nil { @@ -1692,7 +1752,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { }, ForceEmailOnThirdPartySignup: boolValueOrDefault(req.ForceEmailOnThirdPartySignup, previousAuthSourceDefaults.ForceEmailOnThirdPartySignup), } - if err := h.settingService.UpdateSettingsWithAuthSourceDefaults(c.Request.Context(), settings, authSourceDefaults); err != nil { + if err := h.settingService.UpdateSettingsWithAuthSourceDefaultsOmitting(c.Request.Context(), settings, authSourceDefaults, omitted); err != nil { response.ErrorFrom(c, err) return } diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index 7449a3de6..2364b8948 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "math" "strconv" "strings" @@ -15,22 +16,50 @@ import ( infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" ) +// OmittedSettingKeys marks setting keys the caller's payload never carried. +// SystemSettings is a plain struct, so a field the caller omitted arrives as a +// zero value and is indistinguishable from a deliberate clear. Listing the key +// here drops it from the write, leaving the stored value in place. +// +// A nil or empty set keeps whole-document semantics: every key is written. +type OmittedSettingKeys map[string]struct{} + +func (o OmittedSettingKeys) dropFrom(updates map[string]string) { + for key := range o { + delete(updates, key) + } +} + // UpdateSettings 更新系统设置 func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSettings) error { + return s.UpdateSettingsOmitting(ctx, settings, nil) +} + +// UpdateSettingsOmitting persists system settings, leaving the keys in omitted +// at their stored value. +func (s *SettingService) UpdateSettingsOmitting(ctx context.Context, settings *SystemSettings, omitted OmittedSettingKeys) error { updates, err := s.buildSystemSettingsUpdates(ctx, settings) if err != nil { return err } + omitted.dropFrom(updates) - err = s.settingRepo.SetMultiple(ctx, updates) - if err == nil { - s.refreshCachedSettings(settings) + if err := s.settingRepo.SetMultiple(ctx, updates); err != nil { + return err } - return err + s.refreshCachedSettingsAfterWrite(ctx, settings, omitted) + return nil } // UpdateSettingsWithAuthSourceDefaults persists system settings and auth-source defaults in a single write. func (s *SettingService) UpdateSettingsWithAuthSourceDefaults(ctx context.Context, settings *SystemSettings, authDefaults *AuthSourceDefaultSettings) error { + return s.UpdateSettingsWithAuthSourceDefaultsOmitting(ctx, settings, authDefaults, nil) +} + +// UpdateSettingsWithAuthSourceDefaultsOmitting persists system settings and +// auth-source defaults in a single write, leaving the keys in omitted at their +// stored value. +func (s *SettingService) UpdateSettingsWithAuthSourceDefaultsOmitting(ctx context.Context, settings *SystemSettings, authDefaults *AuthSourceDefaultSettings, omitted OmittedSettingKeys) error { updates, err := s.buildSystemSettingsUpdates(ctx, settings) if err != nil { return err @@ -43,12 +72,30 @@ func (s *SettingService) UpdateSettingsWithAuthSourceDefaults(ctx context.Contex for key, value := range authSourceUpdates { updates[key] = value } + omitted.dropFrom(updates) - err = s.settingRepo.SetMultiple(ctx, updates) - if err == nil { - s.refreshCachedSettings(settings) + if err := s.settingRepo.SetMultiple(ctx, updates); err != nil { + return err } - return err + s.refreshCachedSettingsAfterWrite(ctx, settings, omitted) + return nil +} + +// refreshCachedSettingsAfterWrite keeps the in-process caches in step with the +// write that just landed. A partial payload carries zero values for the fields +// it omitted, so in that case the caches are rebuilt from storage rather than +// from the request struct. +func (s *SettingService) refreshCachedSettingsAfterWrite(ctx context.Context, settings *SystemSettings, omitted OmittedSettingKeys) { + if len(omitted) == 0 { + s.refreshCachedSettings(settings) + return + } + stored, err := s.GetAllSettings(ctx) + if err != nil { + slog.Warn("refresh cached settings after partial update failed", "error", err) + return + } + s.refreshCachedSettings(stored) } func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, settings *SystemSettings) (map[string]string, error) { From 291a7374223a17ca70fbeb6436ea3c851f9e0dee Mon Sep 17 00:00:00 2001 From: Nick <126941599+coo1white@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:41:44 +0700 Subject: [PATCH 05/26] test: stop four concurrency tests from failing on a busy machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four pass on a quiet box and fail on a loaded one, each for its own reason. None of them is testing the clock, so none of them should be failing on it. - ollama_cloud_usage_test.go: the second caller was released with `close(release)` right after its goroutine was started, not after it had reached the singleflight group. When the first refresh won that race, the second became a new singleflight execution, re-read the account, saw the LastAttemptAt the first one had just written, and came back with the 30-second manual-refresh 429 at line 685. It now counts account loads and waits for the second caller's own load, which happens right before it joins the group. Adds a counting GetByID to the test repo. - gateway_hotpath_optimization_test.go: a 20ms sleep was meant to let all 12 callers reach the cache before the loader was released. A caller that arrived after the load had finished got a hit, not a miss, so the miss count came out 11 of 12. It now waits on the miss counter itself, which is the value the test asserts on. - token_refresh_pool_health_test.go: the floor was `configuredSpacing` minus 10ms, i.e. 40ms out of 50ms. Each start timestamp is taken after the rate gate releases the goroutine, so scheduler delay can compress one observed gap with the gate behaving correctly — seen at 37ms and again at 13ms. The floor is now a tenth of the configured spacing. Measured with providerQPS=20, 8 attempts, concurrency 2: gate at 50ms gives a minimum gap of 49.97ms, gate at 0 gives 22µs. So an unpaced gate sits three orders of magnitude under the 5ms floor and is still caught, while jitter has room to move. A comment warns against replacing this with an assertion on the total span of the starts: the span is set by how long each attempt takes under the concurrency limit, not by the gate — 471ms paced against 241ms unpaced — so a span check passes with the gate disabled. - prompt_guard_test.go: the bound only has to show the failover shared the first endpoint's 70ms deadline and did not take the second endpoint's own 500ms one. An unshared deadline lands near 535ms, so 350ms still fails loudly (seen: 224ms against a 180ms bound). Tests only; no product code is touched. Each fix was checked in both directions: it passes with the behaviour intact, and it still fails when the behaviour is broken on purpose (for the QPS one, by swapping the shared rate gate for a zero-interval one). --- .../securityaudit/prompt_guard_test.go | 7 +++++- .../gateway_hotpath_optimization_test.go | 10 +++++++- .../service/ollama_cloud_usage_test.go | 20 ++++++++++++++++ .../service/token_refresh_pool_health_test.go | 24 ++++++++++++++++++- 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/backend/internal/securityaudit/prompt_guard_test.go b/backend/internal/securityaudit/prompt_guard_test.go index 76e9dea05..2346857ea 100644 --- a/backend/internal/securityaudit/prompt_guard_test.go +++ b/backend/internal/securityaudit/prompt_guard_test.go @@ -222,7 +222,12 @@ func TestGuardEvaluatorFlagSharedDeadlineFailClosedAndContextCancel(t *testing.T elapsed := time.Since(started) require.Error(t, err) require.Equal(t, 2, calls) - require.Less(t, elapsed, 180*time.Millisecond) + // The bound only has to prove the failover shared the first endpoint's + // 70ms deadline instead of taking the second endpoint's own 500ms one. + // An unshared deadline lands at ~535ms, so 350ms still fails loudly + // while leaving room for scheduler delay on a busy CI machine. A + // tighter bound made this test flaky, not stricter. + require.Less(t, elapsed, 350*time.Millisecond) require.GreaterOrEqual(t, elapsed, 50*time.Millisecond) require.Equal(t, int64(1), metrics.Snapshot().Failovers) require.Equal(t, int64(1), metrics.Snapshot().Timeouts) diff --git a/backend/internal/service/gateway_hotpath_optimization_test.go b/backend/internal/service/gateway_hotpath_optimization_test.go index e5bf49b8b..13fc32165 100644 --- a/backend/internal/service/gateway_hotpath_optimization_test.go +++ b/backend/internal/service/gateway_hotpath_optimization_test.go @@ -219,7 +219,15 @@ func TestGetUserGroupRateMultiplier_UsesCacheAndSingleflight(t *testing.T) { } close(start) - time.Sleep(20 * time.Millisecond) + // Wait for every caller to have recorded its cache miss before releasing the + // loader. A fixed sleep raced here: a goroutine that reached the cache after + // the singleflight load had already finished got a hit instead of a miss, and + // the miss assertion below saw 11 of 12. The miss counter is the observable + // that says "all callers are now inside the singleflight group". + require.Eventually(t, func() bool { + _, miss, _, _, _ := GatewayUserGroupRateCacheStats() + return miss == int64(concurrent) + }, 5*time.Second, time.Millisecond, "all callers must miss the cache before the loader is released") close(unblock) wg.Wait() diff --git a/backend/internal/service/ollama_cloud_usage_test.go b/backend/internal/service/ollama_cloud_usage_test.go index d315d7eb4..4b4a5aadb 100644 --- a/backend/internal/service/ollama_cloud_usage_test.go +++ b/backend/internal/service/ollama_cloud_usage_test.go @@ -36,6 +36,14 @@ type ollamaUsageTestRepo struct { disableAutoAttempts atomic.Int64 disableAutoCalls atomic.Int64 groupResolveCalls atomic.Int64 + getByIDCalls atomic.Int64 +} + +// GetByID counts loads so a test can wait for a caller to reach the point just +// before the singleflight group, instead of guessing with a sleep. +func (r *ollamaUsageTestRepo) GetByID(ctx context.Context, id int64) (*Account, error) { + r.getByIDCalls.Add(1) + return r.upstreamBillingProbeAccountRepo.GetByID(ctx, id) } func (r *ollamaUsageTestRepo) ListOllamaCloudUsageGroupAccounts(_ context.Context, anchors []*Account) ([]Account, error) { @@ -805,7 +813,19 @@ func TestOllamaCloudUsageRefreshSingleflightAndRunnerDeduplicateSharedGroup(t *t errs := make(chan error, 2) go func() { _, err := svc.Refresh(context.Background(), first.ID); errs <- err }() <-started + // The first caller is now parked in the stub, having loaded the account twice + // (once to build the group key, once inside the singleflight function). + loadsBeforeSecond := repo.getByIDCalls.Load() go func() { _, err := svc.Refresh(context.Background(), second.ID); errs <- err }() + // Only release the first caller once the second one has loaded its own + // account, which happens immediately before it joins the singleflight group. + // Releasing right after starting the goroutine raced: if the first refresh + // finished first, the second became a fresh singleflight execution, re-read + // the account, saw the LastAttemptAt just written, and failed with the 30s + // manual-refresh 429 instead of sharing the in-flight result. + require.Eventually(t, func() bool { + return repo.getByIDCalls.Load() > loadsBeforeSecond + }, 5*time.Second, time.Millisecond, "the second caller must reach the singleflight group before the first is released") close(release) require.NoError(t, <-errs) require.NoError(t, <-errs) diff --git a/backend/internal/service/token_refresh_pool_health_test.go b/backend/internal/service/token_refresh_pool_health_test.go index f4950d879..a9bd760d9 100644 --- a/backend/internal/service/token_refresh_pool_health_test.go +++ b/backend/internal/service/token_refresh_pool_health_test.go @@ -630,7 +630,29 @@ func TestTokenRefreshService_SaturatedProviderPreservesConcurrencyAndActualQPSSt starts := refresher.startsSnapshot() require.Len(t, starts, attemptCount) configuredSpacing := time.Second / time.Duration(providerQPS) - minimumObservedSpacing := configuredSpacing - 10*time.Millisecond + + // The floor is a tenth of the configured spacing, not spacing minus a few + // milliseconds. Each start timestamp is taken after the rate gate releases + // the goroutine, so scheduler delay can compress one observed gap without + // the gate having done anything wrong: this assertion failed on a loaded + // machine at 13ms and again at 37ms against a 40ms floor, both times with + // the gate pacing correctly. + // + // A tenth still separates the two cases by a wide margin. Measured with + // providerQPS=20 (50ms spacing), 8 attempts, providerConcurrency=2: + // + // gate at 50ms -> minimum observed gap 49.97ms + // gate at 0 -> minimum observed gap 22µs + // + // So an unpaced gate lands three orders of magnitude below the 5ms floor + // and is still caught, while jitter on a busy machine has room to move. + // + // Do not swap this for an assertion on the total span of the starts: the + // span is dominated by how long each attempt takes under + // providerConcurrency, not by the gate. Same measurement — 471ms paced + // against 241ms unpaced — so a span check passes with the gate disabled and + // tests nothing. + minimumObservedSpacing := configuredSpacing / 10 actualMinimumSpacing := starts[1].Sub(starts[0]) for i := 1; i < len(starts); i++ { spacing := starts[i].Sub(starts[i-1]) From 1850e009559cdc4fc5431b5c96dc6537e46aa3a2 Mon Sep 17 00:00:00 2001 From: Wey Gu Date: Sun, 26 Jul 2026 00:50:44 +0800 Subject: [PATCH 06/26] fix(admin): filter usage logs by request id --- backend/internal/handler/admin/usage_handler.go | 2 ++ .../admin/usage_handler_request_type_test.go | 12 ++++++++++++ .../internal/pkg/usagestats/usage_log_types.go | 1 + .../internal/repository/usage_log_repo_query.go | 4 ++++ .../usage_log_repo_request_type_test.go | 17 +++++++++++++++++ 5 files changed, 36 insertions(+) diff --git a/backend/internal/handler/admin/usage_handler.go b/backend/internal/handler/admin/usage_handler.go index a7e0156d1..ad2bbecd2 100644 --- a/backend/internal/handler/admin/usage_handler.go +++ b/backend/internal/handler/admin/usage_handler.go @@ -110,6 +110,7 @@ func (h *UsageHandler) List(c *gin.Context) { } model := c.Query("model") + requestID := strings.TrimSpace(c.Query("request_id")) billingMode := strings.TrimSpace(c.Query("billing_mode")) var requestType *int16 @@ -176,6 +177,7 @@ func (h *UsageHandler) List(c *gin.Context) { APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID, + RequestID: requestID, Model: model, ModelFilterSource: usagestats.ModelSourceRequested, RequestType: requestType, diff --git a/backend/internal/handler/admin/usage_handler_request_type_test.go b/backend/internal/handler/admin/usage_handler_request_type_test.go index bb4d43b4b..7ea657749 100644 --- a/backend/internal/handler/admin/usage_handler_request_type_test.go +++ b/backend/internal/handler/admin/usage_handler_request_type_test.go @@ -107,6 +107,18 @@ func TestAdminUsageListExactTotalTrue(t *testing.T) { require.True(t, repo.listFilters.ExactTotal) } +func TestAdminUsageListRequestIDFilter(t *testing.T) { + repo := &adminUsageRepoCapture{} + router := newAdminUsageRequestTypeTestRouter(repo) + + req := httptest.NewRequest(http.MethodGet, "/admin/usage?request_id=req-0123", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "req-0123", repo.listFilters.RequestID) +} + func TestAdminUsageListInvalidExactTotal(t *testing.T) { repo := &adminUsageRepoCapture{} router := newAdminUsageRequestTypeTestRouter(repo) diff --git a/backend/internal/pkg/usagestats/usage_log_types.go b/backend/internal/pkg/usagestats/usage_log_types.go index 29af4d2d5..da9e748b1 100644 --- a/backend/internal/pkg/usagestats/usage_log_types.go +++ b/backend/internal/pkg/usagestats/usage_log_types.go @@ -270,6 +270,7 @@ type UsageLogFilters struct { APIKeyID int64 AccountID int64 GroupID int64 + RequestID string Model string // ModelFilterSource controls how Model is matched. Empty preserves raw usage_logs.model semantics. ModelFilterSource string diff --git a/backend/internal/repository/usage_log_repo_query.go b/backend/internal/repository/usage_log_repo_query.go index 88b7eeaf3..084b79ad9 100644 --- a/backend/internal/repository/usage_log_repo_query.go +++ b/backend/internal/repository/usage_log_repo_query.go @@ -116,6 +116,10 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat conditions = append(conditions, fmt.Sprintf("group_id = $%d", len(args)+1)) args = append(args, filters.GroupID) } + if requestID := strings.TrimSpace(filters.RequestID); requestID != "" { + conditions = append(conditions, fmt.Sprintf("request_id = $%d", len(args)+1)) + args = append(args, requestID) + } conditions, args = appendUsageLogModelWhereCondition(conditions, args, filters.Model, filters.ModelFilterSource) conditions, args = appendRequestTypeOrStreamWhereCondition(conditions, args, filters.RequestType, filters.Stream) if filters.BillingType != nil { 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 aff472b0c..9c59b38e5 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -374,6 +374,23 @@ func TestUsageLogRepositoryListWithFiltersRequestTypePriority(t *testing.T) { require.NoError(t, mock.ExpectationsWereMet()) } +func TestUsageLogRepositoryListWithFiltersRequestID(t *testing.T) { + db, mock := newSQLMock(t) + repo := &usageLogRepository{sql: db} + + filters := usagestats.UsageLogFilters{RequestID: " req-0123 "} + + mock.ExpectQuery("SELECT .* FROM usage_logs WHERE request_id = \\$1 ORDER BY id DESC LIMIT \\$2 OFFSET \\$3"). + WithArgs("req-0123", 21, 0). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + + logs, page, err := repo.ListWithFilters(context.Background(), pagination.PaginationParams{Page: 1, PageSize: 20}, filters) + require.NoError(t, err) + require.Empty(t, logs) + require.NotNil(t, page) + require.NoError(t, mock.ExpectationsWereMet()) +} + func TestUsageLogRepositoryListWithFiltersRequestedModelSource(t *testing.T) { db, mock := newSQLMock(t) repo := &usageLogRepository{sql: db} From 6c7625800a78a37afdeb8b4afbd8e869ef84241c Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Sun, 26 Jul 2026 01:56:10 +0800 Subject: [PATCH 07/26] fix(billing): price Antigravity Gemini 3.6 Flash --- .../service/account_stats_pricing_test.go | 20 +++++ backend/internal/service/billing_service.go | 14 ++++ backend/internal/service/pricing_service.go | 34 ++++++-- .../internal/service/pricing_service_test.go | 77 +++++++++++++++++++ .../model_prices_and_context_window.json | 17 ++++ 5 files changed, 156 insertions(+), 6 deletions(-) diff --git a/backend/internal/service/account_stats_pricing_test.go b/backend/internal/service/account_stats_pricing_test.go index 57ace01c2..a934a5621 100644 --- a/backend/internal/service/account_stats_pricing_test.go +++ b/backend/internal/service/account_stats_pricing_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/stretchr/testify/require" ) @@ -698,6 +699,25 @@ func TestResolveAccountStatsCost_FallsBackToLiteLLM(t *testing.T) { require.InDelta(t, 0.2, *result, 1e-12) } +func TestResolveAccountStatsCost_Gemini36FlashTierUsesFallbackPricing(t *testing.T) { + channel := &Channel{ + ID: 1, + Status: StatusActive, + ApplyPricingToAccountStats: false, + } + cs := newTestChannelServiceForStats(t, channel, 10, "antigravity") + bs := NewBillingService(&config.Config{}, nil) + + result := resolveAccountStatsCost( + context.Background(), + cs, bs, + 1, 10, "gemini-3.6-flash-low", + UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000}, 1, 0, + ) + require.NotNil(t, result) + require.InDelta(t, 9.15, *result, 1e-12) +} + func TestResolveAccountStatsCost_AllMiss_ReturnsNil(t *testing.T) { channel := &Channel{ ID: 1, diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 98eb9c3ec..748eaf009 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -272,6 +272,17 @@ func (s *BillingService) initFallbackPricing() { SupportsCacheBreakdown: false, } + // Gemini 3.6 Flash (Google AI pricing: $1.50 input / $7.50 output / + // $0.15 cached input per MTok). Antigravity's -high/-low/-medium/-tiered + // aliases are matched below so unavailable remote pricing never records + // token-bearing requests at $0. + s.fallbackPrices["gemini-3.6-flash"] = &ModelPricing{ + InputPricePerToken: 1.5e-6, + OutputPricePerToken: 7.5e-6, + CacheReadPricePerToken: 0.15e-6, + SupportsCacheBreakdown: false, + } + // OpenAI GPT-5.4(业务指定价格) s.fallbackPrices["gpt-5.4"] = &ModelPricing{ InputPricePerToken: 2.5e-6, // $2.5 per MTok @@ -630,6 +641,9 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { if strings.Contains(modelLower, "gemini-3.1-pro") || strings.Contains(modelLower, "gemini-3-1-pro") { return s.fallbackPrices["gemini-3.1-pro"] } + if strings.Contains(modelLower, "gemini-3.6-flash") || strings.Contains(modelLower, "gemini-3-6-flash") { + return s.fallbackPrices["gemini-3.6-flash"] + } // DeepSeek V4 系列:仅匹配已知 V4 Pro/Flash 与官方兼容别名 // (deepseek-chat / deepseek-reasoner → V4 Flash),未知 deepseek-* 型号不回退,避免误计价。 diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index 636bd690c..c969ec18d 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -695,16 +695,24 @@ func (s *PricingService) GetModelPricing(modelName string) *LiteLLMModelPricing } func (s *PricingService) buildModelLookupCandidates(modelLower string) []string { - // Prefer canonical model name first (this also improves billing compatibility with "models/xxx"). - candidates := []string{ - normalizeModelNameForPricing(modelLower), + rawCandidates := []string{ modelLower, - } - candidates = append(candidates, strings.TrimPrefix(modelLower, "models/"), lastSegment(modelLower), lastSegment(strings.TrimPrefix(modelLower, "models/")), - ) + } + normalized := normalizeModelNameForPricing(modelLower) + + // A tier-specific entry should take precedence when the pricing catalog gains + // one later. Today Antigravity's Gemini 3.6 Flash tiers share the base rate, + // so the normalized base remains the fallback after the exact aliases. + candidates := rawCandidates + if normalizeGeminiThinkingTierAlias(lastSegment(modelLower)) != lastSegment(modelLower) { + candidates = append(candidates, normalized) + } else { + // Prefer canonical model names for all other aliases (including models/xxx). + candidates = append([]string{normalized}, candidates...) + } seen := make(map[string]struct{}, len(candidates)) out := make([]string, 0, len(candidates)) @@ -752,6 +760,20 @@ func normalizeModelNameForPricing(model string) string { } return canonical } + return normalizeGeminiThinkingTierAlias(model) +} + +// normalizeGeminiThinkingTierAlias maps Antigravity's Gemini 3.6 Flash +// thinking-tier model IDs to the public base model. The tier controls reasoning +// behavior, not the published token rate, so this keeps -high/-low/-medium and +// -tiered requests on the same price card as gemini-3.6-flash. +func normalizeGeminiThinkingTierAlias(model string) string { + const baseModel = "gemini-3.6-flash" + for _, tier := range []string{"-high", "-low", "-medium", "-tiered"} { + if model == baseModel+tier { + return baseModel + } + } return model } diff --git a/backend/internal/service/pricing_service_test.go b/backend/internal/service/pricing_service_test.go index 88553f0fb..48e5159ad 100644 --- a/backend/internal/service/pricing_service_test.go +++ b/backend/internal/service/pricing_service_test.go @@ -454,6 +454,83 @@ func TestGetModelPricing_OpenAICompactAliasUsesStaticFallback(t *testing.T) { require.InDelta(t, 1.5e-5, got.OutputCostPerToken, 1e-12) } +func TestPricingService_Gemini36FlashThinkingTiersUseBasePricing(t *testing.T) { + basePricing := &LiteLLMModelPricing{ + InputCostPerToken: 1.5e-6, + OutputCostPerToken: 7.5e-6, + CacheReadInputTokenCost: 0.15e-6, + } + svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ + "gemini-3.6-flash": basePricing, + }} + + for _, model := range []string{ + "gemini-3.6-flash", + "gemini-3.6-flash-high", + "gemini-3.6-flash-low", + "gemini-3.6-flash-medium", + "gemini-3.6-flash-tiered", + } { + t.Run(model, func(t *testing.T) { + require.Same(t, basePricing, svc.GetModelPricing(model)) + }) + } +} + +func TestPricingService_Gemini36FlashTierSpecificPricingTakesPrecedence(t *testing.T) { + basePricing := &LiteLLMModelPricing{InputCostPerToken: 1.5e-6} + tierPricing := &LiteLLMModelPricing{InputCostPerToken: 2e-6} + svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ + "gemini-3.6-flash": basePricing, + "gemini-3.6-flash-low": tierPricing, + }} + + require.Same(t, tierPricing, svc.GetModelPricing("models/gemini-3.6-flash-low")) +} + +func TestBillingService_Gemini36FlashThinkingTierFallbacksAreBillable(t *testing.T) { + svc := NewBillingService(&config.Config{}, nil) + tokens := UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000} + + for _, model := range []string{ + "gemini-3.6-flash", + "gemini-3.6-flash-high", + "gemini-3.6-flash-low", + "gemini-3.6-flash-medium", + "gemini-3.6-flash-tiered", + } { + t.Run(model, func(t *testing.T) { + cost, err := svc.CalculateCost(model, tokens, 1) + require.NoError(t, err) + require.InDelta(t, 1.5, cost.InputCost, 1e-12) + require.InDelta(t, 7.5, cost.OutputCost, 1e-12) + require.InDelta(t, 0.15, cost.CacheReadCost, 1e-12) + require.InDelta(t, 9.15, cost.TotalCost, 1e-12) + }) + } +} + +func TestDefaultPricingIncludesGemini36FlashRates(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "resources", "model-pricing", "model_prices_and_context_window.json")) + require.NoError(t, err) + + pricingSvc := &PricingService{} + pricingData, err := pricingSvc.parsePricingData(data) + require.NoError(t, err) + pricingSvc.pricingData = pricingData + billingSvc := NewBillingService(&config.Config{}, pricingSvc) + + for _, model := range []string{"gemini-3.6-flash", "gemini-3.6-flash-low", "gemini-3.6-flash-high"} { + t.Run(model, func(t *testing.T) { + pricing, err := billingSvc.GetModelPricing(model) + require.NoError(t, err) + require.InDelta(t, 1.5e-6, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 7.5e-6, pricing.OutputPricePerToken, 1e-12) + require.InDelta(t, 0.15e-6, pricing.CacheReadPricePerToken, 1e-12) + }) + } +} + func TestDefaultPricingIncludesCodexAutoReview(t *testing.T) { data, err := os.ReadFile(filepath.Join("..", "..", "resources", "model-pricing", "model_prices_and_context_window.json")) require.NoError(t, err) diff --git a/backend/resources/model-pricing/model_prices_and_context_window.json b/backend/resources/model-pricing/model_prices_and_context_window.json index 8c3e95aab..21180fb56 100644 --- a/backend/resources/model-pricing/model_prices_and_context_window.json +++ b/backend/resources/model-pricing/model_prices_and_context_window.json @@ -2339,6 +2339,23 @@ "supports_web_search": true, "web_search_billing_unit": "per_query" }, + "gemini-3.6-flash": { + "cache_read_input_token_cost": 1.5e-07, + "cache_read_input_token_cost_priority": 2.7e-07, + "input_cost_per_token": 1.5e-06, + "input_cost_per_token_priority": 2.7e-06, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 7.5e-06, + "output_cost_per_token_priority": 1.35e-05, + "source": "https://ai.google.dev/gemini-api/docs/latest-model", + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_service_tier": true + }, "gemini-embedding-001": { "input_cost_per_token": 1.5e-07, "litellm_provider": "vertex_ai-embedding-models", From d11b8387025cd56f23f3b31d17af855039795f3f Mon Sep 17 00:00:00 2001 From: wucm667 Date: Sun, 26 Jul 2026 02:25:54 +0800 Subject: [PATCH 08/26] fix: show routed user in usage filters --- .../components/admin/usage/UsageFilters.vue | 4 +- frontend/src/views/admin/UsageView.vue | 21 +++ .../views/admin/__tests__/UsageView.spec.ts | 132 +++++++++++++++++- 3 files changed, 153 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/admin/usage/UsageFilters.vue b/frontend/src/components/admin/usage/UsageFilters.vue index 6a5fad626..0965567ce 100644 --- a/frontend/src/components/admin/usage/UsageFilters.vue +++ b/frontend/src/components/admin/usage/UsageFilters.vue @@ -523,5 +523,7 @@ const setUserKeyword = (email: string) => { showUserDropdown.value = false } -defineExpose({ setUserKeyword }) +const getUserSearchRevision = () => userSearchSequence + +defineExpose({ getUserSearchRevision, setUserKeyword }) diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue index 8930cf635..2d64c92f0 100644 --- a/frontend/src/views/admin/UsageView.vue +++ b/frontend/src/views/admin/UsageView.vue @@ -335,6 +335,26 @@ const applyRouteQueryFilters = () => { granularity.value = getGranularityForRange(startDate.value, endDate.value) } +const loadRouteUserFilterLabel = async () => { + const requestedUserId = filters.value.user_id + if (!requestedUserId) return + const userSearchRevision = usageFiltersRef.value?.getUserSearchRevision?.() + + const routeUserFilterIsCurrent = () => ( + filters.value.user_id === requestedUserId + && usageFiltersRef.value?.getUserSearchRevision?.() === userSearchRevision + ) + + try { + const user = await adminAPI.users.getById(requestedUserId, true) + if (!routeUserFilterIsCurrent()) return + usageFiltersRef.value?.setUserKeyword?.(user.email || String(requestedUserId)) + } catch { + if (!routeUserFilterIsCurrent()) return + usageFiltersRef.value?.setUserKeyword?.(String(requestedUserId)) + } +} + const onDateRangeChange = (range: { startDate: string; endDate: string; preset: string | null }) => { startDate.value = range.startDate endDate.value = range.endDate @@ -822,6 +842,7 @@ const handleColumnClickOutside = (event: MouseEvent) => { onMounted(() => { applyRouteQueryFilters() + void loadRouteUserFilterLabel() loadLogs() loadStats() loadModelStats(modelDistributionSource.value, true) diff --git a/frontend/src/views/admin/__tests__/UsageView.spec.ts b/frontend/src/views/admin/__tests__/UsageView.spec.ts index 6afeebe93..e0fc24b12 100644 --- a/frontend/src/views/admin/__tests__/UsageView.spec.ts +++ b/frontend/src/views/admin/__tests__/UsageView.spec.ts @@ -1,9 +1,10 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' +import { defineComponent, ref } from 'vue' import UsageView from '../UsageView.vue' -const { list, getStats, getSnapshotV2, getById, getModelStats, listErrorLogs } = vi.hoisted(() => { +const { list, getStats, getSnapshotV2, getById, getModelStats, listErrorLogs, routeQuery } = vi.hoisted(() => { vi.stubGlobal('localStorage', { getItem: vi.fn(() => null), setItem: vi.fn(), @@ -17,6 +18,7 @@ const { list, getStats, getSnapshotV2, getById, getModelStats, listErrorLogs } = getById: vi.fn(), getModelStats: vi.fn(), listErrorLogs: vi.fn(), + routeQuery: {} as Record, } }) @@ -85,12 +87,28 @@ vi.mock('vue-i18n', async () => { vi.mock('vue-router', () => ({ useRoute: () => ({ - query: {} + query: routeQuery }) })) const AppLayoutStub = { template: '
' } -const UsageFiltersStub = { template: '
' } +const UsageFiltersStub = defineComponent({ + setup(_, { expose }) { + const userKeyword = ref('') + let userSearchRevision = 0 + const setUserKeyword = (email: string) => { + userSearchRevision += 1 + userKeyword.value = email + } + expose({ + getUserSearchRevision: () => userSearchRevision, + setUserKeyword, + simulateUserInput: setUserKeyword, + }) + return { userKeyword } + }, + template: '
{{ userKeyword }}
', +}) const UsageTableStub = { emits: ['userClick'], template: '
', @@ -120,6 +138,114 @@ const GroupDistributionChartStub = { `, } +const mountRouteFilteredUsageView = () => mount(UsageView, { + global: { stubs: { + AppLayout: AppLayoutStub, UsageStatsCards: true, UsageFilters: UsageFiltersStub, + UsageTable: true, UsageExportProgress: true, UsageCleanupDialog: true, + UserBalanceHistoryModal: true, Pagination: true, Select: true, + DateRangePicker: true, Icon: true, TokenUsageTrend: true, + ModelDistributionChart: true, GroupDistributionChart: true, + EndpointDistributionChart: true, UserTokenRanking: true, + } }, +}) + +describe('admin UsageView route filters', () => { + beforeEach(() => { + vi.useFakeTimers() + Object.keys(routeQuery).forEach((key) => delete routeQuery[key]) + list.mockReset().mockResolvedValue({ items: [], total: 0, pages: 0 }) + getStats.mockReset().mockResolvedValue({ + total_requests: 0, + total_input_tokens: 0, + total_output_tokens: 0, + total_cache_tokens: 0, + total_tokens: 0, + total_cost: 0, + total_actual_cost: 0, + average_duration_ms: 0, + }) + getSnapshotV2.mockReset().mockResolvedValue({ trend: [], models: [], groups: [] }) + getModelStats.mockReset().mockResolvedValue({ models: [] }) + getById.mockReset() + }) + + afterEach(() => { + Object.keys(routeQuery).forEach((key) => delete routeQuery[key]) + vi.useRealTimers() + }) + + it('shows the routed user while applying user_id to usage requests', async () => { + routeQuery.user_id = '42' + getById.mockResolvedValue({ id: 42, email: 'route-user@test.com' }) + + const wrapper = mountRouteFilteredUsageView() + await flushPromises() + + expect(getById).toHaveBeenCalledWith(42, true) + expect(list).toHaveBeenCalledWith(expect.objectContaining({ user_id: 42 }), expect.anything()) + expect(wrapper.find('[data-test="user-filter-label"]').text()).toBe('route-user@test.com') + }) + + it('does not apply a stale routed user label after user_id changes', async () => { + routeQuery.user_id = '42' + let resolveLookup!: (user: { id: number; email: string }) => void + getById.mockReturnValue(new Promise((resolve) => { resolveLookup = resolve })) + + const wrapper = mountRouteFilteredUsageView() + await wrapper.vm.$nextTick() + ;(wrapper.vm as any).filters.user_id = 84 + ;(wrapper.findComponent(UsageFiltersStub).vm as any).setUserKeyword('current-user@test.com') + + resolveLookup({ id: 42, email: 'stale-user@test.com' }) + await flushPromises() + + expect(wrapper.find('[data-test="user-filter-label"]').text()).toBe('current-user@test.com') + }) + + it('does not overwrite newer user input when the routed user lookup succeeds', async () => { + routeQuery.user_id = '42' + let resolveLookup!: (user: { id: number; email: string }) => void + getById.mockReturnValue(new Promise((resolve) => { resolveLookup = resolve })) + + const wrapper = mountRouteFilteredUsageView() + await wrapper.vm.$nextTick() + ;(wrapper.findComponent(UsageFiltersStub).vm as any).simulateUserInput('new-search@test.com') + + resolveLookup({ id: 42, email: 'route-user@test.com' }) + await flushPromises() + + expect((wrapper.vm as any).filters.user_id).toBe(42) + expect(wrapper.find('[data-test="user-filter-label"]').text()).toBe('new-search@test.com') + }) + + it('does not overwrite newer user input when the routed user lookup fails', async () => { + routeQuery.user_id = '42' + let rejectLookup!: (error: Error) => void + getById.mockReturnValue(new Promise((_, reject) => { rejectLookup = reject })) + + const wrapper = mountRouteFilteredUsageView() + await wrapper.vm.$nextTick() + ;(wrapper.findComponent(UsageFiltersStub).vm as any).simulateUserInput('new-search@test.com') + + rejectLookup(new Error('lookup failed')) + await flushPromises() + + expect((wrapper.vm as any).filters.user_id).toBe(42) + expect(wrapper.find('[data-test="user-filter-label"]').text()).toBe('new-search@test.com') + }) + + it('shows the routed user ID when its label lookup fails', async () => { + routeQuery.user_id = '42' + getById.mockRejectedValue(new Error('lookup failed')) + + const wrapper = mountRouteFilteredUsageView() + await flushPromises() + + expect(list).toHaveBeenCalledWith(expect.objectContaining({ user_id: 42 }), expect.anything()) + expect(wrapper.find('[data-test="user-filter-label"]').text()).toBe('42') + }) +}) + describe('admin UsageView distribution metric toggles', () => { beforeEach(() => { vi.useFakeTimers() From 0875143d987007f2c5d1d90defccfd85508c7ae4 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Sun, 26 Jul 2026 02:47:35 +0800 Subject: [PATCH 09/26] fix: show optional affiliate code on registration --- frontend/src/views/auth/RegisterView.vue | 25 +++- .../views/auth/__tests__/RegisterView.spec.ts | 112 ++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 frontend/src/views/auth/__tests__/RegisterView.spec.ts diff --git a/frontend/src/views/auth/RegisterView.vue b/frontend/src/views/auth/RegisterView.vue index b4a7a6009..4b8977914 100644 --- a/frontend/src/views/auth/RegisterView.vue +++ b/frontend/src/views/auth/RegisterView.vue @@ -134,6 +134,27 @@
+ +
+ +
+
+ +
+ +
+
+
-
+
(true) const emailVerifyEnabled = ref(false) const promoCodeEnabled = ref(true) const invitationCodeEnabled = ref(false) +const affiliateEnabled = ref(false) const turnstileEnabled = ref(false) const turnstileSiteKey = ref('') const siteName = ref('Sub2API') @@ -459,6 +481,7 @@ onMounted(async () => { emailVerifyEnabled.value = settings.email_verify_enabled promoCodeEnabled.value = settings.promo_code_enabled invitationCodeEnabled.value = settings.invitation_code_enabled + affiliateEnabled.value = settings.affiliate_enabled turnstileEnabled.value = settings.turnstile_enabled turnstileSiteKey.value = settings.turnstile_site_key || '' siteName.value = settings.site_name || 'Sub2API' diff --git a/frontend/src/views/auth/__tests__/RegisterView.spec.ts b/frontend/src/views/auth/__tests__/RegisterView.spec.ts new file mode 100644 index 000000000..20f63018a --- /dev/null +++ b/frontend/src/views/auth/__tests__/RegisterView.spec.ts @@ -0,0 +1,112 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import RegisterView from '@/views/auth/RegisterView.vue' + +const { getPublicSettingsMock } = vi.hoisted(() => ({ + getPublicSettingsMock: vi.fn() +})) + +const publicSettings = { + registration_enabled: true, + email_verify_enabled: false, + promo_code_enabled: false, + invitation_code_enabled: false, + affiliate_enabled: true, + turnstile_enabled: true, + turnstile_site_key: 'site-key', + site_name: 'Sub2API', + registration_email_suffix_whitelist: [], + linuxdo_oauth_enabled: false, + wechat_oauth_enabled: false, + oidc_oauth_enabled: false, + github_oauth_enabled: false, + google_oauth_enabled: false +} + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: vi.fn() }), + useRoute: () => ({ query: {} }) +})) + +vi.mock('vue-i18n', () => ({ + createI18n: () => ({ + global: { + t: (key: string) => key + } + }), + useI18n: () => ({ + t: (key: string) => key, + locale: { value: 'en' } + }) +})) + +vi.mock('@/stores', () => ({ + useAuthStore: () => ({ register: vi.fn() }), + useAppStore: () => ({ + showError: vi.fn(), + showSuccess: vi.fn(), + showWarning: vi.fn() + }) +})) + +vi.mock('@/api/auth', async () => { + const actual = await vi.importActual('@/api/auth') + return { + ...actual, + getPublicSettings: (...args: unknown[]) => getPublicSettingsMock(...args) + } +}) + +function mountRegister() { + return mount(RegisterView, { + global: { + stubs: { + AuthLayout: { template: '
' }, + Icon: true, + TurnstileWidget: { template: '
' }, + LoginAgreementPrompt: true, + EmailOAuthButtons: true, + LinuxDoOAuthSection: true, + WechatOAuthSection: true, + OidcOAuthSection: true, + RouterLink: true, + transition: false + } + } + }) +} + +describe('RegisterView invitation layout', () => { + beforeEach(() => { + getPublicSettingsMock.mockReset() + getPublicSettingsMock.mockResolvedValue(publicSettings) + }) + + it('keeps the optional affiliate invitation field before Turnstile', async () => { + const wrapper = mountRegister() + await flushPromises() + + const invitationField = wrapper.get('[data-testid="affiliate-invitation-field"]') + const turnstile = wrapper.get('[data-testid="registration-turnstile"]') + + expect(invitationField.get('input').attributes('id')).toBe('affiliate_code') + expect(invitationField.text()).toContain('common.optional') + expect( + invitationField.element.compareDocumentPosition(turnstile.element) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + }) + + it('uses the mandatory invitation field without duplicating the affiliate field', async () => { + getPublicSettingsMock.mockResolvedValueOnce({ + ...publicSettings, + invitation_code_enabled: true + }) + + const wrapper = mountRegister() + await flushPromises() + + expect(wrapper.find('[data-testid="affiliate-invitation-field"]').exists()).toBe(false) + expect(wrapper.get('#invitation_code').exists()).toBe(true) + }) +}) From 6d99e668d218c5f269b9a45ec437823a6ef58588 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Sun, 26 Jul 2026 03:18:30 +0800 Subject: [PATCH 10/26] fix(payment): group dashboard stats by currency --- backend/internal/service/payment_service.go | 34 ++++--- backend/internal/service/payment_stats.go | 93 ++++++++++++------- .../internal/service/payment_stats_test.go | 84 +++++++++++++++++ .../admin/payment/DailyRevenueChart.vue | 34 ++++--- .../admin/payment/OrderStatsCards.vue | 22 +++-- .../admin/payment/PaymentMethodChart.vue | 44 ++++++--- .../admin/payment/TopUsersLeaderboard.vue | 56 +++++++---- frontend/src/types/payment.ts | 32 +++++-- .../orders/AdminPaymentDashboardView.vue | 37 ++++++-- 9 files changed, 324 insertions(+), 112 deletions(-) create mode 100644 backend/internal/service/payment_stats_test.go diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go index 3518fbe2e..792a842a2 100644 --- a/backend/internal/service/payment_service.go +++ b/backend/internal/service/payment_service.go @@ -144,28 +144,32 @@ type RefundResult struct { } type DashboardStats struct { - TodayAmount float64 `json:"today_amount"` - TotalAmount float64 `json:"total_amount"` - TodayCount int `json:"today_count"` - TotalCount int `json:"total_count"` - AvgAmount float64 `json:"avg_amount"` - PendingOrders int `json:"pending_orders"` + TodayAmount CurrencyAmounts `json:"today_amount"` + TotalAmount CurrencyAmounts `json:"total_amount"` + TodayCount int `json:"today_count"` + TotalCount int `json:"total_count"` + AvgAmount CurrencyAmounts `json:"avg_amount"` + PendingOrders int `json:"pending_orders"` DailySeries []DailyStats `json:"daily_series"` PaymentMethods []PaymentMethodStat `json:"payment_methods"` - TopUsers []TopUserStat `json:"top_users"` + TopUsers TopUsersByCurrency `json:"top_users"` } +// CurrencyAmounts holds payment amounts keyed by their ISO 4217 currency. +// Amounts in different currencies must never be added together. +type CurrencyAmounts map[string]float64 + type DailyStats struct { - Date string `json:"date"` - Amount float64 `json:"amount"` - Count int `json:"count"` + Date string `json:"date"` + Amount CurrencyAmounts `json:"amount"` + Count int `json:"count"` } type PaymentMethodStat struct { - Type string `json:"type"` - Amount float64 `json:"amount"` - Count int `json:"count"` + Type string `json:"type"` + Amount CurrencyAmounts `json:"amount"` + Count int `json:"count"` } type TopUserStat struct { @@ -174,6 +178,10 @@ type TopUserStat struct { Amount float64 `json:"amount"` } +// TopUsersByCurrency contains an independent ranked user list for each +// currency. A single cross-currency leaderboard would be misleading. +type TopUsersByCurrency map[string][]TopUserStat + // --- Service --- type PaymentService struct { diff --git a/backend/internal/service/payment_stats.go b/backend/internal/service/payment_stats.go index d206b2717..9dc48e012 100644 --- a/backend/internal/service/payment_stats.go +++ b/backend/internal/service/payment_stats.go @@ -54,22 +54,27 @@ func (s *PaymentService) GetDashboardStats(ctx context.Context, days int) (*Dash } func computeBasicStats(st *DashboardStats, orders []*dbent.PaymentOrder, todayStart time.Time) { - var totalAmount, todayAmount float64 + st.TotalAmount = make(CurrencyAmounts) + st.TodayAmount = make(CurrencyAmounts) + st.AvgAmount = make(CurrencyAmounts) + currencyCounts := make(map[string]int) var todayCount int for _, o := range orders { - totalAmount += o.PayAmount + currency := PaymentOrderCurrency(o) + st.TotalAmount[currency] += o.PayAmount + currencyCounts[currency]++ if o.PaidAt != nil && !o.PaidAt.Before(todayStart) { - todayAmount += o.PayAmount + st.TodayAmount[currency] += o.PayAmount todayCount++ } } - st.TotalAmount = math.Round(totalAmount*100) / 100 - st.TodayAmount = math.Round(todayAmount*100) / 100 st.TotalCount = len(orders) st.TodayCount = todayCount - if st.TotalCount > 0 { - st.AvgAmount = math.Round(totalAmount/float64(st.TotalCount)*100) / 100 + for currency, totalAmount := range st.TotalAmount { + st.AvgAmount[currency] = roundAmount(totalAmount / float64(currencyCounts[currency])) } + roundCurrencyAmounts(st.TotalAmount) + roundCurrencyAmounts(st.TodayAmount) } func buildDailySeries(orders []*dbent.PaymentOrder, since time.Time, days int) []DailyStats { @@ -81,20 +86,20 @@ func buildDailySeries(orders []*dbent.PaymentOrder, since time.Time, days int) [ date := o.PaidAt.Format("2006-01-02") ds, ok := dailyMap[date] if !ok { - ds = &DailyStats{Date: date} + ds = &DailyStats{Date: date, Amount: make(CurrencyAmounts)} dailyMap[date] = ds } - ds.Amount += o.PayAmount + ds.Amount[PaymentOrderCurrency(o)] += o.PayAmount ds.Count++ } series := make([]DailyStats, 0, days) for i := 0; i < days; i++ { date := since.AddDate(0, 0, i+1).Format("2006-01-02") if ds, ok := dailyMap[date]; ok { - ds.Amount = math.Round(ds.Amount*100) / 100 + roundCurrencyAmounts(ds.Amount) series = append(series, *ds) } else { - series = append(series, DailyStats{Date: date}) + series = append(series, DailyStats{Date: date, Amount: make(CurrencyAmounts)}) } } return series @@ -105,49 +110,71 @@ func buildMethodDistribution(orders []*dbent.PaymentOrder) []PaymentMethodStat { for _, o := range orders { ms, ok := methodMap[o.PaymentType] if !ok { - ms = &PaymentMethodStat{Type: o.PaymentType} + ms = &PaymentMethodStat{Type: o.PaymentType, Amount: make(CurrencyAmounts)} methodMap[o.PaymentType] = ms } - ms.Amount += o.PayAmount + ms.Amount[PaymentOrderCurrency(o)] += o.PayAmount ms.Count++ } methods := make([]PaymentMethodStat, 0, len(methodMap)) for _, ms := range methodMap { - ms.Amount = math.Round(ms.Amount*100) / 100 + roundCurrencyAmounts(ms.Amount) methods = append(methods, *ms) } + sort.Slice(methods, func(i, j int) bool { + return methods[i].Type < methods[j].Type + }) return methods } -func buildTopUsers(orders []*dbent.PaymentOrder) []TopUserStat { - userMap := make(map[int64]*TopUserStat) +func buildTopUsers(orders []*dbent.PaymentOrder) TopUsersByCurrency { + userMap := make(map[string]map[int64]*TopUserStat) for _, o := range orders { - us, ok := userMap[o.UserID] + currency := PaymentOrderCurrency(o) + users, ok := userMap[currency] + if !ok { + users = make(map[int64]*TopUserStat) + userMap[currency] = users + } + us, ok := users[o.UserID] if !ok { us = &TopUserStat{UserID: o.UserID, Email: o.UserEmail} - userMap[o.UserID] = us + users[o.UserID] = us } us.Amount += o.PayAmount } - userList := make([]*TopUserStat, 0, len(userMap)) - for _, us := range userMap { - us.Amount = math.Round(us.Amount*100) / 100 - userList = append(userList, us) - } - sort.Slice(userList, func(i, j int) bool { - return userList[i].Amount > userList[j].Amount - }) - limit := topUsersLimit - if len(userList) < limit { - limit = len(userList) - } - result := make([]TopUserStat, 0, limit) - for i := 0; i < limit; i++ { - result = append(result, *userList[i]) + result := make(TopUsersByCurrency, len(userMap)) + for currency, users := range userMap { + userList := make([]*TopUserStat, 0, len(users)) + for _, us := range users { + us.Amount = roundAmount(us.Amount) + userList = append(userList, us) + } + sort.Slice(userList, func(i, j int) bool { + return userList[i].Amount > userList[j].Amount + }) + limit := topUsersLimit + if len(userList) < limit { + limit = len(userList) + } + result[currency] = make([]TopUserStat, 0, limit) + for i := 0; i < limit; i++ { + result[currency] = append(result[currency], *userList[i]) + } } return result } +func roundCurrencyAmounts(amounts CurrencyAmounts) { + for currency, amount := range amounts { + amounts[currency] = roundAmount(amount) + } +} + +func roundAmount(amount float64) float64 { + return math.Round(amount*100) / 100 +} + // --- Audit Logs --- func (s *PaymentService) writeAuditLog(ctx context.Context, oid int64, action, op string, detail map[string]any) { diff --git a/backend/internal/service/payment_stats_test.go b/backend/internal/service/payment_stats_test.go new file mode 100644 index 000000000..267719288 --- /dev/null +++ b/backend/internal/service/payment_stats_test.go @@ -0,0 +1,84 @@ +//go:build unit + +package service + +import ( + "testing" + "time" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/stretchr/testify/require" +) + +func TestComputeBasicStatsGroupsAmountsByCurrency(t *testing.T) { + t.Parallel() + + todayStart := time.Date(2026, time.July, 25, 0, 0, 0, 0, time.UTC) + yesterday := todayStart.Add(-time.Hour) + today := todayStart.Add(time.Hour) + orders := []*dbent.PaymentOrder{ + paymentStatsTestOrder(1, "alice@example.com", "CNY", 10, &today), + paymentStatsTestOrder(2, "bob@example.com", "USD", 10, &today), + paymentStatsTestOrder(1, "alice@example.com", "CNY", 5, &yesterday), + } + + stats := &DashboardStats{} + computeBasicStats(stats, orders, todayStart) + + require.Equal(t, CurrencyAmounts{"CNY": 15, "USD": 10}, stats.TotalAmount) + require.Equal(t, CurrencyAmounts{"CNY": 10, "USD": 10}, stats.TodayAmount) + require.Equal(t, CurrencyAmounts{"CNY": 7.5, "USD": 10}, stats.AvgAmount) + require.Equal(t, 3, stats.TotalCount) + require.Equal(t, 2, stats.TodayCount) +} + +func TestPaymentDashboardBreakdownsGroupAmountsAndRankingsByCurrency(t *testing.T) { + t.Parallel() + + firstDay := time.Date(2026, time.July, 24, 12, 0, 0, 0, time.UTC) + secondDay := firstDay.AddDate(0, 0, 1) + orders := []*dbent.PaymentOrder{ + paymentStatsTestOrder(1, "alice@example.com", "CNY", 5.555, &firstDay), + paymentStatsTestOrder(2, "bob@example.com", "CNY", 10, &firstDay), + paymentStatsTestOrder(1, "alice@example.com", "USD", 20, &secondDay), + paymentStatsTestOrder(2, "bob@example.com", "USD", 10, &secondDay), + } + orders[0].PaymentType = "stripe" + orders[1].PaymentType = "stripe" + orders[2].PaymentType = "stripe" + orders[3].PaymentType = "alipay" + + daily := buildDailySeries(orders, firstDay.AddDate(0, 0, -1), 2) + require.Equal(t, []DailyStats{ + {Date: "2026-07-24", Amount: CurrencyAmounts{"CNY": 15.56}, Count: 2}, + {Date: "2026-07-25", Amount: CurrencyAmounts{"USD": 30}, Count: 2}, + }, daily) + + methods := buildMethodDistribution(orders) + require.Equal(t, []PaymentMethodStat{ + {Type: "alipay", Amount: CurrencyAmounts{"USD": 10}, Count: 1}, + {Type: "stripe", Amount: CurrencyAmounts{"CNY": 15.56, "USD": 20}, Count: 3}, + }, methods) + + users := buildTopUsers(orders) + require.Equal(t, TopUsersByCurrency{ + "CNY": { + {UserID: 2, Email: "bob@example.com", Amount: 10}, + {UserID: 1, Email: "alice@example.com", Amount: 5.56}, + }, + "USD": { + {UserID: 1, Email: "alice@example.com", Amount: 20}, + {UserID: 2, Email: "bob@example.com", Amount: 10}, + }, + }, users) +} + +func paymentStatsTestOrder(userID int64, email, currency string, amount float64, paidAt *time.Time) *dbent.PaymentOrder { + return &dbent.PaymentOrder{ + UserID: userID, + UserEmail: email, + PayAmount: amount, + PaidAt: paidAt, + ProviderSnapshot: map[string]any{"currency": currency}, + } +} diff --git a/frontend/src/components/admin/payment/DailyRevenueChart.vue b/frontend/src/components/admin/payment/DailyRevenueChart.vue index 77583da17..6e2544f99 100644 --- a/frontend/src/components/admin/payment/DailyRevenueChart.vue +++ b/frontend/src/components/admin/payment/DailyRevenueChart.vue @@ -33,31 +33,43 @@ import { } from 'chart.js' import { Line } from 'vue-chartjs' import LoadingSpinner from '@/components/common/LoadingSpinner.vue' +import type { DailyPaymentStats } from '@/types/payment' ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend, Filler) const { t } = useI18n() const props = defineProps<{ - data: { date: string; amount: number; count: number }[] + data: DailyPaymentStats[] loading?: boolean }>() +const colors = [ + ['rgb(59, 130, 246)', 'rgba(59, 130, 246, 0.1)'], + ['rgb(168, 85, 247)', 'rgba(168, 85, 247, 0.1)'], + ['rgb(245, 158, 11)', 'rgba(245, 158, 11, 0.1)'], + ['rgb(239, 68, 68)', 'rgba(239, 68, 68, 0.1)'], +] + const chartData = computed(() => { if (!props.data || props.data.length === 0) return null + const currencies = [...new Set(props.data.flatMap(day => Object.keys(day.amount)))].sort() return { labels: props.data.map(d => d.date), datasets: [ - { - label: t('payment.admin.revenue'), - data: props.data.map(d => d.amount), - borderColor: 'rgb(59, 130, 246)', - backgroundColor: 'rgba(59, 130, 246, 0.1)', - fill: true, - tension: 0.3, - pointRadius: 3, - pointHoverRadius: 5, - }, + ...currencies.map((currency, index) => { + const [borderColor, backgroundColor] = colors[index % colors.length] + return { + label: `${currency} ${t('payment.admin.revenue')}`, + data: props.data.map(day => day.amount[currency] || 0), + borderColor, + backgroundColor, + fill: true, + tension: 0.3, + pointRadius: 3, + pointHoverRadius: 5, + } + }), { label: t('payment.admin.orderCount'), data: props.data.map(d => d.count), diff --git a/frontend/src/components/admin/payment/OrderStatsCards.vue b/frontend/src/components/admin/payment/OrderStatsCards.vue index 0f7ec956d..1fece5fb5 100644 --- a/frontend/src/components/admin/payment/OrderStatsCards.vue +++ b/frontend/src/components/admin/payment/OrderStatsCards.vue @@ -8,7 +8,9 @@

{{ t('payment.admin.todayRevenue') }}

-

${{ formatMoney(stats.today_amount) }}

+

+ {{ formatMoney(currency, amount) }} +

{{ stats.today_count }} {{ t('payment.admin.orders') }}

@@ -24,7 +26,9 @@

{{ t('payment.admin.totalRevenue') }}

-

${{ formatMoney(stats.total_amount) }}

+

+ {{ formatMoney(currency, amount) }} +

{{ stats.total_count }} {{ t('payment.admin.orders') }}

@@ -53,7 +57,9 @@

{{ t('payment.admin.avgAmount') }}

-

${{ formatMoney(stats.avg_amount) }}

+

+ {{ formatMoney(currency, amount) }} +

@@ -63,7 +69,7 @@ diff --git a/frontend/src/components/admin/payment/PaymentMethodChart.vue b/frontend/src/components/admin/payment/PaymentMethodChart.vue index dd6909e95..00b3178f2 100644 --- a/frontend/src/components/admin/payment/PaymentMethodChart.vue +++ b/frontend/src/components/admin/payment/PaymentMethodChart.vue @@ -18,20 +18,23 @@ {{ t('payment.methods.' + method.type, method.type) }} -
- - ${{ method.amount.toFixed(2) }} +
+ + {{ formatMoney(currency, amount) }} ({{ method.count }})
-
-
+
+ {{ currency }} +
+
+
@@ -41,11 +44,12 @@ diff --git a/frontend/src/components/admin/payment/TopUsersLeaderboard.vue b/frontend/src/components/admin/payment/TopUsersLeaderboard.vue index 1c85fcf16..ca635735d 100644 --- a/frontend/src/components/admin/payment/TopUsersLeaderboard.vue +++ b/frontend/src/components/admin/payment/TopUsersLeaderboard.vue @@ -4,31 +4,34 @@ {{ t('payment.admin.topUsers') }}
{{ t('payment.admin.noData') }}
-
-
- - {{ idx + 1 }} +
+

{{ currency }}

+
+
+ + {{ idx + 1 }} + + {{ user.email }} +
+ + {{ formatMoney(currency, user.amount) }} - {{ user.email }}
- - ${{ user.amount.toFixed(2) }} -
@@ -36,11 +39,12 @@ diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts index 726e101ed..b94dbf42e 100644 --- a/frontend/src/types/payment.ts +++ b/frontend/src/types/payment.ts @@ -222,13 +222,33 @@ export interface CreateOrderResult { jsapi_payload?: WechatJSAPIPayload } +export type CurrencyAmounts = Record + +export interface DailyPaymentStats { + date: string + amount: CurrencyAmounts + count: number +} + +export interface PaymentMethodStats { + type: string + amount: CurrencyAmounts + count: number +} + +export interface TopUserPaymentStats { + user_id: number + email: string + amount: number +} + export interface DashboardStats { - today_amount: number - total_amount: number + today_amount: CurrencyAmounts + total_amount: CurrencyAmounts today_count: number total_count: number - avg_amount: number - daily_series: { date: string; amount: number; count: number }[] - payment_methods: { type: string; amount: number; count: number }[] - top_users: { user_id: number; email: string; amount: number }[] + avg_amount: CurrencyAmounts + daily_series: DailyPaymentStats[] + payment_methods: PaymentMethodStats[] + top_users: Record } diff --git a/frontend/src/views/admin/orders/AdminPaymentDashboardView.vue b/frontend/src/views/admin/orders/AdminPaymentDashboardView.vue index 5a80db44f..70fe95962 100644 --- a/frontend/src/views/admin/orders/AdminPaymentDashboardView.vue +++ b/frontend/src/views/admin/orders/AdminPaymentDashboardView.vue @@ -41,8 +41,8 @@ {{ t('payment.methods.' + method.type, method.type) }}
-
- ¥{{ method.amount.toFixed(2) }} +
+ {{ formatMoney(currency, amount) }} ({{ method.count }})
@@ -50,14 +50,17 @@

{{ t('payment.admin.topUsers') }}

-
{{ t('payment.admin.noData') }}
+
{{ t('payment.admin.noData') }}
-
-
- {{ idx + 1 }} - {{ user.email }} +
+

{{ currency }}

+
+
+ {{ idx + 1 }} + {{ user.email }} +
+ {{ formatMoney(currency, user.amount) }}
- ¥{{ user.amount.toFixed(2) }}
@@ -73,7 +76,7 @@ import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' import { adminPaymentAPI } from '@/api/admin/payment' import { extractI18nErrorMessage } from '@/utils/apiError' -import type { DashboardStats } from '@/types/payment' +import type { CurrencyAmounts, DashboardStats, TopUserPaymentStats } from '@/types/payment' import AppLayout from '@/components/layout/AppLayout.vue' import LoadingSpinner from '@/components/common/LoadingSpinner.vue' import Icon from '@/components/icons/Icon.vue' @@ -104,6 +107,22 @@ function rankClass(idx: number): string { return 'bg-gray-100 text-gray-500 dark:bg-dark-700 dark:text-gray-400' } +function sortedAmounts(amounts: CurrencyAmounts): [string, number][] { + return Object.entries(amounts).sort(([left], [right]) => left.localeCompare(right)) +} + +function sortedTopUsers(usersByCurrency: Record): [string, TopUserPaymentStats[]][] { + return Object.entries(usersByCurrency).sort(([left], [right]) => left.localeCompare(right)) +} + +function hasTopUsers(usersByCurrency: Record): boolean { + return Object.values(usersByCurrency).some(users => users.length > 0) +} + +function formatMoney(currency: string, amount: number): string { + return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(amount) +} + async function loadDashboard() { loading.value = true try { From ba5fa6a38f3e3918e9f0f47441ad25bdb56bd1dc Mon Sep 17 00:00:00 2001 From: Wey Gu Date: Sun, 26 Jul 2026 03:56:12 +0800 Subject: [PATCH 11/26] fix(deps): update image and telemetry packages --- backend/go.mod | 17 +++++++------- backend/go.sum | 64 ++++++++++++++++---------------------------------- 2 files changed, 29 insertions(+), 52 deletions(-) diff --git a/backend/go.mod b/backend/go.mod index d9d5007d0..49a5d8da1 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -20,7 +20,7 @@ require ( github.com/google/uuid v1.6.0 github.com/google/wire v0.7.0 github.com/gorilla/websocket v1.5.3 - github.com/imroc/req/v3 v3.57.0 + github.com/imroc/req/v3 v3.59.0 github.com/klauspost/compress v1.18.2 github.com/lib/pq v1.10.9 github.com/patrickmn/go-cache v2.1.0+incompatible @@ -43,7 +43,7 @@ require ( github.com/zeromicro/go-zero v1.9.4 go.uber.org/zap v1.24.0 golang.org/x/crypto v0.53.0 - golang.org/x/image v0.39.0 + golang.org/x/image v0.41.0 golang.org/x/mod v0.37.0 golang.org/x/net v0.56.0 golang.org/x/sync v0.21.0 @@ -141,7 +141,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/quic-go/qpack v0.6.0 // indirect - github.com/quic-go/quic-go v0.57.1 // indirect + github.com/quic-go/quic-go v0.60.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect @@ -166,12 +166,12 @@ require ( github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zclconf/go-cty v1.14.4 // indirect github.com/zclconf/go-cty-yaml v1.1.0 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect - go.opentelemetry.io/otel v1.37.0 // indirect - go.opentelemetry.io/otel/metric v1.37.0 // indirect - go.opentelemetry.io/otel/sdk v1.37.0 // indirect - go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/sdk v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect go.uber.org/atomic v1.10.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.9.0 // indirect @@ -179,6 +179,7 @@ require ( golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.39.0 // indirect + golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.47.0 // indirect google.golang.org/grpc v1.75.1 // indirect google.golang.org/protobuf v1.36.10 // indirect diff --git a/backend/go.sum b/backend/go.sum index bb33293b7..0218ed057 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -166,8 +166,6 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= -github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= @@ -185,8 +183,8 @@ github.com/hashicorp/hcl/v2 v2.18.1 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZY github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE= github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= -github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI= -github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00= +github.com/imroc/req/v3 v3.59.0 h1:PqKhJHyBmJYob47LVuTHwRZE00ZO6icbLHe5Zra13jo= +github.com/imroc/req/v3 v3.59.0/go.mod h1:cwLwwaE90Pq6ZMmKuHA9FivJbg3avnCH8/RA5lUUs74= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -222,8 +220,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -257,8 +253,6 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -278,22 +272,22 @@ github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10= -github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= +github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= +github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= @@ -322,8 +316,6 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= @@ -386,22 +378,22 @@ github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWB github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs= github.com/zeromicro/go-zero v1.9.4 h1:aRLFoISqAYijABtkbliQC5SsI5TbizJpQvoHc9xup8k= github.com/zeromicro/go-zero v1.9.4/go.mod h1:a17JOTch25SWxBcUgJZYps60hygK3pIYdw7nGwlcS38= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8= +go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= @@ -419,24 +411,16 @@ go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= -golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= -golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= +golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -448,22 +432,14 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From fd7e2039d36c7fd145554b925855d480a57ca8f6 Mon Sep 17 00:00:00 2001 From: feeeei Date: Fri, 24 Jul 2026 13:10:46 +0800 Subject: [PATCH 12/26] =?UTF-8?q?fix(gemini):=20=E5=AE=8C=E5=96=84gemini?= =?UTF-8?q?=E5=8F=B7=E6=B1=A0=E6=A8=A1=E5=BC=8F=E6=97=B6retryable=E5=A4=B1?= =?UTF-8?q?=E6=95=88=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前 Gemini 三条转发路径对池模式账号命中 ErrorPolicySkipped 时直接把 上游错误体透传给客户端(强制标 500),既不同账号重试也不换号, pool_mode_retry_count 配置完全不生效;而 Anthropic/OpenAI 等路径 会构造 UpstreamFailoverError 交给 handler 层按池模式配置重试后换号。 - 新增 poolModeSkippedFailoverError:池模式 + 可 failover 状态码时 返回 UpstreamFailoverError,RetryableOnSameAccount 按 pool_mode_retry_status_codes(默认 401/403/429)判定 - 原生 v1beta(ForwardNative)与 Claude messages 兼容路径的 Skipped 分支接入;非池模式的自定义错误码透传行为不变 - chat completions 兼容路径的 failover 错误补上 RetryableOnSameAccount 池模式判定 --- .../gemini_chat_completions_compat_service.go | 6 +- .../service/gemini_error_policy_test.go | 62 +++++++++++++++++++ .../service/gemini_messages_compat_service.go | 39 ++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/gemini_chat_completions_compat_service.go b/backend/internal/service/gemini_chat_completions_compat_service.go index 5f868a5d6..f23c18008 100644 --- a/backend/internal/service/gemini_chat_completions_compat_service.go +++ b/backend/internal/service/gemini_chat_completions_compat_service.go @@ -231,7 +231,11 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions( Kind: "failover", Message: upstreamMsg, }) - return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: evBody} + return nil, &UpstreamFailoverError{ + StatusCode: resp.StatusCode, + ResponseBody: evBody, + RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), + } } return nil, s.writeGeminiChatCompletionsMappedError(c, account, resp.StatusCode, requestID, evBody) diff --git a/backend/internal/service/gemini_error_policy_test.go b/backend/internal/service/gemini_error_policy_test.go index deae912c9..e71bd141b 100644 --- a/backend/internal/service/gemini_error_policy_test.go +++ b/backend/internal/service/gemini_error_policy_test.go @@ -352,6 +352,68 @@ func TestGeminiErrorPolicyIntegration(t *testing.T) { } } +// --------------------------------------------------------------------------- +// TestPoolModeSkippedFailoverError — pool-mode accounts hitting +// ErrorPolicySkipped must failover (align with other platform forwards) +// instead of passing the upstream error through to the client. +// --------------------------------------------------------------------------- + +func TestPoolModeSkippedFailoverError(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := &GeminiMessagesCompatService{} + + poolAccount := func(extra map[string]any) *Account { + creds := map[string]any{"pool_mode": true} + for k, v := range extra { + creds[k] = v + } + return &Account{ID: 300, Type: AccountTypeAPIKey, Platform: PlatformGemini, Credentials: creds} + } + + tests := []struct { + name string + account *Account + statusCode int + expectFailover bool + expectSameAccount bool + }{ + {"pool_500_failover_no_same_account_retry", poolAccount(nil), 500, true, false}, + {"pool_429_failover_with_same_account_retry", poolAccount(nil), 429, true, true}, + {"pool_custom_retry_codes_500", poolAccount(map[string]any{ + "pool_mode_retry_status_codes": []any{float64(500)}, + }), 500, true, true}, + {"pool_400_not_failover_worthy", poolAccount(nil), 400, false, false}, + {"non_pool_account_keeps_passthrough", &Account{ + ID: 301, Type: AccountTypeAPIKey, Platform: PlatformGemini, + Credentials: map[string]any{ + "custom_error_codes_enabled": true, + "custom_error_codes": []any{float64(429)}, + }, + }, 500, false, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + writer := httptest.NewRecorder() + c, _ := gin.CreateTestContext(writer) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + + body := []byte(`{"error":{"code":"bad_response_status_code","message":"openai_error"}}`) + failoverErr := svc.poolModeSkippedFailoverError(c, tt.account, tt.statusCode, body, "req-1") + + if !tt.expectFailover { + require.Nil(t, failoverErr) + return + } + require.NotNil(t, failoverErr) + require.Equal(t, tt.statusCode, failoverErr.StatusCode) + require.Equal(t, body, failoverErr.ResponseBody) + require.Equal(t, tt.expectSameAccount, failoverErr.RetryableOnSameAccount) + require.True(t, failoverErr.ShouldRetryNextAccount()) + }) + } +} + // --------------------------------------------------------------------------- // TestGeminiErrorPolicy_NilRateLimitService — verifies nil safety // --------------------------------------------------------------------------- diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 1a3821847..692c09cc9 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -945,6 +945,9 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex if upstreamReqID == "" { upstreamReqID = resp.Header.Get("x-goog-request-id") } + if failoverErr := s.poolModeSkippedFailoverError(c, account, resp.StatusCode, respBody, upstreamReqID); failoverErr != nil { + return nil, failoverErr + } return nil, s.writeGeminiMappedError(c, account, http.StatusInternalServerError, upstreamReqID, respBody) case ErrorPolicyMatched, ErrorPolicyTempUnscheduled: if policy == ErrorPolicyMatched { @@ -1452,6 +1455,9 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. policy := s.rateLimitService.CheckErrorPolicy(ctx, account, resp.StatusCode, respBody, mappedModel) switch policy { case ErrorPolicySkipped: + if failoverErr := s.poolModeSkippedFailoverError(c, account, resp.StatusCode, respBody, requestID); failoverErr != nil { + return nil, failoverErr + } respBody = unwrapIfNeeded(isOAuth, respBody) contentType := resp.Header.Get("Content-Type") if contentType == "" { @@ -1683,6 +1689,39 @@ func (s *GeminiMessagesCompatService) shouldFailoverGeminiUpstreamError(statusCo } } +// poolModeSkippedFailoverError 池模式账号命中 ErrorPolicySkipped 时构造 failover 错误: +// 可 failover 的状态码返回 UpstreamFailoverError,交给 handler 层按 pool_mode_retry_count +// 同账号重试后换号;返回 nil 表示不适用(非池模式或状态码不可 failover),由调用方透传。 +func (s *GeminiMessagesCompatService) poolModeSkippedFailoverError(c *gin.Context, account *Account, statusCode int, respBody []byte, upstreamRequestID string) *UpstreamFailoverError { + if !account.IsPoolMode() || !s.shouldFailoverGeminiUpstreamError(statusCode) { + return nil + } + upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) + upstreamDetail := "" + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes + if maxBytes <= 0 { + maxBytes = 2048 + } + upstreamDetail = truncateString(string(respBody), maxBytes) + } + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: statusCode, + UpstreamRequestID: upstreamRequestID, + Kind: "failover", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + return &UpstreamFailoverError{ + StatusCode: statusCode, + ResponseBody: respBody, + RetryableOnSameAccount: account.IsPoolModeRetryableStatus(statusCode), + } +} + func sleepGeminiBackoff(attempt int) { delay := geminiRetryBaseDelay * time.Duration(1< geminiRetryMaxDelay { From 2447c44f85ae709766e7a53362dc883a8af0b61e Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Sat, 25 Jul 2026 15:14:04 -0400 Subject: [PATCH 13/26] fix(repository): parse nanosecond next_probe_at in due probe scheduling Go persists upstream_billing_probe.next_probe_at via RFC3339Nano, but jsonpath datetime() parses at most 6 fractional digits, so every stored timestamp failed to parse and was treated as malformed: fail-open due, ordered into the invalid bucket by id ASC. With more enabled accounts than the per-cycle limit, the same lowest IDs monopolized every cycle and higher IDs were never probed again. Trim the fraction to microseconds before datetime(), mirroring ListDueOllamaCloudUsageAccounts, and pin the behavior with integration regressions: nanosecond parsing, due-time ordering beyond the limit, preserved fail-open for truly invalid dates. --- backend/internal/repository/account_repo.go | 12 +- ...ream_billing_probe_due_integration_test.go | 104 ++++++++++++++++++ ...nt_repo_upstream_billing_probe_due_test.go | 1 + 3 files changed, 116 insertions(+), 1 deletion(-) diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 6ab4446ba..212523a4f 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -3358,6 +3358,12 @@ func (r *accountRepository) FindByExtraField(ctx context.Context, key string, va // ListDueUpstreamBillingProbeAccounts bounds result hydration and network work // to limit. PostgreSQL must still filter and order all enabled candidates; // MATERIALIZED avoids repeating the defensive timestamp parse expression. +// Go writes next_probe_at via RFC3339Nano (up to 9 fractional digits) while +// jsonpath datetime() parses at most microseconds, so fractions beyond 6 +// digits are trimmed first — mirroring ListDueOllamaCloudUsageAccounts. +// Without this, every nanosecond timestamp is treated as malformed and the +// fail-open ordering pins the cycle to the lowest account IDs, starving the +// rest of the pool. func (r *accountRepository) ListDueUpstreamBillingProbeAccounts(ctx context.Context, now time.Time, limit int) ([]service.Account, error) { if limit <= 0 { return []service.Account{}, nil @@ -3387,7 +3393,11 @@ func (r *accountRepository) ListDueUpstreamBillingProbeAccounts(ctx context.Cont jsonb_path_query_first_tz( jsonb_build_object( 'value', - replace(regexp_replace(next_probe_at, 'Z$', '+00:00'), 'T', ' ') + replace(regexp_replace(regexp_replace( + next_probe_at, + '(\.[0-9]{6})[0-9]+(Z|[+-][0-9]{2}:[0-9]{2})$', + '\1\2' + ), 'Z$', '+00:00'), 'T', ' ') ), '$.value.datetime()', '{}'::jsonb, diff --git a/backend/internal/repository/account_repo_upstream_billing_probe_due_integration_test.go b/backend/internal/repository/account_repo_upstream_billing_probe_due_integration_test.go index 64d29553f..104771af0 100644 --- a/backend/internal/repository/account_repo_upstream_billing_probe_due_integration_test.go +++ b/backend/internal/repository/account_repo_upstream_billing_probe_due_integration_test.go @@ -49,3 +49,107 @@ func TestListDueUpstreamBillingProbeAccountsHandlesInvalidCalendarDate(t *testin require.Equal(t, invalidID, accounts[0].ID) require.Equal(t, dueID, accounts[1].ID) } + +func insertUpstreamBillingProbeAccount(ctx context.Context, t *testing.T, tx sqlQueryer, name, nextProbeAt string) int64 { + t.Helper() + var id int64 + extra := fmt.Sprintf(`{ + "upstream_billing_probe_enabled": true, + "upstream_billing_probe": {"status": "ok", "next_probe_at": %q} + }`, nextProbeAt) + err := scanSingleRow(ctx, tx, ` + INSERT INTO accounts (name, platform, type, status, extra) + VALUES ($1, 'openai', $2, 'active', $3::jsonb) + RETURNING id + `, []any{name, service.AccountTypeAPIKey, extra}, &id) + require.NoError(t, err) + return id +} + +// rfc3339WithFraction renders t in UTC with an explicit fractional-second +// suffix, e.g. "2026-07-25T17:29:00.123456789Z". The probes' Go writer uses +// RFC3339Nano, so stored fractions carry up to 9 digits. +func rfc3339WithFraction(t time.Time, fraction string) string { + return fmt.Sprintf("%s.%sZ", t.UTC().Format("2006-01-02T15:04:05"), fraction) +} + +// Regression for the scheduled-probe starvation bug: Go persists next_probe_at +// via RFC3339Nano (7-9 fractional digits), which jsonpath datetime() cannot +// parse. Before the trim fix every such row was treated as malformed and +// fail-open "due", so the cycle always returned the lowest account IDs and +// higher IDs were never probed. +func TestListDueUpstreamBillingProbeAccountsParsesNanosecondTimestamps(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil) + now := time.Date(2026, time.July, 25, 17, 30, 0, 0, time.UTC) + _, err := tx.ExecContext(ctx, ` + UPDATE accounts + SET extra = extra - 'upstream_billing_probe_enabled' - 'upstream_billing_probe' + `) + require.NoError(t, err) + + // 22 low-ID accounts that are NOT due yet, stored with 9 fractional digits + // exactly as the legacy writer produced them (no migration). + notDue := now.Add(time.Hour) + for i := 0; i < 22; i++ { + insertUpstreamBillingProbeAccount(ctx, t, tx, + fmt.Sprintf("probe-nano-not-due-%02d", i), + rfc3339WithFraction(notDue.Add(time.Duration(i)*time.Second), "123456789")) + } + // 3 high-ID accounts that ARE due, with 9/8/7 fractional digits. Their + // parsed order must follow due time, not insertion/ID order. + dueThird := insertUpstreamBillingProbeAccount(ctx, t, tx, + "probe-nano-due-9digits", rfc3339WithFraction(now.Add(-time.Minute), "123456789")) + dueFirst := insertUpstreamBillingProbeAccount(ctx, t, tx, + "probe-nano-due-8digits", rfc3339WithFraction(now.Add(-3*time.Minute), "12345678")) + dueSecond := insertUpstreamBillingProbeAccount(ctx, t, tx, + "probe-nano-due-7digits", rfc3339WithFraction(now.Add(-2*time.Minute), "1234567")) + + accounts, err := repo.ListDueUpstreamBillingProbeAccounts(ctx, now, 20) + require.NoError(t, err) + require.Len(t, accounts, 3) + require.Equal(t, dueFirst, accounts[0].ID) + require.Equal(t, dueSecond, accounts[1].ID) + require.Equal(t, dueThird, accounts[2].ID) +} + +// With more due accounts than the cycle limit, selection must follow the +// parsed due time so accounts beyond the first IDs still get their +// turn; before the fix the same lowest IDs monopolized every cycle. +func TestListDueUpstreamBillingProbeAccountsSelectsEarliestDueAcrossIDs(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil) + now := time.Date(2026, time.July, 25, 17, 30, 0, 0, time.UTC) + _, err := tx.ExecContext(ctx, ` + UPDATE accounts + SET extra = extra - 'upstream_billing_probe_enabled' - 'upstream_billing_probe' + `) + require.NoError(t, err) + + // 25 due accounts; the LOWEST IDs carry the LATEST due times, so a + // correct query must pick the 20 highest-ID rows here. + ids := make([]int64, 0, 25) + for i := 0; i < 25; i++ { + due := now.Add(-time.Duration(i+1) * time.Minute) + ids = append(ids, insertUpstreamBillingProbeAccount(ctx, t, tx, + fmt.Sprintf("probe-nano-order-%02d", i), + rfc3339WithFraction(due, "123456789"))) + } + + accounts, err := repo.ListDueUpstreamBillingProbeAccounts(ctx, now, 20) + require.NoError(t, err) + require.Len(t, accounts, 20) + got := make([]int64, 0, len(accounts)) + for _, account := range accounts { + got = append(got, account.ID) + } + // Earliest due first == highest index first; the five most recently due + // (lowest index / lowest IDs) fall outside the limit this cycle. + want := make([]int64, 0, 20) + for i := 24; i >= 5; i-- { + want = append(want, ids[i]) + } + require.Equal(t, want, got) +} diff --git a/backend/internal/repository/account_repo_upstream_billing_probe_due_test.go b/backend/internal/repository/account_repo_upstream_billing_probe_due_test.go index 350b95d4a..aa1fcb430 100644 --- a/backend/internal/repository/account_repo_upstream_billing_probe_due_test.go +++ b/backend/internal/repository/account_repo_upstream_billing_probe_due_test.go @@ -32,6 +32,7 @@ func TestAccountRepositoryListDueUpstreamBillingProbeAccountsBoundsQuery(t *test require.Contains(t, normalized, "type = 'apikey'") require.Contains(t, normalized, `extra @> '{"upstream_billing_probe_enabled": true}'::jsonb`) require.Contains(t, normalized, "jsonb_path_query_first_tz") + require.Contains(t, normalized, `'(\.[0-9]{6})[0-9]+(Z|[+-][0-9]{2}:[0-9]{2})$'`) require.Contains(t, normalized, "parsed AS MATERIALIZED") require.Contains(t, normalized, "parsed_next_probe_at::timestamptz <= $1") require.Contains(t, normalized, "LIMIT $2") From a36222748d3885fb46d3f915a428d638915ab196 Mon Sep 17 00:00:00 2001 From: Edison42 Date: Thu, 23 Jul 2026 18:06:50 +0800 Subject: [PATCH 14/26] fix(openai): strip foreign reasoning on account failover When a /responses forwarding loop has attempted an OpenAI passthrough account, sanitize every subsequent non-passthrough attempt by deriving its body from the immutable canonical request and dropping provider-specific encrypted reasoning input items in full. This prevents Bedrock-compatible accounts from rejecting Kiro reasoning IDs and encrypted_content, including on same-account retries and later non-passthrough failovers. Preserve JSON numbers exactly while sanitizing. Co-Authored-By: Claude Opus 4.8 --- .../handler/openai_gateway_handler.go | 7 +- .../openai_gateway_reasoning_failover.go | 62 ++++++++ .../openai_gateway_reasoning_failover_test.go | 146 ++++++++++++++++++ .../service/openai_gateway_request_body.go | 113 ++++++++++++++ ...nai_gateway_request_body_reasoning_test.go | 48 ++++++ 5 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 backend/internal/handler/openai_gateway_reasoning_failover.go create mode 100644 backend/internal/handler/openai_gateway_reasoning_failover_test.go diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 761f291e9..b4d3145ca 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -386,6 +386,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { sameAccountRetryCount := make(map[int64]int) var lastFailoverErr *service.UpstreamFailoverError var oauth429FailoverState service.OpenAIOAuth429FailoverState + var passthroughFailoverState openAIPassthroughFailoverState // 生图意图的 /v1/responses 请求必须调度到确实支持 Responses API 的账号,否则 // 会在 forward 阶段被静默降级为无法生图的 Chat Completions 直转(#4417)。 @@ -482,13 +483,17 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { // 用扣除 compact 心跳字节的口径快照:心跳注释不构成语义响应, // 不能因心跳字节变化而放弃 failover 换号(#3887)。 writerSizeBeforeForward := service.OpenAICompactKeepaliveAdjustedWrittenSize(c) + // 跨 passthrough 边界的 failover:从 Kiro 等透传账号切到 Bedrock 等非透传账号前, + // 从不可变的 canonical forwardBody 派生本次尝试 body 并整块剔除上游私有的加密 + // reasoning item(含耦合的 id/summary),避免非透传上游 400 拒绝 Kiro reasoning 形态。 + attemptBody := h.deriveOpenAIForwardAttemptBody(reqLog, forwardBody, account, &passthroughFailoverState) result, err := func() (*service.OpenAIForwardResult, error) { defer func() { if accountReleaseFunc != nil { accountReleaseFunc() } }() - return h.gatewayService.Forward(c.Request.Context(), c, account, forwardBody) + return h.gatewayService.Forward(c.Request.Context(), c, account, attemptBody) }() cyberBlockKeyHTTP := "" if service.GetOpsCyberPolicy(c) != nil { diff --git a/backend/internal/handler/openai_gateway_reasoning_failover.go b/backend/internal/handler/openai_gateway_reasoning_failover.go new file mode 100644 index 000000000..fecba8033 --- /dev/null +++ b/backend/internal/handler/openai_gateway_reasoning_failover.go @@ -0,0 +1,62 @@ +package handler + +import ( + "github.com/Wei-Shaw/sub2api/internal/service" + "go.uber.org/zap" +) + +// openAIPassthroughFailoverState tracks whether this forwarding loop has attempted +// an OpenAI passthrough account. Once it has, every subsequent non-passthrough +// attempt must use a sanitized body because the immutable canonical body can carry +// provider-specific encrypted reasoning produced by that passthrough upstream. +type openAIPassthroughFailoverState struct { + passthroughSeen bool +} + +// deriveOpenAIForwardAttemptBody returns the request body for the upcoming forward +// attempt against account. It always derives from the immutable canonical body and +// only removes encrypted reasoning input item(s) when the loop has already attempted +// a passthrough account and the upcoming account is non-passthrough. This remains +// sticky across retries and additional non-passthrough accounts. Attempts before +// any passthrough account, and all passthrough attempts, forward the canonical body +// unchanged. The canonical slice is never mutated. +// +// This method is invoked exactly once per forward attempt, immediately before the +// Forward call, and advances the failover state as a side effect. +func (h *OpenAIGatewayHandler) deriveOpenAIForwardAttemptBody( + reqLog *zap.Logger, + canonicalBody []byte, + account *service.Account, + state *openAIPassthroughFailoverState, +) []byte { + currentPassthrough := account.IsOpenAIPassthroughEnabled() + if currentPassthrough { + state.passthroughSeen = true + return canonicalBody + } + if !state.passthroughSeen { + return canonicalBody + } + + sanitized, changed, err := service.SanitizeOpenAICrossModeFailoverReasoning(canonicalBody) + if err != nil { + if reqLog != nil { + reqLog.Warn("openai.failover_cross_mode_reasoning_sanitize_failed", + zap.Int64("account_id", account.ID), + zap.Error(err), + ) + } + return canonicalBody + } + if !changed { + return canonicalBody + } + if reqLog != nil { + reqLog.Info("openai.failover_cross_mode_reasoning_stripped", + zap.Int64("account_id", account.ID), + zap.Bool("account_passthrough", currentPassthrough), + zap.Bool("passthrough_seen", true), + ) + } + return sanitized +} diff --git a/backend/internal/handler/openai_gateway_reasoning_failover_test.go b/backend/internal/handler/openai_gateway_reasoning_failover_test.go new file mode 100644 index 000000000..321364cd4 --- /dev/null +++ b/backend/internal/handler/openai_gateway_reasoning_failover_test.go @@ -0,0 +1,146 @@ +package handler + +import ( + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// kiroReasoningCanonicalBody mirrors a native OpenAI Responses request whose input +// carries a provider-specific encrypted reasoning item (Kiro shape: encrypted_content +// coupled with an id and summary), plus surrounding message items. +const kiroReasoningCanonicalBody = `{"model":"gpt-5.1","stream":false,"input":[` + + `{"type":"message","role":"user","content":"hello"},` + + `{"type":"reasoning","id":"rs_kiro_abc123","encrypted_content":"ENC_BLOB","summary":[{"type":"summary_text","text":"thinking"}]},` + + `{"type":"message","role":"assistant","content":"hi"}` + + `]}` + +func newOpenAIPassthroughAccount(id int64, passthrough bool) *service.Account { + extra := map[string]any{"openai_passthrough": passthrough} + return &service.Account{ + ID: id, + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Extra: extra, + } +} + +func reasoningItemCount(t *testing.T, body []byte) int { + t.Helper() + count := 0 + gjson.GetBytes(body, "input").ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() == "reasoning" { + count++ + } + return true + }) + return count +} + +// TestDeriveOpenAIForwardAttemptBody_CrossModeStripsKiroReasoning drives the real +// account-switch/request-body seam used inside OpenAIGatewayHandler.Responses: the +// same deriveOpenAIForwardAttemptBody call the failover loop makes, fed real +// *service.Account values in sequence. It proves the first passthrough attempt gets +// the untouched reasoning item, and after a failover to a non-passthrough account +// the second attempt body has the entire reasoning item removed — while the +// immutable canonical body is never mutated. +func TestDeriveOpenAIForwardAttemptBody_CrossModeStripsKiroReasoning(t *testing.T) { + h := &OpenAIGatewayHandler{} + canonical := []byte(kiroReasoningCanonicalBody) + + kiro := newOpenAIPassthroughAccount(1, true) // Kiro: openai_passthrough=true + bedrock := newOpenAIPassthroughAccount(2, false) // Bedrock Mantle: passthrough disabled + + state := &openAIPassthroughFailoverState{} + + // Attempt 1 — passthrough (Kiro): original reasoning item must survive intact. + firstBody := h.deriveOpenAIForwardAttemptBody(nil, canonical, kiro, state) + require.Equal(t, 1, reasoningItemCount(t, firstBody), "first passthrough attempt must keep the reasoning item") + require.Equal(t, "ENC_BLOB", gjson.GetBytes(firstBody, "input.1.encrypted_content").String()) + require.Equal(t, "rs_kiro_abc123", gjson.GetBytes(firstBody, "input.1.id").String()) + require.JSONEq(t, kiroReasoningCanonicalBody, string(firstBody), "first attempt body must equal the canonical body") + + // Attempt 2 — failover switches to non-passthrough (Bedrock): the whole + // provider-specific encrypted reasoning item (id/summary/encrypted_content) is dropped. + secondBody := h.deriveOpenAIForwardAttemptBody(nil, canonical, bedrock, state) + require.Equal(t, 0, reasoningItemCount(t, secondBody), "cross-mode attempt must drop the reasoning item entirely") + require.False(t, gjson.GetBytes(secondBody, "input.#(encrypted_content)").Exists(), "no encrypted_content may remain") + require.NotContains(t, string(secondBody), "rs_kiro_abc123", "coupled reasoning id must be gone") + require.NotContains(t, string(secondBody), "ENC_BLOB") + + // Surviving message items are preserved. + require.Equal(t, 2, int(gjson.GetBytes(secondBody, "input.#").Int())) + require.Equal(t, "hello", gjson.GetBytes(secondBody, "input.0.content").String()) + require.Equal(t, "hi", gjson.GetBytes(secondBody, "input.1.content").String()) + + // The canonical body is immutable across attempts. + require.JSONEq(t, kiroReasoningCanonicalBody, string(canonical), "canonical forwardBody must never be mutated") +} + +// TestDeriveOpenAIForwardAttemptBody_SameModePreservesReasoning proves that +// non-passthrough attempts before any passthrough attempt, passthrough-family +// failovers, and switching into passthrough forward the canonical reasoning item +// unchanged. +func TestDeriveOpenAIForwardAttemptBody_SameModePreservesReasoning(t *testing.T) { + h := &OpenAIGatewayHandler{} + canonical := []byte(kiroReasoningCanonicalBody) + + t.Run("non_passthrough_to_non_passthrough", func(t *testing.T) { + state := &openAIPassthroughFailoverState{} + first := h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(10, false), state) + second := h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(11, false), state) + require.Equal(t, 1, reasoningItemCount(t, first)) + require.Equal(t, 1, reasoningItemCount(t, second), "Bedrock-family failover must preserve reasoning") + require.JSONEq(t, kiroReasoningCanonicalBody, string(second)) + }) + + t.Run("passthrough_to_passthrough", func(t *testing.T) { + state := &openAIPassthroughFailoverState{} + first := h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(20, true), state) + second := h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(21, true), state) + require.Equal(t, 1, reasoningItemCount(t, first)) + require.Equal(t, 1, reasoningItemCount(t, second), "Kiro-family failover must preserve reasoning") + }) + + t.Run("non_passthrough_to_passthrough", func(t *testing.T) { + state := &openAIPassthroughFailoverState{} + _ = h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(30, false), state) + second := h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(31, true), state) + require.Equal(t, 1, reasoningItemCount(t, second), "switching into passthrough must preserve reasoning") + }) + + t.Run("same_account_pool_retry", func(t *testing.T) { + state := &openAIPassthroughFailoverState{} + kiro := newOpenAIPassthroughAccount(40, true) + _ = h.deriveOpenAIForwardAttemptBody(nil, canonical, kiro, state) + retry := h.deriveOpenAIForwardAttemptBody(nil, canonical, kiro, state) + require.Equal(t, 1, reasoningItemCount(t, retry), "same-account retry must preserve reasoning") + }) +} + +// TestDeriveOpenAIForwardAttemptBody_SanitizationSticksAcrossBedrockRetries proves +// that once a passthrough account has been attempted, all later non-passthrough +// retries and failovers remain sanitized instead of restoring the canonical Kiro +// reasoning item after the first Bedrock failure. +func TestDeriveOpenAIForwardAttemptBody_SanitizationSticksAcrossBedrockRetries(t *testing.T) { + h := &OpenAIGatewayHandler{} + canonical := []byte(kiroReasoningCanonicalBody) + state := &openAIPassthroughFailoverState{} + + kiro := newOpenAIPassthroughAccount(50, true) + bedrockA := newOpenAIPassthroughAccount(51, false) + bedrockB := newOpenAIPassthroughAccount(52, false) + + first := h.deriveOpenAIForwardAttemptBody(nil, canonical, kiro, state) + second := h.deriveOpenAIForwardAttemptBody(nil, canonical, bedrockA, state) + retry := h.deriveOpenAIForwardAttemptBody(nil, canonical, bedrockA, state) + nextAccount := h.deriveOpenAIForwardAttemptBody(nil, canonical, bedrockB, state) + + require.Equal(t, 1, reasoningItemCount(t, first)) + require.Equal(t, 0, reasoningItemCount(t, second)) + require.Equal(t, 0, reasoningItemCount(t, retry), "same Bedrock account retry must remain sanitized") + require.Equal(t, 0, reasoningItemCount(t, nextAccount), "later Bedrock account must remain sanitized") + require.JSONEq(t, kiroReasoningCanonicalBody, string(canonical), "canonical forwardBody must never be mutated") +} diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 14520958f..fdd401911 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -156,6 +156,119 @@ func sanitizeEncryptedReasoningInputItem(item any) (next any, changed bool, keep return inputItem, true, true } +// SanitizeOpenAICrossModeFailoverReasoning derives a failover attempt body from +// the canonical request body by dropping provider-specific encrypted reasoning +// input items in full (encrypted_content plus the coupled id/summary shape). +// +// This is the proactive counterpart to the reactive same-account +// invalid_encrypted_content recovery in Forward: when a failover switches from an +// OpenAI passthrough account (which forwards upstream-native encrypted reasoning, +// e.g. Kiro) to a non-passthrough account (e.g. Bedrock Mantle) that rejects the +// provider-specific reasoning IDs/shape, the whole reasoning item must go before +// the request reaches the new upstream. Unlike trimOpenAIEncryptedReasoningItems, +// which only strips the encrypted_content / null-content fields while preserving +// the reasoning item's id and summary, this drops the entire item. +// +// The input slice is treated as immutable and is never mutated; a distinct slice +// is returned only when changed is true. +func SanitizeOpenAICrossModeFailoverReasoning(body []byte) (sanitized []byte, changed bool, err error) { + if len(body) == 0 { + return body, false, nil + } + if !gjson.GetBytes(body, "input").Exists() { + return body, false, nil + } + var decoded map[string]any + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + if err := decoder.Decode(&decoded); err != nil { + return body, false, fmt.Errorf("decode cross-mode failover body: %w", err) + } + if !dropOpenAIEncryptedReasoningInputItems(decoded) { + return body, false, nil + } + out, marshalErr := marshalOpenAIUpstreamJSON(decoded) + if marshalErr != nil { + return body, false, fmt.Errorf("serialize cross-mode failover body: %w", marshalErr) + } + return out, true, nil +} + +// dropOpenAIEncryptedReasoningInputItems removes reasoning input items that carry +// provider-specific encrypted_content in full — including their coupled id and +// summary — and reports whether anything changed. Contrast with +// trimOpenAIEncryptedReasoningItems, which only strips fields while keeping the +// reasoning item skeleton. +func dropOpenAIEncryptedReasoningInputItems(reqBody map[string]any) bool { + if len(reqBody) == 0 { + return false + } + inputValue, has := reqBody["input"] + if !has { + return false + } + switch input := inputValue.(type) { + case []any: + filtered := input[:0] + changed := false + for _, item := range input { + if isOpenAIEncryptedReasoningInputItem(item) { + changed = true + continue + } + filtered = append(filtered, item) + } + if !changed { + return false + } + if len(filtered) == 0 { + delete(reqBody, "input") + return true + } + reqBody["input"] = filtered + return true + case []map[string]any: + filtered := input[:0] + changed := false + for _, item := range input { + if isOpenAIEncryptedReasoningInputItem(item) { + changed = true + continue + } + filtered = append(filtered, item) + } + if !changed { + return false + } + if len(filtered) == 0 { + delete(reqBody, "input") + return true + } + reqBody["input"] = filtered + return true + case map[string]any: + if isOpenAIEncryptedReasoningInputItem(input) { + delete(reqBody, "input") + return true + } + return false + default: + return false + } +} + +func isOpenAIEncryptedReasoningInputItem(item any) bool { + inputItem, ok := item.(map[string]any) + if !ok { + return false + } + if itemType, _ := inputItem["type"].(string); strings.TrimSpace(itemType) != "reasoning" { + return false + } + _, has := inputItem["encrypted_content"] + return has +} + func IsOpenAIResponsesCompactPathForTest(c *gin.Context) bool { return isOpenAIResponsesCompactPath(c) } diff --git a/backend/internal/service/openai_gateway_request_body_reasoning_test.go b/backend/internal/service/openai_gateway_request_body_reasoning_test.go index 62a885511..ad14f4d8d 100644 --- a/backend/internal/service/openai_gateway_request_body_reasoning_test.go +++ b/backend/internal/service/openai_gateway_request_body_reasoning_test.go @@ -5,6 +5,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) func TestTrimOpenAIEncryptedReasoningItems_ContentNull(t *testing.T) { @@ -98,6 +99,53 @@ func TestTrimOpenAIEncryptedReasoningItems_NoReasoningItems(t *testing.T) { assert.False(t, changed) } +func TestSanitizeOpenAICrossModeFailoverReasoning_DropsWholeEncryptedItem(t *testing.T) { + body := []byte(`{"model":"gpt-5.1","input":[` + + `{"type":"message","role":"user","content":"hi"},` + + `{"type":"reasoning","id":"rs_kiro_1","encrypted_content":"ENC","summary":[{"type":"summary_text","text":"t"}]},` + + `{"type":"message","role":"assistant","content":"yo"}` + + `]}`) + + sanitized, changed, err := SanitizeOpenAICrossModeFailoverReasoning(body) + require.NoError(t, err) + require.True(t, changed) + // The whole reasoning item is gone — id and summary go with encrypted_content, + // unlike trimOpenAIEncryptedReasoningItems which keeps the skeleton. + require.NotContains(t, string(sanitized), "reasoning") + require.NotContains(t, string(sanitized), "rs_kiro_1") + require.NotContains(t, string(sanitized), "summary_text") + require.Equal(t, int64(2), gjson.GetBytes(sanitized, "input.#").Int()) +} + +func TestSanitizeOpenAICrossModeFailoverReasoning_NoEncryptedIsNoop(t *testing.T) { + body := []byte(`{"model":"gpt-5.1","input":[{"type":"reasoning","summary":[{"type":"summary_text","text":"t"}]}]}`) + sanitized, changed, err := SanitizeOpenAICrossModeFailoverReasoning(body) + require.NoError(t, err) + require.False(t, changed, "reasoning without encrypted_content must be preserved") + require.Equal(t, string(body), string(sanitized)) +} + +func TestSanitizeOpenAICrossModeFailoverReasoning_NoInputIsNoop(t *testing.T) { + body := []byte(`{"model":"gpt-5.1"}`) + sanitized, changed, err := SanitizeOpenAICrossModeFailoverReasoning(body) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, string(body), string(sanitized)) +} + +func TestSanitizeOpenAICrossModeFailoverReasoning_PreservesLargeIntegers(t *testing.T) { + body := []byte(`{"model":"gpt-5.1","input":[` + + `{"type":"reasoning","id":"rs_kiro_1","encrypted_content":"ENC"},` + + `{"type":"message","role":"user","content":"hi"}` + + `],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object","properties":{"id":{"const":9007199254740993}}}}}]}`) + + sanitized, changed, err := SanitizeOpenAICrossModeFailoverReasoning(body) + require.NoError(t, err) + require.True(t, changed) + require.Contains(t, string(sanitized), `"const":9007199254740993`, + "sanitization must not round JSON integers through float64") +} + func TestTrimOpenAIEncryptedReasoningItems_ContentNullDropsBareSkeleton(t *testing.T) { reqBody := map[string]any{ "input": []any{ From 7ce6e8d6529cbd588727e742972335d55991b5e6 Mon Sep 17 00:00:00 2001 From: KtzeAbyss Date: Thu, 23 Jul 2026 20:17:11 +0800 Subject: [PATCH 15/26] fix(openai): track websocket models per turn --- .../handler/openai_gateway_handler.go | 111 ++++- .../handler/openai_gateway_handler_test.go | 416 +++++++++++++++--- .../internal/service/openai_ws_forwarder.go | 10 +- .../service/openai_ws_forwarder_ingress.go | 33 +- .../internal/service/openai_ws_http_bridge.go | 17 +- .../service/openai_ws_http_bridge_test.go | 21 +- .../service/openai_ws_v2/passthrough_relay.go | 32 +- .../openai_ws_v2/passthrough_relay_test.go | 50 +++ .../openai_ws_v2_passthrough_adapter.go | 130 +++++- 9 files changed, 719 insertions(+), 101 deletions(-) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 761f291e9..2ed648c49 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -9,6 +9,7 @@ import ( "runtime/debug" "strconv" "strings" + "sync/atomic" "time" "github.com/Wei-Shaw/sub2api/internal/config" @@ -43,6 +44,49 @@ type OpenAIGatewayHandler struct { cfg *config.Config } +type openAIWSTurnChannelMappingSnapshot struct { + turn int + mapping service.ChannelMappingResult +} + +var errOpenAIWSUnsupportedModelSwitch = errors.New("selected account does not support websocket model switch") + +func newOpenAIWSUnsupportedModelSwitchError(model string) error { + cause := fmt.Errorf("%w: model %q", errOpenAIWSUnsupportedModelSwitch, strings.TrimSpace(model)) + return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "model switch requires reconnect", cause) +} + +func shouldReportOpenAIWSProxyAccountFailure(err error) bool { + return err != nil && !errors.Is(err, errOpenAIWSUnsupportedModelSwitch) +} + +func openAIWSTurnBillingModel(result *service.OpenAIForwardResult, mapping service.ChannelMappingResult, requestedModel, upstreamModel string) string { + billingModel := "" + if result != nil { + billingModel = strings.TrimSpace(result.BillingModel) + } + if billingModel == "" { + billingModel = strings.TrimSpace(upstreamModel) + } + if billingModel == "" { + billingModel = strings.TrimSpace(requestedModel) + } + + requestedModel = strings.TrimSpace(requestedModel) + switch mapping.BillingModelSource { + case service.BillingModelSourceRequested: + if requestedModel != "" { + billingModel = requestedModel + } + case service.BillingModelSourceChannelMapped: + mappedModel := strings.TrimSpace(mapping.MappedModel) + if mappedModel != "" && mappedModel != requestedModel { + billingModel = mappedModel + } + } + return billingModel +} + type grokMediaEligibilityProber interface { ProbeMediaEligibility(ctx context.Context, accountID int64) (bool, string, error) } @@ -1519,7 +1563,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { previousResponseCanMove := !firstMessageToolCoverage.HasFunctionCallOutput || firstMessageToolCoverage.ContextCoversAllCallIDs reqLog = reqLog.With( zap.Bool("ws_ingress", true), - zap.String("model", reqModel), + zap.String("session_initial_model", reqModel), zap.Bool("has_previous_response_id", previousResponseID != ""), zap.String("previous_response_id_kind", previousResponseIDKind), ) @@ -1771,6 +1815,10 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { reasoningEffortMappings = apiKey.Group.ReasoningEffortMappings } var requestPayloadHash string + // Passthrough rejects overlapping response.create frames, so one immutable + // turn-tagged slot preserves the exact mapping used for the in-flight request. + var turnChannelMapping atomic.Pointer[openAIWSTurnChannelMappingSnapshot] + turnChannelMapping.Store(&openAIWSTurnChannelMappingSnapshot{turn: 1, mapping: channelMappingWS}) hooks := &service.OpenAIWSIngressHooks{ InitialRequestModel: reqModel, MaxReasoningEffort: maxReasoningEffort, @@ -1795,6 +1843,22 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { } return nil }, + MapRequestModel: func(turn int, originalModel string) (string, error) { + model := strings.TrimSpace(originalModel) + if model == "" { + model = reqModel + } + mapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, model) + mappedModelUnchanged := false + if previous := turnChannelMapping.Load(); previous != nil && previous.turn < turn { + mappedModelUnchanged = strings.TrimSpace(previous.mapping.MappedModel) == strings.TrimSpace(mapping.MappedModel) + } + if turn > 1 && !mappedModelUnchanged && !account.IsModelSupported(model) && !account.IsModelSupported(mapping.MappedModel) { + return "", newOpenAIWSUnsupportedModelSwitchError(mapping.MappedModel) + } + turnChannelMapping.Store(&openAIWSTurnChannelMappingSnapshot{turn: turn, mapping: mapping}) + return mapping.MappedModel, nil + }, BeforeTurn: func(turn int) error { // turn==1 的会话屏蔽已由握手层检查覆盖;连接内 flag 只拦截后续 turn。 if cyberBlockedThisConn { @@ -1836,7 +1900,27 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { // 届时 defer 已清除标记)。 defer clearCyberPolicyTurnState(c) releaseTurnSlots() - h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, reqModel, turnErr != nil, cyberBlockKey, clientRequestedUsageFields(c, channelMappingWS, reqModel, ""), requestPayloadHash) + turnRequestedModel := reqModel + turnUpstreamModel := "" + if result != nil && turn > 1 { + if model := strings.TrimSpace(result.Model); model != "" { + turnRequestedModel = model + } + } + if result != nil { + turnUpstreamModel = strings.TrimSpace(result.UpstreamModel) + } + var turnMapping service.ChannelMappingResult + if snapshot := turnChannelMapping.Load(); snapshot != nil && snapshot.turn == turn { + turnMapping = snapshot.mapping + } else { + turnMapping, _ = h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, turnRequestedModel) + } + if turnUpstreamModel == "" { + turnUpstreamModel = turnRequestedModel + } + turnUsageFields := turnMapping.ToUsageFields(turnRequestedModel, turnUpstreamModel) + h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, turnRequestedModel, turnErr != nil, cyberBlockKey, turnUsageFields, requestPayloadHash) if service.GetOpsCyberPolicy(c) != nil { cyberBlockedThisConn = true } @@ -1858,11 +1942,22 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { if result == nil { return } + result.BillingModel = openAIWSTurnBillingModel(result, turnMapping, turnRequestedModel, turnUpstreamModel) + reqLog.Debug("openai.websocket_turn_billing", + zap.Int("turn", turn), + zap.String("turn_requested_model", turnRequestedModel), + zap.String("turn_upstream_model", turnUpstreamModel), + zap.String("billing_model", result.BillingModel), + ) // 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。 if account.Type == service.AccountTypeOAuth && !account.IsShadow() { h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(ctx, account.ID, result.ResponseHeaders) } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), openAIForwardSucceededForScheduling(result), result.FirstTokenMs) + scheduleModel := turnUpstreamModel + if scheduleModel == "" { + scheduleModel = turnRequestedModel + } + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, scheduleModel, openAIForwardSucceededForScheduling(result), result.FirstTokenMs) inboundEndpoint := GetInboundEndpoint(c) upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result) quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) @@ -1883,7 +1978,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { APIKeyService: h.apiKeyService, QuotaPlatform: quotaPlatform, SessionID: sessionID, - ChannelUsageFields: clientRequestedUsageFields(c, channelMappingWS, reqModel, result.UpstreamModel), + ChannelUsageFields: turnUsageFields, CyberBlocked: cyberBlocked, }); err != nil { reqLog.Error("openai.websocket_record_usage_failed", @@ -1896,11 +1991,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { }, } - // 应用渠道模型映射到 WebSocket 首条消息 wsFirstMessage := firstMessage - if channelMappingWS.Mapped { - wsFirstMessage = h.gatewayService.ReplaceModelInBody(firstMessage, channelMappingWS.MappedModel) - } // 切组/会话失配防护:previous_response_id 未在当前分组命中粘连账号(StickyPreviousHit=false), // 说明该会话链不属于本次调度到的账号,原样转发会触发上游会话链鉴权失败(“鉴权失败,请检查 API Key”)。 // 故剥离首包里的 previous_response_id,改用首包内 input 重建上下文;带 function_call_output 的 @@ -1944,7 +2035,9 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { return } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil) + if shouldReportOpenAIWSProxyAccountFailure(err) { + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil) + } closeStatus, closeReason := summarizeWSCloseErrorForLog(err) proxyFailedFields := []zap.Field{ zap.Int64("account_id", account.ID), diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index eace1c7c9..abf81b79e 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "net/http" "net/http/httptest" @@ -1369,6 +1370,229 @@ func TestOpenAIResponsesWebSocket_PassthroughUsageLogLeavesUserAgentNilWhenMissi require.Equal(t, "medium", *got.log.ReasoningEffort) } +func TestOpenAIResponsesWebSocket_PassthroughTracksModelPerTurn(t *testing.T) { + got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"sol","stream":false}`, + secondPayload: `{"type":"response.create","model":"terra","stream":false}`, + channelMapping: map[string]string{ + "sol": "sol-channel", + "terra": "terra-channel", + }, + accountModelMapping: map[string]any{ + "sol": "gpt-5.6-sol", + "terra": "gpt-5.6-terra", + "sol-channel": "gpt-5.6-sol", + "terra-channel": "gpt-5.6-terra", + }, + }) + + require.Len(t, got.upstreamPayloads, 2) + require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(got.upstreamPayloads[0], "model").String()) + require.Equal(t, "gpt-5.6-terra", gjson.GetBytes(got.upstreamPayloads[1], "model").String()) + require.Len(t, got.clientEvents, 2) + require.Equal(t, "sol", gjson.GetBytes(got.clientEvents[0], "response.model").String()) + require.Equal(t, "terra", gjson.GetBytes(got.clientEvents[1], "response.model").String()) + + require.Len(t, got.logs, 2) + require.Equal(t, "sol", got.logs[0].Model) + require.Equal(t, "sol", got.logs[0].RequestedModel) + require.NotNil(t, got.logs[0].UpstreamModel) + require.Equal(t, "gpt-5.6-sol", *got.logs[0].UpstreamModel) + require.NotNil(t, got.logs[0].ModelMappingChain) + require.Equal(t, "sol→sol-channel→gpt-5.6-sol", *got.logs[0].ModelMappingChain) + + require.Equal(t, "terra", got.logs[1].Model) + require.Equal(t, "terra", got.logs[1].RequestedModel) + require.NotNil(t, got.logs[1].UpstreamModel) + require.Equal(t, "gpt-5.6-terra", *got.logs[1].UpstreamModel) + require.NotNil(t, got.logs[1].ModelMappingChain) + require.Equal(t, "terra→terra-channel→gpt-5.6-terra", *got.logs[1].ModelMappingChain) + require.InDelta(t, got.logs[1].TotalCost*2, got.logs[0].TotalCost, 1e-12, + "each turn must be billed with its own channel-mapped model") +} + +func TestOpenAIResponsesWebSocket_UnchangedChannelTargetOutsideAccountMappingKeysRemainsValid(t *testing.T) { + got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"public-alias","stream":false}`, + secondPayload: `{"type":"response.create","stream":false}`, + channelMapping: map[string]string{ + "public-alias": "gpt-5.6-sol", + }, + accountModelMapping: map[string]any{ + "public-alias": "gpt-5.6-terra", + }, + }) + + require.Len(t, got.upstreamPayloads, 2) + require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(got.upstreamPayloads[0], "model").String()) + require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(got.upstreamPayloads[1], "model").String()) + require.Len(t, got.clientEvents, 2) + require.Equal(t, "public-alias", gjson.GetBytes(got.clientEvents[0], "response.model").String()) + require.Equal(t, "public-alias", gjson.GetBytes(got.clientEvents[1], "response.model").String()) + require.Len(t, got.logs, 2) + for _, usageLog := range got.logs { + require.Equal(t, "public-alias", usageLog.RequestedModel) + require.NotNil(t, usageLog.UpstreamModel) + require.Equal(t, "gpt-5.6-sol", *usageLog.UpstreamModel) + require.NotNil(t, usageLog.ModelMappingChain) + require.Equal(t, "public-alias→gpt-5.6-sol", *usageLog.ModelMappingChain) + } +} + +func TestOpenAIResponsesWebSocket_PassthroughKeepsTurnMappingSnapshot(t *testing.T) { + got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"sol","stream":false}`, + secondPayload: `{"type":"response.create","model":"sol","stream":false}`, + channelMapping: map[string]string{ + "sol": "gpt-5.6-sol", + }, + afterFirstUpstreamRequest: func(channelSvc *service.ChannelService) error { + if channelSvc == nil { + return errors.New("channel service is nil") + } + _, err := channelSvc.Update(context.Background(), 7701, &service.UpdateChannelInput{ + ModelMapping: map[string]map[string]string{ + service.PlatformOpenAI: {"sol": "gpt-5.6-terra"}, + }, + }) + return err + }, + }) + + require.Len(t, got.upstreamPayloads, 2) + require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(got.upstreamPayloads[0], "model").String()) + require.Equal(t, "gpt-5.6-terra", gjson.GetBytes(got.upstreamPayloads[1], "model").String()) + + require.Len(t, got.logs, 2) + require.Equal(t, "sol", got.logs[0].Model) + require.NotNil(t, got.logs[0].UpstreamModel) + require.Equal(t, "gpt-5.6-sol", *got.logs[0].UpstreamModel) + require.NotNil(t, got.logs[0].ModelMappingChain) + require.Equal(t, "sol→gpt-5.6-sol", *got.logs[0].ModelMappingChain) + require.InDelta(t, 40e-6, got.logs[0].TotalCost, 1e-12, + "the in-flight turn must retain the channel-mapped billing model used when it was sent") + + require.Equal(t, "sol", got.logs[1].Model) + require.NotNil(t, got.logs[1].UpstreamModel) + require.Equal(t, "gpt-5.6-terra", *got.logs[1].UpstreamModel) + require.NotNil(t, got.logs[1].ModelMappingChain) + require.Equal(t, "sol→gpt-5.6-terra", *got.logs[1].ModelMappingChain) + require.InDelta(t, got.logs[1].TotalCost*2, got.logs[0].TotalCost, 1e-12, + "the next turn must use the updated channel mapping") +} + +func TestOpenAIResponsesWebSocket_CtxPoolAppliesPerTurnMappingAndPreservesRequestedModel(t *testing.T) { + got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"gpt-5.6-sol","stream":false}`, + secondPayload: `{"type":"response.create","model":"gpt-5.6-terra","stream":false}`, + ingressMode: service.OpenAIWSIngressModeCtxPool, + billingModelSource: service.BillingModelSourceRequested, + channelMapping: map[string]string{ + "gpt-5.6-terra": "gpt-5.6-sol", + }, + }) + + require.Len(t, got.upstreamPayloads, 2) + require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(got.upstreamPayloads[0], "model").String()) + require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(got.upstreamPayloads[1], "model").String()) + require.Len(t, got.clientEvents, 2) + require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(got.clientEvents[0], "response.model").String()) + require.Equal(t, "gpt-5.6-terra", gjson.GetBytes(got.clientEvents[1], "response.model").String()) + + require.Len(t, got.logs, 2) + require.Equal(t, "gpt-5.6-sol", got.logs[0].RequestedModel) + require.Nil(t, got.logs[0].ModelMappingChain) + require.InDelta(t, 40e-6, got.logs[0].TotalCost, 1e-12) + require.Equal(t, "gpt-5.6-terra", got.logs[1].RequestedModel) + require.NotNil(t, got.logs[1].ModelMappingChain) + require.Equal(t, "gpt-5.6-terra→gpt-5.6-sol", *got.logs[1].ModelMappingChain) + require.InDelta(t, 20e-6, got.logs[1].TotalCost, 1e-12, + "BillingModelSourceRequested must use the client model before channel mapping") +} + +func TestOpenAIWSTurnBillingModelPreservesImagePricingModel(t *testing.T) { + tests := []struct { + name string + resultModel string + mapping service.ChannelMappingResult + requestedModel string + upstreamModel string + wantBillingModel string + }{ + { + name: "upstream billing preserves image model", + resultModel: "gpt-image-2", + mapping: service.ChannelMappingResult{BillingModelSource: service.BillingModelSourceUpstream}, + requestedModel: "gpt-5.6-sol", + upstreamModel: "gpt-5.6-sol", + wantBillingModel: "gpt-image-2", + }, + { + name: "unmapped channel preserves image model", + resultModel: "gpt-image-2", + mapping: service.ChannelMappingResult{MappedModel: "gpt-5.6-sol", BillingModelSource: service.BillingModelSourceChannelMapped}, + requestedModel: "gpt-5.6-sol", + upstreamModel: "gpt-5.6-sol", + wantBillingModel: "gpt-image-2", + }, + { + name: "requested source overrides image model", + resultModel: "gpt-image-2", + mapping: service.ChannelMappingResult{BillingModelSource: service.BillingModelSourceRequested}, + requestedModel: "public-image-alias", + upstreamModel: "gpt-5.6-sol", + wantBillingModel: "public-image-alias", + }, + { + name: "mapped channel source overrides image model", + resultModel: "gpt-image-2", + mapping: service.ChannelMappingResult{MappedModel: "priced-channel-model", BillingModelSource: service.BillingModelSourceChannelMapped}, + requestedModel: "public-image-alias", + upstreamModel: "gpt-5.6-sol", + wantBillingModel: "priced-channel-model", + }, + { + name: "text turn falls back to upstream model", + mapping: service.ChannelMappingResult{BillingModelSource: service.BillingModelSourceUpstream}, + requestedModel: "public-alias", + upstreamModel: "gpt-5.6-sol", + wantBillingModel: "gpt-5.6-sol", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := &service.OpenAIForwardResult{BillingModel: tt.resultModel} + require.Equal(t, tt.wantBillingModel, openAIWSTurnBillingModel(result, tt.mapping, tt.requestedModel, tt.upstreamModel)) + }) + } +} + +func TestShouldReportOpenAIWSProxyAccountFailure(t *testing.T) { + t.Run("unsupported client model switch does not penalize account", func(t *testing.T) { + err := fmt.Errorf("wrapped ingress turn: %w", newOpenAIWSUnsupportedModelSwitchError("gpt-unsupported")) + require.False(t, shouldReportOpenAIWSProxyAccountFailure(err)) + + var closeErr *service.OpenAIWSClientCloseError + require.ErrorAs(t, err, &closeErr) + require.Equal(t, coderws.StatusPolicyViolation, closeErr.StatusCode()) + require.Equal(t, "model switch requires reconnect", closeErr.Reason()) + }) + + t.Run("upstream policy violation still penalizes account", func(t *testing.T) { + err := service.NewOpenAIWSClientCloseError( + coderws.StatusPolicyViolation, + "upstream websocket authentication failed", + errors.New("upstream rejected credentials"), + ) + require.True(t, shouldReportOpenAIWSProxyAccountFailure(err)) + }) + + t.Run("generic proxy failure still penalizes account", func(t *testing.T) { + require.True(t, shouldReportOpenAIWSProxyAccountFailure(errors.New("upstream websocket read failed"))) + }) +} + func TestSetOpenAIClientTransportHTTP(t *testing.T) { gin.SetMode(gin.TestMode) @@ -1516,14 +1740,22 @@ func newOpenAIWSHandlerTestServer(t *testing.T, h *OpenAIGatewayHandler, subject } type openAIResponsesWSUsageLogCase struct { - firstPayload string - userAgent *string - channelMapping map[string]string + firstPayload string + secondPayload string + userAgent *string + ingressMode string + channelMapping map[string]string + billingModelSource string + accountModelMapping map[string]any + afterFirstUpstreamRequest func(channelSvc *service.ChannelService) error } type openAIResponsesWSUsageLogResult struct { log *service.UsageLog + logs []*service.UsageLog upstreamFirstPayload []byte + upstreamPayloads [][]byte + clientEvents [][]byte } type openAIWSUsageHandlerAccountRepoStub struct { @@ -1633,12 +1865,50 @@ func (s *openAIWSUsageHandlerUsageLogRepoStub) Create(ctx context.Context, log * type openAIWSUsageHandlerChannelRepoStub struct { service.ChannelRepository + mu sync.Mutex channels []service.Channel groupPlatforms map[int64]string } func (s *openAIWSUsageHandlerChannelRepoStub) ListAll(ctx context.Context) ([]service.Channel, error) { - return s.channels, nil + s.mu.Lock() + defer s.mu.Unlock() + out := make([]service.Channel, 0, len(s.channels)) + for i := range s.channels { + out = append(out, *s.channels[i].Clone()) + } + return out, nil +} + +func (s *openAIWSUsageHandlerChannelRepoStub) GetByID(ctx context.Context, id int64) (*service.Channel, error) { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.channels { + if s.channels[i].ID == id { + return s.channels[i].Clone(), nil + } + } + return nil, service.ErrChannelNotFound +} + +func (s *openAIWSUsageHandlerChannelRepoStub) Update(ctx context.Context, channel *service.Channel) error { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.channels { + if s.channels[i].ID == channel.ID { + s.channels[i] = *channel.Clone() + return nil + } + } + return service.ErrChannelNotFound +} + +func (s *openAIWSUsageHandlerChannelRepoStub) GetGroupIDs(ctx context.Context, channelID int64) ([]int64, error) { + channel, err := s.GetByID(ctx, channelID) + if err != nil { + return nil, err + } + return append([]int64(nil), channel.GroupIDs...), nil } func (s *openAIWSUsageHandlerChannelRepoStub) GetGroupPlatforms(ctx context.Context, groupIDs []int64) (map[int64]string, error) { @@ -2147,8 +2417,13 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU t.Helper() gin.SetMode(gin.TestMode) - upstreamPayloadCh := make(chan []byte, 1) + turnCount := 1 + if strings.TrimSpace(tc.secondPayload) != "" { + turnCount = 2 + } + upstreamPayloadCh := make(chan []byte, turnCount) upstreamErrCh := make(chan error, 1) + var channelSvc *service.ChannelService upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{ CompressionMode: coderws.CompressionContextTakeover, @@ -2161,29 +2436,39 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU _ = conn.CloseNow() }() - readCtx, cancelRead := context.WithTimeout(r.Context(), 3*time.Second) - msgType, payload, readErr := conn.Read(readCtx) - cancelRead() - if readErr != nil { - upstreamErrCh <- readErr - return - } - if msgType != coderws.MessageText && msgType != coderws.MessageBinary { - upstreamErrCh <- errors.New("unexpected upstream websocket message type") - return - } - upstreamPayloadCh <- payload + for turn := 1; turn <= turnCount; turn++ { + readCtx, cancelRead := context.WithTimeout(r.Context(), 3*time.Second) + msgType, payload, readErr := conn.Read(readCtx) + cancelRead() + if readErr != nil { + upstreamErrCh <- readErr + return + } + if msgType != coderws.MessageText && msgType != coderws.MessageBinary { + upstreamErrCh <- errors.New("unexpected upstream websocket message type") + return + } + upstreamPayloadCh <- payload + if turn == 1 && tc.afterFirstUpstreamRequest != nil { + if callbackErr := tc.afterFirstUpstreamRequest(channelSvc); callbackErr != nil { + upstreamErrCh <- callbackErr + return + } + } - writeCtx, cancelWrite := context.WithTimeout(r.Context(), 3*time.Second) - writeErr := conn.Write(writeCtx, coderws.MessageText, []byte( - `{"type":"response.completed","response":{"id":"resp_usage_e2e","model":"gpt-5.4","usage":{"input_tokens":2,"output_tokens":1}}}`, - )) - cancelWrite() - if writeErr != nil { - upstreamErrCh <- writeErr - return + response := fmt.Sprintf( + `{"type":"response.completed","response":{"id":"resp_usage_e2e_%d","model":%q,"usage":{"input_tokens":2,"output_tokens":1}}}`, + turn, + gjson.GetBytes(payload, "model").String(), + ) + writeCtx, cancelWrite := context.WithTimeout(r.Context(), 3*time.Second) + writeErr := conn.Write(writeCtx, coderws.MessageText, []byte(response)) + cancelWrite() + if writeErr != nil { + upstreamErrCh <- writeErr + return + } } - _ = conn.Close(coderws.StatusNormalClosure, "done") upstreamErrCh <- nil })) defer upstreamServer.Close() @@ -2198,14 +2483,18 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU Schedulable: true, Concurrency: 1, Credentials: map[string]any{ - "api_key": "sk-test", - "base_url": upstreamServer.URL, + "api_key": "sk-test", + "base_url": upstreamServer.URL, + "model_mapping": tc.accountModelMapping, }, Extra: map[string]any{ "openai_apikey_responses_websockets_v2_enabled": true, "openai_apikey_responses_websockets_v2_mode": service.OpenAIWSIngressModePassthrough, }, } + if strings.TrimSpace(tc.ingressMode) != "" { + account.Extra["openai_apikey_responses_websockets_v2_mode"] = tc.ingressMode + } cfg := &config.Config{} cfg.RunMode = config.RunModeSimple @@ -2221,17 +2510,17 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 accountRepo := &openAIWSUsageHandlerAccountRepoStub{account: account} - usageRepo := &openAIWSUsageHandlerUsageLogRepoStub{created: make(chan *service.UsageLog, 1)} + usageRepo := &openAIWSUsageHandlerUsageLogRepoStub{created: make(chan *service.UsageLog, turnCount)} - var channelSvc *service.ChannelService if len(tc.channelMapping) > 0 { channelSvc = service.NewChannelService(&openAIWSUsageHandlerChannelRepoStub{ channels: []service.Channel{{ - ID: 7701, - Name: "openai-ws-e2e-channel", - Status: service.StatusActive, - GroupIDs: []int64{groupID}, - ModelMapping: map[string]map[string]string{service.PlatformOpenAI: tc.channelMapping}, + ID: 7701, + Name: "openai-ws-e2e-channel", + Status: service.StatusActive, + GroupIDs: []int64{groupID}, + ModelMapping: map[string]map[string]string{service.PlatformOpenAI: tc.channelMapping}, + BillingModelSource: tc.billingModelSource, }}, groupPlatforms: map[int64]string{groupID: service.PlatformOpenAI}, }, nil, nil, nil) @@ -2314,26 +2603,44 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU cancelWrite() require.NoError(t, err) - readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second) - _, event, err := clientConn.Read(readCtx) - cancelRead() - require.NoError(t, err) - require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) + clientEvents := make([][]byte, 0, turnCount) + readCompleted := func() { + readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second) + _, event, readErr := clientConn.Read(readCtx) + cancelRead() + require.NoError(t, readErr) + require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) + clientEvents = append(clientEvents, append([]byte(nil), event...)) + } + readCompleted() + if turnCount == 2 { + writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(tc.secondPayload)) + cancelWrite() + require.NoError(t, err) + readCompleted() + } _ = clientConn.Close(coderws.StatusNormalClosure, "done") - var usageLog *service.UsageLog - select { - case usageLog = <-usageRepo.created: - require.NotNil(t, usageLog) - case <-time.After(3 * time.Second): - t.Fatal("等待 WebSocket usage log 写入超时") + usageLogs := make([]*service.UsageLog, 0, turnCount) + for len(usageLogs) < turnCount { + select { + case usageLog := <-usageRepo.created: + require.NotNil(t, usageLog) + usageLogs = append(usageLogs, usageLog) + case <-time.After(3 * time.Second): + t.Fatal("等待 WebSocket usage log 写入超时") + } } - var upstreamFirstPayload []byte - select { - case upstreamFirstPayload = <-upstreamPayloadCh: - case <-time.After(3 * time.Second): - t.Fatal("等待上游 WebSocket 首帧超时") + upstreamPayloads := make([][]byte, 0, turnCount) + for len(upstreamPayloads) < turnCount { + select { + case payload := <-upstreamPayloadCh: + upstreamPayloads = append(upstreamPayloads, payload) + case <-time.After(3 * time.Second): + t.Fatal("等待上游 WebSocket 请求帧超时") + } } select { @@ -2344,8 +2651,11 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU } return openAIResponsesWSUsageLogResult{ - log: usageLog, - upstreamFirstPayload: upstreamFirstPayload, + log: usageLogs[0], + logs: usageLogs, + upstreamFirstPayload: upstreamPayloads[0], + upstreamPayloads: upstreamPayloads, + clientEvents: clientEvents, } } diff --git a/backend/internal/service/openai_ws_forwarder.go b/backend/internal/service/openai_ws_forwarder.go index d22bec21d..9c0869fbe 100644 --- a/backend/internal/service/openai_ws_forwarder.go +++ b/backend/internal/service/openai_ws_forwarder.go @@ -207,8 +207,9 @@ func (e *OpenAIWSClientCloseError) Reason() string { // OpenAIWSIngressHooks 定义入站 WS 每个 turn 的生命周期回调。 type OpenAIWSIngressHooks struct { - // InitialRequestModel 是首帧渠道映射前的请求模型,只用于 usage metadata - // 的 reasoning effort 后缀推导,禁止用于上游请求或计费模型。 + // InitialRequestModel is the client-facing model from the first frame, + // before channel or account mapping. Ingress modes preserve it for usage + // attribution while MapRequestModel determines the upstream model. InitialRequestModel string // MaxReasoningEffort limits explicit reasoning effort values for this WS session. MaxReasoningEffort string @@ -216,7 +217,10 @@ type OpenAIWSIngressHooks struct { ReasoningEffortMappings []ReasoningEffortMapping BeforeTurn func(turn int) error BeforeRequest func(turn int, payload []byte, originalModel string) error - AfterTurn func(turn int, result *OpenAIForwardResult, turnErr error) + // MapRequestModel resolves the current turn's client model to the model + // that must be written into the upstream response.create frame. + MapRequestModel func(turn int, originalModel string) (string, error) + AfterTurn func(turn int, result *OpenAIForwardResult, turnErr error) } func (s *OpenAIGatewayService) getOpenAIWSConnPool() *openAIWSConnPool { diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index c896ec794..3664ae8b2 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -163,7 +163,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( return rebuilt, nil } - parseClientPayload := func(raw []byte) (openAIWSClientPayload, error) { + parseClientPayload := func(turn int, raw []byte) (openAIWSClientPayload, error) { trimmed := bytes.TrimSpace(raw) if len(trimmed) == 0 { return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "empty websocket request payload", nil) @@ -288,7 +288,17 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( normalized = rebuilt } } - upstreamModel := normalizeOpenAIModelForUpstream(account, account.GetMappedModel(originalModel)) + requestModel := originalModel + if hooks != nil && hooks.MapRequestModel != nil { + mappedModel, mapErr := hooks.MapRequestModel(turn, originalModel) + if mapErr != nil { + return openAIWSClientPayload{}, mapErr + } + if mappedModel = strings.TrimSpace(mappedModel); mappedModel != "" { + requestModel = mappedModel + } + } + upstreamModel := normalizeOpenAIModelForUpstream(account, account.GetMappedModel(requestModel)) if modelMissing || upstreamModel != originalModel { next, setErr := applyPayloadMutation(normalized, "model", upstreamModel) if setErr != nil { @@ -412,7 +422,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( return payload, nil } - firstPayload, err := parseClientPayload(firstClientMessage) + firstPayload, err := parseClientPayload(1, firstClientMessage) if err != nil { return err } @@ -513,7 +523,13 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } grokCacheIdentity := "" if account.Platform == PlatformGrok { - grokCacheIdentity, err = resolveGrokWSCacheIdentity(c, account, grokCacheSeedPayload, currentBridgePayload.originalModel) + grokCacheIdentity, err = resolveGrokWSCacheIdentity( + c, + account, + grokCacheSeedPayload, + currentBridgePayload.payloadRaw, + currentBridgePayload.originalModel, + ) if err != nil { return fmt.Errorf("resolve Grok websocket cache identity: %w", err) } @@ -573,7 +589,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } return fmt.Errorf("read client websocket request: %w", readErr) } - nextPayload, parseErr := parseClientPayload(nextClientMessage) + nextPayload, parseErr := parseClientPayload(turn+1, nextClientMessage) if parseErr != nil { return parseErr } @@ -792,7 +808,10 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( mappedModel := "" var mappedModelBytes []byte if originalModel != "" { - mappedModel = normalizeOpenAIModelForUpstream(account, account.GetMappedModel(originalModel)) + mappedModel = strings.TrimSpace(gjson.GetBytes(payload, "model").String()) + if mappedModel == "" { + mappedModel = normalizeOpenAIModelForUpstream(account, account.GetMappedModel(originalModel)) + } needModelReplace = mappedModel != "" && mappedModel != originalModel if needModelReplace { mappedModelBytes = []byte(mappedModel) @@ -1589,7 +1608,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( return fmt.Errorf("read client websocket request: %w", readErr) } - nextPayload, parseErr := parseClientPayload(nextClientMessage) + nextPayload, parseErr := parseClientPayload(turn+1, nextClientMessage) if parseErr != nil { return parseErr } diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index eb7943589..b2c7c67f6 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -286,7 +286,10 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( needModelReplace := false var mappedModelBytes []byte if originalModel != "" { - mappedModel = normalizeOpenAIModelForUpstream(account, account.GetMappedModel(originalModel)) + mappedModel = strings.TrimSpace(gjson.GetBytes(body, "model").String()) + if mappedModel == "" { + mappedModel = normalizeOpenAIModelForUpstream(account, account.GetMappedModel(originalModel)) + } needModelReplace = mappedModel != "" && mappedModel != originalModel if needModelReplace { mappedModelBytes = []byte(mappedModel) @@ -487,12 +490,12 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( return resultWithUsage(), terminalErr } -func resolveGrokWSCacheIdentity(c *gin.Context, account *Account, payload []byte, originalModel string) (string, error) { - body, err := prepareOpenAIWSHTTPBridgeBody(payload) +func resolveGrokWSCacheIdentity(c *gin.Context, account *Account, seedPayload, currentPayload []byte, originalModel string) (string, error) { + body, err := prepareOpenAIWSHTTPBridgeBody(seedPayload) if err != nil { return "", err } - upstreamModel := resolveGrokWSUpstreamModel(account, body, originalModel) + upstreamModel := resolveGrokWSUpstreamModel(account, currentPayload, originalModel) body, err = patchGrokResponsesBody(body, upstreamModel) if err != nil { return "", err @@ -502,7 +505,11 @@ func resolveGrokWSCacheIdentity(c *gin.Context, account *Account, payload []byte func resolveGrokWSUpstreamModel(account *Account, body []byte, originalModel string) string { upstreamModel := strings.TrimSpace(gjson.GetBytes(body, "model").String()) - if account != nil && originalModel != "" { + originalModel = strings.TrimSpace(originalModel) + // Shared ingress has already applied channel and account mappings when the + // body model differs from the client-facing model. Only resolve from the + // original model when the body still carries that original value. + if account != nil && originalModel != "" && (upstreamModel == "" || upstreamModel == originalModel) { if mappedModel := normalizeOpenAIModelForUpstream(account, account.GetMappedModel(originalModel)); mappedModel != "" { upstreamModel = mappedModel } diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index 73f8d1f01..802146cd3 100644 --- a/backend/internal/service/openai_ws_http_bridge_test.go +++ b/backend/internal/service/openai_ws_http_bridge_test.go @@ -520,7 +520,7 @@ func TestProxyOpenAIWSHTTPBridgeTurnPromotesCodexAdditionalToolsForMixedCache(t require.Empty(t, upstream.lastReq.Header.Get(grokClientToolCacheOptInHeader)) } -func TestProxyResponsesWebSocketFromClientForGrokUsesXAIHTTPBridge(t *testing.T) { +func TestProxyResponsesWebSocketFromClientForGrokUsesXAIHTTPBridgeAndPreservesMappedModels(t *testing.T) { gin.SetMode(gin.TestMode) bridgeResponse := func(responseID, requestID string, cachedTokens int) *http.Response { @@ -594,7 +594,14 @@ func TestProxyResponsesWebSocketFromClientForGrokUsesXAIHTTPBridge(t *testing.T) ginCtx.Request = req ginCtx.Set("api_key", &APIKey{ID: 7101}) - errCh <- svc.ProxyResponsesWebSocketFromClient(r.Context(), ginCtx, conn, account, "access-token", firstMessage, nil) + errCh <- svc.ProxyResponsesWebSocketFromClient(r.Context(), ginCtx, conn, account, "access-token", firstMessage, &OpenAIWSIngressHooks{ + MapRequestModel: func(_ int, originalModel string) (string, error) { + if originalModel == "channel-alias" { + return "grok-4.3", nil + } + return originalModel, nil + }, + }) })) defer wsServer.Close() @@ -626,7 +633,7 @@ func TestProxyResponsesWebSocketFromClientForGrokUsesXAIHTTPBridge(t *testing.T) require.Equal(t, "resp_grok_ws_1", gjson.GetBytes(completed, "response.id").String()) writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second) - err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","generate":true,"model":"grok","stream":true,"previous_response_id":"resp_grok_ws_1","input":"second turn"}`)) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","generate":true,"model":"channel-alias","stream":true,"previous_response_id":"resp_grok_ws_1","input":"second turn"}`)) cancelWrite() require.NoError(t, err) @@ -637,6 +644,7 @@ func TestProxyResponsesWebSocketFromClientForGrokUsesXAIHTTPBridge(t *testing.T) require.Equal(t, "response.output_text.delta", gjson.GetBytes(delta, "type").String()) require.Equal(t, "response.completed", gjson.GetBytes(completed, "type").String()) require.Equal(t, "resp_grok_ws_2", gjson.GetBytes(completed, "response.id").String()) + require.Equal(t, "channel-alias", gjson.GetBytes(completed, "response.model").String()) writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second) err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","generate":true,"model":"grok-4.3","stream":true,"previous_response_id":"resp_grok_ws_2","input":"third turn with a different model"}`)) @@ -666,7 +674,7 @@ func TestProxyResponsesWebSocketFromClientForGrokUsesXAIHTTPBridge(t *testing.T) require.Equal(t, "sub2api-grok/1.0", upstream.lastReq.Header.Get("User-Agent")) require.Equal(t, grokCLIVersion, upstream.lastReq.Header.Get("X-Grok-Client-Version")) require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.bodies[0], "model").String()) - require.Equal(t, "grok-4.5", gjson.GetBytes(upstream.bodies[1], "model").String()) + require.Equal(t, "grok-4.3", gjson.GetBytes(upstream.bodies[1], "model").String()) require.Equal(t, "grok-4.3", gjson.GetBytes(upstream.bodies[2], "model").String()) require.NotEmpty(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()) require.Equal(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String(), upstream.lastReq.Header.Get(grokConversationIDHeader)) @@ -677,9 +685,10 @@ func TestProxyResponsesWebSocketFromClientForGrokUsesXAIHTTPBridge(t *testing.T) secondIdentity := gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").String() thirdIdentity := gjson.GetBytes(upstream.bodies[2], "prompt_cache_key").String() require.NotEmpty(t, firstIdentity) - require.Equal(t, firstIdentity, secondIdentity) + require.NotEmpty(t, secondIdentity) + require.NotEqual(t, firstIdentity, secondIdentity) require.NotEmpty(t, thirdIdentity) - require.NotEqual(t, firstIdentity, thirdIdentity) + require.Equal(t, secondIdentity, thirdIdentity) require.Equal(t, firstIdentity, upstream.requests[0].Header.Get(grokConversationIDHeader)) require.Equal(t, secondIdentity, upstream.requests[1].Header.Get(grokConversationIDHeader)) require.Equal(t, thirdIdentity, upstream.requests[2].Header.Get(grokConversationIDHeader)) diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay.go b/backend/internal/service/openai_ws_v2/passthrough_relay.go index 4dae079b5..8d98fc78a 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay.go @@ -7,6 +7,7 @@ import ( "net" "strconv" "strings" + "sync" "sync/atomic" "time" @@ -86,6 +87,7 @@ type RelayTraceEvent struct { type relayState struct { usage Usage + requestModelMu sync.RWMutex requestModel string lastResponseID string terminalEventType string @@ -164,6 +166,12 @@ func Relay( defer cancel() return upstreamConn.WriteFrame(writeCtx, msgType, payload) } + writeClientFrameUpstream := func(msgType coderws.MessageType, payload []byte) error { + if msgType == coderws.MessageText && strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == "response.create" { + state.setRequestModel(strings.TrimSpace(gjson.GetBytes(payload, "model").String())) + } + return writeUpstream(msgType, payload) + } writeClient := func(msgType coderws.MessageType, payload []byte) error { writeCtx, cancel := context.WithTimeout(relayCtx, writeTimeout) defer cancel() @@ -215,7 +223,7 @@ func Relay( if !clientReaderStarted.CompareAndSwap(false, true) { return } - go runClientToUpstream(relayCtx, clientConn, options.ReadClientFrame, writeUpstream, markActivity, clientToUpstreamFrames, onTrace, exitCh) + go runClientToUpstream(relayCtx, clientConn, options.ReadClientFrame, writeClientFrameUpstream, markActivity, clientToUpstreamFrames, onTrace, exitCh) } if !options.StartClientAfterFirstDownstream { startClientReader() @@ -712,7 +720,7 @@ func emitTurnComplete( } requestModel := "" if state != nil { - requestModel = state.requestModel + requestModel = state.currentRequestModel() } onTurnComplete(RelayTurnResult{ RequestModel: requestModel, @@ -873,13 +881,31 @@ func enrichResult(result *RelayResult, state *relayState, duration time.Duration if state == nil { return } - result.RequestModel = state.requestModel + result.RequestModel = state.currentRequestModel() result.Usage = state.usage result.RequestID = state.lastResponseID result.TerminalEventType = state.terminalEventType result.FirstTokenMs = state.firstTokenMs } +func (s *relayState) setRequestModel(model string) { + if s == nil || model == "" { + return + } + s.requestModelMu.Lock() + s.requestModel = model + s.requestModelMu.Unlock() +} + +func (s *relayState) currentRequestModel() string { + if s == nil { + return "" + } + s.requestModelMu.RLock() + defer s.requestModelMu.RUnlock() + return s.requestModel +} + func isDisconnectError(err error) bool { if err == nil { return false diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay_test.go b/backend/internal/service/openai_ws_v2/passthrough_relay_test.go index a5d77fe0f..c083943bc 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay_test.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay_test.go @@ -472,6 +472,56 @@ func TestRelay_OnTurnComplete_PerTerminalEvent(t *testing.T) { require.Equal(t, 5, result.Usage.OutputTokens) } +func TestRelay_OnTurnComplete_UsesCurrentResponseCreateModel(t *testing.T) { + t.Parallel() + + clientConn := newPassthroughTestFrameConn(nil, false) + upstreamConn := newPassthroughTestFrameConn(nil, false) + firstPayload := []byte(`{"type":"response.create","model":"gpt-5.6-sol","input":[]}`) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + turns := make(chan RelayTurnResult, 2) + done := make(chan struct{}) + go func() { + defer close(done) + _, _ = Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{ + OnTurnComplete: func(turn RelayTurnResult) { + turns <- turn + }, + }) + }() + + upstreamConn.readCh <- passthroughTestFrame{ + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.completed","response":{"id":"resp_sol","usage":{"input_tokens":2,"output_tokens":1}}}`), + } + firstTurn := <-turns + require.Equal(t, "gpt-5.6-sol", firstTurn.RequestModel) + + clientConn.readCh <- passthroughTestFrame{ + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.create","model":"gpt-5.6-terra","input":[]}`), + } + require.Eventually(t, func() bool { + return len(upstreamConn.Writes()) == 2 + }, time.Second, 10*time.Millisecond) + upstreamConn.readCh <- passthroughTestFrame{ + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.completed","response":{"id":"resp_terra","usage":{"input_tokens":3,"output_tokens":1}}}`), + } + secondTurn := <-turns + require.Equal(t, "gpt-5.6-terra", secondTurn.RequestModel) + + _ = clientConn.Close() + _ = upstreamConn.Close() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("relay did not stop after client close") + } +} + func TestRelay_OnTurnComplete_ProvidesTurnMetrics(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 934a35fcc..ae74027bc 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -25,6 +25,9 @@ type openAIWSClientFrameConn struct { interTurnIdleTimeout time.Duration interTurnStarted chan struct{} waitingForNextTurn atomic.Bool + // The relay observes upstream payloads, while clients must keep seeing the + // model identifier they supplied for the current turn. + restoreResponseModel func([]byte) []byte } // openAIWSPolicyEnforcingFrameConn wraps a client-side FrameConn and runs @@ -133,6 +136,8 @@ func openAIWSPassthroughPolicyModelFromSessionFrame(account *Account, payload [] type openAIWSPassthroughUsageMeta struct { serviceTier atomic.Pointer[string] reasoningEffort atomic.Pointer[string] + requestModel atomic.Pointer[string] + upstreamModel atomic.Pointer[string] // 仅在 client->upstream filter goroutine 中读写;Load 侧通过上方原子指针同步。 sessionRequestModel string @@ -154,6 +159,7 @@ func (m *openAIWSPassthroughUsageMeta) initFromFirstFrame(policyOutput []byte, m } m.serviceTier.Store(extractOpenAIServiceTierFromBody(policyOutput)) m.reasoningEffort.Store(extractOpenAIReasoningEffortFromBody(policyOutput, mappedModel, m.sessionRequestModel)) + m.storeTurnModels(m.sessionRequestModel, policyOutput) } func (m *openAIWSPassthroughUsageMeta) updateSessionRequestModel(payload []byte) { @@ -181,6 +187,51 @@ func (m *openAIWSPassthroughUsageMeta) updateFromResponseCreate(policyOutput []b } m.serviceTier.Store(extractOpenAIServiceTierFromBody(policyOutput)) m.reasoningEffort.Store(extractOpenAIReasoningEffortFromBody(policyOutput, mappedModel, requestModelForFrame)) + m.storeTurnModels(requestModelForFrame, policyOutput) +} + +func (m *openAIWSPassthroughUsageMeta) storeTurnModels(requestModel string, upstreamPayload []byte) { + if m == nil { + return + } + requestModel = strings.TrimSpace(requestModel) + upstreamModel := strings.TrimSpace(gjson.GetBytes(upstreamPayload, "model").String()) + if upstreamModel == "" { + upstreamModel = requestModel + } + m.requestModel.Store(openAIWSTrimmedStringPtr(requestModel)) + m.upstreamModel.Store(openAIWSTrimmedStringPtr(upstreamModel)) +} + +func (m *openAIWSPassthroughUsageMeta) turnModels(fallback string) (string, string) { + requestModel := strings.TrimSpace(fallback) + upstreamModel := requestModel + if m == nil { + return requestModel, upstreamModel + } + if current := m.requestModel.Load(); current != nil && strings.TrimSpace(*current) != "" { + requestModel = strings.TrimSpace(*current) + } + if current := m.upstreamModel.Load(); current != nil && strings.TrimSpace(*current) != "" { + upstreamModel = strings.TrimSpace(*current) + } + return requestModel, upstreamModel +} + +func openAIWSTrimmedStringPtr(value string) *string { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + return &value +} + +func openAIWSDifferentModel(requestModel, upstreamModel string) string { + upstreamModel = strings.TrimSpace(upstreamModel) + if upstreamModel == "" || upstreamModel == strings.TrimSpace(requestModel) { + return "" + } + return upstreamModel } func openAIWSPassthroughRequestModelForFrame(payload []byte) string { @@ -585,6 +636,9 @@ func (c *openAIWSClientFrameConn) WriteFrame(ctx context.Context, msgType coderw if normalized, changed := normalizeCompletedImageGenerationStatus(payload); changed { payload = normalized } + if c.restoreResponseModel != nil { + payload = c.restoreResponseModel(payload) + } } return c.conn.Write(ctx, msgType, payload) } @@ -655,10 +709,25 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( // negotiated at session.update time. Without this fallback, an empty // model would miss any admin-configured model whitelist and be silently // passed through, defeating that policy on every frame after the first. - capturedSessionModel := openAIWSPassthroughPolicyModelForFrame(account, firstClientMessage) initialRequestModel := "" if hooks != nil { - initialRequestModel = hooks.InitialRequestModel + initialRequestModel = strings.TrimSpace(hooks.InitialRequestModel) + } + if initialRequestModel == "" { + initialRequestModel = openAIWSPassthroughRequestModelForFrame(firstClientMessage) + } + if hooks != nil && hooks.MapRequestModel != nil { + mappedModel, mapErr := hooks.MapRequestModel(1, initialRequestModel) + if mapErr != nil { + return mapErr + } + if mappedModel = strings.TrimSpace(mappedModel); mappedModel != "" { + firstClientMessage = s.ReplaceModelInBody(firstClientMessage, mappedModel) + } + } + capturedSessionModel := openAIWSPassthroughPolicyModelForFrame(account, firstClientMessage) + if capturedSessionModel != "" && capturedSessionModel != strings.TrimSpace(gjson.GetBytes(firstClientMessage, "model").String()) { + firstClientMessage = s.ReplaceModelInBody(firstClientMessage, capturedSessionModel) } usageMeta := newOpenAIWSPassthroughUsageMeta(initialRequestModel, firstClientMessage) updatedFirst, blocked, policyErr := s.applyOpenAIFastPolicyToWSResponseCreate(ctx, account, capturedSessionModel, firstClientMessage) @@ -839,6 +908,14 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( controlCtx: ctx, interTurnIdleTimeout: s.openAIWSIngressInterTurnIdleTimeout(), interTurnStarted: make(chan struct{}, 1), + restoreResponseModel: func(payload []byte) []byte { + eventType := strings.TrimSpace(gjson.GetBytes(payload, "type").String()) + if !openAIWSEventMayContainModel(eventType) { + return payload + } + requestModel, upstreamModel := usageMeta.turnModels("") + return replaceOpenAIWSMessageModel(payload, upstreamModel, requestModel) + }, } policyClientConn := &openAIWSPolicyEnforcingFrameConn{ inner: clientFrameConn, @@ -878,17 +955,29 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( } } } - if isResponseCreate && hooks != nil && hooks.BeforeRequest != nil { - turnNo := int(completedTurns.Load()) + 1 - if turnNo < 2 { - turnNo = 2 + turnNo := int(completedTurns.Load()) + 1 + if turnNo < 2 { + turnNo = 2 + } + requestModelForThisFrame := "" + if isResponseCreate { + requestModelForThisFrame = usageMeta.requestModelForFrame(payload) + if requestModelForThisFrame == "" { + requestModelForThisFrame = capturedSessionModel } - requestModel := usageMeta.requestModelForFrame(payload) - if requestModel == "" { - requestModel = capturedSessionModel + if hooks != nil && hooks.BeforeRequest != nil { + if err := hooks.BeforeRequest(turnNo, payload, requestModelForThisFrame); err != nil { + return payload, nil, err + } } - if err := hooks.BeforeRequest(turnNo, payload, requestModel); err != nil { - return payload, nil, err + if hooks != nil && hooks.MapRequestModel != nil { + upstreamModel, err := hooks.MapRequestModel(turnNo, requestModelForThisFrame) + if err != nil { + return payload, nil, err + } + if upstreamModel = strings.TrimSpace(upstreamModel); upstreamModel != "" { + payload = s.ReplaceModelInBody(payload, upstreamModel) + } } } // 在评估策略前先刷新 capturedSessionModel:客户端可能通过 @@ -902,7 +991,9 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( capturedSessionModel = updated } usageMeta.updateSessionRequestModel(payload) - requestModelForThisFrame := usageMeta.requestModelForFrame(payload) + if requestModelForThisFrame == "" { + requestModelForThisFrame = usageMeta.requestModelForFrame(payload) + } // Per-frame model first; if the client omits "model" on a // follow-up frame (legal in Realtime), fall back to the // session-level model captured from the first frame so the @@ -912,6 +1003,9 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( if model == "" { model = capturedSessionModel } + if isResponseCreate && model != "" && model != strings.TrimSpace(gjson.GetBytes(payload, "model").String()) { + payload = s.ReplaceModelInBody(payload, model) + } out, blocked, policyErr := s.applyOpenAIFastPolicyToWSResponseCreate(ctx, account, model, payload) // 多轮 passthrough usage:仅在成功(non-block / non-err) // 的 response.create 帧上更新 usageMeta,使用 @@ -999,6 +1093,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( }, OnTurnComplete: func(turn openaiwsv2.RelayTurnResult) { turnNo := int(completedTurns.Add(1)) + turnRequestModel, turnUpstreamModel := usageMeta.turnModels(turn.RequestModel) turnResult := &OpenAIForwardResult{ RequestID: turn.RequestID, Usage: OpenAIUsage{ @@ -1008,7 +1103,8 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( CacheReadInputTokens: turn.Usage.CacheReadInputTokens, ImageOutputTokens: turn.Usage.ImageOutputTokens, }, - Model: turn.RequestModel, + Model: turnRequestModel, + UpstreamModel: openAIWSDifferentModel(turnRequestModel, turnUpstreamModel), ServiceTier: usageMeta.serviceTier.Load(), ReasoningEffort: usageMeta.reasoningEffort.Load(), Stream: true, @@ -1019,11 +1115,13 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( FirstTokenMs: turn.FirstTokenMs, } logOpenAIWSV2Passthrough( - "relay_turn_completed account_id=%d turn=%d request_id=%s terminal_event=%s duration_ms=%d first_token_ms=%d input_tokens=%d output_tokens=%d cache_read_tokens=%d", + "relay_turn_completed account_id=%d turn=%d request_id=%s terminal_event=%s turn_requested_model=%s turn_upstream_model=%s duration_ms=%d first_token_ms=%d input_tokens=%d output_tokens=%d cache_read_tokens=%d", account.ID, turnNo, truncateOpenAIWSLogValue(turnResult.RequestID, openAIWSIDValueMaxLen), truncateOpenAIWSLogValue(turn.TerminalEventType, openAIWSLogValueMaxLen), + truncateOpenAIWSLogValue(turnRequestModel, openAIWSLogValueMaxLen), + truncateOpenAIWSLogValue(turnUpstreamModel, openAIWSLogValueMaxLen), turnResult.Duration.Milliseconds(), openAIWSFirstTokenMsForLog(turnResult.FirstTokenMs), turnResult.Usage.InputTokens, @@ -1114,6 +1212,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( return NewOpenAIWSClientCloseError(status, reason, cause) } + resultRequestModel, resultUpstreamModel := usageMeta.turnModels(relayResult.RequestModel) result := &OpenAIForwardResult{ RequestID: relayResult.RequestID, Usage: OpenAIUsage{ @@ -1123,7 +1222,8 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( CacheReadInputTokens: relayResult.Usage.CacheReadInputTokens, ImageOutputTokens: relayResult.Usage.ImageOutputTokens, }, - Model: relayResult.RequestModel, + Model: resultRequestModel, + UpstreamModel: openAIWSDifferentModel(resultRequestModel, resultUpstreamModel), ServiceTier: usageMeta.serviceTier.Load(), ReasoningEffort: usageMeta.reasoningEffort.Load(), Stream: true, From 56b5f0df680fa3d1a1afb116aa3874f7ffa0c68e Mon Sep 17 00:00:00 2001 From: wucm667 Date: Sun, 26 Jul 2026 14:33:59 +0800 Subject: [PATCH 16/26] fix(security-audit): reject unavailable prompt config --- .../internal/securityaudit/prompt_config.go | 2 +- .../securityaudit/prompt_config_store.go | 8 +-- .../securityaudit/prompt_config_test.go | 63 +++++++++++++++++++ .../internal/securityaudit/prompt_handler.go | 11 +++- .../securityaudit/prompt_handler_test.go | 19 +++++- .../internal/securityaudit/prompt_service.go | 2 +- .../internal/securityaudit/prompt_types.go | 11 ++-- .../securityaudit/prompt_worker_test.go | 2 +- 8 files changed, 103 insertions(+), 15 deletions(-) diff --git a/backend/internal/securityaudit/prompt_config.go b/backend/internal/securityaudit/prompt_config.go index 3ad690d4d..e7238b517 100644 --- a/backend/internal/securityaudit/prompt_config.go +++ b/backend/internal/securityaudit/prompt_config.go @@ -44,7 +44,7 @@ type ConfigStore interface { // It must stay false when blocking is not intended, even if config is // untrusted—otherwise default-off deployments fail closed for all traffic. BlockingActivationDegraded() bool - Public() PublicConfig + Public() (PublicConfig, error) Save(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) RuntimeState() (expected int64, active int64, loadedAt *time.Time, loadError string) Encrypt(value string) (string, error) diff --git a/backend/internal/securityaudit/prompt_config_store.go b/backend/internal/securityaudit/prompt_config_store.go index 7bc70f4c0..70d17da94 100644 --- a/backend/internal/securityaudit/prompt_config_store.go +++ b/backend/internal/securityaudit/prompt_config_store.go @@ -194,15 +194,15 @@ func (m *ConfigManager) markUntrustedIfNoActiveSnapshot() { } } -func (m *ConfigManager) Public() PublicConfig { +func (m *ConfigManager) Public() (PublicConfig, error) { if m == nil { - return PublicFromStorage(DefaultStorageConfig(), false) + return PublicConfig{}, infraerrors.ServiceUnavailable(ErrorCodeConfigUnavailable, "提示词审计配置暂不可用") } snapshot := m.snapshot.Load() if snapshot == nil { - return PublicFromStorage(DefaultStorageConfig(), false) + return PublicConfig{}, infraerrors.ServiceUnavailable(ErrorCodeConfigUnavailable, "提示词审计配置暂不可用") } - return PublicFromStorage(cloneStorageConfig(snapshot.storage), snapshot.active.RiskControlEnabled) + return PublicFromStorage(cloneStorageConfig(snapshot.storage), snapshot.active.RiskControlEnabled), nil } func (m *ConfigManager) Save(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) { diff --git a/backend/internal/securityaudit/prompt_config_test.go b/backend/internal/securityaudit/prompt_config_test.go index 22ea50a35..b71fca1d5 100644 --- a/backend/internal/securityaudit/prompt_config_test.go +++ b/backend/internal/securityaudit/prompt_config_test.go @@ -56,6 +56,57 @@ func TestConfigRuntimeLoadErrorIsStableBoundedAndSecretFree(t *testing.T) { require.LessOrEqual(t, len([]rune(message)), 160) } +func TestConfigManagerPublicRequiresSuccessfullyLoadedSnapshot(t *testing.T) { + t.Run("absent persisted setting is legitimate default", func(t *testing.T) { + manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{ + SettingKeyPromptAuditConfig: "", + SettingKeyRiskControl: "false", + }}, nil, prefixEncryptor{}) + require.NoError(t, manager.Reload(context.Background())) + + public, err := manager.Public() + require.NoError(t, err) + require.Equal(t, int64(1), public.ConfigVersion) + require.False(t, public.Enabled) + }) + + t.Run("persisted config activation failure is unavailable", func(t *testing.T) { + const canary = "persisted-token-canary" + manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{ + SettingKeyPromptAuditConfig: `{"enabled":true,"config_version":9,"endpoints":[{"token_ciphertext":"` + canary + `"}]}`, + SettingKeyRiskControl: "true", + }}, nil, prefixEncryptor{}) + require.Error(t, manager.Reload(context.Background())) + + public, err := manager.Public() + require.Error(t, err) + require.Empty(t, public) + require.Equal(t, ErrorCodeConfigUnavailable, infraerrors.Reason(err)) + require.NotContains(t, err.Error(), canary) + }) + + t.Run("reload failure preserves last successfully loaded snapshot", func(t *testing.T) { + storage := DefaultStorageConfig() + storage.ConfigVersion = 4 + storage.ChangeSummary = "trusted snapshot" + raw, err := json.Marshal(storage) + require.NoError(t, err) + repository := &switchableSettingRepository{staticSettingRepository: staticSettingRepository{values: map[string]string{ + SettingKeyPromptAuditConfig: string(raw), + SettingKeyRiskControl: "false", + }}} + manager := NewConfigManager(nil, repository, nil, prefixEncryptor{}) + require.NoError(t, manager.Reload(context.Background())) + repository.loadErr = errors.New("settings unavailable") + require.Error(t, manager.Reload(context.Background())) + + public, err := manager.Public() + require.NoError(t, err) + require.Equal(t, int64(4), public.ConfigVersion) + require.Equal(t, "trusted snapshot", public.ChangeSummary) + }) +} + func TestBuildNextStoragePreserveReplaceAndClearToken(t *testing.T) { manager := &ConfigManager{encryptor: prefixEncryptor{}} current := DefaultStorageConfig() @@ -137,6 +188,18 @@ func (errorSettingRepository) GetMultiple(context.Context, []string) (map[string return nil, errors.New("settings unavailable") } +type switchableSettingRepository struct { + staticSettingRepository + loadErr error +} + +func (r *switchableSettingRepository) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { + if r.loadErr != nil { + return nil, r.loadErr + } + return r.staticSettingRepository.GetMultiple(ctx, keys) +} + func TestConfigManagerStartupLoadFailureDoesNotBlockWhenBlockingNotIntended(t *testing.T) { // Settings unavailable and no prior blocking intent: stay ModeOff so the // gateway remains usable and admins can still disable/configure Prompt Audit. diff --git a/backend/internal/securityaudit/prompt_handler.go b/backend/internal/securityaudit/prompt_handler.go index b2d5a51a6..24284aa54 100644 --- a/backend/internal/securityaudit/prompt_handler.go +++ b/backend/internal/securityaudit/prompt_handler.go @@ -13,7 +13,7 @@ import ( ) type PromptAdminService interface { - GetConfig() PublicConfig + GetConfig() (PublicConfig, error) SaveConfig(context.Context, UpdateConfigRequest, int64) (PublicConfig, error) Probe(context.Context, ProbeRequest) ProbeResult Runtime(context.Context) RuntimeSnapshot @@ -31,7 +31,14 @@ func NewPromptAdminHandler(service PromptAdminService) *PromptAdminHandler { return &PromptAdminHandler{service: service} } -func (h *PromptAdminHandler) GetConfig(c *gin.Context) { response.Success(c, h.service.GetConfig()) } +func (h *PromptAdminHandler) GetConfig(c *gin.Context) { + config, err := h.service.GetConfig() + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, config) +} func (h *PromptAdminHandler) UpdateConfig(c *gin.Context) { var request UpdateConfigRequest diff --git a/backend/internal/securityaudit/prompt_handler_test.go b/backend/internal/securityaudit/prompt_handler_test.go index 51a4305f3..8561ad93e 100644 --- a/backend/internal/securityaudit/prompt_handler_test.go +++ b/backend/internal/securityaudit/prompt_handler_test.go @@ -29,7 +29,9 @@ type fakePromptAdminService struct { deleteFilter func(context.Context, DeleteByFilterRequest, int64) (*DeleteResult, error) } -func (s *fakePromptAdminService) GetConfig() PublicConfig { return s.config } +func (s *fakePromptAdminService) GetConfig() (PublicConfig, error) { + return s.config, nil +} func (s *fakePromptAdminService) SaveConfig(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) { if s.save == nil { return PublicConfig{}, errors.New("unexpected SaveConfig call") @@ -156,6 +158,21 @@ func TestPromptAdminConfigRequiresVersionMapsConflictAndNeverEchoesToken(t *test }) } +func TestPromptAdminGetConfigReturnsSecretFreeUnavailableError(t *testing.T) { + const canary = "persisted-config-secret-canary" + repository := &switchableSettingRepository{loadErr: errors.New("failed to load token " + canary)} + manager := NewConfigManager(nil, repository, nil, prefixEncryptor{}) + require.Error(t, manager.Reload(context.Background())) + service := &PromptService{config: manager} + + response := promptAdminRequest(t, promptAdminRouter(service), http.MethodGet, "/admin/prompt-audit/config", nil) + require.Equal(t, http.StatusServiceUnavailable, response.Code) + require.Contains(t, response.Body.String(), ErrorCodeConfigUnavailable) + require.NotContains(t, response.Body.String(), canary) + require.NotContains(t, response.Body.String(), `"config_version"`) + require.NotContains(t, response.Body.String(), `"token"`) +} + func TestPromptAdminProbeSupportsTemporaryOrSavedTokenWithoutEcho(t *testing.T) { const canary = "probe-token-canary" for _, tc := range []struct { diff --git a/backend/internal/securityaudit/prompt_service.go b/backend/internal/securityaudit/prompt_service.go index 71d0fadbe..2d9d6605e 100644 --- a/backend/internal/securityaudit/prompt_service.go +++ b/backend/internal/securityaudit/prompt_service.go @@ -166,7 +166,7 @@ func (s *PromptService) Evaluate(ctx context.Context, req Request) (*PromptDecis return s.evaluator.Evaluate(ctx, cfg, snapshot) } -func (s *PromptService) GetConfig() PublicConfig { return s.config.Public() } +func (s *PromptService) GetConfig() (PublicConfig, error) { return s.config.Public() } func (s *PromptService) SaveConfig(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) { return s.config.Save(ctx, req, actorID) diff --git a/backend/internal/securityaudit/prompt_types.go b/backend/internal/securityaudit/prompt_types.go index 34611444e..7b25c33bd 100644 --- a/backend/internal/securityaudit/prompt_types.go +++ b/backend/internal/securityaudit/prompt_types.go @@ -12,11 +12,12 @@ const ( ConfigInvalidationChannel = "sub2api:prompt_guard:config:invalidate" PayloadKeyPrefix = "sub2api:prompt_audit:payload:" - ErrorCodeBlocked = "prompt_guard_blocked" - ErrorCodeUnavailable = "prompt_guard_unavailable" - ErrorCodeInvalidResponse = "prompt_guard_invalid_response" - ErrorCodeConfigConflict = "prompt_audit_config_conflict" - ErrorCodeRequiresEnabled = "prompt_guard_requires_audit_enabled" + ErrorCodeBlocked = "prompt_guard_blocked" + ErrorCodeUnavailable = "prompt_guard_unavailable" + ErrorCodeInvalidResponse = "prompt_guard_invalid_response" + ErrorCodeConfigConflict = "prompt_audit_config_conflict" + ErrorCodeConfigUnavailable = "prompt_audit_config_unavailable" + ErrorCodeRequiresEnabled = "prompt_guard_requires_audit_enabled" DefaultGuardModel = "sileader/qwen3guard:0.6b" ) diff --git a/backend/internal/securityaudit/prompt_worker_test.go b/backend/internal/securityaudit/prompt_worker_test.go index 5c39be8e4..a91569c2c 100644 --- a/backend/internal/securityaudit/prompt_worker_test.go +++ b/backend/internal/securityaudit/prompt_worker_test.go @@ -48,7 +48,7 @@ func (s *fakeConfigStore) EffectiveMode() Mode { return s.cfg.EffectiveMode() } func (s *fakeConfigStore) BlockingActivationDegraded() bool { return false } -func (s *fakeConfigStore) Public() PublicConfig { return PublicConfig{} } +func (s *fakeConfigStore) Public() (PublicConfig, error) { return PublicConfig{}, nil } func (s *fakeConfigStore) Save(context.Context, UpdateConfigRequest, int64) (PublicConfig, error) { return PublicConfig{}, nil } From 16dd3d8ee65a1b644682833a658c68517e60e7ae Mon Sep 17 00:00:00 2001 From: wucm667 Date: Sun, 26 Jul 2026 16:26:25 +0800 Subject: [PATCH 17/26] fix(frontend): adapt available channels for mobile --- .../channels/AvailableChannelsTable.vue | 150 +++++++++++++++- .../__tests__/AvailableChannelsTable.spec.ts | 161 +++++++++++++++++- 2 files changed, 309 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/channels/AvailableChannelsTable.vue b/frontend/src/components/channels/AvailableChannelsTable.vue index 2de45c6a5..23130d3fd 100644 --- a/frontend/src/components/channels/AvailableChannelsTable.vue +++ b/frontend/src/components/channels/AvailableChannelsTable.vue @@ -2,7 +2,10 @@
- +
@@ -168,6 +171,151 @@
{{ columns.name }}
+ +
+
+ +
+
+ +

{{ emptyLabel }}

+
+
+
+

+ {{ channel.name }} +

+

+ {{ channel.description || '-' }} +

+
+ +
+
+ + + {{ section.platform }} + + +
+
+
+ {{ columns.groups }} +
+
+
+ + + {{ t('availableChannels.exclusive') }} + +
+ + + + {{ peakRateLabel(g) }} + +
+
+
+ + + {{ t('availableChannels.public') }} + +
+ + + + {{ peakRateLabel(g) }} + +
+
+ - +
+
+ +
+
+ {{ columns.supportedModels }} +
+
+ + + {{ noModelsLabel }} + +
+
+
+
+
+
+
diff --git a/frontend/src/components/channels/__tests__/AvailableChannelsTable.spec.ts b/frontend/src/components/channels/__tests__/AvailableChannelsTable.spec.ts index ecadecf56..82f8c2087 100644 --- a/frontend/src/components/channels/__tests__/AvailableChannelsTable.spec.ts +++ b/frontend/src/components/channels/__tests__/AvailableChannelsTable.spec.ts @@ -2,7 +2,19 @@ import { readFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' +import { createPinia } from 'pinia' +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import AvailableChannelsTable from '../AvailableChannelsTable.vue' +import type { UserAvailableChannel } from '@/api/channels' + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ t: (key: string) => key }), + } +}) const componentPath = resolve(dirname(fileURLToPath(import.meta.url)), '../AvailableChannelsTable.vue') const componentSource = readFileSync(componentPath, 'utf8') @@ -18,3 +30,150 @@ describe('AvailableChannelsTable scroll integration', () => { expect(componentSource).not.toMatch(/
/) }) }) + +const rows: UserAvailableChannel[] = [ + { + name: 'Primary channel', + description: 'Fast and reliable access', + platforms: [ + { + platform: 'anthropic', + groups: [ + { + id: 1, + name: 'Exclusive Pro', + platform: 'anthropic', + subscription_type: 'standard', + rate_multiplier: 1.2, + peak_rate_enabled: true, + peak_start: '08:00', + peak_end: '10:00', + peak_rate_multiplier: 1.5, + is_exclusive: true, + }, + { + id: 2, + name: 'Public', + platform: 'anthropic', + subscription_type: 'standard', + rate_multiplier: 1, + peak_rate_enabled: false, + peak_start: '', + peak_end: '', + peak_rate_multiplier: 1, + is_exclusive: false, + }, + ], + supported_models: [{ name: 'claude-test', platform: 'anthropic', pricing: null }], + }, + ], + }, +] + +const baseProps = { + columns: { + name: 'Channel', + description: 'Description', + platform: 'Platform', + groups: 'Groups and rates', + supportedModels: 'Models and pricing', + }, + rows, + loading: false, + pricingKeyPrefix: 'availableChannels.pricing', + noPricingLabel: 'No pricing', + noModelsLabel: 'No models', + emptyLabel: 'No channels', + userGroupRates: { 1: 0.8 }, +} + +function mountTable(props = {}) { + return mount(AvailableChannelsTable, { + props: { ...baseProps, ...props }, + global: { + plugins: [createPinia()], + stubs: { + Icon: { props: ['name'], template: '' }, + PlatformIcon: { template: '' }, + GroupBadge: { + props: ['name', 'rateMultiplier', 'userRateMultiplier'], + template: + '{{ name }}:{{ rateMultiplier }}:{{ userRateMultiplier }}', + }, + SupportedModelChip: { + props: ['model', 'noPricingLabel'], + template: '{{ model.name }}:{{ noPricingLabel }}', + }, + }, + }, + }) +} + +describe('AvailableChannelsTable responsive surfaces', () => { + it('keeps the five-column table as the desktop-only surface', () => { + const wrapper = mountTable() + const desktop = wrapper.get('[data-testid="desktop-channels"]') + + // TablePageLayout has a more-specific `display: table` rule for descendants, + // so these display utilities must remain important at both breakpoints. + expect(desktop.classes()).toContain('!hidden') + expect(desktop.classes()).toContain('lg:!table') + expect(desktop.findAll('thead th')).toHaveLength(5) + expect(desktop.text()).toContain('Primary channel') + expect(desktop.text()).toContain('Fast and reliable access') + expect(desktop.findAll('[data-group-badge]')).toHaveLength(2) + expect(desktop.get('[data-model-chip]').text()).toContain('claude-test:No pricing') + }) + + it('renders a mobile-only readable surface with groups, rates, peaks, and model pricing chips', () => { + const wrapper = mountTable() + const mobile = wrapper.get('[data-testid="mobile-channels"]') + + expect(mobile.classes()).toContain('lg:hidden') + expect(mobile.classes()).toContain('overflow-x-hidden') + expect(mobile.text()).toContain('Primary channel') + expect(mobile.text()).toContain('Fast and reliable access') + expect(mobile.text()).toContain('Groups and rates') + expect(mobile.text()).toContain('Models and pricing') + expect(mobile.text()).toContain('availableChannels.exclusive') + expect(mobile.text()).toContain('availableChannels.public') + expect(mobile.get('[data-group-badge]').text()).toBe('Exclusive Pro:1.2:0.8') + expect(mobile.findAll('[data-group-badge]')).toHaveLength(2) + expect(mobile.get('[data-icon="clock"]')).toBeTruthy() + expect(mobile.text()).toContain('08:00') + expect(mobile.text()).toContain('10:00') + expect(mobile.text()).toContain('×1.5') + expect(mobile.get('[data-model-chip]').text()).toBe('claude-test:No pricing') + expect(mobile.findAll('.max-w-full')).not.toHaveLength(0) + }) + + it('keeps the mobile placeholders when a platform has no groups or models', () => { + const wrapper = mountTable({ + rows: [ + { + name: 'Fallback channel', + description: '', + platforms: [{ platform: 'openai', groups: [], supported_models: [] }], + }, + ], + }) + const mobile = wrapper.get('[data-testid="mobile-channels"]') + + expect(mobile.text()).toContain('Fallback channel') + expect(mobile.text()).toContain('openai') + expect(mobile.text()).toContain('No models') + expect(mobile.findAll('dd')[0].text()).toBe('-') + }) + + it('provides loading and empty states on both responsive surfaces', async () => { + const wrapper = mountTable({ loading: true, rows: [] }) + + expect(wrapper.get('[data-testid="desktop-channels"] [data-icon="refresh"]')).toBeTruthy() + expect(wrapper.get('[data-testid="mobile-loading"] [data-icon="refresh"]')).toBeTruthy() + + await wrapper.setProps({ loading: false }) + + expect(wrapper.get('[data-testid="desktop-channels"]').text()).toContain('No channels') + expect(wrapper.get('[data-testid="mobile-empty"]').text()).toContain('No channels') + }) +}) From 7af28ca84382aebdccc21e7a9c53d797c8097605 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Sun, 26 Jul 2026 19:20:11 +0800 Subject: [PATCH 18/26] =?UTF-8?q?fix(claude):=20=E4=BC=AA=E8=A3=85?= =?UTF-8?q?=E7=9A=84=20Claude=20Code=20CLI=20=E7=89=88=E6=9C=AC=E5=8F=B7?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E5=88=B0=202.1.220?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/pkg/claude/constants.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index 310f02788..0e8a05d36 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -66,7 +66,7 @@ const DefaultCacheControlTTL = "5m" // CLICurrentVersion 是 sub2api 当前对外伪装的 Claude Code CLI 版本号(三段 semver)。 // 用于 billing attribution block 中的 cc_version=X.Y.Z.{fp} 前缀以及 fingerprint 计算。 // 必须与 DefaultHeaders["User-Agent"] 中的版本号严格一致;不一致会被 Anthropic 判第三方。 -const CLICurrentVersion = "2.1.161" +const CLICurrentVersion = "2.1.220" // FullClaudeCodeMimicryBetas 返回最"像"真实 Claude Code CLI 的完整 beta 列表, // 用于 OAuth 账号伪装成 Claude Code 时使用。 From 2db0cbd292a8e0ec1067d98a1e880b049fe1df82 Mon Sep 17 00:00:00 2001 From: yan9651688 <174289968+yan9651688@users.noreply.github.com> Date: Sun, 26 Jul 2026 21:04:18 +0800 Subject: [PATCH 19/26] fix(grok): pause accounts after manual test payment failure Manual Grok connection tests previously surfaced upstream HTTP 402 errors without changing account availability. Persist the same 30-minute payment-required cooldown used by the live forwarding path so refreshed account lists no longer present the account as schedulable. Constraint: Keep manual-test HTTP 402 handling aligned with existing Grok forwarding semantics. Rejected: Mark the account permanently error | payment state can recover and the forwarding path intentionally uses a bounded cooldown. Confidence: high Scope-risk: narrow Directive: Keep the manual-test cooldown reason and duration aligned with handleGrokAccountUpstreamError. Tested: go test -tags=unit ./internal/service -count=1; go vet -tags=unit ./internal/service; production package compile check Not-tested: Live xAI account with an exhausted subscription Related: #4794 --- .../internal/service/account_test_service.go | 10 ++++++ .../service/openai_gateway_grok_test.go | 31 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 29f611450..3200174b2 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -794,6 +794,16 @@ func (s *AccountTestService) testGrokAccountConnection(c *gin.Context, account * if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) + if resp.StatusCode == http.StatusPaymentRequired && s.accountRepo != nil { + stateCtx, cancel := openAIAccountStateContext(ctx) + defer cancel() + _ = s.accountRepo.SetTempUnschedulable( + stateCtx, + account.ID, + time.Now().Add(30*time.Minute), + "grok payment required", + ) + } return s.sendErrorAndEnd(c, fmt.Sprintf("Grok Responses API returned %d: %s", resp.StatusCode, string(body))) } diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index d13cfa19f..09a0df401 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -2006,6 +2006,37 @@ func TestAccountTestServiceGrokAPIKeyAllowsConfiguredHTTPWhenGlobalPolicyDoes(t require.Contains(t, recorder.Body.String(), `"type":"test_complete"`) } +func TestAccountTestServiceGrokOAuthPaymentRequiredTemporarilyUnschedulesAccount(t *testing.T) { + gin.SetMode(gin.TestMode) + + account := healthyGrokOAuthGatewayTestAccount(56, "access-token") + repo := &grokQuotaAccountRepo{} + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusPaymentRequired, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"code":"personal-team-blocked:spending-limit"}`)), + }} + svc := &AccountTestService{ + accountRepo: repo, + grokTokenProvider: NewGrokTokenProvider(repo, nil), + httpUpstream: upstream, + } + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/56/test", nil) + before := time.Now() + + err := svc.testGrokAccountConnection(c, account, "grok") + + require.Error(t, err) + require.Equal(t, 1, repo.tempUnschedCalls) + require.Equal(t, account.ID, repo.lastTempUnschedID) + require.Equal(t, "grok payment required", repo.lastTempUnschedReason) + require.WithinDuration(t, before.Add(30*time.Minute), repo.lastTempUnschedUntil, time.Second) + require.Contains(t, recorder.Body.String(), `"type":"error"`) + require.Contains(t, recorder.Body.String(), "Grok Responses API returned 402") +} + func TestForwardAsChatCompletionsForGrokStreamingUsesRawXAIChatCompletions(t *testing.T) { gin.SetMode(gin.TestMode) From 1f45c99de7d2230f056d69c2e504d35d6bf59c9b Mon Sep 17 00:00:00 2001 From: visa2 <1927401+visa2@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:56:34 +0800 Subject: [PATCH 20/26] fix(usage): correct mapped model statistics --- .../usage_log_repo_breakdown_test.go | 4 +-- .../repository/usage_log_repo_trend.go | 5 +-- .../service/gateway_record_usage_test.go | 29 ++++++++++++++++ .../internal/service/gateway_usage_billing.go | 2 +- .../openai_gateway_record_usage_test.go | 34 +++++++++++++++++++ .../internal/service/openai_gateway_usage.go | 2 +- backend/internal/service/usage_log_helpers.go | 4 +-- 7 files changed, 72 insertions(+), 8 deletions(-) diff --git a/backend/internal/repository/usage_log_repo_breakdown_test.go b/backend/internal/repository/usage_log_repo_breakdown_test.go index 1fb99b0d6..69f50bbad 100644 --- a/backend/internal/repository/usage_log_repo_breakdown_test.go +++ b/backend/internal/repository/usage_log_repo_breakdown_test.go @@ -40,8 +40,8 @@ func TestResolveModelDimensionExpression(t *testing.T) { want string }{ {usagestats.ModelSourceRequested, "COALESCE(NULLIF(TRIM(requested_model), ''), model)"}, - {usagestats.ModelSourceUpstream, "COALESCE(NULLIF(TRIM(upstream_model), ''), COALESCE(NULLIF(TRIM(requested_model), ''), model))"}, - {usagestats.ModelSourceMapping, "(COALESCE(NULLIF(TRIM(requested_model), ''), model) || ' -> ' || COALESCE(NULLIF(TRIM(upstream_model), ''), COALESCE(NULLIF(TRIM(requested_model), ''), model)))"}, + {usagestats.ModelSourceUpstream, "COALESCE(NULLIF(TRIM(upstream_model), ''), model)"}, + {usagestats.ModelSourceMapping, "(COALESCE(NULLIF(TRIM(requested_model), ''), model) || ' -> ' || COALESCE(NULLIF(TRIM(upstream_model), ''), model))"}, {"", "COALESCE(NULLIF(TRIM(requested_model), ''), model)"}, {"invalid", "COALESCE(NULLIF(TRIM(requested_model), ''), model)"}, } diff --git a/backend/internal/repository/usage_log_repo_trend.go b/backend/internal/repository/usage_log_repo_trend.go index f2e0045ff..ae431c706 100644 --- a/backend/internal/repository/usage_log_repo_trend.go +++ b/backend/internal/repository/usage_log_repo_trend.go @@ -750,11 +750,12 @@ func resolveModelDimensionExpressionWithAlias(modelType, alias string) string { return alias + "." + name } requestedExpr := fmt.Sprintf("COALESCE(NULLIF(TRIM(%s), ''), %s)", column("requested_model"), column("model")) + upstreamExpr := fmt.Sprintf("COALESCE(NULLIF(TRIM(%s), ''), %s)", column("upstream_model"), column("model")) switch usagestats.NormalizeModelSource(modelType) { case usagestats.ModelSourceUpstream: - return fmt.Sprintf("COALESCE(NULLIF(TRIM(%s), ''), %s)", column("upstream_model"), requestedExpr) + return upstreamExpr case usagestats.ModelSourceMapping: - return fmt.Sprintf("(%s || ' -> ' || COALESCE(NULLIF(TRIM(%s), ''), %s))", requestedExpr, column("upstream_model"), requestedExpr) + return fmt.Sprintf("(%s || ' -> ' || %s)", requestedExpr, upstreamExpr) default: return requestedExpr } diff --git a/backend/internal/service/gateway_record_usage_test.go b/backend/internal/service/gateway_record_usage_test.go index 28963a430..df4b7eae5 100644 --- a/backend/internal/service/gateway_record_usage_test.go +++ b/backend/internal/service/gateway_record_usage_test.go @@ -194,6 +194,35 @@ func TestGatewayServiceRecordUsage_PreservesRequestedAndUpstreamModels(t *testin require.Equal(t, mappedModel, *usageRepo.lastLog.UpstreamModel) } +func TestGatewayServiceRecordUsage_PreservesChannelMappedUpstreamModel(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newGatewayRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}) + + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gateway_channel_mapping_models", + Usage: ClaudeUsage{InputTokens: 10, OutputTokens: 6}, + Model: "gpt-5.6-terra", + UpstreamModel: "gpt-5.6-terra", + Duration: time.Second, + }, + APIKey: &APIKey{ID: 501, Quota: 100}, + User: &User{ID: 601}, + Account: &Account{ID: 701}, + ChannelUsageFields: ChannelUsageFields{ + OriginalModel: "gpt-5.6-sol", + ChannelMappedModel: "gpt-5.6-terra", + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, "gpt-5.6-sol", usageRepo.lastLog.RequestedModel) + require.Equal(t, "gpt-5.6-terra", usageRepo.lastLog.Model) + require.NotNil(t, usageRepo.lastLog.UpstreamModel) + require.Equal(t, "gpt-5.6-terra", *usageRepo.lastLog.UpstreamModel) +} + func TestGatewayServiceRecordUsage_EmptyImageSizeDefaultsBeforeBillingAndPersistence(t *testing.T) { imagePrice2K := 0.19 groupID := int64(901) diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index f445814d0..656b84821 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -986,7 +986,7 @@ func (s *GatewayService) buildRecordUsageLog( RequestID: requestID, Model: result.Model, RequestedModel: requestedModel, - UpstreamModel: optionalNonEqualStringPtr(result.UpstreamModel, result.Model), + UpstreamModel: optionalNonEqualStringPtr(result.UpstreamModel, requestedModel), ReasoningEffort: result.ReasoningEffort, InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint), UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint), diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index d2eca05d7..fe35f4b52 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -1335,6 +1335,40 @@ func TestOpenAIGatewayServiceRecordUsage_UsesRequestedModelAndUpstreamModelMetad require.Equal(t, 1, userRepo.deductCalls) } +func TestOpenAIGatewayServiceRecordUsage_PreservesChannelMappedUpstreamModel(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + subRepo := &openAIRecordUsageSubRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "openai_channel_mapping_models", + Model: "gpt-5.6-terra", + UpstreamModel: "gpt-5.6-terra", + Usage: OpenAIUsage{ + InputTokens: 20, + OutputTokens: 10, + }, + Duration: time.Second, + }, + APIKey: &APIKey{ID: 10}, + User: &User{ID: 20}, + Account: &Account{ID: 30}, + ChannelUsageFields: ChannelUsageFields{ + OriginalModel: "gpt-5.6-sol", + ChannelMappedModel: "gpt-5.6-terra", + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, "gpt-5.6-sol", usageRepo.lastLog.RequestedModel) + require.Equal(t, "gpt-5.6-terra", usageRepo.lastLog.Model) + require.NotNil(t, usageRepo.lastLog.UpstreamModel) + require.Equal(t, "gpt-5.6-terra", *usageRepo.lastLog.UpstreamModel) +} + func TestOpenAIGatewayServiceRecordUsage_BillsMappedRequestsUsingRequestedModel(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 16429f740..9bb9e6298 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -256,7 +256,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec RequestID: requestID, Model: result.Model, RequestedModel: requestedModel, - UpstreamModel: optionalNonEqualStringPtr(result.UpstreamModel, result.Model), + UpstreamModel: optionalNonEqualStringPtr(result.UpstreamModel, requestedModel), ServiceTier: result.ServiceTier, ReasoningEffort: result.ReasoningEffort, InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint), diff --git a/backend/internal/service/usage_log_helpers.go b/backend/internal/service/usage_log_helpers.go index 7cc8a713c..32423605b 100644 --- a/backend/internal/service/usage_log_helpers.go +++ b/backend/internal/service/usage_log_helpers.go @@ -11,8 +11,8 @@ func optionalTrimmedStringPtr(raw string) *string { } // optionalNonEqualStringPtr returns a pointer to value if it is non-empty and -// differs from compare; otherwise nil. Used to store upstream_model only when -// it differs from the requested model. +// differs from compare; otherwise nil. Usage logging passes the requested +// model as compare so a channel mapping still records its effective upstream. func optionalNonEqualStringPtr(value, compare string) *string { if value == "" || value == compare { return nil From be65c713ff262a4c7813811ebfdb3cff86d95fb2 Mon Sep 17 00:00:00 2001 From: visa2 <1927401+visa2@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:43:53 +0800 Subject: [PATCH 21/26] fix(usage): preserve final upstream model --- .../service/gateway_record_usage_test.go | 29 +++++++++++++++++ .../internal/service/gateway_usage_billing.go | 2 +- .../openai_gateway_record_usage_test.go | 31 +++++++++++++++++++ .../internal/service/openai_gateway_usage.go | 2 +- backend/internal/service/usage_log_helpers.go | 10 ------ 5 files changed, 62 insertions(+), 12 deletions(-) diff --git a/backend/internal/service/gateway_record_usage_test.go b/backend/internal/service/gateway_record_usage_test.go index df4b7eae5..81aff5827 100644 --- a/backend/internal/service/gateway_record_usage_test.go +++ b/backend/internal/service/gateway_record_usage_test.go @@ -223,6 +223,35 @@ func TestGatewayServiceRecordUsage_PreservesChannelMappedUpstreamModel(t *testin require.Equal(t, "gpt-5.6-terra", *usageRepo.lastLog.UpstreamModel) } +func TestGatewayServiceRecordUsage_PreservesLoopedChannelAndAccountUpstreamModel(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newGatewayRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}) + + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gateway_looped_mapping_models", + Usage: ClaudeUsage{InputTokens: 10, OutputTokens: 6}, + Model: "gpt-5.6-terra", + UpstreamModel: "gpt-5.6-sol", + Duration: time.Second, + }, + APIKey: &APIKey{ID: 501, Quota: 100}, + User: &User{ID: 601}, + Account: &Account{ID: 701}, + ChannelUsageFields: ChannelUsageFields{ + OriginalModel: "gpt-5.6-sol", + ChannelMappedModel: "gpt-5.6-terra", + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, "gpt-5.6-sol", usageRepo.lastLog.RequestedModel) + require.Equal(t, "gpt-5.6-terra", usageRepo.lastLog.Model) + require.NotNil(t, usageRepo.lastLog.UpstreamModel) + require.Equal(t, "gpt-5.6-sol", *usageRepo.lastLog.UpstreamModel) +} + func TestGatewayServiceRecordUsage_EmptyImageSizeDefaultsBeforeBillingAndPersistence(t *testing.T) { imagePrice2K := 0.19 groupID := int64(901) diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 656b84821..51b8031fc 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -986,7 +986,7 @@ func (s *GatewayService) buildRecordUsageLog( RequestID: requestID, Model: result.Model, RequestedModel: requestedModel, - UpstreamModel: optionalNonEqualStringPtr(result.UpstreamModel, requestedModel), + UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel), ReasoningEffort: result.ReasoningEffort, InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint), UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint), diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index fe35f4b52..f0dc78b74 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -1369,6 +1369,37 @@ func TestOpenAIGatewayServiceRecordUsage_PreservesChannelMappedUpstreamModel(t * require.Equal(t, "gpt-5.6-terra", *usageRepo.lastLog.UpstreamModel) } +func TestOpenAIGatewayServiceRecordUsage_PreservesLoopedChannelAndAccountUpstreamModel(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + subRepo := &openAIRecordUsageSubRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "openai_looped_mapping_models", + Model: "gpt-5.6-terra", + UpstreamModel: "gpt-5.6-sol", + Usage: OpenAIUsage{InputTokens: 20, OutputTokens: 10}, + Duration: time.Second, + }, + APIKey: &APIKey{ID: 10}, + User: &User{ID: 20}, + Account: &Account{ID: 30}, + ChannelUsageFields: ChannelUsageFields{ + OriginalModel: "gpt-5.6-sol", + ChannelMappedModel: "gpt-5.6-terra", + }, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, "gpt-5.6-sol", usageRepo.lastLog.RequestedModel) + require.Equal(t, "gpt-5.6-terra", usageRepo.lastLog.Model) + require.NotNil(t, usageRepo.lastLog.UpstreamModel) + require.Equal(t, "gpt-5.6-sol", *usageRepo.lastLog.UpstreamModel) +} + func TestOpenAIGatewayServiceRecordUsage_BillsMappedRequestsUsingRequestedModel(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 9bb9e6298..124469833 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -256,7 +256,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec RequestID: requestID, Model: result.Model, RequestedModel: requestedModel, - UpstreamModel: optionalNonEqualStringPtr(result.UpstreamModel, requestedModel), + UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel), ServiceTier: result.ServiceTier, ReasoningEffort: result.ReasoningEffort, InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint), diff --git a/backend/internal/service/usage_log_helpers.go b/backend/internal/service/usage_log_helpers.go index 32423605b..b431b50ae 100644 --- a/backend/internal/service/usage_log_helpers.go +++ b/backend/internal/service/usage_log_helpers.go @@ -10,16 +10,6 @@ func optionalTrimmedStringPtr(raw string) *string { return &trimmed } -// optionalNonEqualStringPtr returns a pointer to value if it is non-empty and -// differs from compare; otherwise nil. Usage logging passes the requested -// model as compare so a channel mapping still records its effective upstream. -func optionalNonEqualStringPtr(value, compare string) *string { - if value == "" || value == compare { - return nil - } - return &value -} - func forwardResultBillingModel(requestedModel, upstreamModel string) string { if trimmed := strings.TrimSpace(requestedModel); trimmed != "" { return trimmed From 7b3ed2a961bb2f10507eda16f4dd5a83bc7af44f Mon Sep 17 00:00:00 2001 From: eyre Date: Sun, 26 Jul 2026 17:54:56 +0000 Subject: [PATCH 22/26] fix(gateway): detect proxied Claude Code traffic by body to preserve prompt cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an upstream API gateway (e.g. new-api) relays real Claude Code requests, the User-Agent becomes Go-http-client while the body retains the full Claude Code fingerprint (billing attribution block + metadata.user_id + cache_control breakpoints). Previously, the OAuth mimicry path relied solely on UA matching to detect Claude Code clients. Without a matching UA, the gateway would rewrite the system prompt — replacing the client's carefully structured system blocks and cache_control breakpoints with its own injection. This breaks Anthropic's prefix-based prompt cache: since the cache key evaluates tools → system → messages in order, a changed system invalidates all downstream message caching. Symptoms observed: - cache_read permanently locked at ~25K (only system prompt cached) - cache_creation growing monotonically every turn (full messages rewrite) - Single-request costs $17-27 instead of normal $1-2 Fix: when UA does not match but the body contains a valid billing attribution block (x-anthropic-billing-header with cc_entrypoint=), treat the request as proxied Claude Code traffic and skip mimicry. This preserves the client's original system structure and cache_control breakpoints, allowing Anthropic's prompt cache to function correctly. --- .../service/gateway_claude_oauth_body.go | 25 +++++++++++++++++++ backend/internal/service/gateway_forward.go | 11 ++++++++ 2 files changed, 36 insertions(+) diff --git a/backend/internal/service/gateway_claude_oauth_body.go b/backend/internal/service/gateway_claude_oauth_body.go index 9fbbc674d..9450ddbb1 100644 --- a/backend/internal/service/gateway_claude_oauth_body.go +++ b/backend/internal/service/gateway_claude_oauth_body.go @@ -1208,3 +1208,28 @@ func (s *GatewayService) claudeOAuthSystemPromptInjectionSettings(ctx context.Co } return s.settingService.GetClaudeOAuthSystemPromptInjectionSettings(ctx) } + +// systemHasBillingAttributionBlock 检查请求体的 system 字段中是否包含真实 Claude Code +// 客户端注入的 billing attribution block。该 block 格式稳定(见 gateway_billing_block.go), +// 仅由真实 Claude Code CLI 生成;第三方客户端(opencode 等)不会生成此 block。 +// +// 用于识别被上游 API 网关代理的真实 Claude Code 流量:此类请求的 User-Agent 被网关替换 +// 为 Go-http-client,但 body 保留了完整的客户端特征。如果不识别这类请求而走 mimicry +// 重写 system,会破坏 Anthropic prompt cache 的前缀一致性,导致 messages 级缓存永不命中。 +func systemHasBillingAttributionBlock(body []byte) bool { + system := gjson.GetBytes(body, "system") + if !system.IsArray() { + return false + } + found := false + system.ForEach(func(_, item gjson.Result) bool { + text := item.Get("text").String() + if strings.HasPrefix(text, claudeCodeBillingHeaderPrefix) && + strings.Contains(text, claudeCodeEntrypointMarker) { + found = true + return false + } + return true + }) + return found +} diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index 3005d93fa..5a78a1981 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -170,6 +170,17 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A clientUserAgent = c.GetHeader("User-Agent") } isClaudeCode := IsClaudeCodeClient(ctx) || isClaudeCodeClient(clientUserAgent, parsed.MetadataUserID) + + // 补充判定:上游 API 网关(如 new-api)转发真实 Claude Code 流量时, + // UA 会变成 Go-http-client 但 body 保留了完整的 Claude Code 特征 + // (billing attribution block + metadata.user_id)。此时如果仍走 mimicry + // 重写 system prompt,会破坏 Anthropic prompt cache 的前缀匹配—— + // 导致 messages 级缓存永远 miss、cache_creation 每轮全量重写。 + // 通过检查 body 中的 billing attribution block 来识别被代理的真实 CC 流量。 + if !isClaudeCode && parsed.MetadataUserID != "" { + isClaudeCode = systemHasBillingAttributionBlock(body) + } + shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode if shouldMimicClaudeCode { From 5c471485ab6626ac84d15196e991a1bccd6fc4f0 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Mon, 27 Jul 2026 06:20:11 +0800 Subject: [PATCH 23/26] fix(config): honor explicit CONFIG_FILE path Make CONFIG_FILE select an explicit config for both full loading and lightweight address lookup, with regression tests. --- backend/internal/config/config.go | 37 ++++++++-------- backend/internal/config/config_test.go | 61 ++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 18 deletions(-) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index a62c068f2..3bd071e9a 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1640,20 +1640,7 @@ func LoadForBootstrap() (*Config, error) { func load(allowMissingJWTSecret bool) (*Config, error) { viper.SetConfigName("config") viper.SetConfigType("yaml") - - // Add config paths in priority order - // 1. DATA_DIR environment variable (highest priority) - if dataDir := os.Getenv("DATA_DIR"); dataDir != "" { - viper.AddConfigPath(dataDir) - } - // 2. Docker data directory - viper.AddConfigPath("/app/data") - // 3. Current directory - viper.AddConfigPath(".") - // 4. Config subdirectory - viper.AddConfigPath("./config") - // 5. System config directory - viper.AddConfigPath("/etc/sub2api") + configureConfigSource(viper.SetConfigFile, viper.AddConfigPath) // 环境变量支持 viper.AutomaticEnv() @@ -1828,6 +1815,22 @@ func load(allowMissingJWTSecret bool) (*Config, error) { return &cfg, nil } +func configureConfigSource(setConfigFile, addConfigPath func(string)) { + if configFile := strings.TrimSpace(os.Getenv("CONFIG_FILE")); configFile != "" { + setConfigFile(configFile) + return + } + + // Add config paths in priority order. + if dataDir := strings.TrimSpace(os.Getenv("DATA_DIR")); dataDir != "" { + addConfigPath(dataDir) + } + addConfigPath("/app/data") + addConfigPath(".") + addConfigPath("./config") + addConfigPath("/etc/sub2api") +} + func setDefaults() { viper.SetDefault("run_mode", RunModeStandard) @@ -3488,14 +3491,12 @@ func generateJWTSecret(byteLength int) (string, error) { // GetServerAddress returns the server address (host:port) from config file or environment variable. // This is a lightweight function that can be used before full config validation, // such as during setup wizard startup. -// Priority: config.yaml > environment variables > defaults +// Priority: environment variables > config file > defaults. func GetServerAddress() string { v := viper.New() v.SetConfigName("config") v.SetConfigType("yaml") - v.AddConfigPath(".") - v.AddConfigPath("./config") - v.AddConfigPath("/etc/sub2api") + configureConfigSource(v.SetConfigFile, v.AddConfigPath) // Support SERVER_HOST and SERVER_PORT environment variables v.AutomaticEnv() diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index e4186732a..f67fbb4cc 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -17,6 +17,9 @@ import ( func resetViperWithJWTSecret(t *testing.T) { t.Helper() viper.Reset() + t.Cleanup(viper.Reset) + t.Setenv("CONFIG_FILE", "") + t.Setenv("DATA_DIR", "") t.Setenv("JWT_SECRET", strings.Repeat("x", 32)) } @@ -253,8 +256,53 @@ func TestLoadTrustedProxiesPresenceFromYAML(t *testing.T) { } } +func TestLoadTrustedProxiesFromConfigFile(t *testing.T) { + resetViperWithJWTSecret(t) + configFile := filepath.Join(t.TempDir(), "reporter-config.yaml") + require.NoError(t, os.WriteFile(configFile, []byte(`server: + host: 192.0.2.10 + trusted_proxies: + - 127.0.0.1/32 + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 +`), 0o600)) + t.Setenv("CONFIG_FILE", configFile) + + cfg, err := Load() + require.NoError(t, err) + require.Equal(t, "192.0.2.10", cfg.Server.Host) + require.Equal(t, []string{"127.0.0.1/32", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"}, cfg.Server.TrustedProxies) + require.True(t, cfg.Server.TrustedProxiesConfigured) +} + +func TestConfigFileTakesPrecedenceOverDataDir(t *testing.T) { + resetViperWithJWTSecret(t) + dataDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dataDir, "config.yaml"), []byte("server:\n host: 192.0.2.20\n"), 0o600)) + configFile := filepath.Join(t.TempDir(), "explicit.yaml") + require.NoError(t, os.WriteFile(configFile, []byte("server:\n host: 192.0.2.30\n"), 0o600)) + t.Setenv("DATA_DIR", dataDir) + t.Setenv("CONFIG_FILE", configFile) + + cfg, err := Load() + require.NoError(t, err) + require.Equal(t, "192.0.2.30", cfg.Server.Host) +} + +func TestLoadReturnsErrorForMissingConfigFile(t *testing.T) { + resetViperWithJWTSecret(t) + t.Setenv("CONFIG_FILE", filepath.Join(t.TempDir(), "missing.yaml")) + + _, err := Load() + require.ErrorContains(t, err, "read config error") +} + func TestLoadForBootstrapAllowsMissingJWTSecret(t *testing.T) { viper.Reset() + t.Cleanup(viper.Reset) + t.Setenv("CONFIG_FILE", "") + t.Setenv("DATA_DIR", "") t.Setenv("JWT_SECRET", "") cfg, err := LoadForBootstrap() @@ -1155,6 +1203,8 @@ func TestNormalizeStringSlice(t *testing.T) { } func TestGetServerAddressFromEnv(t *testing.T) { + t.Setenv("CONFIG_FILE", "") + t.Setenv("DATA_DIR", "") t.Setenv("SERVER_HOST", "127.0.0.1") t.Setenv("SERVER_PORT", "9090") @@ -1164,6 +1214,17 @@ func TestGetServerAddressFromEnv(t *testing.T) { } } +func TestGetServerAddressFromConfigFile(t *testing.T) { + configFile := filepath.Join(t.TempDir(), "setup.yaml") + require.NoError(t, os.WriteFile(configFile, []byte("server:\n host: 192.0.2.40\n port: 9191\n"), 0o600)) + t.Setenv("CONFIG_FILE", configFile) + t.Setenv("DATA_DIR", "") + t.Setenv("SERVER_HOST", "") + t.Setenv("SERVER_PORT", "") + + require.Equal(t, "192.0.2.40:9191", GetServerAddress()) +} + func TestValidateAbsoluteHTTPURL(t *testing.T) { if err := ValidateAbsoluteHTTPURL("https://example.com/path"); err != nil { t.Fatalf("ValidateAbsoluteHTTPURL valid url error: %v", err) From 7dde9370e49dab46d822221017cfd9b5d9e7dac0 Mon Sep 17 00:00:00 2001 From: Ricardo-binZzz <285964759+Ricardo-binZzz@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:19:19 +0800 Subject: [PATCH 24/26] Codex++ Responses<->Anthropic compatibility fixes Namespace tool flatten/restore, array function_call_output, omit empty input_schema for native tools, lift additional_tools; scoped to ForwardAsResponses. --- .../pkg/apicompat/responses_client_tools.go | 2 +- .../apicompat/responses_client_tools_test.go | 8 +- .../responses_to_anthropic_request.go | 45 +++++- ...esponses_to_anthropic_tool_pairing_test.go | 25 ++++ .../responses_to_anthropic_tools_test.go | 3 + backend/internal/pkg/apicompat/types.go | 36 ++++- .../service/gateway_forward_as_responses.go | 101 ++++++++++++-- .../gateway_forward_as_responses_test.go | 128 +++++++++++++++++- 8 files changed, 324 insertions(+), 24 deletions(-) diff --git a/backend/internal/pkg/apicompat/responses_client_tools.go b/backend/internal/pkg/apicompat/responses_client_tools.go index 947d3e8ff..daffc984d 100644 --- a/backend/internal/pkg/apicompat/responses_client_tools.go +++ b/backend/internal/pkg/apicompat/responses_client_tools.go @@ -586,7 +586,7 @@ func (r *ResponsesClientToolStreamRestorer) restoreNamespaceEvent(event Response event.Item.Name, event.Item.Namespace = name.Name, name.Namespace } } - if event.Type == "response.function_call_arguments.done" { + if event.Type == "response.function_call_arguments.delta" || event.Type == "response.function_call_arguments.done" { if name, ok := r.adapter.NamespaceTools[event.Name]; ok { event.Name = name.Name } diff --git a/backend/internal/pkg/apicompat/responses_client_tools_test.go b/backend/internal/pkg/apicompat/responses_client_tools_test.go index 61dc0d648..91ee0890a 100644 --- a/backend/internal/pkg/apicompat/responses_client_tools_test.go +++ b/backend/internal/pkg/apicompat/responses_client_tools_test.go @@ -146,7 +146,13 @@ func TestResponsesClientToolStreamRestorer_RestoresNamespaceLifecycle(t *testing require.Equal(t, "open", gjson.GetBytes(added[0], "item.name").String()) require.Equal(t, "browser", gjson.GetBytes(added[0], "item.namespace").String()) - done, changed, err := restorer.RestoreEvent([]byte(`{"type":"response.function_call_arguments.done","sequence_number":5,"output_index":0,"item_id":"i1","name":"browser__open","arguments":"{}"}`)) + delta, changed, err := restorer.RestoreEvent([]byte(`{"type":"response.function_call_arguments.delta","sequence_number":5,"output_index":0,"item_id":"i1","name":"browser__open","delta":"{\"url\":"}`)) + require.NoError(t, err) + require.True(t, changed) + require.Len(t, delta, 1) + require.Equal(t, "open", gjson.GetBytes(delta[0], "name").String()) + + done, changed, err := restorer.RestoreEvent([]byte(`{"type":"response.function_call_arguments.done","sequence_number":6,"output_index":0,"item_id":"i1","name":"browser__open","arguments":"{}"}`)) require.NoError(t, err) require.True(t, changed) require.Len(t, done, 1) diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go index 46f57d0ca..7a28b44ab 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go @@ -152,11 +152,7 @@ func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMess case item.Type == "function_call_output": // function_call_output → user message with tool_result block - outputContent := item.Output - if outputContent == "" { - outputContent = "(empty)" - } - contentJSON, _ := json.Marshal(outputContent) + contentJSON := responsesFunctionOutputToAnthropicContent(item) block := AnthropicContentBlock{ Type: "tool_result", ToolUseID: fromResponsesCallIDToAnthropic(item.CallID), @@ -216,6 +212,45 @@ func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMess return system, messages, nil } +func responsesFunctionOutputToAnthropicContent(item ResponsesInputItem) json.RawMessage { + if len(item.outputRaw) == 0 { + output := item.Output + if output == "" { + output = "(empty)" + } + content, _ := json.Marshal(output) + return content + } + + var parts []ResponsesContentPart + if err := json.Unmarshal(item.outputRaw, &parts); err == nil { + blocks := make([]AnthropicContentBlock, 0, len(parts)) + for _, part := range parts { + switch part.Type { + case "input_text", "output_text", "text": + if part.Text != "" { + blocks = append(blocks, AnthropicContentBlock{Type: "text", Text: part.Text}) + } + case "input_image": + if source := dataURIToAnthropicImageSource(part.ImageURL); source != nil { + blocks = append(blocks, AnthropicContentBlock{Type: "image", Source: source}) + } + } + } + if len(blocks) > 0 { + content, _ := json.Marshal(blocks) + return content + } + if len(parts) == 0 { + content, _ := json.Marshal("(empty)") + return content + } + } + + content, _ := json.Marshal(item.Output) + return content +} + // normalizeAnthropicToolPairing rebuilds the message sequence so it satisfies // Anthropic's tool_use/tool_result invariants, which the naive item-by-item // conversion violates whenever the Responses history interleaves anything diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go b/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go index 1a51b5478..42dd2d0cc 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go @@ -163,3 +163,28 @@ func TestAnthropicPairing_SingleCall(t *testing.T) { require.True(t, hasToolUse(parseContentBlocks(msgs[1].Content), "call_A")) require.True(t, hasToolResult(parseContentBlocks(msgs[2].Content), "call_A")) } + +func TestResponsesToAnthropic_FunctionOutputContentArray(t *testing.T) { + msgs := convertAnthropic(t, `[ + {"type":"function_call","call_id":"call_A","name":"view_image","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_A","output":[ + {"type":"input_text","text":"image loaded"}, + {"type":"input_image","image_url":"data:image/png;base64,YQ=="} + ]} + ]`) + + require.Len(t, msgs, 2) + resultBlocks := parseContentBlocks(msgs[1].Content) + require.Len(t, resultBlocks, 1) + require.Equal(t, "tool_result", resultBlocks[0].Type) + + var content []AnthropicContentBlock + require.NoError(t, json.Unmarshal(resultBlocks[0].Content, &content)) + require.Len(t, content, 2) + require.Equal(t, "text", content[0].Type) + require.Equal(t, "image loaded", content[0].Text) + require.Equal(t, "image", content[1].Type) + require.NotNil(t, content[1].Source) + require.Equal(t, "image/png", content[1].Source.MediaType) + require.Equal(t, "YQ==", content[1].Source.Data) +} diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_tools_test.go b/backend/internal/pkg/apicompat/responses_to_anthropic_tools_test.go index 1b732a10e..54873b547 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_tools_test.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_tools_test.go @@ -122,6 +122,9 @@ func TestResponsesToAnthropic_MixedToolsProduceValidAnthropicTools(t *testing.T) assert.Equal(t, "web_search_20250305", tools[2].Type) assert.Equal(t, "web_search", tools[2].Name) assert.Empty(t, tools[2].InputSchema) + serverToolWire, err := json.Marshal(tools[2]) + require.NoError(t, err) + assert.NotContains(t, string(serverToolWire), `"input_schema"`) } func TestResponsesToAnthropic_DefaultToolNormalizesInputSchema(t *testing.T) { diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index 6031eb41d..c4cde405e 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -4,7 +4,10 @@ // formats can be served through a unified gateway. package apicompat -import "encoding/json" +import ( + "bytes" + "encoding/json" +) // --------------------------------------------------------------------------- // Anthropic Messages API types @@ -112,7 +115,7 @@ type AnthropicTool struct { Type string `json:"type,omitempty"` // e.g. "web_search_20250305" for server tools Name string `json:"name"` Description string `json:"description,omitempty"` - InputSchema json.RawMessage `json:"input_schema"` // JSON Schema object + InputSchema json.RawMessage `json:"input_schema,omitempty"` // JSON Schema object CacheControl *AnthropicCacheControl `json:"cache_control,omitempty"` } @@ -260,7 +263,34 @@ type ResponsesInputItem struct { ID string `json:"id,omitempty"` // type=function_call_output - Output string `json:"output,omitempty"` + Output string `json:"output,omitempty"` + outputRaw json.RawMessage +} + +func (i *ResponsesInputItem) UnmarshalJSON(data []byte) error { + type alias ResponsesInputItem + var wire struct { + *alias + Output json.RawMessage `json:"output"` + } + + *i = ResponsesInputItem{} + wire.alias = (*alias)(i) + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + + output := bytes.TrimSpace(wire.Output) + if len(output) == 0 || bytes.Equal(output, []byte("null")) { + return nil + } + if err := json.Unmarshal(output, &i.Output); err == nil { + return nil + } + + i.outputRaw = append(i.outputRaw[:0], output...) + i.Output = string(output) + return nil } // ResponsesContentPart is a typed content part in a Responses message. diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index e5f0ddc3c..2aa5255c2 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -37,15 +37,21 @@ func (s *GatewayService) ForwardAsResponses( ) (*ForwardResult, error) { startTime := time.Now() - // 1. Parse Responses request + // 1. Lower Codex client-side tools to function tools understood by Anthropic. + adaptedBody, clientToolMapping, err := adaptResponsesClientToolsForAnthropic(body) + if err != nil { + return nil, fmt.Errorf("adapt responses client tools: %w", err) + } + + // 2. Parse Responses request var responsesReq apicompat.ResponsesRequest - if err := json.Unmarshal(body, &responsesReq); err != nil { + if err := json.Unmarshal(adaptedBody, &responsesReq); err != nil { return nil, fmt.Errorf("parse responses request: %w", err) } originalModel := responsesReq.Model clientStream := responsesReq.Stream - // 2. Convert Responses → Anthropic + // 3. Convert Responses → Anthropic anthropicReq, err := apicompat.ResponsesToAnthropicRequest(&responsesReq) if err != nil { return nil, fmt.Errorf("convert responses to anthropic: %w", err) @@ -182,14 +188,71 @@ func (s *GatewayService) ForwardAsResponses( var result *ForwardResult var handleErr error if clientStream { - result, handleErr = s.handleResponsesStreamingResponse(resp, c, originalModel, mappedModel, reasoningEffort, startTime) + result, handleErr = s.handleResponsesStreamingResponse(resp, c, originalModel, mappedModel, reasoningEffort, startTime, clientToolMapping) } else { - result, handleErr = s.handleResponsesBufferedStreamingResponse(resp, c, originalModel, mappedModel, reasoningEffort, startTime) + result, handleErr = s.handleResponsesBufferedStreamingResponse(resp, c, originalModel, mappedModel, reasoningEffort, startTime, clientToolMapping) } return result, handleErr } +func adaptResponsesClientToolsForAnthropic(body []byte) ([]byte, apicompat.ResponsesClientToolMapping, error) { + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + var requestBody map[string]any + if err := decoder.Decode(&requestBody); err != nil { + return body, apicompat.ResponsesClientToolMapping{}, err + } + additionalToolsChanged, err := liftResponsesAdditionalTools(requestBody) + if err != nil { + return body, apicompat.ResponsesClientToolMapping{}, err + } + + mapping, changed, err := apicompat.AdaptResponsesClientTools(requestBody) + if err != nil { + return body, apicompat.ResponsesClientToolMapping{}, err + } + changed = changed || additionalToolsChanged + if !changed { + return body, mapping, nil + } + rebuilt, err := json.Marshal(requestBody) + if err != nil { + return body, apicompat.ResponsesClientToolMapping{}, err + } + return rebuilt, mapping, nil +} + +func liftResponsesAdditionalTools(requestBody map[string]any) (bool, error) { + input, ok := requestBody["input"].([]any) + if !ok { + return false, nil + } + + tools, _ := requestBody["tools"].([]any) + kept := make([]any, 0, len(input)) + changed := false + for _, raw := range input { + item, ok := raw.(map[string]any) + if !ok || strings.TrimSpace(fmt.Sprint(item["type"])) != "additional_tools" { + kept = append(kept, raw) + continue + } + additional, ok := item["tools"].([]any) + if !ok { + return false, fmt.Errorf("additional_tools.tools must be an array") + } + tools = append(tools, additional...) + changed = true + } + if !changed { + return false, nil + } + requestBody["tools"] = tools + requestBody["input"] = kept + return true, nil +} + // ExtractResponsesReasoningEffortFromBody reads Responses API reasoning.effort // and normalizes it for usage logging. func ExtractResponsesReasoningEffortFromBody(body []byte) *string { @@ -243,6 +306,7 @@ func (s *GatewayService) handleResponsesBufferedStreamingResponse( mappedModel string, reasoningEffort *string, startTime time.Time, + clientToolMapping apicompat.ResponsesClientToolMapping, ) (*ForwardResult, error) { requestID := resp.Header.Get("x-request-id") @@ -358,6 +422,10 @@ func (s *GatewayService) handleResponsesBufferedStreamingResponse( c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") if respBytes, err := json.Marshal(responsesResp); err == nil { respBytes = reverseToolNamesIfPresent(c, respBytes) + respBytes, _, err = apicompat.RestoreResponsesClientToolPayload(respBytes, clientToolMapping) + if err != nil { + return nil, fmt.Errorf("restore responses client tools: %w", err) + } c.Data(http.StatusOK, "application/json; charset=utf-8", respBytes) } else { c.JSON(http.StatusOK, responsesResp) @@ -383,6 +451,7 @@ func (s *GatewayService) handleResponsesStreamingResponse( mappedModel string, reasoningEffort *string, startTime time.Time, + clientToolMapping apicompat.ResponsesClientToolMapping, ) (*ForwardResult, error) { requestID := resp.Header.Get("x-request-id") @@ -397,6 +466,7 @@ func (s *GatewayService) handleResponsesStreamingResponse( state := apicompat.NewAnthropicEventToResponsesState() state.Model = originalModel + clientToolRestorer := apicompat.NewResponsesClientToolStreamRestorer(clientToolMapping) var usage ClaudeUsage var firstTokenMs *int firstChunk := true @@ -441,7 +511,7 @@ func (s *GatewayService) handleResponsesStreamingResponse( // Convert to Responses events events := apicompat.AnthropicEventToResponsesEvents(event, state) for _, evt := range events { - sse, err := apicompat.ResponsesEventToSSE(evt) + payload, err := json.Marshal(evt) if err != nil { logger.L().Warn("forward_as_responses stream: failed to marshal event", zap.Error(err), @@ -449,12 +519,23 @@ func (s *GatewayService) handleResponsesStreamingResponse( ) continue } - out := string(reverseToolNamesIfPresent(c, []byte(sse))) - if _, err := fmt.Fprint(c.Writer, out); err != nil { - logger.L().Info("forward_as_responses stream: client disconnected", + payload = reverseToolNamesIfPresent(c, payload) + payloads, _, err := clientToolRestorer.RestoreEvent(payload) + if err != nil { + logger.L().Warn("forward_as_responses stream: failed to restore client tools", + zap.Error(err), zap.String("request_id", requestID), ) - return true // client disconnected + continue + } + for _, restored := range payloads { + eventType := gjson.GetBytes(restored, "type").String() + if _, err := fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", eventType, restored); err != nil { + logger.L().Info("forward_as_responses stream: client disconnected", + zap.String("request_id", requestID), + ) + return true // client disconnected + } } } if len(events) > 0 { diff --git a/backend/internal/service/gateway_forward_as_responses_test.go b/backend/internal/service/gateway_forward_as_responses_test.go index baaf8b954..6784e7c7a 100644 --- a/backend/internal/service/gateway_forward_as_responses_test.go +++ b/backend/internal/service/gateway_forward_as_responses_test.go @@ -3,6 +3,7 @@ package service import ( + "encoding/json" "io" "net/http" "net/http/httptest" @@ -10,10 +11,129 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) +func TestAdaptResponsesClientToolsForAnthropic_FlattensNamespace(t *testing.T) { + t.Parallel() + + body := []byte(`{ + "model":"claude-fable-5", + "input":[{"type":"function_call","call_id":"call_1","namespace":"codex_app","name":"read_thread","arguments":"{}"}], + "tools":[{"type":"namespace","name":"codex_app","tools":[{"type":"function","name":"read_thread","description":"Read a task","parameters":{"type":"object","properties":{}}}]}] + }`) + + adapted, mapping, err := adaptResponsesClientToolsForAnthropic(body) + require.NoError(t, err) + require.Equal(t, apicompat.ResponsesNamespaceName{Namespace: "codex_app", Name: "read_thread"}, mapping.NamespaceTools["codex_app__read_thread"]) + + var request map[string]any + require.NoError(t, json.Unmarshal(adapted, &request)) + tools := request["tools"].([]any) + require.Len(t, tools, 1) + tool := tools[0].(map[string]any) + require.Equal(t, "function", tool["type"]) + require.Equal(t, "codex_app__read_thread", tool["name"]) + + input := request["input"].([]any) + call := input[0].(map[string]any) + require.Equal(t, "codex_app__read_thread", call["name"]) + require.NotContains(t, call, "namespace") +} + +func TestAdaptResponsesClientToolsForAnthropic_LiftsAdditionalTools(t *testing.T) { + body := []byte(`{ + "model":"claude-fable-5", + "input":[ + {"type":"additional_tools","tools":[ + {"type":"custom","name":"exec","description":"Run a command"}, + {"type":"namespace","name":"codex_app","tools":[ + {"type":"function","name":"read_thread","parameters":{"type":"object"}} + ]} + ]}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"inspect"}]} + ] + }`) + + adapted, mapping, err := adaptResponsesClientToolsForAnthropic(body) + require.NoError(t, err) + require.True(t, mapping.CustomTools["exec"]) + require.Equal(t, apicompat.ResponsesNamespaceName{Namespace: "codex_app", Name: "read_thread"}, mapping.NamespaceTools["codex_app__read_thread"]) + + var request map[string]any + require.NoError(t, json.Unmarshal(adapted, &request)) + tools := request["tools"].([]any) + require.Len(t, tools, 2) + require.Equal(t, "function", tools[0].(map[string]any)["type"]) + require.Equal(t, "codex_app__read_thread", tools[1].(map[string]any)["name"]) + + input := request["input"].([]any) + require.Len(t, input, 1) + require.Equal(t, "message", input[0].(map[string]any)["type"]) +} + +func namespaceToolAnthropicStream() string { + return strings.Join([]string{ + `event: message_start`, + `data: {"type":"message_start","message":{"id":"msg_namespace","type":"message","role":"assistant","content":[],"model":"claude-fable-5","stop_reason":"","usage":{"input_tokens":10}}}`, + ``, + `event: content_block_start`, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_namespace","name":"codex_app__read_thread","input":{"thread_id":"123"}}}`, + ``, + `event: content_block_stop`, + `data: {"type":"content_block_stop","index":0}`, + ``, + `event: message_delta`, + `data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":5}}`, + ``, + `event: message_stop`, + `data: {"type":"message_stop"}`, + ``, + }, "\n") +} + +func namespaceToolMapping() apicompat.ResponsesClientToolMapping { + return apicompat.ResponsesClientToolMapping{NamespaceTools: map[string]apicompat.ResponsesNamespaceName{ + "codex_app__read_thread": {Namespace: "codex_app", Name: "read_thread"}, + }} +} + +func TestHandleResponsesBufferedStreamingResponse_RestoresNamespaceTool(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + resp := &http.Response{Body: io.NopCloser(strings.NewReader(namespaceToolAnthropicStream()))} + + svc := &GatewayService{} + _, err := svc.handleResponsesBufferedStreamingResponse(resp, c, "claude-fable-5", "claude-fable-5", nil, time.Now(), namespaceToolMapping()) + require.NoError(t, err) + require.Contains(t, rec.Body.String(), `"type":"function_call"`) + require.Contains(t, rec.Body.String(), `"name":"read_thread"`) + require.Contains(t, rec.Body.String(), `"namespace":"codex_app"`) + require.NotContains(t, rec.Body.String(), `"name":"codex_app__read_thread"`) +} + +func TestHandleResponsesStreamingResponse_RestoresNamespaceTool(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + resp := &http.Response{Body: io.NopCloser(strings.NewReader(namespaceToolAnthropicStream()))} + + svc := &GatewayService{} + _, err := svc.handleResponsesStreamingResponse(resp, c, "claude-fable-5", "claude-fable-5", nil, time.Now(), namespaceToolMapping()) + require.NoError(t, err) + require.Contains(t, rec.Body.String(), `response.output_item.added`) + require.Contains(t, rec.Body.String(), `"name":"read_thread"`) + require.Contains(t, rec.Body.String(), `"namespace":"codex_app"`) + require.NotContains(t, rec.Body.String(), `"name":"codex_app__read_thread"`) +} + func TestExtractResponsesReasoningEffortFromBody(t *testing.T) { t.Parallel() @@ -51,7 +171,7 @@ func TestHandleResponsesBufferedStreamingResponse_PreservesMessageStartCacheUsag } svc := &GatewayService{} - result, err := svc.handleResponsesBufferedStreamingResponse(resp, c, "claude-sonnet-4.5", "claude-sonnet-4.5", nil, time.Now()) + result, err := svc.handleResponsesBufferedStreamingResponse(resp, c, "claude-sonnet-4.5", "claude-sonnet-4.5", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, 12, result.Usage.InputTokens) @@ -87,7 +207,7 @@ func TestHandleResponsesStreamingResponse_PreservesMessageStartCacheUsage(t *tes } svc := &GatewayService{} - result, err := svc.handleResponsesStreamingResponse(resp, c, "claude-sonnet-4.5", "claude-sonnet-4.5", nil, time.Now()) + result, err := svc.handleResponsesStreamingResponse(resp, c, "claude-sonnet-4.5", "claude-sonnet-4.5", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, 20, result.Usage.InputTokens) @@ -198,7 +318,7 @@ func TestHandleResponsesBufferedStreamingResponse_CompactSSEFormat(t *testing.T) } svc := &GatewayService{} - result, err := svc.handleResponsesBufferedStreamingResponse(resp, c, "claude-sonnet-4.5", "claude-sonnet-4.5", nil, time.Now()) + result, err := svc.handleResponsesBufferedStreamingResponse(resp, c, "claude-sonnet-4.5", "claude-sonnet-4.5", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, 10, result.Usage.InputTokens) @@ -232,7 +352,7 @@ func TestHandleResponsesStreamingResponse_CompactSSEFormat(t *testing.T) { } svc := &GatewayService{} - result, err := svc.handleResponsesStreamingResponse(resp, c, "claude-sonnet-4.5", "claude-sonnet-4.5", nil, time.Now()) + result, err := svc.handleResponsesStreamingResponse(resp, c, "claude-sonnet-4.5", "claude-sonnet-4.5", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, 15, result.Usage.InputTokens) From 7d3a896fcd912e6141b9bbbe90ca5bd08ff49ea3 Mon Sep 17 00:00:00 2001 From: shaw Date: Mon, 27 Jul 2026 08:59:12 +0800 Subject: [PATCH 25/26] chore: update sponsors --- README.md | 18 ------------------ README_CN.md | 18 ------------------ README_JA.md | 18 ------------------ assets/partners/logos/apikl.png | Bin 9773 -> 0 bytes assets/partners/logos/miyaip.png | Bin 10422 -> 0 bytes assets/partners/logos/tokeneum.png | Bin 24001 -> 0 bytes 6 files changed, 54 deletions(-) delete mode 100644 assets/partners/logos/apikl.png delete mode 100644 assets/partners/logos/miyaip.png delete mode 100644 assets/partners/logos/tokeneum.png diff --git a/README.md b/README.md index 0e87f7599..e5b625e39 100644 --- a/README.md +++ b/README.md @@ -102,18 +102,6 @@ Please read the following carefully before using this project: - -apikl -Thanks to Apikl for sponsoring this project! Built on Sub2API, the platform provides developers with relay services for Codex / Claude series models, focusing on long-term stability, high-speed direct connections, and excellent cost-effectiveness. It offers pay-as-you-go balance billing, enterprise-grade official invoices, and one-on-one dedicated support. Register now for a 1:1 top-up bonus — double your balance! - - - - -tokeneum -Thanks to TokenEum for sponsoring this project! TokenEum is a comprehensive AI model aggregation platform and intelligent agent development company. It brings together top-tier international models — including Claude, Gemini, and OpenAI — alongside leading open-source models such as GLM, Qwen, and Kimi, offering a wide range of options across different quality and price tiers to suit every need. TokenEum also provides access to cutting-edge video generation models like Seedance2.0 and Happy Horse. Committed to transparency and honest business practices, TokenEum ensures all model information is accurate and reliable. Visit tokeneum.ai to get started. - - - 666api Thanks to 666api for sponsoring this project! 666api is an all-in-one platform offering:
@@ -135,12 +123,6 @@ Please read the following carefully before using this project: - -miyaip -Thanks to MiyaIP for sponsoring this project! MiyaIP is a platform dedicated to global residential proxy network services, committed to providing high-quality, pure overseas residential IP resources for enterprise developers, cross-border business teams, and AI application users. It delivers stable, independent overseas network environments for AI platforms, overseas SaaS, and other online services, supporting multi-region access testing and project environment isolation. Ideal for development and testing scenarios that require access to overseas AI services, such as: AI model platform access, AI development testing, AI SaaS service usage, AI API debugging, and multi-region network environment validation. - - - anpin Thanks to anpin.ai for sponsoring this project! anpin.ai is a premium AI relay service platform dedicated to advancing AI accessibility. With an advanced technical architecture and globally distributed deployment, it provides users with a direct high-speed channel to the world's top-tier large language models.
diff --git a/README_CN.md b/README_CN.md index b03ba9311..5ccc48ab4 100644 --- a/README_CN.md +++ b/README_CN.md @@ -105,18 +105,6 @@ - -apikl -感谢 Apikl 赞助本项目!平台基于 Sub2API 搭建,为开发者提供 Codex / Claude 系列模型的中转服务,专注于长期稳定、高速直连与高性价比。支持按量计费的余额结算、企业级正规发票及一对一专属对接。立即注册即享充值 1:1 赠送 — 余额翻倍! - - - - -tokeneum -感谢 TokenEum 赞助本项目!TokenEum 是一家综合性 AI 模型聚合平台与智能体开发公司,汇聚 Claude、Gemini、OpenAI 等国际顶级模型,以及 GLM、Qwen、Kimi 等主流开源模型,提供不同质量与价格梯度的丰富选择,满足多样化需求。平台还接入了 Seedance2.0、Happy Horse 等前沿视频生成模型。秉持透明诚信的经营理念,TokenEum 确保所有模型信息真实可靠。访问 tokeneum.ai 开始使用。 - - - 666api 感谢 666api 赞助本项目!666api 是一站式综合服务平台,提供:
@@ -138,12 +126,6 @@ - -miyaip -感谢 MiyaIP 赞助本项目!MiyaIP 是一家专注于全球住宅代理网络服务的平台,致力于为企业开发者、跨境业务团队及AI 应用用户提供高质量、纯净的海外住宅 IP 资源。为 AI 平台、海外 SaaS 及其他在线服务提供稳定、独立的海外网络环境,支持多地区访问测试和项目环境隔离。适用于需要访问海外 AI 服务的开发和测试场景,例如:AI 模型平台访问、AI 开发测试、AI SaaS 服务使用、AI API 调试、多地区网络环境验证 - - - anpin 感谢 anpin.ai 赞助本项目!anpin.ai 是一家致力于推动 AI 普惠的高端 AI 中转服务平台。我们以先进的技术架构和全球分布式部署,为用户提供直达国际顶尖大模型的高速通道。
diff --git a/README_JA.md b/README_JA.md index 53546c0f2..a4fd86b84 100644 --- a/README_JA.md +++ b/README_JA.md @@ -104,18 +104,6 @@ - -apikl -Apikl のご支援に感謝します!Sub2API をベースに構築された本プラットフォームは、開発者向けに Codex / Claude シリーズモデルの中継サービスを提供しています。長期安定性、高速直結、高いコストパフォーマンスを重視し、従量課金の残高ベース課金、エンタープライズグレードの正規請求書、1対1の専属サポートを提供します。今すぐ登録でチャージ 1:1 ボーナス — 残高が倍に! - - - - -tokeneum -TokenEum のご支援に感謝します!TokenEum は総合的な AI モデル集約プラットフォームおよびインテリジェントエージェント開発企業です。Claude、Gemini、OpenAI などの世界トップクラスのモデルに加え、GLM、Qwen、Kimi などの主要なオープンソースモデルも集約しており、品質と価格の異なる豊富な選択肢を提供してあらゆるニーズに対応します。また、Seedance2.0 や Happy Horse などの最先端の動画生成モデルも利用可能です。TokenEum は透明性と誠実なビジネスを重視し、すべてのモデル情報の正確性と信頼性を保証します。tokeneum.ai でぜひお試しください。 - - - 666api 666api のご支援に感謝します!666api はオールインワンプラットフォームで、以下を提供しています:⚡ API 中継 — グローバルモデルへの従量課金アクセス、100% 公式ソースから直接供給、公式価格の最大 75% オフ。独占特典:Zhipu GLM 50% オフ・DeepSeek V4-pro 50% オフ・Seedance2.0 8% オフ(ホワイトリスト)・HappyHorse Overseas 30% オフ(ホワイトリスト)🔑 GPT サブスクリプションアカウント — 同源 IP 込み・グローバル住宅 IP 💰 請求書発行対応 @@ -133,12 +121,6 @@ - -miyaip -MiyaIP のご支援に感謝します!MiyaIP はグローバル住宅プロキシネットワークサービスに特化したプラットフォームで、企業開発者、越境ビジネスチーム、AI アプリケーションユーザーに高品質で純粋な海外住宅 IP リソースを提供することに注力しています。AI プラットフォーム、海外 SaaS、その他のオンラインサービスに安定した独立した海外ネットワーク環境を提供し、複数地域でのアクセステストやプロジェクト環境の分離をサポートします。海外 AI サービスへのアクセスが必要な開発・テストシナリオに最適です。例:AI モデルプラットフォームアクセス、AI 開発テスト、AI SaaS サービス利用、AI API デバッグ、複数地域のネットワーク環境検証など。 - - - anpin anpin.ai のご支援に感謝します!anpin.ai は AI の普及推進に取り組むハイエンド AI 中継サービスプラットフォームです。先進的な技術アーキテクチャとグローバル分散デプロイメントにより、国際トップクラスの大規模モデルへの高速直通ルートを提供します。
diff --git a/assets/partners/logos/apikl.png b/assets/partners/logos/apikl.png deleted file mode 100644 index f98211ac0630950d0b4fbaaa2a610d786c10bfda..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9773 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 NLq+#xgOXL${{X_PU1R_N diff --git a/assets/partners/logos/miyaip.png b/assets/partners/logos/miyaip.png deleted file mode 100644 index 3d484c9450f98970bd4f0040d8e191dfd3561255..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10422 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 F{{wwVJjnn6 From e94383a4c45750d820534f1558661b5784dfdf39 Mon Sep 17 00:00:00 2001 From: Rick <68499367+OG-Wang@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:08:56 +0800 Subject: [PATCH 26/26] =?UTF-8?q?fix(frontend):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=B8=A0=E9=81=93=E7=9B=91=E6=8E=A7=E6=97=B6=E9=97=B4=E7=BA=BF?= =?UTF-8?q?=E5=9C=A8=E7=AA=84=E5=8D=A1=E7=89=87=E4=B8=8B=E6=BA=A2=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MonitorTimeline 每根柱子设置了 min-w-[3px],60 根柱子加 2px 间距的 最小总宽度为 298px。当卡片内容区宽度低于该值时(如 100% 缩放下的 部分布局),时间线整体溢出卡片边缘。改为 min-w-0 让柱子随容器等分 压缩,任意宽度下均不再溢出。 --- frontend/src/components/user/monitor/MonitorTimeline.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/user/monitor/MonitorTimeline.vue b/frontend/src/components/user/monitor/MonitorTimeline.vue index 2445bc519..548e2c084 100644 --- a/frontend/src/components/user/monitor/MonitorTimeline.vue +++ b/frontend/src/components/user/monitor/MonitorTimeline.vue @@ -17,7 +17,7 @@