diff --git a/docs/AI网关.md b/docs/AI网关.md index a1f135b..690f45b 100644 --- a/docs/AI网关.md +++ b/docs/AI网关.md @@ -196,7 +196,9 @@ Codex 工具兼容现状: > 同一请求改为非流式实测可正常完成;单独扩大 `input` 未触发该限制。 该问题于 2026-07-13 通过字节级二分定位,2026-07-16 在 Chicago 复测仍存在: -`70.4 KB` 断流、`59.7 KB` 正常,API Key 与签名鉴权表现一致。本文及设置页中的 +`70.4 KB` 断流、`59.7 KB` 正常,API Key 与签名鉴权表现一致。2026-07-21 +再次复测(Chicago 签名路径)仍未修复:`70,463 B` 两次均在数个推理 delta 后纯 +EOF,同时段 `59,651 B` 对照正常 `completed`。本文及设置页中的 `KB` 均按 `1024 B` 计算。 | 协议 | 网关保护 | 客户端表现 | @@ -221,13 +223,25 @@ Responses 合成的最小事件序列为:`response.created` → ### `multi-agent` 加密推理内容流式断流 > [!WARNING] -> 当 `multi-agent` 请求同时启用 `stream: true` 与 -> `include: ["reasoning.encrypted_content"]` 时,上游可能在序列化大体量 -> `encrypted_content` 事件期间静默断开连接,且不返回 `error` 或终态事件。 -> -> 已复现场景中的断点位于 `output_index: 8` 附近:第三次搜索结束后,上游准备 -> 发送较大的加密推理块时连接被中止。`multi-agent` 产生的加密推理内容体积较大, -> 因而更容易暴露该上游流式序列化缺陷。 +> `xai.grok-4.20-multi-agent` 请求同时启用 `stream: true` 与 +> `include: ["reasoning.encrypted_content"]` 时,上游高概率在序列化大体量 +> `encrypted_content` 事件期间静默断开连接,不返回 `error` 或终态事件。 +> 断点常见于 `output_index: 8` 附近(第三次搜索结束后的大加密推理块), +> 也观测到更早(推理起始阶段)与更晚(15 块之后的文本输出阶段)断开。 + +2026-07-21 复测仍存在,并进一步定界: + +- 网关(Phoenix 渠道 API Key)四次全断:两次断于 `output_index: 8` 搜索阶段 + (此前 8 块加密内容累计约 `364 KB`),一次收满 15 块约 `762 KB` 后断于文本 + 输出;Chicago 签名直发同样可断(亦有一次仅 2 块 `35 KB` 的小规模会话正常 + 完成)——与区域、鉴权方式无关,与加密块规模相关。 +- 该模型单块 `encrypted_content` 约 `47 KB`,比 `xai.grok-4.3`(约 + `2-10 KB`)大一个量级;`grok-4.3` + `web_search` + 同 `include` 在累计 + `148 KB` 加密内容、`output_index: 15` 下流式完整——断流特定于 + `multi-agent` 模型的大加密块序列化。 +- 对照:同请求仅改 `include: ["web_search_call.action.sources"]`(不含加密 + 推理)正常完成。规避方式:该模型流式时不请求 + `reasoning.encrypted_content`。 ### ZDR 与文件输入 diff --git a/docs/docs.go b/docs/docs.go index 176fcbe..8f9fb50 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -2682,7 +2682,7 @@ const docTemplate = `{ }, { "type": "string", - "description": "起始时间 RFC3339,缺省为 endTime-30 天", + "description": "起始时间 RFC3339,缺省为 endTime-30 天;返回行严格限于 [startTime, endTime) 窗口(Usage API HOURLY 粒度会把起点下扩到 UTC 日零点,越界行已在服务端过滤)", "name": "startTime", "in": "query" }, diff --git a/docs/swagger.json b/docs/swagger.json index 294b593..f8858d8 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -2675,7 +2675,7 @@ }, { "type": "string", - "description": "起始时间 RFC3339,缺省为 endTime-30 天", + "description": "起始时间 RFC3339,缺省为 endTime-30 天;返回行严格限于 [startTime, endTime) 窗口(Usage API HOURLY 粒度会把起点下扩到 UTC 日零点,越界行已在服务端过滤)", "name": "startTime", "in": "query" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index b97ef3e..341e298 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -4587,7 +4587,8 @@ paths: name: id required: true type: integer - - description: 起始时间 RFC3339,缺省为 endTime-30 天 + - description: 起始时间 RFC3339,缺省为 endTime-30 天;返回行严格限于 [startTime, endTime) 窗口(Usage + API HOURLY 粒度会把起点下扩到 UTC 日零点,越界行已在服务端过滤) in: query name: startTime type: string diff --git a/internal/api/tenant.go b/internal/api/tenant.go index c11a14f..2c47475 100644 --- a/internal/api/tenant.go +++ b/internal/api/tenant.go @@ -38,7 +38,7 @@ func (h *ociConfigHandler) instanceTraffic(c *gin.Context) { // @Summary 配置成本快照 // @Tags 成本 // @Param id path int true "配置 ID" -// @Param startTime query string false "起始时间 RFC3339,缺省为 endTime-30 天" +// @Param startTime query string false "起始时间 RFC3339,缺省为 endTime-30 天;返回行严格限于 [startTime, endTime) 窗口(Usage API HOURLY 粒度会把起点下扩到 UTC 日零点,越界行已在服务端过滤)" // @Param endTime query string false "结束时间 RFC3339,缺省为当前时刻" // @Param granularity query string false "HOURLY / DAILY / MONTHLY,缺省 DAILY" // @Param queryType query string false "COST / USAGE,缺省 COST" diff --git a/internal/oci/objectstorage.go b/internal/oci/objectstorage.go index 9ca23b7..0f04115 100644 --- a/internal/oci/objectstorage.go +++ b/internal/oci/objectstorage.go @@ -593,7 +593,8 @@ func (c *RealClient) CreatePAR(ctx context.Context, cred Credentials, region, bu if in.ObjectName != "" { details.ObjectName = &in.ObjectName } - if in.AccessType == "AnyObjectReadWrite" || in.AccessType == "AnyObjectWrite" { + // 桶级 PAR(AnyObject*)必须显式桶列举动作,缺省会被 OCI 拒绝;一律放开列举便于收件人浏览 + if strings.HasPrefix(in.AccessType, "AnyObject") { details.BucketListingAction = objectstorage.PreauthenticatedRequestBucketListingActionListobjects } resp, err := oc.CreatePreauthenticatedRequest(ctx, objectstorage.CreatePreauthenticatedRequestRequest{ diff --git a/internal/service/overview_test.go b/internal/service/overview_test.go index 43912f8..0c14392 100644 --- a/internal/service/overview_test.go +++ b/internal/service/overview_test.go @@ -18,12 +18,15 @@ func costItem(day string, amount float32, currency string) oci.CostItem { } func TestRunCostTaskSkipsFreeAndUpserts(t *testing.T) { + // 日期取相对当前的近两天:任务查近 7 天,窗口外的行会被 Costs 过滤 + day1 := time.Now().UTC().AddDate(0, 0, -2).Format("2006-01-02") + day2 := time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02") client := &fakeClient{ tenancy: oci.TenancyInfo{Name: "acme", HomeRegionKey: "FRA"}, costItems: []oci.CostItem{ - costItem("2026-07-01", 5.2, "USD"), - costItem("2026-07-01", 1.4, "USD"), - costItem("2026-07-02", 6.3, "USD"), + costItem(day1, 5.2, "USD"), + costItem(day1, 1.4, "USD"), + costItem(day2, 6.3, "USD"), }, } tasks, configs, db := newTaskEnv(t, client) @@ -55,13 +58,13 @@ func TestRunCostTaskSkipsFreeAndUpserts(t *testing.T) { t.Errorf("log = %+v, want synced 1 skipped 1", entry) } - assertCostSnapshots(t, db, map[string]float64{"2026-07-01": 6.6, "2026-07-02": 6.3}) + assertCostSnapshots(t, db, map[string]float64{day1: 6.6, day2: 6.3}) // 再次执行为覆盖更新,不产生重复行 if _, err := tasks.RunTaskNow(ctx, task.ID); err != nil { t.Fatalf("RunTaskNow again: %v", err) } - assertCostSnapshots(t, db, map[string]float64{"2026-07-01": 6.6, "2026-07-02": 6.3}) + assertCostSnapshots(t, db, map[string]float64{day1: 6.6, day2: 6.3}) } // assertCostSnapshots 断言快照表恰好为 want 中的日期与金额(配置 #1)。 diff --git a/internal/service/usage.go b/internal/service/usage.go index b5bc9a9..6d704e0 100644 --- a/internal/service/usage.go +++ b/internal/service/usage.go @@ -45,7 +45,28 @@ func (s *OciConfigService) Costs(ctx context.Context, id uint, q oci.CostQuery) if err != nil { return nil, err } - return s.client.SummarizeCosts(ctx, cred, q) + items, err := s.client.SummarizeCosts(ctx, cred, q) + if err != nil { + return nil, err + } + return filterCostWindow(items, q.StartTime, q.EndTime), nil +} + +// filterCostWindow 只保留落在请求窗口 [start, end) 内的行:Usage API 的 +// HOURLY 粒度会把起点下扩到 UTC 日零点,返回窗口之外的整日数据。 +func filterCostWindow(items []oci.CostItem, start, end time.Time) []oci.CostItem { + out := items[:0] + for _, it := range items { + if it.TimeStart == nil { + continue + } + ts := it.TimeStart.UTC() + if ts.Before(start) || !ts.Before(end) { + continue + } + out = append(out, it) + } + return out } // applyCostDefaults 填充缺省值并把时间对齐到粒度边界(Usage API 要求)。 diff --git a/internal/service/usage_test.go b/internal/service/usage_test.go index 3550b5c..2f00bce 100644 --- a/internal/service/usage_test.go +++ b/internal/service/usage_test.go @@ -101,6 +101,40 @@ func TestApplyCostDefaults(t *testing.T) { } } +func TestFilterCostWindow(t *testing.T) { + hour := func(h int) *time.Time { + ts := time.Date(2026, 7, 20, h, 0, 0, 0, time.UTC) + return &ts + } + start := time.Date(2026, 7, 20, 16, 0, 0, 0, time.UTC) + end := time.Date(2026, 7, 21, 0, 0, 0, 0, time.UTC) + tests := []struct { + name string + items []oci.CostItem + want int + }{ + {name: "空输入", items: []oci.CostItem{}, want: 0}, + {name: "窗口前的整日下扩数据被丢弃", items: []oci.CostItem{{TimeStart: hour(0)}, {TimeStart: hour(15)}}, want: 0}, + {name: "起点边界含", items: []oci.CostItem{{TimeStart: hour(16)}}, want: 1}, + {name: "终点边界不含", items: []oci.CostItem{{TimeStart: &end}}, want: 0}, + {name: "TimeStart 缺失丢弃", items: []oci.CostItem{{TimeStart: nil}}, want: 0}, + {name: "窗口内外混合只留窗口内", items: []oci.CostItem{{TimeStart: hour(2)}, {TimeStart: hour(16)}, {TimeStart: hour(23)}, {TimeStart: &end}}, want: 2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := filterCostWindow(tt.items, start, end) + if len(got) != tt.want { + t.Errorf("filterCostWindow() kept %d rows, want %d", len(got), tt.want) + } + for _, it := range got { + if it.TimeStart.Before(start) || !it.TimeStart.Before(end) { + t.Errorf("row %v outside window [%v, %v)", it.TimeStart, start, end) + } + } + }) + } +} + func TestValidateEmails(t *testing.T) { tests := []struct { name string