Skip to content

Commit 90c58ab

Browse files
authored
feat: 优化 Stats 页面和升级计费精度至纳美元 (#146)
* feat: 优化 Stats 页面和升级计费精度至纳美元 - 添加 year 粒度支持,用于全时间范围查询 - 修复时间范围边界问题,避免 lastWeek/lastMonth 等重复计算边界日期 - 使用系统配置的时区而不是 UTC 进行时间桶计算 - 添加筛选条件摘要显示(精确到时间) - 修复 model 参数未传递到 API 的问题 - 移除侧边栏折叠功能,保持始终显示 - 图表颜色使用主题变量,Cost 在图例和 Tooltip 中优先显示 - 修复 Filter 清空按钮的布局位移问题 - Provider 分组显示类型名称标签 - 成本计算从微美元(microUSD)升级到纳美元(nanoUSD),精度提升1000倍 - 使用 big.Int 进行计算,防止大数量 token 时的整数溢出 - 添加 CacheCreationCount fallback 支持 - 保留旧函数作为兼容层(标记为 Deprecated) * fix: 设置失败请求的 EndTime 和 Duration 在 defer 块中,当 attempt 仍处于 IN_PROGRESS 状态需要标记为 failed/cancelled 时,之前没有设置 EndTime 和 Duration, 导致统计数据不准确。 * feat: 添加首字时长 (TTFT) 字段用于流式响应延迟追踪 为 ProxyRequest 和 ProxyUpstreamAttempt 添加 TTFT (Time To First Token) 字段, 记录流式 API 请求从开始到收到第一个 token 的时间间隔。 主要改动: - domain: 添加 TTFT time.Duration 字段 - sqlite: 添加 TTFTMs 毫秒存储字段及转换逻辑 - adapters: custom/antigravity/kiro 适配器追踪首字时间 - executor: 处理 EventFirstToken 事件并同步到 proxyReq - frontend: 同步添加 ttft 类型字段 * feat: 添加请求列表状态过滤器和斑马纹样式 - 添加状态过滤器下拉框,支持按 COMPLETED/FAILED/IN_PROGRESS 等状态筛选 - 后端 API 支持 status 查询参数过滤 - 实时更新逻辑支持状态过滤,新数据会根据当前过滤条件正确显示或隐藏 - 表格添加斑马纹背景(奇数行微透明),提升可读性 - 失败状态行根据奇偶行使用不同透明度的红色背景 - Hover 效果使用伪元素叠加,不覆盖原有背景色 - 修复 TTFT 列表查询时数据丢失问题(添加 ttft_ms 到 SELECT) - Cost 格式化保留完整 6 位小数 * fix: 修复请求列表表格列错位和样式问题 - 移除 TableRow 伪元素避免列错位 - 为所有单元格添加明确宽度和 padding - 修复 Failed 行顶部细线问题(移除 border-l) - 添加 Table border-collapse 消除行间隙 - 移除 TableRow 默认边框 - 增强正常行 hover 效果 - 添加 TTFT 字段聚合到 UsageStats - 修复多处 lint 警告 * feat: 添加 Stats 页面缓存率和 TTFT 显示,以及优雅关机支持 - Token 统计包含所有 token (input + output + cacheRead + cacheWrite) - 添加缓存命中率显示 (cacheRead / (input + cacheRead)) - 添加平均首字时长 (TTFT) 显示在 RPM 后面 - 实现 Proxy Server 优雅关机,等待所有请求完成后再关闭 - 增加 SIGTERM/SIGINT 信号处理,支持 docker down 优雅关机 - 关机超时 2 分钟,每 5 秒检查一次活跃请求 * refactor: 优化优雅关机和完善失败请求处理 - 使用 channel 通信替代定时轮询,请求完成时实时打印日志 - 标记失败请求时设置 end_time 和计算 duration_ms - 同步处理 upstream_attempt 表的失败状态 * fix: 启动时修复历史遗留的没有 end_time 的失败请求 将 FAILED 状态但 end_time=0 的请求/attempts 设置正确的 end_time, 避免它们一直显示在请求列表最前面 * fix: 创建 attempt 时设置 RequestInfo
1 parent a9985e6 commit 90c58ab

Some content is hidden

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

70 files changed

+5960
-2072
lines changed

cmd/maxx/main.go

Lines changed: 74 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
package main
22

33
import (
4+
"context"
45
"flag"
56
"fmt"
67
"log"
78
"net/http"
89
"os"
10+
"os/signal"
911
"path/filepath"
12+
"syscall"
1013
"time"
1114

1215
"github.com/awsl-project/maxx/internal/adapter/client"
@@ -18,9 +21,9 @@ import (
1821
"github.com/awsl-project/maxx/internal/handler"
1922
"github.com/awsl-project/maxx/internal/repository/cached"
2023
"github.com/awsl-project/maxx/internal/repository/sqlite"
21-
"github.com/awsl-project/maxx/internal/stats"
2224
"github.com/awsl-project/maxx/internal/router"
2325
"github.com/awsl-project/maxx/internal/service"
26+
"github.com/awsl-project/maxx/internal/stats"
2427
"github.com/awsl-project/maxx/internal/version"
2528
"github.com/awsl-project/maxx/internal/waiter"
2629
)
@@ -118,6 +121,23 @@ func main() {
118121
} else if count > 0 {
119122
log.Printf("Marked %d stale requests as failed", count)
120123
}
124+
// Also mark stale upstream attempts as failed
125+
if count, err := attemptRepo.MarkStaleAttemptsFailed(); err != nil {
126+
log.Printf("Warning: Failed to mark stale attempts: %v", err)
127+
} else if count > 0 {
128+
log.Printf("Marked %d stale upstream attempts as failed", count)
129+
}
130+
// Fix legacy failed requests/attempts without end_time
131+
if count, err := proxyRequestRepo.FixFailedRequestsWithoutEndTime(); err != nil {
132+
log.Printf("Warning: Failed to fix failed requests without end_time: %v", err)
133+
} else if count > 0 {
134+
log.Printf("Fixed %d failed requests without end_time", count)
135+
}
136+
if count, err := attemptRepo.FixFailedAttemptsWithoutEndTime(); err != nil {
137+
log.Printf("Warning: Failed to fix failed attempts without end_time: %v", err)
138+
} else if count > 0 {
139+
log.Printf("Fixed %d failed attempts without end_time", count)
140+
}
121141

122142
// Create cached repositories
123143
cachedProviderRepo := cached.NewProviderRepository(providerRepo)
@@ -228,6 +248,7 @@ func main() {
228248
responseModelRepo,
229249
*addr,
230250
r, // Router implements ProviderAdapterRefresher interface
251+
wsHub,
231252
)
232253

233254
// Create backup service
@@ -257,8 +278,12 @@ func main() {
257278
log.Println("Proxy token authentication is enabled")
258279
}
259280

281+
// Create request tracker for graceful shutdown
282+
requestTracker := core.NewRequestTracker()
283+
260284
// Create handlers
261285
proxyHandler := handler.NewProxyHandler(clientAdapter, exec, cachedSessionRepo, tokenAuthMiddleware)
286+
proxyHandler.SetRequestTracker(requestTracker)
262287
adminHandler := handler.NewAdminHandler(adminService, backupService, logPath)
263288
authHandler := handler.NewAuthHandler(authMiddleware)
264289
antigravityHandler := handler.NewAntigravityHandler(adminService, antigravityQuotaRepo, wsHub)
@@ -309,22 +334,55 @@ func main() {
309334
// Wrap with logging middleware
310335
loggedMux := handler.LoggingMiddleware(mux)
311336

312-
// Start server
337+
// Create HTTP server
338+
server := &http.Server{
339+
Addr: *addr,
340+
Handler: loggedMux,
341+
}
342+
343+
// Start server in goroutine
313344
log.Printf("Starting Maxx server %s on %s", version.Info(), *addr)
314345
log.Printf("Data directory: %s", dataDirPath)
315-
log.Printf(" Database: %s", dbPath)
316-
log.Printf(" Log file: %s", logPath)
317-
log.Printf("Admin API: http://localhost%s/api/admin/", *addr)
318-
log.Printf("WebSocket: ws://localhost%s/ws", *addr)
319-
log.Printf("Proxy endpoints:")
320-
log.Printf(" Claude: http://localhost%s/v1/messages", *addr)
321-
log.Printf(" OpenAI: http://localhost%s/v1/chat/completions", *addr)
322-
log.Printf(" Codex: http://localhost%s/v1/responses", *addr)
323-
log.Printf(" Gemini: http://localhost%s/v1beta/models/{model}:generateContent", *addr)
324-
log.Printf("Project proxy: http://localhost%s/{project-slug}/v1/messages (etc.)", *addr)
325-
326-
if err := http.ListenAndServe(*addr, loggedMux); err != nil {
327-
log.Printf("Server error: %v", err)
328-
os.Exit(1)
346+
347+
go func() {
348+
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
349+
log.Printf("Server error: %v", err)
350+
os.Exit(1)
351+
}
352+
}()
353+
354+
// Wait for interrupt signal (SIGINT or SIGTERM)
355+
sigCh := make(chan os.Signal, 1)
356+
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
357+
sig := <-sigCh
358+
log.Printf("Received signal %v, initiating graceful shutdown...", sig)
359+
360+
// Step 1: Wait for active proxy requests to complete
361+
activeCount := requestTracker.ActiveCount()
362+
if activeCount > 0 {
363+
log.Printf("Waiting for %d active proxy requests to complete...", activeCount)
364+
completed := requestTracker.GracefulShutdown(core.GracefulShutdownTimeout)
365+
if !completed {
366+
log.Printf("Graceful shutdown timeout, some requests may be interrupted")
367+
} else {
368+
log.Printf("All proxy requests completed successfully")
369+
}
370+
} else {
371+
// Mark as shutting down to reject new requests
372+
requestTracker.GracefulShutdown(0)
373+
log.Printf("No active proxy requests")
374+
}
375+
376+
// Step 2: Shutdown HTTP server
377+
shutdownCtx, cancel := context.WithTimeout(context.Background(), core.HTTPShutdownTimeout)
378+
defer cancel()
379+
380+
if err := server.Shutdown(shutdownCtx); err != nil {
381+
log.Printf("HTTP server graceful shutdown failed: %v, forcing close", err)
382+
if closeErr := server.Close(); closeErr != nil {
383+
log.Printf("Force close error: %v", closeErr)
384+
}
329385
}
386+
387+
log.Printf("Server stopped")
330388
}

coverage.out

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
mode: set
2+
github.com/awsl-project/maxx/internal/stats/aggregator.go:14.90,18.2 1 0
3+
github.com/awsl-project/maxx/internal/stats/aggregator.go:21.46,23.2 1 0
4+
github.com/awsl-project/maxx/internal/stats/pure.go:35.93,37.11 2 1
5+
github.com/awsl-project/maxx/internal/stats/pure.go:38.32,39.33 1 1
6+
github.com/awsl-project/maxx/internal/stats/pure.go:40.30,41.31 1 1
7+
github.com/awsl-project/maxx/internal/stats/pure.go:42.29,43.66 1 1
8+
github.com/awsl-project/maxx/internal/stats/pure.go:44.30,47.19 2 1
9+
github.com/awsl-project/maxx/internal/stats/pure.go:47.19,49.4 1 1
10+
github.com/awsl-project/maxx/internal/stats/pure.go:50.3,50.78 1 1
11+
github.com/awsl-project/maxx/internal/stats/pure.go:51.31,52.60 1 1
12+
github.com/awsl-project/maxx/internal/stats/pure.go:53.30,54.52 1 1
13+
github.com/awsl-project/maxx/internal/stats/pure.go:55.10,56.31 1 1
14+
github.com/awsl-project/maxx/internal/stats/pure.go:63.90,64.23 1 1
15+
github.com/awsl-project/maxx/internal/stats/pure.go:64.23,66.3 1 1
16+
github.com/awsl-project/maxx/internal/stats/pure.go:68.2,79.28 3 1
17+
github.com/awsl-project/maxx/internal/stats/pure.go:79.28,93.21 4 1
18+
github.com/awsl-project/maxx/internal/stats/pure.go:93.21,95.4 1 1
19+
github.com/awsl-project/maxx/internal/stats/pure.go:96.3,96.17 1 1
20+
github.com/awsl-project/maxx/internal/stats/pure.go:96.17,98.4 1 1
21+
github.com/awsl-project/maxx/internal/stats/pure.go:100.3,100.33 1 1
22+
github.com/awsl-project/maxx/internal/stats/pure.go:100.33,110.4 9 1
23+
github.com/awsl-project/maxx/internal/stats/pure.go:110.9,130.4 1 1
24+
github.com/awsl-project/maxx/internal/stats/pure.go:133.2,134.29 2 1
25+
github.com/awsl-project/maxx/internal/stats/pure.go:134.29,136.3 1 1
26+
github.com/awsl-project/maxx/internal/stats/pure.go:137.2,137.15 1 1
27+
github.com/awsl-project/maxx/internal/stats/pure.go:143.105,144.21 1 1
28+
github.com/awsl-project/maxx/internal/stats/pure.go:144.21,146.3 1 1
29+
github.com/awsl-project/maxx/internal/stats/pure.go:148.2,159.26 3 1
30+
github.com/awsl-project/maxx/internal/stats/pure.go:159.26,172.40 3 1
31+
github.com/awsl-project/maxx/internal/stats/pure.go:172.40,182.4 9 1
32+
github.com/awsl-project/maxx/internal/stats/pure.go:182.9,202.4 1 1
33+
github.com/awsl-project/maxx/internal/stats/pure.go:205.2,206.29 2 1
34+
github.com/awsl-project/maxx/internal/stats/pure.go:206.29,208.3 1 1
35+
github.com/awsl-project/maxx/internal/stats/pure.go:209.2,209.15 1 1
36+
github.com/awsl-project/maxx/internal/stats/pure.go:214.73,227.34 3 1
37+
github.com/awsl-project/maxx/internal/stats/pure.go:227.34,228.27 1 1
38+
github.com/awsl-project/maxx/internal/stats/pure.go:228.27,240.39 2 1
39+
github.com/awsl-project/maxx/internal/stats/pure.go:240.39,250.5 9 1
40+
github.com/awsl-project/maxx/internal/stats/pure.go:250.10,254.5 2 1
41+
github.com/awsl-project/maxx/internal/stats/pure.go:258.2,259.27 2 1
42+
github.com/awsl-project/maxx/internal/stats/pure.go:259.27,261.3 1 1
43+
github.com/awsl-project/maxx/internal/stats/pure.go:262.2,262.15 1 1
44+
github.com/awsl-project/maxx/internal/stats/pure.go:268.140,269.26 1 1
45+
github.com/awsl-project/maxx/internal/stats/pure.go:269.26,278.3 8 1
46+
github.com/awsl-project/maxx/internal/stats/pure.go:279.2,279.8 1 1
47+
github.com/awsl-project/maxx/internal/stats/pure.go:284.83,287.26 2 1
48+
github.com/awsl-project/maxx/internal/stats/pure.go:287.26,288.24 1 1
49+
github.com/awsl-project/maxx/internal/stats/pure.go:288.24,289.12 1 1
50+
github.com/awsl-project/maxx/internal/stats/pure.go:292.3,292.47 1 1
51+
github.com/awsl-project/maxx/internal/stats/pure.go:292.47,301.4 8 1
52+
github.com/awsl-project/maxx/internal/stats/pure.go:301.9,313.4 1 1
53+
github.com/awsl-project/maxx/internal/stats/pure.go:317.2,317.28 1 1
54+
github.com/awsl-project/maxx/internal/stats/pure.go:317.28,318.27 1 1
55+
github.com/awsl-project/maxx/internal/stats/pure.go:318.27,320.4 1 1
56+
github.com/awsl-project/maxx/internal/stats/pure.go:323.2,323.15 1 1
57+
github.com/awsl-project/maxx/internal/stats/pure.go:327.97,329.26 2 1
58+
github.com/awsl-project/maxx/internal/stats/pure.go:329.26,330.25 1 1
59+
github.com/awsl-project/maxx/internal/stats/pure.go:330.25,332.4 1 1
60+
github.com/awsl-project/maxx/internal/stats/pure.go:334.2,334.15 1 1
61+
github.com/awsl-project/maxx/internal/stats/pure.go:339.95,341.26 2 1
62+
github.com/awsl-project/maxx/internal/stats/pure.go:341.26,342.62 1 1
63+
github.com/awsl-project/maxx/internal/stats/pure.go:342.62,344.4 1 1
64+
github.com/awsl-project/maxx/internal/stats/pure.go:346.2,346.15 1 1

internal/adapter/provider/antigravity/adapter.go

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,8 @@ func (a *AntigravityAdapter) Execute(ctx context.Context, w http.ResponseWriter,
111111

112112
// Transform request based on client type
113113
var geminiBody []byte
114-
if clientType == domain.ClientTypeClaude {
114+
switch clientType {
115+
case domain.ClientTypeClaude:
115116
// Use direct transformation (no converter dependency)
116117
// This combines cache control cleanup, thinking filter, tool loop recovery,
117118
// system instruction building, content transformation, tool building, and generation config
@@ -127,10 +128,10 @@ func (a *AntigravityAdapter) Execute(ctx context.Context, w http.ResponseWriter,
127128

128129
// Apply minimal post-processing for features not yet fully integrated
129130
geminiBody = applyClaudePostProcess(geminiBody, sessionID, hasThinking, requestBody, mappedModel)
130-
} else if clientType == domain.ClientTypeOpenAI {
131+
case domain.ClientTypeOpenAI:
131132
// TODO: Implement OpenAI transformation in the future
132133
return domain.NewProxyErrorWithMessage(domain.ErrFormatConversion, true, "OpenAI transformation not yet implemented")
133-
} else {
134+
default:
134135
// For Gemini, unwrap CLI envelope if present
135136
geminiBody = unwrapGeminiCLIEnvelope(requestBody)
136137
}
@@ -439,12 +440,9 @@ func applyClaudePostProcess(geminiBody []byte, sessionID string, hasThinking boo
439440
return geminiBody
440441
}
441442

442-
modified := false
443+
modified := InjectToolConfig(request)
443444

444445
// 1. Inject toolConfig with VALIDATED mode when tools exist
445-
if InjectToolConfig(request) {
446-
modified = true
447-
}
448446

449447
// 2. Process contents for additional signature validation
450448
if contents, ok := request["contents"].([]interface{}); ok {
@@ -545,16 +543,17 @@ func (a *AntigravityAdapter) handleNonStreamResponse(ctx context.Context, w http
545543
var responseBody []byte
546544

547545
// Transform response based on client type
548-
if clientType == domain.ClientTypeClaude {
546+
switch clientType {
547+
case domain.ClientTypeClaude:
549548
requestModel := ctxutil.GetRequestModel(ctx)
550549
responseBody, err = convertGeminiToClaudeResponse(unwrappedBody, requestModel)
551550
if err != nil {
552551
return domain.NewProxyErrorWithMessage(domain.ErrFormatConversion, false, "failed to transform response")
553552
}
554-
} else if clientType == domain.ClientTypeOpenAI {
553+
case domain.ClientTypeOpenAI:
555554
// TODO: Implement OpenAI response transformation
556555
return domain.NewProxyErrorWithMessage(domain.ErrFormatConversion, false, "OpenAI response transformation not yet implemented")
557-
} else {
556+
default:
558557
// Gemini native
559558
responseBody = unwrappedBody
560559
}
@@ -648,6 +647,7 @@ func (a *AntigravityAdapter) handleStreamResponse(ctx context.Context, w http.Re
648647
// Read chunks and accumulate until we have complete lines
649648
var lineBuffer bytes.Buffer
650649
buf := make([]byte, 4096)
650+
firstChunkSent := false // Track TTFT
651651

652652
for {
653653
// Check context before reading
@@ -700,6 +700,12 @@ func (a *AntigravityAdapter) handleStreamResponse(ctx context.Context, w http.Re
700700
return domain.NewProxyErrorWithMessage(writeErr, false, "client disconnected")
701701
}
702702
flusher.Flush()
703+
704+
// Track TTFT: send first token time on first successful write
705+
if !firstChunkSent {
706+
firstChunkSent = true
707+
eventChan.SendFirstToken(time.Now().UnixMilli())
708+
}
703709
}
704710
}
705711
}

internal/adapter/provider/antigravity/claude_request_postprocess.go

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,9 @@ func PostProcessClaudeRequest(geminiBody []byte, sessionID string, hasThinking b
3131
return geminiBody
3232
}
3333

34-
modified := false
34+
modified := injectAntigravityIdentity(request)
3535

3636
// 1. Inject Antigravity identity into system instruction (like Antigravity-Manager)
37-
if injectAntigravityIdentity(request) {
38-
modified = true
39-
}
4037

4138
// 2. Clean tool input schemas for Gemini compatibility (like Antigravity-Manager)
4239
if cleanToolInputSchemas(request) {

internal/adapter/provider/antigravity/response.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,6 @@ func convertGeminiToClaudeResponse(geminiBody []byte, requestModel string) ([]by
304304
"thinking": "",
305305
"signature": trailingSignature,
306306
})
307-
trailingSignature = ""
308307
}
309308
}
310309

internal/adapter/provider/antigravity/retry_delay.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ func ParseRetryInfo(statusCode int, body []byte) *RetryInfo {
4747
bodyStr := string(body)
4848

4949
// Parse reason
50-
reason := RateLimitReasonUnknown
50+
var reason RateLimitReason
5151
if statusCode == 429 {
5252
reason = parseRateLimitReason(bodyStr)
5353
} else {

internal/adapter/provider/antigravity/transform_request.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ func removeTrailingUnsignedThinking(messages *[]ClaudeMessage) {
270270
}
271271

272272
blocks := parseContentBlocks((*messages)[i].Content)
273-
if blocks == nil || len(blocks) == 0 {
273+
if len(blocks) == 0 {
274274
continue
275275
}
276276

internal/adapter/provider/antigravity/transform_tools.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import (
88
// buildTools converts Claude tools to Gemini tools format
99
// Reference: Antigravity-Manager's build_tools
1010
func buildTools(claudeReq *ClaudeRequest) interface{} {
11-
if claudeReq.Tools == nil || len(claudeReq.Tools) == 0 {
11+
if len(claudeReq.Tools) == 0 {
1212
return nil
1313
}
1414

internal/adapter/provider/custom/adapter.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,7 @@ func (a *CustomAdapter) handleStreamResponse(ctx context.Context, w http.Respons
316316
// Use buffer-based approach to handle incomplete lines properly
317317
var lineBuffer bytes.Buffer
318318
buf := make([]byte, 4096)
319+
firstChunkSent := false // Track TTFT
319320

320321
for {
321322
// Check context before reading
@@ -361,6 +362,12 @@ func (a *CustomAdapter) handleStreamResponse(ctx context.Context, w http.Respons
361362
return domain.NewProxyErrorWithMessage(writeErr, false, "client disconnected")
362363
}
363364
flusher.Flush()
365+
366+
// Track TTFT: send first token time on first successful write
367+
if !firstChunkSent {
368+
firstChunkSent = true
369+
eventChan.SendFirstToken(time.Now().UnixMilli())
370+
}
364371
}
365372
}
366373
}
@@ -579,7 +586,7 @@ func copyResponseHeaders(dst, src http.Header) {
579586
// Supports multiple API formats: OpenAI, Anthropic, Gemini, etc.
580587
func parseRateLimitInfo(resp *http.Response, body []byte, clientType domain.ClientType) *domain.RateLimitInfo {
581588
var resetTime time.Time
582-
var rateLimitType string = "rate_limit_exceeded"
589+
var rateLimitType = "rate_limit_exceeded"
583590

584591
// Method 1: Parse Retry-After header
585592
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
@@ -698,7 +705,6 @@ func extractResponseModel(body []byte, targetType domain.ClientType) string {
698705
return ""
699706
}
700707

701-
702708
// extractResponseModelFromSSE extracts the model name from SSE content based on target type
703709
func extractResponseModelFromSSE(sseContent string, targetType domain.ClientType) string {
704710
var lastModel string

0 commit comments

Comments
 (0)