From 033424797587c035876447bc742383450bf76f51 Mon Sep 17 00:00:00 2001 From: Simba98 Date: Sat, 23 May 2026 14:49:08 +0800 Subject: [PATCH 1/5] fix codex usage window stats --- .../internal/service/account_usage_service.go | 41 ++++++++++++++- .../service/account_usage_service_test.go | 50 +++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 1c871d2ba48..f0546fe1cef 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -528,14 +528,14 @@ func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Accou return usage, nil } - if stats, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, now.Add(-5*time.Hour)); err == nil { + if stats, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, codexWindowStart(account.Extra, "5h", now)); err == nil { if usage.FiveHour == nil { usage.FiveHour = &UsageProgress{Utilization: 0} } usage.FiveHour.WindowStats = windowStatsFromAccountStats(stats) } - if stats, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, now.Add(-7*24*time.Hour)); err == nil { + if stats, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, codexWindowStart(account.Extra, "7d", now)); err == nil { if usage.SevenDay == nil { usage.SevenDay = &UsageProgress{Utilization: 0} } @@ -1120,6 +1120,43 @@ func buildCodexUsageProgressFromExtra(extra map[string]any, window string, now t return progress } +func codexWindowStart(extra map[string]any, window string, now time.Time) time.Time { + var ( + resetAtKey string + windowMinuteKey string + fallback time.Time + ) + + switch window { + case "5h": + resetAtKey = "codex_5h_reset_at" + windowMinuteKey = "codex_5h_window_minutes" + fallback = now.Add(-5 * time.Hour) + case "7d": + resetAtKey = "codex_7d_reset_at" + windowMinuteKey = "codex_7d_window_minutes" + fallback = now.Add(-7 * 24 * time.Hour) + default: + return now + } + + resetAtRaw, hasResetAt := extra[resetAtKey] + windowMinutes := parseExtraInt(extra[windowMinuteKey]) + if !hasResetAt || windowMinutes <= 0 { + return fallback + } + + resetAt, err := parseTime(fmt.Sprint(resetAtRaw)) + if err != nil { + return fallback + } + if !now.Before(resetAt) { + return fallback + } + + return resetAt.Add(-time.Duration(windowMinutes) * time.Minute) +} + func (s *AccountUsageService) GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (*usagestats.AccountUsageStatsResponse, error) { stats, err := s.usageLogRepo.GetAccountUsageStats(ctx, accountID, startTime, endTime) if err != nil { diff --git a/backend/internal/service/account_usage_service_test.go b/backend/internal/service/account_usage_service_test.go index e0390c4c2a7..5f4a4125d9e 100644 --- a/backend/internal/service/account_usage_service_test.go +++ b/backend/internal/service/account_usage_service_test.go @@ -207,3 +207,53 @@ func TestBuildCodexUsageProgressFromExtra_ZerosExpiredWindow(t *testing.T) { } }) } + +func TestCodexWindowStart(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 3, 16, 12, 0, 0, 0, time.UTC) + + t.Run("uses reset minus 5h window length", func(t *testing.T) { + extra := map[string]any{ + "codex_5h_reset_at": "2026-03-16T15:30:00Z", + "codex_5h_window_minutes": 300, + } + got := codexWindowStart(extra, "5h", now) + want := time.Date(2026, 3, 16, 10, 30, 0, 0, time.UTC) + if !got.Equal(want) { + t.Fatalf("codexWindowStart() = %v, want %v", got, want) + } + }) + + t.Run("uses reset minus 7d window length", func(t *testing.T) { + extra := map[string]any{ + "codex_7d_reset_at": "2026-03-20T00:00:00Z", + "codex_7d_window_minutes": 10080, + } + got := codexWindowStart(extra, "7d", now) + want := time.Date(2026, 3, 13, 0, 0, 0, 0, time.UTC) + if !got.Equal(want) { + t.Fatalf("codexWindowStart() = %v, want %v", got, want) + } + }) + + t.Run("falls back to rolling window when metadata is missing", func(t *testing.T) { + got := codexWindowStart(map[string]any{}, "5h", now) + want := now.Add(-5 * time.Hour) + if !got.Equal(want) { + t.Fatalf("codexWindowStart() = %v, want %v", got, want) + } + }) + + t.Run("falls back to rolling window when reset is expired", func(t *testing.T) { + extra := map[string]any{ + "codex_5h_reset_at": "2026-03-16T10:00:00Z", + "codex_5h_window_minutes": 300, + } + got := codexWindowStart(extra, "5h", now) + want := now.Add(-5 * time.Hour) + if !got.Equal(want) { + t.Fatalf("codexWindowStart() = %v, want %v", got, want) + } + }) +} From 838fe1c3c7a043abf1209718539770bd9dba725a Mon Sep 17 00:00:00 2001 From: Simba98 Date: Sat, 23 May 2026 15:14:40 +0800 Subject: [PATCH 2/5] fix(service): add anthropic 7d window stats --- .../internal/service/account_usage_service.go | 82 ++++++++++++------- .../service/account_usage_service_test.go | 60 ++++++++++++++ 2 files changed, 114 insertions(+), 28 deletions(-) diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index f0546fe1cef..37420447b03 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -97,6 +97,11 @@ type windowStatsCache struct { timestamp time.Time } +type windowStatsCacheKey struct { + accountID int64 + startUnixNano int64 +} + // antigravityUsageCache 缓存 Antigravity 额度数据 type antigravityUsageCache struct { usageInfo *UsageInfo @@ -116,7 +121,7 @@ const ( // UsageCache 封装账户使用量相关的缓存 type UsageCache struct { apiCache sync.Map // accountID -> *apiUsageCache - windowStatsCache sync.Map // accountID -> *windowStatsCache + windowStatsCache sync.Map // windowStatsCacheKey -> *windowStatsCache antigravityCache sync.Map // accountID -> *antigravityUsageCache apiFlight singleflight.Group // 防止同一账号的并发请求击穿缓存(Anthropic) antigravityFlight singleflight.Group // 防止同一 Antigravity 账号的并发请求击穿缓存 @@ -928,44 +933,65 @@ func (s *AccountUsageService) addWindowStats(ctx context.Context, account *Accou return } - // 检查窗口统计缓存(1 分钟) - var windowStats *WindowStats - if cached, ok := s.cache.windowStatsCache.Load(account.ID); ok { - if cache, ok := cached.(*windowStatsCache); ok && time.Since(cache.timestamp) < windowStatsCacheTTL { - windowStats = cache.stats + // 为 FiveHour 添加 WindowStats(5h 窗口统计) + if usage.FiveHour != nil { + if windowStats, err := s.accountWindowStats(ctx, account.ID, account.GetCurrentWindowStartTime()); err == nil { + usage.FiveHour.WindowStats = windowStats + } else { + log.Printf("Failed to get 5h window stats for account %d: %v", account.ID, err) } } - // 如果没有缓存,从数据库查询 - if windowStats == nil { - // 使用统一的窗口开始时间计算逻辑(考虑窗口过期情况) - startTime := account.GetCurrentWindowStartTime() + if usage.SevenDay != nil { + if startTime, ok := usageWindowStartFromReset(usage.SevenDay, 7*24*time.Hour); ok { + if windowStats, err := s.accountWindowStats(ctx, account.ID, startTime); err == nil { + usage.SevenDay.WindowStats = windowStats + } else { + log.Printf("Failed to get 7d window stats for account %d: %v", account.ID, err) + } + } + } - stats, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, startTime) - if err != nil { - log.Printf("Failed to get window stats for account %d: %v", account.ID, err) - return + if usage.SevenDaySonnet != nil { + if startTime, ok := usageWindowStartFromReset(usage.SevenDaySonnet, 7*24*time.Hour); ok { + if windowStats, err := s.accountWindowStats(ctx, account.ID, startTime); err == nil { + usage.SevenDaySonnet.WindowStats = windowStats + } else { + log.Printf("Failed to get 7d Sonnet window stats for account %d: %v", account.ID, err) + } } + } +} - windowStats = &WindowStats{ - Requests: stats.Requests, - Tokens: stats.Tokens, - Cost: stats.Cost, - StandardCost: stats.StandardCost, - UserCost: stats.UserCost, +func (s *AccountUsageService) accountWindowStats(ctx context.Context, accountID int64, startTime time.Time) (*WindowStats, error) { + key := windowStatsCacheKey{accountID: accountID, startUnixNano: startTime.UnixNano()} + if cached, ok := s.cache.windowStatsCache.Load(key); ok { + if cache, ok := cached.(*windowStatsCache); ok && time.Since(cache.timestamp) < windowStatsCacheTTL { + return cache.stats, nil } + } - // 缓存窗口统计(1 分钟) - s.cache.windowStatsCache.Store(account.ID, &windowStatsCache{ - stats: windowStats, - timestamp: time.Now(), - }) + stats, err := s.usageLogRepo.GetAccountWindowStats(ctx, accountID, startTime) + if err != nil { + return nil, err } - // 为 FiveHour 添加 WindowStats(5h 窗口统计) - if usage.FiveHour != nil { - usage.FiveHour.WindowStats = windowStats + windowStats := windowStatsFromAccountStats(stats) + s.cache.windowStatsCache.Store(key, &windowStatsCache{ + stats: windowStats, + timestamp: time.Now(), + }) + return windowStats, nil +} + +func usageWindowStartFromReset(progress *UsageProgress, duration time.Duration) (time.Time, bool) { + if progress == nil || progress.ResetsAt == nil || duration <= 0 { + return time.Time{}, false + } + if !progress.ResetsAt.After(time.Now()) { + return time.Time{}, false } + return progress.ResetsAt.Add(-duration), true } // GetTodayStats 获取账号今日统计 diff --git a/backend/internal/service/account_usage_service_test.go b/backend/internal/service/account_usage_service_test.go index 5f4a4125d9e..58cd9b4a97a 100644 --- a/backend/internal/service/account_usage_service_test.go +++ b/backend/internal/service/account_usage_service_test.go @@ -5,8 +5,24 @@ import ( "net/http" "testing" "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" ) +type accountUsageWindowStatsRepo struct { + UsageLogRepository + statsByStart map[int64]*usagestats.AccountStats + calls []time.Time +} + +func (r *accountUsageWindowStatsRepo) GetAccountWindowStats(_ context.Context, _ int64, startTime time.Time) (*usagestats.AccountStats, error) { + r.calls = append(r.calls, startTime) + if stats, ok := r.statsByStart[startTime.UnixNano()]; ok { + return stats, nil + } + return &usagestats.AccountStats{}, nil +} + type accountUsageCodexProbeRepo struct { stubOpenAIAccountRepo updateExtraCh chan map[string]any @@ -66,6 +82,50 @@ func TestShouldRefreshOpenAICodexSnapshot(t *testing.T) { } } +func TestAccountUsageService_AddWindowStatsAttachesAnthropicSevenDay(t *testing.T) { + now := time.Now().UTC() + fiveHourStart := now.Add(-2 * time.Hour).Truncate(time.Second) + fiveHourEnd := now.Add(3 * time.Hour).Truncate(time.Second) + sevenDayReset := now.Add(48 * time.Hour).Truncate(time.Second) + sevenDayStart := sevenDayReset.Add(-7 * 24 * time.Hour) + + repo := &accountUsageWindowStatsRepo{statsByStart: map[int64]*usagestats.AccountStats{ + fiveHourStart.UnixNano(): { + Requests: 5, + Tokens: 50, + StandardCost: 0.5, + }, + sevenDayStart.UnixNano(): { + Requests: 70, + Tokens: 700, + StandardCost: 7, + }, + }} + svc := &AccountUsageService{usageLogRepo: repo, cache: NewUsageCache()} + usage := &UsageInfo{ + FiveHour: &UsageProgress{Utilization: 10}, + SevenDay: &UsageProgress{Utilization: 20, ResetsAt: &sevenDayReset}, + } + account := &Account{ID: 42, SessionWindowStart: &fiveHourStart, SessionWindowEnd: &fiveHourEnd} + + svc.addWindowStats(context.Background(), account, usage) + + if usage.FiveHour.WindowStats == nil || usage.FiveHour.WindowStats.Requests != 5 { + t.Fatalf("FiveHour.WindowStats = %#v, want requests=5", usage.FiveHour.WindowStats) + } + if usage.SevenDay.WindowStats == nil || usage.SevenDay.WindowStats.Requests != 70 { + t.Fatalf("SevenDay.WindowStats = %#v, want requests=70", usage.SevenDay.WindowStats) + } + if len(repo.calls) != 2 { + t.Fatalf("GetAccountWindowStats calls = %d, want 2", len(repo.calls)) + } + + svc.addWindowStats(context.Background(), account, usage) + if len(repo.calls) != 2 { + t.Fatalf("GetAccountWindowStats calls after cache hit = %d, want 2", len(repo.calls)) + } +} + func TestExtractOpenAICodexProbeUpdatesAccepts429WithCodexHeaders(t *testing.T) { t.Parallel() From e2fe75e90119e926bc960ff35385cdcf6716223d Mon Sep 17 00:00:00 2001 From: Simba98 Date: Sat, 23 May 2026 15:14:48 +0800 Subject: [PATCH 3/5] fix(frontend): show anthropic window stats --- .../components/account/AccountUsageCell.vue | 2 + .../components/account/UsageProgressBar.vue | 2 +- .../__tests__/AccountUsageCell.spec.ts | 68 +++++++++++++++++++ .../__tests__/UsageProgressBar.spec.ts | 23 +++++++ 4 files changed, 94 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index 887fbf7955c..c84cb2760bf 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -56,6 +56,7 @@ label="7d" :utilization="usageInfo.seven_day.utilization" :resets-at="usageInfo.seven_day.resets_at" + :window-stats="usageInfo.seven_day.window_stats" color="emerald" /> @@ -65,6 +66,7 @@ label="7d S" :utilization="usageInfo.seven_day_sonnet.utilization" :resets-at="usageInfo.seven_day_sonnet.resets_at" + :window-stats="usageInfo.seven_day_sonnet.window_stats" color="purple" /> diff --git a/frontend/src/components/account/UsageProgressBar.vue b/frontend/src/components/account/UsageProgressBar.vue index 52f0ecbbe75..604ed087efe 100644 --- a/frontend/src/components/account/UsageProgressBar.vue +++ b/frontend/src/components/account/UsageProgressBar.vue @@ -185,7 +185,7 @@ const formatTokens = computed(() => { const formatAccountCost = computed(() => { if (!props.windowStats) return '0.00' - return props.windowStats.cost.toFixed(2) + return (props.windowStats.standard_cost ?? props.windowStats.cost).toFixed(2) }) const formatUserCost = computed(() => { diff --git a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts index fa4104f6dc9..8515b3a3d0e 100644 --- a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts +++ b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts @@ -211,6 +211,74 @@ describe('AccountUsageCell', () => { expect(wrapper.text()).toContain('7d|77|300') }) + it('Anthropic OAuth 会把 5h/7d/7d Sonnet 各自 window_stats 传给窗口条', async () => { + getUsage.mockResolvedValue({ + five_hour: { + utilization: 10, + resets_at: '2026-03-08T12:00:00Z', + remaining_seconds: 3600, + window_stats: { + requests: 5, + tokens: 500, + cost: 0.5, + standard_cost: 0.55, + user_cost: 0.25 + } + }, + seven_day: { + utilization: 20, + resets_at: '2026-03-13T12:00:00Z', + remaining_seconds: 3600, + window_stats: { + requests: 70, + tokens: 7000, + cost: 7, + standard_cost: 7.7, + user_cost: 3.5 + } + }, + seven_day_sonnet: { + utilization: 30, + resets_at: '2026-03-13T12:00:00Z', + remaining_seconds: 3600, + window_stats: { + requests: 17, + tokens: 1700, + cost: 1.7, + standard_cost: 1.87, + user_cost: 0.85 + } + } + }) + + const wrapper = mount(AccountUsageCell, { + props: { + account: makeAccount({ + id: 2100, + platform: 'anthropic', + type: 'oauth', + extra: {} + }) + }, + global: { + stubs: { + UsageProgressBar: { + props: ['label', 'utilization', 'resetsAt', 'windowStats', 'color'], + template: '
{{ label }}|{{ utilization }}|{{ windowStats?.requests }}|{{ windowStats?.tokens }}|{{ windowStats?.standard_cost }}|{{ windowStats?.user_cost }}
' + }, + AccountQuotaInfo: true + } + } + }) + + await flushPromises() + + expect(getUsage).toHaveBeenCalledWith(2100) + expect(wrapper.text()).toContain('5h|10|5|500|0.55|0.25') + expect(wrapper.text()).toContain('7d|20|70|7000|7.7|3.5') + expect(wrapper.text()).toContain('7d S|30|17|1700|1.87|0.85') + }) + it('OpenAI OAuth 有 codex 快照时仍然使用 /usage API 数据渲染', async () => { getUsage.mockResolvedValue({ five_hour: { diff --git a/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts b/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts index 9def052c2e1..067f899c500 100644 --- a/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts +++ b/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts @@ -66,4 +66,27 @@ describe('UsageProgressBar', () => { expect(wrapper.text()).toContain('2h 30m') expect(wrapper.text()).not.toContain('现在') }) + + it('窗口统计展示 req、Token、A-USD 标准成本和 U-USD 用户成本', () => { + const wrapper = mount(UsageProgressBar, { + props: { + label: '7d', + utilization: 12, + resetsAt: '2026-03-17T02:30:00Z', + color: 'emerald', + windowStats: { + requests: 12, + tokens: 3456, + cost: 9.99, + standard_cost: 1.23, + user_cost: 4.56 + } + } + }) + + expect(wrapper.text()).toContain('12 req') + expect(wrapper.text()).toContain('3.5K') + expect(wrapper.text()).toContain('A $1.23') + expect(wrapper.text()).toContain('U $4.56') + }) }) From 70bf4d007d40e32816a9dd714d5db5435d2c4447 Mon Sep 17 00:00:00 2001 From: Simba98 Date: Mon, 8 Jun 2026 16:29:22 +0800 Subject: [PATCH 4/5] fix(antigravity): preserve claude file blocks as inline data --- backend/internal/pkg/antigravity/claude_types.go | 6 +++--- .../pkg/antigravity/request_transformer.go | 2 +- .../pkg/antigravity/request_transformer_test.go | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/backend/internal/pkg/antigravity/claude_types.go b/backend/internal/pkg/antigravity/claude_types.go index b651db94084..b3906402e7d 100644 --- a/backend/internal/pkg/antigravity/claude_types.go +++ b/backend/internal/pkg/antigravity/claude_types.go @@ -79,14 +79,14 @@ type ContentBlock struct { ToolUseID string `json:"tool_use_id,omitempty"` Content json.RawMessage `json:"content,omitempty"` IsError bool `json:"is_error,omitempty"` - // image + // image/document/file Source *ImageSource `json:"source,omitempty"` } -// ImageSource Claude 图片来源 +// ImageSource Claude 图片/文件来源 type ImageSource struct { Type string `json:"type"` // "base64" - MediaType string `json:"media_type"` // "image/png", "image/jpeg" 等 + MediaType string `json:"media_type"` // "image/png", "image/jpeg", "application/pdf" 等 Data string `json:"data"` } diff --git a/backend/internal/pkg/antigravity/request_transformer.go b/backend/internal/pkg/antigravity/request_transformer.go index 9068ad97308..e6835c150ad 100644 --- a/backend/internal/pkg/antigravity/request_transformer.go +++ b/backend/internal/pkg/antigravity/request_transformer.go @@ -464,7 +464,7 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu } parts = append(parts, part) - case "image": + case "image", "document", "file": if block.Source != nil && block.Source.Type == "base64" { parts = append(parts, GeminiPart{ InlineData: &GeminiInlineData{ diff --git a/backend/internal/pkg/antigravity/request_transformer_test.go b/backend/internal/pkg/antigravity/request_transformer_test.go index 6fae5b7c562..179ea34a8d8 100644 --- a/backend/internal/pkg/antigravity/request_transformer_test.go +++ b/backend/internal/pkg/antigravity/request_transformer_test.go @@ -150,6 +150,21 @@ func TestBuildParts_ToolUseSignatureHandling(t *testing.T) { }) } +func TestBuildParts_DocumentBase64PreservedAsInlineData(t *testing.T) { + content := `[ + {"type":"text","text":"这可以看得出是谁购买的吗?"}, + {"type":"document","source":{"type":"base64","media_type":"application/pdf","data":"JVBERi0xLjQ="},"title":"receipt.pdf"} + ]` + + parts, _, err := buildParts(json.RawMessage(content), map[string]string{}, true) + require.NoError(t, err) + require.Len(t, parts, 2) + require.Equal(t, "这可以看得出是谁购买的吗?", parts[0].Text) + require.NotNil(t, parts[1].InlineData) + require.Equal(t, "application/pdf", parts[1].InlineData.MimeType) + require.Equal(t, "JVBERi0xLjQ=", parts[1].InlineData.Data) +} + // TestBuildTools_CustomTypeTools 测试custom类型工具转换 func TestBuildTools_CustomTypeTools(t *testing.T) { tests := []struct { From 61d4c145ed38220166270f5260683aed65d58979 Mon Sep 17 00:00:00 2001 From: Simba98 Date: Mon, 8 Jun 2026 16:29:22 +0800 Subject: [PATCH 5/5] fix(apicompat): preserve file blocks across protocol bridges --- .../pkg/apicompat/anthropic_responses_test.go | 130 ++++++++++++++++++ .../pkg/apicompat/anthropic_to_responses.go | 30 +++- .../chatcompletions_responses_bridge.go | 48 ++++++- .../chatcompletions_responses_test.go | 93 +++++++++++++ .../apicompat/chatcompletions_to_responses.go | 23 ++++ .../responses_to_anthropic_request.go | 25 ++++ backend/internal/pkg/apicompat/types.go | 22 ++- 7 files changed, 367 insertions(+), 4 deletions(-) diff --git a/backend/internal/pkg/apicompat/anthropic_responses_test.go b/backend/internal/pkg/apicompat/anthropic_responses_test.go index 8997835c2aa..186ec185043 100644 --- a/backend/internal/pkg/apicompat/anthropic_responses_test.go +++ b/backend/internal/pkg/apicompat/anthropic_responses_test.go @@ -1314,6 +1314,136 @@ func TestAnthropicToResponses_ImageOnlyUserMessage(t *testing.T) { assert.Equal(t, "data:image/jpeg;base64,/9j/4AAQ", parts[0].ImageURL) } +func TestAnthropicToResponses_UserDocumentBlock(t *testing.T) { + req := &AnthropicRequest{ + Model: "gpt-5.2", + MaxTokens: 1024, + Messages: []AnthropicMessage{ + {Role: "user", Content: json.RawMessage(`[ + {"type":"text","text":"Summarize this file"}, + {"type":"document","title":"example-report.pdf","source":{"type":"base64","media_type":"application/pdf","data":"JVBERi0x"}} + ]`)}, + }, + } + + resp, err := AnthropicToResponses(req) + require.NoError(t, err) + + var items []ResponsesInputItem + require.NoError(t, json.Unmarshal(resp.Input, &items)) + require.Len(t, items, 1) + + var parts []ResponsesContentPart + require.NoError(t, json.Unmarshal(items[0].Content, &parts)) + require.Len(t, parts, 2) + assert.Equal(t, "input_text", parts[0].Type) + assert.Equal(t, "Summarize this file", parts[0].Text) + assert.Equal(t, "input_file", parts[1].Type) + assert.Equal(t, "example-report.pdf", parts[1].Filename) + assert.Equal(t, "data:application/pdf;base64,JVBERi0x", parts[1].FileData) + assert.Empty(t, parts[1].FileURL) +} + +func TestAnthropicToResponses_UserDocumentURLBlock(t *testing.T) { + req := &AnthropicRequest{ + Model: "gpt-5.2", + MaxTokens: 1024, + Messages: []AnthropicMessage{ + {Role: "user", Content: json.RawMessage(`[ + {"type":"document","title":"report.pdf","source":{"type":"url","url":"https://example.com/report.pdf"}} + ]`)}, + }, + } + + resp, err := AnthropicToResponses(req) + require.NoError(t, err) + + var items []ResponsesInputItem + require.NoError(t, json.Unmarshal(resp.Input, &items)) + require.Len(t, items, 1) + + var parts []ResponsesContentPart + require.NoError(t, json.Unmarshal(items[0].Content, &parts)) + require.Len(t, parts, 1) + assert.Equal(t, "input_file", parts[0].Type) + assert.Equal(t, "report.pdf", parts[0].Filename) + assert.Equal(t, "https://example.com/report.pdf", parts[0].FileURL) + assert.Empty(t, parts[0].FileData) +} + +func TestAnthropicToResponses_UserFileBlock(t *testing.T) { + req := &AnthropicRequest{ + Model: "gpt-5.2", + MaxTokens: 1024, + Messages: []AnthropicMessage{ + {Role: "user", Content: json.RawMessage(`[ + {"type":"file","title":"example-report.pdf","source":{"type":"base64","media_type":"application/pdf","data":"JVBERi0x"}} + ]`)}, + }, + } + + resp, err := AnthropicToResponses(req) + require.NoError(t, err) + + var items []ResponsesInputItem + require.NoError(t, json.Unmarshal(resp.Input, &items)) + require.Len(t, items, 1) + + var parts []ResponsesContentPart + require.NoError(t, json.Unmarshal(items[0].Content, &parts)) + require.Len(t, parts, 1) + assert.Equal(t, "input_file", parts[0].Type) + assert.Equal(t, "example-report.pdf", parts[0].Filename) + assert.Equal(t, "data:application/pdf;base64,JVBERi0x", parts[0].FileData) +} + +func TestResponsesToAnthropicRequest_InputFileDataBecomesDocument(t *testing.T) { + input := json.RawMessage(`[ + {"role":"user","content":[ + {"type":"input_text","text":"Analyze this"}, + {"type":"input_file","filename":"receipt.pdf","file_data":"data:application/pdf;base64,JVBERi0x"} + ]} + ]`) + req := &ResponsesRequest{Model: "claude-sonnet-4-6", Input: input} + + out, err := ResponsesToAnthropicRequest(req) + require.NoError(t, err) + require.Len(t, out.Messages, 1) + + var blocks []AnthropicContentBlock + require.NoError(t, json.Unmarshal(out.Messages[0].Content, &blocks)) + require.Len(t, blocks, 2) + assert.Equal(t, "text", blocks[0].Type) + assert.Equal(t, "document", blocks[1].Type) + assert.Equal(t, "receipt.pdf", blocks[1].Title) + require.NotNil(t, blocks[1].Source) + assert.Equal(t, "base64", blocks[1].Source.Type) + assert.Equal(t, "application/pdf", blocks[1].Source.MediaType) + assert.Equal(t, "JVBERi0x", blocks[1].Source.Data) +} + +func TestResponsesToAnthropicRequest_InputFileURLBecomesDocument(t *testing.T) { + input := json.RawMessage(`[ + {"role":"user","content":[ + {"type":"input_file","filename":"receipt.pdf","file_url":"https://example.com/receipt.pdf"} + ]} + ]`) + req := &ResponsesRequest{Model: "claude-sonnet-4-6", Input: input} + + out, err := ResponsesToAnthropicRequest(req) + require.NoError(t, err) + require.Len(t, out.Messages, 1) + + var blocks []AnthropicContentBlock + require.NoError(t, json.Unmarshal(out.Messages[0].Content, &blocks)) + require.Len(t, blocks, 1) + assert.Equal(t, "document", blocks[0].Type) + assert.Equal(t, "receipt.pdf", blocks[0].Title) + require.NotNil(t, blocks[0].Source) + assert.Equal(t, "url", blocks[0].Source.Type) + assert.Equal(t, "https://example.com/receipt.pdf", blocks[0].Source.URL) +} + func TestAnthropicToResponses_ToolResultWithImage(t *testing.T) { req := &AnthropicRequest{ Model: "gpt-5.2", diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses.go b/backend/internal/pkg/apicompat/anthropic_to_responses.go index e2011bee0bf..a59969221b1 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses.go @@ -226,7 +226,7 @@ func anthropicUserToResponses(raw json.RawMessage) ([]ResponsesInputItem, error) toolResultImageParts = append(toolResultImageParts, imageParts...) } - // Remaining text + image blocks → user message with content parts. + // Remaining text + image/file blocks → user message with content parts. // Also include images extracted from tool_results so the model can see them. var parts []ResponsesContentPart for _, b := range blocks { @@ -239,6 +239,10 @@ func anthropicUserToResponses(raw json.RawMessage) ([]ResponsesInputItem, error) if uri := anthropicImageToDataURI(b.Source); uri != "" { parts = append(parts, ResponsesContentPart{Type: "input_image", ImageURL: uri}) } + case "document", "file": + if part := anthropicFileToResponsesPart(b); part != nil { + parts = append(parts, *part) + } } } parts = append(parts, toolResultImageParts...) @@ -341,6 +345,30 @@ func anthropicImageToDataURI(src *AnthropicImageSource) string { return "data:" + mediaType + ";base64," + src.Data } +func anthropicFileToResponsesPart(block AnthropicContentBlock) *ResponsesContentPart { + if block.Source == nil { + return nil + } + part := ResponsesContentPart{Type: "input_file", Filename: block.Title} + switch block.Source.Type { + case "url": + part.FileURL = block.Source.URL + default: + if block.Source.Data == "" { + return nil + } + mediaType := block.Source.MediaType + if mediaType == "" { + mediaType = "application/octet-stream" + } + part.FileData = "data:" + mediaType + ";base64," + block.Source.Data + } + if part.FileData == "" && part.FileURL == "" { + return nil + } + return &part +} + // convertToolResultOutput extracts text and image content from a tool_result // block. Returns the text as a string for the function_call_output Output // field, plus any image parts that must be sent in a separate user message diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index 09b680c7c73..2f6425f8ebb 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -121,7 +121,7 @@ func responsesInputToChatMessages(instructions string, inputRaw json.RawMessage) content, _ := json.Marshal(rawString(item["text"])) messages = append(messages, ChatMessage{Role: "user", Content: content}) continue - case "input_image": + case "input_image", "input_file": content, err := chatContentFromSingleResponsesPart(itemType, item) if err != nil { return nil, err @@ -217,6 +217,31 @@ func responsesContentPartsToChatContent(rawParts []json.RawMessage, role string) Type: "image_url", ImageURL: &ChatImageURL{URL: imageURL}, }) + case "input_file", "file": + fileData := rawString(part["file_data"]) + fileURL := rawString(part["file_url"]) + filename := rawString(part["filename"]) + if fileData == "" { + fileData = rawNestedString(part["file"], "file_data") + } + if fileURL == "" { + fileURL = rawNestedString(part["file"], "file_url") + } + if filename == "" { + filename = rawNestedString(part["file"], "filename") + } + if fileData == "" && fileURL == "" { + continue + } + hasNonText = true + chatParts = append(chatParts, ChatContentPart{ + Type: "file", + File: &ChatFile{ + Filename: filename, + FileData: fileData, + FileURL: fileURL, + }, + }) } } @@ -246,6 +271,27 @@ func chatContentFromSingleResponsesPart(partType string, part map[string]json.Ra Type: "image_url", ImageURL: &ChatImageURL{URL: imageURL}, }}) + case "input_file", "file": + fileData := rawString(part["file_data"]) + fileURL := rawString(part["file_url"]) + filename := rawString(part["filename"]) + if fileData == "" { + fileData = rawNestedString(part["file"], "file_data") + } + if fileURL == "" { + fileURL = rawNestedString(part["file"], "file_url") + } + if filename == "" { + filename = rawNestedString(part["file"], "filename") + } + return json.Marshal([]ChatContentPart{{ + Type: "file", + File: &ChatFile{ + Filename: filename, + FileData: fileData, + FileURL: fileURL, + }, + }}) default: return json.Marshal(rawString(part["text"])) } diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index b03b012fc7a..de71ec002bc 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -260,6 +260,56 @@ func TestChatCompletionsToResponses_EmptyContentNeverNull(t *testing.T) { } } +func TestChatCompletionsToResponses_FileData(t *testing.T) { + content := `[{"type":"text","text":"Analyze this file"},{"type":"file","file":{"filename":"example-report.pdf","file_data":"data:application/pdf;base64,abc123"}}]` + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + Messages: []ChatMessage{ + {Role: "user", Content: json.RawMessage(content)}, + }, + } + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + + var items []ResponsesInputItem + require.NoError(t, json.Unmarshal(resp.Input, &items)) + require.Len(t, items, 1) + + var parts []ResponsesContentPart + require.NoError(t, json.Unmarshal(items[0].Content, &parts)) + require.Len(t, parts, 2) + assert.Equal(t, "input_text", parts[0].Type) + assert.Equal(t, "Analyze this file", parts[0].Text) + assert.Equal(t, "input_file", parts[1].Type) + assert.Equal(t, "example-report.pdf", parts[1].Filename) + assert.Equal(t, "data:application/pdf;base64,abc123", parts[1].FileData) + assert.Empty(t, parts[1].FileURL) +} + +func TestChatCompletionsToResponses_FileURL(t *testing.T) { + content := `[{"type":"file","file":{"filename":"report.pdf","file_url":"https://example.com/report.pdf"}}]` + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + Messages: []ChatMessage{ + {Role: "user", Content: json.RawMessage(content)}, + }, + } + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + + var items []ResponsesInputItem + require.NoError(t, json.Unmarshal(resp.Input, &items)) + require.Len(t, items, 1) + + var parts []ResponsesContentPart + require.NoError(t, json.Unmarshal(items[0].Content, &parts)) + require.Len(t, parts, 1) + assert.Equal(t, "input_file", parts[0].Type) + assert.Equal(t, "report.pdf", parts[0].Filename) + assert.Equal(t, "https://example.com/report.pdf", parts[0].FileURL) + assert.Empty(t, parts[0].FileData) +} + func TestChatCompletionsToResponses_SystemArrayContent(t *testing.T) { req := &ChatCompletionsRequest{ Model: "gpt-4o", @@ -617,6 +667,49 @@ func TestChatCompletionsToResponses_ToolArrayContent(t *testing.T) { assert.Equal(t, "image width: 100; image height: 200", items[2].Output) } +func TestResponsesToChatCompletionsRequest_InputFile(t *testing.T) { + input := json.RawMessage(`[ + {"role":"user","content":[ + {"type":"input_text","text":"Analyze this"}, + {"type":"input_file","filename":"receipt.pdf","file_data":"data:application/pdf;base64,JVBERi0x"} + ]} + ]`) + req := &ResponsesRequest{Model: "gpt-4o", Input: input} + + chatReq, err := ResponsesToChatCompletionsRequest(req) + require.NoError(t, err) + require.Len(t, chatReq.Messages, 1) + + var parts []ChatContentPart + require.NoError(t, json.Unmarshal(chatReq.Messages[0].Content, &parts)) + require.Len(t, parts, 2) + assert.Equal(t, "text", parts[0].Type) + assert.Equal(t, "Analyze this", parts[0].Text) + require.NotNil(t, parts[1].File) + assert.Equal(t, "file", parts[1].Type) + assert.Equal(t, "receipt.pdf", parts[1].File.Filename) + assert.Equal(t, "data:application/pdf;base64,JVBERi0x", parts[1].File.FileData) +} + +func TestResponsesToChatCompletionsRequest_TopLevelInputFile(t *testing.T) { + input := json.RawMessage(`[ + {"type":"input_file","filename":"receipt.pdf","file_url":"https://example.com/receipt.pdf"} + ]`) + req := &ResponsesRequest{Model: "gpt-4o", Input: input} + + chatReq, err := ResponsesToChatCompletionsRequest(req) + require.NoError(t, err) + require.Len(t, chatReq.Messages, 1) + + var parts []ChatContentPart + require.NoError(t, json.Unmarshal(chatReq.Messages[0].Content, &parts)) + require.Len(t, parts, 1) + require.NotNil(t, parts[0].File) + assert.Equal(t, "file", parts[0].Type) + assert.Equal(t, "receipt.pdf", parts[0].File.Filename) + assert.Equal(t, "https://example.com/receipt.pdf", parts[0].File.FileURL) +} + func TestResponsesToChatCompletions_Incomplete(t *testing.T) { resp := &ResponsesResponse{ ID: "resp_inc", diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index 463bdd0d15d..0a976763bf9 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -370,6 +370,29 @@ func convertChatContentPartsToResponses(parts []ChatContentPart) []ResponsesCont ImageURL: p.ImageURL.URL, }) } + case "file": + fileData := p.FileData + fileURL := p.FileURL + filename := p.Filename + if p.File != nil { + if fileData == "" { + fileData = p.File.FileData + } + if fileURL == "" { + fileURL = p.File.FileURL + } + if filename == "" { + filename = p.File.Filename + } + } + if fileData != "" || fileURL != "" { + responseParts = append(responseParts, ResponsesContentPart{ + Type: "input_file", + FileData: fileData, + FileURL: fileURL, + Filename: filename, + }) + } } } return responseParts diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go index 8fa652f2bd1..45a4bb087f8 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go @@ -259,6 +259,10 @@ func convertResponsesUserToAnthropicContent(raw json.RawMessage) (json.RawMessag Source: src, }) } + case "input_file": + if block := responsesFileToAnthropicBlock(p); block != nil { + blocks = append(blocks, *block) + } } } @@ -324,6 +328,10 @@ func fromResponsesCallIDToAnthropic(id string) string { // dataURIToAnthropicImageSource parses a data URI into an AnthropicImageSource. func dataURIToAnthropicImageSource(dataURI string) *AnthropicImageSource { + return dataURIToAnthropicSource(dataURI) +} + +func dataURIToAnthropicSource(dataURI string) *AnthropicImageSource { if !strings.HasPrefix(dataURI, "data:") { return nil } @@ -346,6 +354,23 @@ func dataURIToAnthropicImageSource(dataURI string) *AnthropicImageSource { } } +func responsesFileToAnthropicBlock(part ResponsesContentPart) *AnthropicContentBlock { + block := AnthropicContentBlock{Type: "document", Title: part.Filename} + switch { + case part.FileData != "": + src := dataURIToAnthropicSource(part.FileData) + if src == nil { + return nil + } + block.Source = src + case part.FileURL != "": + block.Source = &AnthropicImageSource{Type: "url", URL: part.FileURL} + default: + return nil + } + return &block +} + // mergeConsecutiveMessages merges consecutive messages with the same role // because Anthropic requires alternating user/assistant turns. func mergeConsecutiveMessages(messages []AnthropicMessage) []AnthropicMessage { diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index b4451f235bb..01e8e99e0ae 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -63,6 +63,7 @@ type AnthropicContentBlock struct { // type=image Source *AnthropicImageSource `json:"source,omitempty"` + Title string `json:"title,omitempty"` // type=tool_use ID string `json:"id,omitempty"` @@ -102,6 +103,7 @@ type AnthropicImageSource struct { Type string `json:"type"` // "base64" MediaType string `json:"media_type"` Data string `json:"data"` + URL string `json:"url,omitempty"` } // AnthropicTool describes a tool available to the model. @@ -241,9 +243,12 @@ type ResponsesInputItem struct { // ResponsesContentPart is a typed content part in a Responses message. type ResponsesContentPart struct { - Type string `json:"type"` // "input_text" | "output_text" | "input_image" + Type string `json:"type"` // "input_text" | "output_text" | "input_image" | "input_file" Text string `json:"text,omitempty"` ImageURL string `json:"image_url,omitempty"` // data URI for input_image + FileData string `json:"file_data,omitempty"` // data URI for input_file + FileURL string `json:"file_url,omitempty"` // URL for input_file + Filename string `json:"filename,omitempty"` // original filename for input_file } // ResponsesTool describes a tool in the Responses API. @@ -460,9 +465,15 @@ type ChatMessage struct { // ChatContentPart is a typed content part in a multi-modal message. type ChatContentPart struct { - Type string `json:"type"` // "text" | "image_url" + Type string `json:"type"` // "text" | "image_url" | "file" Text string `json:"text,omitempty"` ImageURL *ChatImageURL `json:"image_url,omitempty"` + File *ChatFile `json:"file,omitempty"` + + // Optional flat file fields for compatibility with different client payloads. + FileData string `json:"file_data,omitempty"` + FileURL string `json:"file_url,omitempty"` + Filename string `json:"filename,omitempty"` } // ChatImageURL contains the URL for an image content part. @@ -471,6 +482,13 @@ type ChatImageURL struct { Detail string `json:"detail,omitempty"` // "auto" | "low" | "high" } +// ChatFile contains file metadata and source for a file content part. +type ChatFile struct { + Filename string `json:"filename,omitempty"` + FileData string `json:"file_data,omitempty"` + FileURL string `json:"file_url,omitempty"` +} + // ChatTool describes a tool available to the model. type ChatTool struct { Type string `json:"type"` // "function"