From de27905e88aa291acd2a292e2b439bce3de0f118 Mon Sep 17 00:00:00 2001 From: feeeei Date: Wed, 2 Sep 2026 17:24:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(usage):=20=E7=94=A8=E9=87=8F=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E8=90=BD=E5=BA=93=E4=B8=8A=E6=B8=B8=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E6=A0=87=E8=AF=86=E5=B9=B6=E6=94=AF=E6=8C=81=E6=8C=89=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E9=85=8D=E7=BD=AE=E5=93=8D=E5=BA=94=E5=A4=B4=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - usage_logs 新增 upstream_request_id 列(迁移 232)与仅索引非空行的 CONCURRENTLY 部分索引(迁移 233,runner 登记失效索引清理) - 账户 extra.upstream_request_id_header 指定要读取的上游响应头,只读该头;留空不记录;创建/更新/批量入口校验头名合法且不超过 64 字节 - ForwardResult/OpenAIForwardResult 携带 UpstreamHeaders,中央计费按账户解析落库,WS 轮次保持 NULL - 管理端用量明细新增默认隐藏的"上游ID"列,带与请求ID列一致的复制按钮,并同步导出 - 创建/编辑账户弹窗新增"上游ID"输入(UpstreamRequestIdHeaderField),提示按接入类型与平台分条列出常见头名 - HelpTooltip 悬停模式下指针移入提示框时保持打开,便于选中复制提示内容 --- .../internal/handler/admin/account_handler.go | 25 +++ backend/internal/handler/dto/mappers.go | 1 + backend/internal/handler/dto/types.go | 2 + .../internal/repository/migrations_runner.go | 4 + .../repository/usage_log_repo_insert.go | 15 +- .../usage_log_repo_insert_shape_unit_test.go | 145 ++++++++++++++++++ .../repository/usage_log_repo_query.go | 7 +- .../usage_log_repo_request_type_test.go | 6 + .../service/antigravity_gateway_claude.go | 1 + .../service/antigravity_gateway_compat.go | 1 + .../service/antigravity_gateway_gemini.go | 1 + .../service/gateway_anthropic_passthrough.go | 1 + backend/internal/service/gateway_bedrock.go | 1 + backend/internal/service/gateway_forward.go | 1 + .../gateway_forward_as_chat_completions.go | 2 + .../service/gateway_forward_as_responses.go | 2 + backend/internal/service/gateway_service.go | 6 +- .../service/gateway_upstream_response.go | 1 + .../internal/service/gateway_usage_billing.go | 1 + .../gemini_chat_completions_compat_service.go | 1 + .../service/gemini_messages_compat_service.go | 17 +- backend/internal/service/grok_audio.go | 11 +- backend/internal/service/grok_media.go | 2 + .../internal/service/openai_alpha_search.go | 12 +- backend/internal/service/openai_embeddings.go | 15 +- .../openai_gateway_chat_completions.go | 2 + ...teway_chat_completions_anthropic_native.go | 2 + .../openai_gateway_chat_completions_raw.go | 2 + .../service/openai_gateway_forward.go | 1 + .../internal/service/openai_gateway_grok.go | 1 + .../service/openai_gateway_messages.go | 2 + ...penai_gateway_messages_anthropic_native.go | 2 + .../openai_gateway_messages_chat_fallback.go | 3 + .../service/openai_gateway_passthrough.go | 1 + ...enai_gateway_responses_anthropic_native.go | 2 + .../openai_gateway_responses_chat_fallback.go | 4 + .../service/openai_gateway_service.go | 6 +- .../internal/service/openai_gateway_usage.go | 1 + backend/internal/service/openai_images.go | 3 + .../service/openai_images_responses.go | 2 + .../internal/service/upstream_request_id.go | 84 ++++++++++ .../service/upstream_request_id_test.go | 78 ++++++++++ backend/internal/service/usage_log.go | 4 + .../232_add_usage_log_upstream_request_id.sql | 5 + ...age_log_upstream_request_id_index_notx.sql | 4 + .../components/account/CreateAccountModal.vue | 37 +++-- .../components/account/EditAccountModal.vue | 27 ++++ .../account/UpstreamRequestIdHeaderField.vue | 78 ++++++++++ .../__tests__/CreateAccountModal.spec.ts | 21 +++ .../__tests__/EditAccountModal.spec.ts | 43 ++++++ .../UpstreamRequestIdHeaderField.spec.ts | 66 ++++++++ .../src/components/admin/usage/UsageTable.vue | 32 +++- .../admin/usage/__tests__/UsageTable.spec.ts | 31 ++++ .../src/components/common/HelpTooltip.vue | 18 ++- .../common/__tests__/HelpTooltip.spec.ts | 30 ++++ .../src/i18n/locales/en/admin/accounts.ts | 8 + .../src/i18n/locales/en/admin/resources.ts | 2 + .../src/i18n/locales/zh/admin/accounts.ts | 8 + .../src/i18n/locales/zh/admin/resources.ts | 2 + frontend/src/types/index.ts | 1 + frontend/src/views/admin/UsageView.vue | 19 ++- .../views/admin/__tests__/UsageView.spec.ts | 44 +++++- 62 files changed, 901 insertions(+), 56 deletions(-) create mode 100644 backend/internal/repository/usage_log_repo_insert_shape_unit_test.go create mode 100644 backend/internal/service/upstream_request_id.go create mode 100644 backend/internal/service/upstream_request_id_test.go create mode 100644 backend/migrations/232_add_usage_log_upstream_request_id.sql create mode 100644 backend/migrations/233_add_usage_log_upstream_request_id_index_notx.sql create mode 100644 frontend/src/components/account/UpstreamRequestIdHeaderField.vue create mode 100644 frontend/src/components/account/__tests__/UpstreamRequestIdHeaderField.spec.ts diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 599a04a34e..495b8e7f0e 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -837,6 +837,10 @@ func (h *AccountHandler) Create(c *gin.Context) { } // base_rpm 输入校验:负值归零,超过 10000 截断 sanitizeExtraBaseRPM(req.Extra) + if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil { + response.ErrorFrom(c, err) + return + } // 确定是否跳过混合渠道检查 skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk @@ -970,6 +974,10 @@ func (h *AccountHandler) Update(c *gin.Context) { } // base_rpm 输入校验:负值归零,超过 10000 截断 sanitizeExtraBaseRPM(req.Extra) + if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil { + response.ErrorFrom(c, err) + return + } // 确定是否跳过混合渠道检查 skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk @@ -1424,6 +1432,10 @@ func (h *AccountHandler) ApplyOAuthCredentials(c *gin.Context) { response.ErrorFrom(c, err) return } + if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil { + response.ErrorFrom(c, err) + return + } // Drop SSO/password residue; re-auth must leave only OAuth tokens on disk. req.Credentials = service.SanitizeStoredCredentials(existing.Platform, req.Credentials) @@ -1897,6 +1909,15 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) { // base_rpm 输入校验:负值归零,超过 10000 截断 sanitizeExtraBaseRPM(item.Extra) + if err := service.ValidateUpstreamRequestIDHeaderExtra(item.Extra); err != nil { + failed++ + results = append(results, gin.H{ + "name": item.Name, + "success": false, + "error": err.Error(), + }) + continue + } skipCheck := item.ConfirmMixedChannelRisk != nil && *item.ConfirmMixedChannelRisk @@ -2091,6 +2112,10 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) { } // base_rpm 输入校验:负值归零,超过 10000 截断 sanitizeExtraBaseRPM(req.Extra) + if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil { + response.ErrorFrom(c, err) + return + } // 确定是否跳过混合渠道检查 skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index e6759a110c..bcee796561 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -723,6 +723,7 @@ func UsageLogFromServiceAdmin(l *service.UsageLog) *AdminUsageLog { UpstreamModelMismatch: l.UpstreamModelMismatch, ChannelID: l.ChannelID, ModelMappingChain: l.ModelMappingChain, + UpstreamRequestID: l.UpstreamRequestID, BillingTier: l.BillingTier, AccountRateMultiplier: l.AccountRateMultiplier, AccountStatsCost: l.AccountStatsCost, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 7fb8373810..57b80d645e 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -588,6 +588,8 @@ type AdminUsageLog struct { ChannelID *int64 `json:"channel_id,omitempty"` // ModelMappingChain 模型映射链,如 "a→b→c" ModelMappingChain *string `json:"model_mapping_chain,omitempty"` + // UpstreamRequestID 是直接上游声明的请求标识,仅管理端可见。 + UpstreamRequestID *string `json:"upstream_request_id,omitempty"` // BillingTier 计费层级标签(per_request/image 模式) BillingTier *string `json:"billing_tier,omitempty"` diff --git a/backend/internal/repository/migrations_runner.go b/backend/internal/repository/migrations_runner.go index 0b786616a5..8c69eb50dd 100644 --- a/backend/internal/repository/migrations_runner.go +++ b/backend/internal/repository/migrations_runner.go @@ -62,6 +62,8 @@ const usageLogsUpstreamModelMismatchIndex = "idx_usage_logs_upstream_model_misma const usageLogsEffectiveModelIndexesMigration = "226_add_usage_log_effective_model_indexes_notx.sql" const usageLogsEffectiveRequestedModelIndex = "idx_usage_logs_effective_requested_model_created" const usageLogsEffectiveUpstreamModelIndex = "idx_usage_logs_effective_upstream_model_created" +const usageLogsUpstreamRequestIDIndexMigration = "233_add_usage_log_upstream_request_id_index_notx.sql" +const usageLogsUpstreamRequestIDIndex = "idx_usage_logs_upstream_request_id" type migrationChecksumCompatibilityRule struct { fileChecksum string @@ -305,6 +307,8 @@ func prepareNonTransactionalMigration(ctx context.Context, db migrationConnectio } } return nil + case usageLogsUpstreamRequestIDIndexMigration: + return dropInvalidIndexIfPresent(ctx, db, usageLogsUpstreamRequestIDIndex) default: return nil } diff --git a/backend/internal/repository/usage_log_repo_insert.go b/backend/internal/repository/usage_log_repo_insert.go index f70706d481..e004433f3d 100644 --- a/backend/internal/repository/usage_log_repo_insert.go +++ b/backend/internal/repository/usage_log_repo_insert.go @@ -82,6 +82,7 @@ var usageLogInsertArgTypes = [...]string{ "text", // billing_tier "text", // billing_mode "numeric", // account_stats_cost + "text", // upstream_request_id "text", // session_id "boolean", // native_compaction_v2 "timestamptz", // created_at @@ -282,6 +283,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, billing_tier, billing_mode, account_stats_cost, + upstream_request_id, session_id, native_compaction_v2, created_at @@ -291,7 +293,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, - $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61 + $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61, $62 ) ON CONFLICT (request_id, api_key_id) DO NOTHING RETURNING id, created_at @@ -741,6 +743,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage billing_tier, billing_mode, account_stats_cost, + upstream_request_id, session_id, native_compaction_v2, created_at @@ -835,6 +838,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage billing_tier, billing_mode, account_stats_cost, + upstream_request_id, session_id, native_compaction_v2, created_at @@ -898,6 +902,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage billing_tier, billing_mode, account_stats_cost, + upstream_request_id, session_id, native_compaction_v2, created_at @@ -1001,6 +1006,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( billing_tier, billing_mode, account_stats_cost, + upstream_request_id, session_id, native_compaction_v2, created_at @@ -1090,6 +1096,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( billing_tier, billing_mode, account_stats_cost, + upstream_request_id, session_id, native_compaction_v2, created_at @@ -1153,6 +1160,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( billing_tier, billing_mode, account_stats_cost, + upstream_request_id, session_id, native_compaction_v2, created_at @@ -1224,6 +1232,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared billing_tier, billing_mode, account_stats_cost, + upstream_request_id, session_id, native_compaction_v2, created_at @@ -1233,7 +1242,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, - $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61 + $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61, $62 ) ON CONFLICT (request_id, api_key_id) DO NOTHING `, prepared.args...) @@ -1275,6 +1284,7 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { modelMappingChain := nullString(log.ModelMappingChain) billingTier := nullString(log.BillingTier) billingMode := nullString(log.BillingMode) + upstreamRequestID := nullString(log.UpstreamRequestID) sessionID := nullString(log.SessionID) requestedModel := strings.TrimSpace(log.RequestedModel) if requestedModel == "" { @@ -1353,6 +1363,7 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { billingTier, billingMode, log.AccountStatsCost, // account_stats_cost + upstreamRequestID, // upstream_request_id sessionID, // session_id log.NativeCompactionV2, createdAt, diff --git a/backend/internal/repository/usage_log_repo_insert_shape_unit_test.go b/backend/internal/repository/usage_log_repo_insert_shape_unit_test.go new file mode 100644 index 0000000000..74f6aafb60 --- /dev/null +++ b/backend/internal/repository/usage_log_repo_insert_shape_unit_test.go @@ -0,0 +1,145 @@ +//go:build unit + +package repository + +import ( + "context" + "database/sql" + "regexp" + "strconv" + "strings" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +var ( + usageLogStaticInsertShapeRe = regexp.MustCompile(`(?s)INSERT INTO usage_logs \((.*?)\) VALUES \((.*?)\)`) + usageLogPlaceholderRe = regexp.MustCompile(`\$(\d+)`) +) + +// newSQLCapturingMock 返回把实际下发 SQL 记录到 captured 的 sqlmock;语句一律视为匹配, +// 参数仍由 WithArgs 校验。 +func newSQLCapturingMock(t *testing.T, captured *[]string) (*sql.DB, sqlmock.Sqlmock) { + t.Helper() + matcher := sqlmock.QueryMatcherFunc(func(_, actualSQL string) error { + *captured = append(*captured, actualSQL) + return nil + }) + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(matcher)) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + return db, mock +} + +// requireStaticInsertMatchesArgTypes 断言手写的 INSERT:列清单长度与 VALUES 占位符数量 +// 都等于 usageLogInsertArgTypes,且占位符恰为 $1..$N 各出现一次。 +func requireStaticInsertMatchesArgTypes(t *testing.T, query string) { + t.Helper() + m := usageLogStaticInsertShapeRe.FindStringSubmatch(query) + require.Len(t, m, 3, "unrecognised INSERT shape:\n%s", query) + + want := len(usageLogInsertArgTypes) + columns := 0 + for _, col := range strings.Split(m[1], ",") { + if strings.TrimSpace(col) != "" { + columns++ + } + } + require.Equal(t, want, columns, "INSERT column list must match usageLogInsertArgTypes") + + seen := make(map[int]struct{}, want) + for _, ph := range usageLogPlaceholderRe.FindAllStringSubmatch(m[2], -1) { + n, err := strconv.Atoi(ph[1]) + require.NoError(t, err) + _, dup := seen[n] + require.False(t, dup, "duplicate placeholder $%d", n) + seen[n] = struct{}{} + } + require.Len(t, seen, want, "VALUES placeholder count must match usageLogInsertArgTypes") + for i := 1; i <= want; i++ { + _, ok := seen[i] + require.True(t, ok, "missing placeholder $%d", i) + } +} + +// TestUsageLogStaticInsertShape_PlaceholdersMatchArgTypes 覆盖两条不经占位符生成器、 +// 直接手写 $1..$N 的 INSERT 路径,防止加列后漏补占位符只在集成测试才暴露。 +func TestUsageLogStaticInsertShape_PlaceholdersMatchArgTypes(t *testing.T) { + upstreamRequestID := "20260902080329-oneapi" + log := &service.UsageLog{ + UserID: 1, + APIKeyID: 2, + AccountID: 3, + RequestID: "client:insert-shape", + UpstreamRequestID: &upstreamRequestID, + Model: "claude-3", + InputTokens: 10, + CreatedAt: time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC), + } + prepared := prepareUsageLogInsert(log) + args := anySliceToDriverValues(prepared.args) + + t.Run("createSingle", func(t *testing.T) { + var captured []string + db, mock := newSQLCapturingMock(t, &captured) + repo := &usageLogRepository{sql: db} + + mock.ExpectQuery("INSERT INTO usage_logs"). + WithArgs(args...). + WillReturnRows(sqlmock.NewRows([]string{"id", "created_at"}).AddRow(int64(1), log.CreatedAt)) + + inserted, err := repo.Create(context.Background(), log) + require.NoError(t, err) + require.True(t, inserted) + require.NoError(t, mock.ExpectationsWereMet()) + require.Len(t, captured, 1) + requireStaticInsertMatchesArgTypes(t, captured[0]) + }) + + t.Run("execUsageLogInsertNoResult", func(t *testing.T) { + var captured []string + db, mock := newSQLCapturingMock(t, &captured) + + mock.ExpectExec("INSERT INTO usage_logs"). + WithArgs(args...). + WillReturnResult(sqlmock.NewResult(0, 1)) + + require.NoError(t, execUsageLogInsertNoResult(context.Background(), db, prepared)) + require.NoError(t, mock.ExpectationsWereMet()) + require.Len(t, captured, 1) + requireStaticInsertMatchesArgTypes(t, captured[0]) + }) +} + +// TestPrepareUsageLogInsert_UpstreamRequestIDArgWiring 把 upstream_request_id 钉在 +// session_id 之前,与参数类型表保持同位;缺失时落 NULL 而不是空串。 +func TestPrepareUsageLogInsert_UpstreamRequestIDArgWiring(t *testing.T) { + upstreamRequestID := "req_upstream_123" + prepared := prepareUsageLogInsert(&service.UsageLog{ + UserID: 1, + APIKeyID: 2, + RequestID: "client:wiring", + Model: "gpt-5", + UpstreamRequestID: &upstreamRequestID, + CreatedAt: time.Now().UTC(), + }) + require.Len(t, prepared.args, len(usageLogInsertArgTypes)) + + idx := len(prepared.args) - 4 + arg, ok := prepared.args[idx].(sql.NullString) + require.True(t, ok, "upstream_request_id arg should be sql.NullString, got %T", prepared.args[idx]) + require.True(t, arg.Valid) + require.Equal(t, upstreamRequestID, arg.String) + require.Equal(t, "text", usageLogInsertArgTypes[idx]) + + absent := prepareUsageLogInsert(&service.UsageLog{UserID: 1, APIKeyID: 2, RequestID: "client:absent", Model: "gpt-5", CreatedAt: time.Now().UTC()}) + nullArg, ok := absent.args[idx].(sql.NullString) + require.True(t, ok) + require.False(t, nullArg.Valid, "absent upstream request id must be NULL") + + require.Contains(t, usageLogSelectColumns, "upstream_request_id") +} diff --git a/backend/internal/repository/usage_log_repo_query.go b/backend/internal/repository/usage_log_repo_query.go index 556dd3ef88..5f637e1e52 100644 --- a/backend/internal/repository/usage_log_repo_query.go +++ b/backend/internal/repository/usage_log_repo_query.go @@ -19,7 +19,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/service" ) -const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, upstream_response_model, upstream_model_mismatch, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, requested_reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, session_id, native_compaction_v2, created_at" +const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, upstream_response_model, upstream_model_mismatch, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, requested_reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, upstream_request_id, session_id, native_compaction_v2, created_at" func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (log *service.UsageLog, err error) { query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE id = $1" @@ -499,6 +499,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e billingTier sql.NullString billingMode sql.NullString accountStatsCost sql.NullFloat64 + upstreamRequestID sql.NullString sessionID sql.NullString nativeCompactionV2 bool createdAt time.Time @@ -564,6 +565,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e &billingTier, &billingMode, &accountStatsCost, + &upstreamRequestID, &sessionID, &nativeCompactionV2, &createdAt, @@ -700,6 +702,9 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e if sessionID.Valid { log.SessionID = &sessionID.String } + if upstreamRequestID.Valid { + log.UpstreamRequestID = &upstreamRequestID.String + } return log, 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 434bb7163f..87d6c8672c 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -99,6 +99,7 @@ func TestUsageLogRepositoryCreateSyncRequestTypeAndLegacyFields(t *testing.T) { sqlmock.AnyArg(), // billing_tier sqlmock.AnyArg(), // billing_mode sqlmock.AnyArg(), // account_stats_cost + sqlmock.AnyArg(), // upstream_request_id sqlmock.AnyArg(), // session_id log.NativeCompactionV2, createdAt, @@ -193,6 +194,7 @@ func TestUsageLogRepositoryCreate_PersistsServiceTier(t *testing.T) { sqlmock.AnyArg(), // billing_tier sqlmock.AnyArg(), // billing_mode sqlmock.AnyArg(), // account_stats_cost + sqlmock.AnyArg(), // upstream_request_id sqlmock.AnyArg(), // session_id log.NativeCompactionV2, createdAt, @@ -955,6 +957,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, sql.NullString{}, sql.NullFloat64{}, + sql.NullString{}, // upstream_request_id sql.NullString{}, false, // native_compaction_v2 now, @@ -1034,6 +1037,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, // billing_tier sql.NullString{}, // billing_mode sql.NullFloat64{}, // account_stats_cost + sql.NullString{}, // upstream_request_id sql.NullString{}, // session_id false, // native_compaction_v2 now, @@ -1096,6 +1100,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, // billing_tier sql.NullString{}, // billing_mode sql.NullFloat64{}, // account_stats_cost + sql.NullString{}, // upstream_request_id sql.NullString{}, // session_id true, // native_compaction_v2 now, @@ -1159,6 +1164,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, // billing_tier sql.NullString{}, // billing_mode sql.NullFloat64{}, // account_stats_cost + sql.NullString{}, // upstream_request_id sql.NullString{}, // session_id false, // native_compaction_v2 now, diff --git a/backend/internal/service/antigravity_gateway_claude.go b/backend/internal/service/antigravity_gateway_claude.go index 3511f60288..1a94bbd747 100644 --- a/backend/internal/service/antigravity_gateway_claude.go +++ b/backend/internal/service/antigravity_gateway_claude.go @@ -450,6 +450,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: *usage, Model: originalModel, UpstreamModel: billingModel, diff --git a/backend/internal/service/antigravity_gateway_compat.go b/backend/internal/service/antigravity_gateway_compat.go index 1d6d39e1aa..f4e5911819 100644 --- a/backend/internal/service/antigravity_gateway_compat.go +++ b/backend/internal/service/antigravity_gateway_compat.go @@ -364,6 +364,7 @@ func (s *AntigravityGatewayService) consumeAntigravityCompatResponse( return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: *streamResult.usage, Model: call.request.originalModel, UpstreamModel: call.billingModel, diff --git a/backend/internal/service/antigravity_gateway_gemini.go b/backend/internal/service/antigravity_gateway_gemini.go index 77a7be3df7..520291b452 100644 --- a/backend/internal/service/antigravity_gateway_gemini.go +++ b/backend/internal/service/antigravity_gateway_gemini.go @@ -434,6 +434,7 @@ handleSuccess: return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: *usage, Model: originalModel, UpstreamModel: forwardedModel, diff --git a/backend/internal/service/gateway_anthropic_passthrough.go b/backend/internal/service/gateway_anthropic_passthrough.go index bf899211e8..664a93fbc7 100644 --- a/backend/internal/service/gateway_anthropic_passthrough.go +++ b/backend/internal/service/gateway_anthropic_passthrough.go @@ -272,6 +272,7 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput( return &ForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: *usage, Model: input.OriginalModel, UpstreamModel: input.RequestModel, diff --git a/backend/internal/service/gateway_bedrock.go b/backend/internal/service/gateway_bedrock.go index 77baf3f29b..457f233192 100644 --- a/backend/internal/service/gateway_bedrock.go +++ b/backend/internal/service/gateway_bedrock.go @@ -159,6 +159,7 @@ func (s *GatewayService) forwardBedrock( return &ForwardResult{ RequestID: resp.Header.Get("x-amzn-requestid"), + UpstreamHeaders: resp.Header, Usage: *usage, Model: reqModel, UpstreamModel: mappedModel, diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index 2571079403..a370371d89 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -862,6 +862,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A return &ForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: *usage, Model: originalModel, // 使用原始模型用于计费和日志 UpstreamModel: mappedModel, diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index b3d6c565ba..60b8bb6506 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -337,6 +337,7 @@ func (s *GatewayService) handleCCBufferedFromAnthropic( return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, UpstreamModel: mappedModel, @@ -389,6 +390,7 @@ func (s *GatewayService) handleCCStreamingFromAnthropic( resultWithUsage := func() *ForwardResult { return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, UpstreamModel: mappedModel, diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index 7c6cde8328..49cf40bde5 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -466,6 +466,7 @@ func (s *GatewayService) handleResponsesBufferedStreamingResponse( return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, UpstreamModel: mappedModel, @@ -514,6 +515,7 @@ func (s *GatewayService) handleResponsesStreamingResponse( resultWithUsage := func() *ForwardResult { return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, UpstreamModel: mappedModel, diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 33652e733c..54ebcfde18 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -610,8 +610,10 @@ type AudioUsage struct { type ForwardResult struct { RequestID string - Usage ClaudeUsage - Model string + // UpstreamHeaders 是直接上游的响应头,用于按账户配置解析上游请求标识。 + UpstreamHeaders http.Header + Usage ClaudeUsage + Model string // UpstreamModel is the actual upstream model after mapping. // Prefer empty when it is identical to Model; persistence normalizes equal values away as no-op mappings. UpstreamModel string diff --git a/backend/internal/service/gateway_upstream_response.go b/backend/internal/service/gateway_upstream_response.go index 5663333a91..7c173ca456 100644 --- a/backend/internal/service/gateway_upstream_response.go +++ b/backend/internal/service/gateway_upstream_response.go @@ -675,6 +675,7 @@ func partialStreamUsageResult(c *gin.Context, resp *http.Response, streamResult } return &ForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: *streamResult.usage, Model: model, UpstreamModel: upstreamModel, diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 069758baae..e134fbdb17 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -1143,6 +1143,7 @@ func (s *GatewayService) buildRecordUsageLog( APIKeyID: apiKey.ID, AccountID: account.ID, RequestID: requestID, + UpstreamRequestID: usageUpstreamRequestIDPtr(account, result.UpstreamHeaders, false), Model: result.Model, RequestedModel: requestedModel, UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel), diff --git a/backend/internal/service/gemini_chat_completions_compat_service.go b/backend/internal/service/gemini_chat_completions_compat_service.go index a56a1b3ebc..21abe5f894 100644 --- a/backend/internal/service/gemini_chat_completions_compat_service.go +++ b/backend/internal/service/gemini_chat_completions_compat_service.go @@ -290,6 +290,7 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions( return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: *usage, Model: originalModel, UpstreamModel: mappedModel, diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 5f9854167d..51ab2befb0 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -1103,6 +1103,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: *usage, Model: originalModel, UpstreamModel: mappedModel, @@ -1451,13 +1452,14 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. estimated := estimateGeminiCountTokens(body) c.JSON(http.StatusOK, map[string]any{"totalTokens": estimated}) return &ForwardResult{ - RequestID: requestID, - Usage: ClaudeUsage{}, - Model: originalModel, - UpstreamModel: mappedModel, - Stream: false, - Duration: time.Since(startTime), - FirstTokenMs: nil, + RequestID: requestID, + UpstreamHeaders: resp.Header, + Usage: ClaudeUsage{}, + Model: originalModel, + UpstreamModel: mappedModel, + Stream: false, + Duration: time.Since(startTime), + FirstTokenMs: nil, }, nil } @@ -1604,6 +1606,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: *usage, Model: originalModel, UpstreamModel: mappedModel, diff --git a/backend/internal/service/grok_audio.go b/backend/internal/service/grok_audio.go index 0cd138cad5..c726b339df 100644 --- a/backend/internal/service/grok_audio.go +++ b/backend/internal/service/grok_audio.go @@ -111,11 +111,12 @@ func (s *OpenAIGatewayService) ForwardGrokVoice(ctx context.Context, c *gin.Cont upstreamID := firstNonEmpty(resp.Header.Get("x-request-id"), resp.Header.Get("xai-request-id")) return &OpenAIForwardResult{ // Forced durable money-event id so usage_billing_dedup cannot collapse under a reused client id. - RequestID: StableGrokAudioBillingRequestID(upstreamID), - Model: baseEndpoint, - UpstreamModel: baseEndpoint, - Duration: time.Since(started), - AudioUsage: audioUsage, + RequestID: StableGrokAudioBillingRequestID(upstreamID), + UpstreamHeaders: resp.Header, + Model: baseEndpoint, + UpstreamModel: baseEndpoint, + Duration: time.Since(started), + AudioUsage: audioUsage, }, nil } diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go index 85dd1b14ec..eb4e9fc5bd 100644 --- a/backend/internal/service/grok_media.go +++ b/backend/internal/service/grok_media.go @@ -739,6 +739,7 @@ func (s *OpenAIGatewayService) ForwardGrokMedia( } return &OpenAIForwardResult{ RequestID: requestIDHeader, + UpstreamHeaders: resp.Header, ResponseID: usage.ResponseID, Usage: usage.Usage, Model: resultModel, @@ -873,6 +874,7 @@ func (s *OpenAIGatewayService) forwardGrokMediaVideoContent( // (same path as status polling). Pending snapshot is merged in the handler. result := &OpenAIForwardResult{ RequestID: contentRequestID, + UpstreamHeaders: contentResp.Header, ResponseHeaders: contentResp.Header.Clone(), Duration: time.Since(startTime), } diff --git a/backend/internal/service/openai_alpha_search.go b/backend/internal/service/openai_alpha_search.go index ac242f050c..caff28208b 100644 --- a/backend/internal/service/openai_alpha_search.go +++ b/backend/internal/service/openai_alpha_search.go @@ -128,11 +128,12 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co return nil, nil } return &OpenAIForwardResult{ - RequestID: strings.TrimSpace(resp.Header.Get("x-request-id")), - Model: requestedModel, - UpstreamModel: upstreamModel, - Duration: time.Since(upstreamStart), - WebSearchCalls: 1, + RequestID: strings.TrimSpace(resp.Header.Get("x-request-id")), + UpstreamHeaders: resp.Header, + Model: requestedModel, + UpstreamModel: upstreamModel, + Duration: time.Since(upstreamStart), + WebSearchCalls: 1, }, nil } @@ -212,6 +213,7 @@ func (s *OpenAIGatewayService) forwardAlphaSearchViaResponsesWebSearch( c.Data(http.StatusOK, "application/json", alphaRespBody) return &OpenAIForwardResult{ RequestID: strings.TrimSpace(resp.Header.Get("x-request-id")), + UpstreamHeaders: resp.Header, Model: requestedModel, UpstreamModel: upstreamModel, UpstreamEndpoint: "/v1/responses", diff --git a/backend/internal/service/openai_embeddings.go b/backend/internal/service/openai_embeddings.go index 87b1904923..6512c2e3d6 100644 --- a/backend/internal/service/openai_embeddings.go +++ b/backend/internal/service/openai_embeddings.go @@ -160,13 +160,14 @@ func (s *OpenAIGatewayService) ForwardEmbeddings( writeOpenAIEmbeddingsUpstreamResponse(c, resp, respBody, s.responseHeaderFilter) return &OpenAIForwardResult{ - RequestID: firstNonEmptyString(resp.Header.Get("x-request-id"), resp.Header.Get("request-id")), - Usage: extractOpenAIEmbeddingsUsage(respBody), - Model: originalModel, - BillingModel: billingModel, - UpstreamModel: upstreamModel, - Stream: false, - Duration: time.Since(startTime), + RequestID: firstNonEmptyString(resp.Header.Get("x-request-id"), resp.Header.Get("request-id")), + UpstreamHeaders: resp.Header, + Usage: extractOpenAIEmbeddingsUsage(respBody), + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + Stream: false, + Duration: time.Since(startTime), }, nil } diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index c9a7e34b6a..6507d8b57c 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -577,6 +577,7 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( result := &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, BillingModel: billingModel, @@ -698,6 +699,7 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( resultWithUsage := func() *OpenAIForwardResult { out := &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, BillingModel: billingModel, diff --git a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go index 6e24fec392..1f9262055e 100644 --- a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -281,6 +281,7 @@ func (s *OpenAIGatewayService) handleCCBufferedFromNativeAnthropic( return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: claudeUsageToOpenAIUsage(&usage), Model: originalModel, BillingModel: billingModel, @@ -336,6 +337,7 @@ func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic( resultWithUsage := func() *OpenAIForwardResult { return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: claudeUsageToOpenAIUsage(&usage), Model: originalModel, BillingModel: billingModel, diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index a98c11e5e8..7f95b05a74 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -356,6 +356,7 @@ func (s *OpenAIGatewayService) streamRawChatCompletions( resultWithUsage := func() *OpenAIForwardResult { return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, BillingModel: billingModel, @@ -524,6 +525,7 @@ func (s *OpenAIGatewayService) bufferRawChatCompletions( return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, BillingModel: billingModel, diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 18b5aff28e..4f56879d4f 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -1238,6 +1238,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco forwardResult := &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, ResponseID: responseID, Usage: *usage, Model: originalModel, diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index efcbaa8d44..8047590c1c 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -248,6 +248,7 @@ func (s *OpenAIGatewayService) forwardGrokResponses( reasoningEffort := extractOpenAIReasoningEffortFromBody(patchedBody, originalModel) result := &OpenAIForwardResult{ RequestID: firstNonEmpty(resp.Header.Get("x-request-id"), resp.Header.Get("xai-request-id")), + UpstreamHeaders: resp.Header, ResponseID: responseID, Usage: *usage, Model: originalModel, diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index b12ab68044..1b3819a17e 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -651,6 +651,7 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( result := &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, ResponseID: finalResponse.ID, Usage: usage, Model: originalModel, @@ -955,6 +956,7 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( resultWithUsage := func() *OpenAIForwardResult { out := &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, ResponseID: responseID, Usage: usage, Model: originalModel, diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go index 4fb5c2ec5a..444b5b999f 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -244,6 +244,7 @@ func (s *OpenAIGatewayService) handleNativeAnthropicBufferedResponse( return &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: claudeUsageToOpenAIUsage(usage), Model: originalModel, BillingModel: billingModel, @@ -517,6 +518,7 @@ func (s *OpenAIGatewayService) nativeAnthropicStreamResult( } return &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: claudeUsageToOpenAIUsage(usage), Model: originalModel, BillingModel: billingModel, diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index c4fcdd772f..270762a0dc 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback.go @@ -163,6 +163,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsAnthropic( return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, BillingModel: billingModel, @@ -224,6 +225,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsAnthropic( // (mirrors forwardResponsesViaRawChatCompletions). return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, BillingModel: billingModel, @@ -260,6 +262,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsAnthropic( return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, BillingModel: billingModel, diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index ecca154781..107ac2ada2 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -519,6 +519,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( forwardResult := &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, ResponseID: responseID, Usage: *usage, Model: reqModel, diff --git a/backend/internal/service/openai_gateway_responses_anthropic_native.go b/backend/internal/service/openai_gateway_responses_anthropic_native.go index 423cba3459..9a513bce1e 100644 --- a/backend/internal/service/openai_gateway_responses_anthropic_native.go +++ b/backend/internal/service/openai_gateway_responses_anthropic_native.go @@ -287,6 +287,7 @@ func (s *OpenAIGatewayService) handleResponsesBufferedFromNativeAnthropic( return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: claudeUsageToOpenAIUsage(&usage), Model: originalModel, BillingModel: billingModel, @@ -340,6 +341,7 @@ func (s *OpenAIGatewayService) handleResponsesStreamingFromNativeAnthropic( resultWithUsage := func() *OpenAIForwardResult { return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: claudeUsageToOpenAIUsage(&usage), Model: originalModel, BillingModel: billingModel, diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go index e5ba05d5f9..9939c5b469 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go @@ -154,6 +154,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, BillingModel: billingModel, @@ -225,6 +226,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( if scan.Err != nil { return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: scan.Usage, Model: originalModel, BillingModel: billingModel, @@ -240,6 +242,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( if err := state.ValidateToolCallArguments(); err != nil { return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: scan.Usage, Model: originalModel, BillingModel: billingModel, @@ -271,6 +274,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: scan.Usage, Model: originalModel, BillingModel: billingModel, diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index aaa2e30af7..bc115d2186 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -233,8 +233,10 @@ type OpenAIUsage struct { type OpenAIForwardResult struct { RequestID string ResponseID string - Usage OpenAIUsage - Model string // 原始模型(用于响应和日志显示) + // UpstreamHeaders 是直接上游的响应头,用于按账户配置解析上游请求标识。 + UpstreamHeaders http.Header + Usage OpenAIUsage + Model string // 原始模型(用于响应和日志显示) // BillingModel is the model used for cost calculation. // When non-empty, CalculateCost uses this instead of Model. // This is set by the Anthropic Messages conversion path where diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index cb998b55fa..ea5345514e 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -371,6 +371,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec APIKeyID: apiKey.ID, AccountID: account.ID, RequestID: requestID, + UpstreamRequestID: usageUpstreamRequestIDPtr(account, result.UpstreamHeaders, result.OpenAIWSMode), Model: result.Model, RequestedModel: requestedModel, UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel), diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go index c661aade36..9062104361 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -682,6 +682,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( if streamCount > 0 { return &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: streamUsage, Model: requestModel, UpstreamModel: upstreamModel, @@ -703,6 +704,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( firstTokenMs = ttft return &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: usage, Model: requestModel, UpstreamModel: upstreamModel, @@ -726,6 +728,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( } return &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: usage, Model: requestModel, UpstreamModel: upstreamModel, diff --git a/backend/internal/service/openai_images_responses.go b/backend/internal/service/openai_images_responses.go index 434c352da2..8fb73158eb 100644 --- a/backend/internal/service/openai_images_responses.go +++ b/backend/internal/service/openai_images_responses.go @@ -1891,6 +1891,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth( if imageCount > 0 { return &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: usage, Model: requestModel, UpstreamModel: requestModel, @@ -1935,6 +1936,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth( } return &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: usage, Model: requestModel, UpstreamModel: requestModel, diff --git a/backend/internal/service/upstream_request_id.go b/backend/internal/service/upstream_request_id.go new file mode 100644 index 0000000000..1906601d35 --- /dev/null +++ b/backend/internal/service/upstream_request_id.go @@ -0,0 +1,84 @@ +package service + +import ( + "net/http" + "strings" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "golang.org/x/net/http/httpguts" +) + +// AccountExtraUpstreamRequestIDHeader 是账户 extra 中的键,值为直接上游声明请求标识的响应头名。 +// 未指定时不记录上游请求标识。 +const AccountExtraUpstreamRequestIDHeader = "upstream_request_id_header" + +const ( + maxUpstreamRequestIDHeaderNameLen = 64 + // maxUsageUpstreamRequestIDLen 与 usage_logs.upstream_request_id VARCHAR(128) 对齐。 + maxUsageUpstreamRequestIDLen = 128 +) + +// UpstreamRequestIDHeaderName 返回账户指定的上游请求标识头名,未指定时为空串。 +func UpstreamRequestIDHeaderName(account *Account) string { + if account == nil { + return "" + } + return strings.TrimSpace(account.GetExtraString(AccountExtraUpstreamRequestIDHeader)) +} + +// UpstreamRequestIDFromHeaders 从直接上游的响应头解析请求标识。 +// 只读账户指定的头;账户未指定头名时恒为空串。 +func UpstreamRequestIDFromHeaders(account *Account, h http.Header) string { + if len(h) == 0 { + return "" + } + name := UpstreamRequestIDHeaderName(account) + if name == "" { + return "" + } + return strings.TrimSpace(h.Get(name)) +} + +// usageUpstreamRequestIDPtr 生成落库到 usage_logs.upstream_request_id 的值。 +// WS 轮次没有 HTTP 响应头,保持 nil;超长时截断到列宽而不是让整条用量行失败。 +func usageUpstreamRequestIDPtr(account *Account, h http.Header, wsMode bool) *string { + if wsMode { + return nil + } + id := UpstreamRequestIDFromHeaders(account, h) + if id == "" { + return nil + } + if len(id) > maxUsageUpstreamRequestIDLen { + id = id[:maxUsageUpstreamRequestIDLen] + } + return &id +} + +// ValidateUpstreamRequestIDHeaderExtra 校验并规范化 extra 中的上游请求标识头名: +// 必须是合法的 HTTP 头字段名且不超过 64 字节;空白值视为未指定并从 extra 中移除。 +func ValidateUpstreamRequestIDHeaderExtra(extra map[string]any) error { + if extra == nil { + return nil + } + raw, ok := extra[AccountExtraUpstreamRequestIDHeader] + if !ok || raw == nil { + return nil + } + name, ok := raw.(string) + if !ok { + return infraerrors.BadRequest("INVALID_UPSTREAM_REQUEST_ID_HEADER", + "upstream_request_id_header must be a string") + } + name = strings.TrimSpace(name) + if name == "" { + delete(extra, AccountExtraUpstreamRequestIDHeader) + return nil + } + if len(name) > maxUpstreamRequestIDHeaderNameLen || !httpguts.ValidHeaderFieldName(name) { + return infraerrors.BadRequest("INVALID_UPSTREAM_REQUEST_ID_HEADER", + "upstream_request_id_header must be a valid HTTP header name of at most 64 bytes") + } + extra[AccountExtraUpstreamRequestIDHeader] = name + return nil +} diff --git a/backend/internal/service/upstream_request_id_test.go b/backend/internal/service/upstream_request_id_test.go new file mode 100644 index 0000000000..bdeebaf198 --- /dev/null +++ b/backend/internal/service/upstream_request_id_test.go @@ -0,0 +1,78 @@ +package service + +import ( + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUpstreamRequestIDFromHeaders_UnconfiguredAccountRecordsNothing(t *testing.T) { + h := http.Header{} + h.Set("X-Client-Request-ID", "sub2api-client") + h.Set("X-Request-ID", "sub2api-local") + h.Set("X-Oneapi-Request-Id", "oneapi-1") + h.Set("Request-Id", "req_official") + h.Set("xai-request-id", "xai-1") + h.Set("x-goog-request-id", "goog-1") + + require.Equal(t, "", UpstreamRequestIDFromHeaders(nil, h)) + for _, platform := range []string{PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok} { + require.Equal(t, "", UpstreamRequestIDFromHeaders(&Account{Platform: platform}, h), platform) + } + blank := &Account{Platform: PlatformOpenAI, Extra: map[string]any{AccountExtraUpstreamRequestIDHeader: " "}} + require.Equal(t, "", UpstreamRequestIDFromHeaders(blank, h)) +} + +func TestUpstreamRequestIDFromHeaders_ReadsOnlyConfiguredHeader(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Extra: map[string]any{AccountExtraUpstreamRequestIDHeader: " x-oneapi-request-id "}, + } + h := http.Header{} + h.Set("X-Request-ID", "passthrough-from-real-upstream") + require.Equal(t, "", UpstreamRequestIDFromHeaders(account, h)) + + h.Set("X-Oneapi-Request-Id", " oneapi-2 ") + require.Equal(t, "oneapi-2", UpstreamRequestIDFromHeaders(account, h)) + require.Equal(t, "", UpstreamRequestIDFromHeaders(account, nil)) + + official := &Account{Platform: PlatformAnthropic, Extra: map[string]any{AccountExtraUpstreamRequestIDHeader: "request-id"}} + only := http.Header{} + only.Set("Request-Id", "req_official") + require.Equal(t, "req_official", UpstreamRequestIDFromHeaders(official, only)) +} + +func TestUsageUpstreamRequestIDPtr(t *testing.T) { + account := &Account{Extra: map[string]any{AccountExtraUpstreamRequestIDHeader: "X-Request-ID"}} + h := http.Header{} + h.Set("X-Request-ID", strings.Repeat("a", 200)) + require.Nil(t, usageUpstreamRequestIDPtr(account, h, true)) + require.Nil(t, usageUpstreamRequestIDPtr(account, http.Header{}, false)) + require.Nil(t, usageUpstreamRequestIDPtr(nil, h, false)) + require.Nil(t, usageUpstreamRequestIDPtr(&Account{}, h, false)) + + got := usageUpstreamRequestIDPtr(account, h, false) + require.NotNil(t, got) + require.Len(t, *got, maxUsageUpstreamRequestIDLen) +} + +func TestValidateUpstreamRequestIDHeaderExtra(t *testing.T) { + require.NoError(t, ValidateUpstreamRequestIDHeaderExtra(nil)) + require.NoError(t, ValidateUpstreamRequestIDHeaderExtra(map[string]any{})) + + blank := map[string]any{AccountExtraUpstreamRequestIDHeader: " "} + require.NoError(t, ValidateUpstreamRequestIDHeaderExtra(blank)) + _, present := blank[AccountExtraUpstreamRequestIDHeader] + require.False(t, present, "blank header name must be removed") + + valid := map[string]any{AccountExtraUpstreamRequestIDHeader: " X-Oneapi-Request-Id "} + require.NoError(t, ValidateUpstreamRequestIDHeaderExtra(valid)) + require.Equal(t, "X-Oneapi-Request-Id", valid[AccountExtraUpstreamRequestIDHeader]) + + require.Error(t, ValidateUpstreamRequestIDHeaderExtra(map[string]any{AccountExtraUpstreamRequestIDHeader: 1})) + require.Error(t, ValidateUpstreamRequestIDHeaderExtra(map[string]any{AccountExtraUpstreamRequestIDHeader: "X Request Id"})) + require.Error(t, ValidateUpstreamRequestIDHeaderExtra(map[string]any{AccountExtraUpstreamRequestIDHeader: "X-Request-Id:"})) + require.Error(t, ValidateUpstreamRequestIDHeaderExtra(map[string]any{AccountExtraUpstreamRequestIDHeader: strings.Repeat("x", 65)})) +} diff --git a/backend/internal/service/usage_log.go b/backend/internal/service/usage_log.go index e20e9d56ca..64df9e2695 100644 --- a/backend/internal/service/usage_log.go +++ b/backend/internal/service/usage_log.go @@ -186,6 +186,10 @@ type UsageLog struct { // (e.g. the session_id / X-Session-Id headers). Nil when the client sent no // valid session header. It is never derived from prompt_cache_key or content. SessionID *string + // UpstreamRequestID 是直接上游在响应头中声明的请求标识,只读账户 + // extra.upstream_request_id_header 指定的头;账户未指定头名、WS 轮次 + // 与上游没有该头的路径为 nil。 + UpstreamRequestID *string // Cache TTL Override 标记(管理员强制替换了缓存 TTL 计费) CacheTTLOverridden bool diff --git a/backend/migrations/232_add_usage_log_upstream_request_id.sql b/backend/migrations/232_add_usage_log_upstream_request_id.sql new file mode 100644 index 0000000000..917739c019 --- /dev/null +++ b/backend/migrations/232_add_usage_log_upstream_request_id.sql @@ -0,0 +1,5 @@ +-- usage_logs.upstream_request_id 记录直接上游在响应头中声明的请求标识, +-- 头名由账户 extra.upstream_request_id_header 指定,未指定时按默认识别链取值。 +-- NULL 表示历史行、WS 轮次或该路径没有上游请求标识。 +ALTER TABLE usage_logs + ADD COLUMN IF NOT EXISTS upstream_request_id VARCHAR(128); diff --git a/backend/migrations/233_add_usage_log_upstream_request_id_index_notx.sql b/backend/migrations/233_add_usage_log_upstream_request_id_index_notx.sql new file mode 100644 index 0000000000..8ebeeb2458 --- /dev/null +++ b/backend/migrations/233_add_usage_log_upstream_request_id_index_notx.sql @@ -0,0 +1,4 @@ +-- 只索引带上游请求标识的行;CONCURRENTLY 避免阻塞热表写入。 +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_upstream_request_id + ON usage_logs (upstream_request_id) + WHERE upstream_request_id IS NOT NULL; diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 8702ca7595..a8db1d4e59 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -2922,6 +2922,12 @@ + +
@@ -3798,6 +3804,7 @@ import BaseDialog from '@/components/common/BaseDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import Select from '@/components/common/Select.vue' import HelpTooltip from '@/components/common/HelpTooltip.vue' +import UpstreamRequestIdHeaderField from '@/components/account/UpstreamRequestIdHeaderField.vue' import PlatformIcon from '@/components/common/PlatformIcon.vue' import Icon from '@/components/icons/Icon.vue' import ProxySelector from '@/components/common/ProxySelector.vue' @@ -3871,6 +3878,14 @@ const oauthStepTitle = computed(() => { }) // Platform-specific hints for API Key type +// 上游ID:直接上游声明请求标识的响应头名,留空不记录。 +const upstreamRequestIdHeader = ref('') +const withUpstreamRequestIdHeader = | undefined>(extra: T): T | Record => { + const name = upstreamRequestIdHeader.value.trim() + if (!name) return extra + return { ...(extra || {}), upstream_request_id_header: name } +} + const baseUrlHint = computed(() => { if (form.platform === 'openai') return t('admin.accounts.openai.baseUrlHint') if (form.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint') @@ -5648,7 +5663,7 @@ const handleSubmit = async () => { await doCreateAccount({ ...form, group_ids: form.group_ids, - extra, + extra: withUpstreamRequestIdHeader(extra), upstream_billing_probe_enabled: upstreamBillingAutoProbeEnabled.value, auto_pause_on_expired: autoPauseOnExpired.value }) @@ -5711,9 +5726,9 @@ const createAccountAndFinish = async ( return } // Inject quota limits for apikey/bedrock accounts - let finalExtra = extra + let finalExtra = withUpstreamRequestIdHeader(extra) if (type === 'apikey' || type === 'bedrock') { - const quotaExtra: Record = { ...(extra || {}) } + const quotaExtra: Record = { ...(finalExtra || {}) } if (editQuotaLimit.value != null && editQuotaLimit.value > 0) { quotaExtra.quota_limit = editQuotaLimit.value } @@ -5837,7 +5852,7 @@ const handleGrokValidateRT = async (refreshTokenInput: string) => { platform: 'grok', type: 'oauth', credentials, - extra, + extra: withUpstreamRequestIdHeader(extra), proxy_id: form.proxy_id, concurrency: form.concurrency, load_factor: form.load_factor ?? undefined, @@ -6014,7 +6029,7 @@ const handleGrokAuthorizePassword = async (emailPasswordInput: string) => { platform: 'grok', type: 'oauth', credentials, - extra, + extra: withUpstreamRequestIdHeader(extra), proxy_id: form.proxy_id, concurrency: form.concurrency, load_factor: form.load_factor ?? undefined, @@ -6113,7 +6128,7 @@ const handleOpenAIExchange = async (authCode: string) => { platform: 'openai', type: 'oauth', credentials, - extra, + extra: withUpstreamRequestIdHeader(extra), proxy_id: form.proxy_id, concurrency: form.concurrency, load_factor: form.load_factor ?? undefined, @@ -6228,7 +6243,7 @@ const handleOpenAIImportCodexSession = async (content: string) => { expires_at: form.expires_at, auto_pause_on_expired: autoPauseOnExpired.value, credential_extras: Object.keys(credentialExtras).length > 0 ? credentialExtras : undefined, - extra, + extra: withUpstreamRequestIdHeader(extra), update_existing: true }) @@ -6306,7 +6321,7 @@ const handleOpenAIImportCodexPAT = async (accessToken: string) => { expires_at: form.expires_at, auto_pause_on_expired: autoPauseOnExpired.value, credential_extras: Object.keys(credentialExtras).length > 0 ? credentialExtras : undefined, - extra + extra: withUpstreamRequestIdHeader(extra) }) appStore.showSuccess(t('admin.accounts.messages.accountCreated')) @@ -6394,7 +6409,7 @@ const handleOpenAIBatchRT = async (refreshTokenInput: string, clientId?: string) platform: 'openai', type: 'oauth', credentials, - extra, + extra: withUpstreamRequestIdHeader(extra), proxy_id: form.proxy_id, concurrency: form.concurrency, load_factor: form.load_factor ?? undefined, @@ -6493,7 +6508,7 @@ const handleAntigravityValidateRT = async (refreshTokenInput: string) => { platform: 'antigravity', type: 'oauth', credentials, - extra: {}, + extra: withUpstreamRequestIdHeader({}), proxy_id: form.proxy_id, concurrency: form.concurrency, load_factor: form.load_factor ?? undefined, @@ -6874,7 +6889,7 @@ const handleCookieAuth = async (sessionKey: string) => { platform: form.platform, type: addMethod.value, // Use addMethod as type: 'oauth' or 'setup-token' credentials, - extra, + extra: withUpstreamRequestIdHeader(extra), proxy_id: form.proxy_id, concurrency: form.concurrency, load_factor: form.load_factor ?? undefined, diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 8ef424f4d9..1c0beebf50 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1543,6 +1543,12 @@
+ +
@@ -2887,6 +2893,7 @@ import BaseDialog from '@/components/common/BaseDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import Select from '@/components/common/Select.vue' import HelpTooltip from '@/components/common/HelpTooltip.vue' +import UpstreamRequestIdHeaderField from '@/components/account/UpstreamRequestIdHeaderField.vue' import Toggle from '@/components/common/Toggle.vue' import Icon from '@/components/icons/Icon.vue' import ProxySelector from '@/components/common/ProxySelector.vue' @@ -3207,6 +3214,12 @@ const autoResetCredit7dThreshold = ref(100) const upstreamBillingAutoProbeEnabled = ref(false) const upstreamBillingRateSyncEnabled = ref(false) const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling +// 上游ID:直接上游声明请求标识的响应头名,留空不记录。 +const upstreamRequestIdHeader = ref('') +const readUpstreamRequestIdHeader = (extra: unknown): string => { + const value = (extra as Record | undefined)?.upstream_request_id_header + return typeof value === 'string' ? value : '' +} const allowOverages = ref(false) // For antigravity accounts: enable AI Credits overages const antigravityProjectId = ref('') const antigravityModelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist') @@ -3728,6 +3741,7 @@ const syncFormFromAccount = (newAccount: Account | null) => { const extra = newAccount.extra as Record | undefined mixedScheduling.value = extra?.mixed_scheduling === true allowOverages.value = extra?.allow_overages === true + upstreamRequestIdHeader.value = readUpstreamRequestIdHeader(extra) autoPause5hThreshold.value = typeof extra?.auto_pause_5h_threshold === 'number' ? extra.auto_pause_5h_threshold * 100 : null autoPause7dThreshold.value = typeof extra?.auto_pause_7d_threshold === 'number' ? extra.auto_pause_7d_threshold * 100 : null autoPause5hDisabled.value = extra?.auto_pause_5h_disabled === true @@ -5337,6 +5351,19 @@ const handleSubmit = async () => { updatePayload.extra = newExtra } + // 上游ID头名只在改动时写回 extra,避免用弹窗打开时的快照覆盖运行态键。 + const nextUpstreamRequestIdHeader = upstreamRequestIdHeader.value.trim() + if (nextUpstreamRequestIdHeader !== readUpstreamRequestIdHeader(props.account.extra)) { + const currentExtra = (updatePayload.extra as Record) || (props.account.extra as Record) || {} + const newExtra: Record = { ...currentExtra } + if (nextUpstreamRequestIdHeader) { + newExtra.upstream_request_id_header = nextUpstreamRequestIdHeader + } else { + delete newExtra.upstream_request_id_header + } + updatePayload.extra = newExtra + } + const canContinue = await ensureAntigravityMixedChannelConfirmed(async () => { await submitUpdateAccount(accountID, updatePayload) }) diff --git a/frontend/src/components/account/UpstreamRequestIdHeaderField.vue b/frontend/src/components/account/UpstreamRequestIdHeaderField.vue new file mode 100644 index 0000000000..2f90ab5a73 --- /dev/null +++ b/frontend/src/components/account/UpstreamRequestIdHeaderField.vue @@ -0,0 +1,78 @@ + + + diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts index 876e719fd7..4a052d61f8 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts @@ -247,6 +247,27 @@ describe('CreateAccountModal OpenAI long-context billing', () => { expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false) }) + it('omits the upstream request id header from extra when left empty', async () => { + await submitApiKeyAccount('openai') + + expect(createAccountMock).toHaveBeenCalledTimes(1) + expect(createAccountMock.mock.calls[0]?.[0]?.extra).not.toHaveProperty('upstream_request_id_header') + }) + + it('sends the trimmed upstream request id header in extra when filled', async () => { + const wrapper = mountModal() + await selectButtonByText(wrapper, 'OpenAI') + await selectButtonByText(wrapper, 'API Key') + await wrapper.get('form#create-account-form input[type="text"]').setValue('openai account') + await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key') + await wrapper.get('[data-testid="upstream-request-id-header"]').setValue(' X-Oneapi-Request-Id ') + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await flushPromises() + + expect(createAccountMock).toHaveBeenCalledTimes(1) + expect(createAccountMock.mock.calls[0]?.[0]?.extra?.upstream_request_id_header).toBe('X-Oneapi-Request-Id') + }) + it('persists upstream model metadata after creating an account from preview', async () => { const wrapper = mountModal() await selectButtonByText(wrapper, 'OpenAI') diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index d81ffacebe..8232e2e619 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -675,6 +675,49 @@ describe('EditAccountModal', () => { ) }) + it('writes the upstream request id header into extra only when it changes', async () => { + const account = buildAccount() + account.extra = { openai_compact_mode: 'force_on' } + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const untouched = mountModal(account) + await untouched.get('form#edit-account-form').trigger('submit.prevent') + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.upstream_request_id_header).toBeUndefined() + + updateAccountMock.mockClear() + const wrapper = mountModal(account) + await wrapper.get('[data-testid="upstream-request-id-header"]').setValue(' X-Oneapi-Request-Id ') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra).toMatchObject({ + openai_compact_mode: 'force_on', + upstream_request_id_header: 'X-Oneapi-Request-Id' + }) + }) + + it('removes the upstream request id header from extra when cleared', async () => { + const account = buildAccount() + account.extra = { upstream_request_id_header: 'X-Request-ID' } + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + expect((wrapper.get('[data-testid="upstream-request-id-header"]').element as HTMLInputElement).value).toBe('X-Request-ID') + await wrapper.get('[data-testid="upstream-request-id-header"]').setValue('') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra).toBeDefined() + expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('upstream_request_id_header') + }) + it('hides the Codex namespace flatten toggle for non-OAuth OpenAI accounts', async () => { const account = buildAccount() const wrapper = mountModal(account) diff --git a/frontend/src/components/account/__tests__/UpstreamRequestIdHeaderField.spec.ts b/frontend/src/components/account/__tests__/UpstreamRequestIdHeaderField.spec.ts new file mode 100644 index 0000000000..363a883ee7 --- /dev/null +++ b/frontend/src/components/account/__tests__/UpstreamRequestIdHeaderField.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import UpstreamRequestIdHeaderField from '../UpstreamRequestIdHeaderField.vue' + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string, params?: Record) => + params?.platform ? `${key}:${String(params.platform)}` : key, + }), + } +}) + +function mountField(props: { platform?: string; type?: string; modelValue?: string }) { + return mount(UpstreamRequestIdHeaderField, { + props, + global: { + stubs: { Teleport: true }, + }, + }) +} + +describe('UpstreamRequestIdHeaderField', () => { + it('lists relay and official header examples for API key accounts', () => { + const wrapper = mountField({ platform: 'openai', type: 'apikey' }) + const text = wrapper.text() + + expect(text).toContain('admin.accounts.upstreamRequestIdHeaderHelp.intro') + expect(text).toContain('admin.accounts.upstreamRequestIdHeaderHelp.examplesTitle') + expect(text).toContain('X-Client-Request-ID') + expect(text).toContain('admin.accounts.upstreamRequestIdHeaderHelp.sub2apiNote') + expect(text).toContain('X-Oneapi-Request-Id') + expect(text).toContain('admin.accounts.upstreamRequestIdHeaderHelp.official:OpenAI') + expect(text).toContain('x-request-id') + }) + + it('lists only the official header for accounts that connect to the platform directly', () => { + const wrapper = mountField({ platform: 'anthropic', type: 'oauth' }) + const text = wrapper.text() + + expect(text).toContain('admin.accounts.upstreamRequestIdHeaderHelp.official:Anthropic') + expect(text).toContain('request-id') + expect(text).not.toContain('X-Client-Request-ID') + expect(text).not.toContain('X-Oneapi-Request-Id') + }) + + it('omits the examples section when no header is known for the platform', () => { + const wrapper = mountField({ platform: 'kimi', type: 'oauth' }) + const text = wrapper.text() + + expect(text).toContain('admin.accounts.upstreamRequestIdHeaderHelp.intro') + expect(text).not.toContain('admin.accounts.upstreamRequestIdHeaderHelp.examplesTitle') + expect(wrapper.findAll('code')).toHaveLength(0) + }) + + it('binds the header name through v-model', async () => { + const wrapper = mountField({ platform: 'openai', type: 'apikey', modelValue: 'X-Request-ID' }) + const input = wrapper.get('[data-testid="upstream-request-id-header"]') + expect((input.element as HTMLInputElement).value).toBe('X-Request-ID') + + await input.setValue('X-Oneapi-Request-Id') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['X-Oneapi-Request-Id']) + }) +}) diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue index df0ad10bad..3b2509129b 100644 --- a/frontend/src/components/admin/usage/UsageTable.vue +++ b/frontend/src/components/admin/usage/UsageTable.vue @@ -273,6 +273,24 @@ - + +