From 931660f731ddfc9174e29ed24a0beade079b61ca Mon Sep 17 00:00:00 2001 From: Lollipopdamn <2516731482@qq.com> Date: Fri, 13 Nov 2026 15:29:19 +0800 Subject: [PATCH] fix: sync v2.3.66 and correct usage accounting and mobile layout --- .github/workflows/backend-ci.yml | 2 +- LOCAL_FORK.md | 6 + backend/cmd/server/VERSION | 2 +- .../anthropic_usage_presence_test.go | 41 +++++ backend/internal/pkg/apicompat/types.go | 34 ++++- backend/internal/pkg/urljoin/urljoin.go | 106 +++++++++++++ backend/internal/pkg/urljoin/urljoin_test.go | 120 +++++++++++++++ .../securityaudit/prompt_outbound_security.go | 5 +- .../prompt_outbound_security_test.go | 54 +++++++ ...anthropic_converted_usage_fidelity_test.go | 13 ++ .../anthropic_delta_placeholder_zero_test.go | 96 ++++++++++++ .../service/anthropic_messages_url.go | 17 ++- .../service/anthropic_messages_url_test.go | 60 ++++++++ .../service/antigravity_gateway_upstream.go | 3 +- .../service/channel_monitor_checker.go | 20 +-- .../service/gateway_anthropic_passthrough.go | 40 ++++- .../internal/service/gateway_count_tokens.go | 4 +- .../service/gateway_forward_as_responses.go | 2 +- .../service/gateway_upstream_request.go | 3 +- .../service/gateway_upstream_response.go | 15 +- .../service/update_source_baselines.go | 4 +- backend/internal/service/upstream_models.go | 10 +- .../internal/service/upstream_models_test.go | 3 + docs/release-notes/v2.1.64.md | 16 ++ .../admin/usage/UsageStatsCards.vue | 5 + frontend/src/stores/__tests__/app.spec.ts | 78 ++++++++++ frontend/src/stores/app.ts | 11 ++ frontend/src/views/admin/UsageView.vue | 1 + .../notes/upstream-sync-20261113-v2366.md | 54 +++++++ maintenance/upstream.lock.json | 144 +++++++++++++++--- 30 files changed, 896 insertions(+), 73 deletions(-) create mode 100644 backend/internal/pkg/urljoin/urljoin.go create mode 100644 backend/internal/pkg/urljoin/urljoin_test.go create mode 100644 backend/internal/service/anthropic_delta_placeholder_zero_test.go create mode 100644 docs/release-notes/v2.1.64.md create mode 100644 maintenance/notes/upstream-sync-20261113-v2366.md diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 5ce72f7d..35988946 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -66,7 +66,7 @@ jobs: run: go test -race -p 1 -tags=unit ./internal/service -run 'Test(TerminalOpenBody|OpenAIStream|OpenAIHTTP524|GrokSimpleMessages|AnthropicCacheTTLBeta|RequestNeedsExtendedCacheTTLBeta|EnsureExtendedCacheTTLBeta|ApplyExtendedCacheTTLBetaHeader)' -count=1 - name: Anthropic cache conversion and committed-stream regression working-directory: backend - run: go test -race -p 1 -tags=unit ./internal/pkg/apicompat ./internal/service ./internal/handler -run 'Test(AnthropicUsagePresence|MergeAnthropicUsage|ParseSSEUsage|GatewayService_ParseSSEUsagePassthrough|ChatAnthropicCacheControl|ResponsesAnthropicCacheControl|ConvertedAnthropic|NativeAnthropicCachePolicy|GatewayConvertedToolFileError|EnforceCacheControlLimit|NormalizeCacheControlTTLOrder)' -count=1 + run: go test -race -p 1 -tags=unit ./internal/pkg/apicompat ./internal/pkg/urljoin ./internal/securityaudit ./internal/service ./internal/handler -run 'Test(AnthropicUsagePresence|AnthropicMessageDelta|MergeAnthropicUsage|ParseSSEUsage|GatewayService_ParseSSEUsagePassthrough|ChatAnthropicCacheControl|ResponsesAnthropicCacheControl|ConvertedAnthropic|NativeAnthropicCachePolicy|GatewayConvertedToolFileError|EnforceCacheControlLimit|NormalizeCacheControlTTLOrder|APIPath|WithQueryParam|PromptAudit|AnthropicVersionedURL|AnthropicMessagesURL|Build.*ModelsURL|JoinMonitorURL|CallProvider_BasePath)' -count=1 - name: Passthrough lifecycle and domestic model catalog concurrency regression working-directory: backend run: go test -race -p 1 -tags=unit ./internal/service ./internal/handler -run 'Test(PassthroughLifecycle|DomesticModel|DomesticUpstream|GatewayModelsDomestic|ResponsesPassthroughHeartbeat)' -count=1 diff --git a/LOCAL_FORK.md b/LOCAL_FORK.md index 15d28264..80a0a96e 100644 --- a/LOCAL_FORK.md +++ b/LOCAL_FORK.md @@ -30,6 +30,12 @@ 原生支付模块已退役,充值使用链动小铺及兑换码。服务器部署继续使用备份和校验后的受控发布流程。 +## 二改 v2.3.66 选择性同步(2026-11-13) + +从正式 v2.3.58 审查至 v2.3.66,适配版本化 API 地址幂等拼接、Anthropic 流式输入占位零及缓存别名用量一致性,以及公共设置失败后的 30 秒重试退避。保留 Cloudflare beta 例外、原生厂商版本/路由、真实终态全零与本地计费、并发/force 设置刷新及冷灰蓝主题。 + +公共设置 HTTP/CDN 缓存、固定缓存命中 critical 阈值、受管代理/Mihomo、IP 自动风控和上游类型/余额图标继续延期,退役支付不恢复。发布前审查补齐 prompt 总量/显式 cache miss 与 cache hit 归零的转换一致性,并修复 320px 英文管理页 Grid 列宽溢出。用户已授权审查修复后提交 GitHub 并部署,个人版本推进至 v2.1.64,沿用 PR、CI、自有 Release/GHCR 和备份恢复校验流程;无迁移、依赖或运行配置变化。来源与同步验证见[同步记录](maintenance/notes/upstream-sync-20261113-v2366.md)和[发布说明](docs/release-notes/v2.1.64.md),最终部署验收另存发布证据。 + ## 二改 v2.3.58 选择性同步(2026-11-12) 从正式 v2.3.54 审查至 v2.3.58,适配声明倍率始终显示数字,以及管理端、用户端用量缓存明细浮层的窄屏越界。保留现有倍率时效/峰值计算、原生厂商路由、真实终态用量与冷灰蓝主题。 diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 6a6199df..0b8811fe 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -2.1.63 +2.1.64 diff --git a/backend/internal/pkg/apicompat/anthropic_usage_presence_test.go b/backend/internal/pkg/apicompat/anthropic_usage_presence_test.go index 869a02be..75d14776 100644 --- a/backend/internal/pkg/apicompat/anthropic_usage_presence_test.go +++ b/backend/internal/pkg/apicompat/anthropic_usage_presence_test.go @@ -131,3 +131,44 @@ func TestAnthropicUsagePresenceEmptyTTLDoesNotClearAggregate(t *testing.T) { require.Nil(t, usage.CacheCreation) } } + +func TestAnthropicUsagePresencePlaceholderAndExplicitCacheZero(t *testing.T) { + for _, tc := range []struct { + name, delta string + wantInput int + }{ + {"bare zero", `{"input_tokens":0,"output_tokens":8}`, 11}, + {"null cache", `{"input_tokens":0,"output_tokens":8,"cache_read_input_tokens":null}`, 11}, + {"empty cache details", `{"input_tokens":0,"output_tokens":8,"cache_creation":{}}`, 11}, + {"positive cache", `{"input_tokens":0,"output_tokens":8,"cache_read_input_tokens":600}`, 0}, + {"explicit zero cache", `{"input_tokens":0,"output_tokens":8,"cache_read_input_tokens":0}`, 0}, + {"explicit zero TTL", `{"input_tokens":0,"output_tokens":8,"cache_creation":{"ephemeral_1h_input_tokens":0}}`, 0}, + {"cache alias", `{"input_tokens":0,"output_tokens":8,"cached_tokens":600}`, 0}, + {"prompt detail alias", `{"input_tokens":0,"output_tokens":8,"prompt_tokens_details":{"cached_tokens":600}}`, 0}, + {"cache hit alias", `{"input_tokens":0,"output_tokens":8,"prompt_cache_hit_tokens":600}`, 0}, + {"bare alias zero", `{"input_tokens":0,"output_tokens":8,"cached_tokens":0}`, 11}, + } { + t.Run(tc.name, func(t *testing.T) { + usage := AnthropicUsage{InputTokens: 11} + var delta AnthropicUsage + require.NoError(t, json.Unmarshal([]byte(tc.delta), &delta)) + usage.Merge(delta) + require.Equal(t, tc.wantInput, usage.InputTokens) + require.Equal(t, 8, usage.OutputTokens) + + state := NewAnthropicEventToResponsesState() + for _, raw := range []string{ + `{"type":"message_start","message":{"id":"msg_placeholder","model":"claude-test","usage":{"input_tokens":11}}}`, + `{"type":"message_delta","usage":` + tc.delta + `,"delta":{"stop_reason":"end_turn"}}`, + } { + var event AnthropicStreamEvent + require.NoError(t, json.Unmarshal([]byte(raw), &event)) + AnthropicEventToResponsesEvents(&event, state) + } + events := AnthropicEventToResponsesEvents(&AnthropicStreamEvent{Type: "message_stop"}, state) + require.Len(t, events, 1) + require.Equal(t, tc.wantInput+usage.CacheReadInputTokens, events[0].Response.Usage.InputTokens) + require.Equal(t, 8, events[0].Response.Usage.OutputTokens) + }) + } +} diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index 47e64208..01cceac1 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -242,10 +242,21 @@ func (u AnthropicUsage) HasCacheReadInputTokens() bool { return u.cacheReadInputTokensPresent || u.CacheReadInputTokens != 0 } +// HasCacheTokenFields distinguishes a reported cache split, including explicit +// zeroes, from a relay's input-only placeholder. Positive provider cache aliases +// also establish a split. Null and empty details do not report any counters. +func (u AnthropicUsage) HasCacheTokenFields() bool { + return u.HasCacheCreationInputTokens() || u.HasCacheReadInputTokens() || + (u.CacheCreation != nil && (u.CacheCreation.Ephemeral5mInputTokens != nil || u.CacheCreation.Ephemeral1hInputTokens != nil)) || + u.CachedTokens > 0 || (u.PromptTokensDetails != nil && u.PromptTokensDetails.CachedTokens > 0) || + (u.PromptCacheHitTokens != nil && *u.PromptCacheHitTokens > 0) +} + // Merge applies cumulative usage from a streaming event. Missing fields retain -// their preceding values; explicit zeroes clear them rather than double billing. +// their preceding values. A bare input zero is a relay placeholder; a reported +// cache split makes it authoritative, including an explicitly all-zero split. func (u *AnthropicUsage) Merge(src AnthropicUsage) { - if src.HasInputTokens() { + if src.HasInputTokens() && (src.InputTokens != 0 || src.HasCacheTokenFields()) { u.InputTokens, u.inputTokensPresent = src.InputTokens, true } if src.HasOutputTokens() { @@ -260,6 +271,18 @@ func (u *AnthropicUsage) Merge(src AnthropicUsage) { if src.HasCacheReadInputTokens() { u.CacheReadInputTokens, u.cacheReadInputTokensPresent = src.CacheReadInputTokens, true } + // Normalize reported cache-read aliases into the canonical bucket consumed + // by event converters, even when the event omits the prompt total. + if src.CacheReadInputTokens == 0 { + switch { + case src.CachedTokens > 0: + u.CacheReadInputTokens, u.cacheReadInputTokensPresent = src.CachedTokens, true + case src.PromptTokensDetails != nil && src.PromptTokensDetails.CachedTokens > 0: + u.CacheReadInputTokens, u.cacheReadInputTokensPresent = src.PromptTokensDetails.CachedTokens, true + case src.PromptCacheHitTokens != nil: + u.CacheReadInputTokens, u.cacheReadInputTokensPresent = max(*src.PromptCacheHitTokens, 0), true + } + } // A zero aggregate rules out any billable TTL bucket, even when an // incomplete or contradictory detail object accompanies it. An empty // detail object has no update and must not erase an aggregate-only total. @@ -286,6 +309,13 @@ func (u *AnthropicUsage) Merge(src AnthropicUsage) { } } } + // Provider prompt totals include cache reads/writes. Keep the event + // converter's uncached input identical to the service billing buckets. + if src.PromptCacheMissTokens != nil { + u.InputTokens, u.inputTokensPresent = max(*src.PromptCacheMissTokens, 0), true + } else if src.PromptTokens > 0 { + u.InputTokens, u.inputTokensPresent = max(src.PromptTokens-u.CacheReadInputTokens-u.CacheCreationInputTokens, 0), true + } } // --------------------------------------------------------------------------- diff --git a/backend/internal/pkg/urljoin/urljoin.go b/backend/internal/pkg/urljoin/urljoin.go new file mode 100644 index 00000000..8b262285 --- /dev/null +++ b/backend/internal/pkg/urljoin/urljoin.go @@ -0,0 +1,106 @@ +// Package urljoin builds upstream API URLs from an operator-supplied base URL +// and a versioned API path. +// +// Base URLs are written by hand, and relay providers commonly keep the API +// version inside the base path (`https://relay.example/claude/v1`), while the +// endpoint constants used by the gateway are already versioned (`/v1/models`, +// `/v1/messages`). Plain string concatenation therefore produces +// `/v1/v1/models`, which the upstream answers with 404 even though +// `/v1/messages` on the same base works. +// +// APIPath joins the two idempotently: an existing version or endpoint suffix in +// the base path is never duplicated, and a query string stays attached to the +// URL instead of being buried in front of the appended path. +package urljoin + +import ( + "net/url" + "strings" +) + +// APIPath joins endpoint onto base. +// +// It is idempotent for every base spelling that appears in operator +// configuration: +// +// APIPath("https://x", "v1/models") = "https://x/v1/models" +// APIPath("https://x/", "v1/models") = "https://x/v1/models" +// APIPath("https://x/v1", "v1/models") = "https://x/v1/models" +// APIPath("https://x/v1/", "v1/models") = "https://x/v1/models" +// APIPath("https://x/api/openai/v1", "v1/models") = "https://x/api/openai/v1/models" +// APIPath("https://x/prefix", "v1/models") = "https://x/prefix/v1/models" +// +// An endpoint that is already present in full is returned unchanged, and any +// query string on base is preserved: +// +// APIPath("https://x/v1/models?tenant=a", "v1/models") = "https://x/v1/models?tenant=a" +// APIPath("https://x/v1?tenant=a", "v1/models") = "https://x/v1/models?tenant=a" +func APIPath(base, endpoint string) string { + base = strings.TrimSpace(base) + if base == "" { + return "" + } + endpoint = "/" + strings.Trim(strings.TrimSpace(endpoint), "/") + if endpoint == "/" { + return base + } + + // A fragment is never sent upstream; drop it instead of carrying it into + // the appended path. + if i := strings.IndexByte(base, '#'); i >= 0 { + base = base[:i] + } + if base == "" { + return "" + } + + query := "" + if i := strings.IndexByte(base, '?'); i >= 0 { + query = base[i:] + base = base[:i] + } + base = strings.TrimRight(base, "/") + + path := "" + if parsed, err := url.Parse(base); err == nil { + path = parsed.EscapedPath() + } + path = strings.TrimRight(path, "/") + if path == "" { + return base + endpoint + query + } + + // The endpoint is already complete (`.../v1/models`). + if path == endpoint || strings.HasSuffix(path, endpoint) { + return base + query + } + // Walk the endpoint's own segments from the longest to the shortest: a base + // that already ends in the version segment (`.../v1`) only needs the rest of + // the endpoint (`/models`). Segment matching keeps a base whose last segment + // merely contains the version (`/v10`, `/tenant%2Fv1`) from being treated as + // versioned. + for end := strings.LastIndexByte(endpoint, '/'); end > 0; end = strings.LastIndexByte(endpoint[:end], '/') { + if strings.HasSuffix(path, endpoint[:end]) { + return base + endpoint[end:] + query + } + } + return base + endpoint + query +} + +// WithQueryParam appends key=value to raw's query string without rewriting the +// parameters it already carries. Callers that add protocol flags (for example +// `beta=true` on the Anthropic messages endpoint) must not turn an existing +// query into a second `?`. +func WithQueryParam(raw, key, value string) string { + if key == "" || value == "" { + return raw + } + pair := url.QueryEscape(key) + "=" + url.QueryEscape(value) + if i := strings.IndexByte(raw, '?'); i >= 0 { + if strings.HasSuffix(raw, "?") || strings.HasSuffix(raw, "&") { + return raw + pair + } + return raw + "&" + pair + } + return raw + "?" + pair +} diff --git a/backend/internal/pkg/urljoin/urljoin_test.go b/backend/internal/pkg/urljoin/urljoin_test.go new file mode 100644 index 00000000..98cae8fd --- /dev/null +++ b/backend/internal/pkg/urljoin/urljoin_test.go @@ -0,0 +1,120 @@ +package urljoin + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestAPIPathModelsBaseURLTable is the regression table for the reported bug: +// every base_url spelling an operator can paste into an account or audit node +// must end up on the single /v1/models endpoint. +func TestAPIPathModelsBaseURLTable(t *testing.T) { + t.Parallel() + + tests := []struct { + base string + want string + }{ + {base: "https://x", want: "https://x/v1/models"}, + {base: "https://x/", want: "https://x/v1/models"}, + {base: "https://x/v1", want: "https://x/v1/models"}, + {base: "https://x/v1/", want: "https://x/v1/models"}, + {base: "https://x/api/openai/v1", want: "https://x/api/openai/v1/models"}, + {base: "https://x/prefix", want: "https://x/prefix/v1/models"}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.base, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tt.want, APIPath(tt.base, "/v1/models")) + }) + } +} + +// TestAPIPathMessagesBaseURLTable covers the same base_url table for the +// Anthropic messages endpoint: no spelling may produce /v1/v1/messages. +func TestAPIPathMessagesBaseURLTable(t *testing.T) { + t.Parallel() + + tests := []struct { + base string + want string + }{ + {base: "https://x", want: "https://x/v1/messages"}, + {base: "https://x/", want: "https://x/v1/messages"}, + {base: "https://x/v1", want: "https://x/v1/messages"}, + {base: "https://x/v1/", want: "https://x/v1/messages"}, + {base: "https://x/api/openai/v1", want: "https://x/api/openai/v1/messages"}, + {base: "https://x/prefix", want: "https://x/prefix/v1/messages"}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.base, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tt.want, APIPath(tt.base, "/v1/messages")) + }) + } +} + +func TestAPIPathPreservesQueryAndDropsFragment(t *testing.T) { + t.Parallel() + + require.Equal(t, "https://x/v1/models?tenant=a", APIPath("https://x/v1?tenant=a", "/v1/models")) + require.Equal(t, "https://x/v1/models?tenant=a", APIPath("https://x/v1/?tenant=a", "/v1/models")) + require.Equal(t, "https://x/v1/models?tenant=a", APIPath("https://x/v1/models?tenant=a", "/v1/models")) + require.Equal(t, "https://x/v1/messages?beta=true", APIPath("https://x/v1?beta=true", "/v1/messages")) + require.Equal(t, "https://x/v1/models", APIPath("https://x/v1#stale", "/v1/models")) + require.Equal(t, "https://x/v1/models?tenant=a", APIPath("https://x/v1?tenant=a#stale", "/v1/models")) +} + +func TestAPIPathDoesNotTreatLookalikeSegmentsAsVersion(t *testing.T) { + t.Parallel() + + // /v10 and an encoded slash are plain path segments, not the v1 prefix. + require.Equal(t, "https://x/v10/v1/messages", APIPath("https://x/v10", "/v1/messages")) + require.Equal(t, "https://relay.example/tenant%2Fv1/v1/messages", + APIPath("https://relay.example/tenant%2Fv1", "/v1/messages")) + // A hostname that happens to be "v1" is not a path segment either. + require.Equal(t, "https://v1/v1/messages", APIPath("https://v1", "/v1/messages")) +} + +func TestAPIPathLeavesCompleteEndpointsAlone(t *testing.T) { + t.Parallel() + + require.Equal(t, "https://x/v1/models", APIPath("https://x/v1/models", "/v1/models")) + require.Equal(t, "https://x/anthropic/v1/models", APIPath("https://x/anthropic/v1/models", "/v1/models")) + require.Equal(t, "https://x/v1/messages", APIPath("https://x/v1/messages", "/v1/messages")) + require.Equal(t, "https://x/anthropic/v1/messages", APIPath("https://x/anthropic/v1/messages", "/v1/messages")) +} + +func TestAPIPathKeepsVersionedBases(t *testing.T) { + t.Parallel() + + require.Equal(t, "https://generativelanguage.googleapis.com/v1beta/models", + APIPath("https://generativelanguage.googleapis.com/v1beta", "/v1beta/models")) + require.Equal(t, "https://generativelanguage.googleapis.com/v1beta/models", + APIPath("https://generativelanguage.googleapis.com", "/v1beta/models")) +} + +func TestAPIPathDegenerateInputs(t *testing.T) { + t.Parallel() + + require.Empty(t, APIPath("", "/v1/models")) + require.Empty(t, APIPath(" ", "/v1/models")) + require.Equal(t, "https://x/v1/models", APIPath("https://x", "v1/models")) + require.Equal(t, "https://x/v1/models", APIPath("https://x", "/v1/models/")) + require.Equal(t, "https://x/v1/models", APIPath(" https://x ", " /v1/models ")) +} + +func TestWithQueryParam(t *testing.T) { + t.Parallel() + + require.Equal(t, "https://x/v1/messages?beta=true", WithQueryParam("https://x/v1/messages", "beta", "true")) + require.Equal(t, "https://x/v1/messages?beta=true&proxy=p", WithQueryParam("https://x/v1/messages?beta=true", "proxy", "p")) + require.Equal(t, "https://x/v1/messages?beta=true", WithQueryParam("https://x/v1/messages?", "beta", "true")) + require.Equal(t, "https://x/v1/messages?a=b", WithQueryParam("https://x/v1/messages?a=b", "", "true")) + require.Equal(t, "https://x/v1/messages?a=b", WithQueryParam("https://x/v1/messages?a=b", "beta", "")) +} diff --git a/backend/internal/securityaudit/prompt_outbound_security.go b/backend/internal/securityaudit/prompt_outbound_security.go index 81e987eb..6872f216 100644 --- a/backend/internal/securityaudit/prompt_outbound_security.go +++ b/backend/internal/securityaudit/prompt_outbound_security.go @@ -9,6 +9,7 @@ import ( "time" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/urljoin" ) const maxGuardResponseBytes int64 = 256 * 1024 @@ -44,7 +45,7 @@ func ChatCompletionsURL(base string) (string, error) { if err != nil { return "", err } - return normalized + "/v1/chat/completions", nil + return urljoin.APIPath(normalized, "/v1/chat/completions"), nil } func ModelsURL(base string) (string, error) { @@ -52,7 +53,7 @@ func ModelsURL(base string) (string, error) { if err != nil { return "", err } - return normalized + "/v1/models", nil + return urljoin.APIPath(normalized, "/v1/models"), nil } func NewSecureHTTPClient(endpoint ActiveEndpoint) (*http.Client, error) { diff --git a/backend/internal/securityaudit/prompt_outbound_security_test.go b/backend/internal/securityaudit/prompt_outbound_security_test.go index 327504d1..9f918d0a 100644 --- a/backend/internal/securityaudit/prompt_outbound_security_test.go +++ b/backend/internal/securityaudit/prompt_outbound_security_test.go @@ -37,6 +37,60 @@ func TestNormalizeBaseURLAllowsAdministratorConfiguredDestinations(t *testing.T) require.Equal(t, "https://guard.example.com/v1/chat/completions", url) } +// TestPromptAuditURLsAreIdempotentForVersionedBases covers the reported 404: +// a guard base that keeps the API version inside a path prefix must not gain a +// second version segment when the endpoint is appended. +func TestPromptAuditURLsAreIdempotentForVersionedBases(t *testing.T) { + t.Parallel() + + tests := []struct { + base string + wantBase string + }{ + {base: "https://guard.example.com", wantBase: "https://guard.example.com/v1"}, + {base: "https://guard.example.com/", wantBase: "https://guard.example.com/v1"}, + {base: "https://guard.example.com/v1", wantBase: "https://guard.example.com/v1"}, + {base: "https://guard.example.com/v1/", wantBase: "https://guard.example.com/v1"}, + {base: "https://guard.example.com/api/openai/v1", wantBase: "https://guard.example.com/api/openai/v1"}, + {base: "https://guard.example.com/prefix", wantBase: "https://guard.example.com/prefix/v1"}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.base, func(t *testing.T) { + t.Parallel() + + modelsURL, err := ModelsURL(tt.base) + require.NoError(t, err) + require.Equal(t, tt.wantBase+"/models", modelsURL) + + chatURL, err := ChatCompletionsURL(tt.base) + require.NoError(t, err) + require.Equal(t, tt.wantBase+"/chat/completions", chatURL) + }) + } +} + +// TestPromptAuditProbeUsesSingleVersionSegment is the end-to-end regression for +// the probe that pulls the model list before falling back to a chat call. +func TestPromptAuditProbeUsesSingleVersionSegment(t *testing.T) { + t.Parallel() + + var modelPathHits atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/claude/v1/models" { + modelPathHits.Add(1) + w.WriteHeader(http.StatusNotFound) + return + } + _, _ = w.Write([]byte(`{"data":[{"id":"` + DefaultGuardModel + `"}]}`)) + })) + defer server.Close() + + result := newProbeTestService().Probe(context.Background(), ProbeRequest{Endpoint: probeEndpoint(server.URL+"/claude/v1", "temporary-token")}) + require.True(t, result.OK) + require.Zero(t, modelPathHits.Load()) +} + func TestHTTPClientUsesDirectStandardDialer(t *testing.T) { client, err := NewSecureHTTPClient(ActiveEndpoint{BaseURL: "https://guard.example.com", TimeoutMS: 1000}) require.NoError(t, err) diff --git a/backend/internal/service/anthropic_converted_usage_fidelity_test.go b/backend/internal/service/anthropic_converted_usage_fidelity_test.go index 1d7602c5..a8d4cd1a 100644 --- a/backend/internal/service/anthropic_converted_usage_fidelity_test.go +++ b/backend/internal/service/anthropic_converted_usage_fidelity_test.go @@ -28,6 +28,19 @@ func TestConvertedAnthropicUsageFidelity(t *testing.T) { name, start, delta string want ClaudeUsage }{ + {"provider explicit zero hit clears prior alias", `{"input_tokens":1200,"output_tokens":1,"prompt_tokens":1200,"prompt_cache_hit_tokens":800}`, `{"input_tokens":1200,"output_tokens":30,"prompt_tokens":1200,"prompt_cache_hit_tokens":0,"prompt_cache_miss_tokens":1200}`, ClaudeUsage{InputTokens: 1200, OutputTokens: 30}}, + {"provider prompt total includes read alias", `{"input_tokens":11,"output_tokens":1}`, `{"input_tokens":1200,"output_tokens":30,"prompt_tokens":1200,"prompt_tokens_details":{"cached_tokens":800}}`, ClaudeUsage{InputTokens: 400, OutputTokens: 30, CacheReadInputTokens: 800}}, + {"provider explicit cache miss outranks total", `{"input_tokens":11,"output_tokens":1}`, `{"input_tokens":1200,"output_tokens":30,"prompt_tokens":1200,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":200}`, ClaudeUsage{InputTokens: 200, OutputTokens: 30, CacheReadInputTokens: 800}}, + {"provider explicit zero cache miss", `{"input_tokens":11,"output_tokens":1}`, `{"input_tokens":1200,"output_tokens":30,"prompt_tokens":1200,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":0}`, ClaudeUsage{OutputTokens: 30, CacheReadInputTokens: 800}}, + {"provider total includes read and TTL write", `{"input_tokens":11,"output_tokens":1}`, `{"input_tokens":1200,"output_tokens":30,"prompt_tokens":1200,"cached_tokens":600,"cache_creation_input_tokens":300,"cache_creation":{"ephemeral_1h_input_tokens":300}}`, ClaudeUsage{InputTokens: 300, OutputTokens: 30, CacheReadInputTokens: 600, CacheCreationInputTokens: 300, CacheCreation1hTokens: 300}}, + {"cache hit alias retains nonzero input without prompt total", `{"input_tokens":11,"output_tokens":1}`, `{"input_tokens":2,"output_tokens":8,"prompt_cache_hit_tokens":600}`, ClaudeUsage{InputTokens: 2, OutputTokens: 8, CacheReadInputTokens: 600}}, + {"canonical read outranks alias", `{"input_tokens":11,"output_tokens":1}`, `{"input_tokens":0,"output_tokens":8,"cache_read_input_tokens":200,"cached_tokens":600}`, ClaudeUsage{OutputTokens: 8, CacheReadInputTokens: 200}}, + {"cached tokens alias without prompt total", `{"input_tokens":11,"output_tokens":1,"cache_read_input_tokens":100}`, `{"input_tokens":0,"output_tokens":8,"cached_tokens":600}`, ClaudeUsage{OutputTokens: 8, CacheReadInputTokens: 600}}, + {"prompt detail alias without prompt total", `{"input_tokens":11,"output_tokens":1,"cache_read_input_tokens":100}`, `{"input_tokens":0,"output_tokens":8,"prompt_tokens_details":{"cached_tokens":600}}`, ClaudeUsage{OutputTokens: 8, CacheReadInputTokens: 600}}, + {"cache hit alias without prompt total", `{"input_tokens":11,"output_tokens":1,"cache_read_input_tokens":100}`, `{"input_tokens":0,"output_tokens":8,"prompt_cache_hit_tokens":600}`, ClaudeUsage{OutputTokens: 8, CacheReadInputTokens: 600}}, + {"placeholder input zero", `{"input_tokens":47168,"output_tokens":1,"cache_creation_input_tokens":787,"cache_read_input_tokens":186900}`, `{"input_tokens":0,"output_tokens":2876}`, ClaudeUsage{InputTokens: 47168, OutputTokens: 2876, CacheCreationInputTokens: 787, CacheReadInputTokens: 186900}}, + {"placeholder with null cache", `{"input_tokens":11,"output_tokens":1,"cache_read_input_tokens":600}`, `{"input_tokens":0,"output_tokens":8,"cache_read_input_tokens":null,"cache_creation":{"ephemeral_5m_input_tokens":null}}`, ClaudeUsage{InputTokens: 11, OutputTokens: 8, CacheReadInputTokens: 600}}, + {"input zero with explicit zero TTL", `{"input_tokens":11,"output_tokens":1,"cache_creation_input_tokens":600,"cache_creation":{"ephemeral_1h_input_tokens":600}}`, `{"input_tokens":0,"output_tokens":8,"cache_creation":{"ephemeral_1h_input_tokens":0}}`, ClaudeUsage{OutputTokens: 8}}, {"upstream one hour cache write", `{"input_tokens":11,"output_tokens":4,"cache_creation_input_tokens":600,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":600}}`, `{"output_tokens":8}`, ClaudeUsage{InputTokens: 11, OutputTokens: 8, CacheCreationInputTokens: 600, CacheCreation1hTokens: 600}}, {"final read clears provisional write", `{"input_tokens":11,"output_tokens":4,"cache_creation_input_tokens":600,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":600}}`, `{"input_tokens":0,"output_tokens":8,"cache_creation_input_tokens":0,"cache_read_input_tokens":600}`, ClaudeUsage{OutputTokens: 8, CacheReadInputTokens: 600}}, {"fully cached zero output", `{"input_tokens":11,"output_tokens":4,"cache_creation_input_tokens":600}`, `{"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":600}`, ClaudeUsage{CacheReadInputTokens: 600}}, diff --git a/backend/internal/service/anthropic_delta_placeholder_zero_test.go b/backend/internal/service/anthropic_delta_placeholder_zero_test.go new file mode 100644 index 00000000..f52305f0 --- /dev/null +++ b/backend/internal/service/anthropic_delta_placeholder_zero_test.go @@ -0,0 +1,96 @@ +//go:build unit + +package service + +import ( + "encoding/json" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/stretchr/testify/require" +) + +// issue #320: a relay re-serialized message_delta as +// {"input_tokens":0,"output_tokens":N}, i.e. a placeholder zero without any +// cache counters. The real input had already been reported by message_start +// together with the cache split, so the bare zero must not erase it: doing so +// drops the whole uncached-input bucket from usage logs and billing. +func TestAnthropicMessageDeltaPlaceholderZeroInputKeepsMessageStartInput(t *testing.T) { + start := `{"type":"message_start","message":{"usage":{"input_tokens":47168,"output_tokens":1,"cache_creation_input_tokens":787,"cache_read_input_tokens":186900}}}` + delta := `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":0,"output_tokens":2876}}` + + t.Run("passthrough", func(t *testing.T) { + usage := &ClaudeUsage{} + parseSSEUsagePassthrough(start, usage) + parseSSEUsagePassthrough(delta, usage) + + require.Equal(t, 47168, usage.InputTokens, "a placeholder zero must not replace the reported input") + require.Equal(t, 2876, usage.OutputTokens) + require.Equal(t, 787, usage.CacheCreationInputTokens) + require.Equal(t, 186900, usage.CacheReadInputTokens) + }) + + t.Run("general", func(t *testing.T) { + svc := newMinimalGatewayService() + usage := &ClaudeUsage{} + svc.parseSSEUsage(start, usage) + svc.parseSSEUsage(delta, usage) + + require.Equal(t, 47168, usage.InputTokens, "a placeholder zero must not replace the reported input") + require.Equal(t, 2876, usage.OutputTokens) + require.Equal(t, 787, usage.CacheCreationInputTokens) + require.Equal(t, 186900, usage.CacheReadInputTokens) + }) +} + +// Upstream issue #8407: when message_delta carries the final usage split, an +// explicit input_tokens=0 next to positive cache tokens is authoritative and +// must be kept, otherwise input and cache reads are billed twice. +func TestAnthropicMessageDeltaExplicitZeroInputWithCacheSplitIsAuthoritative(t *testing.T) { + start := `{"type":"message_start","message":{"usage":{"input_tokens":8535,"output_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}` + delta := `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":0,"output_tokens":33,"cache_creation_input_tokens":0,"cache_read_input_tokens":4004}}` + + t.Run("passthrough", func(t *testing.T) { + usage := &ClaudeUsage{} + parseSSEUsagePassthrough(start, usage) + parseSSEUsagePassthrough(delta, usage) + + require.Zero(t, usage.InputTokens) + require.Equal(t, 33, usage.OutputTokens) + require.Equal(t, 4004, usage.CacheReadInputTokens) + }) + + t.Run("general", func(t *testing.T) { + svc := newMinimalGatewayService() + usage := &ClaudeUsage{} + svc.parseSSEUsage(start, usage) + svc.parseSSEUsage(delta, usage) + + require.Zero(t, usage.InputTokens) + require.Equal(t, 33, usage.OutputTokens) + require.Equal(t, 4004, usage.CacheReadInputTokens) + }) +} + +// The converted chat/responses paths merge through mergeAnthropicUsage; they +// must apply the same rule so a stream bills identically whichever endpoint a +// client used. +func TestMergeAnthropicUsagePlaceholderZeroInputKeepsEarlierInput(t *testing.T) { + var placeholder apicompat.AnthropicUsage + require.NoError(t, json.Unmarshal([]byte(`{"input_tokens":0,"output_tokens":2876}`), &placeholder)) + + usage := ClaudeUsage{InputTokens: 47168, OutputTokens: 1, CacheCreationInputTokens: 787, CacheReadInputTokens: 186900} + mergeAnthropicUsage(&usage, placeholder) + + require.Equal(t, 47168, usage.InputTokens) + require.Equal(t, 2876, usage.OutputTokens) + require.Equal(t, 787, usage.CacheCreationInputTokens) + require.Equal(t, 186900, usage.CacheReadInputTokens) + + var cumulative apicompat.AnthropicUsage + require.NoError(t, json.Unmarshal([]byte(`{"input_tokens":0,"output_tokens":33,"cache_creation_input_tokens":0,"cache_read_input_tokens":4004}`), &cumulative)) + mergeAnthropicUsage(&usage, cumulative) + + require.Zero(t, usage.InputTokens, "a delta that restates the cache split stays authoritative") + require.Equal(t, 4004, usage.CacheReadInputTokens) +} diff --git a/backend/internal/service/anthropic_messages_url.go b/backend/internal/service/anthropic_messages_url.go index 47dec3e7..8b43308f 100644 --- a/backend/internal/service/anthropic_messages_url.go +++ b/backend/internal/service/anthropic_messages_url.go @@ -3,9 +3,11 @@ package service import ( "net/url" "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/urljoin" ) -// buildAnthropicMessagesURL appends the Messages endpoint to an already validated +// buildAnthropicMessagesURL joins the Messages endpoint to an already validated // base URL. Cloudflare's account REST endpoint rejects the beta query parameter; // all other upstreams, including Cloudflare's native gateway, keep it. func buildAnthropicMessagesURL(baseURL string) string { @@ -13,13 +15,14 @@ func buildAnthropicMessagesURL(baseURL string) string { if err != nil { return strings.TrimRight(baseURL, "/") + "/v1/messages?beta=true" } - parsed.Path = strings.TrimRight(parsed.Path, "/") + "/v1/messages" - if parsed.RawPath != "" { - parsed.RawPath = strings.TrimRight(parsed.RawPath, "/") + "/v1/messages" + original := parsed + parsed, err = url.Parse(urljoin.APIPath(baseURL, "/v1/messages")) + if err != nil { + return strings.TrimRight(baseURL, "/") + "/v1/messages?beta=true" } omitBeta := false if strings.EqualFold(parsed.Scheme, "https") && parsed.User == nil && - parsed.Fragment == "" && parsed.RawPath == "" { + original.Fragment == "" && original.RawPath == "" { host := strings.ToLower(parsed.Host) parts := strings.Split(parsed.Path, "/") if (host == "api.cloudflare.com" || host == "api.cloudflare.com:443") && @@ -39,3 +42,7 @@ func buildAnthropicMessagesURL(baseURL string) string { } return parsed.String() } + +func buildAnthropicCountTokensURL(baseURL string) string { + return urljoin.WithQueryParam(urljoin.APIPath(baseURL, "/v1/messages/count_tokens"), "beta", "true") +} diff --git a/backend/internal/service/anthropic_messages_url_test.go b/backend/internal/service/anthropic_messages_url_test.go index 59ac9c9b..c306f489 100644 --- a/backend/internal/service/anthropic_messages_url_test.go +++ b/backend/internal/service/anthropic_messages_url_test.go @@ -6,6 +6,7 @@ import ( "context" "io" "net/http" + "net/url" "strings" "testing" @@ -17,6 +18,65 @@ import ( const cloudflareMessagesTestBaseURL = "https://api.cloudflare.com/client/v4/accounts/0123456789abcdef0123456789abcdef/ai" +func TestAnthropicVersionedURL_RelayAndAntigravity(t *testing.T) { + proxyID := int64(1) + account := &Account{ID: 1, ProxyID: &proxyID, Proxy: &Proxy{Protocol: "http", Host: "proxy.example", Port: 8080}} + svc := &GatewayService{} + relayURL, err := url.Parse(svc.buildCustomRelayURL("https://relay.example/prefix/v1?tenant=a", "/v1/messages", account)) + require.NoError(t, err) + require.Equal(t, "/prefix/v1/messages", relayURL.Path) + require.Equal(t, "a", relayURL.Query().Get("tenant")) + require.Equal(t, "true", relayURL.Query().Get("beta")) + require.Equal(t, "http://proxy.example:8080", relayURL.Query().Get("proxy")) + + upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{"usage":{"input_tokens":2,"output_tokens":3}}`))}} + antigravity := &AntigravityGatewayService{httpUpstream: upstream, settingService: &SettingService{cfg: &config.Config{}}} + account.Credentials = map[string]any{"base_url": "https://relay.example/prefix/v1?tenant=a", "api_key": "synthetic"} + c, _ := newTestContext() + result, err := antigravity.ForwardUpstream(context.Background(), c, account, []byte(`{"model":"claude-test","messages":[{"role":"user","content":"hi"}]}`)) + require.NoError(t, err) + require.Equal(t, "https://relay.example/prefix/v1/messages?tenant=a", upstream.lastReq.URL.String()) + require.Equal(t, 2, result.Usage.InputTokens) +} + +func TestAnthropicVersionedURL_Requests(t *testing.T) { + for _, tc := range []struct { + base, messages, count string + }{ + {"https://relay.example/claude/v1/", "https://relay.example/claude/v1/messages?beta=true", "https://relay.example/claude/v1/messages/count_tokens?beta=true"}, + {"https://relay.example/claude/v1?tenant=a", "https://relay.example/claude/v1/messages?tenant=a&beta=true", "https://relay.example/claude/v1/messages/count_tokens?tenant=a&beta=true"}, + {"https://relay.example/claude/v1/messages", "https://relay.example/claude/v1/messages?beta=true", "https://relay.example/claude/v1/messages/count_tokens?beta=true"}, + {"https://relay.example/tenant%2Fv1", "https://relay.example/tenant%2Fv1/v1/messages?beta=true", "https://relay.example/tenant%2Fv1/v1/messages/count_tokens?beta=true"}, + {cloudflareMessagesTestBaseURL + "/v1", cloudflareMessagesTestBaseURL + "/v1/messages", cloudflareMessagesTestBaseURL + "/v1/messages/count_tokens?beta=true"}, + {cloudflareMessagesTestBaseURL + "/v1/messages", cloudflareMessagesTestBaseURL + "/v1/messages", cloudflareMessagesTestBaseURL + "/v1/messages/count_tokens?beta=true"}, + } { + t.Run(tc.base, func(t *testing.T) { + account := newAnthropicAPIKeyAccountForTest() + account.Credentials["base_url"] = tc.base + body := []byte(`{"model":"claude-haiku-4-5","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`) + svc := &GatewayService{cfg: &config.Config{}} + c := gatewayAnthropicBaseURLContext(t) + req, _, err := svc.buildUpstreamRequest(context.Background(), c, account, body, "upstream-anthropic-key", "apikey", "claude-haiku-4-5", false, false) + require.NoError(t, err) + require.Equal(t, tc.messages, req.URL.String()) + req, _, err = svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(context.Background(), c, account, body, "upstream-anthropic-key") + require.NoError(t, err) + require.Equal(t, tc.messages, req.URL.String()) + req, _, err = svc.buildCountTokensRequest(context.Background(), c, account, body, "upstream-anthropic-key", "apikey", "claude-haiku-4-5", false) + require.NoError(t, err) + require.Equal(t, tc.count, req.URL.String()) + req, err = svc.buildCountTokensRequestAnthropicAPIKeyPassthrough(context.Background(), c, account, body, "upstream-anthropic-key") + require.NoError(t, err) + require.Equal(t, tc.count, req.URL.String()) + upstream := &anthropicHTTPUpstreamRecorder{resp: adaptiveCNAnthropicTestResponse()} + testSvc := &AccountTestService{validateTestTarget: allowFakeAccountTestTarget, cfg: &config.Config{}, httpUpstream: upstream} + c, _ = newTestContext() + require.NoError(t, testSvc.testClaudeAccountConnection(c, account, "claude-haiku-4-5", "")) + require.Equal(t, tc.messages, upstream.lastReq.URL.String()) + }) + } +} + func TestAnthropicMessagesURL_EntryPoints(t *testing.T) { for _, tt := range []struct { name string diff --git a/backend/internal/service/antigravity_gateway_upstream.go b/backend/internal/service/antigravity_gateway_upstream.go index c5db51be..31d450d7 100644 --- a/backend/internal/service/antigravity_gateway_upstream.go +++ b/backend/internal/service/antigravity_gateway_upstream.go @@ -13,6 +13,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/urljoin" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" ) @@ -43,7 +44,7 @@ func (s *AntigravityGatewayService) ForwardUpstream(ctx context.Context, c *gin. originalModel := claudeReq.Model // 构建上游请求 URL - upstreamURL := baseURL + "/v1/messages" + upstreamURL := urljoin.APIPath(baseURL, "/v1/messages") // 能力维度 sanitize:Anthropic-compatible 上游透传路径也需要保证 body↔beta header // 对称。客户端 anthropic-beta header 不含 context-management-2025-06-27 但 body 带 diff --git a/backend/internal/service/channel_monitor_checker.go b/backend/internal/service/channel_monitor_checker.go index 110d674b..8a8c37c4 100644 --- a/backend/internal/service/channel_monitor_checker.go +++ b/backend/internal/service/channel_monitor_checker.go @@ -14,6 +14,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/pkg/servertiming" + "github.com/Wei-Shaw/sub2api/internal/pkg/urljoin" "github.com/tidwall/gjson" ) @@ -586,24 +587,9 @@ func isZhipuOfficialHost(u *url.URL) bool { } // joinURL 保留 base 的上游路径前缀,并避免重复追加已有的 API 路径前缀。 -// 使用 EscapedPath 匹配完整路径段,避免把 hostname 或编码斜杠当作路径。 +// 与 models/messages 共用拼接规则;保留完整路径段和编码斜杠边界。 func joinURL(base, path string) string { - base = strings.TrimRight(base, "/") - if !strings.HasPrefix(path, "/") { - path = "/" + path - } - if u, err := url.Parse(base); err == nil { - basePath := u.EscapedPath() - if basePath == path || (path != "/" && strings.HasSuffix(basePath, path)) { - return base - } - for end := strings.LastIndex(path, "/"); end > 0; end = strings.LastIndex(path[:end], "/") { - if strings.HasSuffix(basePath, path[:end]) { - return base + path[end:] - } - } - } - return base + path + return urljoin.APIPath(base, path) } // extractOrigin 从一个 endpoint URL 中提取 scheme://host[:port] 部分。 diff --git a/backend/internal/service/gateway_anthropic_passthrough.go b/backend/internal/service/gateway_anthropic_passthrough.go index da3723eb..c8ef7968 100644 --- a/backend/internal/service/gateway_anthropic_passthrough.go +++ b/backend/internal/service/gateway_anthropic_passthrough.go @@ -847,7 +847,9 @@ func parseSSEUsagePassthrough(data string, usage *ClaudeUsage) { switch parsed.Get("type").String() { case "message_start", "message_delta": if v := usageNode.Get("input_tokens"); v.Exists() && v.Type != gjson.Null { - usage.InputTokens = int(v.Int()) + if parsed.Get("type").String() != "message_delta" || v.Int() != 0 || hasAnthropicCacheTokenFields(usageNode) { + usage.InputTokens = int(v.Int()) + } } if v := usageNode.Get("output_tokens"); v.Exists() && v.Type != gjson.Null { usage.OutputTokens = int(v.Int()) @@ -870,9 +872,9 @@ func parseSSEUsagePassthrough(data string, usage *ClaudeUsage) { } } - if usage.CacheReadInputTokens == 0 { - if cached := usageNode.Get("cached_tokens").Int(); cached > 0 { - usage.CacheReadInputTokens = int(cached) + if usageNode.Get("cache_read_input_tokens").Int() == 0 { + if cached, ok := anthropicCacheReadAlias(usageNode); ok { + usage.CacheReadInputTokens = cached } } // A cumulative aggregate is authoritative, including zero. Split-only @@ -896,7 +898,35 @@ func parseSSEUsagePassthrough(data string, usage *ClaudeUsage) { // uncached input. prompt_tokens remains the total in both events. Normalize // to ClaudeUsage's mutually-exclusive buckets so downstream billing does not // subtract cache tokens from an already-uncached value. - normalizeAnthropicCompatiblePromptUsage(usageNode, usage) + // A cache alias alone is not a prompt total. Only recompute uncached input + // when the event actually reports a total or an explicit miss count. + if usageNode.Get("prompt_tokens").Int() > 0 || usageNode.Get("prompt_cache_miss_tokens").Exists() { + normalizeAnthropicCompatiblePromptUsage(usageNode, usage) + } +} + +// Cache counters establish a reported usage split, including explicit zeroes. +// Empty/null/invalid counters cannot turn an input placeholder into final usage. +func hasAnthropicCacheTokenFields(usageNode gjson.Result) bool { + for _, path := range []string{"cache_creation_input_tokens", "cache_read_input_tokens", "cache_creation.ephemeral_5m_input_tokens", "cache_creation.ephemeral_1h_input_tokens"} { + if _, ok := parseSSEUsageInt(usageNode.Get(path).Value()); ok { + return true + } + } + cached, _ := anthropicCacheReadAlias(usageNode) + return cached > 0 +} + +func anthropicCacheReadAlias(usageNode gjson.Result) (int, bool) { + for _, path := range []string{"cached_tokens", "prompt_tokens_details.cached_tokens"} { + if value, ok := parseSSEUsageInt(usageNode.Get(path).Value()); ok && value > 0 { + return value, true + } + } + if value, ok := parseSSEUsageInt(usageNode.Get("prompt_cache_hit_tokens").Value()); ok { + return max(value, 0), true + } + return 0, false } // normalizeAnthropicCompatiblePromptUsage converts provider-native OpenAI-style diff --git a/backend/internal/service/gateway_count_tokens.go b/backend/internal/service/gateway_count_tokens.go index 0e030082..7fc94c20 100644 --- a/backend/internal/service/gateway_count_tokens.go +++ b/backend/internal/service/gateway_count_tokens.go @@ -402,7 +402,7 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough( if err != nil { return nil, err } - targetURL = validatedURL + "/v1/messages/count_tokens?beta=true" + targetURL = buildAnthropicCountTokensURL(validatedURL) } body = sanitizeCountTokensRequestBody(body) @@ -478,7 +478,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con if err != nil { return nil, nil, err } - targetURL = validatedURL + "/v1/messages/count_tokens?beta=true" + targetURL = buildAnthropicCountTokensURL(validatedURL) } } else if account.IsCustomBaseURLEnabled() { customURL := account.GetCustomBaseURL() diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index 858b7d7c..dab81359 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -307,7 +307,7 @@ func mergeAnthropicUsage(dst *ClaudeUsage, src apicompat.AnthropicUsage) { if dst == nil { return } - if src.HasInputTokens() { + if src.HasInputTokens() && (src.InputTokens != 0 || src.HasCacheTokenFields()) { dst.InputTokens = src.InputTokens } if src.HasCacheReadInputTokens() { diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go index c7d3f812..3f784627 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -12,6 +12,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/domain" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/urljoin" "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" "github.com/google/uuid" "github.com/tidwall/gjson" @@ -975,7 +976,7 @@ func truncateForLog(b []byte, maxBytes int) string { // buildCustomRelayURL 构建自定义中继转发 URL // 在 path 后附加 beta=true 和可选的 proxy 查询参数 func (s *GatewayService) buildCustomRelayURL(baseURL, path string, account *Account) string { - u := strings.TrimRight(baseURL, "/") + path + "?beta=true" + u := urljoin.WithQueryParam(urljoin.APIPath(baseURL, path), "beta", "true") if account.ProxyID != nil && account.Proxy != nil { proxyURL := account.Proxy.URL() if proxyURL != "" { diff --git a/backend/internal/service/gateway_upstream_response.go b/backend/internal/service/gateway_upstream_response.go index 1baf75c7..2e82d46f 100644 --- a/backend/internal/service/gateway_upstream_response.go +++ b/backend/internal/service/gateway_upstream_response.go @@ -1328,6 +1328,13 @@ func (s *GatewayService) extractSSEUsagePatch(event map[string]any) *sseUsagePat patch.providerUsage = gjson.ParseBytes(raw) } } + // A delta's bare input zero may be a relay placeholder. Explicit cache + // counters, even zeroes, and positive aliases establish a final split. + if eventType == "message_delta" && patch.inputTokens == 0 && + !patch.hasCacheCreationInput && !patch.hasCacheReadInput && !patch.hasCacheCreation5m && !patch.hasCacheCreation1h && + !hasAnthropicCacheTokenFields(patch.providerUsage) { + patch.hasInputTokens = false + } return patch } @@ -1365,10 +1372,12 @@ func mergeSSEUsagePatch(usage *ClaudeUsage, patch *sseUsagePatch) { usage.CacheCreationInputTokens = usage.CacheCreation5mTokens + usage.CacheCreation1hTokens } if patch.providerUsage.Exists() { - if v := patch.providerUsage.Get("cached_tokens"); usage.CacheReadInputTokens == 0 && v.Int() > 0 { - usage.CacheReadInputTokens = int(v.Int()) + if cached, ok := anthropicCacheReadAlias(patch.providerUsage); (!patch.hasCacheReadInput || patch.cacheReadInputTokens == 0) && ok { + usage.CacheReadInputTokens = cached + } + if patch.providerUsage.Get("prompt_tokens").Int() > 0 || patch.providerUsage.Get("prompt_cache_miss_tokens").Exists() { + normalizeAnthropicCompatiblePromptUsage(patch.providerUsage, usage) } - normalizeAnthropicCompatiblePromptUsage(patch.providerUsage, usage) } } diff --git a/backend/internal/service/update_source_baselines.go b/backend/internal/service/update_source_baselines.go index a4ee9e21..b2620665 100644 --- a/backend/internal/service/update_source_baselines.go +++ b/backend/internal/service/update_source_baselines.go @@ -4,8 +4,8 @@ package service // this project's release version. A reviewed checkpoint does not imply every // feature was merged. Update only after the corresponding review/sync completes. const ( - forkImportedVersion = "2.3.58" - forkImportedCommit = "28ff1b3d41dddb2959892e66f4b410b064a266d4" + forkImportedVersion = "2.3.66" + forkImportedCommit = "00e1d64b7e5a12375507f8a31958297d7f6f99b8" originalReviewedCommit = "10ba8f9b4dc2e6023d4a9879ab77b21841babd10" ) diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 5d06fd22..ae217d8f 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -17,6 +17,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/Wei-Shaw/sub2api/internal/pkg/urljoin" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" ) @@ -1700,14 +1701,7 @@ func upstreamModelsProxyURL(account *Account) string { } func buildV1ModelsURL(base string) string { - normalized := strings.TrimRight(strings.TrimSpace(base), "/") - if strings.HasSuffix(normalized, "/v1/models") { - return normalized - } - if strings.HasSuffix(normalized, "/v1") { - return normalized + "/models" - } - return normalized + "/v1/models" + return urljoin.APIPath(base, "/v1/models") } func buildOpenAIModelsURL(base string) string { diff --git a/backend/internal/service/upstream_models_test.go b/backend/internal/service/upstream_models_test.go index abf63a64..06d5b4b2 100644 --- a/backend/internal/service/upstream_models_test.go +++ b/backend/internal/service/upstream_models_test.go @@ -70,6 +70,9 @@ func TestBuildV1ModelsURL(t *testing.T) { require.Equal(t, "https://api.anthropic.com/v1/models", buildV1ModelsURL("https://api.anthropic.com/v1")) require.Equal(t, "https://api.anthropic.com/v1/models", buildV1ModelsURL("https://api.anthropic.com/v1/models")) require.Equal(t, "https://gateway.example.com/antigravity/v1/models", buildV1ModelsURL("https://gateway.example.com/antigravity/")) + require.Equal(t, "https://relay.example/claude/v1/models?tenant=a", buildV1ModelsURL("https://relay.example/claude/v1/?tenant=a")) + require.Equal(t, "https://relay.example/claude/v1/models?tenant=a", buildV1ModelsURL("https://relay.example/claude/v1/models?tenant=a")) + require.Equal(t, "https://relay.example/tenant%2Fv1/v1/models", buildV1ModelsURL("https://relay.example/tenant%2Fv1")) } func TestBuildOpenAIModelsURL(t *testing.T) { diff --git a/docs/release-notes/v2.1.64.md b/docs/release-notes/v2.1.64.md new file mode 100644 index 00000000..0820ca2b --- /dev/null +++ b/docs/release-notes/v2.1.64.md @@ -0,0 +1,16 @@ +选择性同步二改正式 v2.3.66,修复窄屏管理页和流式用量一致性。 + +## 更新 + +- 修复 320px 英文管理端用量页被日期工具栏撑宽的问题。页面 Grid 使用可收缩列宽,极窄屏统计卡片将图标放到数值上方,避免金额小数拆行;保留日期、粒度、刷新、记录切换与缓存明细操作。 +- 上游地址已含 `/v1`、接口路径或查询参数时正确拼接 Messages、count_tokens、models、通道监控和安全审计接口;保留原生厂商版本路由、Cloudflare beta 例外和凭据目的地。 +- Anthropic 流式响应的裸输入占位零不再抹掉先前真实用量;明确的 cache/TTL 零值仍会清零。缓存别名、prompt 总量、显式 cache miss 和 cache hit 零值在原生及转换链路一致处理,避免客户端返回用量重复包含缓存或保留已清除的缓存桶。 +- 公共设置加载失败后,普通请求退避 30 秒;保留强制刷新、并发合并、成功缓存与服务端注入配置。 + +二改来源检查点推进至 v2.3.66,原版仍为 v0.2.21。公共设置 HTTP/CDN 缓存、固定 cache critical 阈值、受管代理/Mihomo、IP 自动风控、上游类型/余额探测及此前延期项保持原决定,退役支付不恢复。范围与取舍见[同步记录](../../maintenance/notes/upstream-sync-20261113-v2366.md)。 + +## 验证与范围 + +复用源码未变的同步测试结果,发布前重跑受影响的前端用量页与设置测试、后端用量一致性回归,并用实际浏览器检查中英文、浅深主题及窄屏/桌面视口和关键交互。最终 PR/主分支 CI、安全检查、Release/GHCR 制品身份、备份实际恢复与线上验收另存发布证据。 + +无依赖、数据库迁移或运行配置变化。保留本地真实终态计费、原生平台和冷灰蓝主题;三份原有未跟踪用量测试保持原样,不纳入提交。 diff --git a/frontend/src/components/admin/usage/UsageStatsCards.vue b/frontend/src/components/admin/usage/UsageStatsCards.vue index c4c33060..7e444530 100644 --- a/frontend/src/components/admin/usage/UsageStatsCards.vue +++ b/frontend/src/components/admin/usage/UsageStatsCards.vue @@ -147,4 +147,9 @@ const cacheDetailLabel = () => t('usage.cacheBreakdown') .usage-metric-value { font-size: 23px; line-height: 30px; } .usage-stat-icon :deep(svg) { width: 16px; height: 16px; } } +@media (max-width: 359px) { + .usage-metric { flex-direction: column; } + .usage-metric > div:last-child { align-self: stretch; } + .usage-metric-cost { font-size: 20px; } +} diff --git a/frontend/src/stores/__tests__/app.spec.ts b/frontend/src/stores/__tests__/app.spec.ts index ceffb015..98134be9 100644 --- a/frontend/src/stores/__tests__/app.spec.ts +++ b/frontend/src/stores/__tests__/app.spec.ts @@ -405,6 +405,84 @@ describe('useAppStore', () => { // --- 公开设置 --- describe('公开设置加载', () => { + it('失败后 30 秒内不重复请求,到期后可重试', async () => { + vi.setSystemTime(0) + vi.spyOn(console, 'error').mockImplementation(() => undefined) + vi.mocked(getPublicSettings).mockRejectedValueOnce(new Error('offline')) + const store = useAppStore() + await expect(store.fetchPublicSettings()).resolves.toBeNull() + await expect(store.fetchPublicSettings()).resolves.toBeNull() + vi.advanceTimersByTime(29_999) + await expect(store.fetchPublicSettings()).resolves.toBeNull() + expect(getPublicSettings).toHaveBeenCalledTimes(1) + expect(store.publicSettingsLoaded).toBe(false) + + const settings = createPublicSettings() + vi.mocked(getPublicSettings).mockResolvedValueOnce(settings) + vi.advanceTimersByTime(1) + await expect(store.fetchPublicSettings()).resolves.toEqual(settings) + expect(getPublicSettings).toHaveBeenCalledTimes(2) + vi.mocked(console.error).mockRestore() + }) + + it('冷却期间 force 立即重试,普通和 force 并发调用合并', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined) + vi.mocked(getPublicSettings).mockRejectedValueOnce(new Error('offline')) + const store = useAppStore() + await store.fetchPublicSettings() + const deferred = createDeferred() + vi.mocked(getPublicSettings).mockReturnValueOnce(deferred.promise) + const forced = store.fetchPublicSettings(true) + const ordinary = store.fetchPublicSettings() + const otherForced = store.fetchPublicSettings(true) + expect(getPublicSettings).toHaveBeenCalledTimes(2) + const settings = createPublicSettings({ site_name: 'Recovered' }) + deferred.resolve(settings) + await expect(Promise.all([forced, ordinary, otherForced])).resolves.toEqual([ + settings, settings, settings, + ]) + expect(store.siteName).toBe('Recovered') + vi.mocked(console.error).mockRestore() + }) + + it('同步异常同样触发冷却,清除缓存后可立即重新请求', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined) + vi.mocked(getPublicSettings).mockImplementationOnce(() => { throw new Error('sync failure') }) + const store = useAppStore() + await expect(store.fetchPublicSettings()).resolves.toBeNull() + await expect(store.fetchPublicSettings()).resolves.toBeNull() + expect(getPublicSettings).toHaveBeenCalledTimes(1) + store.clearPublicSettingsCache() + const settings = createPublicSettings() + vi.mocked(getPublicSettings).mockResolvedValueOnce(settings) + await expect(store.fetchPublicSettings()).resolves.toEqual(settings) + expect(getPublicSettings).toHaveBeenCalledTimes(2) + vi.mocked(console.error).mockRestore() + }) + + it('失败刷新保留成功缓存,冷却期间仍可应用服务端注入配置', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined) + const initial = createPublicSettings({ site_name: 'Cached' }) + vi.mocked(getPublicSettings).mockResolvedValueOnce(initial) + const store = useAppStore() + await store.fetchPublicSettings() + vi.mocked(getPublicSettings).mockRejectedValueOnce(new Error('offline')) + await expect(store.fetchPublicSettings(true)).resolves.toBeNull() + await expect(store.fetchPublicSettings()).resolves.toEqual(initial) + expect(getPublicSettings).toHaveBeenCalledTimes(2) + + store.clearPublicSettingsCache() + delete window.__APP_CONFIG__ + vi.mocked(getPublicSettings).mockRejectedValueOnce(new Error('still offline')) + await store.fetchPublicSettings() + const injected = createPublicSettings({ site_name: 'Injected' }) + window.__APP_CONFIG__ = injected + await expect(store.fetchPublicSettings()).resolves.toEqual(injected) + expect(store.siteName).toBe('Injected') + expect(getPublicSettings).toHaveBeenCalledTimes(3) + vi.mocked(console.error).mockRestore() + }) + it('并发调用复用并等待同一个请求,包括 force 调用', async () => { const deferred = createDeferred() vi.mocked(getPublicSettings).mockReturnValue(deferred.promise) diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index 795d7e26..c0b37569 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -16,6 +16,7 @@ import { import { getPublicSettings as fetchPublicSettingsAPI } from '@/api/auth' export const VERSION_CACHE_TTL_MS = 20 * 60 * 1000 +const PUBLIC_SETTINGS_RETRY_COOLDOWN_MS = 30_000 export const useAppStore = defineStore('app', () => { // ==================== State ==================== @@ -38,6 +39,7 @@ export const useAppStore = defineStore('app', () => { const docUrl = ref('') const cachedPublicSettings = ref(null) let publicSettingsRequest: Promise | null = null + let publicSettingsRetryAfter = 0 // Version cache state const versionLoaded = ref(false) @@ -340,6 +342,7 @@ export const useAppStore = defineStore('app', () => { apiBaseUrl.value = normalizedConfig.api_base_url || '' docUrl.value = normalizedConfig.doc_url || '' publicSettingsLoaded.value = true + publicSettingsRetryAfter = 0 } function normalizeSiteLogo(value: string | undefined): string { @@ -426,12 +429,18 @@ export const useAppStore = defineStore('app', () => { }) } + // Failed page mounts share a bounded cooldown; explicit refresh can retry. + if (!force && Date.now() < publicSettingsRetryAfter) { + return Promise.resolve(cachedPublicSettings.value) + } + publicSettingsLoading.value = true let apiRequest: Promise try { apiRequest = fetchPublicSettingsAPI() } catch (error) { console.error('Failed to fetch public settings:', error) + publicSettingsRetryAfter = Date.now() + PUBLIC_SETTINGS_RETRY_COOLDOWN_MS publicSettingsLoading.value = false return Promise.resolve(null) } @@ -443,6 +452,7 @@ export const useAppStore = defineStore('app', () => { }) .catch((error) => { console.error('Failed to fetch public settings:', error) + publicSettingsRetryAfter = Date.now() + PUBLIC_SETTINGS_RETRY_COOLDOWN_MS return null }) .finally(() => { @@ -462,6 +472,7 @@ export const useAppStore = defineStore('app', () => { function clearPublicSettingsCache(): void { publicSettingsLoaded.value = false cachedPublicSettings.value = null + publicSettingsRetryAfter = 0 } /** diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue index 22fecea4..19d16ff1 100644 --- a/frontend/src/views/admin/UsageView.vue +++ b/frontend/src/views/admin/UsageView.vue @@ -1027,6 +1027,7 @@ defineExpose({ requestedModelStats, refreshData })