Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/backend-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions LOCAL_FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,适配声明倍率始终显示数字,以及管理端、用户端用量缓存明细浮层的窄屏越界。保留现有倍率时效/峰值计算、原生厂商路由、真实终态用量与冷灰蓝主题。
Expand Down
2 changes: 1 addition & 1 deletion backend/cmd/server/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.1.63
2.1.64
41 changes: 41 additions & 0 deletions backend/internal/pkg/apicompat/anthropic_usage_presence_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
}
34 changes: 32 additions & 2 deletions backend/internal/pkg/apicompat/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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.
Expand All @@ -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
}
}

// ---------------------------------------------------------------------------
Expand Down
106 changes: 106 additions & 0 deletions backend/internal/pkg/urljoin/urljoin.go
Original file line number Diff line number Diff line change
@@ -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
}
120 changes: 120 additions & 0 deletions backend/internal/pkg/urljoin/urljoin_test.go
Original file line number Diff line number Diff line change
@@ -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", ""))
}
5 changes: 3 additions & 2 deletions backend/internal/securityaudit/prompt_outbound_security.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -44,15 +45,15 @@ 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) {
normalized, err := NormalizeBaseURL(base)
if err != nil {
return "", err
}
return normalized + "/v1/models", nil
return urljoin.APIPath(normalized, "/v1/models"), nil
}

func NewSecureHTTPClient(endpoint ActiveEndpoint) (*http.Client, error) {
Expand Down
Loading
Loading