diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index c297713d49cb..4414d8452736 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -37,6 +37,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/tidwall/gjson" + "golang.org/x/sync/singleflight" ) const ( @@ -152,24 +153,28 @@ func normalizeGrokAccountTestMode(mode string) string { // AccountTestService handles account testing operations type AccountTestService struct { - quotaActivity *QuotaActivityService - accountRepo AccountRepository - geminiTokenProvider *GeminiTokenProvider - claudeTokenProvider *ClaudeTokenProvider - grokTokenProvider *GrokTokenProvider - antigravityGatewayService *AntigravityGatewayService - httpUpstream HTTPUpstream - cfg *config.Config - settingService *SettingService - tlsFPProfileService *TLSFingerprintProfileService - openAIGatewayService *OpenAIGatewayService - openaiGatewayService *OpenAIGatewayService - modelMetadataRegistryMu sync.Mutex - modelMetadataRegistry map[string]modelsDevProvider - modelMetadataRegistryAt time.Time - pluginManager *PluginManager - agentIdentityTaskMu sync.Mutex - agentIdentityWS agentIdentityWSConnectionInvalidator + quotaActivity *QuotaActivityService + accountRepo AccountRepository + geminiTokenProvider *GeminiTokenProvider + claudeTokenProvider *ClaudeTokenProvider + grokTokenProvider *GrokTokenProvider + antigravityGatewayService *AntigravityGatewayService + httpUpstream HTTPUpstream + cfg *config.Config + settingService *SettingService + tlsFPProfileService *TLSFingerprintProfileService + openAIGatewayService *OpenAIGatewayService + openaiGatewayService *OpenAIGatewayService + modelMetadataRegistryMu sync.Mutex + modelMetadataRegistry map[string]modelsDevProvider + modelMetadataRegistryAt time.Time + cindyMetadataRegistryMu sync.Mutex + cindyMetadataRegistry map[string]UpstreamModelMetadata + cindyMetadataRegistryAt time.Time + cindyMetadataRegistryFlight singleflight.Group + pluginManager *PluginManager + agentIdentityTaskMu sync.Mutex + agentIdentityWS agentIdentityWSConnectionInvalidator // grokWSDialer is optional; realtime account tests use the default OpenAI-style // WS dialer when nil (supports proxy + coder/websocket handshake). grokWSDialer openAIWSClientDialer diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index f82bcef8aead..c80094de8156 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -294,6 +294,7 @@ func NewBillingService(cfg *config.Config, pricingService *PricingService) *Bill // initFallbackPricing 初始化硬编码回退价格(当动态价格不可用时使用) // 价格单位:USD per token(与LiteLLM格式一致) func (s *BillingService) initFallbackPricing() { + addConversationStandardFallbackPricing(s.fallbackPrices) // Claude 4.5 Opus s.fallbackPrices["claude-opus-4.5"] = &ModelPricing{ InputPricePerToken: 5e-6, // $5 per MTok @@ -905,6 +906,12 @@ func (s *BillingService) initFallbackPricing() { // getFallbackPricing 根据模型系列获取回退价格 func (s *BillingService) getFallbackPricing(model string) *ModelPricing { modelLower := strings.ToLower(model) + switch modelLower { + case "hy3", "tencent/hy3", "hy4-preview", "tencent/hy4-preview", + "qwen3.8-27b", "qwen/qwen3.8-27b", "qwen3.8-flash", "qwen/qwen3.8-flash", + "qwen3.8-omni-flash", "qwen/qwen3.8-omni-flash", "muse-spark-1.3", "meta/muse-spark-1.3": + return s.fallbackPrices[modelLower] + } if modelLower == "jev-latest" { return s.fallbackPrices["jev-latest"] } @@ -1262,7 +1269,7 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing if err != nil { return nil, err } - if channelPricing == nil { + if channelPricing == nil || channelPricing.IsTokenAllowlistOnly() { return pricing, nil } // 防止修改 fallbackPrices 中的共享指针 diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index 43dfbc2407b5..2faf16f64a73 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -110,6 +110,16 @@ type ChannelModelPricing struct { UpdatedAt time.Time `json:"updated_at,omitempty"` } +// IsTokenAllowlistOnly distinguishes a model restriction from a price override. +// An explicitly configured zero remains an override. +func (p *ChannelModelPricing) IsTokenAllowlistOnly() bool { + return p != nil && (p.BillingMode == "" || p.BillingMode == BillingModeToken) && + p.InputPrice == nil && p.OutputPrice == nil && p.CacheWritePrice == nil && p.CacheWrite1hPrice == nil && + p.CacheReadPrice == nil && p.FastMultiplier == nil && p.FlexMultiplier == nil && + len(p.ReasoningEffortMultipliers) == 0 && p.ImageInputPrice == nil && p.ImageOutputPrice == nil && + p.PerRequestPrice == nil && len(p.Intervals) == 0 && p.TimePricing == nil +} + // ChannelTimePricing 渠道模型定价的分时倍率配置。 type ChannelTimePricing struct { Timezone string `json:"timezone"` diff --git a/backend/internal/service/conversation_standard_pricing.go b/backend/internal/service/conversation_standard_pricing.go new file mode 100644 index 000000000000..db1cf35d9a66 --- /dev/null +++ b/backend/internal/service/conversation_standard_pricing.go @@ -0,0 +1,31 @@ +package service + +// Exact international USD API prices verified 2026-10-04. These enter the +// ordinary fallback table; a dynamic catalog price remains authoritative. +// Tencent: https://intl.cloud.tencent.com/zh/document/product/1300/78937 +// Qwen: https://www.alibabacloud.com/help/en/model-studio/qwen3-8-27b +// +// https://www.alibabacloud.com/help/en/model-studio/qwen3-8-flash +// https://www.alibabacloud.com/help/en/model-studio/model-pricing +// +// Meta: https://dev.meta.ai/docs/pricing-rate-limits +func addConversationStandardFallbackPricing(prices map[string]*ModelPricing) { + for _, row := range []struct { + name, provider string + input, output, cacheRead, cacheWrite float64 // USD / million tokens + }{ + {"hy3", "tencent", 0.132, 0.528, 0.033, 0}, + {"hy4-preview", "tencent", 0.834, 2.501, 0.042, 0}, + // The single cached-input field uses the implicit-cache rate. Explicit + // cache creation has its own published rate; no TTL ladder is assumed. + {"qwen3.8-27b", "qwen", 0.50, 3.00, 0.10, 0.625}, + {"qwen3.8-flash", "qwen", 0.15, 0.47, 0.016, 0.20}, + {"qwen3.8-omni-flash", "qwen", 0.15, 0.47, 0.016, 0}, + {"muse-spark-1.3", "meta", 1.25, 4.25, 0.15, 0}, + } { + price := &ModelPricing{InputPricePerToken: row.input / 1e6, OutputPricePerToken: row.output / 1e6, + CacheReadPricePerToken: row.cacheRead / 1e6, CacheCreationPricePerToken: row.cacheWrite / 1e6} + prices[row.name] = price + prices[row.provider+"/"+row.name] = price + } +} diff --git a/backend/internal/service/conversation_standard_pricing_test.go b/backend/internal/service/conversation_standard_pricing_test.go new file mode 100644 index 000000000000..e3924b741da8 --- /dev/null +++ b/backend/internal/service/conversation_standard_pricing_test.go @@ -0,0 +1,65 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestConversationStandardPricingExactFallbackAndDynamicPriority(t *testing.T) { + bs := &BillingService{fallbackPrices: make(map[string]*ModelPricing)} + bs.initFallbackPricing() + for _, row := range []struct { + model, namespace string + input, output, read, write float64 + }{ + {"hy3", "tencent", .132, .528, .033, 0}, {"hy4-preview", "tencent", .834, 2.501, .042, 0}, + {"qwen3.8-27b", "qwen", .5, 3, .1, .625}, {"qwen3.8-flash", "qwen", .15, .47, .016, .2}, + {"qwen3.8-omni-flash", "qwen", .15, .47, .016, 0}, {"muse-spark-1.3", "meta", 1.25, 4.25, .15, 0}, + } { + for _, model := range []string{row.model, row.namespace + "/" + row.model} { + price, err := bs.GetModelPricing(model) + require.NoError(t, err, model) + require.InDelta(t, row.input/1e6, price.InputPricePerToken, 1e-14) + require.InDelta(t, row.output/1e6, price.OutputPricePerToken, 1e-14) + require.InDelta(t, row.read/1e6, price.CacheReadPricePerToken, 1e-14) + require.InDelta(t, row.write/1e6, price.CacheCreationPricePerToken, 1e-14) + } + } + for _, unknown := range []string{"qwen3.8-unknown", "qwen3.8-flash-next", "hy4-preview-unknown", "muse-spark-1.3-contributor", "other/hy3"} { + _, err := bs.GetModelPricing(unknown) + require.ErrorIs(t, err, ErrModelPricingUnavailable, unknown) + } + bs.pricingService = newStubPricingServiceFromJSON(t, `{"hy3":{"input_cost_per_token":0.000001,"output_cost_per_token":0.000002,"litellm_provider":"tencent","mode":"chat"}}`) + price, err := bs.GetModelPricing("hy3") + require.NoError(t, err) + require.Equal(t, 1e-6, price.InputPricePerToken) +} + +func TestTokenAllowlistOnlyPreservesDefaultPricing(t *testing.T) { + bs := newTestBillingServiceForResolver() + base := bs.fallbackPrices["claude-sonnet-4"] + fast, flex := 2.0, .5 + base.FastMultiplier, base.FlexMultiplier = &fast, &flex + base.ReasoningEffortMultipliers = map[string]float64{"high": 1.5} + card := ChannelModelPricing{Platform: PlatformOpenAI, Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken} + require.True(t, card.IsTokenAllowlistOnly()) + cs := newChannelServiceWithPricings(12, []ChannelModelPricing{card}) + groupID := int64(12) + resolved := NewModelPricingResolver(cs, bs).Resolve(context.Background(), PricingInput{Model: "claude-sonnet-4", GroupID: &groupID}) + require.Equal(t, PricingSourceLiteLLM, resolved.Source) + require.Equal(t, base, resolved.BasePricing) + price, err := bs.GetModelPricingWithChannel("claude-sonnet-4", &card) + require.NoError(t, err) + require.Equal(t, base, price) + zero := 0.0 + card.InputPrice = &zero + require.False(t, card.IsTokenAllowlistOnly()) + price, err = bs.GetModelPricingWithChannel("claude-sonnet-4", &card) + require.NoError(t, err) + require.Zero(t, price.InputPricePerToken) + require.Equal(t, 3e-6, base.InputPricePerToken, "explicit overrides do not mutate shared defaults") +} diff --git a/backend/internal/service/model_pricing_resolver.go b/backend/internal/service/model_pricing_resolver.go index 46c84da230f1..87aa414be9c0 100644 --- a/backend/internal/service/model_pricing_resolver.go +++ b/backend/internal/service/model_pricing_resolver.go @@ -87,6 +87,9 @@ func (r *ModelPricingResolver) Resolve(ctx context.Context, input PricingInput) var chPricing *ChannelModelPricing if input.GroupID != nil && r.channelService != nil { chPricing = r.lookupChannelPricingNormalized(ctx, *input.GroupID, input.Model) + if chPricing.IsTokenAllowlistOnly() { + chPricing = nil + } if chPricing != nil { mode := chPricing.BillingMode if mode == "" { @@ -225,7 +228,7 @@ func (r *ModelPricingResolver) lookupChannelPricingNormalized(ctx context.Contex // applyChannelOverrides 应用渠道定价覆盖 func (r *ModelPricingResolver) applyChannelOverrides(ctx context.Context, groupID int64, model string, resolved *ResolvedPricing) { chPricing := r.lookupChannelPricingNormalized(ctx, groupID, model) - if chPricing == nil { + if chPricing == nil || chPricing.IsTokenAllowlistOnly() { return } @@ -246,6 +249,9 @@ func (r *ModelPricingResolver) applyChannelOverrides(ctx context.Context, groupI // applyTokenOverrides 应用 token 模式的渠道覆盖 func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricing, resolved *ResolvedPricing) { + if chPricing.IsTokenAllowlistOnly() { + return + } if resolved.BasePricing == nil { resolved.BasePricing = &ModelPricing{} } else { diff --git a/backend/internal/service/testdata/cindy_model_metadata_v4.json b/backend/internal/service/testdata/cindy_model_metadata_v4.json new file mode 100644 index 000000000000..f4cedce1e75a --- /dev/null +++ b/backend/internal/service/testdata/cindy_model_metadata_v4.json @@ -0,0 +1 @@ +{"schemaVersion":4,"models":[{"id":"anthropic/claude-sonnet-5-5","name":"Claude Sonnet 5.5","mode":"chat","contextWindow":1000000,"maxOutputTokens":128000,"efforts":["low","medium","high","xhigh","max"],"defaultEffort":"high","modalities":{"input":["text","image"],"output":["text"]},"perAgent":{"claude-code":{"wireProtocol":"anthropic-messages","contextWindow":1000000,"efforts":["low","medium","high","xhigh","max"],"supportsFastMode":false,"defaultEffort":"high"},"codex":{"wireProtocol":"openai-responses","contextWindow":1000000,"efforts":["low","medium","high","xhigh","max"],"supportsFastMode":false,"defaultEffort":"high"},"pi":{"wireProtocol":"openai-responses","contextWindow":1000000,"efforts":["low","medium","high","xhigh","max"],"supportsFastMode":false,"defaultEffort":"high"}}},{"id":"deepseek/deepseek-flash","name":"DeepSeek Flash","mode":"chat","contextWindow":1000000,"maxOutputTokens":384000,"efforts":["medium","high","max"],"defaultEffort":"high","modalities":{"input":["text","image"],"output":["text"]},"perAgent":{"claude-code":{"wireProtocol":"anthropic-messages","contextWindow":1000000,"efforts":["medium","high","max"],"supportsFastMode":false,"defaultEffort":"high"},"codex":{"wireProtocol":"openai-responses","contextWindow":1000000,"efforts":["medium","high","max"],"supportsFastMode":false,"defaultEffort":"high"},"pi":{"wireProtocol":"openai-responses","contextWindow":1000000,"efforts":["medium","high","max"],"supportsFastMode":false,"defaultEffort":"high"}}},{"id":"deepseek/deepseek-v4-flash","name":"DeepSeek V4 Flash","description":"DeepSeek V4 Flash efficiency-optimized MoE; supports low/high/max effort","mode":"chat","contextWindow":1000000,"maxOutputTokens":384000,"efforts":["medium","high","max"],"defaultEffort":"high","modalities":{"input":["text"],"output":["text"]},"perAgent":{"claude-code":{"wireProtocol":"anthropic-messages","contextWindow":1000000,"efforts":["medium","high","max"],"supportsFastMode":false,"defaultEffort":"high"},"codex":{"wireProtocol":"openai-responses","contextWindow":1000000,"efforts":["medium","high","max"],"supportsFastMode":false,"defaultEffort":"high"},"pi":{"wireProtocol":"openai-responses","contextWindow":1000000,"efforts":["medium","high","max"],"supportsFastMode":false,"defaultEffort":"high"}}},{"id":"deepseek/deepseek-v4-flash-vision-exp","name":"DeepSeek V4 Flash Vision Exp","mode":"chat","contextWindow":1000000,"maxOutputTokens":384000,"efforts":["medium","high","max"],"defaultEffort":"high","modalities":{"input":["text","image"],"output":["text"]},"perAgent":{"claude-code":{"wireProtocol":"anthropic-messages","contextWindow":1000000,"efforts":["medium","high","max"],"supportsFastMode":false,"defaultEffort":"high"},"codex":{"wireProtocol":"openai-responses","contextWindow":1000000,"efforts":["medium","high","max"],"supportsFastMode":false,"defaultEffort":"high"},"pi":{"wireProtocol":"openai-responses","contextWindow":1000000,"efforts":["medium","high","max"],"supportsFastMode":false,"defaultEffort":"high"}}},{"id":"deepseek/deepseek-v4-pro","name":"DeepSeek V4 Pro","description":"DeepSeek V4 Pro reasoning model","mode":"chat","contextWindow":1000000,"maxOutputTokens":384000,"efforts":["medium","high","max"],"defaultEffort":"high","modalities":{"input":["text"],"output":["text"]},"perAgent":{"claude-code":{"wireProtocol":"anthropic-messages","contextWindow":1000000,"efforts":["medium","high","max"],"supportsFastMode":false,"defaultEffort":"high"},"codex":{"wireProtocol":"openai-responses","contextWindow":1000000,"efforts":["medium","high","max"],"supportsFastMode":false,"defaultEffort":"high"},"pi":{"wireProtocol":"openai-responses","contextWindow":1000000,"efforts":["medium","high","max"],"supportsFastMode":false,"defaultEffort":"high"}}},{"id":"google/gemini-3.6-flash","name":"Gemini 3.6 Flash","description":"Google Gemini 3.6 Flash — frontier intelligence optimized for speed and cost","mode":"chat","contextWindow":1048576,"maxOutputTokens":65536,"efforts":["medium","high"],"defaultEffort":"medium","modalities":{"input":["text","image","audio","video"],"output":["text"]},"perAgent":{"claude-code":{"wireProtocol":"anthropic-messages","contextWindow":1048576,"efforts":["medium","high"],"supportsFastMode":false,"defaultEffort":"medium"},"codex":{"wireProtocol":"openai-responses","contextWindow":1048576,"efforts":["medium","high"],"supportsFastMode":false,"defaultEffort":"medium"},"pi":{"wireProtocol":"openai-responses","contextWindow":1048576,"efforts":["medium","high"],"supportsFastMode":false,"defaultEffort":"medium"}}},{"id":"google/gemini-3.8-flash","name":"Gemini 3.8 Flash","mode":"chat","contextWindow":1048576,"maxOutputTokens":65536,"efforts":["low","medium","high"],"defaultEffort":"medium","modalities":{"input":["text","image","audio","video"],"output":["text"]},"perAgent":{"claude-code":{"wireProtocol":"anthropic-messages","contextWindow":1048576,"efforts":["low","medium","high"],"supportsFastMode":false,"defaultEffort":"medium"},"codex":{"wireProtocol":"openai-responses","contextWindow":1048576,"efforts":["low","medium","high"],"supportsFastMode":false,"defaultEffort":"medium"},"pi":{"wireProtocol":"openai-responses","contextWindow":1048576,"efforts":["low","medium","high"],"supportsFastMode":false,"defaultEffort":"medium"}}},{"id":"meta/muse-spark-1.3","name":"Muse Spark 1.3","mode":"chat","contextWindow":1048576,"maxOutputTokens":131072,"efforts":["minimal","low","medium","high","xhigh"],"defaultEffort":"medium","modalities":{"input":["text","image","audio","video"],"output":["text"]},"perAgent":{"claude-code":{"wireProtocol":"anthropic-messages","contextWindow":1048576,"efforts":["minimal","low","medium","high","xhigh"],"supportsFastMode":false,"defaultEffort":"medium"},"codex":{"wireProtocol":"openai-responses","contextWindow":1048576,"efforts":["minimal","low","medium","high","xhigh"],"supportsFastMode":false,"defaultEffort":"medium"},"pi":{"wireProtocol":"openai-responses","contextWindow":1048576,"efforts":["minimal","low","medium","high","xhigh"],"supportsFastMode":false,"defaultEffort":"medium"}}},{"id":"moonshotai/kimi-k2.8-preview","name":"Kimi K2.8 Preview","mode":"chat","contextWindow":1048576,"efforts":["low","medium","high","max"],"defaultEffort":"max","modalities":{"input":["text","image","video"],"output":["text"]},"perAgent":{"claude-code":{"wireProtocol":"anthropic-messages","contextWindow":1048576,"efforts":["low","medium","high","max"],"supportsFastMode":false,"defaultEffort":"max"},"codex":{"wireProtocol":"openai-responses","contextWindow":1048576,"efforts":["low","medium","high","max"],"supportsFastMode":false,"defaultEffort":"max"},"pi":{"wireProtocol":"openai-responses","contextWindow":1048576,"efforts":["low","medium","high","max"],"supportsFastMode":false,"defaultEffort":"max"}}},{"id":"openai/gpt-5.6-luna","name":"GPT-5.6 Luna","mode":"chat","contextWindow":1050000,"maxOutputTokens":128000,"efforts":["low","medium","high","xhigh","max"],"defaultEffort":"medium","modalities":{"input":["text","image"],"output":["text"]},"perAgent":{"claude-code":{"wireProtocol":"anthropic-messages","contextWindow":1050000,"efforts":["low","medium","high","xhigh","max"],"supportsFastMode":true,"defaultEffort":"medium"},"codex":{"contextWindow":1050000,"wireProtocol":"openai-responses","efforts":["low","medium","high","xhigh","max"],"supportsFastMode":true,"defaultEffort":"medium"},"pi":{"wireProtocol":"openai-responses","contextWindow":1050000,"efforts":["low","medium","high","xhigh","max"],"supportsFastMode":true,"defaultEffort":"medium"}}},{"id":"openai/gpt-6-astra","name":"GPT-6 Astra","description":"OpenAI GPT-6 Astra for complex reasoning, coding, computer use, research, and document creation","mode":"chat","contextWindow":1050000,"maxOutputTokens":128000,"efforts":["low","medium","high","xhigh","max"],"defaultEffort":"medium","modalities":{"input":["text","image"],"output":["text"]},"perAgent":{"claude-code":{"wireProtocol":"anthropic-messages","contextWindow":1050000,"efforts":["low","medium","high","xhigh","max"],"supportsFastMode":true,"defaultEffort":"medium"},"codex":{"wireProtocol":"openai-responses","contextWindow":1050000,"efforts":["low","medium","high","xhigh","max"],"supportsFastMode":true,"defaultEffort":"medium"},"pi":{"wireProtocol":"openai-responses","contextWindow":1050000,"efforts":["low","medium","high","xhigh","max"],"supportsFastMode":true,"defaultEffort":"medium"}}},{"id":"openai/gpt-6-luna","name":"GPT-6 Luna","mode":"chat","contextWindow":1050000,"maxOutputTokens":128000,"efforts":["low","medium","high","xhigh","max"],"defaultEffort":"medium","modalities":{"input":["text","image"],"output":["text"]},"perAgent":{"claude-code":{"wireProtocol":"anthropic-messages","contextWindow":1050000,"efforts":["low","medium","high","xhigh","max"],"supportsFastMode":true,"defaultEffort":"medium"},"codex":{"wireProtocol":"openai-responses","contextWindow":1050000,"efforts":["low","medium","high","xhigh","max"],"supportsFastMode":true,"defaultEffort":"medium"},"pi":{"wireProtocol":"openai-responses","contextWindow":1050000,"efforts":["low","medium","high","xhigh","max"],"supportsFastMode":true,"defaultEffort":"medium"}}},{"id":"qwen/qwen3.8-27b","name":"Qwen3.8 27B","mode":"chat","contextWindow":991808,"maxOutputTokens":131072,"efforts":["low","medium","high","xhigh"],"defaultEffort":"xhigh","modalities":{"input":["text","image","video"],"output":["text"]},"perAgent":{"claude-code":{"wireProtocol":"anthropic-messages","contextWindow":991808,"efforts":["low","medium","high","xhigh"],"supportsFastMode":false,"defaultEffort":"xhigh"},"codex":{"wireProtocol":"openai-responses","contextWindow":991808,"efforts":["low","medium","high","xhigh"],"supportsFastMode":false,"defaultEffort":"xhigh"},"pi":{"wireProtocol":"openai-responses","contextWindow":991808,"efforts":["low","medium","high","xhigh"],"supportsFastMode":false,"defaultEffort":"xhigh"}}},{"id":"qwen/qwen3.8-flash","name":"Qwen3.8 Flash","description":"Alibaba Qwen 3.8 Flash multimodal model with provider-managed thinking","mode":"chat","contextWindow":991808,"maxOutputTokens":131072,"efforts":["low","medium","high","xhigh"],"defaultEffort":"xhigh","modalities":{"input":["text","image","video"],"output":["text"]},"perAgent":{"claude-code":{"wireProtocol":"anthropic-messages","contextWindow":991808,"efforts":["low","medium","high","xhigh"],"supportsFastMode":false,"defaultEffort":"xhigh"},"codex":{"wireProtocol":"openai-responses","contextWindow":991808,"efforts":["low","medium","high","xhigh"],"supportsFastMode":false,"defaultEffort":"xhigh"},"pi":{"wireProtocol":"openai-responses","contextWindow":991808,"efforts":["low","medium","high","xhigh"],"supportsFastMode":false,"defaultEffort":"xhigh"}}},{"id":"qwen/qwen3.8-omni-flash","name":"Qwen3.8 Omni Flash","mode":"chat","contextWindow":991808,"maxOutputTokens":131072,"efforts":["minimal","low","medium","high","xhigh","max"],"defaultEffort":"xhigh","modalities":{"input":["text","image","audio","video"],"output":["text"]},"perAgent":{"claude-code":{"wireProtocol":"anthropic-messages","contextWindow":991808,"efforts":["minimal","low","medium","high","xhigh","max"],"supportsFastMode":false,"defaultEffort":"xhigh"},"codex":{"wireProtocol":"openai-responses","contextWindow":991808,"efforts":["minimal","low","medium","high","xhigh","max"],"supportsFastMode":false,"defaultEffort":"xhigh"},"pi":{"wireProtocol":"openai-responses","contextWindow":991808,"efforts":["minimal","low","medium","high","xhigh","max"],"supportsFastMode":false,"defaultEffort":"xhigh"}}},{"id":"tencent/hy3","name":"Hy3","mode":"chat","contextWindow":262144,"maxOutputTokens":128000,"efforts":["low","medium","high"],"defaultEffort":"high","modalities":{"input":["text"],"output":["text"]},"perAgent":{"claude-code":{"wireProtocol":"anthropic-messages","contextWindow":262144,"efforts":["low","medium","high"],"supportsFastMode":false,"defaultEffort":"high"},"codex":{"wireProtocol":"openai-responses","contextWindow":262144,"efforts":["low","medium","high"],"supportsFastMode":false,"defaultEffort":"high"},"pi":{"wireProtocol":"openai-responses","contextWindow":262144,"efforts":["low","medium","high"],"supportsFastMode":false,"defaultEffort":"high"}}},{"id":"tencent/hy4-preview","name":"Hy4 preview","mode":"chat","contextWindow":960000,"maxOutputTokens":64000,"efforts":["low","medium","high"],"defaultEffort":"high","modalities":{"input":["text"],"output":["text"]},"perAgent":{"claude-code":{"wireProtocol":"anthropic-messages","contextWindow":960000,"efforts":["low","medium","high"],"supportsFastMode":false,"defaultEffort":"high"},"codex":{"wireProtocol":"openai-responses","contextWindow":960000,"efforts":["low","medium","high"],"supportsFastMode":false,"defaultEffort":"high"},"pi":{"wireProtocol":"openai-responses","contextWindow":960000,"efforts":["low","medium","high"],"supportsFastMode":false,"defaultEffort":"high"}}},{"id":"z-ai/glm-5.3-flash","name":"GLM 5.3 Flash","description":"Zhipu GLM-5.3-Flash native multimodal coding model; 1M context","mode":"chat","contextWindow":1000000,"maxOutputTokens":131072,"efforts":["low","medium","high","max"],"defaultEffort":"max","modalities":{"input":["text","image"],"output":["text"]},"perAgent":{"claude-code":{"wireProtocol":"anthropic-messages","contextWindow":1000000,"efforts":["low","medium","high","max"],"supportsFastMode":false,"defaultEffort":"max"},"codex":{"wireProtocol":"openai-responses","contextWindow":1000000,"efforts":["low","medium","high","max"],"supportsFastMode":false,"defaultEffort":"max"},"pi":{"wireProtocol":"openai-responses","contextWindow":1000000,"efforts":["low","medium","high","max"],"supportsFastMode":false,"defaultEffort":"max"}}},{"id":"z-ai/glm-5.3-flashx","name":"GLM 5.3 FlashX","mode":"chat","contextWindow":1000000,"maxOutputTokens":131072,"efforts":["low","medium","high","max"],"defaultEffort":"max","modalities":{"input":["text","image","video"],"output":["text"]},"perAgent":{"claude-code":{"wireProtocol":"anthropic-messages","contextWindow":1000000,"efforts":["low","medium","high","max"],"supportsFastMode":false,"defaultEffort":"max"},"codex":{"wireProtocol":"openai-responses","contextWindow":1000000,"efforts":["low","medium","high","max"],"supportsFastMode":false,"defaultEffort":"max"},"pi":{"wireProtocol":"openai-responses","contextWindow":1000000,"efforts":["low","medium","high","max"],"supportsFastMode":false,"defaultEffort":"max"}}}]} diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 552056fa8e4c..788b381aa7f2 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -32,7 +32,7 @@ const ( // capacity fields (context_window, max_context_window, max_input_tokens, // max_output_tokens) always come from one declaration, never completed field // by field; CapacitySource says whose: "upstream" (this account's upstream) or -// "registry" (models.dev reference), observed at ObservedAt. +// "registry" (provider registry reference), observed at ObservedAt. type UpstreamModelMetadata struct { ID string `json:"id"` DisplayName string `json:"display_name,omitempty"` @@ -277,11 +277,11 @@ func (s *AccountTestService) SyncUpstreamModelCatalog(ctx context.Context, accou // Dedicated image/video generators are not Codex agent catalog entries and often // omit context windows in public registries. Keep them out of completeness checks // so they do not mask successful agent-model capability sync. - capabilityIDs := capabilitySyncModelIDs(enrichIDs) + capabilityIDs := cindyCapabilitySyncModelIDs(account, capabilitySyncModelIDs(enrichIDs)) source := "upstream" if upstreamCatalogNeedsRegistry(capabilityIDs, catalog.Metadata) { - if registryMetadata, registryErr := s.fetchModelsDevMetadata(ctx, account, enrichIDs); registryErr == nil { + if registryMetadata, registrySource, registryErr := s.fetchUpstreamRegistryMetadata(ctx, account, capabilityIDs); registryErr == nil { for modelID, fallback := range registryMetadata { if upstreamModelMetadataHasCapacity(fallback) { fallback.ObservedAt = syncedAt @@ -290,7 +290,7 @@ func (s *AccountTestService) SyncUpstreamModelCatalog(ctx context.Context, accou merged, changed := mergeUpstreamModelMetadata(current, fallback) catalog.Metadata[modelID] = merged if changed { - source = "models.dev" + source = registrySource } } } else { diff --git a/backend/internal/service/upstream_models_cindy.go b/backend/internal/service/upstream_models_cindy.go new file mode 100644 index 000000000000..32c2fc1fea86 --- /dev/null +++ b/backend/internal/service/upstream_models_cindy.go @@ -0,0 +1,192 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "strings" + "time" +) + +const cindyModelMetadataRegistryURL = "https://model-access.cindy.app/api/model-access/models?schemaVersion=4" + +// Cindy remains an ordinary OpenAI API-key account. This public reference only +// enriches IDs from the authenticated list; it is never an access catalog. +func usesCindyModelMetadataRegistry(account *Account) bool { + if account == nil || !account.IsOpenAI() || account.Type != AccountTypeAPIKey { + return false + } + u, err := url.Parse(upstreamModelRegistryBaseURL(account)) + return err == nil && u.Scheme == "https" && strings.EqualFold(u.Hostname(), "api.laxarouter.ai") && + (u.Port() == "" || u.Port() == "443") && u.User == nil && u.RawQuery == "" && u.Fragment == "" && + (u.Path == "" || u.Path == "/" || u.Path == "/v1" || u.Path == "/v1/") +} + +func cindyCapabilitySyncModelIDs(account *Account, ids []string) []string { + if !usesCindyModelMetadataRegistry(account) { + return ids + } + filtered := make([]string, 0, len(ids)) + for _, id := range ids { + if id != "cindy/auto-review" && id != "cindy/web-search" { + filtered = append(filtered, id) + } + } + return filtered +} + +func (s *AccountTestService) fetchUpstreamRegistryMetadata(ctx context.Context, account *Account, ids []string) (map[string]UpstreamModelMetadata, string, error) { + if !usesCindyModelMetadataRegistry(account) { + metadata, err := s.fetchModelsDevMetadata(ctx, account, ids) + return metadata, "models.dev", err + } + registry, err := s.fetchCindyModelMetadataRegistry(ctx, account) + if err != nil { + return nil, "cindy-model-access", err + } + metadata := make(map[string]UpstreamModelMetadata) + for _, id := range ids { + if entry, ok := registry[id]; ok { + metadata[id] = entry + } + } + return metadata, "cindy-model-access", nil +} + +func (s *AccountTestService) fetchCindyModelMetadataRegistry(ctx context.Context, account *Account) (map[string]UpstreamModelMetadata, error) { + if s == nil || s.httpUpstream == nil { + return nil, fmt.Errorf("cindy model metadata registry is not configured") + } + result := s.cindyMetadataRegistryFlight.DoChan("global", func() (any, error) { + s.cindyMetadataRegistryMu.Lock() + cached, observedAt := s.cindyMetadataRegistry, s.cindyMetadataRegistryAt + s.cindyMetadataRegistryMu.Unlock() + if len(cached) > 0 && time.Since(observedAt) < modelsDevRegistryTTL { + return cached, nil + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, cindyModelMetadataRegistryURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + resp, err := s.doUpstreamModelsRequest(req, upstreamModelsProxyURL(account), account) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("cindy model metadata registry returned HTTP %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, upstreamModelsBodyLimit+1)) + if err != nil { + return nil, err + } + if int64(len(body)) > upstreamModelsBodyLimit { + return nil, fmt.Errorf("cindy model metadata registry exceeds %d bytes", upstreamModelsBodyLimit) + } + metadata, err := parseCindyModelMetadataRegistry(body) + if err != nil { + return nil, err + } + s.cindyMetadataRegistryMu.Lock() + s.cindyMetadataRegistry, s.cindyMetadataRegistryAt = metadata, time.Now() + s.cindyMetadataRegistryMu.Unlock() + return metadata, nil + }) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case value := <-result: + if value.Err != nil { + return nil, value.Err + } + metadata, ok := value.Val.(map[string]UpstreamModelMetadata) + if !ok { + return nil, fmt.Errorf("invalid cindy model metadata cache result") + } + return metadata, nil + } +} + +type cindyModelMetadataContract struct { + ContextWindow *int64 `json:"contextWindow"` + MaxOutputTokens *int64 `json:"maxOutputTokens"` + Efforts []string `json:"efforts"` + DefaultEffort string `json:"defaultEffort"` +} + +func parseCindyModelMetadataRegistry(body []byte) (map[string]UpstreamModelMetadata, error) { + var payload struct { + SchemaVersion int `json:"schemaVersion"` + Models []struct { + cindyModelMetadataContract + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Mode string `json:"mode"` + Modalities struct { + Input []string `json:"input"` + } `json:"modalities"` + PerAgent map[string]cindyModelMetadataContract `json:"perAgent"` + } `json:"models"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("invalid Cindy model metadata JSON: %w", err) + } + if payload.SchemaVersion != 4 || len(payload.Models) == 0 { + return nil, fmt.Errorf("invalid Cindy model metadata schema or empty catalog") + } + metadata := make(map[string]UpstreamModelMetadata) + seen := make(map[string]bool) + for _, model := range payload.Models { + id := strings.TrimSpace(model.ID) + if !validModelContextID(id) || seen[id] { + return nil, fmt.Errorf("invalid or duplicate Cindy model metadata ID") + } + seen[id] = true + if model.Mode != "chat" { + continue + } + contract := model.cindyModelMetadataContract + if codex, ok := model.PerAgent["codex"]; ok { + if codex.ContextWindow != nil { + contract.ContextWindow = codex.ContextWindow + } + if codex.MaxOutputTokens != nil { + contract.MaxOutputTokens = codex.MaxOutputTokens + } + if codex.Efforts != nil { + contract.Efforts = codex.Efforts + } + if codex.DefaultEffort != "" { + contract.DefaultEffort = codex.DefaultEffort + } + } + if contract.ContextWindow == nil || *contract.ContextWindow <= 0 || + (contract.MaxOutputTokens != nil && *contract.MaxOutputTokens <= 0) { + return nil, fmt.Errorf("invalid Cindy model metadata capacity") + } + levels := normalizeReasoningLevels(contract.Efforts) + reasoning := len(levels) > 0 + if len(levels) != len(contract.Efforts) || (contract.DefaultEffort != "" && !slices.Contains(levels, contract.DefaultEffort)) || + len(normalizeCodexInputModalities(model.Modalities.Input)) == 0 { + return nil, fmt.Errorf("invalid Cindy model metadata capabilities") + } + entry := UpstreamModelMetadata{ID: id, DisplayName: model.Name, Description: model.Description, + Reasoning: &reasoning, DefaultReasoningLevel: contract.DefaultEffort, SupportedReasoningLevels: levels, + InputModalities: append([]string(nil), model.Modalities.Input...), ContextWindow: *contract.ContextWindow, + CapacitySource: ModelContextSourceRegistry} + if contract.MaxOutputTokens != nil { + entry.MaxOutputTokens = *contract.MaxOutputTokens + } + metadata[id] = entry + } + if len(metadata) == 0 { + return nil, fmt.Errorf("cindy model metadata has no conversation models") + } + return metadata, nil +} diff --git a/backend/internal/service/upstream_models_cindy_test.go b/backend/internal/service/upstream_models_cindy_test.go new file mode 100644 index 000000000000..96a217c898be --- /dev/null +++ b/backend/internal/service/upstream_models_cindy_test.go @@ -0,0 +1,170 @@ +package service + +import ( + "context" + "encoding/json" + "io" + "net/http" + "os" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/stretchr/testify/require" +) + +var cindyFreeConversationIDs = []string{ + "deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-flash-vision-exp", "deepseek/deepseek-v4-pro", + "google/gemini-3.6-flash", "z-ai/glm-5.3-flash", "openai/gpt-5.6-luna", "tencent/hy3", + "qwen/qwen3.8-27b", "qwen/qwen3.8-flash", "deepseek/deepseek-flash", "qwen/qwen3.8-omni-flash", + "openai/gpt-6-luna", "meta/muse-spark-1.3", "moonshotai/kimi-k2.8-preview", "tencent/hy4-preview", + "google/gemini-3.8-flash", "z-ai/glm-5.3-flashx", "anthropic/claude-sonnet-5-5", +} + +func cindyMetadataFixture(t *testing.T) []byte { + t.Helper() + body, err := os.ReadFile("testdata/cindy_model_metadata_v4.json") + require.NoError(t, err) + return body +} + +func cindySyncFixture(t *testing.T, id int64) (*AccountTestService, *Account, *upstreamModelMetadataRepoStub, *httpUpstreamRecorder) { + t.Helper() + rows := make([]map[string]any, 0, 20) + for _, model := range append(append([]string{}, cindyFreeConversationIDs...), "cindy/auto-review", "cindy/web-search") { + row := map[string]any{"id": model} + if model == "deepseek/deepseek-flash" { + row["max_input_tokens"], row["max_output_tokens"] = 900000, 12345 + } + rows = append(rows, row) + } + body, err := json.Marshal(map[string]any{"data": rows}) + require.NoError(t, err) + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + {StatusCode: 200, Body: io.NopCloser(strings.NewReader(string(body)))}, + {StatusCode: 200, Body: io.NopCloser(strings.NewReader(string(cindyMetadataFixture(t))))}, + }} + repo := &upstreamModelMetadataRepoStub{} + account := &Account{ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://api.laxarouter.ai", "api_key": "private-account-key"}} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + return svc, account, repo, upstream +} + +func TestCindyModelCatalogAuthenticatedMembershipAndMetadata(t *testing.T) { + svc, account, repo, upstream := cindySyncFixture(t, 17) + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account) + require.NoError(t, err) + require.Len(t, catalog.Models, 20) + require.Len(t, catalog.Metadata, 18) + require.Empty(t, catalog.Warnings) + require.NotContains(t, catalog.Models, "openai/gpt-6-astra", "public metadata cannot grant access") + require.NotContains(t, catalog.Metadata, "cindy/web-search") + require.Len(t, upstream.requests, 2) + require.Equal(t, cindyModelMetadataRegistryURL, upstream.requests[1].URL.String()) + require.Empty(t, upstream.requests[1].Header.Get("Authorization")) + require.Empty(t, upstream.requests[1].Header.Get("x-api-key")) + for _, id := range cindyFreeConversationIDs { + require.True(t, upstreamModelMetadataIsComplete(catalog.Metadata[id]), id) + } + declared := catalog.Metadata["deepseek/deepseek-flash"] + require.Equal(t, int64(900000), declared.MaxInputTokens) + require.Equal(t, int64(12345), declared.MaxOutputTokens) + require.Zero(t, declared.ContextWindow, "capacity declarations are not mixed") + require.Equal(t, ModelContextSourceUpstream, declared.CapacitySource) + registry := catalog.Metadata["qwen/qwen3.8-omni-flash"] + require.Equal(t, ModelContextSourceRegistry, registry.CapacitySource) + require.Contains(t, registry.InputModalities, "audio") + require.Equal(t, "xhigh", registry.DefaultReasoningLevel) + require.Zero(t, catalog.Metadata["moonshotai/kimi-k2.8-preview"].MaxOutputTokens) + snapshot := account.GetUpstreamModelMetadataSnapshot() + require.Equal(t, UpstreamModelMetadataSourceIdentity(account), snapshot.SourceIdentity) + require.Equal(t, "cindy-model-access", snapshot.Source) + require.Contains(t, repo.updates, UpstreamModelMetadataExtraKey) +} + +func TestCindyModelCatalogPreviewDoesNotPersist(t *testing.T) { + svc, account, repo, _ := cindySyncFixture(t, 0) + _, err := svc.SyncUpstreamModelCatalog(context.Background(), account) + require.NoError(t, err) + require.Nil(t, repo.updates) +} + +func TestCindyModelCatalogFailedRegistryPreservesSnapshot(t *testing.T) { + svc, account, _, upstream := cindySyncFixture(t, 17) + _, err := svc.SyncUpstreamModelCatalog(context.Background(), account) + require.NoError(t, err) + before, ok := account.GetUpstreamModelMetadata("qwen/qwen3.8-omni-flash") + require.True(t, ok) + svc.cindyMetadataRegistryAt = time.Now().Add(-7 * time.Hour) + upstream.responses = []*http.Response{ + {StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"qwen/qwen3.8-omni-flash"}]}`))}, + {StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"schemaVersion":99,"models":[]}`))}, + } + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account) + require.NoError(t, err) + require.NotEmpty(t, catalog.Warnings) + after, ok := account.GetUpstreamModelMetadata("qwen/qwen3.8-omni-flash") + require.True(t, ok) + require.Equal(t, before, after) + require.Len(t, svc.cindyMetadataRegistry, 19, "invalid data cannot replace valid cache") +} + +func TestCindyModelMetadataContract(t *testing.T) { + body := `{"schemaVersion":4,"models":[{"id":"test/model","mode":"chat","contextWindow":1000,"maxOutputTokens":200,"efforts":["high"],"defaultEffort":"high","modalities":{"input":["text"]},"perAgent":{"codex":{"contextWindow":900,"efforts":["low","high"],"defaultEffort":"low"}}}]}` + metadata, err := parseCindyModelMetadataRegistry([]byte(body)) + require.NoError(t, err) + require.Equal(t, int64(900), metadata["test/model"].ContextWindow) + require.Equal(t, int64(200), metadata["test/model"].MaxOutputTokens) + require.Equal(t, "low", metadata["test/model"].DefaultReasoningLevel) + for _, invalid := range []string{ + strings.Replace(body, `"schemaVersion":4`, `"schemaVersion":5`, 1), + strings.Replace(body, `"contextWindow":900`, `"contextWindow":0`, 1), + strings.Replace(body, `"efforts":["low","high"]`, `"efforts":["invalid"]`, 1), + } { + _, err := parseCindyModelMetadataRegistry([]byte(invalid)) + require.Error(t, err) + } + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://api.laxarouter.ai.attacker.example"}} + require.False(t, usesCindyModelMetadataRegistry(account)) + require.Equal(t, []string{"cindy/web-search"}, cindyCapabilitySyncModelIDs(account, []string{"cindy/web-search"})) +} + +type cindyConcurrentMetadataUpstream struct { + HTTPUpstream + calls atomic.Int64 + body []byte + entered chan struct{} + release chan struct{} +} + +func (u *cindyConcurrentMetadataUpstream) DoWithTLS(_ *http.Request, _ string, _ int64, _ int, _ *tlsfingerprint.Profile) (*http.Response, error) { + u.calls.Add(1) + close(u.entered) + <-u.release + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(string(u.body)))}, nil +} + +func TestCindyModelMetadataCoalescesAndCaches(t *testing.T) { + u := &cindyConcurrentMetadataUpstream{body: cindyMetadataFixture(t), entered: make(chan struct{}), release: make(chan struct{})} + svc := &AccountTestService{httpUpstream: u} + account := &Account{ID: 1} + var wg sync.WaitGroup + for range 10 { + wg.Go(func() { + _, err := svc.fetchCindyModelMetadataRegistry(context.Background(), account) + if err != nil { + t.Error(err) + } + }) + } + <-u.entered + close(u.release) + wg.Wait() + _, err := svc.fetchCindyModelMetadataRegistry(context.Background(), account) + require.NoError(t, err) + require.Equal(t, int64(1), u.calls.Load()) +}