mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-17 17:38:17 +08:00
此前 OpenAI service_tier 与 Anthropic speed 的计费档位只看请求侧,上游 因容量把 priority/fast 静默降为 default/standard 时仍按 2x 收费。现在: - upstreamResponseModelObserver 同时观察响应侧档位:OpenAI 只信终止事件 与无类型 body 里的 service_tier(response.created 为请求回显,忽略), Anthropic 读 usage.speed;查找仅在带 model 的帧上触发 - ResolveBillingServiceTier 只降不升:响应档位比请求便宜才采纳,更贵、 未知或缺失一律沿用请求侧,与图片尺寸归并、response_model 计费的 "不得更贵"不变式一致 - usage_logs.service_tier 记录实际计费档位,降级写 billing.service_tier_downgraded 审计日志;HTTP、WS 及 ws_v2 relay 全覆盖
273 lines
9.9 KiB
Go
273 lines
9.9 KiB
Go
package service
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestUpstreamResponseModelObserverTerminalWinsAndRecordsConflict(t *testing.T) {
|
|
observer := &upstreamResponseModelObserver{}
|
|
|
|
observer.ObserveOpenAI([]byte(`{"type":"response.created","response":{"model":"gpt-5.5"}}`), "response.created")
|
|
observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"model":"gpt-5.4"}}`), "response.completed")
|
|
|
|
require.Equal(t, "gpt-5.4", observer.Model())
|
|
require.True(t, observer.Conflict())
|
|
}
|
|
|
|
func TestUpstreamResponseModelObserverSupportsAnthropicAndGeminiShapes(t *testing.T) {
|
|
t.Run("anthropic", func(t *testing.T) {
|
|
observer := &upstreamResponseModelObserver{}
|
|
observer.ObserveAnthropic([]byte(`{"type":"message_start","message":{"model":"claude-sonnet-4-20250514"}}`))
|
|
require.Equal(t, "claude-sonnet-4-20250514", observer.Model())
|
|
})
|
|
|
|
t.Run("gemini outer and nested", func(t *testing.T) {
|
|
observer := &upstreamResponseModelObserver{}
|
|
observer.ObserveGemini([]byte(`{"response":{"modelVersion":"gemini-2.5-pro"}}`))
|
|
observer.ObserveGemini([]byte(`{"modelVersion":"gemini-2.5-pro-latest"}`))
|
|
require.Equal(t, "gemini-2.5-pro-latest", observer.Model())
|
|
require.True(t, observer.Conflict())
|
|
})
|
|
}
|
|
|
|
func TestUpstreamResponseModelObservationAttemptReset(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
c, _ := gin.CreateTestContext(nil)
|
|
|
|
first := beginUpstreamResponseModelObservation(c)
|
|
first.Observe("failed-attempt-model", false)
|
|
second := beginUpstreamResponseModelObservation(c)
|
|
second.Observe("successful-attempt-model", false)
|
|
|
|
require.Equal(t, "successful-attempt-model", observedUpstreamResponseModel(c))
|
|
require.False(t, observedUpstreamResponseModelConflict(c))
|
|
}
|
|
|
|
func TestUpstreamModelMismatchThreeStateAndCaseInsensitiveComparison(t *testing.T) {
|
|
require.Nil(t, upstreamModelMismatch("gpt-5.5", ""))
|
|
|
|
matched := upstreamModelMismatch("gpt-5.5", "GPT-5.5")
|
|
require.NotNil(t, matched)
|
|
require.False(t, *matched)
|
|
|
|
mismatched := upstreamModelMismatch("gpt-5.5", "gpt-5.4")
|
|
require.NotNil(t, mismatched)
|
|
require.True(t, *mismatched)
|
|
}
|
|
|
|
func TestUpstreamModelMismatchTreatsGrokBuildRuntimeIDsAsAliases(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
sentModel string
|
|
responseModel string
|
|
}{
|
|
{
|
|
name: "issue 5634 grok 4.6",
|
|
sentModel: "grok-4.6",
|
|
responseModel: "grok-4.6-build",
|
|
},
|
|
{
|
|
name: "grok 4.6 latest",
|
|
sentModel: "grok-4.6-latest",
|
|
responseModel: "grok-4.6-build",
|
|
},
|
|
{
|
|
name: "issue 5647 grok 4.5 latest",
|
|
sentModel: "grok-4.5-latest",
|
|
responseModel: "grok-4.5-build",
|
|
},
|
|
{
|
|
name: "grok 4.5 canonical",
|
|
sentModel: "grok-4.5",
|
|
responseModel: "GROK-4.5-BUILD",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
mismatch := upstreamModelMismatch(tt.sentModel, tt.responseModel)
|
|
|
|
require.NotNil(t, mismatch)
|
|
require.False(t, *mismatch)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUpstreamModelMismatchDoesNotCollapseDifferentModels(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
sentModel string
|
|
responseModel string
|
|
}{
|
|
{
|
|
name: "different grok versions",
|
|
sentModel: "grok-4.5",
|
|
responseModel: "grok-4.6-build",
|
|
},
|
|
{
|
|
name: "unrelated build suffix",
|
|
sentModel: "gpt-5.5",
|
|
responseModel: "gpt-5.5-build",
|
|
},
|
|
{
|
|
name: "different grok runtime",
|
|
sentModel: "grok-build-0.1",
|
|
responseModel: "grok-4.5-build",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
mismatch := upstreamModelMismatch(tt.sentModel, tt.responseModel)
|
|
|
|
require.NotNil(t, mismatch)
|
|
require.True(t, *mismatch)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestObserveOpenAISSEBodyIgnoresMalformedPayload(t *testing.T) {
|
|
observer := &upstreamResponseModelObserver{}
|
|
observeOpenAISSEBody(observer, "data: not-json\n\ndata: {\"type\":\"response.completed\",\"response\":{\"model\":\"gpt-5.4\"}}\n\n")
|
|
|
|
require.Equal(t, "gpt-5.4", observer.Model())
|
|
require.False(t, observer.Conflict())
|
|
}
|
|
|
|
func TestObserveAntigravityGeminiSSELineReadsWrapperModelWithoutUnwrap(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
payload string
|
|
want string
|
|
}{
|
|
{
|
|
name: "top-level sibling",
|
|
payload: `{"modelVersion":"gemini-3-pro","response":{"candidates":[]}}`,
|
|
want: "gemini-3-pro",
|
|
},
|
|
{
|
|
name: "single wrapper",
|
|
payload: `{"response":{"modelVersion":"gemini-3-pro","candidates":[]}}`,
|
|
want: "gemini-3-pro",
|
|
},
|
|
{
|
|
name: "nested response after one wrapper",
|
|
payload: `{"response":{"response":{"modelVersion":"gemini-3-pro","candidates":[]}}}`,
|
|
want: "gemini-3-pro",
|
|
},
|
|
{
|
|
name: "outer declaration takes precedence",
|
|
payload: `{"modelVersion":"gemini-outer","response":{"modelVersion":"gemini-inner","candidates":[]}}`,
|
|
want: "gemini-outer",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
c, _ := gin.CreateTestContext(nil)
|
|
beginUpstreamResponseModelObservation(c)
|
|
|
|
svc := &AntigravityGatewayService{}
|
|
svc.observeAntigravityGeminiSSELine(c, "data: "+tt.payload)
|
|
|
|
require.Equal(t, tt.want, observedUpstreamResponseModel(c))
|
|
require.False(t, observedUpstreamResponseModelConflict(c))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUpstreamResponseModelObserverRejectsMalformedJSONWithModelField(t *testing.T) {
|
|
observer := &upstreamResponseModelObserver{}
|
|
observer.ObserveOpenAI([]byte(`{"response":{"model":"gpt-5.4"}`), "response.completed")
|
|
|
|
require.Empty(t, observer.Model())
|
|
}
|
|
|
|
func TestUpstreamResponseModelObserverBoundsUntrustedModelName(t *testing.T) {
|
|
observer := &upstreamResponseModelObserver{}
|
|
observer.Observe(" "+strings.Repeat("模", upstreamResponseModelMaxLength+1)+" ", false)
|
|
|
|
require.Len(t, []rune(observer.Model()), upstreamResponseModelMaxLength)
|
|
}
|
|
|
|
func TestUpstreamResponseModelObserverServiceTierTerminalEventWins(t *testing.T) {
|
|
observer := &upstreamResponseModelObserver{}
|
|
|
|
// response.created echoes the requested tier and must not count as a declaration.
|
|
observer.ObserveOpenAI([]byte(`{"type":"response.created","response":{"id":"resp_1","model":"gpt-5.6-sol","service_tier":"priority"}}`), "response.created")
|
|
require.Equal(t, "", observer.ServiceTier())
|
|
|
|
observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5.6-sol","service_tier":"default","usage":{"input_tokens":1,"output_tokens":2}}}`), "response.completed")
|
|
require.Equal(t, "default", observer.ServiceTier())
|
|
require.False(t, observer.Conflict(), "tier echo must not be reported as a model conflict")
|
|
}
|
|
|
|
func TestUpstreamResponseModelObserverServiceTierUntypedPayloads(t *testing.T) {
|
|
t.Run("chat completions chunks agree", func(t *testing.T) {
|
|
observer := &upstreamResponseModelObserver{}
|
|
observer.ObserveOpenAI([]byte(`{"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-5.4","service_tier":"default","choices":[]}`), "")
|
|
observer.ObserveOpenAI([]byte(`{"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-5.4","service_tier":"default","choices":[]}`), "")
|
|
require.Equal(t, "default", observer.ServiceTier())
|
|
})
|
|
|
|
t.Run("disagreeing chunks are not trusted", func(t *testing.T) {
|
|
observer := &upstreamResponseModelObserver{}
|
|
observer.ObserveOpenAI([]byte(`{"model":"gpt-5.4","service_tier":"priority"}`), "")
|
|
observer.ObserveOpenAI([]byte(`{"model":"gpt-5.4","service_tier":"default"}`), "")
|
|
require.Equal(t, "", observer.ServiceTier())
|
|
})
|
|
|
|
t.Run("non-stream body normalises fast and drops auto", func(t *testing.T) {
|
|
observer := &upstreamResponseModelObserver{}
|
|
observer.ObserveOpenAI([]byte(`{"id":"resp_1","object":"response","model":"gpt-5.6-sol","service_tier":"fast"}`), "")
|
|
require.Equal(t, "priority", observer.ServiceTier())
|
|
|
|
auto := &upstreamResponseModelObserver{}
|
|
auto.ObserveOpenAI([]byte(`{"id":"resp_2","object":"response","model":"gpt-5.6-sol","service_tier":"auto"}`), "")
|
|
require.Equal(t, "", auto.ServiceTier())
|
|
})
|
|
|
|
t.Run("model-free deltas never declare a tier", func(t *testing.T) {
|
|
observer := &upstreamResponseModelObserver{}
|
|
observer.ObserveOpenAI([]byte(`{"type":"response.output_text.delta","delta":"hi","service_tier":"default"}`), "response.output_text.delta")
|
|
require.Equal(t, "", observer.ServiceTier())
|
|
})
|
|
}
|
|
|
|
func TestUpstreamResponseModelObserverServiceTierAnthropicSpeed(t *testing.T) {
|
|
t.Run("message_start reports standard", func(t *testing.T) {
|
|
observer := &upstreamResponseModelObserver{}
|
|
observer.ObserveAnthropic([]byte(`{"type":"message_start","message":{"id":"msg_1","model":"claude-opus-5","usage":{"input_tokens":8,"output_tokens":1,"speed":"standard"}}}`))
|
|
observer.ObserveAnthropic([]byte(`{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":12}}`))
|
|
require.Equal(t, "standard", observer.ServiceTier())
|
|
require.Equal(t, "claude-opus-5", observer.Model())
|
|
})
|
|
|
|
t.Run("non-stream body reports fast", func(t *testing.T) {
|
|
observer := &upstreamResponseModelObserver{}
|
|
observer.ObserveAnthropic([]byte(`{"id":"msg_1","type":"message","model":"claude-opus-5","usage":{"input_tokens":8,"output_tokens":12,"speed":"fast"}}`))
|
|
require.Equal(t, "fast", observer.ServiceTier())
|
|
})
|
|
|
|
t.Run("missing speed declares nothing", func(t *testing.T) {
|
|
observer := &upstreamResponseModelObserver{}
|
|
observer.ObserveAnthropic([]byte(`{"id":"msg_1","type":"message","model":"claude-opus-5","usage":{"input_tokens":8,"output_tokens":12}}`))
|
|
require.Equal(t, "", observer.ServiceTier())
|
|
})
|
|
}
|
|
|
|
func TestObservedUpstreamResponseServiceTierFromContext(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
c, _ := gin.CreateTestContext(nil)
|
|
require.Equal(t, "", observedUpstreamResponseServiceTier(c))
|
|
|
|
observer := beginUpstreamResponseModelObservation(c)
|
|
observer.ObserveOpenAI([]byte(`{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5.6-sol","service_tier":"default"}}`), "response.completed")
|
|
require.Equal(t, "default", observedUpstreamResponseServiceTier(c))
|
|
}
|