diff --git a/README.md b/README.md index 34d6dfeb4..39673abd1 100644 --- a/README.md +++ b/README.md @@ -148,18 +148,6 @@ Please read the following carefully before using this project: - -claudeapi -Thanks to Claude API for sponsoring this project! Claude API is an official-channel API provider focused on Claude models. Built on official Anthropic keys and the official AWS Bedrock channel, it delivers a stable integration experience for Claude Code and Agent applications, supports the full Claude model lineup, and retains official capabilities such as Tool Use and long context. The service involves no reverse engineering and no model degradation, making it a great fit for heavy Claude Code users, Agent engineers, and enterprise engineering teams. Register via the exclusive link and contact customer support to receive free trial credits; invoicing and team onboarding are also supported. - - - - -code0 -Thanks to code0.ai for sponsoring this project! code0.ai is an AI coding workbench for developers and engineering teams, aggregating mainstream agent coding capabilities such as Claude Code and Codex, and covering common development scenarios including code generation, project understanding, debugging and fixing, code review, and documentation generation. It suits independent developers, Agent engineers, open-source maintainers, and enterprise R&D teams, with invoicing and team onboarding supported. Register via the exclusive link and contact customer support to receive free trial credits and experience a more efficient AI coding workflow. - - - Nagora Nagora is a multi-model AI API gateway built for developers and teams. With a single account and API key, you can access more than 26 leading text and image models through one unified interface. It is compatible with OpenAI, Anthropic, and Gemini protocols and integrates seamlessly with development tools such as Claude Code, Codex, and Gemini CLI. The platform provides intelligent routing, automatic failover, transparent pricing, and consolidated billing, along with budget management, rate limiting, and concurrency controls. This makes AI usage more reliable and manageable across individual development, team collaboration, and production environments. No changes to your existing application are required. Simply replace the Base URL and API key to complete the integration in as little as one minute. @@ -581,12 +569,6 @@ default: rate_multiplier: 1.0 ``` -### Sora Status (Temporarily Unavailable) - -> ⚠️ Sora-related features are temporarily unavailable due to technical issues in upstream integration and media delivery. -> Please do not rely on Sora in production at this time. -> Existing `gateway.sora_*` configuration keys are reserved and may not take effect until these issues are resolved. - Additional security-related options are available in `config.yaml`: - `cors.allowed_origins` for CORS allowlist @@ -897,11 +879,11 @@ sub2api/ ## Star History - + - - - Star History Chart + + + Star History Chart diff --git a/README_CN.md b/README_CN.md index e1c992620..3e87ef39c 100644 --- a/README_CN.md +++ b/README_CN.md @@ -183,18 +183,6 @@ docker logs --since 30m sub2api 2>&1 | \ - -claudeapi -感谢 Claude API 对本项目的赞助! Claude API 是专注 Claude 模型的官方渠道 API 服务商,基于 Anthropic 官方 Key 与 AWS Bedrock 官方渠道,提供稳定的 Claude Code 与 Agent 应用接入体验,支持 Claude 全系列模型,保留 Tool Use、长上下文等官方能力。服务非逆向、非降智,适合 Claude Code 深度用户、Agent 工程师与企业技术团队使用。通过[专属链接]注册后联系客服,可领取免费测试额度,并支持开票和团队对接。 - - - - -code0 -感谢 code0.ai 对本项目的赞助! code0.ai 是面向开发者与技术团队的 AI 编程工作台,聚合 Claude Code、Codex 等主流 Agent 编程能力,支持代码生成、项目理解、调试修复、代码审查与文档生成等常见研发场景。适合独立开发者、Agent 工程师、开源项目维护者和企业研发团队使用,支持开票和团队对接。通过[专属链接]注册后联系客服,可领取免费测试额度,体验更高效的 AI 编程工作流。 - - - Nagora Nagora 是专为开发者和团队打造的多模型 AI API 网关。通过一个账户和一枚 API Key,即可统一调用 26+ 款主流文本与图像模型,兼容 OpenAI、Anthropic 与 Gemini 协议,并可无缝接入 Claude Code、Codex、Gemini CLI 等开发工具。平台提供智能路由、自动故障转移、透明计费与统一账单,同时支持预算、限速、并发控制,让个人开发、团队协作和生产环境中的 AI 调用更稳定、更可控。无需改造现有应用,只需替换 Base URL 与 API Key,最快 1 分钟即可完成接入。 @@ -623,33 +611,6 @@ default: rate_multiplier: 1.0 ``` -### Sora 功能状态(暂不可用) - -> ⚠️ 当前 Sora 相关功能因上游接入与媒体链路存在技术问题,暂时不可用。 -> 现阶段请勿在生产环境依赖 Sora 能力。 -> 文档中的 `gateway.sora_*` 配置仅作预留,待技术问题修复后再恢复可用。 - -### Sora 媒体签名 URL(功能恢复后可选) - -当配置 `gateway.sora_media_signing_key` 且 `gateway.sora_media_signed_url_ttl_seconds > 0` 时,网关会将 Sora 输出的媒体地址改写为临时签名 URL(`/sora/media-signed/...`)。这样无需 API Key 即可在浏览器中直接访问,且具备过期控制与防篡改能力(签名包含 path + query)。 - -```yaml -gateway: - # /sora/media 是否强制要求 API Key(默认 false) - sora_media_require_api_key: false - # 媒体临时签名密钥(为空则禁用签名) - sora_media_signing_key: "your-signing-key" - # 临时签名 URL 有效期(秒) - sora_media_signed_url_ttl_seconds: 900 -``` - -> 若未配置签名密钥,`/sora/media-signed` 将返回 503。 -> 如需更严格的访问控制,可将 `sora_media_require_api_key` 设为 true,仅允许携带 API Key 的 `/sora/media` 访问。 - -访问策略说明: -- `/sora/media`:内部调用或客户端携带 API Key 才能下载 -- `/sora/media-signed`:外部可访问,但有签名 + 过期控制 - `config.yaml` 还支持以下安全相关配置: - `cors.allowed_origins` 配置 CORS 白名单 @@ -857,11 +818,11 @@ sub2api/ ## Star History - + - - - Star History Chart + + + Star History Chart diff --git a/README_JA.md b/README_JA.md index e623063b2..07df8fc81 100644 --- a/README_JA.md +++ b/README_JA.md @@ -144,18 +144,6 @@ docker compose \ - -claudeapi -Claude API のご支援に感謝します!Claude API は Claude モデルに特化した公式チャネルの API サービスプロバイダーで、Anthropic 公式キーと AWS Bedrock 公式チャネルをベースに、Claude Code や Agent アプリケーションへの安定した接続体験を提供します。Claude 全シリーズのモデルに対応し、Tool Use や長文コンテキストなどの公式機能もそのまま利用可能。リバースエンジニアリングやモデル劣化のないサービスで、Claude Code のヘビーユーザー、Agent エンジニア、企業の技術チームに最適です。専用リンクから登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取れます。請求書発行やチーム導入にも対応しています。 - - - - -code0 -code0.ai のご支援に感謝します!code0.ai は開発者と技術チーム向けの AI プログラミングワークベンチで、Claude Code や Codex などの主要な Agent コーディング能力を集約し、コード生成、プロジェクト理解、デバッグと修正、コードレビュー、ドキュメント生成といった一般的な開発シーンをサポートします。個人開発者、Agent エンジニア、OSS メンテナー、企業の開発チームに最適で、請求書発行やチーム導入にも対応。専用リンクから登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取り、より効率的な AI プログラミングワークフローを体験できます。 - - - Nagora Nagoraは、開発者やチーム向けに設計されたマルチモデルAI APIゲートウェイです。1つのアカウントと1つのAPIキーだけで、26種類以上の主要なテキストモデルおよび画像モデルを一元的に利用できます。OpenAI、Anthropic、Geminiの各プロトコルに対応し、Claude Code、Codex、Gemini CLIなどの開発ツールにもシームレスに接続できます。 プラットフォームには、インテリジェントルーティング、自動フェイルオーバー、透明性の高い料金体系、請求の一元管理に加え、予算管理、レート制限、同時実行数の制御機能が備わっています。これにより、個人開発、チームでの共同作業、本番環境におけるAI APIの利用を、より安定的かつ柔軟に管理できます。 既存のアプリケーションを改修する必要はありません。Base URLとAPIキーを置き換えるだけで、最短1分で導入を完了できます。 @@ -575,12 +563,6 @@ default: rate_multiplier: 1.0 ``` -### Sora ステータス(一時的に利用不可) - -> ⚠️ Sora 関連の機能は、上流統合およびメディア配信の技術的問題により一時的に利用できません。 -> 現時点では本番環境で Sora に依存しないでください。 -> 既存の `gateway.sora_*` 設定キーは予約されていますが、これらの問題が解決されるまで有効にならない場合があります。 - `config.yaml` では追加のセキュリティ関連オプションも利用できます: - `cors.allowed_origins` - CORS 許可リスト @@ -757,11 +739,11 @@ sub2api/ ## スター履歴 - + - - - Star History Chart + + + Star History Chart diff --git a/assets/partners/logos/claudeapi.jpg b/assets/partners/logos/claudeapi.jpg deleted file mode 100644 index 026c0f039..000000000 Binary files a/assets/partners/logos/claudeapi.jpg and /dev/null differ diff --git a/assets/partners/logos/code0.jpg b/assets/partners/logos/code0.jpg deleted file mode 100644 index aa179d747..000000000 Binary files a/assets/partners/logos/code0.jpg and /dev/null differ diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index e5c7f62e4..f4fdeb66b 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.178 +0.1.179 diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index e68a6bd7d..6432afcfb 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -337,7 +337,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { batchImageWorkerRuntime := service.ProvideBatchImageWorkerRuntime(batchImageRepository, accountRepository, batchImageQueue, usageBillingRepository, usageLogRepository, batchImageModelPricingResolver, apiKeyAuthCacheInvalidator, configConfig) scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig) paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService, leaderLockCache, db) - channelMonitorQuotaFetcher := service.NewChannelMonitorQuotaFetcher(accountUsageService, cnProviderQuotaService, cnProviderBalanceService, accountRepository) + channelMonitorQuotaFetcher := service.NewChannelMonitorQuotaFetcher(accountUsageService, cnProviderQuotaService, cnProviderBalanceService, accountRepository, configConfig) channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService, channelMonitorQuotaFetcher) channelMonitorV2Aggregator := service.ProvideChannelMonitorV2Aggregator(channelMonitorV2Repository, db, settingService) userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index f3af5d95e..f0ed03679 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -830,6 +830,53 @@ type ProxyFallbackConfig struct { type ProxyProbeConfig struct { InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` // 已禁用:禁止跳过 TLS 证书验证 + // URLs 按优先级排列的自定义探测 URL 列表。 + // 留空时使用内置默认列表(ip-api → ipify)。 + // 某些 AI API 专用代理只允许访问特定域名,配置多个备选可提高探测成功率。 + URLs []ProbeURLConfig `mapstructure:"urls"` +} + +// ProbeURLConfig 描述一个探测端点及其响应解析方式。 +type ProbeURLConfig struct { + URL string `mapstructure:"url"` + Parser string `mapstructure:"parser"` // "ip-api" / "ipify" / "chatgpt-trace" +} + +func normalizeProxyProbeURLs(targets []ProbeURLConfig) ([]ProbeURLConfig, error) { + if len(targets) == 0 { + return nil, nil + } + + normalized := make([]ProbeURLConfig, 0, len(targets)) + for i, target := range targets { + rawURL := strings.TrimSpace(target.URL) + parser := strings.ToLower(strings.TrimSpace(target.Parser)) + if rawURL == "" { + return nil, fmt.Errorf("entry %d: url is required", i) + } + if parser == "" { + return nil, fmt.Errorf("entry %d: parser is required", i) + } + switch parser { + case "ip-api", "ipify", "chatgpt-trace": + default: + return nil, fmt.Errorf("entry %d: unsupported parser %q", i, target.Parser) + } + + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Host == "" { + return nil, fmt.Errorf("entry %d: invalid url %q", i, target.URL) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, fmt.Errorf("entry %d: url scheme must be http or https", i) + } + + normalized = append(normalized, ProbeURLConfig{ + URL: rawURL, + Parser: parser, + }) + } + return normalized, nil } type BillingConfig struct { @@ -2573,6 +2620,11 @@ func (c *Config) Validate() error { } c.Security.ForwardedClientIPHeaders = forwardedClientIPHeaders c.SetForwardedClientIPSettings(c.Security.TrustForwardedIPForAPIKeyACL, forwardedClientIPHeaders) + proxyProbeURLs, err := normalizeProxyProbeURLs(c.Security.ProxyProbe.URLs) + if err != nil { + return fmt.Errorf("security.proxy_probe.urls: %w", err) + } + c.Security.ProxyProbe.URLs = proxyProbeURLs if c.Server.ReadHeaderTimeout < 1 || c.Server.ReadHeaderTimeout > 60 { return fmt.Errorf("server.read_header_timeout must be between 1 and 60 seconds") } diff --git a/backend/internal/config/env_reachability_test.go b/backend/internal/config/env_reachability_test.go index 8141a2ec3..cfe39587e 100644 --- a/backend/internal/config/env_reachability_test.go +++ b/backend/internal/config/env_reachability_test.go @@ -45,6 +45,20 @@ func collectMapstructureKeys(t reflect.Type, prefix string, out map[string]strin // is out of scope here — such settings need a config file either way. continue } + if ft.Kind() == reflect.Slice { + elem := ft.Elem() + for elem.Kind() == reflect.Ptr { + elem = elem.Elem() + } + if elem.Kind() == reflect.Struct { + // AutomaticEnv exposes one string value. Viper's string-to-slice + // hook can populate scalar slices, but it cannot decode a string + // into []struct. Registering a default would turn silent ignore + // into a startup unmarshal error, so structured slices remain + // config-file-only just like maps. + continue + } + } out[strings.ToLower(key)] = ft.String() } } @@ -62,7 +76,7 @@ func collectMapstructureKeys(t reflect.Type, prefix string, out map[string]strin // were lost, silently disabling async image tasks for env-driven deployments. // // When this fails, register a zero-valued default in setEnvReachableDefaults -// for each reported key. +// for each reported scalar key. Maps and slices of structs are config-file-only. func TestConfigKeysAreEnvReachable(t *testing.T) { bound := map[string]string{} collectMapstructureKeys(reflect.TypeOf(Config{}), "", bound) diff --git a/backend/internal/config/proxy_probe_config_test.go b/backend/internal/config/proxy_probe_config_test.go new file mode 100644 index 000000000..af5b97209 --- /dev/null +++ b/backend/internal/config/proxy_probe_config_test.go @@ -0,0 +1,47 @@ +//go:build unit + +package config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeProxyProbeURLs(t *testing.T) { + t.Parallel() + + got, err := normalizeProxyProbeURLs([]ProbeURLConfig{ + {URL: " https://chatgpt.com/cdn-cgi/trace ", Parser: " CHATGPT-TRACE "}, + {URL: "https://api64.ipify.org?format=json", Parser: "ipify"}, + }) + require.NoError(t, err) + require.Equal(t, []ProbeURLConfig{ + {URL: "https://chatgpt.com/cdn-cgi/trace", Parser: "chatgpt-trace"}, + {URL: "https://api64.ipify.org?format=json", Parser: "ipify"}, + }, got) +} + +func TestNormalizeProxyProbeURLsRejectsInvalidEntries(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + target ProbeURLConfig + wantErr string + }{ + {name: "missing URL", target: ProbeURLConfig{Parser: "ipify"}, wantErr: "url is required"}, + {name: "missing parser", target: ProbeURLConfig{URL: "https://example.com"}, wantErr: "parser is required"}, + {name: "unknown parser", target: ProbeURLConfig{URL: "https://example.com", Parser: "ip_api"}, wantErr: "unsupported parser"}, + {name: "relative URL", target: ProbeURLConfig{URL: "/cdn-cgi/trace", Parser: "chatgpt-trace"}, wantErr: "invalid url"}, + {name: "unsupported scheme", target: ProbeURLConfig{URL: "ftp://example.com/file", Parser: "ipify"}, wantErr: "scheme must be http or https"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := normalizeProxyProbeURLs([]ProbeURLConfig{tt.target}) + require.ErrorContains(t, err, tt.wantErr) + }) + } +} diff --git a/backend/internal/domain/channel_monitor_quota.go b/backend/internal/domain/channel_monitor_quota.go index 72c989c38..1b93cd394 100644 --- a/backend/internal/domain/channel_monitor_quota.go +++ b/backend/internal/domain/channel_monitor_quota.go @@ -50,6 +50,10 @@ type MonitorQuotaSnapshot struct { Balances []MonitorBalance `json:"balances,omitempty"` // 多币种余额(如 DeepSeek CNY+USD) Currency string `json:"currency,omitempty"` // 主余额币种 PlanLevel string `json:"plan_level,omitempty"` // 套餐等级(如智谱 level) + // BalanceLow 余额低于阈值或账号被上游标记不可用(仅 cn_balance 来源)。 + // 抓取器按 Gateway.CNProviders.BalanceThreshold 判定,口径与账号停调 + // (CNProviderBalanceCheckService.checkOne)一致:任一币种达标即健康。 + BalanceLow bool `json:"balance_low,omitempty"` // CredentialInvalid 上游 401/403 鉴权失败(区别于网络/解析错误), // 检测状态据此推导 failed 而非 error。 CredentialInvalid bool `json:"credential_invalid,omitempty"` diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index 3640d1b82..cd821165a 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -44,6 +44,7 @@ const ( APIProtocolChatCompletions = "chat_completions" // OpenAI Chat Completions(默认) APIProtocolAnthropic = "anthropic" // 原生 Anthropic /v1/messages(适配 Claude Code) APIProtocolResponses = "responses" // OpenAI Responses(仅 deepseek,适配 Codex) + APIProtocolAdaptive = "adaptive" // 按入站协议优先选择供应商原生端点 ) // Account type constants diff --git a/backend/internal/handler/admin/channel_handler.go b/backend/internal/handler/admin/channel_handler.go index dfce024fa..dc0e5b07e 100644 --- a/backend/internal/handler/admin/channel_handler.go +++ b/backend/internal/handler/admin/channel_handler.go @@ -64,6 +64,8 @@ type channelModelPricingRequest struct { OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"` CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"` CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"` + FastMultiplier *float64 `json:"fast_multiplier" binding:"omitempty,gt=0"` + FlexMultiplier *float64 `json:"flex_multiplier" binding:"omitempty,gt=0"` ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"` ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"` PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"` @@ -83,15 +85,19 @@ type channelTimePricingPeriodRequest struct { } type pricingIntervalRequest struct { - MinTokens int `json:"min_tokens"` - MaxTokens *int `json:"max_tokens"` - TierLabel string `json:"tier_label"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - PerRequestPrice *float64 `json:"per_request_price"` - SortOrder int `json:"sort_order"` + MinTokens int `json:"min_tokens"` + MaxTokens *int `json:"max_tokens"` + TierLabel string `json:"tier_label"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + InputMultiplier *float64 `json:"input_multiplier" binding:"omitempty,gt=0"` + OutputMultiplier *float64 `json:"output_multiplier" binding:"omitempty,gt=0"` + CacheWriteMultiplier *float64 `json:"cache_write_multiplier" binding:"omitempty,gt=0"` + CacheReadMultiplier *float64 `json:"cache_read_multiplier" binding:"omitempty,gt=0"` + PerRequestPrice *float64 `json:"per_request_price"` + SortOrder int `json:"sort_order"` } type accountStatsPricingRuleRequest struct { @@ -128,6 +134,8 @@ type channelModelPricingResponse struct { OutputPrice *float64 `json:"output_price"` CacheWritePrice *float64 `json:"cache_write_price"` CacheReadPrice *float64 `json:"cache_read_price"` + FastMultiplier *float64 `json:"fast_multiplier"` + FlexMultiplier *float64 `json:"flex_multiplier"` ImageInputPrice *float64 `json:"image_input_price"` ImageOutputPrice *float64 `json:"image_output_price"` PerRequestPrice *float64 `json:"per_request_price"` @@ -147,16 +155,20 @@ type channelTimePricingPeriodResponse struct { } type pricingIntervalResponse struct { - ID int64 `json:"id"` - MinTokens int `json:"min_tokens"` - MaxTokens *int `json:"max_tokens"` - TierLabel string `json:"tier_label,omitempty"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - PerRequestPrice *float64 `json:"per_request_price"` - SortOrder int `json:"sort_order"` + ID int64 `json:"id"` + MinTokens int `json:"min_tokens"` + MaxTokens *int `json:"max_tokens"` + TierLabel string `json:"tier_label,omitempty"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + InputMultiplier *float64 `json:"input_multiplier"` + OutputMultiplier *float64 `json:"output_multiplier"` + CacheWriteMultiplier *float64 `json:"cache_write_multiplier"` + CacheReadMultiplier *float64 `json:"cache_read_multiplier"` + PerRequestPrice *float64 `json:"per_request_price"` + SortOrder int `json:"sort_order"` } type accountStatsPricingRuleResponse struct { @@ -248,6 +260,8 @@ func pricingToResponse(p *service.ChannelModelPricing) channelModelPricingRespon OutputPrice: p.OutputPrice, CacheWritePrice: p.CacheWritePrice, CacheReadPrice: p.CacheReadPrice, + FastMultiplier: p.FastMultiplier, + FlexMultiplier: p.FlexMultiplier, ImageInputPrice: p.ImageInputPrice, ImageOutputPrice: p.ImageOutputPrice, PerRequestPrice: p.PerRequestPrice, @@ -273,20 +287,24 @@ func timePricingToResponse(value *service.ChannelTimePricing) *channelTimePricin func intervalToResponse(iv service.PricingInterval) pricingIntervalResponse { return pricingIntervalResponse{ - ID: iv.ID, - MinTokens: iv.MinTokens, - MaxTokens: iv.MaxTokens, - TierLabel: iv.TierLabel, - InputPrice: iv.InputPrice, - OutputPrice: iv.OutputPrice, - CacheWritePrice: iv.CacheWritePrice, - CacheReadPrice: iv.CacheReadPrice, - PerRequestPrice: iv.PerRequestPrice, - SortOrder: iv.SortOrder, + ID: iv.ID, + MinTokens: iv.MinTokens, + MaxTokens: iv.MaxTokens, + TierLabel: iv.TierLabel, + InputPrice: iv.InputPrice, + OutputPrice: iv.OutputPrice, + CacheWritePrice: iv.CacheWritePrice, + CacheReadPrice: iv.CacheReadPrice, + InputMultiplier: iv.InputMultiplier, + OutputMultiplier: iv.OutputMultiplier, + CacheWriteMultiplier: iv.CacheWriteMultiplier, + CacheReadMultiplier: iv.CacheReadMultiplier, + PerRequestPrice: iv.PerRequestPrice, + SortOrder: iv.SortOrder, } } -func pricingRequestToService(reqs []channelModelPricingRequest) []service.ChannelModelPricing { +func pricingRequestToService(reqs []channelModelPricingRequest, allowChannelMultipliers bool) []service.ChannelModelPricing { result := make([]service.ChannelModelPricing, 0, len(reqs)) for _, r := range reqs { billingMode := service.BillingMode(r.BillingMode) @@ -296,18 +314,34 @@ func pricingRequestToService(reqs []channelModelPricingRequest) []service.Channe platform := r.Platform intervals := make([]service.PricingInterval, 0, len(r.Intervals)) for _, iv := range r.Intervals { + var inputMultiplier, outputMultiplier, cacheWriteMultiplier, cacheReadMultiplier *float64 + if allowChannelMultipliers { + inputMultiplier = iv.InputMultiplier + outputMultiplier = iv.OutputMultiplier + cacheWriteMultiplier = iv.CacheWriteMultiplier + cacheReadMultiplier = iv.CacheReadMultiplier + } intervals = append(intervals, service.PricingInterval{ - MinTokens: iv.MinTokens, - MaxTokens: iv.MaxTokens, - TierLabel: iv.TierLabel, - InputPrice: iv.InputPrice, - OutputPrice: iv.OutputPrice, - CacheWritePrice: iv.CacheWritePrice, - CacheReadPrice: iv.CacheReadPrice, - PerRequestPrice: iv.PerRequestPrice, - SortOrder: iv.SortOrder, + MinTokens: iv.MinTokens, + MaxTokens: iv.MaxTokens, + TierLabel: iv.TierLabel, + InputPrice: iv.InputPrice, + OutputPrice: iv.OutputPrice, + CacheWritePrice: iv.CacheWritePrice, + CacheReadPrice: iv.CacheReadPrice, + InputMultiplier: inputMultiplier, + OutputMultiplier: outputMultiplier, + CacheWriteMultiplier: cacheWriteMultiplier, + CacheReadMultiplier: cacheReadMultiplier, + PerRequestPrice: iv.PerRequestPrice, + SortOrder: iv.SortOrder, }) } + var fastMultiplier, flexMultiplier *float64 + if allowChannelMultipliers { + fastMultiplier = r.FastMultiplier + flexMultiplier = r.FlexMultiplier + } result = append(result, service.ChannelModelPricing{ Platform: platform, Models: r.Models, @@ -316,6 +350,8 @@ func pricingRequestToService(reqs []channelModelPricingRequest) []service.Channe OutputPrice: r.OutputPrice, CacheWritePrice: r.CacheWritePrice, CacheReadPrice: r.CacheReadPrice, + FastMultiplier: fastMultiplier, + FlexMultiplier: flexMultiplier, ImageInputPrice: r.ImageInputPrice, ImageOutputPrice: r.ImageOutputPrice, PerRequestPrice: r.PerRequestPrice, @@ -346,7 +382,7 @@ func accountStatsPricingRuleRequestToService(r accountStatsPricingRuleRequest) s Name: r.Name, GroupIDs: r.GroupIDs, AccountIDs: r.AccountIDs, - Pricing: pricingRequestToService(r.Pricing), + Pricing: pricingRequestToService(r.Pricing, false), } } @@ -407,7 +443,7 @@ func (h *ChannelHandler) Create(c *gin.Context) { return } - pricing := pricingRequestToService(req.ModelPricing) + pricing := pricingRequestToService(req.ModelPricing, true) // Main model_pricing requires a platform; default to anthropic for backward compatibility. for i := range pricing { if pricing[i].Platform == "" { @@ -481,7 +517,7 @@ func (h *ChannelHandler) Update(c *gin.Context) { ApplyPricingToAccountStats: req.ApplyPricingToAccountStats, } if req.ModelPricing != nil { - pricing := pricingRequestToService(*req.ModelPricing) + pricing := pricingRequestToService(*req.ModelPricing, true) for i := range pricing { if pricing[i].Platform == "" { pricing[i].Platform = service.PlatformAnthropic diff --git a/backend/internal/handler/admin/channel_handler_test.go b/backend/internal/handler/admin/channel_handler_test.go index 6c5ddedc9..75aec7db3 100644 --- a/backend/internal/handler/admin/channel_handler_test.go +++ b/backend/internal/handler/admin/channel_handler_test.go @@ -305,7 +305,7 @@ func TestPricingRequestToService_Defaults(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := pricingRequestToService([]channelModelPricingRequest{tt.req}) + result := pricingRequestToService([]channelModelPricingRequest{tt.req}, true) require.Len(t, result, 1) switch tt.wantField { case "BillingMode": @@ -332,7 +332,7 @@ func TestPricingRequestToService_WithAllFields(t *testing.T) { }, } - result := pricingRequestToService(reqs) + result := pricingRequestToService(reqs, true) require.Len(t, result, 1) r := result[0] require.Equal(t, "openai", r.Platform) @@ -373,7 +373,7 @@ func TestPricingRequestToService_WithIntervals(t *testing.T) { }, } - result := pricingRequestToService(reqs) + result := pricingRequestToService(reqs, true) require.Len(t, result, 1) require.Len(t, result[0].Intervals, 2) @@ -396,7 +396,7 @@ func TestPricingRequestToService_WithIntervals(t *testing.T) { } func TestPricingRequestToService_EmptySlice(t *testing.T) { - result := pricingRequestToService([]channelModelPricingRequest{}) + result := pricingRequestToService([]channelModelPricingRequest{}, true) require.NotNil(t, result) require.Empty(t, result) } @@ -410,7 +410,7 @@ func TestPricingRequestToService_NilPriceFields(t *testing.T) { }, } - result := pricingRequestToService(reqs) + result := pricingRequestToService(reqs, true) require.Len(t, result, 1) r := result[0] require.Nil(t, r.InputPrice) @@ -433,16 +433,52 @@ func TestPricingRequestToService_TimePricing(t *testing.T) { }, } - got := pricingRequestToService([]channelModelPricingRequest{req}) + got := pricingRequestToService([]channelModelPricingRequest{req}, true) require.Equal(t, "Asia/Shanghai", got[0].TimePricing.Timezone) require.Equal(t, 2.0, got[0].TimePricing.Periods[0].Multiplier) } func TestPricingRequestToService_TimePricingNil(t *testing.T) { - got := pricingRequestToService([]channelModelPricingRequest{{Models: []string{"gpt-5"}}}) + got := pricingRequestToService([]channelModelPricingRequest{{Models: []string{"gpt-5"}}}, true) require.Nil(t, got[0].TimePricing) } +// 账号成本统计规则不支持倍率:allowChannelMultipliers=false 时必须丢弃, +// 避免渠道倍率意外污染账号成本口径。 +func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) { + req := channelModelPricingRequest{ + Models: []string{"gpt-5"}, + BillingMode: "token", + FastMultiplier: float64Ptr(2.5), + FlexMultiplier: float64Ptr(0.5), + Intervals: []pricingIntervalRequest{{ + MinTokens: 272000, + InputMultiplier: float64Ptr(2), + OutputMultiplier: float64Ptr(1.5), + CacheWriteMultiplier: float64Ptr(2), + CacheReadMultiplier: float64Ptr(2), + }}, + } + + allowed := pricingRequestToService([]channelModelPricingRequest{req}, true) + require.Equal(t, float64Ptr(2.5), allowed[0].FastMultiplier) + require.Equal(t, float64Ptr(0.5), allowed[0].FlexMultiplier) + require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].InputMultiplier) + require.Equal(t, float64Ptr(1.5), allowed[0].Intervals[0].OutputMultiplier) + require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].CacheWriteMultiplier) + require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].CacheReadMultiplier) + + dropped := pricingRequestToService([]channelModelPricingRequest{req}, false) + require.Nil(t, dropped[0].FastMultiplier) + require.Nil(t, dropped[0].FlexMultiplier) + require.Nil(t, dropped[0].Intervals[0].InputMultiplier) + require.Nil(t, dropped[0].Intervals[0].OutputMultiplier) + require.Nil(t, dropped[0].Intervals[0].CacheWriteMultiplier) + require.Nil(t, dropped[0].Intervals[0].CacheReadMultiplier) + // 非倍率字段不受开关影响 + require.Equal(t, 272000, dropped[0].Intervals[0].MinTokens) +} + func TestPricingToResponse_TimePricing(t *testing.T) { got := pricingToResponse(&service.ChannelModelPricing{ BillingMode: service.BillingModeToken, diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 1c3ecb07e..99833e912 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -234,7 +234,7 @@ type UpdateGroupRequest struct { type CompositeRouteRequest struct { PublicModel string `json:"public_model" binding:"required"` MatchType string `json:"match_type" binding:"omitempty,oneof=exact prefix"` - TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok"` + TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek"` UpstreamModel string `json:"upstream_model"` Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"` Priority int `json:"priority"` diff --git a/backend/internal/handler/admin/group_handler_platform_test.go b/backend/internal/handler/admin/group_handler_platform_test.go index ca1703180..aeba6d1c5 100644 --- a/backend/internal/handler/admin/group_handler_platform_test.go +++ b/backend/internal/handler/admin/group_handler_platform_test.go @@ -71,13 +71,11 @@ func TestGroupPlatformBinding_RejectsInvalidPlatforms(t *testing.T) { } } -// 守住 composite 路由目标不放行 CN:CN 平台不可作为 composite 路由目标 -// (DetectModelPlatform/isConcreteRequestPlatform 均无 CN 分支,放行即打开半实现路径)。 -func TestCompositeRouteTargetPlatform_StillExcludesCNProviders(t *testing.T) { +func TestCompositeRouteTargetPlatform_AllowsCNProviders(t *testing.T) { for _, platform := range []string{"kimi", "zhipu", "deepseek"} { var req CompositeRouteRequest body := fmt.Sprintf(`{"public_model":"m","target_platform":%q}`, platform) - require.Error(t, bindGroupPlatformJSON(t, &req, body), - "composite target_platform %q 应保持被拒", platform) + require.NoError(t, bindGroupPlatformJSON(t, &req, body)) + require.Equal(t, platform, req.TargetPlatform) } } diff --git a/backend/internal/handler/composite_platform_test.go b/backend/internal/handler/composite_platform_test.go index 2fa79c538..be1706fba 100644 --- a/backend/internal/handler/composite_platform_test.go +++ b/backend/internal/handler/composite_platform_test.go @@ -22,18 +22,42 @@ func TestCompositeTargetPlatformAllowedResolvesKnownAllowedModel(t *testing.T) { require.Equal(t, service.PlatformOpenAI, platform) } -func TestOpenAICompatibleTextTargetAllowsCompositeGrokModel(t *testing.T) { +func TestOpenAICompatibleTextTargetAllowsCompositeProviders(t *testing.T) { gin.SetMode(gin.TestMode) - for _, path := range []string{"/v1/messages", "/v1/chat/completions"} { - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - c.Request = httptest.NewRequest("POST", path, nil) - apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}} + providers := []struct { + model string + platform string + }{ + {model: "grok-4.3", platform: service.PlatformGrok}, + {model: "kimi-k2-thinking", platform: service.PlatformKimi}, + {model: "glm-5.2", platform: service.PlatformZhipu}, + {model: "deepseek-v3.2", platform: service.PlatformDeepseek}, + } + for _, path := range []string{"/v1/messages", "/v1/chat/completions", "/v1/responses", "/v1/responses/input_tokens", "/v1/messages/count_tokens"} { + for _, provider := range providers { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", path, nil) + apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}} - require.True(t, openAICompatibleTextTargetAllowed(c, apiKey, "grok-4.3"), "path=%s", path) - platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()) - require.True(t, ok, "path=%s", path) - require.Equal(t, service.PlatformGrok, platform, "path=%s", path) + require.True(t, openAICompatibleTextTargetAllowed(c, apiKey, provider.model), "path=%s model=%s", path, provider.model) + platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()) + require.True(t, ok, "path=%s model=%s", path, provider.model) + require.Equal(t, provider.platform, platform, "path=%s model=%s", path, provider.model) + } + } +} + +// WS ingress 对 CN 账号既过不了 transport 过滤、HTTP 桥也没有 Responses 转换, +// 放行只会把明确的策略拒绝换成 "no available account",因此 WS 白名单保持 openai+grok。 +func TestResponsesWebSocketCompositePlatformGuardKeepsOpenAIAndGrokOnly(t *testing.T) { + require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformOpenAI)) + require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformGrok)) + for _, platform := range []string{ + service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, + service.PlatformAnthropic, service.PlatformGemini, + } { + require.False(t, isResponsesWebSocketCompositePlatform(platform), "platform=%s", platform) } } diff --git a/backend/internal/handler/dto/public_settings_injection_schema_test.go b/backend/internal/handler/dto/public_settings_injection_schema_test.go index 428fed3d8..6a35c8867 100644 --- a/backend/internal/handler/dto/public_settings_injection_schema_test.go +++ b/backend/internal/handler/dto/public_settings_injection_schema_test.go @@ -29,8 +29,6 @@ func TestPublicSettingsInjectionPayload_SchemaDoesNotDrift(t *testing.T) { // Fields that legitimately live only on the DTO. Keep tiny; document each. dtoOnlyFields := map[string]string{ - // sora_client_enabled is an upstream-only field the fork does not surface. - "sora_client_enabled": "upstream-only field, not used on this fork", // force_email_on_third_party_signup lives on the DTO but is not injected via SSR. "force_email_on_third_party_signup": "auth-source default, not a feature flag", } diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index f0284eb55..798de2fed 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -398,7 +398,6 @@ type PublicSettings struct { OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` GitHubOAuthEnabled bool `json:"github_oauth_enabled"` GoogleOAuthEnabled bool `json:"google_oauth_enabled"` - SoraClientEnabled bool `json:"sora_client_enabled"` BackendModeEnabled bool `json:"backend_mode_enabled"` PaymentEnabled bool `json:"payment_enabled"` Version string `json:"version"` diff --git a/backend/internal/handler/endpoint.go b/backend/internal/handler/endpoint.go index df8776981..698539292 100644 --- a/backend/internal/handler/endpoint.go +++ b/backend/internal/handler/endpoint.go @@ -15,20 +15,21 @@ import ( // ────────────────────────────────────────────────────────── const ( - EndpointMessages = "/v1/messages" - EndpointChatCompletions = "/v1/chat/completions" - EndpointEmbeddings = "/v1/embeddings" - EndpointAlphaSearch = "/v1/alpha/search" - EndpointResponses = "/v1/responses" - EndpointResponsesCompact = "/v1/responses/compact" - EndpointImagesGenerations = "/v1/images/generations" - EndpointImagesEdits = "/v1/images/edits" - EndpointImageTasks = "/v1/images/tasks" - EndpointVideosGenerations = "/v1/videos/generations" - EndpointVideosEdits = "/v1/videos/edits" - EndpointVideosExtensions = "/v1/videos/extensions" - EndpointVideos = "/v1/videos" - EndpointGeminiModels = "/v1beta/models" + EndpointMessages = "/v1/messages" + EndpointChatCompletions = "/v1/chat/completions" + EndpointEmbeddings = "/v1/embeddings" + EndpointAlphaSearch = "/v1/alpha/search" + EndpointResponses = "/v1/responses" + EndpointResponsesCompact = "/v1/responses/compact" + EndpointResponsesInputTokens = "/v1/responses/input_tokens" + EndpointImagesGenerations = "/v1/images/generations" + EndpointImagesEdits = "/v1/images/edits" + EndpointImageTasks = "/v1/images/tasks" + EndpointVideosGenerations = "/v1/videos/generations" + EndpointVideosEdits = "/v1/videos/edits" + EndpointVideosExtensions = "/v1/videos/extensions" + EndpointVideos = "/v1/videos" + EndpointGeminiModels = "/v1beta/models" ) const EndpointAntigravityGenerateContent = "/v1internal:streamGenerateContent" @@ -80,6 +81,8 @@ const ( func NormalizeInboundEndpoint(path string) string { path = strings.TrimSpace(path) switch { + case strings.Contains(path, EndpointResponsesInputTokens) || isResponsesInputTokensAliasPath(path): + return EndpointResponsesInputTokens case strings.Contains(path, EndpointEmbeddings): return EndpointEmbeddings case strings.Contains(path, EndpointAlphaSearch) || isBareOrSubpathOf(strings.TrimRight(path, "/"), "/alpha/search") || isBareOrSubpathOf(strings.TrimRight(path, "/"), "/backend-api/codex/alpha/search"): @@ -113,6 +116,15 @@ func NormalizeInboundEndpoint(path string) string { } } +func isResponsesInputTokensAliasPath(path string) bool { + trimmed := strings.TrimRight(strings.TrimSpace(path), "/") + if trimmed == "" { + return false + } + return isBareOrSubpathOf(trimmed, "/responses/input_tokens") || + isBareOrSubpathOf(trimmed, "/backend-api/codex/responses/input_tokens") +} + // isResponsesCompactAliasPath reports whether path is the bare/alias // "compact" client endpoint — i.e. it is rooted at "/responses/compact" // or "/backend-api/codex/responses/compact" (bare routes that serve @@ -185,7 +197,7 @@ func DeriveUpstreamEndpoint(inbound, rawRequestPath, platform string) string { switch platform { case service.PlatformOpenAI, service.PlatformGrok: - if inbound == EndpointEmbeddings || inbound == EndpointAlphaSearch || inbound == EndpointImagesGenerations || inbound == EndpointImagesEdits || inbound == EndpointVideosGenerations || inbound == EndpointVideosEdits || inbound == EndpointVideosExtensions || inbound == EndpointVideos { + if inbound == EndpointEmbeddings || inbound == EndpointAlphaSearch || inbound == EndpointResponsesInputTokens || inbound == EndpointImagesGenerations || inbound == EndpointImagesEdits || inbound == EndpointVideosGenerations || inbound == EndpointVideosEdits || inbound == EndpointVideosExtensions || inbound == EndpointVideos { return inbound } // OpenAI forwards everything to the Responses API. diff --git a/backend/internal/handler/endpoint_test.go b/backend/internal/handler/endpoint_test.go index 3f7b99470..005c397d4 100644 --- a/backend/internal/handler/endpoint_test.go +++ b/backend/internal/handler/endpoint_test.go @@ -27,6 +27,7 @@ func TestNormalizeInboundEndpoint(t *testing.T) { {"/v1/embeddings", EndpointEmbeddings}, {"/v1/alpha/search", EndpointAlphaSearch}, {"/v1/responses", EndpointResponses}, + {"/v1/responses/input_tokens", EndpointResponsesInputTokens}, {"/v1/responses/compact", EndpointResponsesCompact}, {"/v1/responses/compact/detail", EndpointResponsesCompact}, {"/v1/images/generations", EndpointImagesGenerations}, @@ -50,6 +51,7 @@ func TestNormalizeInboundEndpoint(t *testing.T) { // Bare top-level alias route "/responses" — root vs. compact. {"/responses", EndpointResponses}, + {"/responses/input_tokens", EndpointResponsesInputTokens}, {"/responses/compact", EndpointResponsesCompact}, {"/responses/compact/detail", EndpointResponsesCompact}, {"/alpha/search", EndpointAlphaSearch}, @@ -57,6 +59,7 @@ func TestNormalizeInboundEndpoint(t *testing.T) { // Bare Codex direct alias route — root vs. compact. {"/backend-api/codex/responses", EndpointResponses}, + {"/backend-api/codex/responses/input_tokens", EndpointResponsesInputTokens}, {"/backend-api/codex/responses/compact", EndpointResponsesCompact}, {"/backend-api/codex/responses/compact/detail", EndpointResponsesCompact}, {"/backend-api/codex/alpha/search", EndpointAlphaSearch}, @@ -100,6 +103,7 @@ func TestDeriveUpstreamEndpoint(t *testing.T) { // OpenAI — root Responses. {"openai responses root", EndpointResponses, "/v1/responses", service.PlatformOpenAI, EndpointResponses}, + {"openai responses input tokens", EndpointResponsesInputTokens, "/v1/responses/input_tokens", service.PlatformOpenAI, EndpointResponsesInputTokens}, // OpenAI — compact, raw path carries the derivable "/compact" // (or nested) suffix, which must be preserved on the upstream diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 07315d5ef..1128329be 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1147,10 +1147,12 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID * seen := make(map[string]struct{}) models := make([]string, 0) schedulablePlatforms := h.gatewayService.GetSchedulablePlatforms(ctx, groupID) - for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok} { + for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} { platformModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform) if len(platformModels) == 0 { - if _, ok := schedulablePlatforms[platform]; ok { + // CN 供应商没有静态默认模型列表(defaultModelIDsForPlatform 的 + // default 分支是 Claude 列表),composite 下只暴露账号映射键。 + if _, ok := schedulablePlatforms[platform]; ok && !service.IsCNProvider(platform) { platformModels = defaultModelIDsForPlatform(platform) } } @@ -1372,7 +1374,7 @@ func defaultModelIDsForPlatform(platform string) []string { case service.PlatformComposite: ids := make([]string, 0) seen := make(map[string]struct{}) - for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok} { + for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} { for _, id := range defaultModelIDsForPlatform(concretePlatform) { if _, ok := seen[id]; ok { continue diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 58f019029..313b3216f 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "testing" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -321,6 +322,27 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t }, }, }, + { + ID: 4, + Platform: service.PlatformKimi, + Credentials: map[string]any{ + "model_mapping": map[string]any{"kimi-custom": "kimi-upstream"}, + }, + }, + { + ID: 5, + Platform: service.PlatformZhipu, + Credentials: map[string]any{ + "model_mapping": map[string]any{"glm-custom": "glm-upstream"}, + }, + }, + { + ID: 6, + Platform: service.PlatformDeepseek, + Credentials: map[string]any{ + "model_mapping": map[string]any{"deepseek-custom": "deepseek-upstream"}, + }, + }, }, }, }, @@ -335,7 +357,7 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t Platform: service.PlatformComposite, ModelsListConfig: service.GroupModelsListConfig{ Enabled: true, - Models: []string{"gemini-2.5-flash", "missing-model", "ag-custom-model", "gpt-5.5"}, + Models: []string{"gemini-2.5-flash", "missing-model", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom"}, }, }, }) @@ -346,7 +368,7 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t var got gatewayModelsResponseForTest require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) - require.Equal(t, []string{"gemini-2.5-flash", "ag-custom-model", "gpt-5.5"}, modelIDsForTest(got.Data)) + require.Equal(t, []string{"gemini-2.5-flash", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom"}, modelIDsForTest(got.Data)) } func TestGatewayModels_CompositeUnmappedAccountsFallbackToLinkedPlatformsOnly(t *testing.T) { @@ -385,6 +407,56 @@ func TestGatewayModels_CompositeUnmappedAccountsFallbackToLinkedPlatformsOnly(t require.NotContains(t, ids, "gemini-2.5-flash") } +// CN 供应商没有静态默认模型列表:composite 下无映射的可调度 CN 账号不得把 +// defaultModelIDsForPlatform default 分支的 Claude 列表挂到 CN 平台名下。 +func TestGatewayModels_CompositeUnmappedCNAccountsContributeNoDefaults(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := int64(35) + h := newGatewayModelsHandlerForTest( + &gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + {ID: 1, Platform: service.PlatformOpenAI}, + {ID: 2, Platform: service.PlatformKimi}, + {ID: 3, Platform: service.PlatformZhipu}, + {ID: 4, Platform: service.PlatformDeepseek}, + }, + }, + }, + ) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: service.PlatformComposite}, + }) + + h.Models(c) + + require.Equal(t, http.StatusOK, rec.Code) + + var got gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + + ids := modelIDsForTest(got.Data) + require.Contains(t, ids, "gpt-5.5") + require.NotContains(t, ids, "claude-sonnet-4-6") +} + +// 独立 CN 分组沿用 default 分支的 Claude 默认列表(Claude Code 客户端请求的 +// 就是这些模型名并经账号 model_mapping 转换),composite 支持不得改变该回退。 +func TestDefaultModelIDsForPlatform_CNProvidersKeepClaudeDefaults(t *testing.T) { + want := make([]string, 0, len(claude.DefaultModels)) + for _, model := range claude.DefaultModels { + want = append(want, model.ID) + } + for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} { + require.Equal(t, want, defaultModelIDsForPlatform(platform), "platform=%s", platform) + } +} + func TestGatewayModels_CustomModelsListKeepsConcreteModelAllowedByWildcardMapping(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/handler/no_account_error.go b/backend/internal/handler/no_account_error.go index 8982c25b4..6ce7feba7 100644 --- a/backend/internal/handler/no_account_error.go +++ b/backend/internal/handler/no_account_error.go @@ -106,7 +106,11 @@ func classifyNoAccountErrorFromGin( if c != nil && c.Request != nil { ctx = c.Request.Context() } - return classifyNoAccountError(ctx, diag, apiKey, routingModel, displayModel, platform) + classification := classifyNoAccountError(ctx, diag, apiKey, routingModel, displayModel, platform) + if classification.ModelNotFound { + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration) + } + return classification } func classifyOpenAICompatibleNoAccountErrorFromGin( diff --git a/backend/internal/handler/no_account_error_test.go b/backend/internal/handler/no_account_error_test.go index 8c6784e84..43392b72b 100644 --- a/backend/internal/handler/no_account_error_test.go +++ b/backend/internal/handler/no_account_error_test.go @@ -113,6 +113,8 @@ func TestClassifyNoAccountError_ModelNotSupported_Returns404(t *testing.T) { require.Equal(t, service.PlatformOpenAI, fd.calls[0].Platform) require.NotNil(t, fd.calls[0].GroupID) require.Equal(t, int64(42), *fd.calls[0].GroupID) + require.True(t, service.HasOpsClientBusinessLimited(c)) + require.Equal(t, service.OpsClientBusinessLimitedReasonLocalModelConfiguration, service.OpsClientBusinessLimitedReason(c)) } func TestClassifyOpenAICompatibleNoAccountError_GrokUsesGrokPlatform(t *testing.T) { @@ -134,6 +136,8 @@ func TestClassifyOpenAICompatibleNoAccountError_GrokUsesGrokPlatform(t *testing. require.True(t, cls.ModelNotFound) require.Len(t, fd.calls, 1) require.Equal(t, service.PlatformGrok, fd.calls[0].Platform) + require.True(t, service.HasOpsClientBusinessLimited(c)) + require.Equal(t, service.OpsClientBusinessLimitedReasonLocalModelConfiguration, service.OpsClientBusinessLimitedReason(c)) logErr := openAICompatibleSelectionErrorForLog( fmt.Errorf("no available OpenAI accounts supporting model: grok-4.5"), @@ -142,6 +146,18 @@ func TestClassifyOpenAICompatibleNoAccountError_GrokUsesGrokPlatform(t *testing. require.EqualError(t, logErr, "no available Grok accounts supporting model: grok-4.5") } +func TestClassifyNoAccountError_PureClassifierDoesNotMarkGinContext(t *testing.T) { + c := newTestGinContextWithRequest() + fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}} + apiKey := &service.APIKey{GroupID: ptrInt64(7)} + + cls := classifyNoAccountError(c.Request.Context(), fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI) + + require.True(t, cls.ModelNotFound) + require.False(t, service.HasOpsClientBusinessLimited(c)) + require.Empty(t, service.OpsClientBusinessLimitedReason(c)) +} + func TestClassifyNoAccountError_HasModelSupport_KeepsRoutingMessageGenerationToCaller(t *testing.T) { c := newTestGinContextWithRequest() fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: true}} @@ -200,4 +216,6 @@ func TestClassifyNoAccountError_FromGin_NilContextStillSafe(t *testing.T) { require.Equal(t, http.StatusNotFound, cls.Status, "even with a nil gin context the classifier must still run and yield a coherent response") require.True(t, cls.ModelNotFound) + require.False(t, service.HasOpsClientBusinessLimited(nil)) + require.Empty(t, service.OpsClientBusinessLimitedReason(nil)) } diff --git a/backend/internal/handler/openai_alpha_search.go b/backend/internal/handler/openai_alpha_search.go index c5766e566..6e0066682 100644 --- a/backend/internal/handler/openai_alpha_search.go +++ b/backend/internal/handler/openai_alpha_search.go @@ -30,8 +30,8 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) { h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key") return } - if apiKey.Group.Platform != service.PlatformOpenAI { - h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex alpha search is only available for OpenAI groups") + if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite { + h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex alpha search is only available for OpenAI and Composite groups") return } subject, ok := middleware2.GetAuthSubjectFromContext(c) @@ -75,6 +75,10 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) { return } requestedModel := strings.TrimSpace(modelResult.String()) + if !compositeTargetPlatformAllowed(c, apiKey, requestedModel, service.PlatformOpenAI) { + h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex alpha search only supports OpenAI models for Composite groups") + return + } reqLog = reqLog.With(zap.String("model", requestedModel)) setOpsRequestContext(c, requestedModel, false) setOpsEndpointContext(c, "", int16(service.RequestTypeSync)) diff --git a/backend/internal/handler/openai_codex_models_handler.go b/backend/internal/handler/openai_codex_models_handler.go index 690cba9ce..2714dc294 100644 --- a/backend/internal/handler/openai_codex_models_handler.go +++ b/backend/internal/handler/openai_codex_models_handler.go @@ -27,8 +27,8 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { h.errorResponse(c, http.StatusUnauthorized, "invalid_request_error", "API key group is required") return } - if apiKey.Group.Platform != service.PlatformOpenAI { - h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex models manifest is only available for OpenAI groups") + if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite { + h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex models manifest is only available for OpenAI and Composite groups") return } diff --git a/backend/internal/handler/openai_codex_models_handler_test.go b/backend/internal/handler/openai_codex_models_handler_test.go index 7636fa5fd..f8d1fada6 100644 --- a/backend/internal/handler/openai_codex_models_handler_test.go +++ b/backend/internal/handler/openai_codex_models_handler_test.go @@ -116,6 +116,19 @@ func TestCodexModelsCanceledRequestDoesNotWriteResponse(t *testing.T) { } } +func TestCompositeCodexModelsReusesExistingManifestSelection(t *testing.T) { + handler, upstream, groupID := newCodexModelsFailoverTestHandler(http.StatusServiceUnavailable) + + recorder := performCodexModelsRequestForPlatform(t, handler, groupID, service.PlatformComposite) + + if got, want := upstream.calls(), []int64{1, 2}; !equalInt64Slices(got, want) { + t.Fatalf("upstream account calls: got %v, want %v", got, want) + } + if recorder.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } +} + func TestCodexModelsFailsOverFromRetryableUpstreamStatus(t *testing.T) { retryableStatuses := []int{ http.StatusTooManyRequests, @@ -287,13 +300,17 @@ func newCodexModelsFailoverTestHandlerWithAccountCount(firstStatus, accountCount } func performCodexModelsRequest(t *testing.T, handler *OpenAIGatewayHandler, groupID int64) *httptest.ResponseRecorder { + return performCodexModelsRequestForPlatform(t, handler, groupID, service.PlatformOpenAI) +} + +func performCodexModelsRequestForPlatform(t *testing.T, handler *OpenAIGatewayHandler, groupID int64, platform string) *httptest.ResponseRecorder { t.Helper() recorder := httptest.NewRecorder() c, _ := gin.CreateTestContext(recorder) c.Request = httptest.NewRequest(http.MethodGet, "/v1/models?client_version=0.144.0", nil) c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ GroupID: &groupID, - Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI}, + Group: &service.Group{ID: groupID, Platform: platform}, }) handler.CodexModels(c) diff --git a/backend/internal/handler/openai_gateway_cn_dispatch_test.go b/backend/internal/handler/openai_gateway_cn_dispatch_test.go index b7f11c546..c49201bd1 100644 --- a/backend/internal/handler/openai_gateway_cn_dispatch_test.go +++ b/backend/internal/handler/openai_gateway_cn_dispatch_test.go @@ -3,27 +3,74 @@ package handler // CN 分组 /v1/messages 调度闸门回归(修复:正常途径创建的 CN 分组曾恒 403): // sanitizeGroupMessagesDispatchFields 对非 openai 平台强制 AllowMessagesDispatch // =false,故 CN 分组必须与 grok 一样在闸门处豁免,否则原生 Anthropic 直通 -//(Claude Code 主用例)永远不可达。 +//(Claude Code 主用例)永远不可达。composite 分组同理:sanitize 对 composite +// 恒置 false,解析到 grok/CN 目标时必须按目标平台豁免,解析到 openai 目标 +// 仍受开关控制。 import ( + "net/http/httptest" "testing" "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) func TestAllowOpenAICompatibleMessagesDispatch_CNProvidersExempt(t *testing.T) { - require.True(t, allowOpenAICompatibleMessagesDispatch(nil), "无 key 保持放行") + require.True(t, allowOpenAICompatibleMessagesDispatch(nil, nil), "无 key 保持放行") for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformGrok} { apiKey := &service.APIKey{Group: &service.Group{Platform: platform, AllowMessagesDispatch: false}} - require.True(t, allowOpenAICompatibleMessagesDispatch(apiKey), + require.True(t, allowOpenAICompatibleMessagesDispatch(nil, apiKey), "%s 分组必须豁免 allow_messages_dispatch 闸门", platform) } // 非回归:openai 分组仍受开关控制。 openaiOff := &service.APIKey{Group: &service.Group{Platform: service.PlatformOpenAI, AllowMessagesDispatch: false}} - require.False(t, allowOpenAICompatibleMessagesDispatch(openaiOff)) + require.False(t, allowOpenAICompatibleMessagesDispatch(nil, openaiOff)) openaiOn := &service.APIKey{Group: &service.Group{Platform: service.PlatformOpenAI, AllowMessagesDispatch: true}} - require.True(t, allowOpenAICompatibleMessagesDispatch(openaiOn)) + require.True(t, allowOpenAICompatibleMessagesDispatch(nil, openaiOn)) +} + +func TestAllowOpenAICompatibleMessagesDispatch_CompositeResolvedTargets(t *testing.T) { + gin.SetMode(gin.TestMode) + + newCompositeCtx := func(model string) (*gin.Context, *service.APIKey) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", "/v1/messages", nil) + apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite, AllowMessagesDispatch: false}} + ensureCompositeTargetPlatform(c, apiKey, model) + return c, apiKey + } + + // 解析到 grok/CN 目标:与对应独立分组同语义豁免。 + for _, model := range []string{"grok-4.3", "kimi-k2-thinking", "glm-5.2", "deepseek-v3.2"} { + c, apiKey := newCompositeCtx(model) + require.True(t, allowOpenAICompatibleMessagesDispatch(c, apiKey), "model=%s", model) + } + + // 解析到 openai 目标:仍受开关控制(composite 被 sanitize 恒置 false ⇒ 拒绝)。 + c, apiKey := newCompositeCtx("gpt-5.5") + require.False(t, allowOpenAICompatibleMessagesDispatch(c, apiKey)) + + // 未解析出目标平台:保持拒绝,不放宽。 + cNone, _ := gin.CreateTestContext(httptest.NewRecorder()) + cNone.Request = httptest.NewRequest("POST", "/v1/messages", nil) + require.False(t, allowOpenAICompatibleMessagesDispatch(cNone, + &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite, AllowMessagesDispatch: false}})) +} + +// composite 解析到 grok/CN 目标时,Group 级调度映射(gpt-5.x 默认值为 openai +// 专属)不得注入,模型改写完全交给账号级 model_mapping。 +func TestResolveOpenAIMessagesDispatchMappedModel_CompositeCNTargetsSkipGroupMapping(t *testing.T) { + gin.SetMode(gin.TestMode) + + for _, model := range []string{"kimi-k2-thinking", "glm-5.2", "deepseek-v3.2", "grok-4.3"} { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", "/v1/messages", nil) + apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}} + ensureCompositeTargetPlatform(c, apiKey, model) + + require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(c, apiKey, "claude-sonnet-4-5-20250929"), "model=%s", model) + } } diff --git a/backend/internal/handler/openai_gateway_count_tokens.go b/backend/internal/handler/openai_gateway_count_tokens.go index 70e61e24f..ee6e86c4f 100644 --- a/backend/internal/handler/openai_gateway_count_tokens.go +++ b/backend/internal/handler/openai_gateway_count_tokens.go @@ -3,6 +3,7 @@ package handler import ( "net/http" "strconv" + "strings" "time" "github.com/Wei-Shaw/sub2api/internal/domain" @@ -10,9 +11,134 @@ import ( "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" "go.uber.org/zap" ) +// ResponsesInputTokens handles native OpenAI POST +// /v1/responses/input_tokens requests without routing them through the normal +// Responses generation and usage-recording pipeline. +func (h *OpenAIGatewayHandler) ResponsesInputTokens(c *gin.Context) { + apiKey, ok := middleware2.GetAPIKeyFromContext(c) + if !ok { + h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key") + return + } + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found") + return + } + reqLog := requestLogger( + c, + "handler.openai_gateway.responses_input_tokens", + zap.Int64("user_id", subject.UserID), + zap.Int64("api_key_id", apiKey.ID), + zap.Any("group_id", apiKey.GroupID), + ) + if !h.ensureResponsesDependencies(c, reqLog) { + return + } + + body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg) + if err != nil { + if maxErr, ok := extractMaxBytesError(err); ok { + h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) + return + } + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") + return + } + if len(body) == 0 || !gjson.ValidBytes(body) { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body") + return + } + modelResult := gjson.GetBytes(body, "model") + if !modelResult.Exists() || modelResult.Type != gjson.String || strings.TrimSpace(modelResult.String()) == "" { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "model is required") + return + } + reqModel := strings.TrimSpace(modelResult.String()) + ensureCompositeTargetPlatform(c, apiKey, reqModel) + if !openAICompatibleTextTargetAllowed(c, apiKey, reqModel) { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups") + return + } + + setOpsRequestContext(c, reqModel, false) + setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(false, false))) + if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, reqModel, body); decision != nil && !decision.AllowNextStage { + h.openAISecurityAuditError(c, decision) + return + } + + subscription, _ := middleware2.GetSubscriptionFromContext(c) + if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil { + reqLog.Info("openai_input_tokens.billing_eligibility_check_failed", zap.Error(err)) + status, code, message, retryAfter := billingErrorDetails(err) + if retryAfter > 0 { + c.Header("Retry-After", strconv.Itoa(retryAfter)) + } + h.errorResponse(c, status, code, message) + return + } + + channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel) + routingModel := reqModel + forwardBody := body + if channelMapping.Mapped { + routingModel = channelMapping.MappedModel + forwardBody = h.gatewayService.ReplaceModelInBody(body, routingModel) + } + + // Token counting is not billed, so it must not be excluded by the profit gate. + c.Request = c.Request.WithContext(service.WithOpenAIProfitControlSuppressed(c.Request.Context())) + requestPlatform := openAICompatibleRequestPlatform(c.Request.Context(), apiKey) + sessionHash := h.gatewayService.GenerateSessionHash(c, body) + requestStart := time.Now() + selection, _, err := h.gatewayService.SelectAccountWithSchedulerForCapability( + c.Request.Context(), + apiKey.GroupID, + "", + sessionHash, + routingModel, + nil, + service.OpenAIUpstreamTransportAny, + service.OpenAIEndpointCapabilityChatCompletions, + false, + false, + false, + requestPlatform, + ) + service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds()) + if err != nil { + reqLog.Warn("openai_input_tokens.account_select_failed", zap.Error(openAICompatibleSelectionErrorForLog(err, requestPlatform))) + cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, routingModel, reqModel) + if !cls.ModelNotFound { + markOpsRoutingCapacityLimitedIfNoAvailable(c, err) + } + h.errorResponse(c, cls.Status, cls.ErrType, cls.Message) + return + } + if selection == nil || selection.Account == nil { + cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, h.gatewayService, apiKey, routingModel, reqModel) + if !cls.ModelNotFound { + markOpsRoutingCapacityLimited(c) + } + h.errorResponse(c, cls.Status, cls.ErrType, cls.Message) + return + } + + account := selection.Account + setOpsSelectedAccount(c, account.ID, account.Platform) + if selection.Acquired && selection.ReleaseFunc != nil { + defer selection.ReleaseFunc() + } + if err := h.gatewayService.ForwardResponsesInputTokens(c.Request.Context(), c, account, forwardBody); err != nil { + reqLog.Error("openai_input_tokens.forward_failed", zap.Int64("account_id", account.ID), zap.Error(err)) + } +} + // GrokCountTokens handles Anthropic-compatible count_tokens requests locally. // The route middleware already authenticates the API key and resolves the // group; this handler intentionally does not select an account or check billing. @@ -78,7 +204,7 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) { zap.Any("group_id", apiKey.GroupID), ) - if !allowOpenAICompatibleMessagesDispatch(apiKey) { + if !allowOpenAICompatibleMessagesDispatch(c, apiKey) { h.anthropicErrorResponse(c, http.StatusForbidden, "permission_error", "This group does not allow /v1/messages dispatch") return @@ -117,12 +243,14 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) { reqModel := parsedReq.Model ensureCompositeTargetPlatform(c, apiKey, reqModel) - if !compositeTargetPlatformAllowed(c, apiKey, reqModel, service.PlatformOpenAI) { + // composite+grok 在路由层已分流到 GrokCountTokens,这里可达的目标平台是 + // openai 与 CN 供应商;CN 账号由 ForwardCountTokensAsAnthropic 本地估算。 + if !openAICompatibleTextTargetAllowed(c, apiKey, reqModel) { h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups") return } routingModel := service.NormalizeOpenAICompatRequestedModel(reqModel) - preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(apiKey, reqModel) + preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(c, apiKey, reqModel) reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", parsedReq.Stream)) setOpsRequestContext(c, reqModel, false) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index b14ac4cd6..06cb82772 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -98,10 +98,18 @@ func openAIForwardSucceededForScheduling(result *service.OpenAIForwardResult) bo return result.SucceededForScheduling() } -func resolveOpenAIMessagesDispatchMappedModel(apiKey *service.APIKey, requestedModel string) string { +func resolveOpenAIMessagesDispatchMappedModel(c *gin.Context, apiKey *service.APIKey, requestedModel string) string { if apiKey == nil || apiKey.Group == nil { return "" } + // composite 解析到 grok/CN 目标时调度级映射不适用(Group 级映射的 gpt-5.x + // 默认值是 openai 专属,发给这些上游必错),模型改写交给账号级 model_mapping。 + if apiKey.Group.Platform == service.PlatformComposite && c != nil && c.Request != nil { + if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok && + (platform == service.PlatformGrok || service.IsCNProvider(platform)) { + return "" + } + } return strings.TrimSpace(apiKey.Group.ResolveMessagesDispatchModel(requestedModel)) } @@ -194,7 +202,7 @@ func shouldEnableCodexQuotaOverdraftForResponses(legacyCompact, nativeV2, imageI return !legacyCompact && !nativeV2 && !imageIntent } -func allowOpenAICompatibleMessagesDispatch(apiKey *service.APIKey) bool { +func allowOpenAICompatibleMessagesDispatch(c *gin.Context, apiKey *service.APIKey) bool { if apiKey == nil || apiKey.Group == nil { return true } @@ -208,6 +216,15 @@ func allowOpenAICompatibleMessagesDispatch(apiKey *service.APIKey) bool { if service.IsCNProvider(apiKey.Group.Platform) { return true } + // composite 分组解析到 grok/CN 目标时与对应独立分组同语义豁免:sanitize + // 对 composite 同样恒置 false,不豁免则这些目标的 /v1/messages 永远 403; + // 解析到 openai 目标仍受开关控制,维持现状。 + if apiKey.Group.Platform == service.PlatformComposite && c != nil && c.Request != nil { + if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok && + (platform == service.PlatformGrok || service.IsCNProvider(platform)) { + return true + } + } return apiKey.Group.AllowMessagesDispatch } @@ -217,6 +234,19 @@ func openAICompatibleTextTargetAllowed(c *gin.Context, apiKey *service.APIKey, m service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek) } +// isResponsesWebSocketCompositePlatform 限定 composite 分组在 Responses WebSocket +// 上可服务的目标平台。CN 供应商(kimi/zhipu/deepseek)刻意排除:其账号无法通过 +// WSv2 ingress 的 transport 过滤,且 WS HTTP 桥没有面向 CN 的 Responses 转换, +// 放行只会把明确的策略拒绝变成误导性的 "no available account"。 +func isResponsesWebSocketCompositePlatform(platform string) bool { + switch platform { + case service.PlatformOpenAI, service.PlatformGrok: + return true + default: + return false + } +} + // NewOpenAIGatewayHandler creates a new OpenAIGatewayHandler func NewOpenAIGatewayHandler( gatewayService *service.OpenAIGatewayService, @@ -337,7 +367,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { } reqModel := modelResult.String() ensureCompositeTargetPlatform(c, apiKey, reqModel) - if !compositeTargetPlatformAllowed(c, apiKey, reqModel, service.PlatformOpenAI, service.PlatformGrok) { + if !openAICompatibleTextTargetAllowed(c, apiKey, reqModel) { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups") return } @@ -582,7 +612,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { // Forward request service.SetOpsLatencyMs(c, service.OpsRoutingLatencyMsKey, time.Since(routingStart).Milliseconds()) forwardStart := time.Now() - // 用扣除 compact 心跳字节的口径快照:心跳注释不构成语义响应, + // 用扣除非语义心跳字节的口径快照:心跳注释不构成语义响应, // 不能因心跳字节变化而放弃 failover 换号(#3887)。 writerSizeBeforeForward := service.OpenAICompactKeepaliveAdjustedWrittenSize(c) // 跨 passthrough 边界的 failover:从 Kiro 等透传账号切到 Bedrock 等非透传账号前, @@ -677,7 +707,9 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { h.handleFailoverExhausted(c, failoverErr, true) return } - if failoverErr.SafeToFailoverAfterWrite && c.Writer.Written() { + // openAIForwardMayFailover 已确认写出的字节不含语义输出, + // 但重试耗尽时仍须按已提交的 SSE 响应返回流内错误。 + if c.Writer.Written() { streamStarted = true } if failoverErr.ShouldReportAccountScheduleFailure() { @@ -950,7 +982,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { ) // 检查分组是否允许 /v1/messages 调度 - if !allowOpenAICompatibleMessagesDispatch(apiKey) { + if !allowOpenAICompatibleMessagesDispatch(c, apiKey) { h.anthropicErrorResponse(c, http.StatusForbidden, "permission_error", "This group does not allow /v1/messages dispatch") return @@ -993,7 +1025,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { } bindOpenAIReasoningEffortPolicyForMessagesRequest(c, apiKey, body) routingModel := service.NormalizeOpenAICompatRequestedModel(reqModel) - preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(apiKey, reqModel) + preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(c, apiKey, reqModel) reqStream := gjson.GetBytes(body, "stream").Bool() reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream)) @@ -1757,7 +1789,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { ctx = c.Request.Context() if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite { platform, ok := service.ResolvedTargetPlatformFromContext(ctx) - if !ok || (platform != service.PlatformOpenAI && platform != service.PlatformGrok) { + if !ok || !isResponsesWebSocketCompositePlatform(platform) { closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "Responses WebSocket API only supports OpenAI-compatible models for composite groups") return } @@ -1879,6 +1911,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { failedAccountIDs := make(map[int64]struct{}) var lastFailoverErr *service.UpstreamFailoverError var oauth429FailoverState service.OpenAIOAuth429FailoverState + wsAttemptMessage := append([]byte(nil), firstMessage...) handleWSFailover := func(account *service.Account, failoverErr *service.UpstreamFailoverError) bool { if ctx.Err() != nil { return false @@ -2287,7 +2320,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { }, } - wsFirstMessage := firstMessage + wsFirstMessage := wsAttemptMessage // 切组/会话失配防护:previous_response_id 未在当前分组命中粘连账号(StickyPreviousHit=false), // 说明该会话链不属于本次调度到的账号,原样转发会触发上游会话链鉴权失败(“鉴权失败,请检查 API Key”)。 // 故剥离首包里的 previous_response_id,改用首包内 input 重建上下文;带 function_call_output 的 @@ -2306,6 +2339,21 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { if err := h.gatewayService.ProxyResponsesWebSocketFromClient(ctx, c, wsConn, account, token, wsFirstMessage, hooks); err != nil { var failoverErr *service.UpstreamFailoverError if errors.As(err, &failoverErr) { + retryPayload, retryCurrentTurn := service.OpenAIWSCurrentTurnRetryPayload(err) + nextAttemptMessage, retrySafe := openAIWSNextAttemptMessage(wsAttemptMessage, retryPayload, retryCurrentTurn) + if !retrySafe { + closeOpenAIWSFailoverExhausted(wsConn, failoverErr) + return + } + wsAttemptMessage = nextAttemptMessage + if retryCurrentTurn { + previousResponseID = "" + reqLog.Warn("openai.websocket_current_turn_failover_retry", + zap.Int64("account_id", account.ID), + zap.Int("upstream_status", failoverErr.StatusCode), + zap.Int("retry_payload_bytes", len(retryPayload)), + ) + } if handleWSFailover(account, failoverErr) { continue } @@ -2948,6 +2996,16 @@ func closeOpenAIClientWS(conn *coderws.Conn, status coderws.StatusCode, reason s _ = conn.CloseNow() } +func openAIWSNextAttemptMessage(current, retryPayload []byte, retryCurrentTurn bool) ([]byte, bool) { + if !retryCurrentTurn { + return append([]byte(nil), current...), true + } + if len(retryPayload) == 0 { + return nil, false + } + return append([]byte(nil), retryPayload...), true +} + func closeOpenAIWSFailoverExhausted(conn *coderws.Conn, failoverErr *service.UpstreamFailoverError) { if failoverErr == nil { closeOpenAIClientWS(conn, coderws.StatusInternalError, "upstream websocket proxy failed") diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index 6e8a687d7..898449cc2 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -646,21 +646,21 @@ func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) { }, }, } - require.Equal(t, "gpt-5.4-mini", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5-20250929")) - require.Equal(t, "gpt-5.6-sol", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-fable-5")) + require.Equal(t, "gpt-5.4-mini", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-sonnet-4-5-20250929")) + require.Equal(t, "gpt-5.6-sol", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-fable-5")) }) t.Run("uses_family_default_when_no_override", func(t *testing.T) { apiKey := &service.APIKey{Group: &service.Group{}} - require.Equal(t, "gpt-5.4", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-opus-4-6")) - require.Equal(t, "gpt-5.3-codex", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5-20250929")) - require.Equal(t, "gpt-5.4-mini", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-haiku-4-5-20251001")) + require.Equal(t, "gpt-5.4", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-opus-4-6")) + require.Equal(t, "gpt-5.3-codex", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-sonnet-4-5-20250929")) + require.Equal(t, "gpt-5.4-mini", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-haiku-4-5-20251001")) }) t.Run("returns_empty_for_non_claude_or_missing_group", func(t *testing.T) { - require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, "claude-sonnet-4-5-20250929")) - require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(&service.APIKey{}, "claude-sonnet-4-5-20250929")) - require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(&service.APIKey{Group: &service.Group{}}, "gpt-5.4")) + require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, nil, "claude-sonnet-4-5-20250929")) + require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, &service.APIKey{}, "claude-sonnet-4-5-20250929")) + require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, &service.APIKey{Group: &service.Group{}}, "gpt-5.4")) }) t.Run("grok_group_maps_claude_cli_model_to_grok_default", func(t *testing.T) { @@ -672,8 +672,8 @@ func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) { Platform: service.PlatformGrok, }, } - require.Equal(t, "grok-4.5", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5")) - require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(apiKey, "grok")) + require.Equal(t, "grok-4.5", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-sonnet-4-5")) + require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "grok")) }) t.Run("does_not_fall_back_to_group_default_mapped_model", func(t *testing.T) { @@ -682,8 +682,8 @@ func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) { DefaultMappedModel: "gpt-5.4", }, } - require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(apiKey, "gpt-5.4")) - require.Equal(t, "gpt-5.3-codex", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5-20250929")) + require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "gpt-5.4")) + require.Equal(t, "gpt-5.3-codex", resolveOpenAIMessagesDispatchMappedModel(nil, apiKey, "claude-sonnet-4-5-20250929")) }) } diff --git a/backend/internal/handler/openai_gateway_ws_failover_resume_test.go b/backend/internal/handler/openai_gateway_ws_failover_resume_test.go new file mode 100644 index 000000000..d62f83ee6 --- /dev/null +++ b/backend/internal/handler/openai_gateway_ws_failover_resume_test.go @@ -0,0 +1,35 @@ +package handler + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestOpenAIWSNextAttemptMessageUsesCurrentTurnPayload(t *testing.T) { + firstMessage := []byte(`{"type":"response.create","input":"first"}`) + currentTurn := []byte(`{"type":"response.create","input":"turn-281"}`) + + next, ok := openAIWSNextAttemptMessage(firstMessage, currentTurn, true) + + require.True(t, ok) + require.Equal(t, currentTurn, next) + next[0] = 'x' + require.Equal(t, byte('{'), currentTurn[0], "retry payload must be cloned") +} + +func TestOpenAIWSNextAttemptMessageRejectsMissingCurrentTurnPayload(t *testing.T) { + next, ok := openAIWSNextAttemptMessage([]byte(`{"type":"response.create"}`), nil, true) + + require.False(t, ok) + require.Nil(t, next) +} + +func TestOpenAIWSNextAttemptMessageKeepsInitialMessageForFirstTurnFailover(t *testing.T) { + firstMessage := []byte(`{"type":"response.create","input":"first"}`) + + next, ok := openAIWSNextAttemptMessage(firstMessage, nil, false) + + require.True(t, ok) + require.Equal(t, firstMessage, next) +} diff --git a/backend/internal/handler/openai_live.go b/backend/internal/handler/openai_live.go index 0e4a5af45..1f6b6c49a 100644 --- a/backend/internal/handler/openai_live.go +++ b/backend/internal/handler/openai_live.go @@ -15,6 +15,7 @@ import ( coderws "github.com/coder/websocket" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" + "github.com/tidwall/sjson" "go.uber.org/zap" ) @@ -29,7 +30,7 @@ func (h *OpenAIGatewayHandler) Live(c *gin.Context) { h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found") return } - if apiKey.Group == nil || apiKey.Group.Platform != service.PlatformOpenAI { + if apiKey.Group == nil || (apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite) { h.errorResponse(c, http.StatusNotFound, "not_found_error", "Live is not supported for this platform") return } @@ -43,6 +44,18 @@ func (h *OpenAIGatewayHandler) Live(c *gin.Context) { return } model := strings.TrimSpace(gjson.GetBytes(request.Session, "model").String()) + if !compositeTargetPlatformAllowed(c, apiKey, model, service.PlatformOpenAI) { + h.errorResponse(c, http.StatusNotFound, "not_found_error", "Live only supports OpenAI models for Composite groups") + return + } + if upstreamModel, ok := service.ResolvedUpstreamModelFromContext(c.Request.Context()); ok && upstreamModel != model { + rewrittenSession, rewriteErr := sjson.SetBytes(request.Session, "model", upstreamModel) + if rewriteErr != nil { + h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to apply Composite model route") + return + } + request.Session = rewrittenSession + } reqLog := requestLogger( c, "handler.openai_gateway.live", @@ -231,6 +244,6 @@ func (h *OpenAIGatewayHandler) LiveSideband(c *gin.Context) { func liveEnabledForAPIKey(apiKey *service.APIKey) bool { return apiKey != nil && apiKey.Group != nil && - apiKey.Group.Platform == service.PlatformOpenAI && + (apiKey.Group.Platform == service.PlatformOpenAI || apiKey.Group.Platform == service.PlatformComposite) && apiKey.Group.AllowLive } diff --git a/backend/internal/handler/openai_live_test.go b/backend/internal/handler/openai_live_test.go index 9df49654c..93fc10fff 100644 --- a/backend/internal/handler/openai_live_test.go +++ b/backend/internal/handler/openai_live_test.go @@ -87,6 +87,9 @@ func TestLiveEnabledForAPIKey(t *testing.T) { require.True(t, liveEnabledForAPIKey(&service.APIKey{ Group: &service.Group{Platform: service.PlatformOpenAI, AllowLive: true}, })) + require.True(t, liveEnabledForAPIKey(&service.APIKey{ + Group: &service.Group{Platform: service.PlatformComposite, AllowLive: true}, + })) } func TestLiveAttestationErrorIsExplicit(t *testing.T) { diff --git a/backend/internal/handler/openai_responses_input_tokens_test.go b/backend/internal/handler/openai_responses_input_tokens_test.go new file mode 100644 index 000000000..b3260fc2a --- /dev/null +++ b/backend/internal/handler/openai_responses_input_tokens_test.go @@ -0,0 +1,21 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestResponsesInputTokensIsClassifiedAsTokenCountRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/input_tokens", nil) + + require.True(t, isCountTokensRequest(c)) + require.True(t, isTokenCountRequestPath("/responses/input_tokens")) + require.False(t, isTokenCountRequestPath("/v1/responses")) +} diff --git a/backend/internal/handler/ops_error_logger.go b/backend/internal/handler/ops_error_logger.go index 8be450b16..ce2b9fabd 100644 --- a/backend/internal/handler/ops_error_logger.go +++ b/backend/internal/handler/ops_error_logger.go @@ -1073,6 +1073,7 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { } applyOpsLatencyFieldsFromContext(c, entry) applyOpsUpstreamFieldsFromContext(c, entry) + suppressOpsUpstreamAttributionForLocalModelConfiguration(c, entry) if apiKey != nil { entry.APIKeyID = &apiKey.ID @@ -1249,7 +1250,11 @@ func isCountTokensRequest(c *gin.Context) bool { if c == nil || c.Request == nil || c.Request.URL == nil { return false } - return strings.Contains(c.Request.URL.Path, "/count_tokens") + return isTokenCountRequestPath(c.Request.URL.Path) +} + +func isTokenCountRequestPath(path string) bool { + return strings.Contains(path, "/count_tokens") || strings.Contains(path, "/responses/input_tokens") } func applyOpsLatencyFieldsFromContext(c *gin.Context, entry *service.OpsInsertErrorLogInput) { @@ -1334,6 +1339,19 @@ func applyOpsUpstreamFieldsFromContext(c *gin.Context, entry *service.OpsInsertE } } +func suppressOpsUpstreamAttributionForLocalModelConfiguration(c *gin.Context, entry *service.OpsInsertErrorLogInput) { + if entry == nil || !service.HasOpsClientBusinessLimited(c) || service.OpsClientBusinessLimitedReason(c) != service.OpsClientBusinessLimitedReasonLocalModelConfiguration { + return + } + entry.AccountID = nil + entry.UpstreamEndpoint = "" + entry.UpstreamModel = "" + entry.UpstreamStatusCode = nil + entry.UpstreamErrorMessage = nil + entry.UpstreamErrorDetail = nil + entry.UpstreamErrors = nil +} + func getContextLatencyMs(c *gin.Context, key string) *int64 { if c == nil || strings.TrimSpace(key) == "" { return nil @@ -1546,23 +1564,27 @@ func classifyOpsErrorLog(c *gin.Context, errType, message, code string, status i phase = classifyOpsPhase(errType, message, code) routingCapacityLimited := isOpsRoutingCapacityLimited(c) clientBusinessLimited := service.HasOpsClientBusinessLimited(c) + localModelConfiguration := clientBusinessLimited && service.OpsClientBusinessLimitedReason(c) == service.OpsClientBusinessLimitedReasonLocalModelConfiguration upstreamError := hasOpsUpstreamErrorContext(c) accountAuthFailure := hasOpsAccountAuthFailure(c) - if accountAuthFailure && !routingCapacityLimited { + if localModelConfiguration { + phase = "routing" + } else if accountAuthFailure && !routingCapacityLimited { phase = "account_auth" } else if upstreamError && !routingCapacityLimited { phase = "upstream" } - if clientBusinessLimited && !upstreamError && !routingCapacityLimited { + if clientBusinessLimited && !upstreamError && !routingCapacityLimited && !localModelConfiguration { phase = "auth" } if routingCapacityLimited { phase = "routing" } msg := strings.ToLower(message) - localClientAuthError := !upstreamError && phase == "auth" && isOpsClientAuthError(code, msg) - localBusinessLimited := !upstreamError && classifyOpsIsBusinessLimited(errType, phase, code, status, message, localClientAuthError) - isBusinessLimited = routingCapacityLimited || (clientBusinessLimited && !upstreamError) || localBusinessLimited + effectiveUpstreamError := upstreamError && !localModelConfiguration + localClientAuthError := !effectiveUpstreamError && phase == "auth" && isOpsClientAuthError(code, msg) + localBusinessLimited := !effectiveUpstreamError && classifyOpsIsBusinessLimited(errType, phase, code, status, message, localClientAuthError) + isBusinessLimited = localModelConfiguration || routingCapacityLimited || (clientBusinessLimited && !effectiveUpstreamError) || localBusinessLimited errorOwner = classifyOpsErrorOwner(phase, message) errorSource = classifyOpsErrorSource(phase, message) return phase, isBusinessLimited, errorOwner, errorSource @@ -1774,7 +1796,7 @@ func shouldSkipOpsErrorLog(ctx context.Context, ops *service.OpsService, message bodyLower := strings.ToLower(body) // Check if count_tokens errors should be ignored - if settings.IgnoreCountTokensErrors && strings.Contains(requestPath, "/count_tokens") { + if settings.IgnoreCountTokensErrors && isTokenCountRequestPath(requestPath) { return true } diff --git a/backend/internal/handler/ops_error_logger_test.go b/backend/internal/handler/ops_error_logger_test.go index 3b778cdcf..8b0292860 100644 --- a/backend/internal/handler/ops_error_logger_test.go +++ b/backend/internal/handler/ops_error_logger_test.go @@ -497,6 +497,107 @@ func TestClassifyOpsRoutingCapacityMarkerExcludesMaskedSelectionFailureFromSLA(t require.Equal(t, "gateway", errorSource) } +func TestClassifyOpsLocalModelConfigurationRejection(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration) + + phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog( + c, + "model_not_found", + "Model \"gpt-missing\" is not supported by any configured account in this group", + "", + http.StatusNotFound, + ) + + require.Equal(t, "routing", phase) + require.True(t, isBusinessLimited) + require.Equal(t, "platform", errorOwner) + require.Equal(t, "gateway", errorSource) +} + +func TestClassifyOpsLocalModelConfigurationOverridesStaleUpstreamMarkers(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration) + c.Set(service.OpsUpstreamStatusCodeKey, http.StatusUnauthorized) + c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{ + Stage: string(service.GatewayFailureStageAccountAuth), + UpstreamStatusCode: http.StatusUnauthorized, + }}) + + phase, limited, owner, source := classifyOpsErrorLog(c, "model_not_found", "unsupported configured model", "", http.StatusNotFound) + + require.Equal(t, "routing", phase) + require.True(t, limited) + require.Equal(t, "platform", owner) + require.Equal(t, "gateway", source) +} + +func TestClassifyOpsLocalModelConfigurationRequiresMarkerAndReason(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Set(service.OpsClientBusinessLimitedReasonKey, service.OpsClientBusinessLimitedReasonLocalModelConfiguration) + c.Set(service.OpsUpstreamStatusCodeKey, http.StatusBadGateway) + + phase, limited, owner, source := classifyOpsErrorLog(c, "upstream_error", "provider failed", "", http.StatusBadGateway) + + require.Equal(t, "upstream", phase) + require.False(t, limited) + require.Equal(t, "provider", owner) + require.Equal(t, "upstream_http", source) +} + +func TestOpsErrorLoggerMiddleware_LocalModelConfigurationFields(t *testing.T) { + setupOpsErrorLogTestQueue(t, 1) + gin.SetMode(gin.TestMode) + + ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router := gin.New() + router.Use(OpsErrorLoggerMiddleware(ops)) + router.POST("/v1/chat/completions", func(c *gin.Context) { + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration) + c.Set(opsAccountIDKey, int64(99)) + c.Set(opsUpstreamModelKey, "stale-upstream-model") + setActualUpstreamEndpoint(c, "/v1/chat/completions") + c.Set(service.OpsUpstreamStatusCodeKey, http.StatusUnauthorized) + c.Set(service.OpsUpstreamErrorMessageKey, "stale upstream error") + c.Set(service.OpsUpstreamErrorDetailKey, "stale upstream detail") + c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{ + Stage: string(service.GatewayFailureStageAccountAuth), + UpstreamStatusCode: http.StatusUnauthorized, + Message: "stale auth failure", + }}) + c.JSON(http.StatusNotFound, gin.H{ + "error": gin.H{ + "type": "model_not_found", + "message": "Model \"gpt-missing\" is not supported by any configured account in this group", + }, + }) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusNotFound, w.Code) + require.JSONEq(t, `{"error":{"type":"model_not_found","message":"Model \"gpt-missing\" is not supported by any configured account in this group"}}`, w.Body.String()) + job := <-opsErrorLogQueue + require.Equal(t, http.StatusNotFound, job.entry.StatusCode) + require.Equal(t, "routing", job.entry.ErrorPhase) + require.True(t, job.entry.IsBusinessLimited) + require.Equal(t, "platform", job.entry.ErrorOwner) + require.Equal(t, "gateway", job.entry.ErrorSource) + require.Nil(t, job.entry.AccountID) + require.Nil(t, job.entry.UpstreamStatusCode) + require.Nil(t, job.entry.UpstreamErrors) + require.Nil(t, job.entry.UpstreamErrorMessage) + require.Nil(t, job.entry.UpstreamErrorDetail) + require.Empty(t, job.entry.UpstreamModel) + require.Empty(t, job.entry.UpstreamEndpoint) +} + func TestClassifyOpsAuthClientErrorsExcludedFromSLA(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/model/error_passthrough_rule.go b/backend/internal/model/error_passthrough_rule.go index aa202069b..bb3a31577 100644 --- a/backend/internal/model/error_passthrough_rule.go +++ b/backend/internal/model/error_passthrough_rule.go @@ -1,7 +1,11 @@ // Package model 定义服务层使用的数据模型。 package model -import "time" +import ( + "time" + + "github.com/Wei-Shaw/sub2api/internal/domain" +) // ErrorPassthroughRule 全局错误透传规则 // 用于控制上游错误如何返回给客户端 @@ -32,16 +36,28 @@ const MatchModeAll = "all" // 支持的平台常量 const ( - PlatformAnthropic = "anthropic" - PlatformOpenAI = "openai" - PlatformGemini = "gemini" - PlatformAntigravity = "antigravity" - PlatformGrok = "grok" + PlatformAnthropic = domain.PlatformAnthropic + PlatformOpenAI = domain.PlatformOpenAI + PlatformGemini = domain.PlatformGemini + PlatformAntigravity = domain.PlatformAntigravity + PlatformGrok = domain.PlatformGrok + PlatformKimi = domain.PlatformKimi + PlatformZhipu = domain.PlatformZhipu + PlatformDeepseek = domain.PlatformDeepseek ) // AllPlatforms 返回所有支持的平台列表 func AllPlatforms() []string { - return []string{PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok} + return []string{ + PlatformAnthropic, + PlatformOpenAI, + PlatformGemini, + PlatformAntigravity, + PlatformGrok, + PlatformKimi, + PlatformZhipu, + PlatformDeepseek, + } } // Validate 验证规则配置的有效性 diff --git a/backend/internal/model/error_passthrough_rule_test.go b/backend/internal/model/error_passthrough_rule_test.go new file mode 100644 index 000000000..fc9ff5b38 --- /dev/null +++ b/backend/internal/model/error_passthrough_rule_test.go @@ -0,0 +1,20 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAllPlatformsIncludesEveryConcretePlatform(t *testing.T) { + require.ElementsMatch(t, []string{ + "anthropic", + "openai", + "gemini", + "antigravity", + "grok", + "kimi", + "zhipu", + "deepseek", + }, AllPlatforms()) +} diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index 70dee035d..2b9d69eae 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -17,15 +17,37 @@ const ( type toolOutputMediaByCallID map[string][]ChatContentPart +// ResponsesToChatOptions carries optional hooks for +// ResponsesToChatCompletionsRequestWithOptions. All fields are optional; a nil +// *ResponsesToChatOptions behaves exactly like ResponsesToChatCompletionsRequest. +type ResponsesToChatOptions struct { + // ReasoningContentByID looks up the cached reasoning text for a reasoning + // item id. Codex histories may carry reasoning items with no plaintext + // summary (empty summary + opaque encrypted_content, e.g. after remote + // compaction); DeepSeek's thinking mode rejects such histories with 400 + // "The `reasoning_content` in the thinking mode must be passed back to the + // API". The gateway caches the reasoning text it streamed under the item + // id, so the lookup restores the reasoning_content the client can no + // longer provide. Return "" on a miss. A nil lookup keeps the original + // behavior. + ReasoningContentByID func(itemID string) string +} + // ResponsesToChatCompletionsRequest converts a Responses API request into a // Chat Completions request for upstreams that only implement // /v1/chat/completions. func ResponsesToChatCompletionsRequest(req *ResponsesRequest) (*ChatCompletionsRequest, error) { + return ResponsesToChatCompletionsRequestWithOptions(req, nil) +} + +// ResponsesToChatCompletionsRequestWithOptions is ResponsesToChatCompletionsRequest +// with optional hooks (see ResponsesToChatOptions). +func ResponsesToChatCompletionsRequestWithOptions(req *ResponsesRequest, opts *ResponsesToChatOptions) (*ChatCompletionsRequest, error) { if req == nil { return nil, fmt.Errorf("responses request is nil") } - messages, err := responsesInputToChatMessages(req.Instructions, req.Input) + messages, err := responsesInputToChatMessagesWithOptions(req.Instructions, req.Input, opts) if err != nil { return nil, err } @@ -202,6 +224,12 @@ func HasToolSearchTool(tools []ResponsesTool) bool { // scattered across per-item cases, and makes unknown future codex item types // fail safe instead of leaking into the upstream request. func responsesInputToChatMessages(instructions string, inputRaw json.RawMessage) ([]ChatMessage, error) { + return responsesInputToChatMessagesWithOptions(instructions, inputRaw, nil) +} + +// responsesInputToChatMessagesWithOptions is responsesInputToChatMessages with +// optional hooks (see ResponsesToChatOptions). +func responsesInputToChatMessagesWithOptions(instructions string, inputRaw json.RawMessage, opts *ResponsesToChatOptions) ([]ChatMessage, error) { var messages []ChatMessage if strings.TrimSpace(instructions) != "" { content, _ := json.Marshal(instructions) @@ -226,7 +254,7 @@ func responsesInputToChatMessages(instructions string, inputRaw json.RawMessage) return nil, fmt.Errorf("parse responses input: %w", err) } - built, mediaByCallID, err := buildChatMessagesFromItems(messages, rawItems) + built, mediaByCallID, err := buildChatMessagesFromItems(messages, rawItems, opts) if err != nil { return nil, err } @@ -235,7 +263,7 @@ func responsesInputToChatMessages(instructions string, inputRaw json.RawMessage) // buildChatMessagesFromItems walks the Responses input items and appends the // corresponding Chat messages. -func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessage) ([]ChatMessage, toolOutputMediaByCallID, error) { +func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessage, opts *ResponsesToChatOptions) ([]ChatMessage, toolOutputMediaByCallID, error) { // pendingReasoning holds the reasoning text from a reasoning item until the // assistant message it belongs to is emitted. DeepSeek's thinking mode // requires the reasoning_content that produced a tool call to be passed back @@ -243,8 +271,22 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa // across an assistant message (so a following tool call in the same turn // still receives it); any other role ends the thinking span. var pendingReasoning string + // lastTurnReasoning is the most recent reasoning text of the current turn, + // surviving tool outputs. DeepSeek emits reasoning only once per turn, so + // chained tool calls (reasoning → call A → output A → call B) leave call B's + // assistant message without reasoning_content and DeepSeek 400s the history; + // replaying the turn's reasoning on B's message satisfies the contract. Only + // a user-side item ends the turn and clears it. + var lastTurnReasoning string mediaByCallID := make(toolOutputMediaByCallID) + reasoningForAssistant := func() string { + if pendingReasoning != "" { + return pendingReasoning + } + return lastTurnReasoning + } + for _, raw := range rawItems { raw = bytesTrimSpace(raw) if len(raw) == 0 || string(raw) == "null" { @@ -258,6 +300,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa content, _ := json.Marshal(text) messages = append(messages, ChatMessage{Role: "user", Content: content}) pendingReasoning = "" + lastTurnReasoning = "" continue } return nil, nil, fmt.Errorf("parse responses input item: %w", err) @@ -269,6 +312,18 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa case "reasoning": if txt := extractResponsesReasoningText(item); txt != "" { pendingReasoning = txt + } else if opts != nil && opts.ReasoningContentByID != nil { + // No plaintext summary (encrypted-only reasoning, e.g. after codex + // remote compaction): fall back to the gateway-side cache keyed + // by the reasoning item id, which always round-trips in history. + if id := rawString(item["id"]); id != "" { + if cached := opts.ReasoningContentByID(id); cached != "" { + pendingReasoning = cached + } + } + } + if pendingReasoning != "" { + lastTurnReasoning = pendingReasoning } continue case "function_call": @@ -290,7 +345,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa Arguments: arguments, }, } - messages = appendAssistantToolCall(messages, toolCall, pendingReasoning) + messages = appendAssistantToolCall(messages, toolCall, reasoningForAssistant()) pendingReasoning = "" continue case "tool_search_call": @@ -311,7 +366,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa Arguments: arguments, }, } - messages = appendAssistantToolCall(messages, toolCall, pendingReasoning) + messages = appendAssistantToolCall(messages, toolCall, reasoningForAssistant()) pendingReasoning = "" continue case "custom_tool_call": @@ -327,7 +382,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa Arguments: string(arguments), }, } - messages = appendAssistantToolCall(messages, toolCall, pendingReasoning) + messages = appendAssistantToolCall(messages, toolCall, reasoningForAssistant()) pendingReasoning = "" continue case "function_call_output", "custom_tool_call_output", "tool_search_output": @@ -359,6 +414,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa content, _ := json.Marshal(rawString(item["text"])) messages = append(messages, ChatMessage{Role: "user", Content: content}) pendingReasoning = "" + lastTurnReasoning = "" continue case "input_image": content, err := chatContentFromSingleResponsesPart(itemType, item) @@ -367,6 +423,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa } messages = append(messages, ChatMessage{Role: "user", Content: content}) pendingReasoning = "" + lastTurnReasoning = "" continue } @@ -391,11 +448,22 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa if err != nil { return nil, nil, err } - messages = append(messages, ChatMessage{Role: role, Content: chatContent}) - // Reasoning only survives across an assistant text message. - if role != "assistant" { + msg := ChatMessage{Role: role, Content: chatContent} + // DeepSeek thinking mode requires the reasoning_content from a prior + // reasoning-only / plain-text assistant turn to be passed back on its + // assistant message; dropping it yields 400 "The `reasoning_content` in + // the thinking mode must be passed back to the API" on the next turn. + // A following function_call in the same turn still receives it because + // appendAssistantToolCall merges into this message and only fills + // ReasoningContent when it is still empty. + if role == "assistant" { + msg.ReasoningContent = reasoningForAssistant() pendingReasoning = "" + } else { + pendingReasoning = "" + lastTurnReasoning = "" } + messages = append(messages, msg) } return messages, mediaByCallID, nil @@ -687,6 +755,26 @@ func extractResponsesReasoningText(item map[string]json.RawMessage) string { return strings.Join(parts, "\n") } +// ExtractResponsesReasoningItem parses a raw Responses input item and, when it +// is a reasoning item, returns its id and extractable plaintext (summary +// preferred, content fallback). ok is false for non-reasoning items. It exists +// for the gateway-side reasoning cache: items with plaintext get (re)cached so +// later encrypted-only replicas of the same item id can be restored. +func ExtractResponsesReasoningItem(raw json.RawMessage) (id string, text string, ok bool) { + raw = bytesTrimSpace(raw) + if len(raw) == 0 || string(raw) == "null" { + return "", "", false + } + var item map[string]json.RawMessage + if err := json.Unmarshal(raw, &item); err != nil { + return "", "", false + } + if rawString(item["type"]) != "reasoning" { + return "", "", false + } + return rawString(item["id"]), extractResponsesReasoningText(item), true +} + func chatCompletionsBridgeRole(role string) string { trimmed := strings.TrimSpace(role) if trimmed == "" { diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_reasoning_cache_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_reasoning_cache_test.go new file mode 100644 index 000000000..dacedfc61 --- /dev/null +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_reasoning_cache_test.go @@ -0,0 +1,161 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// Encrypted-only reasoning items (empty summary + opaque encrypted_content, +// e.g. after codex remote compaction) carry no plaintext the bridge can map to +// reasoning_content. The gateway-side cache keyed by reasoning item id restores +// it; without the restore, DeepSeek thinking mode rejects the history with 400 +// "The `reasoning_content` in the thinking mode must be passed back to the API". +func TestResponsesToChat_ReasoningCacheLookup_RestoresEncryptedOnlyItem(t *testing.T) { + req := &ResponsesRequest{ + Model: "deepseek-reasoner", + Input: json.RawMessage(`[ + {"type":"reasoning","id":"item_enc1","summary":[],"encrypted_content":"opaque"}, + {"type":"function_call","call_id":"call_1","name":"get_value","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_1","output":"ok"}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]} + ]`), + } + + out, err := ResponsesToChatCompletionsRequestWithOptions(req, &ResponsesToChatOptions{ + ReasoningContentByID: func(itemID string) string { + if itemID == "item_enc1" { + return "cached thinking" + } + return "" + }, + }) + require.NoError(t, err) + require.Len(t, out.Messages, 3) + require.Equal(t, "assistant", out.Messages[0].Role) + require.Equal(t, "cached thinking", out.Messages[0].ReasoningContent) + require.Len(t, out.Messages[0].ToolCalls, 1) + require.Equal(t, "call_1", out.Messages[0].ToolCalls[0].ID) + require.Equal(t, "tool", out.Messages[1].Role) + require.Equal(t, "user", out.Messages[2].Role) +} + +// A cache miss keeps the original behavior: no reasoning_content, no error. +func TestResponsesToChat_ReasoningCacheLookup_MissKeepsOriginalBehavior(t *testing.T) { + req := &ResponsesRequest{ + Model: "deepseek-reasoner", + Input: json.RawMessage(`[ + {"type":"reasoning","id":"item_unknown","summary":[],"encrypted_content":"opaque"}, + {"type":"function_call","call_id":"call_1","name":"get_value","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_1","output":"ok"}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]} + ]`), + } + + out, err := ResponsesToChatCompletionsRequestWithOptions(req, &ResponsesToChatOptions{ + ReasoningContentByID: func(string) string { return "" }, + }) + require.NoError(t, err) + require.Len(t, out.Messages, 3) + require.Empty(t, out.Messages[0].ReasoningContent) + + // Nil options (legacy path) behaves identically. + legacy, err := ResponsesToChatCompletionsRequest(req) + require.NoError(t, err) + require.Equal(t, out.Messages, legacy.Messages) +} + +// Plaintext summary wins and the cache lookup is not consulted. +func TestResponsesToChat_ReasoningCacheLookup_PlaintextPreferred(t *testing.T) { + req := &ResponsesRequest{ + Model: "deepseek-reasoner", + Input: json.RawMessage(`[ + {"type":"reasoning","id":"item_plain","summary":[{"type":"summary_text","text":"plain thinking"}]}, + {"type":"function_call","call_id":"call_1","name":"get_value","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_1","output":"ok"}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]} + ]`), + } + + lookupCalled := false + out, err := ResponsesToChatCompletionsRequestWithOptions(req, &ResponsesToChatOptions{ + ReasoningContentByID: func(string) string { + lookupCalled = true + return "cached thinking" + }, + }) + require.NoError(t, err) + require.Len(t, out.Messages, 3) + require.Equal(t, "plain thinking", out.Messages[0].ReasoningContent) + require.False(t, lookupCalled, "plaintext summary present → cache lookup must not run") +} + +// DeepSeek emits reasoning only once per turn; chained tool calls +// (reasoning → call A → output A → call B) have no reasoning item before call +// B. The turn's reasoning must be replayed on B's assistant message, otherwise +// DeepSeek thinking mode 400s the history ("reasoning_content ... must be +// passed back"). Reproduced from a real codex 0.147.0 resume history. +func TestResponsesToChat_ChainedToolCallsReplayTurnReasoning(t *testing.T) { + req := &ResponsesRequest{ + Model: "deepseek-reasoner", + Input: json.RawMessage(`[ + {"type":"reasoning","id":"item_r1","summary":[{"type":"summary_text","text":"turn thinking"}]}, + {"type":"message","role":"assistant","content":[{"type":"output_text","text":"\n\n"}]}, + {"type":"function_call","call_id":"call_a","name":"exec_command","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_a","output":"ok"}, + {"type":"function_call","call_id":"call_b","name":"exec_command","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_b","output":"ok"}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"next"}]}, + {"type":"reasoning","id":"item_r2","summary":[{"type":"summary_text","text":"second turn"}]}, + {"type":"function_call","call_id":"call_c","name":"exec_command","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_c","output":"ok"} + ]`), + } + + out, err := ResponsesToChatCompletionsRequest(req) + require.NoError(t, err) + + byCallID := map[string]ChatMessage{} + for _, m := range out.Messages { + for _, tc := range m.ToolCalls { + byCallID[tc.ID] = m + } + } + require.Len(t, byCallID, 3) + require.Equal(t, "turn thinking", byCallID["call_a"].ReasoningContent) + require.Equal(t, "turn thinking", byCallID["call_b"].ReasoningContent, + "链式第二个工具调用必须回放本轮 reasoning") + require.Equal(t, "second turn", byCallID["call_c"].ReasoningContent, + "user 消息后开启新轮次,不得沿用上一轮 reasoning") + + // 每一条 assistant 消息都必须带 reasoning_content(DeepSeek 契约)。 + for i, m := range out.Messages { + if m.Role == "assistant" { + require.NotEmpty(t, m.ReasoningContent, "messages[%d] 缺 reasoning_content", i) + } + } +} + +func TestExtractResponsesReasoningItem(t *testing.T) { + id, text, ok := ExtractResponsesReasoningItem(json.RawMessage( + `{"type":"reasoning","id":"item_a","summary":[{"type":"summary_text","text":"think"}]}`)) + require.True(t, ok) + require.Equal(t, "item_a", id) + require.Equal(t, "think", text) + + // Encrypted-only item: ok with id but empty text. + id, text, ok = ExtractResponsesReasoningItem(json.RawMessage( + `{"type":"reasoning","id":"item_b","summary":[],"encrypted_content":"opaque"}`)) + require.True(t, ok) + require.Equal(t, "item_b", id) + require.Empty(t, text) + + // Non-reasoning items are skipped. + _, _, ok = ExtractResponsesReasoningItem(json.RawMessage( + `{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}`)) + require.False(t, ok) + + _, _, ok = ExtractResponsesReasoningItem(json.RawMessage(`"bare string"`)) + require.False(t, ok) +} diff --git a/backend/internal/pkg/apicompat/responses_client_tools.go b/backend/internal/pkg/apicompat/responses_client_tools.go index c4b1e0018..b7ce024e8 100644 --- a/backend/internal/pkg/apicompat/responses_client_tools.go +++ b/backend/internal/pkg/apicompat/responses_client_tools.go @@ -27,6 +27,13 @@ func AdaptResponsesClientTools(req map[string]any) (ResponsesClientToolMapping, if !ok || len(tools) == 0 { return ResponsesClientToolMapping{}, false, nil } + discovered, err := promoteResponsesToolSearchDiscoveries(req) + if err != nil { + return ResponsesClientToolMapping{}, false, err + } + if discovered { + tools, _ = req["tools"].([]any) + } adapter := ResponsesClientToolMapping{CustomTools: make(map[string]bool)} functionNames := make(map[string]bool) @@ -73,7 +80,7 @@ func AdaptResponsesClientTools(req map[string]any) (ResponsesClientToolMapping, tools, _ = req["tools"].([]any) lowered := make([]any, 0, len(tools)) - changed := flattened + changed := discovered || flattened seenSearch := false for _, raw := range tools { tool, ok := raw.(map[string]any) @@ -115,7 +122,11 @@ func AdaptResponsesClientTools(req map[string]any) (ResponsesClientToolMapping, if changed { req["tools"] = lowered } - if rewriteClientToolHistory(req["input"], &adapter) { + historyChanged, err := rewriteClientToolHistory(req["input"], &adapter) + if err != nil { + return ResponsesClientToolMapping{}, false, err + } + if historyChanged { changed = true } if rewriteClientToolChoice(req, &adapter) { @@ -130,6 +141,50 @@ func AdaptResponsesClientTools(req map[string]any) (ResponsesClientToolMapping, return adapter, changed, nil } +// AdaptResponsesClientToolsWithInheritedMapping lowers client-tool history on +// a follow-up request that omits the session-level tools declaration. An +// explicitly present tools field, including an empty or malformed value, +// always replaces the inherited mapping and is handled by the ordinary +// declaration-driven adapter. +func AdaptResponsesClientToolsWithInheritedMapping( + req map[string]any, + inherited ResponsesClientToolMapping, + inheritedLoweredTools ...[]any, +) (ResponsesClientToolMapping, bool, error) { + if req == nil { + return ResponsesClientToolMapping{}, false, nil + } + if _, toolsPresent := req["tools"]; toolsPresent { + return AdaptResponsesClientTools(req) + } + if len(inherited.CustomTools) == 0 && !inherited.ToolSearch && len(inherited.NamespaceTools) == 0 { + return ResponsesClientToolMapping{}, false, nil + } + if len(inheritedLoweredTools) > 0 && len(inheritedLoweredTools[0]) > 0 { + req["tools"] = restoreInheritedResponsesClientToolDeclarations(inheritedLoweredTools[0], inherited) + return AdaptResponsesClientTools(req) + } + + changed, err := rewriteClientToolHistory(req["input"], &inherited) + if err != nil { + return ResponsesClientToolMapping{}, false, err + } + if len(inherited.NamespaceTools) > 0 { + before := changed + rewriteNamespaceQualifiedCalls(req["input"], inherited.NamespaceTools) + // Namespace rewriting does not currently report whether it changed a + // value. A retained namespace mapping is only used for follow-up + // history, so conservatively rebuild the request when input exists. + if _, inputPresent := req["input"]; inputPresent && !before { + changed = true + } + } + if rewriteClientToolChoice(req, &inherited) { + changed = true + } + return inherited, changed, nil +} + func copyClientTool(tool map[string]any) map[string]any { copy := make(map[string]any, len(tool)) for key, value := range tool { @@ -138,14 +193,16 @@ func copyClientTool(tool map[string]any) map[string]any { return copy } -func rewriteClientToolHistory(value any, adapter *ResponsesClientToolMapping) bool { +func rewriteClientToolHistory(value any, adapter *ResponsesClientToolMapping) (bool, error) { changed := false - var visit func(any) - visit = func(value any) { + var visit func(any) error + visit = func(value any) error { switch typed := value.(type) { case []any: for _, item := range typed { - visit(item) + if err := visit(item); err != nil { + return err + } } case map[string]any: typ := strings.TrimSpace(stringValue(typed["type"])) @@ -155,10 +212,12 @@ func rewriteClientToolHistory(value any, adapter *ResponsesClientToolMapping) bo typed["type"] = "function_call" typed["arguments"] = customToolCallArguments(stringValue(typed["input"])) delete(typed, "input") + dropInvalidLoweredFunctionItemID(typed) changed = true } case "custom_tool_call_output": typed["type"] = "function_call_output" + dropInvalidLoweredFunctionItemID(typed) normalizeClientToolOutput(typed) changed = true case "tool_search_call": @@ -167,22 +226,46 @@ func rewriteClientToolHistory(value any, adapter *ResponsesClientToolMapping) bo typed["name"] = toolSearchProxyName typed["arguments"] = rawObjectString(typed["arguments"]) delete(typed, "execution") + dropInvalidLoweredFunctionItemID(typed) changed = true } case "tool_search_output": if adapter.ToolSearch { + callID := strings.TrimSpace(stringValue(typed["call_id"])) + if callID == "" { + return fmt.Errorf("tool_search_output requires a non-empty string call_id before it can be lowered to function_call_output") + } typed["type"] = "function_call_output" - normalizeClientToolOutput(typed) + dropInvalidLoweredFunctionItemID(typed) + if err := normalizeToolSearchOutput(typed); err != nil { + return err + } changed = true } } for _, child := range typed { - visit(child) + if err := visit(child); err != nil { + return err + } } } + return nil + } + if err := visit(value); err != nil { + return false, err + } + return changed, nil +} + +// dropInvalidLoweredFunctionItemID removes Codex client-only item IDs such as +// ctc_*, ctco_*, tsc_*, and tso_* after their item type is lowered to the +// function protocol. Function upstreams validate these IDs with the fc prefix; +// call_id, which is preserved separately, is the tool call/output pairing key. +func dropInvalidLoweredFunctionItemID(item map[string]any) { + id := strings.TrimSpace(stringValue(item["id"])) + if id != "" && !strings.HasPrefix(id, "fc") { + delete(item, "id") } - visit(value) - return changed } func normalizeClientToolOutput(item map[string]any) { @@ -205,6 +288,47 @@ func normalizeClientToolOutput(item map[string]any) { item["output"] = string(encoded) } +// normalizeToolSearchOutput converts both tool_search output wire shapes into +// the string output required by function_call_output. Older clients send an +// output field directly; newer Codex clients return discovered definitions in +// a top-level tools field. Codex treats that field's value as the tool output, +// so serialize the value directly rather than wrapping it in another object. +func normalizeToolSearchOutput(item map[string]any) error { + if output, hasOutput := item["output"]; hasOutput { + switch typed := output.(type) { + case string: + item["output"] = typed + case nil: + item["output"] = "" + default: + encoded, err := json.Marshal(typed) + if err != nil { + return fmt.Errorf("tool_search_output output cannot be encoded as function_call_output output: %w", err) + } + item["output"] = string(encoded) + } + dropToolSearchOutputPrivateFields(item) + return nil + } + tools, hasTools := item["tools"] + if !hasTools { + return fmt.Errorf("tool_search_output requires output or tools before it can be lowered to function_call_output") + } + encoded, err := json.Marshal(tools) + if err != nil { + return fmt.Errorf("tool_search_output tools cannot be encoded as function_call_output output: %w", err) + } + item["output"] = string(encoded) + dropToolSearchOutputPrivateFields(item) + return nil +} + +func dropToolSearchOutputPrivateFields(item map[string]any) { + delete(item, "tools") + delete(item, "status") + delete(item, "execution") +} + func rewriteClientToolChoice(req map[string]any, adapter *ResponsesClientToolMapping) bool { choice, ok := req["tool_choice"].(map[string]any) if !ok { diff --git a/backend/internal/pkg/apicompat/responses_client_tools_test.go b/backend/internal/pkg/apicompat/responses_client_tools_test.go index cc1ed467b..5b0dac435 100644 --- a/backend/internal/pkg/apicompat/responses_client_tools_test.go +++ b/backend/internal/pkg/apicompat/responses_client_tools_test.go @@ -17,10 +17,10 @@ func TestAdaptResponsesClientTools_LowersDeclarationsHistoryChoiceAndNamespaces( }, "tool_choice": map[string]any{"type": "custom", "name": "exec"}, "input": []any{ - map[string]any{"type": "custom_tool_call", "call_id": "c1", "name": "exec", "input": "dir"}, - map[string]any{"type": "custom_tool_call_output", "call_id": "c1", "output": "ok"}, - map[string]any{"type": "tool_search_call", "call_id": "s1", "arguments": map[string]any{"query": "git"}}, - map[string]any{"type": "tool_search_output", "call_id": "s1", "output": map[string]any{"groups": []string{"git"}}}, + map[string]any{"type": "custom_tool_call", "id": "ctc_client", "call_id": "c1", "name": "exec", "input": "dir"}, + map[string]any{"type": "custom_tool_call_output", "id": "ctco_client", "call_id": "c1", "output": "ok"}, + map[string]any{"type": "tool_search_call", "id": "tsc_client", "call_id": "s1", "arguments": map[string]any{"query": "git"}}, + map[string]any{"type": "tool_search_output", "id": "tso_client", "call_id": "s1", "output": map[string]any{"groups": []string{"git"}}}, map[string]any{"type": "function_call", "call_id": "n1", "namespace": "team", "name": "send", "arguments": "{}"}, }, } @@ -48,20 +48,343 @@ func TestAdaptResponsesClientTools_LowersDeclarationsHistoryChoiceAndNamespaces( input := requireResponsesClientToolValue[[]any](t, req["input"]) customCall := requireResponsesClientToolValue[map[string]any](t, input[0]) require.Equal(t, "function_call", customCall["type"]) + require.NotContains(t, customCall, "id") require.JSONEq(t, `{"input":"dir"}`, requireResponsesClientToolValue[string](t, customCall["arguments"])) customOutput := requireResponsesClientToolValue[map[string]any](t, input[1]) require.Equal(t, "function_call_output", customOutput["type"]) + require.NotContains(t, customOutput, "id") searchCall := requireResponsesClientToolValue[map[string]any](t, input[2]) require.Equal(t, "function_call", searchCall["type"]) + require.NotContains(t, searchCall, "id") require.Equal(t, toolSearchProxyName, searchCall["name"]) require.JSONEq(t, `{"query":"git"}`, requireResponsesClientToolValue[string](t, searchCall["arguments"])) searchOutput := requireResponsesClientToolValue[map[string]any](t, input[3]) require.Equal(t, "function_call_output", searchOutput["type"]) + require.NotContains(t, searchOutput, "id") require.JSONEq(t, `{"groups":["git"]}`, requireResponsesClientToolValue[string](t, searchOutput["output"])) namespaceCall := requireResponsesClientToolValue[map[string]any](t, input[4]) require.Equal(t, "team__send", namespaceCall["name"]) } +func TestAdaptResponsesClientTools_LowersDiscoveredToolSearchOutput(t *testing.T) { + requestJSON := `{ + "tools":[{"type":"tool_search"}], + "input":[ + {"type":"tool_search_call","id":"tsc_client","call_id":"call_search","arguments":{"query":"codex app"},"execution":"client","status":"completed"}, + {"type":"tool_search_output","id":"tso_client","call_id":"call_search","execution":"client","status":"completed","tools":[ + {"type":"namespace","name":"codex_app","tools":[{"type":"function","name":"load_workspace_dependencies","description":"Load workspace dependencies","parameters":{"type":"object","properties":{},"additionalProperties":false}}]}, + {"type":"namespace","name":"multi_agent_v1","tools":[ + {"type":"function","name":"spawn_agent","description":"Spawn an agent","parameters":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false}}, + {"type":"function","name":"wait_agent","description":"Wait for agents","parameters":{"type":"object","properties":{},"additionalProperties":false}} + ]} + ]} + ] + }` + + type adaptedRequest struct { + req map[string]any + mapping ResponsesClientToolMapping + } + adapt := func() adaptedRequest { + var req map[string]any + require.NoError(t, json.Unmarshal([]byte(requestJSON), &req)) + mapping, changed, err := AdaptResponsesClientTools(req) + require.NoError(t, err) + require.True(t, changed) + return adaptedRequest{req: req, mapping: mapping} + } + + first := adapt() + second := adapt() + firstInput := requireResponsesClientToolValue[[]any](t, first.req["input"]) + secondInput := requireResponsesClientToolValue[[]any](t, second.req["input"]) + + tools := requireResponsesClientToolValue[[]any](t, first.req["tools"]) + require.Len(t, tools, 4) + require.Equal(t, []string{ + "tool_search", + "codex_app__load_workspace_dependencies", + "multi_agent_v1__spawn_agent", + "multi_agent_v1__wait_agent", + }, responsesClientToolNames(t, tools)) + require.Equal(t, ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "spawn_agent"}, first.mapping.NamespaceTools["multi_agent_v1__spawn_agent"]) + require.Equal(t, ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "wait_agent"}, first.mapping.NamespaceTools["multi_agent_v1__wait_agent"]) + + call := requireResponsesClientToolValue[map[string]any](t, firstInput[0]) + require.Equal(t, "function_call", call["type"]) + require.Equal(t, toolSearchProxyName, call["name"]) + require.JSONEq(t, `{"query":"codex app"}`, requireResponsesClientToolValue[string](t, call["arguments"])) + require.NotContains(t, call, "execution") + + output := requireResponsesClientToolValue[map[string]any](t, firstInput[1]) + require.Equal(t, map[string]any{ + "type": "function_call_output", + "call_id": "call_search", + "output": output["output"], + }, output) + outputText := requireResponsesClientToolValue[string](t, output["output"]) + require.JSONEq(t, `[ + {"type":"namespace","name":"codex_app","tools":[{"type":"function","name":"load_workspace_dependencies","description":"Load workspace dependencies","parameters":{"type":"object","properties":{},"additionalProperties":false}}]}, + {"type":"namespace","name":"multi_agent_v1","tools":[ + {"type":"function","name":"spawn_agent","description":"Spawn an agent","parameters":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false}}, + {"type":"function","name":"wait_agent","description":"Wait for agents","parameters":{"type":"object","properties":{},"additionalProperties":false}} + ]} + ]`, outputText) + secondOutput := requireResponsesClientToolValue[map[string]any](t, secondInput[1]) + require.Equal(t, outputText, secondOutput["output"], "tool discovery output encoding must be deterministic") + + restored, changed, err := RestoreResponsesClientToolPayload( + []byte(`{"output":[{"type":"function_call","name":"multi_agent_v1__spawn_agent","call_id":"call_spawn","arguments":"{\"message\":\"work\"}"}]}`), + first.mapping, + ) + require.NoError(t, err) + require.True(t, changed) + require.JSONEq(t, `{"output":[{"type":"function_call","name":"spawn_agent","namespace":"multi_agent_v1","call_id":"call_spawn","arguments":"{\"message\":\"work\"}"}]}`, string(restored)) +} + +func TestAdaptResponsesClientTools_PromotesDirectDiscoveryAndDeduplicatesIdenticalDeclarations(t *testing.T) { + direct := map[string]any{ + "type": "function", "name": "inspect_result", "description": "Inspect a result", + "parameters": map[string]any{"type": "object", "properties": map[string]any{}}, + } + custom := map[string]any{ + "type": "custom", "name": "run_script", "description": "Run a script", + "format": map[string]any{"type": "grammar"}, + } + namespace := map[string]any{ + "type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{ + "type": "function", "name": "spawn_agent", "parameters": map[string]any{"type": "object"}, + }}, + } + req := map[string]any{ + "tools": []any{ + map[string]any{"type": "function", "name": "static_first", "parameters": map[string]any{"type": "object"}}, + map[string]any{"type": "tool_search"}, + }, + "input": []any{ + map[string]any{"type": "tool_search_output", "status": "completed", "call_id": "search_1", "tools": []any{direct, custom, namespace}}, + map[string]any{"type": "tool_search_output", "status": "completed", "call_id": "search_2", "tools": []any{copyClientTool(direct), copyClientTool(custom), copyClientTool(namespace)}}, + }, + } + + mapping, changed, err := AdaptResponsesClientTools(req) + require.NoError(t, err) + require.True(t, changed) + require.True(t, mapping.CustomTools["run_script"]) + require.Equal(t, ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "spawn_agent"}, mapping.NamespaceTools["multi_agent_v1__spawn_agent"]) + tools := requireResponsesClientToolValue[[]any](t, req["tools"]) + require.Equal(t, []string{"static_first", "tool_search", "inspect_result", "run_script", "multi_agent_v1__spawn_agent"}, responsesClientToolNames(t, tools)) + customTool := requireResponsesClientToolValue[map[string]any](t, tools[3]) + require.Equal(t, "function", customTool["type"]) + require.NotContains(t, customTool, "format") + for _, raw := range requireResponsesClientToolValue[[]any](t, req["input"]) { + item := requireResponsesClientToolValue[map[string]any](t, raw) + require.Equal(t, "function_call_output", item["type"]) + require.NotContains(t, item, "tools") + require.NotContains(t, item, "status") + } +} + +func TestAdaptResponsesClientTools_RejectsDiscoveredSchemaAndNamespaceCollisions(t *testing.T) { + tests := []struct { + name string + staticTools []any + discovered []any + }{ + { + name: "direct schema collision", + staticTools: []any{map[string]any{ + "type": "function", "name": "inspect", "parameters": map[string]any{"type": "object"}, + }}, + discovered: []any{map[string]any{ + "type": "function", "name": "inspect", "parameters": map[string]any{"type": "string"}, + }}, + }, + { + name: "namespace schema collision", + staticTools: []any{map[string]any{ + "type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{ + "type": "function", "name": "spawn_agent", "parameters": map[string]any{"type": "object"}, + }}, + }}, + discovered: []any{map[string]any{ + "type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{ + "type": "function", "name": "spawn_agent", "parameters": map[string]any{"type": "string"}, + }}, + }}, + }, + { + name: "flattened namespace collision", + staticTools: []any{map[string]any{"type": "function", "name": "multi_agent_v1__spawn_agent"}}, + discovered: []any{map[string]any{ + "type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{"type": "function", "name": "spawn_agent"}}, + }}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := map[string]any{ + "tools": append(tt.staticTools, map[string]any{"type": "tool_search"}), + "input": []any{map[string]any{ + "type": "tool_search_output", "status": "completed", "tools": tt.discovered, + }}, + } + _, _, err := AdaptResponsesClientTools(req) + require.ErrorContains(t, err, "conflicts") + }) + } +} + +func TestAdaptResponsesClientTools_DoesNotPromoteUnusableDiscoveries(t *testing.T) { + req := map[string]any{ + "tools": []any{map[string]any{"type": "tool_search"}}, + "input": []any{ + map[string]any{"type": "tool_search_output", "call_id": "search_in_progress", "status": "in_progress", "tools": []any{map[string]any{"type": "function", "name": "not_ready"}}}, + map[string]any{"type": "tool_search_output", "call_id": "search_malformed", "status": "completed", "tools": []any{map[string]any{"type": "function"}}}, + }, + } + + _, changed, err := AdaptResponsesClientTools(req) + require.NoError(t, err) + require.True(t, changed, "the static tool_search declaration is still lowered") + tools := requireResponsesClientToolValue[[]any](t, req["tools"]) + require.Equal(t, []string{"tool_search"}, responsesClientToolNames(t, tools)) +} + +func responsesClientToolNames(t *testing.T, tools []any) []string { + t.Helper() + names := make([]string, 0, len(tools)) + for _, raw := range tools { + tool := requireResponsesClientToolValue[map[string]any](t, raw) + names = append(names, requireResponsesClientToolValue[string](t, tool["name"])) + } + return names +} + +func TestAdaptResponsesClientTools_ToolSearchOutputEdgeCases(t *testing.T) { + unencodableOutput := make(chan struct{}) + tests := []struct { + name string + item map[string]any + wantOutput any + wantOutputExists bool + wantPrivateKeys []string + wantExactOutput bool + wantErr bool + }{ + { + name: "absent tools and output is rejected", + item: map[string]any{"type": "tool_search_output", "call_id": "call_empty", "status": "completed"}, + wantOutputExists: false, + wantErr: true, + }, + { + name: "preexisting string output wins", + item: map[string]any{ + "type": "tool_search_output", "call_id": "call_legacy", "output": "legacy", + "tools": []any{map[string]any{"type": "function", "name": "ignored"}}, "execution": "client", + }, + wantOutput: "legacy", + wantOutputExists: true, + wantExactOutput: true, + }, + { + name: "preexisting object output remains legacy representation", + item: map[string]any{ + "type": "tool_search_output", "call_id": "call_object", "output": map[string]any{"groups": []any{"github"}}, + "tools": []any{map[string]any{"type": "function", "name": "ignored"}}, + }, + wantOutput: `{"groups":["github"]}`, + wantOutputExists: true, + wantExactOutput: true, + }, + { + name: "unencodable preexisting output is rejected", + item: map[string]any{ + "type": "tool_search_output", "call_id": "call_bad_output", "output": unencodableOutput, + "tools": []any{map[string]any{"type": "function", "name": "retained"}}, "status": "completed", "execution": "client", + }, + wantOutput: unencodableOutput, + wantErr: true, + }, + { + name: "empty tools array is a valid empty output", + item: map[string]any{ + "type": "tool_search_output", "call_id": "call_empty_tools", + "tools": []any{}, "status": "completed", "execution": "client", + }, + wantOutput: `[]`, + wantOutputExists: true, + wantExactOutput: true, + }, + { + name: "non-array tools value is serialized directly", + item: map[string]any{ + "type": "tool_search_output", "call_id": "call_malformed", + "tools": map[string]any{"unexpected": true}, "status": "completed", "execution": "client", + }, + wantOutput: `{"unexpected":true}`, + wantOutputExists: true, + wantExactOutput: true, + }, + { + name: "unencodable tools is rejected", + item: map[string]any{ + "type": "tool_search_output", "call_id": "call_unencodable", "tools": make(chan struct{}), "status": "completed", + }, + wantErr: true, + }, + { + name: "missing call id is rejected", + item: map[string]any{ + "type": "tool_search_output", "tools": []any{}, "status": "completed", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := map[string]any{ + "tools": []any{map[string]any{"type": "tool_search"}}, + "input": []any{tt.item}, + } + _, changed, err := AdaptResponsesClientTools(req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.True(t, changed) + input := requireResponsesClientToolValue[[]any](t, req["input"]) + output := requireResponsesClientToolValue[map[string]any](t, input[0]) + require.Equal(t, "function_call_output", output["type"]) + actualOutput, outputExists := output["output"] + require.Equal(t, tt.wantOutputExists, outputExists) + if tt.wantOutputExists { + require.Equal(t, tt.wantOutput, actualOutput) + } + if tt.wantExactOutput { + require.Equal(t, map[string]any{ + "type": "function_call_output", + "call_id": output["call_id"], + "output": tt.wantOutput, + }, output) + } + if len(tt.wantPrivateKeys) > 0 { + for _, key := range tt.wantPrivateKeys { + require.Contains(t, output, key) + } + } else { + require.NotContains(t, output, "tools") + require.NotContains(t, output, "status") + require.NotContains(t, output, "execution") + } + }) + } +} + func requireResponsesClientToolValue[T any](t *testing.T, value any) T { t.Helper() typed, ok := value.(T) @@ -81,6 +404,122 @@ func TestAdaptResponsesClientTools_RejectsAmbiguousNames(t *testing.T) { } } +func TestAdaptResponsesClientToolsWithInheritedMapping_LowersFollowupHistoryWithoutTools(t *testing.T) { + req := map[string]any{ + "input": []any{ + map[string]any{ + "type": "custom_tool_call", "name": "exec", + "call_id": "call_1", "input": "pwd", + }, + map[string]any{ + "type": "custom_tool_call_output", "call_id": "call_1", + "id": "ctco_client_output_1", + "output": []any{map[string]any{"type": "input_text", "text": "ok"}}, + }, + }, + } + inherited := ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}} + + mapping, changed, err := AdaptResponsesClientToolsWithInheritedMapping(req, inherited) + + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, inherited, mapping) + items := requireResponsesClientToolValue[[]any](t, req["input"]) + call := requireResponsesClientToolValue[map[string]any](t, items[0]) + require.Equal(t, "function_call", call["type"]) + require.JSONEq(t, `{"input":"pwd"}`, requireResponsesClientToolValue[string](t, call["arguments"])) + require.NotContains(t, call, "input") + output := requireResponsesClientToolValue[map[string]any](t, items[1]) + require.Equal(t, "function_call_output", output["type"]) + require.NotContains(t, output, "id") + require.JSONEq(t, `[{"text":"ok","type":"input_text"}]`, requireResponsesClientToolValue[string](t, output["output"])) +} + +func TestAdaptResponsesClientToolsWithInheritedMapping_PromotesOmittedToolsDiscoveryIntoEffectiveDeclarations(t *testing.T) { + req := map[string]any{ + "input": []any{map[string]any{ + "type": "tool_search_output", "call_id": "call_search", "status": "completed", "execution": "client", + "tools": []any{map[string]any{ + "type": "namespace", "name": "multi_agent_v1", "tools": []any{map[string]any{ + "type": "function", "name": "spawn_agent", "parameters": map[string]any{"type": "object"}, + }}, + }}, + }}, + } + inherited := ResponsesClientToolMapping{ + ToolSearch: true, + NamespaceTools: map[string]ResponsesNamespaceName{ + "codex_app__read_resource": {Namespace: "codex_app", Name: "read_resource"}, + }, + } + lowered := []any{ + map[string]any{"type": "function", "name": "static_first", "parameters": map[string]any{"type": "object"}}, + map[string]any{"type": "function", "name": "tool_search", "parameters": json.RawMessage(toolSearchProxySchema)}, + map[string]any{"type": "function", "name": "codex_app__read_resource", "parameters": map[string]any{"type": "object"}}, + } + + mapping, changed, err := AdaptResponsesClientToolsWithInheritedMapping(req, inherited, lowered) + require.NoError(t, err) + require.True(t, changed) + require.True(t, mapping.ToolSearch) + require.Equal(t, ResponsesNamespaceName{Namespace: "codex_app", Name: "read_resource"}, mapping.NamespaceTools["codex_app__read_resource"]) + require.Equal(t, ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "spawn_agent"}, mapping.NamespaceTools["multi_agent_v1__spawn_agent"]) + tools := requireResponsesClientToolValue[[]any](t, req["tools"]) + require.Equal(t, []string{ + "static_first", "tool_search", "codex_app__read_resource", "multi_agent_v1__spawn_agent", + }, responsesClientToolNames(t, tools)) + output := requireResponsesClientToolValue[map[string]any](t, requireResponsesClientToolValue[[]any](t, req["input"])[0]) + require.Equal(t, "function_call_output", output["type"]) + require.IsType(t, "", output["output"]) + require.NotContains(t, output, "tools") + require.NotContains(t, output, "status") + require.NotContains(t, output, "execution") +} + +func TestAdaptResponsesClientToolsWithInheritedMapping_ExplicitToolsReplaceInheritedMapping(t *testing.T) { + req := map[string]any{ + "tools": []any{}, + "input": []any{map[string]any{ + "type": "custom_tool_call", "name": "exec", "input": "pwd", + }}, + } + + mapping, changed, err := AdaptResponsesClientToolsWithInheritedMapping( + req, + ResponsesClientToolMapping{CustomTools: map[string]bool{"exec": true}}, + ) + + require.NoError(t, err) + require.False(t, changed) + require.Empty(t, mapping) + items := requireResponsesClientToolValue[[]any](t, req["input"]) + call := requireResponsesClientToolValue[map[string]any](t, items[0]) + require.Equal(t, "custom_tool_call", call["type"]) +} + +func TestAdaptResponsesClientToolsWithInheritedMapping_ExplicitToolResetDoesNotPromoteDiscovery(t *testing.T) { + for _, reset := range []any{nil, []any{}} { + req := map[string]any{ + "tools": reset, + "input": []any{map[string]any{ + "type": "tool_search_output", "call_id": "call_reset", "status": "completed", + "tools": []any{map[string]any{"type": "function", "name": "must_not_promote"}}, + }}, + } + mapping, changed, err := AdaptResponsesClientToolsWithInheritedMapping( + req, + ResponsesClientToolMapping{ToolSearch: true}, + []any{map[string]any{"type": "function", "name": "tool_search"}}, + ) + require.NoError(t, err) + require.False(t, changed) + require.Empty(t, mapping) + item := requireResponsesClientToolValue[map[string]any](t, requireResponsesClientToolValue[[]any](t, req["input"])[0]) + require.Equal(t, "tool_search_output", item["type"]) + } +} + func TestRestoreResponsesClientToolPayload_RestoresClientAndNamespaceCalls(t *testing.T) { mapping := ResponsesClientToolMapping{ CustomTools: map[string]bool{"exec": true}, ToolSearch: true, diff --git a/backend/internal/pkg/apicompat/responses_tool_search_discoveries.go b/backend/internal/pkg/apicompat/responses_tool_search_discoveries.go new file mode 100644 index 000000000..2617d5f94 --- /dev/null +++ b/backend/internal/pkg/apicompat/responses_tool_search_discoveries.go @@ -0,0 +1,263 @@ +package apicompat + +import ( + "encoding/json" + "fmt" + "strings" +) + +type responsesDiscoveredToolIdentity struct { + typ string + name string + namespace string + encoded string + ambiguous bool +} + +// promoteResponsesToolSearchDiscoveries makes successfully discovered client +// tools callable for function-only upstreams. The original tool_search_output +// remains request history and is normalized separately; declarations are +// appended after static tools so the client's declaration order stays stable. +func promoteResponsesToolSearchDiscoveries(req map[string]any) (bool, error) { + tools, ok := req["tools"].([]any) + if !ok || len(tools) == 0 || !hasResponsesToolSearchDeclaration(tools) { + return false, nil + } + input, ok := req["input"].([]any) + if !ok || len(input) == 0 { + return false, nil + } + + known := make(map[string]responsesDiscoveredToolIdentity) + for _, raw := range tools { + registerExistingResponsesToolIdentity(known, raw) + } + + promoted := make([]any, 0) + for _, rawItem := range input { + item, ok := rawItem.(map[string]any) + if !ok || strings.TrimSpace(stringValue(item["type"])) != "tool_search_output" || !usableResponsesToolSearchOutput(item) { + continue + } + discoveries, ok := item["tools"].([]any) + if !ok || len(discoveries) == 0 { + continue + } + if _, err := json.Marshal(discoveries); err != nil { + continue + } + for _, rawDiscovery := range discoveries { + discovery, ok := rawDiscovery.(map[string]any) + if !ok { + continue + } + typ := strings.TrimSpace(stringValue(discovery["type"])) + switch typ { + case "function", "custom": + copy, identity, ok := responsesDirectToolDiscovery(discovery, typ) + if !ok { + continue + } + appendTool, err := admitResponsesDiscoveredTool(known, identity.name, identity) + if err != nil { + return false, err + } + if appendTool { + promoted = append(promoted, copy) + } + case "namespace": + copy, identities, ok := responsesNamespaceToolDiscovery(discovery) + if !ok { + continue + } + children := make([]any, 0, len(identities)) + for _, candidate := range identities { + appendTool, err := admitResponsesDiscoveredTool(known, candidate.flat, candidate.identity) + if err != nil { + return false, err + } + if appendTool { + children = append(children, candidate.child) + } + } + if len(children) > 0 { + copy["tools"] = children + delete(copy, "children") + promoted = append(promoted, copy) + } + } + } + } + if len(promoted) == 0 { + return false, nil + } + req["tools"] = append(tools, promoted...) + return true, nil +} + +func hasResponsesToolSearchDeclaration(tools []any) bool { + for _, raw := range tools { + tool, ok := raw.(map[string]any) + if ok && strings.TrimSpace(stringValue(tool["type"])) == "tool_search" { + return true + } + } + return false +} + +func usableResponsesToolSearchOutput(item map[string]any) bool { + status, present := item["status"] + if !present { + return true + } + text, ok := status.(string) + return ok && strings.TrimSpace(text) == "completed" +} + +func registerExistingResponsesToolIdentity(known map[string]responsesDiscoveredToolIdentity, raw any) { + tool, ok := raw.(map[string]any) + if !ok { + return + } + typ := strings.TrimSpace(stringValue(tool["type"])) + switch typ { + case "function", "custom": + _, identity, ok := responsesDirectToolDiscovery(tool, typ) + if ok { + registerExistingResponsesIdentity(known, identity.name, identity) + } + case "namespace": + _, identities, ok := responsesNamespaceToolDiscovery(tool) + if !ok { + return + } + for _, candidate := range identities { + registerExistingResponsesIdentity(known, candidate.flat, candidate.identity) + } + } +} + +func registerExistingResponsesIdentity(known map[string]responsesDiscoveredToolIdentity, key string, identity responsesDiscoveredToolIdentity) { + previous, exists := known[key] + if !exists { + known[key] = identity + return + } + if !sameResponsesDiscoveredTool(previous, identity) { + previous.ambiguous = true + known[key] = previous + } +} + +func admitResponsesDiscoveredTool(known map[string]responsesDiscoveredToolIdentity, key string, identity responsesDiscoveredToolIdentity) (bool, error) { + previous, exists := known[key] + if !exists { + known[key] = identity + return true, nil + } + if !previous.ambiguous && sameResponsesDiscoveredTool(previous, identity) { + return false, nil + } + return false, fmt.Errorf("discovered tool %q conflicts with an existing declaration; this upstream cannot safely disambiguate different names, namespaces, or schemas", key) +} + +func sameResponsesDiscoveredTool(left, right responsesDiscoveredToolIdentity) bool { + return left.typ == right.typ && left.name == right.name && left.namespace == right.namespace && left.encoded == right.encoded +} + +func responsesDirectToolDiscovery(tool map[string]any, typ string) (map[string]any, responsesDiscoveredToolIdentity, bool) { + name := strings.TrimSpace(stringValue(tool["name"])) + if name == "" { + return nil, responsesDiscoveredToolIdentity{}, false + } + copy := copyClientTool(tool) + copy["type"] = typ + copy["name"] = name + identityCopy := copy + if typ == "custom" { + identityCopy = copyClientTool(copy) + identityCopy["type"] = "function" + identityCopy["parameters"] = json.RawMessage(customToolInputSchema) + delete(identityCopy, "format") + } + encoded, err := json.Marshal(identityCopy) + if err != nil { + return nil, responsesDiscoveredToolIdentity{}, false + } + return copy, responsesDiscoveredToolIdentity{typ: typ, name: name, encoded: string(encoded)}, true +} + +// restoreInheritedResponsesClientToolDeclarations reverses only the declaration +// identities recorded by ResponsesClientToolMapping. It is used when a WS +// continuation omits tools but the HTTP function upstream still needs the +// effective session declarations on every request. +func restoreInheritedResponsesClientToolDeclarations(lowered []any, mapping ResponsesClientToolMapping) []any { + restored := make([]any, 0, len(lowered)) + for _, raw := range lowered { + tool, ok := raw.(map[string]any) + if !ok { + restored = append(restored, raw) + continue + } + name := strings.TrimSpace(stringValue(tool["name"])) + switch { + case mapping.ToolSearch && name == toolSearchProxyName: + restored = append(restored, map[string]any{"type": "tool_search"}) + case mapping.CustomTools[name]: + copy := copyClientTool(tool) + copy["type"] = "custom" + restored = append(restored, copy) + case mapping.NamespaceTools[name].Namespace != "": + identity := mapping.NamespaceTools[name] + child := copyClientTool(tool) + child["type"] = "function" + child["name"] = identity.Name + restored = append(restored, map[string]any{ + "type": "namespace", "name": identity.Namespace, "tools": []any{child}, + }) + default: + restored = append(restored, copyClientTool(tool)) + } + } + return restored +} + +type responsesNamespaceToolCandidate struct { + flat string + child map[string]any + identity responsesDiscoveredToolIdentity +} + +func responsesNamespaceToolDiscovery(tool map[string]any) (map[string]any, []responsesNamespaceToolCandidate, bool) { + namespace := strings.TrimSpace(stringValue(tool["name"])) + children := namespaceChildren(tool) + if namespace == "" || len(children) == 0 { + return nil, nil, false + } + copy := copyClientTool(tool) + copy["type"] = "namespace" + copy["name"] = namespace + identities := make([]responsesNamespaceToolCandidate, 0, len(children)) + for _, rawChild := range children { + child, ok := rawChild.(map[string]any) + if !ok || strings.TrimSpace(stringValue(child["type"])) != "function" { + continue + } + childCopy, direct, ok := responsesDirectToolDiscovery(child, "function") + if !ok { + continue + } + flat := flattenNamespaceToolName(namespace, direct.name) + identities = append(identities, responsesNamespaceToolCandidate{ + flat: flat, + child: childCopy, + identity: responsesDiscoveredToolIdentity{ + typ: "namespace", name: direct.name, namespace: namespace, encoded: direct.encoded, + }, + }) + } + if len(identities) == 0 { + return nil, nil, false + } + return copy, identities, true +} diff --git a/backend/internal/repository/channel_repo_pricing.go b/backend/internal/repository/channel_repo_pricing.go index b7a9fd6b0..e25642085 100644 --- a/backend/internal/repository/channel_repo_pricing.go +++ b/backend/internal/repository/channel_repo_pricing.go @@ -16,7 +16,7 @@ import ( func (r *channelRepository) ListModelPricing(ctx context.Context, channelID int64) ([]service.ChannelModelPricing, error) { rows, err := r.db.QueryContext(ctx, - `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at + `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at FROM channel_model_pricing WHERE channel_id = $1 ORDER BY id`, channelID, ) if err != nil { @@ -61,10 +61,11 @@ func (r *channelRepository) UpdateModelPricing(ctx context.Context, pricing *ser } result, err := r.db.ExecContext(ctx, `UPDATE channel_model_pricing - SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_read_price = $6, image_input_price = $7, image_output_price = $8, per_request_price = $9, time_pricing = $10, platform = $11, updated_at = NOW() - WHERE id = $12`, + SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_read_price = $6, fast_multiplier = $7, flex_multiplier = $8, image_input_price = $9, image_output_price = $10, per_request_price = $11, time_pricing = $12, platform = $13, updated_at = NOW() + WHERE id = $14`, modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheReadPrice, - pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, timePricingJSON, pricing.Platform, pricing.ID, + pricing.FastMultiplier, pricing.FlexMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, + timePricingJSON, pricing.Platform, pricing.ID, ) if err != nil { return fmt.Errorf("update model pricing: %w", err) @@ -95,7 +96,7 @@ func (r *channelRepository) ReplaceModelPricing(ctx context.Context, channelID i // batchLoadModelPricing 批量加载多个渠道的模型定价(含区间) func (r *channelRepository) batchLoadModelPricing(ctx context.Context, channelIDs []int64) (map[int64][]service.ChannelModelPricing, error) { rows, err := r.db.QueryContext(ctx, - `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at + `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at FROM channel_model_pricing WHERE channel_id = ANY($1) ORDER BY channel_id, id`, pq.Array(channelIDs), ) @@ -136,6 +137,7 @@ func (r *channelRepository) batchLoadIntervals(ctx context.Context, pricingIDs [ rows, err := r.db.QueryContext(ctx, `SELECT id, pricing_id, min_tokens, max_tokens, tier_label, input_price, output_price, cache_write_price, cache_read_price, + input_multiplier, output_multiplier, cache_write_multiplier, cache_read_multiplier, per_request_price, sort_order, created_at, updated_at FROM channel_pricing_intervals WHERE pricing_id = ANY($1) ORDER BY pricing_id, sort_order, id`, @@ -152,6 +154,7 @@ func (r *channelRepository) batchLoadIntervals(ctx context.Context, pricingIDs [ if err := rows.Scan( &iv.ID, &iv.PricingID, &iv.MinTokens, &iv.MaxTokens, &iv.TierLabel, &iv.InputPrice, &iv.OutputPrice, &iv.CacheWritePrice, &iv.CacheReadPrice, + &iv.InputMultiplier, &iv.OutputMultiplier, &iv.CacheWriteMultiplier, &iv.CacheReadMultiplier, &iv.PerRequestPrice, &iv.SortOrder, &iv.CreatedAt, &iv.UpdatedAt, ); err != nil { return nil, fmt.Errorf("scan interval: %w", err) @@ -177,6 +180,7 @@ func scanModelPricingRows(rows *sql.Rows) ([]service.ChannelModelPricing, []int6 if err := rows.Scan( &p.ID, &p.ChannelID, &p.Platform, &modelsJSON, &p.BillingMode, &p.InputPrice, &p.OutputPrice, &p.CacheWritePrice, &p.CacheReadPrice, + &p.FastMultiplier, &p.FlexMultiplier, &p.ImageInputPrice, &p.ImageOutputPrice, &p.PerRequestPrice, &timePricingJSON, &p.CreatedAt, &p.UpdatedAt, ); err != nil { return nil, nil, fmt.Errorf("scan model pricing: %w", err) @@ -243,11 +247,12 @@ func createModelPricingExec(ctx context.Context, exec dbExec, pricing *service.C platform = "anthropic" } err = exec.QueryRowContext(ctx, - `INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price, time_pricing) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, created_at, updated_at`, + `INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, created_at, updated_at`, pricing.ChannelID, platform, modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheReadPrice, - pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, timePricingJSON, + pricing.FastMultiplier, pricing.FlexMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, + pricing.PerRequestPrice, timePricingJSON, ).Scan(&pricing.ID, &pricing.CreatedAt, &pricing.UpdatedAt) if err != nil { return fmt.Errorf("insert model pricing: %w", err) @@ -288,10 +293,11 @@ func unmarshalChannelTimePricing(data []byte) (*service.ChannelTimePricing, erro func createIntervalExec(ctx context.Context, exec dbExec, iv *service.PricingInterval) error { return exec.QueryRowContext(ctx, `INSERT INTO channel_pricing_intervals - (pricing_id, min_tokens, max_tokens, tier_label, input_price, output_price, cache_write_price, cache_read_price, per_request_price, sort_order) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at`, + (pricing_id, min_tokens, max_tokens, tier_label, input_price, output_price, cache_write_price, cache_read_price, input_multiplier, output_multiplier, cache_write_multiplier, cache_read_multiplier, per_request_price, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, created_at, updated_at`, iv.PricingID, iv.MinTokens, iv.MaxTokens, iv.TierLabel, iv.InputPrice, iv.OutputPrice, iv.CacheWritePrice, iv.CacheReadPrice, + iv.InputMultiplier, iv.OutputMultiplier, iv.CacheWriteMultiplier, iv.CacheReadMultiplier, iv.PerRequestPrice, iv.SortOrder, ).Scan(&iv.ID, &iv.CreatedAt, &iv.UpdatedAt) } diff --git a/backend/internal/repository/channel_repo_pricing_time_test.go b/backend/internal/repository/channel_repo_pricing_time_test.go index d7af1886e..38acdbdc7 100644 --- a/backend/internal/repository/channel_repo_pricing_time_test.go +++ b/backend/internal/repository/channel_repo_pricing_time_test.go @@ -16,7 +16,7 @@ import ( var channelModelPricingTimePricingColumns = []string{ "id", "channel_id", "platform", "models", "billing_mode", "input_price", "output_price", - "cache_write_price", "cache_read_price", "image_input_price", "image_output_price", + "cache_write_price", "cache_read_price", "fast_multiplier", "flex_multiplier", "image_input_price", "image_output_price", "per_request_price", "time_pricing", "created_at", "updated_at", } @@ -33,7 +33,7 @@ func newChannelModelPricingTimePricingRepo(t *testing.T) (*channelRepository, sq func modelPricingTimePricingRow(timePricing any) *sqlmock.Rows { return sqlmock.NewRows(channelModelPricingTimePricingColumns).AddRow( int64(11), int64(7), "openai", `["gpt-5"]`, service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, timePricing, + nil, nil, nil, nil, nil, nil, nil, nil, nil, timePricing, time.Date(2026, 8, 17, 0, 0, 0, 0, time.UTC), time.Date(2026, 8, 17, 1, 0, 0, 0, time.UTC), ) } @@ -105,10 +105,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateRoundTrip(t *testing.T) { t.Run("create writes JSON", func(t *testing.T) { repo, mock := newChannelModelPricingTimePricingRepo(t) - mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price, time_pricing)")). + mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)")). WithArgs( int64(7), "openai", []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, + nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, ). WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow(int64(11), time.Time{}, time.Time{})) @@ -118,10 +118,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateRoundTrip(t *testing.T) { t.Run("update writes JSON and entry ID", func(t *testing.T) { repo, mock := newChannelModelPricingTimePricingRepo(t) - mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$9, time_pricing = \$10, platform = \$11.*WHERE id = \$12`). + mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$11, time_pricing = \$12, platform = \$13.*WHERE id = \$14`). WithArgs( []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, "openai", int64(11), + nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, "openai", int64(11), ). WillReturnResult(sqlmock.NewResult(0, 1)) @@ -153,10 +153,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateWriteNullWhenDisabled(t *t t.Run("create writes SQL NULL", func(t *testing.T) { repo, mock := newChannelModelPricingTimePricingRepo(t) - mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_input_price, image_output_price, per_request_price, time_pricing)")). + mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)")). WithArgs( int64(7), "openai", []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, ). WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow(int64(11), time.Time{}, time.Time{})) @@ -166,10 +166,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateWriteNullWhenDisabled(t *t t.Run("update writes SQL NULL", func(t *testing.T) { repo, mock := newChannelModelPricingTimePricingRepo(t) - mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$9, time_pricing = \$10, platform = \$11.*WHERE id = \$12`). + mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$11, time_pricing = \$12, platform = \$13.*WHERE id = \$14`). WithArgs( []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, "openai", int64(11), + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, "openai", int64(11), ). WillReturnResult(sqlmock.NewResult(0, 1)) diff --git a/backend/internal/repository/gateway_cache.go b/backend/internal/repository/gateway_cache.go index dc3d4a980..c05cbded4 100644 --- a/backend/internal/repository/gateway_cache.go +++ b/backend/internal/repository/gateway_cache.go @@ -131,6 +131,48 @@ func (c *gatewayCache) ReleaseGrokVideoBilled(ctx context.Context, key string) e var _ service.CyberSessionBlockStore = (*gatewayCache)(nil) var _ service.LiveCallStore = (*gatewayCache)(nil) +const reasoningContentPrefix = "reasoning_content:" + +// reasoningContentDefaultTTL 是 reasoning 缓存的默认过期时间。Codex 会话可能 +// 跨多天恢复,取 7 天;调用方传入非正 TTL 时兜底。 +const reasoningContentDefaultTTL = 7 * 24 * time.Hour + +// SetReasoningContent 按 reasoning item id 缓存 reasoning 全文。 +// itemID 或 content 为空时直接返回 nil(无可缓存内容,属正常情况而非错误)。 +func (c *gatewayCache) SetReasoningContent(ctx context.Context, itemID string, content string, ttl time.Duration) error { + if c == nil || c.rdb == nil { + return errors.New("gateway cache unavailable") + } + itemID = strings.TrimSpace(itemID) + if itemID == "" || content == "" { + return nil + } + if ttl <= 0 { + ttl = reasoningContentDefaultTTL + } + return c.rdb.Set(ctx, reasoningContentPrefix+itemID, content, ttl).Err() +} + +// GetReasoningContent 返回缓存的 reasoning 全文;未命中返回 +// service.ErrReasoningContentNotFound。 +func (c *gatewayCache) GetReasoningContent(ctx context.Context, itemID string) (string, error) { + if c == nil || c.rdb == nil { + return "", errors.New("gateway cache unavailable") + } + itemID = strings.TrimSpace(itemID) + if itemID == "" { + return "", service.ErrReasoningContentNotFound + } + val, err := c.rdb.Get(ctx, reasoningContentPrefix+itemID).Result() + if err != nil { + if errors.Is(err, redis.Nil) { + return "", service.ErrReasoningContentNotFound + } + return "", err + } + return val, nil +} + const cyberSessionBlockPrefix = "cyber_session_block:" // SetCyberSessionBlocked 把被 cyber_policy 命中的会话写入屏蔽表(TTL 自动过期)。 diff --git a/backend/internal/repository/gateway_cache_reasoning_test.go b/backend/internal/repository/gateway_cache_reasoning_test.go new file mode 100644 index 000000000..f284da51b --- /dev/null +++ b/backend/internal/repository/gateway_cache_reasoning_test.go @@ -0,0 +1,41 @@ +package repository + +import ( + "context" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" +) + +func TestGatewayCacheReasoningContent(t *testing.T) { + mr := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + cache := NewGatewayCache(client) + ctx := context.Background() + + // 未命中返回哨兵错误,区别于真实读取失败。 + _, err := cache.GetReasoningContent(ctx, "item_missing") + require.ErrorIs(t, err, service.ErrReasoningContentNotFound) + + // 写入后可读回。 + require.NoError(t, cache.SetReasoningContent(ctx, "item_abc", "think hard", time.Minute)) + got, err := cache.GetReasoningContent(ctx, "item_abc") + require.NoError(t, err) + require.Equal(t, "think hard", got) + + // ttl<=0 时兜底为默认 7 天。 + require.NoError(t, cache.SetReasoningContent(ctx, "item_ttl", "x", 0)) + ttl := mr.TTL(reasoningContentPrefix + "item_ttl") + require.Greater(t, ttl, 6*24*time.Hour) + require.LessOrEqual(t, ttl, reasoningContentDefaultTTL) + + // 空 itemID / 空 content 是 no-op(无可缓存内容不算错误)。 + require.NoError(t, cache.SetReasoningContent(ctx, "", "x", time.Minute)) + require.NoError(t, cache.SetReasoningContent(ctx, "item_empty", "", time.Minute)) + _, err = cache.GetReasoningContent(ctx, "item_empty") + require.ErrorIs(t, err, service.ErrReasoningContentNotFound) +} diff --git a/backend/internal/repository/migrations_runner.go b/backend/internal/repository/migrations_runner.go index 169a01675..0b786616a 100644 --- a/backend/internal/repository/migrations_runner.go +++ b/backend/internal/repository/migrations_runner.go @@ -59,6 +59,9 @@ const latestAPIKeyIPIndexMigration = "174_add_usage_logs_api_key_latest_ip_index const latestAPIKeyIPIndex = "idx_usage_logs_api_key_latest_ip" const usageLogsUpstreamModelMismatchIndexMigration = "195_add_usage_log_upstream_model_mismatch_index_notx.sql" const usageLogsUpstreamModelMismatchIndex = "idx_usage_logs_upstream_model_mismatch_created_at" +const usageLogsEffectiveModelIndexesMigration = "226_add_usage_log_effective_model_indexes_notx.sql" +const usageLogsEffectiveRequestedModelIndex = "idx_usage_logs_effective_requested_model_created" +const usageLogsEffectiveUpstreamModelIndex = "idx_usage_logs_effective_upstream_model_created" type migrationChecksumCompatibilityRule struct { fileChecksum string @@ -295,6 +298,13 @@ func prepareNonTransactionalMigration(ctx context.Context, db migrationConnectio return dropInvalidIndexIfPresent(ctx, db, latestAPIKeyIPIndex) case usageLogsUpstreamModelMismatchIndexMigration: return dropInvalidIndexIfPresent(ctx, db, usageLogsUpstreamModelMismatchIndex) + case usageLogsEffectiveModelIndexesMigration: + for _, indexName := range []string{usageLogsEffectiveRequestedModelIndex, usageLogsEffectiveUpstreamModelIndex} { + if err := dropInvalidIndexIfPresent(ctx, db, indexName); err != nil { + return err + } + } + return nil default: return nil } diff --git a/backend/internal/repository/migrations_runner_notx_test.go b/backend/internal/repository/migrations_runner_notx_test.go index 55588bb04..8f9882e6d 100644 --- a/backend/internal/repository/migrations_runner_notx_test.go +++ b/backend/internal/repository/migrations_runner_notx_test.go @@ -191,6 +191,47 @@ CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_upstream_model_mismatch_c require.NoError(t, mock.ExpectationsWereMet()) } +func TestApplyMigrationsFS_NonTransactionalMigration_EffectiveModelIndexesDropInvalidIndexesBeforeRetry(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + prepareMigrationsBootstrapExpectations(mock) + mock.ExpectQuery("SELECT checksum FROM schema_migrations WHERE filename = \\$1"). + WithArgs(usageLogsEffectiveModelIndexesMigration). + WillReturnError(sql.ErrNoRows) + for _, indexName := range []string{usageLogsEffectiveRequestedModelIndex, usageLogsEffectiveUpstreamModelIndex} { + mock.ExpectQuery("SELECT EXISTS \\("). + WithArgs(indexName). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + mock.ExpectExec("DROP INDEX CONCURRENTLY IF EXISTS " + indexName). + WillReturnResult(sqlmock.NewResult(0, 0)) + } + mock.ExpectExec("CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_effective_requested_model_created"). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_effective_upstream_model_created"). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("INSERT INTO schema_migrations \\(filename, checksum\\) VALUES \\(\\$1, \\$2\\)"). + WithArgs(usageLogsEffectiveModelIndexesMigration, sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec("SELECT pg_advisory_unlock\\(\\$1\\)"). + WithArgs(migrationsAdvisoryLockID). + WillReturnResult(sqlmock.NewResult(0, 1)) + + fsys := fstest.MapFS{ + usageLogsEffectiveModelIndexesMigration: &fstest.MapFile{Data: []byte(` +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_effective_requested_model_created + ON usage_logs ((COALESCE(NULLIF(BTRIM(requested_model), ''), model)), created_at DESC, id DESC); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_effective_upstream_model_created + ON usage_logs ((COALESCE(NULLIF(BTRIM(upstream_model), ''), model)), created_at DESC, id DESC); +`)}, + } + + err = applyMigrationsFS(context.Background(), db, fsys) + require.NoError(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} + func TestApplyMigrationsFS_PaymentOrdersOutTradeNoUniqueMigration_FailsFastOnDuplicatePrecheck(t *testing.T) { db, mock, err := sqlmock.New() require.NoError(t, err) diff --git a/backend/internal/repository/proxy_probe_service.go b/backend/internal/repository/proxy_probe_service.go index 245eaade8..6088edfcc 100644 --- a/backend/internal/repository/proxy_probe_service.go +++ b/backend/internal/repository/proxy_probe_service.go @@ -31,11 +31,24 @@ func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber { if insecure { log.Printf("[ProxyProbe] Warning: insecure_skip_verify is not allowed and will cause probe failure.") } + // 构建探测 URL 列表:配置存在时覆盖内置默认列表。 + var configuredTargets []configuredProbeTarget + if cfg != nil && len(cfg.Security.ProxyProbe.URLs) > 0 { + configuredTargets = make([]configuredProbeTarget, 0, len(cfg.Security.ProxyProbe.URLs)) + for _, u := range cfg.Security.ProxyProbe.URLs { + configuredTargets = append(configuredTargets, configuredProbeTarget{ + url: u.URL, + parser: u.Parser, + }) + } + } + return &proxyProbeService{ - insecureSkipVerify: insecure, - allowPrivateHosts: allowPrivate, - validateResolvedIP: validateResolvedIP, - maxResponseBytes: maxResponseBytes, + insecureSkipVerify: insecure, + allowPrivateHosts: allowPrivate, + validateResolvedIP: validateResolvedIP, + maxResponseBytes: maxResponseBytes, + configuredProbeURLs: configuredTargets, } } @@ -44,21 +57,27 @@ const ( defaultProxyProbeResponseMaxBytes = int64(1024 * 1024) ) -// probeURLs 按优先级排列的探测 URL 列表 -// 某些 AI API 专用代理只允许访问特定域名,因此需要多个备选 +// probeURLs 按优先级排列的内置探测 URL 列表。 +// 某些 AI API 专用代理只允许访问特定域名,因此需要多个备选。 var probeURLs = []struct { url string - parser string // "ip-api" or "ipify" + parser string }{ {"http://ip-api.com/json/?lang=zh-CN", "ip-api"}, {"http://api64.ipify.org?format=json", "ipify"}, } +type configuredProbeTarget struct { + url string + parser string +} + type proxyProbeService struct { - insecureSkipVerify bool - allowPrivateHosts bool - validateResolvedIP bool - maxResponseBytes int64 + insecureSkipVerify bool + allowPrivateHosts bool + validateResolvedIP bool + maxResponseBytes int64 + configuredProbeURLs []configuredProbeTarget } func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*service.ProxyExitInfo, int64, error) { @@ -74,6 +93,17 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s } var lastErr error + if len(s.configuredProbeURLs) > 0 { + for _, probe := range s.configuredProbeURLs { + exitInfo, latencyMs, err := s.probeWithURL(ctx, client, probe.url, probe.parser) + if err == nil { + return exitInfo, latencyMs, nil + } + lastErr = err + } + return nil, 0, fmt.Errorf("all probe URLs failed, last error: %w", lastErr) + } + for _, probe := range probeURLs { exitInfo, latencyMs, err := s.probeWithURL(ctx, client, probe.url, probe.parser) if err == nil { @@ -121,6 +151,8 @@ func (s *proxyProbeService) probeWithURL(ctx context.Context, client *http.Clien return s.parseIPAPI(body, latencyMs) case "ipify": return s.parseIPify(body, latencyMs) + case "chatgpt-trace": + return s.parseChatGPTTrace(body, latencyMs) default: return nil, latencyMs, fmt.Errorf("unknown parser: %s", parser) } @@ -179,3 +211,35 @@ func (s *proxyProbeService) parseIPify(body []byte, latencyMs int64) (*service.P IP: result.IP, }, latencyMs, nil } + +// parseChatGPTTrace 解析 Cloudflare trace 端点(如 chatgpt.com/cdn-cgi/trace)的纯文本响应。 +// 响应按行给出键值对,其中 ip= 为出口 IP,loc= 为国家代码。 +func (s *proxyProbeService) parseChatGPTTrace(body []byte, latencyMs int64) (*service.ProxyExitInfo, int64, error) { + var ip, loc string + for _, line := range strings.Split(string(body), "\n") { + key, value, found := strings.Cut(strings.TrimSpace(line), "=") + if !found { + continue + } + switch key { + case "ip": + ip = strings.TrimSpace(value) + case "loc": + loc = strings.TrimSpace(value) + } + } + if ip == "" { + preview := string(body) + if len(preview) > 200 { + preview = preview[:200] + "..." + } + return nil, latencyMs, fmt.Errorf("chatgpt-trace: no ip= found in response (body: %s)", preview) + } + info := &service.ProxyExitInfo{ + IP: ip, + } + if loc != "" { + info.CountryCode = loc + } + return info, latencyMs, nil +} diff --git a/backend/internal/repository/proxy_probe_service_test.go b/backend/internal/repository/proxy_probe_service_test.go index c778f6c1e..45c342cb4 100644 --- a/backend/internal/repository/proxy_probe_service_test.go +++ b/backend/internal/repository/proxy_probe_service_test.go @@ -166,6 +166,22 @@ func (s *ProxyProbeServiceSuite) TestParseIPify_NoIP() { require.ErrorContains(s.T(), err, "no IP found") } +func (s *ProxyProbeServiceSuite) TestParseChatGPTTrace_Success() { + body := []byte("fl=abc\nh=chatgpt.com\nip=203.0.113.5\nts=1700000000\nloc=US\ntz=UTC\n") + info, latencyMs, err := s.prober.parseChatGPTTrace(body, 320) + require.NoError(s.T(), err) + require.Equal(s.T(), int64(320), latencyMs) + require.Equal(s.T(), "203.0.113.5", info.IP) + require.Equal(s.T(), "US", info.CountryCode) +} + +func (s *ProxyProbeServiceSuite) TestParseChatGPTTrace_NoIP() { + body := []byte("fl=abc\nh=chatgpt.com\nloc=US\n") + _, _, err := s.prober.parseChatGPTTrace(body, 100) + require.Error(s.T(), err) + require.ErrorContains(s.T(), err, "chatgpt-trace: no ip= found") +} + func TestProxyProbeServiceSuite(t *testing.T) { suite.Run(t, new(ProxyProbeServiceSuite)) } diff --git a/backend/internal/repository/usage_log_repo_request_type_test.go b/backend/internal/repository/usage_log_repo_request_type_test.go index a2b82dfa6..c69f374d4 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -505,33 +505,33 @@ func TestUsageLogRepositoryGetStatsWithFiltersRequestedModelSource(t *testing.T) ModelFilterSource: usagestats.ModelSourceRequested, } - mock.ExpectQuery("FROM usage_logs\\s+WHERE COALESCE\\(NULLIF\\(TRIM\\(requested_model\\), ''\\), model\\) = \\$1"). + mock.ExpectQuery("(?s)FROM usage_logs\\s+WHERE COALESCE\\(NULLIF\\(TRIM\\(requested_model\\), ''\\), model\\) = \\$1.*GROUP BY GROUPING SETS"). WithArgs("gpt-5"). WillReturnRows(sqlmock.NewRows([]string{ - "total_requests", - "total_input_tokens", - "total_output_tokens", - "total_cache_tokens", - "total_cache_creation_tokens", - "total_cache_read_tokens", - "total_cost", - "total_actual_cost", - "total_account_cost", + "inbound_grouped", + "upstream_grouped", + "inbound_endpoint", + "upstream_endpoint", + "requests", + "input_tokens", + "output_tokens", + "cache_creation_tokens", + "cache_read_tokens", + "cost", + "actual_cost", + "account_cost", "avg_duration_ms", - }).AddRow(int64(1), int64(2), int64(3), int64(4), int64(1), int64(3), 1.2, 1.0, 1.2, 20.0)) - mock.ExpectQuery("SELECT COALESCE\\(NULLIF\\(TRIM\\(inbound_endpoint\\), ''\\), 'unknown'\\) AS endpoint"). - WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "gpt-5"). - WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"})) - mock.ExpectQuery("SELECT COALESCE\\(NULLIF\\(TRIM\\(upstream_endpoint\\), ''\\), 'unknown'\\) AS endpoint"). - WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "gpt-5"). - WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"})) - mock.ExpectQuery("SELECT CONCAT\\("). - WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "gpt-5"). - WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"})) + }). + AddRow(1, 1, nil, nil, int64(1), int64(2), int64(3), int64(1), int64(3), 1.2, 1.0, 1.2, 20.0). + AddRow(0, 1, "/v1/responses", nil, int64(1), int64(2), int64(3), int64(1), int64(3), 1.2, 1.0, 1.2, 20.0). + AddRow(1, 0, nil, "/v1/responses", int64(1), int64(2), int64(3), int64(1), int64(3), 1.2, 1.0, 1.2, 20.0). + AddRow(0, 0, "/v1/responses", "/v1/responses", int64(1), int64(2), int64(3), int64(1), int64(3), 1.2, 1.0, 1.2, 20.0)) stats, err := repo.GetStatsWithFilters(context.Background(), filters) require.NoError(t, err) require.Equal(t, int64(1), stats.TotalRequests) + require.Equal(t, "/v1/responses", stats.Endpoints[0].Endpoint) + require.Equal(t, "/v1/responses -> /v1/responses", stats.EndpointPaths[0].Endpoint) require.NoError(t, mock.ExpectationsWereMet()) } @@ -546,29 +546,23 @@ func TestUsageLogRepositoryGetStatsWithFiltersRequestTypePriority(t *testing.T) Stream: &stream, } - mock.ExpectQuery("FROM usage_logs\\s+WHERE \\(request_type = \\$1 OR \\(request_type = 0 AND stream = FALSE AND openai_ws_mode = FALSE\\)\\)"). + mock.ExpectQuery("(?s)FROM usage_logs\\s+WHERE \\(request_type = \\$1 OR \\(request_type = 0 AND stream = FALSE AND openai_ws_mode = FALSE\\)\\).*GROUP BY GROUPING SETS"). WithArgs(requestType). WillReturnRows(sqlmock.NewRows([]string{ - "total_requests", - "total_input_tokens", - "total_output_tokens", - "total_cache_tokens", - "total_cache_creation_tokens", - "total_cache_read_tokens", - "total_cost", - "total_actual_cost", - "total_account_cost", + "inbound_grouped", + "upstream_grouped", + "inbound_endpoint", + "upstream_endpoint", + "requests", + "input_tokens", + "output_tokens", + "cache_creation_tokens", + "cache_read_tokens", + "cost", + "actual_cost", + "account_cost", "avg_duration_ms", - }).AddRow(int64(1), int64(2), int64(3), int64(4), int64(1), int64(3), 1.2, 1.0, 1.2, 20.0)) - mock.ExpectQuery("SELECT COALESCE\\(NULLIF\\(TRIM\\(inbound_endpoint\\), ''\\), 'unknown'\\) AS endpoint"). - WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), requestType). - WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"})) - mock.ExpectQuery("SELECT COALESCE\\(NULLIF\\(TRIM\\(upstream_endpoint\\), ''\\), 'unknown'\\) AS endpoint"). - WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), requestType). - WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"})) - mock.ExpectQuery("SELECT CONCAT\\("). - WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), requestType). - WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"})) + }).AddRow(1, 1, nil, nil, int64(1), int64(2), int64(3), int64(1), int64(3), 1.2, 1.0, 1.2, 20.0)) stats, err := repo.GetStatsWithFilters(context.Background(), filters) require.NoError(t, err) @@ -689,19 +683,12 @@ func TestUsageLogRepositoryGetStatsWithFiltersAlwaysReturnsAccountCost(t *testin // No AccountID filter set - TotalAccountCost should still be returned filters := usagestats.UsageLogFilters{} - mock.ExpectQuery("FROM usage_logs"). + mock.ExpectQuery("(?s)FROM usage_logs.*GROUP BY GROUPING SETS"). WillReturnRows(sqlmock.NewRows([]string{ - "total_requests", "total_input_tokens", "total_output_tokens", - "total_cache_tokens", "total_cache_creation_tokens", "total_cache_read_tokens", - "total_cost", "total_actual_cost", - "total_account_cost", "avg_duration_ms", - }).AddRow(int64(50), int64(1000), int64(2000), int64(100), int64(60), int64(40), 15.0, 12.5, 11.0, 100.0)) - mock.ExpectQuery("SELECT COALESCE\\(NULLIF\\(TRIM\\(inbound_endpoint\\)"). - WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"})) - mock.ExpectQuery("SELECT COALESCE\\(NULLIF\\(TRIM\\(upstream_endpoint\\)"). - WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"})) - mock.ExpectQuery("SELECT CONCAT\\("). - WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"})) + "inbound_grouped", "upstream_grouped", "inbound_endpoint", "upstream_endpoint", + "requests", "input_tokens", "output_tokens", "cache_creation_tokens", "cache_read_tokens", + "cost", "actual_cost", "account_cost", "avg_duration_ms", + }).AddRow(1, 1, nil, nil, int64(50), int64(1000), int64(2000), int64(60), int64(40), 15.0, 12.5, 11.0, 100.0)) stats, err := repo.GetStatsWithFilters(context.Background(), filters) require.NoError(t, err) diff --git a/backend/internal/repository/usage_log_repo_stats.go b/backend/internal/repository/usage_log_repo_stats.go index 53d60b7f4..9385900a7 100644 --- a/backend/internal/repository/usage_log_repo_stats.go +++ b/backend/internal/repository/usage_log_repo_stats.go @@ -3,9 +3,9 @@ package repository import ( "context" "database/sql" - "errors" "fmt" "os" + "sort" "strings" "time" @@ -14,7 +14,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/lib/pq" - "golang.org/x/sync/errgroup" ) // GetUserStatsAggregated returns aggregated usage statistics for a user using database-level aggregation @@ -696,108 +695,131 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us } query := fmt.Sprintf(` + WITH scoped AS ( + SELECT + COALESCE(NULLIF(TRIM(inbound_endpoint), ''), 'unknown') AS inbound_endpoint, + COALESCE(NULLIF(TRIM(upstream_endpoint), ''), 'unknown') AS upstream_endpoint, + input_tokens, + output_tokens, + cache_creation_tokens, + cache_read_tokens, + total_cost, + actual_cost, + COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1) AS account_cost, + duration_ms + FROM usage_logs + %s + ) SELECT - COUNT(*) as total_requests, - COALESCE(SUM(input_tokens), 0) as total_input_tokens, - COALESCE(SUM(output_tokens), 0) as total_output_tokens, - COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens, - COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, - COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens, - COALESCE(SUM(total_cost), 0) as total_cost, - COALESCE(SUM(actual_cost), 0) as total_actual_cost, - COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as total_account_cost, - COALESCE(AVG(duration_ms), 0) as avg_duration_ms - FROM usage_logs - %s + GROUPING(inbound_endpoint) AS inbound_grouped, + GROUPING(upstream_endpoint) AS upstream_grouped, + inbound_endpoint, + upstream_endpoint, + COUNT(*) AS requests, + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens, + COALESCE(SUM(cache_creation_tokens), 0) AS cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, + COALESCE(SUM(total_cost), 0) AS cost, + COALESCE(SUM(actual_cost), 0) AS actual_cost, + COALESCE(SUM(account_cost), 0) AS account_cost, + COALESCE(AVG(duration_ms), 0) AS avg_duration_ms + FROM scoped + GROUP BY GROUPING SETS ( + (), + (inbound_endpoint), + (upstream_endpoint), + (inbound_endpoint, upstream_endpoint) + ) `, buildWhere(conditions)) stats := &UsageStats{} var totalAccountCost float64 - - start := time.Unix(0, 0).UTC() - if filters.StartTime != nil { - start = *filters.StartTime - } - end := time.Now().UTC() - if filters.EndTime != nil { - end = *filters.EndTime + useAccountCostForEndpoint := filters.AccountID > 0 && filters.UserID == 0 && filters.APIKeyID == 0 + rows, err := r.sql.QueryContext(ctx, query, args...) + if err != nil { + return nil, err } + defer func() { _ = rows.Close() }() - var endpoints, upstreamEndpoints, endpointPaths []EndpointStat - - // 汇总查询:失败即致命。 - runSummary := func(c context.Context) error { - return scanSingleRow( - c, r.sql, query, args, - &stats.TotalRequests, - &stats.TotalInputTokens, - &stats.TotalOutputTokens, - &stats.TotalCacheTokens, - &stats.TotalCacheCreationTokens, - &stats.TotalCacheReadTokens, - &stats.TotalCost, - &stats.TotalActualCost, - &totalAccountCost, - &stats.AverageDurationMs, + for rows.Next() { + var ( + inboundGrouped, upstreamGrouped int + inboundEndpoint, upstreamEndpoint sql.NullString + requests, inputTokens, outputTokens, cacheCreationTokens, cacheReads int64 + cost, actualCost, accountCost, averageDurationMs float64 ) - } - // endpoint 明细:best-effort(失败 log + 返空),不致命。 - runEndpoints := func(c context.Context) { - res, err := r.getEndpointStatsByColumnWithFilters(c, "inbound_endpoint", start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) - if err != nil { - if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { - logger.LegacyPrintf("repository.usage_log", "GetEndpointStatsWithFilters failed in GetStatsWithFilters: %v", err) - } - res = []EndpointStat{} + if err := rows.Scan( + &inboundGrouped, + &upstreamGrouped, + &inboundEndpoint, + &upstreamEndpoint, + &requests, + &inputTokens, + &outputTokens, + &cacheCreationTokens, + &cacheReads, + &cost, + &actualCost, + &accountCost, + &averageDurationMs, + ); err != nil { + return nil, err } - endpoints = res - } - runUpstream := func(c context.Context) { - res, err := r.getEndpointStatsByColumnWithFilters(c, "upstream_endpoint", start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) - if err != nil { - if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { - logger.LegacyPrintf("repository.usage_log", "GetUpstreamEndpointStatsWithFilters failed in GetStatsWithFilters: %v", err) - } - res = []EndpointStat{} + + totalTokens := inputTokens + outputTokens + cacheCreationTokens + cacheReads + endpointActualCost := actualCost + if useAccountCostForEndpoint { + endpointActualCost = accountCost } - upstreamEndpoints = res - } - runPaths := func(c context.Context) { - res, err := r.getEndpointPathStatsWithFilters(c, start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode) - if err != nil { - if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { - logger.LegacyPrintf("repository.usage_log", "getEndpointPathStatsWithFilters failed in GetStatsWithFilters: %v", err) - } - res = []EndpointStat{} + + switch { + case inboundGrouped == 1 && upstreamGrouped == 1: + stats.TotalRequests = requests + stats.TotalInputTokens = inputTokens + stats.TotalOutputTokens = outputTokens + stats.TotalCacheCreationTokens = cacheCreationTokens + stats.TotalCacheReadTokens = cacheReads + stats.TotalCacheTokens = cacheCreationTokens + cacheReads + stats.TotalCost = cost + stats.TotalActualCost = actualCost + totalAccountCost = accountCost + stats.AverageDurationMs = averageDurationMs + case inboundGrouped == 0 && upstreamGrouped == 1: + stats.Endpoints = append(stats.Endpoints, EndpointStat{ + Endpoint: inboundEndpoint.String, Requests: requests, TotalTokens: totalTokens, + Cost: cost, ActualCost: endpointActualCost, + }) + case inboundGrouped == 1 && upstreamGrouped == 0: + stats.UpstreamEndpoints = append(stats.UpstreamEndpoints, EndpointStat{ + Endpoint: upstreamEndpoint.String, Requests: requests, TotalTokens: totalTokens, + Cost: cost, ActualCost: endpointActualCost, + }) + case inboundGrouped == 0 && upstreamGrouped == 0: + stats.EndpointPaths = append(stats.EndpointPaths, EndpointStat{ + Endpoint: inboundEndpoint.String + " -> " + upstreamEndpoint.String, + Requests: requests, TotalTokens: totalTokens, Cost: cost, ActualCost: endpointActualCost, + }) } - endpointPaths = res + } + if err := rows.Err(); err != nil { + return nil, err } - if r.db != nil { - // 生产路径:r.sql 是 *sql.DB 连接池,可并发。4 条查询并行,延迟取最大值。 - g, gctx := errgroup.WithContext(ctx) - g.Go(func() error { return runSummary(gctx) }) - g.Go(func() error { runEndpoints(gctx); return nil }) - g.Go(func() error { runUpstream(gctx); return nil }) - g.Go(func() error { runPaths(gctx); return nil }) - if err := g.Wait(); err != nil { - return nil, err - } - } else { - // 事务路径(ent.Tx 不能并发查询):顺序执行,行为与重构前一致。 - if err := runSummary(ctx); err != nil { - return nil, err - } - runEndpoints(ctx) - runUpstream(ctx) - runPaths(ctx) + sortEndpointStats := func(values []EndpointStat) { + sort.Slice(values, func(i, j int) bool { + if values[i].Requests != values[j].Requests { + return values[i].Requests > values[j].Requests + } + return values[i].Endpoint < values[j].Endpoint + }) } + sortEndpointStats(stats.Endpoints) + sortEndpointStats(stats.UpstreamEndpoints) + sortEndpointStats(stats.EndpointPaths) stats.TotalAccountCost = &totalAccountCost stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens - stats.Endpoints = endpoints - stats.UpstreamEndpoints = upstreamEndpoints - stats.EndpointPaths = endpointPaths return stats, nil } @@ -882,78 +904,6 @@ func (r *usageLogRepository) getEndpointStatsByColumnWithFilters(ctx context.Con return results, nil } -func (r *usageLogRepository) getEndpointPathStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []EndpointStat, err error) { - actualCostExpr := "COALESCE(SUM(actual_cost), 0) as actual_cost" - if accountID > 0 && userID == 0 && apiKeyID == 0 { - actualCostExpr = "COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as actual_cost" - } - - query := fmt.Sprintf(` - SELECT - CONCAT( - COALESCE(NULLIF(TRIM(inbound_endpoint), ''), 'unknown'), - ' -> ', - COALESCE(NULLIF(TRIM(upstream_endpoint), ''), 'unknown') - ) AS endpoint, - COUNT(*) AS requests, - COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) AS total_tokens, - COALESCE(SUM(total_cost), 0) as cost, - %s - FROM usage_logs - WHERE created_at >= $1 AND created_at < $2 - `, actualCostExpr) - - args := []any{startTime, endTime} - if userID > 0 { - query += fmt.Sprintf(" AND user_id = $%d", len(args)+1) - args = append(args, userID) - } - if apiKeyID > 0 { - query += fmt.Sprintf(" AND api_key_id = $%d", len(args)+1) - args = append(args, apiKeyID) - } - if accountID > 0 { - query += fmt.Sprintf(" AND account_id = $%d", len(args)+1) - args = append(args, accountID) - } - if groupID > 0 { - query += fmt.Sprintf(" AND group_id = $%d", len(args)+1) - args = append(args, groupID) - } - query, args = appendUsageLogModelQueryFilter(query, args, model, modelSource) - query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream) - if billingType != nil { - query += fmt.Sprintf(" AND billing_type = $%d", len(args)+1) - args = append(args, int16(*billingType)) - } - query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "") - query += " GROUP BY endpoint ORDER BY requests DESC" - - rows, err := r.sql.QueryContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer func() { - if closeErr := rows.Close(); closeErr != nil && err == nil { - err = closeErr - results = nil - } - }() - - results = make([]EndpointStat, 0) - for rows.Next() { - var row EndpointStat - if err := rows.Scan(&row.Endpoint, &row.Requests, &row.TotalTokens, &row.Cost, &row.ActualCost); err != nil { - return nil, err - } - results = append(results, row) - } - if err := rows.Err(); err != nil { - return nil, err - } - return results, nil -} - // GetEndpointStatsWithFilters returns inbound endpoint statistics with optional filters. func (r *usageLogRepository) GetEndpointStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) ([]EndpointStat, error) { return r.getEndpointStatsByColumnWithFilters(ctx, "inbound_endpoint", startTime, endTime, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "") diff --git a/backend/internal/repository/usage_log_repo_stats_integration_test.go b/backend/internal/repository/usage_log_repo_stats_integration_test.go index e9025b1bd..754ea32a9 100644 --- a/backend/internal/repository/usage_log_repo_stats_integration_test.go +++ b/backend/internal/repository/usage_log_repo_stats_integration_test.go @@ -43,6 +43,15 @@ func TestUsageLog_UpstreamModelMismatchFilterAndPartialIndex(t *testing.T) { }) require.NoError(t, err) require.Equal(t, int64(1), stats.TotalRequests) + require.Equal(t, []usagestats.EndpointStat{{ + Endpoint: "unknown", Requests: 1, TotalTokens: 2, + }}, stats.Endpoints) + require.Equal(t, []usagestats.EndpointStat{{ + Endpoint: "unknown", Requests: 1, TotalTokens: 2, + }}, stats.UpstreamEndpoints) + require.Equal(t, []usagestats.EndpointStat{{ + Endpoint: "unknown -> unknown", Requests: 1, TotalTokens: 2, + }}, stats.EndpointPaths) trend, err := repo.GetUsageTrendWithUsageFilters(ctx, start, end, "hour", usagestats.UsageLogFilters{ UserID: user.ID, UpstreamModelMismatch: &trueValue, @@ -53,24 +62,45 @@ func TestUsageLog_UpstreamModelMismatchFilterAndPartialIndex(t *testing.T) { _, err = tx.ExecContext(ctx, "SET LOCAL enable_seqscan = off") require.NoError(t, err) - rows, err := tx.QueryContext(ctx, ` + assertPlanUsesIndex := func(query, indexName string, args ...any) { + rows, queryErr := tx.QueryContext(ctx, query, args...) + require.NoError(t, queryErr) + var planLines []string + for rows.Next() { + var line string + require.NoError(t, rows.Scan(&line)) + planLines = append(planLines, line) + } + require.NoError(t, rows.Err()) + require.NoError(t, rows.Close()) + require.Contains(t, strings.Join(planLines, "\n"), indexName) + } + assertPlanUsesIndex(` EXPLAIN (COSTS OFF) SELECT id FROM usage_logs WHERE upstream_model_mismatch IS TRUE ORDER BY created_at DESC, id DESC LIMIT 100 -`) - require.NoError(t, err) - defer func() { require.NoError(t, rows.Close()) }() - var planLines []string - for rows.Next() { - var line string - require.NoError(t, rows.Scan(&line)) - planLines = append(planLines, line) - } - require.NoError(t, rows.Err()) - require.Contains(t, strings.Join(planLines, "\n"), usageLogsUpstreamModelMismatchIndex) +`, usageLogsUpstreamModelMismatchIndex) + assertPlanUsesIndex(` +EXPLAIN (COSTS OFF) +SELECT id +FROM usage_logs +WHERE COALESCE(NULLIF(TRIM(requested_model), ''), model) = $1 + AND created_at >= $2 AND created_at < $3 +ORDER BY created_at DESC, id DESC +LIMIT 100 +`, usageLogsEffectiveRequestedModelIndex, "gpt-5.5", start, end) + assertPlanUsesIndex(` +EXPLAIN (COSTS OFF) +SELECT id +FROM usage_logs +WHERE COALESCE(NULLIF(TRIM(upstream_model), ''), model) = $1 + AND created_at >= $2 AND created_at < $3 +ORDER BY created_at DESC, id DESC +LIMIT 100 +`, usageLogsEffectiveUpstreamModelIndex, "gpt-5.5", start, end) } func TestUsageLog_GetStatsWithFilters_AggregatesAndEndpoints(t *testing.T) { diff --git a/backend/internal/server/routes/composite_platform_test.go b/backend/internal/server/routes/composite_platform_test.go index 682d129eb..3e64d2fbb 100644 --- a/backend/internal/server/routes/composite_platform_test.go +++ b/backend/internal/server/routes/composite_platform_test.go @@ -133,6 +133,78 @@ func TestCompositeTargetPlatformMiddlewareUsesExplicitRouteAndRewritesBody(t *te require.Equal(t, http.StatusNoContent, w.Code) } +func TestCompositeTargetPlatformMiddlewareRewritesNestedLiveModel(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + resolver := service.NewCompositeRouteResolver(compositeRouteRepoStub{ + routes: []service.CompositeModelRoute{ + { + ID: 1, + GroupID: 1, + PublicModel: "live-alias", + MatchType: service.CompositeRouteMatchExact, + TargetPlatform: service.PlatformOpenAI, + UpstreamModel: "gpt-live", + Endpoint: service.CompositeRouteEndpointAny, + Priority: 100, + Enabled: true, + }, + }, + }) + router.Use(gin.HandlerFunc(servermiddleware.APIKeyAuthMiddleware(func(c *gin.Context) { + groupID := int64(1) + c.Set(string(servermiddleware.ContextKeyAPIKey), &service.APIKey{ + GroupID: &groupID, + Group: &service.Group{ID: groupID, Platform: service.PlatformComposite}, + }) + c.Next() + }))) + router.Use(compositeTargetPlatformMiddleware(resolver)) + router.POST("/backend-api/codex/realtime/calls", func(c *gin.Context) { + platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()) + require.True(t, ok) + require.Equal(t, service.PlatformOpenAI, platform) + + body, err := io.ReadAll(c.Request.Body) + require.NoError(t, err) + require.JSONEq(t, `{"session":{"model":"gpt-live"},"sdp":"v=0"}`, string(body)) + c.Status(http.StatusNoContent) + }) + + req := httptest.NewRequest( + http.MethodPost, + "/backend-api/codex/realtime/calls", + strings.NewReader(`{"session":{"model":"live-alias"},"sdp":"v=0"}`), + ) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusNoContent, w.Code) +} + +func TestCompositeRequestModelFromMultipartLiveSession(t *testing.T) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + require.NoError(t, writer.WriteField("sdp", "v=0")) + require.NoError(t, writer.WriteField("session", `{"model":"live-alias"}`)) + require.NoError(t, writer.Close()) + + require.Equal(t, "live-alias", compositeRequestModelFromBody(writer.FormDataContentType(), body.Bytes())) +} + +func TestCompositeCodexControlPathsUseResponsesRoutes(t *testing.T) { + for _, path := range []string{ + "/v1/alpha/search", + "/backend-api/codex/alpha/search", + "/v1/live", + "/backend-api/codex/realtime/calls", + } { + require.Equal(t, service.CompositeRouteEndpointResponses, compositeRouteEndpointForPath(path), "path=%s", path) + } +} + func TestCompositeTargetPlatformMiddlewareUsesExplicitRouteForMultipartImages(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index 3c4b33f51..de24061db 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -55,9 +55,6 @@ func RegisterGatewayRoutes( return false } } - isOpenAIGatewayPlatform := func(c *gin.Context) bool { - return getGroupPlatform(c) == service.PlatformOpenAI - } countTokensHandler := func(c *gin.Context) { switch getGroupPlatform(c) { case service.PlatformOpenAI, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek: @@ -69,9 +66,12 @@ func RegisterGatewayRoutes( } } modelsHandler := func(c *gin.Context) { - if isOpenAIGatewayPlatform(c) && c.Query("client_version") != "" { - h.OpenAIGateway.CodexModels(c) - return + if c.Query("client_version") != "" { + switch getGroupPlatform(c) { + case service.PlatformOpenAI, service.PlatformComposite: + h.OpenAIGateway.CodexModels(c) + return + } } h.Gateway.Models(c) } @@ -170,6 +170,10 @@ func RegisterGatewayRoutes( }) return } + if service.IsOpenAIResponsesInputTokensRequestPath(c) && isOpenAIResponsesCompatibleGatewayPlatform(c) { + h.OpenAIGateway.ResponsesInputTokens(c) + return + } next(c) } } @@ -551,8 +555,10 @@ func compositeTargetPlatformMiddleware(resolver *service.CompositeRouteResolver) if decision.Matched { c.Request = c.Request.WithContext(service.WithCompositeRouteDecision(c.Request.Context(), decision)) if upstreamModel := strings.TrimSpace(decision.UpstreamModel); upstreamModel != "" && upstreamModel != model && gjson.ValidBytes(body) { - if rewritten, rewriteErr := sjson.SetBytes(body, "model", upstreamModel); rewriteErr == nil { - body = rewritten + if _, modelPath := compositeJSONRequestModel(body); modelPath != "" { + if rewritten, rewriteErr := sjson.SetBytes(body, modelPath, upstreamModel); rewriteErr == nil { + body = rewritten + } } } } @@ -563,12 +569,25 @@ func compositeTargetPlatformMiddleware(resolver *service.CompositeRouteResolver) } func compositeRequestModelFromBody(contentType string, body []byte) string { - if model := strings.TrimSpace(gjson.GetBytes(body, "model").String()); model != "" { + if model, _ := compositeJSONRequestModel(body); model != "" { return model } return compositeMultipartModelFromBody(contentType, body) } +func compositeJSONRequestModel(body []byte) (string, string) { + for _, path := range []string{"model", "session.model"} { + model := gjson.GetBytes(body, path) + if model.Type != gjson.String { + continue + } + if value := strings.TrimSpace(model.String()); value != "" { + return value, path + } + } + return "", "" +} + func compositeMultipartModelFromBody(contentType string, body []byte) string { mediaType, params, err := mime.ParseMediaType(strings.TrimSpace(contentType)) if err != nil || !strings.EqualFold(mediaType, "multipart/form-data") { @@ -587,14 +606,22 @@ func compositeMultipartModelFromBody(contentType string, body []byte) string { if err != nil { return "" } - if part.FormName() != "model" || part.FileName() != "" { + fieldName := part.FormName() + if part.FileName() != "" || (fieldName != "model" && fieldName != "session") { continue } data, err := io.ReadAll(part) if err != nil { return "" } - return strings.TrimSpace(string(data)) + switch fieldName { + case "model": + return strings.TrimSpace(string(data)) + case "session": + if model, _ := compositeJSONRequestModel(data); model != "" { + return model + } + } } } @@ -670,7 +697,10 @@ func compositeRouteEndpointForPath(path string) string { return service.CompositeRouteEndpointCountTokens case strings.Contains(path, "/messages"): return service.CompositeRouteEndpointMessages - case strings.Contains(path, "/responses"): + case strings.Contains(path, "/responses"), + strings.Contains(path, "/alpha/search"), + strings.Contains(path, "/realtime/calls"), + strings.HasSuffix(strings.TrimRight(path, "/"), "/live"): return service.CompositeRouteEndpointResponses case strings.Contains(path, "/chat/completions"): return service.CompositeRouteEndpointChatCompletions diff --git a/backend/internal/server/routes/gateway_test.go b/backend/internal/server/routes/gateway_test.go index ded7bd298..362054a7d 100644 --- a/backend/internal/server/routes/gateway_test.go +++ b/backend/internal/server/routes/gateway_test.go @@ -94,7 +94,7 @@ func TestGatewayRoutesOpenAIAlphaSearchPathsAreRegistered(t *testing.T) { } } -func TestGatewayRoutesAlphaSearchRejectsNonOpenAIGroup(t *testing.T) { +func TestGatewayRoutesAlphaSearchRejectsUnsupportedGroup(t *testing.T) { router := newGatewayRoutesTestRouter(service.PlatformGrok) req := httptest.NewRequest(http.MethodPost, "/v1/alpha/search", strings.NewReader(`{"model":"gpt-5.6-sol"}`)) req.Header.Set("Content-Type", "application/json") @@ -103,7 +103,7 @@ func TestGatewayRoutesAlphaSearchRejectsNonOpenAIGroup(t *testing.T) { router.ServeHTTP(w, req) require.Equal(t, http.StatusNotFound, w.Code) - require.Contains(t, w.Body.String(), "only available for OpenAI groups") + require.Contains(t, w.Body.String(), "only available for OpenAI and Composite groups") } func TestGatewayRoutesOpenAIImagesPathsAreRegistered(t *testing.T) { diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index cd66ef89c..20d4ec582 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1310,6 +1310,13 @@ func (a *Account) GetOpenAIBaseURL() string { if !a.IsOpenAI() && !a.IsCNProvider() { return "" } + if a.IsCNProvider() && a.IsAdaptiveAPIProtocol() { + if baseURLs, ok := a.Credentials["api_base_urls"].(map[string]any); ok { + if baseURL, ok := baseURLs[APIProtocolChatCompletions].(string); ok && strings.TrimSpace(baseURL) != "" { + return strings.TrimSpace(baseURL) + } + } + } if a.Type == AccountTypeAPIKey || a.Type == AccountTypeUpstream { if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" { return baseURL @@ -1361,6 +1368,8 @@ func (a *Account) GetAPIProtocol() string { return APIProtocolChatCompletions } switch strings.TrimSpace(a.GetCredential("api_protocol")) { + case APIProtocolAdaptive: + return APIProtocolAdaptive case APIProtocolAnthropic: return APIProtocolAnthropic case APIProtocolResponses: @@ -1373,6 +1382,66 @@ func (a *Account) GetAPIProtocol() string { return APIProtocolChatCompletions } +// IsAdaptiveAPIProtocol 报告账号是否按入站协议动态选择供应商原生端点。 +func (a *Account) IsAdaptiveAPIProtocol() bool { + return a.GetAPIProtocol() == APIProtocolAdaptive +} + +// GetCNProtocolBaseURL 返回国产供应商指定协议的上游 base URL。 +// adaptive 账号优先使用 api_base_urls 中的分协议地址,缺失时按平台和 +// account_mode 使用官方默认端点。base_url 继续作为 Chat Completions 地址兼容旧字段。 +func (a *Account) GetCNProtocolBaseURL(protocol string) string { + if a == nil || !a.IsCNProvider() { + return "" + } + if a.IsAdaptiveAPIProtocol() { + if baseURLs, ok := a.Credentials["api_base_urls"].(map[string]any); ok { + if baseURL, ok := baseURLs[protocol].(string); ok && strings.TrimSpace(baseURL) != "" { + return strings.TrimSpace(baseURL) + } + } + if protocol == APIProtocolChatCompletions { + if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" { + return baseURL + } + } + } + return a.defaultCNProtocolBaseURL(protocol) +} + +func (a *Account) defaultCNProtocolBaseURL(protocol string) string { + switch protocol { + case APIProtocolAnthropic: + switch a.Platform { + case PlatformKimi: + if a.GetAccountMode() == AccountModeCoding { + return DefaultKimiCodingAnthropicBaseURL + } + return DefaultKimiPayGAnthropicBaseURL + case PlatformZhipu: + return DefaultZhipuAnthropicBaseURL + case PlatformDeepseek: + return DefaultDeepseekAnthropicBaseURL + } + case APIProtocolChatCompletions, APIProtocolResponses: + switch a.Platform { + case PlatformKimi: + if a.GetAccountMode() == AccountModeCoding { + return DefaultKimiCodingBaseURL + } + return DefaultKimiPayGBaseURL + case PlatformZhipu: + if a.GetAccountMode() == AccountModeCoding { + return DefaultZhipuCodingBaseURL + } + return DefaultZhipuPayGBaseURL + case PlatformDeepseek: + return DefaultDeepseekBaseURL + } + } + return "" +} + // IsAnthropicProtocol 报告账号是否以原生 Anthropic 协议接入上游 // (/v1/messages 直通,适配 Claude Code 等客户端)。 func (a *Account) IsAnthropicProtocol() bool { @@ -1383,9 +1452,12 @@ func (a *Account) IsAnthropicProtocol() bool { // (上游路径为 {base}/v1/messages)。优先取凭证 base_url,缺失时按 // 供应商 × 接入模式返回默认端点。非 Anthropic 协议账号返回空串。 func (a *Account) GetAnthropicProtocolBaseURL() string { - if a == nil || !a.IsAnthropicProtocol() { + if a == nil || (!a.IsAnthropicProtocol() && !a.IsAdaptiveAPIProtocol()) { return "" } + if a.IsAdaptiveAPIProtocol() { + return a.GetCNProtocolBaseURL(APIProtocolAnthropic) + } if a.Type == AccountTypeAPIKey || a.Type == AccountTypeUpstream { if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" { return baseURL diff --git a/backend/internal/service/account_header_override.go b/backend/internal/service/account_header_override.go index 506a79084..078d98d83 100644 --- a/backend/internal/service/account_header_override.go +++ b/backend/internal/service/account_header_override.go @@ -9,8 +9,8 @@ import ( "golang.org/x/net/http/httpguts" ) -// 请求头覆写(header override):对 Anthropic / OpenAI 平台的 api_key 账号 -// 以及 Grok 平台的 api_key / oauth 账号生效。 +// 请求头覆写(header override):对 Anthropic / OpenAI / Kimi / Zhipu / DeepSeek +// 平台的 api_key 账号,以及 Grok 平台的 api_key / oauth 账号生效。 // 管理员在账号上配置一组 header name -> value,转发到上游前用配置值覆盖同名请求头 // (匹配不区分大小写);value 为空的条目视为"未填写",不参与覆盖。 const ( @@ -70,14 +70,15 @@ func isHeaderOverrideBlockedName(lowerName string) bool { } // IsHeaderOverrideEligible 报告账号类型是否支持请求头覆写。 -// Anthropic / OpenAI 仅开放 api_key 账号;Grok 额外开放 oauth 账号—— +// Anthropic / OpenAI / Kimi / Zhipu / DeepSeek 仅开放 api_key 账号; +// Grok 额外开放 oauth 账号—— // 订阅流量改发自定义转发地址时,通常需要补充中间层要求的准入头。 func (a *Account) IsHeaderOverrideEligible() bool { if a == nil { return false } switch a.Platform { - case PlatformAnthropic, PlatformOpenAI: + case PlatformAnthropic, PlatformOpenAI, PlatformKimi, PlatformZhipu, PlatformDeepseek: return a.Type == AccountTypeAPIKey case PlatformGrok: return a.Type == AccountTypeAPIKey || a.Type == AccountTypeOAuth diff --git a/backend/internal/service/account_header_override_test.go b/backend/internal/service/account_header_override_test.go index 998fa543b..9d99257f2 100644 --- a/backend/internal/service/account_header_override_test.go +++ b/backend/internal/service/account_header_override_test.go @@ -27,8 +27,14 @@ func TestIsHeaderOverrideEligible(t *testing.T) { }{ {"anthropic apikey", PlatformAnthropic, AccountTypeAPIKey, true}, {"openai apikey", PlatformOpenAI, AccountTypeAPIKey, true}, + {"kimi apikey", PlatformKimi, AccountTypeAPIKey, true}, + {"zhipu apikey", PlatformZhipu, AccountTypeAPIKey, true}, + {"deepseek apikey", PlatformDeepseek, AccountTypeAPIKey, true}, {"anthropic oauth", PlatformAnthropic, AccountTypeOAuth, false}, {"openai oauth", PlatformOpenAI, AccountTypeOAuth, false}, + {"kimi oauth", PlatformKimi, AccountTypeOAuth, false}, + {"zhipu oauth", PlatformZhipu, AccountTypeOAuth, false}, + {"deepseek oauth", PlatformDeepseek, AccountTypeOAuth, false}, {"gemini apikey", PlatformGemini, AccountTypeAPIKey, false}, {"grok apikey", PlatformGrok, AccountTypeAPIKey, true}, {"grok oauth", PlatformGrok, AccountTypeOAuth, true}, diff --git a/backend/internal/service/account_stats_pricing.go b/backend/internal/service/account_stats_pricing.go index df9e8e05a..8d5bc144f 100644 --- a/backend/internal/service/account_stats_pricing.go +++ b/backend/internal/service/account_stats_pricing.go @@ -68,7 +68,7 @@ func tryModelFilePricing(billingService *BillingService, model string, tokens Us return nil } normalizedTier := normalizeBillingServiceTier(serviceTier) - if normalizedTier == "priority" || normalizedTier == "flex" || + if normalizedTier == "priority" || normalizedTier == "fast" || normalizedTier == "flex" || billingService.shouldApplySessionLongContextPricing(tokens, pricing) { breakdown, err := billingService.CalculateCostWithServiceTier(model, tokens, 1, normalizedTier) if err != nil || breakdown == nil || breakdown.TotalCost <= 0 { diff --git a/backend/internal/service/account_stats_pricing_test.go b/backend/internal/service/account_stats_pricing_test.go index 48336a583..1bd28896f 100644 --- a/backend/internal/service/account_stats_pricing_test.go +++ b/backend/internal/service/account_stats_pricing_test.go @@ -768,6 +768,26 @@ func TestResolveAccountStatsCost_FallsBackToLiteLLM(t *testing.T) { require.InDelta(t, 0.2, *result, 1e-12) } +func TestResolveAccountStatsCost_FallbackHonorsAnthropicFast(t *testing.T) { + channel := &Channel{ID: 1, Status: StatusActive} + cs := newTestChannelServiceForStats(t, channel, 10, "anthropic") + bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{ + "claude-opus-5": { + InputPricePerToken: 5e-6, + OutputPricePerToken: 25e-6, + }, + }) + + result := resolveAccountStatsCost( + context.Background(), cs, bs, + 1, 10, "claude-opus-5", + UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000}, + 1, 0, "fast", + ) + require.NotNil(t, result) + require.InDelta(t, 60, *result, 1e-12) +} + func TestResolveAccountStatsCost_Gemini36FlashTierUsesFallbackPricing(t *testing.T) { channel := &Channel{ ID: 1, diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index f92bd4edc..6f7e1463f 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -286,6 +286,15 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int } // Route to platform-specific test method + if account.IsCNProvider() { + switch account.GetAPIProtocol() { + case APIProtocolAdaptive: + return s.testCNProviderAdaptiveConnection(c, account, modelID, prompt) + case APIProtocolChatCompletions: + return s.testCNProviderChatCompletionsConnection(c, account, modelID, prompt) + } + } + if account.IsOpenAI() { return s.testOpenAIAccountConnection(c, account, modelID, prompt, normalizeAccountTestMode(mode)) } @@ -305,6 +314,27 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int return s.testClaudeAccountConnection(c, account, modelID) } +func (s *AccountTestService) testCNProviderChatCompletionsConnection(c *gin.Context, account *Account, modelID string, prompt string) error { + testModelID := strings.TrimSpace(modelID) + if testModelID == "" { + testModelID = openai.DefaultTestModel + } + testModelID = account.GetMappedModel(testModelID) + + authToken := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) + if authToken == "" { + return s.sendErrorAndEnd(c, "No API key available") + } + + baseURL := account.GetOpenAIBaseURL() + normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid base URL: %s", err.Error())) + } + + return s.testOpenAIChatCompletionsConnection(c, account, testModelID, prompt, normalizedBaseURL, authToken) +} + // testClaudeAccountConnection tests an Anthropic Claude account's connection func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account *Account, modelID string) error { ctx := c.Request.Context() @@ -3046,6 +3076,13 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co } func (s *AccountTestService) sendEvent(c *gin.Context, event TestEvent) { + if event.Type == "test_complete" { + if suppress, ok := c.Get(accountTestSuppressCompletionContextKey); ok { + if suppressCompletion, _ := suppress.(bool); suppressCompletion { + return + } + } + } eventJSON, _ := json.Marshal(event) if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", eventJSON); err != nil { log.Printf("failed to write SSE event: %v", err) diff --git a/backend/internal/service/account_test_service_cn_adaptive.go b/backend/internal/service/account_test_service_cn_adaptive.go new file mode 100644 index 000000000..ae52e4f11 --- /dev/null +++ b/backend/internal/service/account_test_service_cn_adaptive.go @@ -0,0 +1,206 @@ +package service + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/gin-gonic/gin" +) + +const accountTestSuppressCompletionContextKey = "account_test_suppress_completion" + +// testCNProviderAdaptiveConnection verifies every native endpoint used by an +// adaptive CN-provider account. Kimi and Zhipu use Chat Completions plus +// Anthropic; DeepSeek additionally uses its native Responses endpoint. +func (s *AccountTestService) testCNProviderAdaptiveConnection(c *gin.Context, account *Account, modelID string, prompt string) error { + testModelID := strings.TrimSpace(modelID) + if testModelID == "" { + testModelID = openai.DefaultTestModel + } + testModelID = account.GetMappedModel(testModelID) + + authToken := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) + if authToken == "" { + return s.sendErrorAndEnd(c, "No API key available") + } + + // The existing Chat probe owns the SSE lifecycle. Suppress intermediate + // completion events until every native adaptive endpoint has passed. + c.Set(accountTestSuppressCompletionContextKey, true) + defer c.Set(accountTestSuppressCompletionContextKey, false) + if err := s.testCNProviderChatCompletionsConnection(c, account, modelID, prompt); err != nil { + return err + } + + if err := s.testCNProviderAdaptiveAnthropicConnection(c, account, testModelID, authToken); err != nil { + return err + } + + if account.Platform == PlatformDeepseek { + if err := s.testCNProviderAdaptiveResponsesConnection(c, account, testModelID, authToken); err != nil { + return err + } + } + + c.Set(accountTestSuppressCompletionContextKey, false) + s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) + return nil +} + +func (s *AccountTestService) testCNProviderAdaptiveAnthropicConnection(c *gin.Context, account *Account, testModelID string, authToken string) error { + ctx := c.Request.Context() + baseURL, err := s.validateUpstreamBaseURL(account.GetCNProtocolBaseURL(APIProtocolAnthropic)) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid adaptive Anthropic base URL: %s", err.Error())) + } + apiURL := strings.TrimRight(baseURL, "/") + "/v1/messages" + + payload, err := createTestPayload(testModelID) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to create adaptive Anthropic test payload") + } + payloadBytes, _ := json.Marshal(payload) + + s.sendEvent(c, TestEvent{Type: "status", Text: "正在通过原生 /v1/messages 测试自适应 Anthropic 端点"}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadBytes)) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to create adaptive Anthropic request") + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("anthropic-version", "2023-06-01") + for key, value := range claude.DefaultHeaders { + req.Header.Set(key, value) + } + req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader) + setAnthropicAPIKeyAuthHeader(req.Header, account, authToken) + account.ApplyHeaderOverrides(req.Header) + + resp, err := s.doCNProviderAdaptiveRequest(req, account) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Adaptive Anthropic endpoint request failed: %s", err.Error())) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + errMsg := fmt.Sprintf("Adaptive Anthropic endpoint returned %d: %s", resp.StatusCode, string(body)) + if resp.StatusCode == http.StatusUnauthorized && s.accountRepo != nil { + _ = s.accountRepo.SetError(ctx, account.ID, errMsg) + } + return s.sendErrorAndEnd(c, errMsg) + } + + if err := s.processCNProviderAdaptiveAnthropicStream(c, resp.Body); err != nil { + return err + } + s.sendEvent(c, TestEvent{Type: "status", Text: "已通过原生 /v1/messages 验证"}) + return nil +} + +func (s *AccountTestService) processCNProviderAdaptiveAnthropicStream(c *gin.Context, body io.Reader) error { + reader := bufio.NewReader(body) + for { + line, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF { + return s.sendErrorAndEnd(c, "Adaptive Anthropic stream ended before message_stop") + } + return s.sendErrorAndEnd(c, fmt.Sprintf("Adaptive Anthropic stream read error: %s", err.Error())) + } + + line = strings.TrimSpace(line) + if line == "" || !sseDataPrefix.MatchString(line) { + continue + } + jsonStr := sseDataPrefix.ReplaceAllString(line, "") + if jsonStr == "[DONE]" { + return nil + } + + var data map[string]any + if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { + continue + } + switch eventType, _ := data["type"].(string); eventType { + case "content_block_delta": + if delta, ok := data["delta"].(map[string]any); ok { + if text, ok := delta["text"].(string); ok && text != "" { + s.sendEvent(c, TestEvent{Type: "content", Text: text}) + } + } + case "message_stop": + return nil + case "error": + errorMsg := "Unknown error" + if errData, ok := data["error"].(map[string]any); ok { + if message, ok := errData["message"].(string); ok && message != "" { + errorMsg = message + } + } + return s.sendErrorAndEnd(c, fmt.Sprintf("Adaptive Anthropic endpoint error: %s", errorMsg)) + } + } +} + +func (s *AccountTestService) testCNProviderAdaptiveResponsesConnection(c *gin.Context, account *Account, testModelID string, authToken string) error { + ctx := c.Request.Context() + baseURL, err := s.validateUpstreamBaseURL(account.GetCNProtocolBaseURL(APIProtocolResponses)) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid adaptive Responses base URL: %s", err.Error())) + } + apiURL := buildOpenAIResponsesURLForPlatform(account.Platform, baseURL) + + payload := createOpenAITestPayload(testModelID, false) + // DeepSeek's native Responses endpoint is stateless and does not need the + // OpenAI probe's synthetic instructions. + delete(payload, "instructions") + payloadBytes, _ := json.Marshal(payload) + payloadBytes = normalizeDeepSeekResponsesRequestBody(account, payloadBytes) + + s.sendEvent(c, TestEvent{Type: "status", Text: "正在通过原生 /responses 测试自适应 Responses 端点"}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadBytes)) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to create adaptive Responses request") + } + req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Authorization", "Bearer "+authToken) + applyOpenAICodexProbeHeaders(req.Header) + account.ApplyHeaderOverrides(req.Header) + + resp, err := s.doCNProviderAdaptiveRequest(req, account) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Adaptive Responses endpoint request failed: %s", err.Error())) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + errMsg := fmt.Sprintf("Adaptive Responses endpoint returned %d: %s", resp.StatusCode, string(body)) + if resp.StatusCode == http.StatusUnauthorized && s.accountRepo != nil { + _ = s.accountRepo.SetError(ctx, account.ID, errMsg) + } + return s.sendErrorAndEnd(c, errMsg) + } + + if err := s.processOpenAIStream(c, resp.Body); err != nil { + return err + } + s.sendEvent(c, TestEvent{Type: "status", Text: "已通过原生 /responses 验证"}) + return nil +} + +func (s *AccountTestService) doCNProviderAdaptiveRequest(req *http.Request, account *Account) (*http.Response, error) { + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + return s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) +} diff --git a/backend/internal/service/account_test_service_cn_adaptive_test.go b/backend/internal/service/account_test_service_cn_adaptive_test.go new file mode 100644 index 000000000..808aec33b --- /dev/null +++ b/backend/internal/service/account_test_service_cn_adaptive_test.go @@ -0,0 +1,191 @@ +//go:build unit + +package service + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func adaptiveCNAccountTestAccount(id int64, platform string) *Account { + return &Account{ + ID: id, + Name: "adaptive-cn-test", + Platform: platform, + Type: AccountTypeAPIKey, + Status: StatusActive, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-adaptive-test", + "api_protocol": APIProtocolAdaptive, + "api_base_urls": map[string]any{ + APIProtocolChatCompletions: "http://chat.example/v1", + APIProtocolAnthropic: "http://anthropic.example", + APIProtocolResponses: "http://responses.example", + }, + }, + } +} + +func adaptiveCNAccountTestService(account *Account, responses ...*http.Response) (*AccountTestService, *httpUpstreamRecorder) { + repo := &openAIAccountTestRepo{ + mockAccountRepoForGemini: mockAccountRepoForGemini{ + accountsByID: map[int64]*Account{account.ID: account}, + }, + } + upstream := &httpUpstreamRecorder{responses: responses} + return &AccountTestService{ + accountRepo: repo, + httpUpstream: upstream, + cfg: rawChatCompletionsTestConfig(), + }, upstream +} + +func adaptiveCNChatTestResponse() *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(`data: {"choices":[{"delta":{"content":"chat ok"},"finish_reason":"stop"}]} + +data: [DONE] + +`)), + } +} + +func adaptiveCNAnthropicTestResponse() *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(`data: {"type":"content_block_delta","delta":{"text":"anthropic ok"}} + +data: {"type":"message_stop"} + +`)), + } +} + +func adaptiveCNResponsesTestResponse() *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(`data: {"type":"response.output_text.delta","delta":"responses ok"} + +data: {"type":"response.completed"} + +`)), + } +} + +func TestAccountTestService_AdaptiveChatOnlyProvidersTestChatAndAnthropicEndpoints(t *testing.T) { + for index, testCase := range []struct { + name string + platform string + model string + }{ + {name: "Kimi", platform: PlatformKimi, model: "kimi-k2.5"}, + {name: "Zhipu", platform: PlatformZhipu, model: "glm-4.7"}, + } { + t.Run(testCase.name, func(t *testing.T) { + account := adaptiveCNAccountTestAccount(int64(301+index), testCase.platform) + svc, upstream := adaptiveCNAccountTestService( + account, + adaptiveCNChatTestResponse(), + adaptiveCNAnthropicTestResponse(), + ) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, testCase.model, "hello", AccountTestModeDefault) + + require.NoError(t, err) + require.Len(t, upstream.requests, 2) + require.Equal(t, "http://chat.example/v1/chat/completions", upstream.requests[0].URL.String()) + require.Equal(t, "http://anthropic.example/v1/messages", upstream.requests[1].URL.String()) + require.Equal(t, "Bearer sk-adaptive-test", upstream.requests[0].Header.Get("Authorization")) + require.Equal(t, "sk-adaptive-test", upstream.requests[1].Header.Get("x-api-key")) + require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_start"`)) + require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_complete"`)) + require.Contains(t, recorder.Body.String(), "已通过原生 /v1/messages 验证") + }) + } +} + +func TestAccountTestService_AdaptiveDeepSeekAlsoTestsResponsesEndpoint(t *testing.T) { + account := adaptiveCNAccountTestAccount(302, PlatformDeepseek) + svc, upstream := adaptiveCNAccountTestService( + account, + adaptiveCNChatTestResponse(), + adaptiveCNAnthropicTestResponse(), + adaptiveCNResponsesTestResponse(), + ) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "deepseek-chat", "", AccountTestModeDefault) + + require.NoError(t, err) + require.Len(t, upstream.requests, 3) + require.Equal(t, "http://responses.example/responses", upstream.requests[2].URL.String()) + require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.requests[2].Context())) + require.Equal(t, "Bearer sk-adaptive-test", upstream.requests[2].Header.Get("Authorization")) + require.True(t, gjson.GetBytes(upstream.bodies[2], "stream").Bool()) + require.False(t, gjson.GetBytes(upstream.bodies[2], "store").Bool()) + require.False(t, gjson.GetBytes(upstream.bodies[2], "instructions").Exists()) + require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_complete"`)) + require.Contains(t, recorder.Body.String(), "已通过原生 /responses 验证") +} + +func TestAccountTestService_AdaptiveStopsAndNamesFailingEndpoint(t *testing.T) { + account := adaptiveCNAccountTestAccount(303, PlatformDeepseek) + svc, upstream := adaptiveCNAccountTestService( + account, + adaptiveCNChatTestResponse(), + newJSONResponse(http.StatusNotFound, `{"error":{"message":"missing messages route"}}`), + ) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "deepseek-chat", "", AccountTestModeDefault) + + require.Error(t, err) + require.Contains(t, err.Error(), "Adaptive Anthropic endpoint returned 404") + require.Len(t, upstream.requests, 2) + require.Contains(t, recorder.Body.String(), `"type":"error"`) + require.NotContains(t, recorder.Body.String(), `"type":"test_complete"`) +} + +func TestAccountTestService_AdaptiveRejectsInvalidAnthropicSuccessBody(t *testing.T) { + account := adaptiveCNAccountTestAccount(305, PlatformKimi) + svc, upstream := adaptiveCNAccountTestService( + account, + adaptiveCNChatTestResponse(), + newJSONResponse(http.StatusOK, `not an Anthropic stream`), + ) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "kimi-k2.5", "", AccountTestModeDefault) + + require.Error(t, err) + require.Contains(t, err.Error(), "Adaptive Anthropic stream ended before message_stop") + require.Len(t, upstream.requests, 2) + require.Contains(t, recorder.Body.String(), `"type":"error"`) + require.NotContains(t, recorder.Body.String(), `"type":"test_complete"`) +} + +func TestAccountTestService_FixedCNChatProtocolStillTestsOnlyChatEndpoint(t *testing.T) { + account := adaptiveCNAccountTestAccount(304, PlatformZhipu) + account.Credentials["api_protocol"] = APIProtocolChatCompletions + account.Credentials["base_url"] = "http://fixed-chat.example/v1" + svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNChatTestResponse()) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "glm-4.7", "", AccountTestModeDefault) + + require.NoError(t, err) + require.Len(t, upstream.requests, 1) + require.Equal(t, "http://fixed-chat.example/v1/chat/completions", upstream.requests[0].URL.String()) + require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_complete"`)) +} diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index a04d3999e..2ce9d443e 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -509,6 +509,13 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64, for return s.getUsageForAccount(ctx, account, forceProbe) } +// GetUsageForAccount 已加载账号的使用量直通入口(配额监控 fetcher 复用, +// 避免缓存未命中时账号被加载两次——每次 GetByID 含 proxies/groups 联查)。 +func (s *AccountUsageService) GetUsageForAccount(ctx context.Context, account *Account, force ...bool) (*UsageInfo, error) { + forceProbe := len(force) > 0 && force[0] + return s.getUsageForAccount(ctx, account, forceProbe) +} + // GetUsageBatch 批量获取账号使用量。 // Anthropic OAuth/SetupToken 统一走 passive 链路,其他账号复用现有主动查询逻辑。 // 单个账号失败不会中断整批请求,错误会按账号返回。 diff --git a/backend/internal/service/adaptive_api_protocol_test.go b/backend/internal/service/adaptive_api_protocol_test.go new file mode 100644 index 000000000..210caa069 --- /dev/null +++ b/backend/internal/service/adaptive_api_protocol_test.go @@ -0,0 +1,227 @@ +//go:build unit + +package service + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func adaptiveProtocolTestAccount(platform string, baseURLs map[string]any) *Account { + return &Account{ + ID: 701, + Name: "adaptive-cn", + Platform: platform, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "api_protocol": APIProtocolAdaptive, + "account_mode": AccountModePayG, + "api_base_urls": baseURLs, + }, + } +} + +func adaptiveProtocolTestContext(path string, body []byte) *gin.Context { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + return c +} + +type cnProtocolIngressCase struct { + name string + path string + body []byte + forward func(*OpenAIGatewayService, *gin.Context, *Account, []byte) error +} + +func cnProtocolIngressCases() []cnProtocolIngressCase { + return []cnProtocolIngressCase{ + { + name: "chat completions", + path: "/v1/chat/completions", + body: []byte(`{"model":"deepseek-chat","messages":[{"role":"user","content":"hello"}],"stream":false}`), + forward: func(svc *OpenAIGatewayService, c *gin.Context, account *Account, body []byte) error { + _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + return err + }, + }, + { + name: "messages", + path: "/v1/messages", + body: []byte(`{"model":"deepseek-chat","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`), + forward: func(svc *OpenAIGatewayService, c *gin.Context, account *Account, body []byte) error { + _, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "") + return err + }, + }, + { + name: "responses", + path: "/v1/responses", + body: []byte(`{"model":"deepseek-chat","input":"hello","stream":false}`), + forward: func(svc *OpenAIGatewayService, c *gin.Context, account *Account, body []byte) error { + _, err := svc.Forward(context.Background(), c, account, body) + return err + }, + }, + } +} + +func TestAdaptiveProtocolRoutesChatCompletionsToNativeChat(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"glm-4.7","messages":[{"role":"user","content":"hello"}],"stream":false}`) + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := adaptiveProtocolTestAccount(PlatformZhipu, map[string]any{ + APIProtocolChatCompletions: "http://chat.example", + APIProtocolAnthropic: "http://anthropic.example", + }) + + _, err := svc.ForwardAsChatCompletions(context.Background(), adaptiveProtocolTestContext("/v1/chat/completions", body), account, body, "", "") + require.Error(t, err) + require.Equal(t, "http://chat.example/v1/chat/completions", upstream.lastReq.URL.String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "messages").IsArray()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists()) +} + +func TestAdaptiveProtocolRoutesResponsesShapedChatToNativeResponses(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"deepseek-v4","input":"hello","max_output_tokens":32,"stream":false}`) + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := adaptiveProtocolTestAccount(PlatformDeepseek, map[string]any{ + APIProtocolChatCompletions: "http://chat.example", + APIProtocolAnthropic: "http://anthropic.example", + APIProtocolResponses: "http://responses.example", + }) + + _, err := svc.ForwardAsChatCompletions(context.Background(), adaptiveProtocolTestContext("/v1/chat/completions", body), account, body, "", "") + require.Error(t, err) + require.Equal(t, "http://responses.example/responses", upstream.lastReq.URL.String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "messages").Exists()) +} + +func TestAdaptiveProtocolConvertsResponsesShapedChatForChatOnlyProvider(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"kimi-k2.5","input":"hello","max_output_tokens":32,"stream":false}`) + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := adaptiveProtocolTestAccount(PlatformKimi, map[string]any{ + APIProtocolChatCompletions: "http://chat.example", + APIProtocolAnthropic: "http://anthropic.example", + }) + + _, err := svc.ForwardAsChatCompletions(context.Background(), adaptiveProtocolTestContext("/v1/chat/completions", body), account, body, "", "") + require.Error(t, err) + require.Equal(t, "http://chat.example/v1/chat/completions", upstream.lastReq.URL.String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "messages").IsArray()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists()) +} + +func TestAdaptiveProtocolRoutesMessagesToNativeAnthropic(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"glm-4.7","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`) + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := adaptiveProtocolTestAccount(PlatformZhipu, map[string]any{ + APIProtocolChatCompletions: "http://chat.example", + APIProtocolAnthropic: "http://anthropic.example", + }) + + _, err := svc.ForwardAsAnthropic(context.Background(), adaptiveProtocolTestContext("/v1/messages", body), account, body, "", "") + require.Error(t, err) + require.Equal(t, "http://anthropic.example/v1/messages", upstream.lastReq.URL.String()) + require.Equal(t, "glm-4.7", gjson.GetBytes(upstream.lastBody, "model").String()) +} + +func TestAdaptiveProtocolConvertsKimiResponsesToChatCompletions(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"kimi-k2.5","input":"hello","stream":false}`) + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := adaptiveProtocolTestAccount(PlatformKimi, map[string]any{ + APIProtocolChatCompletions: "http://chat.example", + APIProtocolAnthropic: "http://anthropic.example", + }) + + _, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body) + require.Error(t, err) + require.Equal(t, "http://chat.example/v1/chat/completions", upstream.lastReq.URL.String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "messages").IsArray()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists()) +} + +func TestAdaptiveProtocolRoutesDeepSeekResponsesToNativeResponses(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"deepseek-v4","input":"hello","max_output_tokens":32,"store":true,"previous_response_id":"resp_old","stream":false}`) + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := adaptiveProtocolTestAccount(PlatformDeepseek, map[string]any{ + APIProtocolChatCompletions: "http://chat.example", + APIProtocolAnthropic: "http://anthropic.example", + APIProtocolResponses: "http://responses.example", + }) + + _, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body) + require.Error(t, err) + require.Equal(t, "http://responses.example/responses", upstream.lastReq.URL.String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "store").Bool()) + require.False(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists()) + require.Equal(t, int64(32), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + require.False(t, gjson.GetBytes(upstream.lastBody, "instructions").Exists()) +} + +func TestFixedCNChatProtocolOverridesStaleResponsesMode(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tc := range cnProtocolIngressCases() { + t.Run(tc.name, func(t *testing.T) { + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := adaptiveProtocolTestAccount(PlatformDeepseek, nil) + account.Credentials["api_protocol"] = APIProtocolChatCompletions + account.Credentials["base_url"] = "http://chat.example" + account.Extra = map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceResponses), + } + + err := tc.forward(svc, adaptiveProtocolTestContext(tc.path, tc.body), account, tc.body) + + require.Error(t, err) + require.Equal(t, "http://chat.example/v1/chat/completions", upstream.lastReq.URL.String()) + }) + } +} + +func TestFixedCNResponsesProtocolOverridesStaleChatMode(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tc := range cnProtocolIngressCases() { + t.Run(tc.name, func(t *testing.T) { + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := adaptiveProtocolTestAccount(PlatformDeepseek, nil) + account.Credentials["api_protocol"] = APIProtocolResponses + account.Credentials["base_url"] = "http://responses.example" + account.Extra = map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions), + } + + err := tc.forward(svc, adaptiveProtocolTestContext(tc.path, tc.body), account, tc.body) + + require.Error(t, err) + require.Equal(t, "http://responses.example/responses", upstream.lastReq.URL.String()) + }) + } +} diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index f8f9acb4e..7b64e4b60 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -267,7 +267,7 @@ func defaultAllowImageGenerationForPlatform(platform string) bool { func compositeDefaultModelsListCandidateIDs() []string { seen := make(map[string]struct{}) ids := make([]string, 0) - for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok} { + for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek} { for _, id := range defaultModelsListCandidateIDs(platform) { if _, ok := seen[id]; ok { continue @@ -510,7 +510,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn ReasoningEffortMappings: reasoningEffortMappings, } sanitizeGroupMessagesDispatchFields(group) - if group.Platform != PlatformOpenAI { + if group.Platform != PlatformOpenAI && group.Platform != PlatformComposite { group.AllowLive = false } sanitizeGroupReasoningEffortPolicy(group) @@ -890,7 +890,7 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd group.ReasoningEffortMappings = reasoningEffortMappings } sanitizeGroupMessagesDispatchFields(group) - if group.Platform != PlatformOpenAI { + if group.Platform != PlatformOpenAI && group.Platform != PlatformComposite { group.AllowLive = false } sanitizeGroupReasoningEffortPolicy(group) diff --git a/backend/internal/service/admin_service_composite_group_test.go b/backend/internal/service/admin_service_composite_group_test.go index 5530720f5..f1c00482b 100644 --- a/backend/internal/service/admin_service_composite_group_test.go +++ b/backend/internal/service/admin_service_composite_group_test.go @@ -6,6 +6,7 @@ import ( "context" "testing" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/stretchr/testify/require" ) @@ -174,6 +175,13 @@ func TestAdminService_CompositeModelsListCandidatesIncludeConcreteAccountMapping "model_mapping": map[string]any{"gemini-custom": "gemini-2.5-flash"}, }, }, + { + ID: 3, + Platform: PlatformKimi, + Credentials: map[string]any{ + "model_mapping": map[string]any{"kimi-custom": "kimi-k2"}, + }, + }, }, } groupRepo := &groupRepoStubForAdmin{ @@ -188,6 +196,19 @@ func TestAdminService_CompositeModelsListCandidatesIncludeConcreteAccountMapping require.NoError(t, err) require.Contains(t, candidates, "gpt-custom") require.Contains(t, candidates, "gemini-custom") + require.Contains(t, candidates, "kimi-custom") require.Contains(t, candidates, "gpt-5.5") require.Contains(t, candidates, "gemini-2.5-flash") } + +// 独立 CN 分组的模型列表候选沿用 default 分支的 Claude 默认列表; +// composite 支持不得改变独立分组的候选语义。 +func TestAdminService_CNProviderModelsListCandidatesKeepClaudeDefaults(t *testing.T) { + want := make([]string, 0, len(claude.DefaultModels)) + for _, model := range claude.DefaultModels { + want = append(want, model.ID) + } + for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} { + require.Equal(t, want, defaultModelsListCandidateIDs(platform), "platform=%s", platform) + } +} diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index 64bfdaeb0..8b0374e6b 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -1029,6 +1029,44 @@ func TestAdminService_CreateGroup_ClearsMessagesDispatchFieldsForNonOpenAIPlatfo require.Equal(t, OpenAIMessagesDispatchModelConfig{}, repo.created.MessagesDispatchModelConfig) } +func TestAdminService_CreateCompositeGroupPreservesLive(t *testing.T) { + repo := &groupRepoStubForAdmin{} + svc := &adminServiceImpl{groupRepo: repo} + + group, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ + Name: "composite-group", + Platform: PlatformComposite, + RateMultiplier: 1.0, + AllowLive: true, + }) + + require.NoError(t, err) + require.NotNil(t, group) + require.NotNil(t, repo.created) + require.True(t, repo.created.AllowLive) +} + +func TestAdminService_UpdateCompositeGroupPreservesLive(t *testing.T) { + existingGroup := &Group{ + ID: 1, + Name: "composite-group", + Platform: PlatformComposite, + Status: StatusActive, + } + repo := &groupRepoStubForAdmin{getByID: existingGroup} + svc := &adminServiceImpl{groupRepo: repo} + allowLive := true + + group, err := svc.UpdateGroup(context.Background(), existingGroup.ID, &UpdateGroupInput{ + AllowLive: &allowLive, + }) + + require.NoError(t, err) + require.NotNil(t, group) + require.NotNil(t, repo.updated) + require.True(t, repo.updated.AllowLive) +} + func TestAdminService_UpdateGroup_ClearsMessagesDispatchFieldsWhenPlatformChangesAwayFromOpenAI(t *testing.T) { existingGroup := &Group{ ID: 1, diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 6ead8da02..af8c69449 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -91,25 +91,27 @@ type BillingCache interface { // ModelPricing 模型价格配置(per-token价格,与LiteLLM格式一致) type ModelPricing struct { - InputPricePerToken float64 // 每token输入价格 (USD) - InputPricePerTokenPriority float64 // priority service tier 下每token输入价格 (USD) - ImageInputPricePerToken float64 // 图片输入 token 价格 (USD),用于多模态 embedding 等图文不同价场景;为 0 时回退到 InputPricePerToken - OutputPricePerToken float64 // 每token输出价格 (USD) - OutputPricePerTokenPriority float64 // priority service tier 下每token输出价格 (USD) - CacheCreationPricePerToken float64 // 缓存创建每token价格 (USD) - CacheCreationPricePerTokenPriority float64 // priority service tier 下缓存创建每token价格 (USD) - CacheCreationPriceExplicit bool // 是否由渠道/区间定价显式设定(为 true 时即使 == 0 也不回退) - CacheReadPricePerToken float64 // 缓存读取每token价格 (USD) - CacheReadPricePerTokenPriority float64 // priority service tier 下缓存读取每token价格 (USD) - CacheCreation5mPrice float64 // 5分钟缓存创建每token价格 (USD) - CacheCreation1hPrice float64 // 1小时缓存创建每token价格 (USD) - SupportsCacheBreakdown bool // 是否支持详细的缓存分类 - LongContextInputThreshold int // 超过阈值后按整次会话提升输入价格 - LongContextThresholdInclusive bool // 达到阈值即应用(xAI);默认保持严格大于以兼容既有模型 - LongContextInputMultiplier float64 // 长上下文整次会话输入倍率 - LongContextOutputMultiplier float64 // 长上下文整次会话输出倍率 - ImageOutputPricePerToken float64 // 图片输出 token 价格 (USD) - ImageOutputPriceExplicit bool // 是否由渠道定价显式设定(为 true 时即使 == 0 也不回退) + InputPricePerToken float64 // 每token输入价格 (USD) + InputPricePerTokenPriority float64 // priority service tier 下每token输入价格 (USD) + ImageInputPricePerToken float64 // 图片输入 token 价格 (USD),用于多模态 embedding 等图文不同价场景;为 0 时回退到 InputPricePerToken + OutputPricePerToken float64 // 每token输出价格 (USD) + OutputPricePerTokenPriority float64 // priority service tier 下每token输出价格 (USD) + CacheCreationPricePerToken float64 // 缓存创建每token价格 (USD) + CacheCreationPricePerTokenPriority float64 // priority service tier 下缓存创建每token价格 (USD) + CacheCreationPriceExplicit bool // 是否由渠道/区间定价显式设定(为 true 时即使 == 0 也不回退) + CacheReadPricePerToken float64 // 缓存读取每token价格 (USD) + CacheReadPricePerTokenPriority float64 // priority service tier 下缓存读取每token价格 (USD) + FastMultiplier *float64 // 渠道显式 Fast/priority 倍率;nil 时沿用模型目录行为 + FlexMultiplier *float64 // 渠道显式 Flex 倍率;nil 时沿用默认行为 + CacheCreation5mPrice float64 // 5分钟缓存创建每token价格 (USD) + CacheCreation1hPrice float64 // 1小时缓存创建每token价格 (USD) + SupportsCacheBreakdown bool // 是否支持详细的缓存分类 + LongContextInputThreshold int // 超过阈值后按整次会话提升输入价格 + LongContextThresholdInclusive bool // 达到阈值即应用(xAI);默认保持严格大于以兼容既有模型 + LongContextInputMultiplier float64 // 长上下文整次会话输入倍率 + LongContextOutputMultiplier float64 // 长上下文整次会话输出倍率 + ImageOutputPricePerToken float64 // 图片输出 token 价格 (USD) + ImageOutputPriceExplicit bool // 是否由渠道定价显式设定(为 true 时即使 == 0 也不回退) } const ( @@ -123,7 +125,14 @@ func normalizeBillingServiceTier(serviceTier string) string { } func usePriorityServiceTierPricing(serviceTier string, pricing *ModelPricing) bool { - if pricing == nil || normalizeBillingServiceTier(serviceTier) != "priority" { + if pricing == nil { + return false + } + tier := normalizeBillingServiceTier(serviceTier) + if tier != "priority" && tier != "fast" { + return false + } + if pricing.FastMultiplier != nil { return false } return pricing.InputPricePerTokenPriority > 0 || pricing.OutputPricePerTokenPriority > 0 || @@ -132,7 +141,7 @@ func usePriorityServiceTierPricing(serviceTier string, pricing *ModelPricing) bo func serviceTierCostMultiplier(serviceTier string) float64 { switch normalizeBillingServiceTier(serviceTier) { - case "priority": + case "priority", "fast": return 2.0 case "flex": return 0.5 @@ -141,6 +150,34 @@ func serviceTierCostMultiplier(serviceTier string) float64 { } } +func configuredServiceTierMultiplier(serviceTier string, pricing *ModelPricing) float64 { + if pricing != nil { + switch normalizeBillingServiceTier(serviceTier) { + case "priority", "fast": + if pricing.FastMultiplier != nil { + return *pricing.FastMultiplier + } + case "flex": + if pricing.FlexMultiplier != nil { + return *pricing.FlexMultiplier + } + } + } + return serviceTierCostMultiplier(serviceTier) +} + +func pricingWithPriorityMultiplier(base *ModelPricing, multiplier float64) *ModelPricing { + if base == nil { + return nil + } + cloned := *base + cloned.InputPricePerTokenPriority = cloned.InputPricePerToken * multiplier + cloned.OutputPricePerTokenPriority = cloned.OutputPricePerToken * multiplier + cloned.CacheCreationPricePerTokenPriority = cloned.CacheCreationPricePerToken * multiplier + cloned.CacheReadPricePerTokenPriority = cloned.CacheReadPricePerToken * multiplier + return &cloned +} + // UsageTokens 使用的token数量 type UsageTokens struct { InputTokens int @@ -281,10 +318,10 @@ func (s *BillingService) initFallbackPricing() { // Claude 4.7 Opus (暂与4.6同价,待官方定价更新) s.fallbackPrices["claude-opus-4.7"] = s.fallbackPrices["claude-opus-4.6"] - // Claude 4.8 Opus / Claude Opus 5(官方同价:$5 输入 / $25 输出 per MTok)。 + // Claude 4.8 Opus / Claude Opus 5(标准 $5/$25,Fast $10/$50 per MTok)。 // 缺少这两条时 getFallbackPricing 会掉到 claude-3-opus($15/$75),造成 3 倍超收。 - s.fallbackPrices["claude-opus-4.8"] = s.fallbackPrices["claude-opus-4.7"] - s.fallbackPrices["claude-opus-5"] = s.fallbackPrices["claude-opus-4.8"] + s.fallbackPrices["claude-opus-4.8"] = pricingWithPriorityMultiplier(s.fallbackPrices["claude-opus-4.7"], 2) + s.fallbackPrices["claude-opus-5"] = pricingWithPriorityMultiplier(s.fallbackPrices["claude-opus-4.8"], 2) // Gemini 3.1 Pro s.fallbackPrices["gemini-3.1-pro"] = &ModelPricing{ @@ -320,9 +357,31 @@ func (s *BillingService) initFallbackPricing() { LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier, LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier, } - // GPT-5.5 / GPT-5.5 Pro 暂无独立定价,回退到 GPT-5.4。 - s.fallbackPrices["gpt-5.5"] = s.fallbackPrices["gpt-5.4"] - s.fallbackPrices["gpt-5.5-pro"] = s.fallbackPrices["gpt-5.4"] + // OpenAI GPT-5.5 官方价格;Fast 为标准价 2.5 倍。 + // Source: https://platform.openai.com/docs/pricing + s.fallbackPrices["gpt-5.5"] = pricingWithPriorityMultiplier(&ModelPricing{ + InputPricePerToken: 5e-6, + OutputPricePerToken: 30e-6, + // 官方未列独立 cache-write 价;内部出现 cache creation token 时按输入价兜底。 + CacheCreationPricePerToken: 5e-6, + CacheReadPricePerToken: 0.5e-6, + SupportsCacheBreakdown: false, + LongContextInputThreshold: openAIGPT54LongContextInputThreshold, + LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier, + LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier, + }, 2.5) + // GPT-5.5 Pro 当前不提供 Fast;保留标准、Flex 和长上下文 fallback 价格。 + s.fallbackPrices["gpt-5.5-pro"] = &ModelPricing{ + InputPricePerToken: 30e-6, + OutputPricePerToken: 180e-6, + // 官方未列独立 cached-input/cache-write 价;内部出现对应 token 时按输入价兜底。 + CacheCreationPricePerToken: 30e-6, + CacheReadPricePerToken: 30e-6, + SupportsCacheBreakdown: false, + LongContextInputThreshold: openAIGPT54LongContextInputThreshold, + LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier, + LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier, + } // OpenAI GPT-5.6 官方价格(USD/token)。缓存写入为输入价的 1.25 倍。 s.fallbackPrices["gpt-5.6-sol"] = &ModelPricing{ @@ -1009,25 +1068,9 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing // 防止修改 fallbackPrices 中的共享指针 cloned := *pricing pricing = &cloned - if channelPricing.InputPrice != nil { - pricing.InputPricePerToken = *channelPricing.InputPrice - pricing.InputPricePerTokenPriority = *channelPricing.InputPrice - } - if channelPricing.OutputPrice != nil { - pricing.OutputPricePerToken = *channelPricing.OutputPrice - pricing.OutputPricePerTokenPriority = *channelPricing.OutputPrice - } - if channelPricing.CacheWritePrice != nil { - pricing.CacheCreationPricePerToken = *channelPricing.CacheWritePrice - pricing.CacheCreationPricePerTokenPriority = *channelPricing.CacheWritePrice - pricing.CacheCreationPriceExplicit = true - pricing.CacheCreation5mPrice = *channelPricing.CacheWritePrice - pricing.CacheCreation1hPrice = *channelPricing.CacheWritePrice - } - if channelPricing.CacheReadPrice != nil { - pricing.CacheReadPricePerToken = *channelPricing.CacheReadPrice - pricing.CacheReadPricePerTokenPriority = *channelPricing.CacheReadPrice - } + applyChannelTokenPriceOverrides(pricing, channelPricing) + pricing.FastMultiplier = channelPricing.FastMultiplier + pricing.FlexMultiplier = channelPricing.FlexMultiplier if channelPricing.ImageOutputPrice != nil { pricing.ImageOutputPricePerToken = *channelPricing.ImageOutputPrice } else { @@ -1038,6 +1081,45 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing return pricing, nil } +// channelTierOverridePrice applies a Standard-tier override while preserving +// an explicit model-catalog Fast/Priority ratio. If the catalog has no tier +// price, generic service-tier defaults remain responsible for the fallback. +func channelTierOverridePrice(baseStandard, baseTier, channelStandard float64) float64 { + if baseStandard > 0 && baseTier > 0 { + return channelStandard * (baseTier / baseStandard) + } + return 0 +} + +func applyChannelTokenPriceOverrides(pricing *ModelPricing, channelPricing *ChannelModelPricing) { + if pricing == nil || channelPricing == nil { + return + } + if channelPricing.InputPrice != nil { + priority := channelTierOverridePrice(pricing.InputPricePerToken, pricing.InputPricePerTokenPriority, *channelPricing.InputPrice) + pricing.InputPricePerToken = *channelPricing.InputPrice + pricing.InputPricePerTokenPriority = priority + } + if channelPricing.OutputPrice != nil { + priority := channelTierOverridePrice(pricing.OutputPricePerToken, pricing.OutputPricePerTokenPriority, *channelPricing.OutputPrice) + pricing.OutputPricePerToken = *channelPricing.OutputPrice + pricing.OutputPricePerTokenPriority = priority + } + if channelPricing.CacheWritePrice != nil { + priority := channelTierOverridePrice(pricing.CacheCreationPricePerToken, pricing.CacheCreationPricePerTokenPriority, *channelPricing.CacheWritePrice) + pricing.CacheCreationPricePerToken = *channelPricing.CacheWritePrice + pricing.CacheCreationPricePerTokenPriority = priority + pricing.CacheCreationPriceExplicit = true + pricing.CacheCreation5mPrice = *channelPricing.CacheWritePrice + pricing.CacheCreation1hPrice = *channelPricing.CacheWritePrice + } + if channelPricing.CacheReadPrice != nil { + priority := channelTierOverridePrice(pricing.CacheReadPricePerToken, pricing.CacheReadPricePerTokenPriority, *channelPricing.CacheReadPrice) + pricing.CacheReadPricePerToken = *channelPricing.CacheReadPrice + pricing.CacheReadPricePerTokenPriority = priority + } +} + // --- 统一计费入口 --- // CostInput 统一计费输入 @@ -1113,18 +1195,27 @@ func (s *BillingService) CalculateCostUnified(input CostInput) (*CostBreakdown, func (s *BillingService) calculateTokenCost(resolved *ResolvedPricing, input CostInput) (*CostBreakdown, error) { totalContext := input.Tokens.InputTokens + input.Tokens.CacheCreationTokens + input.Tokens.CacheReadTokens - pricing := input.Resolver.GetIntervalPricing(resolved, totalContext) + // 分组开关是统一入口;账号 API 开关保留为额外开启能力,但 false 不否决分组配置。 + contextTierPricingEnabled := resolved.longContextPricingEnabled + if input.LongContextBillingEnabled != nil && *input.LongContextBillingEnabled { + contextTierPricingEnabled = true + } + + pricingContext := totalContext + if !contextTierPricingEnabled { + // 渠道可能显式配置了第一档,也可能只配置高上下文档。用 1 token + // 选择最低档;未命中时自然回退到渠道基础价。 + pricingContext = 1 + } + pricing := input.Resolver.GetIntervalPricing(resolved, pricingContext) if pricing == nil { return nil, fmt.Errorf("no pricing available for model: %s: %w", input.Model, ErrModelPricingUnavailable) } pricing = s.applyModelSpecificPricingPolicy(input.Model, pricing) - // 长上下文定价仅在无区间定价时应用(区间定价已包含上下文分层) - applyLongCtx := len(resolved.Intervals) == 0 && resolved.longContextPricingEnabled - if input.LongContextBillingEnabled != nil { - applyLongCtx = applyLongCtx && *input.LongContextBillingEnabled - } + // 官方长上下文阶梯仅在无区间定价时应用(区间定价已包含上下文分层)。 + applyLongCtx := len(resolved.Intervals) == 0 && contextTierPricingEnabled breakdown := s.computeTokenBreakdown(pricing, input.Tokens, input.RateMultiplier, input.ServiceTier, applyLongCtx) applyCostBreakdownMultiplier(breakdown, resolvedChannelTimeMultiplier(resolved, input.PricingAt)) @@ -1164,7 +1255,7 @@ func (s *BillingService) computeTokenBreakdown( cacheCreationPrice = pricing.CacheCreationPricePerTokenPriority } } else { - tierMultiplier = serviceTierCostMultiplier(serviceTier) + tierMultiplier = configuredServiceTierMultiplier(serviceTier, pricing) } longContextPricingEligible := applyLongCtx && s.shouldApplySessionLongContextPricing(tokens, pricing) diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 80d07af44..1e801d8e9 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -215,7 +215,7 @@ func TestGetModelPricing_OpenAICompactAliasesFallback(t *testing.T) { cacheRead float64 longContext int }{ - {model: "gpt5.5", inputPrice: 2.5e-6, outputPrice: 15e-6, cacheRead: 0.25e-6, longContext: 272000}, + {model: "gpt5.5", inputPrice: 5e-6, outputPrice: 30e-6, cacheRead: 0.5e-6, longContext: 272000}, {model: "openai/gpt5.4", inputPrice: 2.5e-6, outputPrice: 15e-6, cacheRead: 0.25e-6, longContext: 272000}, {model: "gpt5.4-mini", inputPrice: 7.5e-7, outputPrice: 4.5e-6, cacheRead: 7.5e-8, longContext: 0}, {model: "gpt5.3codexspark", inputPrice: 1.5e-6, outputPrice: 12e-6, cacheRead: 0.15e-6, longContext: 0}, @@ -293,14 +293,40 @@ func TestCalculateCost_OpenAIGPT55ProUsesGPT55PricingPolicy(t *testing.T) { cost, err := svc.CalculateCost("gpt-5.5-pro", tokens, 1.0) require.NoError(t, err) - expectedInput := float64(tokens.InputTokens) * 2.5e-6 * 2.0 - expectedOutput := float64(tokens.OutputTokens) * 15e-6 * 1.5 + expectedInput := float64(tokens.InputTokens) * 30e-6 * 2.0 + expectedOutput := float64(tokens.OutputTokens) * 180e-6 * 1.5 require.InDelta(t, expectedInput, cost.InputCost, 1e-10) require.InDelta(t, expectedOutput, cost.OutputCost, 1e-10) require.InDelta(t, expectedInput+expectedOutput, cost.TotalCost, 1e-10) require.InDelta(t, expectedInput+expectedOutput, cost.ActualCost, 1e-10) } +func TestFallbackPricing_OpenAIGPT55UsesOfficialPrices(t *testing.T) { + svc := newTestBillingService() + + pricing, err := svc.GetModelPricing("gpt-5.5") + require.NoError(t, err) + require.InDelta(t, 5e-6, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 30e-6, pricing.OutputPricePerToken, 1e-12) + require.InDelta(t, 0.5e-6, pricing.CacheReadPricePerToken, 1e-12) + require.InDelta(t, 5e-6, pricing.CacheCreationPricePerToken, 1e-12) + require.InDelta(t, 12.5e-6, pricing.InputPricePerTokenPriority, 1e-12) + require.InDelta(t, 75e-6, pricing.OutputPricePerTokenPriority, 1e-12) +} + +func TestFallbackPricing_OpenAIGPT55ProUsesOfficialPrices(t *testing.T) { + svc := newTestBillingService() + + pricing, err := svc.GetModelPricing("gpt-5.5-pro") + require.NoError(t, err) + require.InDelta(t, 30e-6, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 180e-6, pricing.OutputPricePerToken, 1e-12) + require.InDelta(t, 30e-6, pricing.CacheReadPricePerToken, 1e-12) + require.InDelta(t, 30e-6, pricing.CacheCreationPricePerToken, 1e-12) + require.Zero(t, pricing.InputPricePerTokenPriority) + require.Zero(t, pricing.OutputPricePerTokenPriority) +} + // 回归测试 #2293:长上下文计费触发时,cache_read_tokens 也应应用 LongContextInputMultiplier。 // 修复前:CacheReadCost = tokens * 0.25e-6 (漏乘倍率,少计费用)。 // 修复后:CacheReadCost = tokens * 0.25e-6 * LongContextInputMultiplier(=2.0)。 @@ -1594,9 +1620,10 @@ func TestGetModelPricingWithChannel_OverrideInputPriceOnly(t *testing.T) { pricing, err := svc.GetModelPricingWithChannel("claude-sonnet-4", chPricing) require.NoError(t, err) - // InputPrice overridden (both normal and priority) + // InputPrice overridden. claude-sonnet-4 has no catalog priority price, so + // the priority slot is zeroed and serviceTierCostMultiplier owns the surcharge. require.InDelta(t, 99e-6, pricing.InputPricePerToken, 1e-12) - require.InDelta(t, 99e-6, pricing.InputPricePerTokenPriority, 1e-12) + require.Zero(t, pricing.InputPricePerTokenPriority) // OutputPrice unchanged (claude-sonnet-4 fallback = 15e-6) require.InDelta(t, 15e-6, pricing.OutputPricePerToken, 1e-12) @@ -1611,9 +1638,9 @@ func TestGetModelPricingWithChannel_OverrideOutputPriceOnly(t *testing.T) { pricing, err := svc.GetModelPricingWithChannel("claude-sonnet-4", chPricing) require.NoError(t, err) - // OutputPrice overridden + // OutputPrice overridden; no catalog priority price to scale, so the slot is zeroed. require.InDelta(t, 88e-6, pricing.OutputPricePerToken, 1e-12) - require.InDelta(t, 88e-6, pricing.OutputPricePerTokenPriority, 1e-12) + require.Zero(t, pricing.OutputPricePerTokenPriority) // InputPrice unchanged (claude-sonnet-4 fallback = 3e-6) require.InDelta(t, 3e-6, pricing.InputPricePerToken, 1e-12) @@ -1633,15 +1660,18 @@ func TestGetModelPricingWithChannel_OverrideAllFields(t *testing.T) { require.NoError(t, err) require.InDelta(t, 10e-6, pricing.InputPricePerToken, 1e-12) - require.InDelta(t, 10e-6, pricing.InputPricePerTokenPriority, 1e-12) require.InDelta(t, 20e-6, pricing.OutputPricePerToken, 1e-12) - require.InDelta(t, 20e-6, pricing.OutputPricePerTokenPriority, 1e-12) require.InDelta(t, 5e-6, pricing.CacheCreationPricePerToken, 1e-12) require.InDelta(t, 5e-6, pricing.CacheCreation5mPrice, 1e-12) require.InDelta(t, 5e-6, pricing.CacheCreation1hPrice, 1e-12) require.InDelta(t, 1e-6, pricing.CacheReadPricePerToken, 1e-12) - require.InDelta(t, 1e-6, pricing.CacheReadPricePerTokenPriority, 1e-12) require.InDelta(t, 50e-6, pricing.ImageOutputPricePerToken, 1e-12) + + // claude-sonnet-4 carries no catalog Fast/Priority tier, so every priority + // slot stays zero and computeTokenBreakdown falls back to the 2x default. + require.Zero(t, pricing.InputPricePerTokenPriority) + require.Zero(t, pricing.OutputPricePerTokenPriority) + require.Zero(t, pricing.CacheReadPricePerTokenPriority) } func TestGetModelPricingWithChannel_CacheWritePriceAffects5mAnd1h(t *testing.T) { @@ -1668,9 +1698,27 @@ func TestGetModelPricingWithChannel_CacheReadPriceAffectsPriority(t *testing.T) pricing, err := svc.GetModelPricingWithChannel("claude-sonnet-4", chPricing) require.NoError(t, err) - // CacheReadPrice should set both normal and priority + // CacheReadPrice sets the standard slot; the priority slot is zeroed because + // claude-sonnet-4 has no catalog tier ratio to preserve. require.InDelta(t, 2e-6, pricing.CacheReadPricePerToken, 1e-12) - require.InDelta(t, 2e-6, pricing.CacheReadPricePerTokenPriority, 1e-12) + require.Zero(t, pricing.CacheReadPricePerTokenPriority) +} + +// 目录带 tier 价时,渠道覆盖必须按目录比例换算 priority 价,而不是归零。 +func TestGetModelPricingWithChannel_PreservesCatalogPriorityRatio(t *testing.T) { + svc := newTestBillingService() + + // gpt-5.4 目录价:input 2.5/5(2x),output 15/30(2x)。 + pricing, err := svc.GetModelPricingWithChannel("gpt-5.4", &ChannelModelPricing{ + InputPrice: testPtrFloat64(4e-6), + OutputPrice: testPtrFloat64(30e-6), + }) + require.NoError(t, err) + + require.InDelta(t, 4e-6, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 8e-6, pricing.InputPricePerTokenPriority, 1e-12) + require.InDelta(t, 30e-6, pricing.OutputPricePerToken, 1e-12) + require.InDelta(t, 60e-6, pricing.OutputPricePerTokenPriority, 1e-12) } func TestGetModelPricingWithChannel_UnknownModelReturnsError(t *testing.T) { diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index 5345693eb..0fe4e3b07 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -96,6 +96,8 @@ type ChannelModelPricing struct { OutputPrice *float64 `json:"output_price"` CacheWritePrice *float64 `json:"cache_write_price"` CacheReadPrice *float64 `json:"cache_read_price"` + FastMultiplier *float64 `json:"fast_multiplier"` + FlexMultiplier *float64 `json:"flex_multiplier"` ImageInputPrice *float64 `json:"image_input_price"` ImageOutputPrice *float64 `json:"image_output_price"` PerRequestPrice *float64 `json:"per_request_price"` @@ -120,19 +122,23 @@ type ChannelTimePricingPeriod struct { // PricingInterval 定价区间(token 区间 / 按次分层 / 图片分辨率分层) type PricingInterval struct { - ID int64 `json:"id,omitempty"` - PricingID int64 `json:"pricing_id,omitempty"` - MinTokens int `json:"min_tokens"` - MaxTokens *int `json:"max_tokens"` - TierLabel string `json:"tier_label"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - PerRequestPrice *float64 `json:"per_request_price"` - SortOrder int `json:"sort_order"` - CreatedAt time.Time `json:"created_at,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` + ID int64 `json:"id,omitempty"` + PricingID int64 `json:"pricing_id,omitempty"` + MinTokens int `json:"min_tokens"` + MaxTokens *int `json:"max_tokens"` + TierLabel string `json:"tier_label"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + InputMultiplier *float64 `json:"input_multiplier"` + OutputMultiplier *float64 `json:"output_multiplier"` + CacheWriteMultiplier *float64 `json:"cache_write_multiplier"` + CacheReadMultiplier *float64 `json:"cache_read_multiplier"` + PerRequestPrice *float64 `json:"per_request_price"` + SortOrder int `json:"sort_order"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` } // IsActive 判断渠道是否启用 @@ -358,7 +364,7 @@ func validateSingleInterval(iv *PricingInterval, idx int) error { return validateIntervalPrices(iv, idx) } -// validateIntervalPrices 校验区间内所有价格字段 >= 0 +// validateIntervalPrices 校验区间价格 >= 0、倍率 > 0。 func validateIntervalPrices(iv *PricingInterval, idx int) error { prices := []struct { name string @@ -375,6 +381,20 @@ func validateIntervalPrices(iv *PricingInterval, idx int) error { return fmt.Errorf("interval #%d: %s must be >= 0", idx+1, p.name) } } + multipliers := []struct { + name string + val *float64 + }{ + {"input_multiplier", iv.InputMultiplier}, + {"output_multiplier", iv.OutputMultiplier}, + {"cache_write_multiplier", iv.CacheWriteMultiplier}, + {"cache_read_multiplier", iv.CacheReadMultiplier}, + } + for _, multiplier := range multipliers { + if multiplier.val != nil && *multiplier.val <= 0 { + return fmt.Errorf("interval #%d: %s must be > 0", idx+1, multiplier.name) + } + } return nil } diff --git a/backend/internal/service/channel_monitor_const.go b/backend/internal/service/channel_monitor_const.go index bf7676ed5..6a2b9b92e 100644 --- a/backend/internal/service/channel_monitor_const.go +++ b/backend/internal/service/channel_monitor_const.go @@ -162,6 +162,9 @@ var ( ErrChannelMonitorProviderIncompatible = infraerrors.BadRequest( "CHANNEL_MONITOR_PROVIDER_INCOMPATIBLE", "monitor provider must match the linked account platform", ) + ErrChannelMonitorAccountNotSupportable = infraerrors.BadRequest( + "CHANNEL_MONITOR_ACCOUNT_NOT_SUPPORTABLE", "linked account cannot serve as a quota data source (cn coding plan must be kimi/zhipu, cn payg must be kimi/deepseek, openai requires an oauth account, anthropic requires oauth or setup-token)", + ) ErrChannelMonitorInvalidAPIMode = infraerrors.BadRequest( "CHANNEL_MONITOR_INVALID_API_MODE", "api_mode must be chat_completions or responses; responses is only supported for openai", ) diff --git a/backend/internal/service/channel_monitor_quota_fetcher.go b/backend/internal/service/channel_monitor_quota_fetcher.go index a4fa992c3..479762ae8 100644 --- a/backend/internal/service/channel_monitor_quota_fetcher.go +++ b/backend/internal/service/channel_monitor_quota_fetcher.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/domain" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "golang.org/x/sync/singleflight" @@ -18,10 +19,12 @@ import ( // 渠道监控「配额模式」的配额抓取器。 // // 不直接对接上游,而是把账号侧现成的用量服务归一成 domain.MonitorQuotaSnapshot: -// - 海外 5 家(anthropic/openai/gemini/antigravity/grok)→ AccountUsageService.GetUsage -// - 国产 coding plan(kimi/zhipu/deepseek)→ CNProviderQuotaService.QueryUsage -// - 国产 payg(kimi/deepseek)→ CNProviderBalanceService.QueryBalance -// (zhipu payg 无公开余额端点,QueryBalance 会返回该错误,原样透出) +// - 海外 5 家(anthropic/openai/gemini/antigravity/grok)→ AccountUsageService.GetUsageForAccount +// - 国产 coding plan(kimi/zhipu/deepseek)→ CNProviderQuotaService.QueryUsageForAccount +// - 国产 payg(kimi/deepseek)→ CNProviderBalanceService.QueryBalanceForAccount +// (zhipu payg 无公开余额端点,探测会返回该错误,原样透出) +// 数据源统一接受已加载的 *Account:fetchUncached 路由前 GetByID 一次并传下去, +// 下游服务不再各自重载(每次 GetByID 含 proxies/groups 联查)。 // // Fetch 永不返回 error:所有失败都降级为 Success=false 的快照照常入库, // 由 deriveQuotaCheckResult 推导为 failed/error 状态。 @@ -32,18 +35,19 @@ import ( // 抓取由 singleflight 合并为一次上游查询。 // monitorUsageSource 海外平台账号用量查询(AccountUsageService 天然满足)。 +// 传已加载的 *Account:fetchUncached 只 GetByID 一次,下游不再重复加载。 type monitorUsageSource interface { - GetUsage(ctx context.Context, accountID int64, force ...bool) (*UsageInfo, error) + GetUsageForAccount(ctx context.Context, account *Account, force ...bool) (*UsageInfo, error) } // monitorCNQuotaSource 国产 coding plan 滚动窗口额度探测(CNProviderQuotaService 天然满足)。 type monitorCNQuotaSource interface { - QueryUsage(ctx context.Context, accountID int64) (*CNProviderQuotaProbeResult, error) + QueryUsageForAccount(ctx context.Context, account *Account) (*CNProviderQuotaProbeResult, error) } // monitorCNBalanceSource 国产 payg 余额探测(CNProviderBalanceService 天然满足)。 type monitorCNBalanceSource interface { - QueryBalance(ctx context.Context, accountID int64) (*CNProviderBalanceResult, error) + QueryBalanceForAccount(ctx context.Context, account *Account) (*CNProviderBalanceResult, error) } // monitorAccountSource 账号加载(AccountRepository 天然满足)。 @@ -58,6 +62,8 @@ type ChannelMonitorQuotaFetcher struct { cnQuota monitorCNQuotaSource cnBalance monitorCNBalanceSource accounts monitorAccountSource + // balanceThreshold cn_balance 余额告警阈值(与账号停调共用配置,见 monitorBalanceThreshold)。 + balanceThreshold float64 mu sync.Mutex cache map[int64]monitorQuotaCacheEntry @@ -76,8 +82,12 @@ func NewChannelMonitorQuotaFetcher( cnQuota *CNProviderQuotaService, cnBalance *CNProviderBalanceService, accounts AccountRepository, + cfg *config.Config, ) *ChannelMonitorQuotaFetcher { - f := &ChannelMonitorQuotaFetcher{cache: make(map[int64]monitorQuotaCacheEntry)} + f := &ChannelMonitorQuotaFetcher{ + cache: make(map[int64]monitorQuotaCacheEntry), + balanceThreshold: monitorBalanceThreshold(cfg), + } if usage != nil { f.usage = usage } @@ -93,6 +103,17 @@ func NewChannelMonitorQuotaFetcher( return f } +// monitorBalanceThreshold 余额告警阈值,与账号停调(CNProviderBalanceCheckService) +// 共用 gateway.cn_providers.balance_threshold,保证监控 degraded 与调度器停调 +// 口径一致(任一币种达标即健康)。未配置/非正值时回退 viper 默认 0.5(config.go), +// 避免 0 阈值下「余额=0 也不告警」相对旧 `<=0` 判定的回归。 +func monitorBalanceThreshold(cfg *config.Config) float64 { + if cfg != nil && cfg.Gateway.CNProviders.BalanceThreshold > 0 { + return cfg.Gateway.CNProviders.BalanceThreshold + } + return 0.5 +} + // LoadAccount 加载账号(不走缓存)。供 Create/Update 时校验 // provider 与 account.platform 一致;账号不存在时返回错误。 func (f *ChannelMonitorQuotaFetcher) LoadAccount(ctx context.Context, id int64) (*Account, error) { @@ -173,23 +194,26 @@ func (f *ChannelMonitorQuotaFetcher) fetchUncached(ctx context.Context, accountI return quotaErrorSnapshot("usage", "linked account not found", now) } + // 账号只在路由前加载这一次;已加载的 account 直接传给数据源 + // (GetUsageForAccount / QueryUsageForAccount / QueryBalanceForAccount), + // 下游服务不再各自 GetByID(每次含 proxies/groups 联查)。 switch account.Platform { case domain.PlatformKimi, domain.PlatformZhipu, domain.PlatformDeepseek: if account.IsCodingPlan() { - return f.fetchCNQuota(ctx, accountID, now) + return f.fetchCNQuota(ctx, account, now) } - return f.fetchCNBalance(ctx, accountID, now) + return f.fetchCNBalance(ctx, account, now) default: - return f.fetchUsage(ctx, accountID, now) + return f.fetchUsage(ctx, account, now) } } -// fetchUsage 海外平台:AccountUsageService.GetUsage → 快照。 -func (f *ChannelMonitorQuotaFetcher) fetchUsage(ctx context.Context, accountID int64, now time.Time) *domain.MonitorQuotaSnapshot { +// fetchUsage 海外平台:AccountUsageService.GetUsageForAccount → 快照。 +func (f *ChannelMonitorQuotaFetcher) fetchUsage(ctx context.Context, account *Account, now time.Time) *domain.MonitorQuotaSnapshot { if f.usage == nil { return quotaErrorSnapshot("usage", "usage service is not configured", now) } - usage, err := f.usage.GetUsage(ctx, accountID) + usage, err := f.usage.GetUsageForAccount(ctx, account) if err != nil { msg := truncateMessage(sanitizeErrorMessage(err.Error())) return &domain.MonitorQuotaSnapshot{ @@ -318,12 +342,12 @@ func sortedQuotaModelNames(quotas map[string]*AntigravityModelQuota) []string { return names } -// fetchCNQuota 国产 coding plan:CNProviderQuotaService.QueryUsage → 快照。 -func (f *ChannelMonitorQuotaFetcher) fetchCNQuota(ctx context.Context, accountID int64, now time.Time) *domain.MonitorQuotaSnapshot { +// fetchCNQuota 国产 coding plan:CNProviderQuotaService.QueryUsageForAccount → 快照。 +func (f *ChannelMonitorQuotaFetcher) fetchCNQuota(ctx context.Context, account *Account, now time.Time) *domain.MonitorQuotaSnapshot { if f.cnQuota == nil { return quotaErrorSnapshot("cn_quota", "cn quota service is not configured", now) } - result, err := f.cnQuota.QueryUsage(ctx, accountID) + result, err := f.cnQuota.QueryUsageForAccount(ctx, account) if err != nil { msg := truncateMessage(sanitizeErrorMessage(err.Error())) return &domain.MonitorQuotaSnapshot{ @@ -341,7 +365,10 @@ func (f *ChannelMonitorQuotaFetcher) fetchCNQuota(ctx context.Context, accountID Error: result.Error, FetchedAt: now, } - if !result.Success && !result.CredentialValid { + // 只有 401/403 判凭据失效(与 fetchCNBalance 口径一致):CN quota 服务的 + // CredentialValid 仅在成功路径置 true,若按 `!Success && !CredentialValid` + // 推导,500/429/智谱业务错误全会被误判为 failed。 + if !result.Success && (result.StatusCode == 401 || result.StatusCode == 403) { snapshot.CredentialInvalid = true } if len(result.Tiers) > 0 { @@ -360,12 +387,12 @@ func (f *ChannelMonitorQuotaFetcher) fetchCNQuota(ctx context.Context, accountID return snapshot } -// fetchCNBalance 国产 payg:CNProviderBalanceService.QueryBalance → 快照。 -func (f *ChannelMonitorQuotaFetcher) fetchCNBalance(ctx context.Context, accountID int64, now time.Time) *domain.MonitorQuotaSnapshot { +// fetchCNBalance 国产 payg:CNProviderBalanceService.QueryBalanceForAccount → 快照。 +func (f *ChannelMonitorQuotaFetcher) fetchCNBalance(ctx context.Context, account *Account, now time.Time) *domain.MonitorQuotaSnapshot { if f.cnBalance == nil { return quotaErrorSnapshot("cn_balance", "cn balance service is not configured", now) } - result, err := f.cnBalance.QueryBalance(ctx, accountID) + result, err := f.cnBalance.QueryBalanceForAccount(ctx, account) if err != nil { msg := truncateMessage(sanitizeErrorMessage(err.Error())) return &domain.MonitorQuotaSnapshot{ @@ -386,6 +413,10 @@ func (f *ChannelMonitorQuotaFetcher) fetchCNBalance(ctx context.Context, account if result.Success { balance := result.Balance snapshot.Balance = &balance + // 与账号停调(checkOne)同口径:上游标记不可用或全部币种低于阈值 + // 才告警,任一币种达标即健康(余额 5 元/阈值 10 元的账号调度器已 + // 停调,监控不能仍绿灯)。 + snapshot.BalanceLow = !result.Available || allCNBalancesBelowThreshold(result, f.balanceThreshold) } else if result.StatusCode == 401 || result.StatusCode == 403 { snapshot.CredentialInvalid = true } @@ -454,7 +485,7 @@ func usageFailureInfo(usage *UsageInfo) (failed, credentialInvalid bool, msg str // deriveQuotaCheckResult 把配额快照推导为检测状态(复用既有 status 枚举, // 时间线/可用率机制自动生效): // - 查询成功且无告警 → operational -// - 任一窗口使用率 >= 阈值或余额耗尽 → degraded +// - 任一窗口使用率 >= 阈值或余额低于阈值/不可用 → degraded // - 账号未关联(配置问题) → degraded // - 凭据失效(401/403) → failed // - 网络/解析等其他错误 → error @@ -499,8 +530,11 @@ func quotaDegradedHint(snapshot *domain.MonitorQuotaSnapshot) string { return fmt.Sprintf("quota high: %s at %s%%", name, strconv.FormatFloat(tier.UsedPercent, 'f', 1, 64)) } } - if snapshot.Balance != nil && *snapshot.Balance <= 0 { - return fmt.Sprintf("balance depleted (%s)", firstNonEmpty(snapshot.Currency, "?")) + if snapshot.BalanceLow { + if snapshot.Balance != nil { + return fmt.Sprintf("balance low: %s %s", strconv.FormatFloat(*snapshot.Balance, 'f', -1, 64), firstNonEmpty(snapshot.Currency, "?")) + } + return fmt.Sprintf("balance low (%s)", firstNonEmpty(snapshot.Currency, "?")) } return "" } diff --git a/backend/internal/service/channel_monitor_quota_fetcher_test.go b/backend/internal/service/channel_monitor_quota_fetcher_test.go index f2e6ff998..6c5b06559 100644 --- a/backend/internal/service/channel_monitor_quota_fetcher_test.go +++ b/backend/internal/service/channel_monitor_quota_fetcher_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/domain" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/stretchr/testify/require" @@ -19,18 +20,20 @@ import ( type stubMonitorUsageSource struct { usage *UsageInfo err error - // block 非 nil 时 GetUsage 阻塞在该 channel 上,用于并发/singleflight 测试。 + // block 非 nil 时 GetUsageForAccount 阻塞在该 channel 上,用于并发/singleflight 测试。 block chan struct{} - mu sync.Mutex - calls int - lastCtx context.Context + mu sync.Mutex + calls int + lastCtx context.Context + lastAccount *Account } -func (s *stubMonitorUsageSource) GetUsage(ctx context.Context, accountID int64, force ...bool) (*UsageInfo, error) { +func (s *stubMonitorUsageSource) GetUsageForAccount(ctx context.Context, account *Account, force ...bool) (*UsageInfo, error) { s.mu.Lock() s.calls++ s.lastCtx = ctx + s.lastAccount = account s.mu.Unlock() if s.block != nil { <-s.block @@ -44,25 +47,35 @@ func (s *stubMonitorUsageSource) getCalls() int { return s.calls } -type stubMonitorCNQuotaSource struct { - result *CNProviderQuotaProbeResult - err error - calls int +func (s *stubMonitorUsageSource) getLastAccount() *Account { + s.mu.Lock() + defer s.mu.Unlock() + return s.lastAccount } -func (s *stubMonitorCNQuotaSource) QueryUsage(ctx context.Context, accountID int64) (*CNProviderQuotaProbeResult, error) { +type stubMonitorCNQuotaSource struct { + result *CNProviderQuotaProbeResult + err error + calls int + lastAccount *Account +} + +func (s *stubMonitorCNQuotaSource) QueryUsageForAccount(ctx context.Context, account *Account) (*CNProviderQuotaProbeResult, error) { s.calls++ + s.lastAccount = account return s.result, s.err } type stubMonitorCNBalanceSource struct { - result *CNProviderBalanceResult - err error - calls int + result *CNProviderBalanceResult + err error + calls int + lastAccount *Account } -func (s *stubMonitorCNBalanceSource) QueryBalance(ctx context.Context, accountID int64) (*CNProviderBalanceResult, error) { +func (s *stubMonitorCNBalanceSource) QueryBalanceForAccount(ctx context.Context, account *Account) (*CNProviderBalanceResult, error) { s.calls++ + s.lastAccount = account return s.result, s.err } @@ -87,11 +100,12 @@ func newQuotaFetcherTestSetup(t *testing.T) (*ChannelMonitorQuotaFetcher, *stubM cnBalance := &stubMonitorCNBalanceSource{} accounts := &stubMonitorAccountSource{accounts: make(map[int64]*Account)} fetcher := &ChannelMonitorQuotaFetcher{ - usage: usage, - cnQuota: cnQuota, - cnBalance: cnBalance, - accounts: accounts, - cache: make(map[int64]monitorQuotaCacheEntry), + usage: usage, + cnQuota: cnQuota, + cnBalance: cnBalance, + accounts: accounts, + balanceThreshold: monitorBalanceThreshold(nil), + cache: make(map[int64]monitorQuotaCacheEntry), } return fetcher, usage, cnQuota, cnBalance, accounts } @@ -167,9 +181,10 @@ func TestQuotaFetcher_PayGAccountUsesCNBalance(t *testing.T) { Credentials: map[string]any{"account_mode": AccountModePayG}, } cnBalance.result = &CNProviderBalanceResult{ - Success: true, - Balance: 12.34, - Currency: "CNY", + Success: true, + Available: true, + Balance: 12.34, + Currency: "CNY", Balances: []CNProviderBalanceEntry{ {Currency: "CNY", Balance: 12.34}, {Currency: "USD", Balance: 1.5}, @@ -185,9 +200,53 @@ func TestQuotaFetcher_PayGAccountUsesCNBalance(t *testing.T) { require.Equal(t, "CNY", snapshot.Currency) require.Len(t, snapshot.Balances, 2) require.Equal(t, "USD", snapshot.Balances[1].Currency) + require.False(t, snapshot.BalanceLow) require.Empty(t, snapshot.Error) } +// P2-6:fetchUncached 只 GetByID 一次,已加载的 account 指针直传数据源, +// 三条路由都不能让下游重载账号。 +func TestQuotaFetcher_LoadsAccountOnceAndPassesItThrough(t *testing.T) { + t.Run("overseas usage", func(t *testing.T) { + fetcher, usage, _, _, accounts := newQuotaFetcherTestSetup(t) + acc := &Account{ID: 21, Platform: domain.PlatformAnthropic} + accounts.accounts[21] = acc + usage.usage = &UsageInfo{} + + fetcher.Fetch(context.Background(), 21) + + require.Equal(t, 1, accounts.calls) + require.Same(t, acc, usage.getLastAccount()) + require.Equal(t, 1, usage.getCalls()) + }) + + t.Run("cn coding plan", func(t *testing.T) { + fetcher, _, cnQuota, _, accounts := newQuotaFetcherTestSetup(t) + acc := &Account{ID: 22, Platform: domain.PlatformKimi, Credentials: map[string]any{"account_mode": AccountModeCoding}} + accounts.accounts[22] = acc + cnQuota.result = &CNProviderQuotaProbeResult{Success: true} + + fetcher.Fetch(context.Background(), 22) + + require.Equal(t, 1, accounts.calls) + require.Same(t, acc, cnQuota.lastAccount) + require.Equal(t, 1, cnQuota.calls) + }) + + t.Run("cn payg", func(t *testing.T) { + fetcher, _, _, cnBalance, accounts := newQuotaFetcherTestSetup(t) + acc := &Account{ID: 23, Platform: domain.PlatformDeepseek, Credentials: map[string]any{"account_mode": AccountModePayG}} + accounts.accounts[23] = acc + cnBalance.result = &CNProviderBalanceResult{Success: true, Available: true, Balance: 1, Currency: "CNY"} + + fetcher.Fetch(context.Background(), 23) + + require.Equal(t, 1, accounts.calls) + require.Same(t, acc, cnBalance.lastAccount) + require.Equal(t, 1, cnBalance.calls) + }) +} + // --- 失败路径(Fetch 永不返回 error) --- func TestQuotaFetcher_AccountMissingYieldsLinkedAccountSnapshot(t *testing.T) { @@ -278,20 +337,44 @@ func TestUsageFailureInfo_ClassificationMatrix(t *testing.T) { } } -func TestQuotaFetcher_CNQuotaCredentialInvalidFlagPropagates(t *testing.T) { - fetcher, _, cnQuota, _, accounts := newQuotaFetcherTestSetup(t) - accounts.accounts[5] = &Account{ - ID: 5, - Platform: domain.PlatformZhipu, - Credentials: map[string]any{"account_mode": AccountModeCoding}, +// 凭据失效只认 401/403(与 fetchCNBalance 口径一致):CN quota 服务的 +// CredentialValid 仅成功路径置 true,500/429/智谱业务错误须推导为 error 而非 failed。 +func TestQuotaFetcher_CNQuotaCredentialInvalidByStatusCode(t *testing.T) { + cases := []struct { + name string + accountID int64 + statusCode int + credentialBad bool + expectedStatus string + }{ + {name: "401 unauthorized", accountID: 5, statusCode: 401, credentialBad: true, expectedStatus: MonitorStatusFailed}, + {name: "403 forbidden", accountID: 15, statusCode: 403, credentialBad: true, expectedStatus: MonitorStatusFailed}, + {name: "500 server error", accountID: 16, statusCode: 500, expectedStatus: MonitorStatusError}, + {name: "429 rate limited", accountID: 17, statusCode: 429, expectedStatus: MonitorStatusError}, + // 智谱 2xx 但业务级失败:StatusCode=200,非凭据问题。 + {name: "200 business error", accountID: 18, statusCode: 200, expectedStatus: MonitorStatusError}, } - cnQuota.result = &CNProviderQuotaProbeResult{Success: false, CredentialValid: false, Error: "api key expired"} + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fetcher, _, cnQuota, _, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[tc.accountID] = &Account{ + ID: tc.accountID, + Platform: domain.PlatformZhipu, + Credentials: map[string]any{"account_mode": AccountModeCoding}, + } + cnQuota.result = &CNProviderQuotaProbeResult{ + Success: false, + StatusCode: tc.statusCode, + Error: "api key expired", + } - snapshot := fetcher.Fetch(context.Background(), 5) + snapshot := fetcher.Fetch(context.Background(), tc.accountID) - require.False(t, snapshot.Success) - require.True(t, snapshot.CredentialInvalid) - require.Equal(t, "api key expired", snapshot.Error) + require.False(t, snapshot.Success) + require.Equal(t, tc.credentialBad, snapshot.CredentialInvalid) + require.Equal(t, tc.expectedStatus, deriveQuotaCheckResult(snapshot, "quota", time.Now()).Status) + }) + } } func TestQuotaFetcher_CNBalanceHTTP403MarksCredentialInvalid(t *testing.T) { @@ -305,6 +388,86 @@ func TestQuotaFetcher_CNBalanceHTTP403MarksCredentialInvalid(t *testing.T) { require.True(t, snapshot.CredentialInvalid) } +// 余额告警口径与账号停调(CNProviderBalanceCheckService.checkOne)一致: +// 上游标记不可用或全部币种低于阈值 → BalanceLow → degraded;任一币种达标即健康。 +func TestQuotaFetcher_CNBalanceLowMarksDegraded(t *testing.T) { + cases := []struct { + name string + accountID int64 + result *CNProviderBalanceResult + balanceLow bool + wantStatus string + wantMessage string + }{ + { + // 审查例:余额 5/阈值 10 的账号调度器已停调,监控不能仍绿灯。 + name: "balance below threshold", + accountID: 21, + result: &CNProviderBalanceResult{Success: true, Available: true, Balance: 5, Currency: "CNY"}, + balanceLow: true, + wantStatus: MonitorStatusDegraded, wantMessage: "balance low: 5 CNY", + }, + { + name: "upstream marked unavailable", + accountID: 22, + result: &CNProviderBalanceResult{Success: true, Available: false, Balance: 20, Currency: "CNY"}, + balanceLow: true, + wantStatus: MonitorStatusDegraded, wantMessage: "balance low: 20 CNY", + }, + { + // deepseek 双币种:任一币种(USD 20)达标即健康。 + name: "any currency above threshold is healthy", + accountID: 23, + result: &CNProviderBalanceResult{ + Success: true, Available: true, Balance: 5, Currency: "CNY", + Balances: []CNProviderBalanceEntry{{Currency: "CNY", Balance: 5}, {Currency: "USD", Balance: 20}}, + }, + wantStatus: MonitorStatusOperational, + }, + { + name: "single currency above threshold", + accountID: 24, + result: &CNProviderBalanceResult{Success: true, Available: true, Balance: 20, Currency: "CNY"}, + wantStatus: MonitorStatusOperational, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fetcher, _, _, cnBalance, accounts := newQuotaFetcherTestSetup(t) + fetcher.balanceThreshold = 10 + accounts.accounts[tc.accountID] = &Account{ + ID: tc.accountID, + Platform: domain.PlatformKimi, + Credentials: map[string]any{"account_mode": AccountModePayG}, + } + cnBalance.result = tc.result + + snapshot := fetcher.Fetch(context.Background(), tc.accountID) + + require.True(t, snapshot.Success) + require.Equal(t, tc.balanceLow, snapshot.BalanceLow) + res := deriveQuotaCheckResult(snapshot, "quota", time.Now()) + require.Equal(t, tc.wantStatus, res.Status) + if tc.wantMessage != "" { + require.Contains(t, res.Message, tc.wantMessage) + } else { + require.Empty(t, res.Message) + } + }) + } +} + +func TestNewChannelMonitorQuotaFetcher_ThresholdFromConfig(t *testing.T) { + require.InDelta(t, 0.5, NewChannelMonitorQuotaFetcher(nil, nil, nil, nil, nil).balanceThreshold, 0.0001) + + cfg10 := &config.Config{Gateway: config.GatewayConfig{CNProviders: config.GatewayCNProvidersConfig{BalanceThreshold: 10}}} + require.InDelta(t, 10, NewChannelMonitorQuotaFetcher(nil, nil, nil, nil, cfg10).balanceThreshold, 0.0001) + + // 非正值(含显式 0)回退默认,避免 0 阈值下「余额=0 也不告警」。 + cfg0 := &config.Config{Gateway: config.GatewayConfig{CNProviders: config.GatewayCNProvidersConfig{BalanceThreshold: 0}}} + require.InDelta(t, 0.5, NewChannelMonitorQuotaFetcher(nil, nil, nil, nil, cfg0).balanceThreshold, 0.0001) +} + func TestQuotaFetcher_NilDependenciesProduceErrorSnapshots(t *testing.T) { // fetcher 本体为 nil:直接降级为错误快照,不 panic。 var nilFetcher *ChannelMonitorQuotaFetcher @@ -495,10 +658,10 @@ func TestDeriveQuotaCheckResult_StatusMatrix(t *testing.T) { require.Contains(t, res.Message, "95.0%") balance := -0.5 - depleted := &domain.MonitorQuotaSnapshot{Success: true, Balance: &balance, Currency: "CNY"} - res = deriveQuotaCheckResult(depleted, "quota", now) + lowBalance := &domain.MonitorQuotaSnapshot{Success: true, BalanceLow: true, Balance: &balance, Currency: "CNY"} + res = deriveQuotaCheckResult(lowBalance, "quota", now) require.Equal(t, MonitorStatusDegraded, res.Status) - require.Contains(t, res.Message, "balance depleted") + require.Contains(t, res.Message, "balance low") invalid := &domain.MonitorQuotaSnapshot{Success: false, CredentialInvalid: true, Error: "401 unauthorized"} res = deriveQuotaCheckResult(invalid, "quota", now) diff --git a/backend/internal/service/channel_monitor_quota_mode_test.go b/backend/internal/service/channel_monitor_quota_mode_test.go index 0e4b3ea7d..7309d2cf2 100644 --- a/backend/internal/service/channel_monitor_quota_mode_test.go +++ b/backend/internal/service/channel_monitor_quota_mode_test.go @@ -325,6 +325,16 @@ func TestValidateCreateParams_CheckModeMatrix(t *testing.T) { }, wantErr: ErrChannelMonitorInvalidCheckMode, }, + { + // quota_probe 仍要打真实探活请求:空模型必须报错,不再用 "quota" 占位。 + name: "quota_probe requires primary model", + params: ChannelMonitorCreateParams{ + Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuotaProbe, + Endpoint: "https://api.kimi.com", APIKey: "sk", + IntervalSeconds: 60, AccountID: &accountID, + }, + wantErr: ErrChannelMonitorMissingPrimaryModel, + }, } for _, tc := range cases { @@ -342,8 +352,15 @@ func TestValidateCreateParams_CheckModeMatrix(t *testing.T) { func TestNormalizeMonitorPrimaryModel_QuotaDefault(t *testing.T) { require.Equal(t, "quota", normalizeMonitorPrimaryModel(MonitorProviderKimi, MonitorCheckModeQuota, "")) require.Equal(t, "quota", normalizeMonitorPrimaryModel(MonitorProviderAntigravity, MonitorCheckModeQuota, " ")) - // 探活模式沿用原语义:grok 默认模型,其余必填(空串报错在 validateCreateParams)。 + // quota_probe 仍要打真实探活请求:空模型返回 ""(由上层报 MissingPrimaryModel), + // 不再用 "quota" 占位打 model="quota" 的请求。 + require.Equal(t, "", normalizeMonitorPrimaryModel(MonitorProviderKimi, MonitorCheckModeQuotaProbe, "")) + // grok 分支在纯 quota 占位之后:grok+quota 占位 "quota", + // grok 探活(probe/quota_probe)默认轻量测活模型。 + require.Equal(t, "quota", normalizeMonitorPrimaryModel(MonitorProviderGrok, MonitorCheckModeQuota, "")) require.Equal(t, MonitorDefaultGrokModel, normalizeMonitorPrimaryModel(MonitorProviderGrok, MonitorCheckModeProbe, "")) + require.Equal(t, MonitorDefaultGrokModel, normalizeMonitorPrimaryModel(MonitorProviderGrok, MonitorCheckModeQuotaProbe, "")) + // 探活模式沿用原语义:其余必填(空串报错在 validateCreateParams)。 require.Equal(t, "kimi-k2", normalizeMonitorPrimaryModel(MonitorProviderKimi, MonitorCheckModeQuotaProbe, "kimi-k2")) } @@ -414,6 +431,150 @@ func TestRevalidateLinkedAccount_PlatformMismatch(t *testing.T) { require.Nil(t, probe.AccountID) } +// 能力矩阵:与 fetchUncached 路由一一对应,创建期拦截注定运行期永久 error 的组合。 +func TestMonitorAccountQuotaCapability_Matrix(t *testing.T) { + cases := []struct { + name string + account *Account + wantErr error + }{ + { + name: "deepseek coding has no quota endpoint", + account: &Account{ID: 1, Platform: domain.PlatformDeepseek, Credentials: map[string]any{"account_mode": AccountModeCoding}}, + wantErr: ErrChannelMonitorAccountNotSupportable, + }, + { + // 自定义域名 kimi coding:GetCodingPlanProvider 识别不到 → 无额度端点。 + name: "custom-domain kimi coding unsupported", + account: &Account{ID: 2, Platform: domain.PlatformKimi, Type: AccountTypeAPIKey, + Credentials: map[string]any{"account_mode": AccountModeCoding, "base_url": "https://cw.example.com"}}, + wantErr: ErrChannelMonitorAccountNotSupportable, + }, + { + name: "kimi coding default endpoint ok", + account: &Account{ID: 3, Platform: domain.PlatformKimi, Credentials: map[string]any{"account_mode": AccountModeCoding}}, + }, + { + name: "zhipu coding default endpoint ok", + account: &Account{ID: 4, Platform: domain.PlatformZhipu, Credentials: map[string]any{"account_mode": AccountModeCoding}}, + }, + { + name: "zhipu payg has no balance endpoint", + account: &Account{ID: 5, Platform: domain.PlatformZhipu}, + wantErr: ErrChannelMonitorAccountNotSupportable, + }, + { + name: "kimi payg ok", + account: &Account{ID: 6, Platform: domain.PlatformKimi}, + }, + { + name: "deepseek payg ok", + account: &Account{ID: 7, Platform: domain.PlatformDeepseek}, + }, + { + name: "anthropic api key cannot query usage", + account: &Account{ID: 8, Platform: domain.PlatformAnthropic, Type: AccountTypeAPIKey}, + wantErr: ErrChannelMonitorAccountNotSupportable, + }, + { + name: "anthropic oauth ok", + account: &Account{ID: 9, Platform: domain.PlatformAnthropic, Type: AccountTypeOAuth}, + }, + { + name: "anthropic setup token ok (local estimation)", + account: &Account{ID: 10, Platform: domain.PlatformAnthropic, Type: AccountTypeSetupToken}, + }, + { + name: "openai api key cannot query usage", + account: &Account{ID: 11, Platform: domain.PlatformOpenAI, Type: AccountTypeAPIKey}, + wantErr: ErrChannelMonitorAccountNotSupportable, + }, + { + name: "openai oauth ok", + account: &Account{ID: 12, Platform: domain.PlatformOpenAI, Type: AccountTypeOAuth}, + }, + { + // 防过度拦截:gemini/grok/antigravity 走本地统计/值通道降级,不会永久 error。 + name: "gemini api key ok", + account: &Account{ID: 13, Platform: domain.PlatformGemini, Type: AccountTypeAPIKey}, + }, + { + name: "grok ok", + account: &Account{ID: 14, Platform: domain.PlatformGrok}, + }, + { + name: "antigravity ok", + account: &Account{ID: 15, Platform: domain.PlatformAntigravity}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := monitorAccountQuotaCapability(tc.account) + if tc.wantErr == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, tc.wantErr) + } + }) + } +} + +func TestValidateLinkedAccount_CapabilityRejected(t *testing.T) { + svc := NewChannelMonitorService(nil, nil) + svc.SetQuotaFetcher(newQuotaModeFetcher(map[int64]*Account{ + 1: {ID: 1, Platform: domain.PlatformDeepseek, Credentials: map[string]any{"account_mode": AccountModeCoding}}, + }, nil)) + + err := svc.validateLinkedAccount(context.Background(), MonitorProviderDeepseek, int64Ptr(1)) + require.ErrorIs(t, err, ErrChannelMonitorAccountNotSupportable) +} + +func TestRevalidateLinkedAccount_Capability(t *testing.T) { + svc := NewChannelMonitorService(nil, nil) + svc.SetQuotaFetcher(newQuotaModeFetcher(map[int64]*Account{ + 2: {ID: 2, Platform: domain.PlatformDeepseek, Credentials: map[string]any{"account_mode": AccountModeCoding}}, + }, nil)) + + quota := &ChannelMonitor{Provider: MonitorProviderDeepseek, CheckMode: MonitorCheckModeQuota, AccountID: int64Ptr(2)} + require.ErrorIs(t, svc.revalidateLinkedAccount(context.Background(), quota), ErrChannelMonitorAccountNotSupportable) + require.NotNil(t, quota.AccountID, "quota mode keeps the binding for the admin to fix") + + probe := &ChannelMonitor{Provider: MonitorProviderDeepseek, CheckMode: MonitorCheckModeProbe, AccountID: int64Ptr(2)} + require.NoError(t, svc.revalidateLinkedAccount(context.Background(), probe)) + require.Nil(t, probe.AccountID, "probe mode should silently unbind unusable account") +} + +// provider-only 更新不得绕过 provider × check_mode 组合校验。 +func TestApplyMonitorUpdate_ProviderOnlyRevalidatesCheckMode(t *testing.T) { + probeKimi := func() *ChannelMonitor { + return &ChannelMonitor{ + Provider: MonitorProviderKimi, APIMode: MonitorAPIModeChatCompletions, + Endpoint: "https://api.kimi.com", PrimaryModel: "kimi-k2", + CheckMode: MonitorCheckModeProbe, + } + } + + provider := MonitorProviderAntigravity + err := applyMonitorUpdate(probeKimi(), ChannelMonitorUpdateParams{Provider: &provider}) + require.ErrorIs(t, err, ErrChannelMonitorInvalidCheckMode) + + // 带上 check_mode/account_id 的完整切换合法。 + accountID := int64(3) + err = applyMonitorUpdate(probeKimi(), ChannelMonitorUpdateParams{ + Provider: &provider, CheckMode: strPtr(MonitorCheckModeQuota), AccountID: &accountID, + }) + require.NoError(t, err) + + // 存量非法行(antigravity+probe)仅改名/停用不被砖化。 + legacy := &ChannelMonitor{ + Provider: MonitorProviderAntigravity, APIMode: MonitorAPIModeChatCompletions, + Endpoint: "https://example.com", PrimaryModel: "gemini-3-pro", + CheckMode: MonitorCheckModeProbe, + } + newName := "renamed" + require.NoError(t, applyMonitorUpdate(legacy, ChannelMonitorUpdateParams{Name: &newName})) +} + // --- quota → probe 切换的 key 管控(validateProbeAPIKey) --- func TestValidateProbeAPIKey_QuotaToProbeRequiresFreshKey(t *testing.T) { diff --git a/backend/internal/service/channel_monitor_service.go b/backend/internal/service/channel_monitor_service.go index 9b0bb0763..b08a623ce 100644 --- a/backend/internal/service/channel_monitor_service.go +++ b/backend/internal/service/channel_monitor_service.go @@ -406,7 +406,8 @@ func validateCreateParams(p ChannelMonitorCreateParams) error { return nil } -// validateLinkedAccount 校验关联账号存在且平台与监控 provider 一致。 +// validateLinkedAccount 校验关联账号存在、平台与监控 provider 一致、且能充当 +// 配额数据源(能力拦截,见 monitorAccountQuotaCapability)。 // fetcher 未注入时 fail-closed(拒绝创建配额监控,而不是创建后静默坏)。 func (s *ChannelMonitorService) validateLinkedAccount(ctx context.Context, provider string, accountID *int64) error { if accountID == nil || *accountID <= 0 { @@ -422,7 +423,7 @@ func (s *ChannelMonitorService) validateLinkedAccount(ctx context.Context, provi if account.Platform != provider { return ErrChannelMonitorProviderIncompatible } - return nil + return monitorAccountQuotaCapability(account) } // Update 更新监控。APIKey 字段:nil 或空字符串 = 不修改;非空 = 加密后覆盖。 @@ -533,6 +534,15 @@ func (s *ChannelMonitorService) revalidateLinkedAccount(ctx context.Context, m * m.AccountID = nil return nil } + // 能力失配(如 deepseek coding / zhipu payg / API-Key 型海外账号): + // quota 模式显式报错(有该类存量监控时编辑会被拦,出路是换账号或切 probe), + // probe 模式账号无用途,静默解绑。 + if err := monitorAccountQuotaCapability(account); err != nil { + if usesQuota { + return err + } + m.AccountID = nil + } return nil } @@ -886,11 +896,16 @@ func applyMonitorUpdate(existing *ChannelMonitor, p ChannelMonitorUpdateParams) existing.Provider = *p.Provider } if p.CheckMode != nil { - mode := defaultCheckMode(*p.CheckMode) - if err := validateCheckMode(existing.Provider, mode); err != nil { + existing.CheckMode = defaultCheckMode(*p.CheckMode) + } + // provider 与 check_mode 任一变化后统一复核组合矩阵:provider-only 更新 + // (如把 probe 监控的 provider 改成 antigravity)也不得落库非法组合,否则 + // 运行期恒 error。条件限定避免把存量非法行的 name/enabled-only 更新也判死 + // (否则连改名/停用都无法操作)。 + if p.Provider != nil || p.CheckMode != nil { + if err := validateCheckMode(existing.Provider, defaultCheckMode(existing.CheckMode)); err != nil { return err } - existing.CheckMode = mode } if p.AccountID != nil { if *p.AccountID > 0 { diff --git a/backend/internal/service/channel_monitor_validate.go b/backend/internal/service/channel_monitor_validate.go index faf7247fb..6b3b9fd81 100644 --- a/backend/internal/service/channel_monitor_validate.go +++ b/backend/internal/service/channel_monitor_validate.go @@ -190,21 +190,61 @@ func normalizeModels(in []string) []string { return out } -// normalizeMonitorPrimaryModel applies provider/check_mode defaults while -// preserving the existing required-model behavior: -// - Grok 探活默认轻量测活模型 -// - quota 模式占位 "quota"(primary_model 列 NotEmpty;历史行/时间线机制无需特判) +// normalizeMonitorPrimaryModel applies provider/check_mode defaults: +// - pure quota mode never sends requests: placeholder "quota" keeps +// primary_model NOT NULL (history rows / timeline need no special-casing) +// - quota_probe still sends a real probe request: empty model returns "" +// so validateCreateParams / applyMonitorUpdate report +// ErrChannelMonitorMissingPrimaryModel instead of probing model="quota" +// - Grok probing (probe/quota_probe) defaults to the lightweight check model func normalizeMonitorPrimaryModel(provider, checkMode, model string) string { model = strings.TrimSpace(model) + if model == "" && defaultCheckMode(checkMode) == MonitorCheckModeQuota { + return MonitorDefaultQuotaModel + } if model == "" && provider == MonitorProviderGrok { return MonitorDefaultGrokModel } - if model == "" && monitorCheckModeUsesQuota(defaultCheckMode(checkMode)) { - return MonitorDefaultQuotaModel - } return model } +// monitorAccountQuotaCapability 校验关联账号能否充当配额数据源,与 +// fetchUncached 的路由一一对应(coding→CN 额度端点 / payg→CN 余额端点 / +// 其余→AccountUsageService)。在创建/更新期拦截注定运行期永久 error 的组合: +// - kimi/zhipu/deepseek coding:GetCodingPlanProvider 须识别为 kimi/zhipu +// (deepseek coding、自定义域名 kimi coding 无法路由额度端点) +// - kimi/zhipu/deepseek payg:仅 kimi/deepseek 有公开余额端点(zhipu payg 无) +// - anthropic:OAuth / Setup Token(API-Key 型无 usage 通道,永久 error) +// - openai:OAuth(API-Key 型无 usage 通道) +// - gemini/grok/antigravity:本地统计/值通道降级,不会永久 error,放行 +func monitorAccountQuotaCapability(account *Account) error { + switch account.Platform { + case PlatformKimi, PlatformZhipu, PlatformDeepseek: + if account.IsCodingPlan() { + if p := account.GetCodingPlanProvider(); p != PlatformKimi && p != PlatformZhipu { + return ErrChannelMonitorAccountNotSupportable + } + return nil + } + if account.Platform == PlatformZhipu { + return ErrChannelMonitorAccountNotSupportable + } + return nil + case PlatformAnthropic: + if account.Type == AccountTypeOAuth || account.Type == AccountTypeSetupToken { + return nil + } + return ErrChannelMonitorAccountNotSupportable + case PlatformOpenAI: + if account.Type == AccountTypeOAuth { + return nil + } + return ErrChannelMonitorAccountNotSupportable + default: + return nil + } +} + // defaultAPIMode 空串归一为 chat_completions,保证历史数据与旧客户端兼容。 func defaultAPIMode(apiMode string) string { if strings.TrimSpace(apiMode) == "" { diff --git a/backend/internal/service/channel_pricing_multipliers_test.go b/backend/internal/service/channel_pricing_multipliers_test.go new file mode 100644 index 000000000..5b38fddec --- /dev/null +++ b/backend/internal/service/channel_pricing_multipliers_test.go @@ -0,0 +1,288 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func pricingMultiplier(value float64) *float64 { return &value } + +func TestConfiguredServiceTierMultiplier(t *testing.T) { + tests := []struct { + name string + serviceTier string + pricing *ModelPricing + want float64 + }{ + {name: "gpt-5.5 fast", serviceTier: "fast", pricing: &ModelPricing{FastMultiplier: pricingMultiplier(2.5)}, want: 2.5}, + {name: "priority alias", serviceTier: "priority", pricing: &ModelPricing{FastMultiplier: pricingMultiplier(2)}, want: 2}, + {name: "flex configured", serviceTier: "flex", pricing: &ModelPricing{FlexMultiplier: pricingMultiplier(0.4)}, want: 0.4}, + {name: "legacy fast default", serviceTier: "fast", pricing: &ModelPricing{}, want: 2}, + {name: "legacy flex default", serviceTier: "flex", pricing: &ModelPricing{}, want: 0.5}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.InDelta(t, tt.want, configuredServiceTierMultiplier(tt.serviceTier, tt.pricing), 1e-12) + }) + } +} + +func TestConfiguredServiceTierMultiplierAppliesToEveryTokenComponent(t *testing.T) { + pricing := &ModelPricing{ + InputPricePerToken: 5e-6, + OutputPricePerToken: 30e-6, + CacheCreationPricePerToken: 6.25e-6, + CacheReadPricePerToken: 0.5e-6, + FastMultiplier: pricingMultiplier(2.5), + FlexMultiplier: pricingMultiplier(0.5), + } + tokens := UsageTokens{ + InputTokens: 1_000_000, + OutputTokens: 1_000_000, + CacheCreationTokens: 1_000_000, + CacheReadTokens: 1_000_000, + } + service := &BillingService{} + + fast := service.computeTokenBreakdown(pricing, tokens, 1, "fast", false) + require.InDelta(t, 12.5, fast.InputCost, 1e-12) + require.InDelta(t, 75, fast.OutputCost, 1e-12) + require.InDelta(t, 15.625, fast.CacheCreationCost, 1e-12) + require.InDelta(t, 1.25, fast.CacheReadCost, 1e-12) + + flex := service.computeTokenBreakdown(pricing, tokens, 1, "flex", false) + require.InDelta(t, 2.5, flex.InputCost, 1e-12) + require.InDelta(t, 15, flex.OutputCost, 1e-12) + require.InDelta(t, 3.125, flex.CacheCreationCost, 1e-12) + require.InDelta(t, 0.25, flex.CacheReadCost, 1e-12) +} + +func TestChannelOverridePreservesCatalogFastRatioByDefault(t *testing.T) { + pricing := &ModelPricing{ + InputPricePerToken: 2, + InputPricePerTokenPriority: 4, + OutputPricePerToken: 6, + OutputPricePerTokenPriority: 12, + } + applyChannelTokenPriceOverrides(pricing, &ChannelModelPricing{ + InputPrice: pricingMultiplier(3), + OutputPrice: pricingMultiplier(9), + }) + + require.InDelta(t, 3, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 6, pricing.InputPricePerTokenPriority, 1e-12) + require.InDelta(t, 9, pricing.OutputPricePerToken, 1e-12) + require.InDelta(t, 18, pricing.OutputPricePerTokenPriority, 1e-12) +} + +func TestAnthropicFastUsesDefaultMultiplierWithoutCatalogTier(t *testing.T) { + pricing := &ModelPricing{InputPricePerToken: 5e-6, OutputPricePerToken: 25e-6} + cost := (&BillingService{}).computeTokenBreakdown(pricing, UsageTokens{ + InputTokens: 1_000_000, OutputTokens: 1_000_000, + }, 1, "fast", false) + + require.InDelta(t, 10, cost.InputCost, 1e-12) + require.InDelta(t, 50, cost.OutputCost, 1e-12) +} + +func TestBuiltInModelFastDefaults(t *testing.T) { + service := &BillingService{fallbackPrices: make(map[string]*ModelPricing)} + service.initFallbackPricing() + + for _, tt := range []struct { + model string + want float64 + }{ + {model: "gpt-5.5", want: 2.5}, + {model: "claude-opus-4.8", want: 2}, + {model: "claude-opus-5", want: 2}, + } { + pricing := service.fallbackPrices[tt.model] + require.NotNil(t, pricing) + require.InDelta(t, tt.want, pricing.InputPricePerTokenPriority/pricing.InputPricePerToken, 1e-12) + require.InDelta(t, tt.want, pricing.OutputPricePerTokenPriority/pricing.OutputPricePerToken, 1e-12) + } +} + +func TestIntervalMultipliersApplyToChannelBase(t *testing.T) { + base := &ModelPricing{ + InputPricePerToken: 5, + OutputPricePerToken: 30, + CacheCreationPricePerToken: 6.25, + CacheCreation5mPrice: 6.25, + CacheCreation1hPrice: 6.25, + CacheReadPricePerToken: 0.5, + FastMultiplier: pricingMultiplier(2), + FlexMultiplier: pricingMultiplier(0.5), + } + resolved := &ResolvedPricing{ + BasePricing: base, + Intervals: []PricingInterval{{ + MinTokens: 272000, + InputMultiplier: pricingMultiplier(2), + OutputMultiplier: pricingMultiplier(1.5), + CacheWriteMultiplier: pricingMultiplier(2), + CacheReadMultiplier: pricingMultiplier(2), + }}, + } + + pricing := (&ModelPricingResolver{}).GetIntervalPricing(resolved, 272001) + require.InDelta(t, 10, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 45, pricing.OutputPricePerToken, 1e-12) + require.InDelta(t, 12.5, pricing.CacheCreationPricePerToken, 1e-12) + require.InDelta(t, 1, pricing.CacheReadPricePerToken, 1e-12) + require.Same(t, base, (&ModelPricingResolver{}).GetIntervalPricing(resolved, 272000)) +} + +func TestIntervalExplicitPriceTakesPrecedenceOverMultiplier(t *testing.T) { + pricing := intervalToModelPricing(&PricingInterval{ + InputPrice: pricingMultiplier(7), + InputMultiplier: pricingMultiplier(2), + }, &ModelPricing{InputPricePerToken: 5}, nil) + + require.InDelta(t, 7, pricing.InputPricePerToken, 1e-12) +} + +func TestIntervalPricePreservesDefaultFastRatio(t *testing.T) { + pricing := intervalToModelPricing(&PricingInterval{ + InputPrice: pricingMultiplier(7), + }, &ModelPricing{ + InputPricePerToken: 5, + InputPricePerTokenPriority: 10, + }, nil) + + require.InDelta(t, 7, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 14, pricing.InputPricePerTokenPriority, 1e-12) +} + +func TestAnthropicSpeedServiceTier(t *testing.T) { + account := &Account{Platform: PlatformAnthropic} + + for _, model := range []string{"claude-opus-5", "claude-opus-4-8", "claude-opus-4.8"} { + tier := anthropicSpeedServiceTier(account, "fast", model) + require.NotNil(t, tier, "model %s should bill as fast", model) + require.Equal(t, "fast", *tier) + } + + require.Nil(t, anthropicSpeedServiceTier(&Account{Platform: PlatformOpenAI}, "fast", "claude-opus-5")) + require.Nil(t, anthropicSpeedServiceTier(account, "standard", "claude-opus-5")) +} + +// fast mode 不存在于这些模型/承载上,即便客户端传了 speed=fast 也不能计 2x。 +func TestAnthropicSpeedServiceTierRejectsUnsupportedTargets(t *testing.T) { + account := &Account{Platform: PlatformAnthropic} + + for _, model := range []string{ + "claude-opus-4-7", // fast mode 已被移除 + "claude-opus-4-6", // + "claude-opus-4-5", // 不能被 "opus-5" 规则误判 + "claude-sonnet-5", // 非 Opus + "claude-haiku-4-5", // + "", // + } { + require.Nil(t, anthropicSpeedServiceTier(account, "fast", model), + "model %q must not bill as fast", model) + } + + bedrock := &Account{Platform: PlatformAnthropic, Type: AccountTypeBedrock} + require.Nil(t, anthropicSpeedServiceTier(bedrock, "fast", "claude-opus-5")) +} + +func TestAnthropicSpeedModelPrefersMappedUpstreamModel(t *testing.T) { + parsed := &ParsedRequest{Model: "claude-opus-5"} + require.Equal(t, "claude-opus-4-7", anthropicSpeedModel(parsed, &ForwardResult{ + UpstreamModel: "claude-opus-4-7", + })) + require.Equal(t, "claude-opus-5", anthropicSpeedModel(parsed, &ForwardResult{})) +} + +func TestMultiplierOnlyIntervalIsValid(t *testing.T) { + require.NoError(t, ValidateIntervals([]PricingInterval{{ + MinTokens: 199999, + InputMultiplier: pricingMultiplier(2), + }}, BillingModeToken)) + require.NoError(t, checkIntervalsHavePrices(ChannelModelPricing{ + Models: []string{"grok-4.6"}, + Intervals: []PricingInterval{{ + MinTokens: 199999, + InputMultiplier: pricingMultiplier(2), + }}, + })) +} + +func TestChannelMultipliersMustBePositive(t *testing.T) { + zero := 0.0 + require.Error(t, checkPricesNotNegative(ChannelModelPricing{FastMultiplier: &zero})) + require.Error(t, checkPricesNotNegative(ChannelModelPricing{FlexMultiplier: &zero})) + require.Error(t, ValidateIntervals([]PricingInterval{{ + MinTokens: 100, + InputMultiplier: &zero, + }}, BillingModeToken)) +} + +func TestCalculateTokenCostContextTierEnablement(t *testing.T) { + base := &ModelPricing{InputPricePerToken: 1e-6} + resolved := &ResolvedPricing{ + BasePricing: base, + Intervals: []PricingInterval{{ + MinTokens: 100, + InputMultiplier: pricingMultiplier(2), + }}, + } + resolver := &ModelPricingResolver{} + service := &BillingService{} + tokens := UsageTokens{InputTokens: 200} + + t.Run("group disabled uses base tier", func(t *testing.T) { + resolved.longContextPricingEnabled = false + cost, err := service.calculateTokenCost(resolved, CostInput{ + Model: "custom", Tokens: tokens, RateMultiplier: 1, Resolver: resolver, + }) + require.NoError(t, err) + require.InDelta(t, 200e-6, cost.TotalCost, 1e-12) + }) + + t.Run("group enabled uses interval", func(t *testing.T) { + resolved.longContextPricingEnabled = true + accountDisabled := false + cost, err := service.calculateTokenCost(resolved, CostInput{ + Model: "custom", Tokens: tokens, RateMultiplier: 1, Resolver: resolver, + LongContextBillingEnabled: &accountDisabled, + }) + require.NoError(t, err) + require.InDelta(t, 400e-6, cost.TotalCost, 1e-12) + }) + + t.Run("account enabled overrides disabled group", func(t *testing.T) { + resolved.longContextPricingEnabled = false + accountEnabled := true + cost, err := service.calculateTokenCost(resolved, CostInput{ + Model: "custom", Tokens: tokens, RateMultiplier: 1, Resolver: resolver, + LongContextBillingEnabled: &accountEnabled, + }) + require.NoError(t, err) + require.InDelta(t, 400e-6, cost.TotalCost, 1e-12) + }) +} + +func TestCalculateTokenCostCombinesIntervalAndFastMultiplier(t *testing.T) { + resolved := &ResolvedPricing{ + BasePricing: &ModelPricing{ + InputPricePerToken: 1e-6, + FastMultiplier: pricingMultiplier(2.5), + }, + Intervals: []PricingInterval{{ + MinTokens: 100, + InputMultiplier: pricingMultiplier(2), + }}, + longContextPricingEnabled: true, + } + cost, err := (&BillingService{}).calculateTokenCost(resolved, CostInput{ + Model: "custom", Tokens: UsageTokens{InputTokens: 200}, RateMultiplier: 1, + ServiceTier: "fast", Resolver: &ModelPricingResolver{}, + }) + require.NoError(t, err) + require.InDelta(t, 1e-3, cost.TotalCost, 1e-12) +} diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index a2c4bf6eb..b3fe7dc63 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -357,7 +357,7 @@ func isPlatformPricingMatch(groupPlatform, pricingPlatform string) bool { // fallback used before a request target has been resolved. func matchingPlatforms(groupPlatform string) []string { if groupPlatform == PlatformComposite { - return []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok} + return []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek} } return []string{groupPlatform} } @@ -735,6 +735,17 @@ func checkPricesNotNegative(p ChannelModelPricing) error { return infraerrors.BadRequest("NEGATIVE_PRICE", fmt.Sprintf("%s must be >= 0", c.field)) } } + for _, c := range []struct { + field string + val *float64 + }{ + {"fast_multiplier", p.FastMultiplier}, + {"flex_multiplier", p.FlexMultiplier}, + } { + if c.val != nil && *c.val <= 0 { + return infraerrors.BadRequest("INVALID_MULTIPLIER", fmt.Sprintf("%s must be > 0", c.field)) + } + } return nil } @@ -742,7 +753,9 @@ func checkIntervalsHavePrices(p ChannelModelPricing) error { for _, iv := range p.Intervals { if iv.InputPrice == nil && iv.OutputPrice == nil && iv.CacheWritePrice == nil && iv.CacheReadPrice == nil && - iv.PerRequestPrice == nil { + iv.PerRequestPrice == nil && iv.InputMultiplier == nil && + iv.OutputMultiplier == nil && iv.CacheWriteMultiplier == nil && + iv.CacheReadMultiplier == nil { return infraerrors.BadRequest( "INTERVAL_MISSING_PRICE", fmt.Sprintf("interval [%d, %s] has no price fields set for model %v", diff --git a/backend/internal/service/channel_service_test.go b/backend/internal/service/channel_service_test.go index 9a81913b0..e443b80b5 100644 --- a/backend/internal/service/channel_service_test.go +++ b/backend/internal/service/channel_service_test.go @@ -2041,6 +2041,9 @@ func TestIsPlatformPricingMatch(t *testing.T) { {"gemini does NOT match anthropic", PlatformGemini, PlatformAnthropic, false}, {"composite matches openai pricing", PlatformComposite, PlatformOpenAI, true}, {"composite matches gemini pricing", PlatformComposite, PlatformGemini, true}, + {"composite matches kimi pricing", PlatformComposite, PlatformKimi, true}, + {"composite matches zhipu pricing", PlatformComposite, PlatformZhipu, true}, + {"composite matches deepseek pricing", PlatformComposite, PlatformDeepseek, true}, {"empty string matches nothing", "", PlatformAnthropic, false}, {"empty string matches empty", "", "", true}, } @@ -2066,7 +2069,7 @@ func TestMatchingPlatforms(t *testing.T) { {"anthropic returns itself", PlatformAnthropic, []string{PlatformAnthropic}}, {"gemini returns itself", PlatformGemini, []string{PlatformGemini}}, {"openai returns itself", PlatformOpenAI, []string{PlatformOpenAI}}, - {"composite returns concrete platforms", PlatformComposite, []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok}}, + {"composite returns concrete platforms", PlatformComposite, []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek}}, } for _, tt := range tests { diff --git a/backend/internal/service/cn_provider_balance_service.go b/backend/internal/service/cn_provider_balance_service.go index d01c9b6d0..06bab8ae0 100644 --- a/backend/internal/service/cn_provider_balance_service.go +++ b/backend/internal/service/cn_provider_balance_service.go @@ -84,14 +84,27 @@ func NewCNProviderBalanceService( // QueryBalance 探测指定 payg 账号的余额并落 Extra 快照。 func (s *CNProviderBalanceService) QueryBalance(ctx context.Context, accountID int64) (*CNProviderBalanceResult, error) { + account, err := s.loadPayGAccount(ctx, accountID) + if err != nil { + return nil, err + } + return s.QueryBalanceForAccount(ctx, account) +} + +// QueryBalanceForAccount 探测已加载账号(配额监控 fetcher / 周期余额检测复用, +// 避免二次 GetByID)。singleflight key 与 QueryBalance 相同,按账号 ID 合并。 +func (s *CNProviderBalanceService) QueryBalanceForAccount(ctx context.Context, account *Account) (*CNProviderBalanceResult, error) { if s == nil || s.accountRepo == nil || s.httpUpstream == nil { return nil, infraerrors.New(http.StatusInternalServerError, "CN_BALANCE_NOT_CONFIGURED", "cn provider balance service is not configured") } - key := "cn_balance:" + strconv.FormatInt(accountID, 10) + if err := validatePayGAccount(account); err != nil { + return nil, err + } + key := "cn_balance:" + strconv.FormatInt(account.ID, 10) resultCh := s.flight.DoChan(key, func() (any, error) { probeCtx, cancel := context.WithTimeout(context.Background(), cnBalanceUpstreamTimeout+5*time.Second) defer cancel() - return s.queryBalance(probeCtx, accountID) + return s.queryBalanceForAccount(probeCtx, account) }) select { case <-ctx.Done(): @@ -109,11 +122,7 @@ func (s *CNProviderBalanceService) QueryBalance(ctx context.Context, accountID i } } -func (s *CNProviderBalanceService) queryBalance(ctx context.Context, accountID int64) (*CNProviderBalanceResult, error) { - account, err := s.loadPayGAccount(ctx, accountID) - if err != nil { - return nil, err - } +func (s *CNProviderBalanceService) queryBalanceForAccount(ctx context.Context, account *Account) (*CNProviderBalanceResult, error) { provider := account.Platform if provider != PlatformKimi && provider != PlatformDeepseek { return nil, infraerrors.New(http.StatusBadRequest, "CN_BALANCE_NO_ENDPOINT", "account provider has no balance endpoint") @@ -230,17 +239,26 @@ func (s *CNProviderBalanceService) loadPayGAccount(ctx context.Context, accountI if err != nil { return nil, infraerrors.Newf(http.StatusNotFound, "CN_BALANCE_ACCOUNT_NOT_FOUND", "account not found: %v", err) } + if err := validatePayGAccount(account); err != nil { + return nil, err + } + return account, nil +} + +// validatePayGAccount 加载后的非 DB 校验(ForAccount 入口同样复用, +// 保证直传 account 也不绕过平台/模式检查)。 +func validatePayGAccount(account *Account) error { if account == nil { - return nil, infraerrors.New(http.StatusNotFound, "CN_BALANCE_ACCOUNT_NOT_FOUND", "account not found") + return infraerrors.New(http.StatusNotFound, "CN_BALANCE_ACCOUNT_NOT_FOUND", "account not found") } if !account.IsCNProvider() { - return nil, infraerrors.New(http.StatusBadRequest, "CN_BALANCE_INVALID_PLATFORM", "account is not a CN provider account") + return infraerrors.New(http.StatusBadRequest, "CN_BALANCE_INVALID_PLATFORM", "account is not a CN provider account") } // coding 账号走额度探测,余额端点不适用。 if account.IsCodingPlan() { - return nil, infraerrors.New(http.StatusBadRequest, "CN_BALANCE_CODING_PLAN", "coding plan account has no balance endpoint; use quota probe") + return infraerrors.New(http.StatusBadRequest, "CN_BALANCE_CODING_PLAN", "coding plan account has no balance endpoint; use quota probe") } - return account, nil + return nil } func (s *CNProviderBalanceService) resolveProxyURL(ctx context.Context, account *Account) string { diff --git a/backend/internal/service/cn_provider_foraccount_test.go b/backend/internal/service/cn_provider_foraccount_test.go new file mode 100644 index 000000000..664da505f --- /dev/null +++ b/backend/internal/service/cn_provider_foraccount_test.go @@ -0,0 +1,125 @@ +package service + +// ForAccount 直传入口(P2-6)的校验回归测试: +// QueryUsageForAccount / QueryBalanceForAccount 接受已加载的 *Account, +// 但必须复用与 ID 入口相同的加载后校验——直传不能绕过平台/模式检查, +// 且校验在 singleflight 之前完成(无效账号不得发起任何上游请求)。 + +import ( + "context" + "testing" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/stretchr/testify/require" +) + +func codingAccount(platform string) *Account { + return &Account{ + ID: 1, Platform: platform, Type: AccountTypeAPIKey, Status: StatusActive, + Credentials: map[string]any{"account_mode": AccountModeCoding, "api_key": "sk-test"}, + } +} + +func paygAccount(platform string) *Account { + return &Account{ + ID: 2, Platform: platform, Type: AccountTypeAPIKey, Status: StatusActive, + Credentials: map[string]any{"account_mode": AccountModePayG, "api_key": "sk-test"}, + } +} + +func requireReason(t *testing.T, err error, reason string) { + t.Helper() + require.Error(t, err) + var appErr *infraerrors.ApplicationError + require.ErrorAs(t, err, &appErr) + require.Equal(t, reason, appErr.Reason) +} + +func TestValidateCodingPlanAccount_Matrix(t *testing.T) { + cases := []struct { + name string + account *Account + wantReason string + }{ + {name: "nil", account: nil, wantReason: "CN_QUOTA_ACCOUNT_NOT_FOUND"}, + {name: "non cn provider", account: &Account{ID: 3, Platform: PlatformAnthropic}, wantReason: "CN_QUOTA_INVALID_PLATFORM"}, + {name: "payg has no quota endpoint", account: paygAccount(PlatformKimi), wantReason: "CN_QUOTA_NOT_CODING_PLAN"}, + {name: "kimi coding ok", account: codingAccount(PlatformKimi)}, + {name: "zhipu coding ok", account: codingAccount(PlatformZhipu)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateCodingPlanAccount(tc.account) + if tc.wantReason == "" { + require.NoError(t, err) + return + } + requireReason(t, err, tc.wantReason) + }) + } +} + +func TestValidatePayGAccount_Matrix(t *testing.T) { + cases := []struct { + name string + account *Account + wantReason string + }{ + {name: "nil", account: nil, wantReason: "CN_BALANCE_ACCOUNT_NOT_FOUND"}, + {name: "non cn provider", account: &Account{ID: 3, Platform: PlatformAnthropic}, wantReason: "CN_BALANCE_INVALID_PLATFORM"}, + {name: "coding has no balance endpoint", account: codingAccount(PlatformKimi), wantReason: "CN_BALANCE_CODING_PLAN"}, + {name: "kimi payg ok", account: paygAccount(PlatformKimi)}, + {name: "deepseek payg ok", account: paygAccount(PlatformDeepseek)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validatePayGAccount(tc.account) + if tc.wantReason == "" { + require.NoError(t, err) + return + } + requireReason(t, err, tc.wantReason) + }) + } +} + +// 直传入口的校验在 singleflight/上游请求之前:无效账号必须零出站请求。 +func TestCNProviderQuotaService_QueryUsageForAccount_RejectsInvalidAccount(t *testing.T) { + repo := &fakeCNProbeAccountRepo{} + upstream := &recordingHTTPUpstream{} + svc := NewCNProviderQuotaService(repo, nil, upstream, nil) + + _, err := svc.QueryUsageForAccount(context.Background(), paygAccount(PlatformKimi)) + requireReason(t, err, "CN_QUOTA_NOT_CODING_PLAN") + require.Zero(t, upstream.calls) + + _, err = svc.QueryUsageForAccount(context.Background(), nil) + requireReason(t, err, "CN_QUOTA_ACCOUNT_NOT_FOUND") + require.Zero(t, upstream.calls) +} + +func TestCNProviderBalanceService_QueryBalanceForAccount_RejectsInvalidAccount(t *testing.T) { + repo := &fakeCNProbeAccountRepo{} + upstream := &recordingHTTPUpstream{} + svc := NewCNProviderBalanceService(repo, nil, upstream, nil) + + _, err := svc.QueryBalanceForAccount(context.Background(), codingAccount(PlatformKimi)) + requireReason(t, err, "CN_BALANCE_CODING_PLAN") + require.Zero(t, upstream.calls) + + _, err = svc.QueryBalanceForAccount(context.Background(), &Account{ID: 9, Platform: PlatformAnthropic}) + requireReason(t, err, "CN_BALANCE_INVALID_PLATFORM") + require.Zero(t, upstream.calls) +} + +// ID 入口与 ForAccount 入口对同一账号的行为一致(loadCodingPlanAccount 的 +// 加载后校验 = validateCodingPlanAccount;余额侧对称)。 +func TestCNProviderServices_IDEntryAppliesSameValidation(t *testing.T) { + repo := &fakeCNProbeAccountRepo{account: paygAccount(PlatformKimi)} + upstream := &recordingHTTPUpstream{} + svc := NewCNProviderQuotaService(repo, nil, upstream, nil) + + _, err := svc.QueryUsage(context.Background(), 2) + requireReason(t, err, "CN_QUOTA_NOT_CODING_PLAN") + require.Zero(t, upstream.calls) +} diff --git a/backend/internal/service/cn_provider_quota_service.go b/backend/internal/service/cn_provider_quota_service.go index f4cda8d6d..271b18afe 100644 --- a/backend/internal/service/cn_provider_quota_service.go +++ b/backend/internal/service/cn_provider_quota_service.go @@ -89,14 +89,27 @@ func NewCNProviderQuotaService( // QueryUsage 探测指定账号的 Coding Plan 滚动窗口用量并落 Extra 快照。 // 同一账号的并发探测会被 singleflight 合并。 func (s *CNProviderQuotaService) QueryUsage(ctx context.Context, accountID int64) (*CNProviderQuotaProbeResult, error) { + account, err := s.loadCodingPlanAccount(ctx, accountID) + if err != nil { + return nil, err + } + return s.QueryUsageForAccount(ctx, account) +} + +// QueryUsageForAccount 探测已加载账号(配额监控 fetcher 复用,避免二次 GetByID)。 +// singleflight key 与 QueryUsage 相同,按账号 ID 与 admin 侧并发探测合并。 +func (s *CNProviderQuotaService) QueryUsageForAccount(ctx context.Context, account *Account) (*CNProviderQuotaProbeResult, error) { if s == nil || s.accountRepo == nil || s.httpUpstream == nil { return nil, infraerrors.New(http.StatusInternalServerError, "CN_QUOTA_NOT_CONFIGURED", "cn provider quota service is not configured") } - key := "cn_quota:" + strconv.FormatInt(accountID, 10) + if err := validateCodingPlanAccount(account); err != nil { + return nil, err + } + key := "cn_quota:" + strconv.FormatInt(account.ID, 10) resultCh := s.flight.DoChan(key, func() (any, error) { probeCtx, cancel := context.WithTimeout(context.Background(), cnQuotaUpstreamTimeout+5*time.Second) defer cancel() - return s.queryUsage(probeCtx, accountID) + return s.queryUsageForAccount(probeCtx, account) }) select { case <-ctx.Done(): @@ -114,12 +127,7 @@ func (s *CNProviderQuotaService) QueryUsage(ctx context.Context, accountID int64 } } -func (s *CNProviderQuotaService) queryUsage(ctx context.Context, accountID int64) (*CNProviderQuotaProbeResult, error) { - account, err := s.loadCodingPlanAccount(ctx, accountID) - if err != nil { - return nil, err - } - +func (s *CNProviderQuotaService) queryUsageForAccount(ctx context.Context, account *Account) (*CNProviderQuotaProbeResult, error) { provider := account.GetCodingPlanProvider() if provider != PlatformKimi && provider != PlatformZhipu { return nil, infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NOT_CODING_PLAN", "account is not a kimi/zhipu coding plan account") @@ -231,18 +239,27 @@ func (s *CNProviderQuotaService) loadCodingPlanAccount(ctx context.Context, acco if err != nil { return nil, infraerrors.Newf(http.StatusNotFound, "CN_QUOTA_ACCOUNT_NOT_FOUND", "account not found: %v", err) } - if account == nil { - return nil, infraerrors.New(http.StatusNotFound, "CN_QUOTA_ACCOUNT_NOT_FOUND", "account not found") - } - if !account.IsCNProvider() { - return nil, infraerrors.New(http.StatusBadRequest, "CN_QUOTA_INVALID_PLATFORM", "account is not a CN provider account") - } - if !account.IsCodingPlan() { - return nil, infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NOT_CODING_PLAN", "account is not a coding plan account") + if err := validateCodingPlanAccount(account); err != nil { + return nil, err } return account, nil } +// validateCodingPlanAccount 加载后的非 DB 校验(ForAccount 入口同样复用, +// 保证直传 account 也不绕过平台/模式检查)。 +func validateCodingPlanAccount(account *Account) error { + if account == nil { + return infraerrors.New(http.StatusNotFound, "CN_QUOTA_ACCOUNT_NOT_FOUND", "account not found") + } + if !account.IsCNProvider() { + return infraerrors.New(http.StatusBadRequest, "CN_QUOTA_INVALID_PLATFORM", "account is not a CN provider account") + } + if !account.IsCodingPlan() { + return infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NOT_CODING_PLAN", "account is not a coding plan account") + } + return nil +} + func (s *CNProviderQuotaService) resolveProxyURL(ctx context.Context, account *Account) string { if account == nil || account.ProxyID == nil { return "" diff --git a/backend/internal/service/cn_providers_test.go b/backend/internal/service/cn_providers_test.go index 24b8bd440..8f46e828e 100644 --- a/backend/internal/service/cn_providers_test.go +++ b/backend/internal/service/cn_providers_test.go @@ -55,9 +55,9 @@ func TestCNMillisToRFC3339(t *testing.T) { t.Parallel() // 1700000000 秒 = 1700000000000 毫秒 want := time.UnixMilli(1700000000000).UTC().Format(time.RFC3339) - require.Equal(t, want, cnMillisToRFC3339(1700000000)) // 秒级 - require.Equal(t, want, cnMillisToRFC3339(1700000000000)) // 毫秒级 - require.Equal(t, "", cnMillisToRFC3339(0)) // 非正 + require.Equal(t, want, cnMillisToRFC3339(1700000000)) // 秒级 + require.Equal(t, want, cnMillisToRFC3339(1700000000000)) // 毫秒级 + require.Equal(t, "", cnMillisToRFC3339(0)) // 非正 require.Equal(t, "", cnMillisToRFC3339(-1)) } @@ -294,8 +294,8 @@ func TestEvaluateAccountSchedulingThreshold_KimiCodingPlan(t *testing.T) { account := &Account{ Platform: PlatformKimi, Extra: map[string]any{ - "kimi_5h_used_percent": 90.0, - "kimi_5h_reset_at": reset.Format(time.RFC3339), + "kimi_5h_used_percent": 90.0, + "kimi_5h_reset_at": reset.Format(time.RFC3339), "kimi_weekly_used_percent": 30.0, "kimi_weekly_reset_at": now.Add(7 * 24 * time.Hour).Format(time.RFC3339), }, @@ -410,9 +410,9 @@ func TestGetOpenAIProtocolAPIKey_CNProviders(t *testing.T) { t.Parallel() kimi := &Account{ - Platform: PlatformKimi, - Type: AccountTypeAPIKey, - Credentials: map[string]any{"api_key": "sk-kimi"}, + Platform: PlatformKimi, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-kimi"}, } require.Equal(t, "sk-kimi", kimi.GetOpenAIProtocolAPIKey()) require.False(t, kimi.IsOpenAIApiKey(), "IsOpenAIApiKey stays openai-only for scheduling gates") @@ -486,12 +486,66 @@ func TestGetAPIProtocol(t *testing.T) { require.Equal(t, APIProtocolAnthropic, mk(PlatformKimi, APIProtocolAnthropic).GetAPIProtocol()) require.Equal(t, APIProtocolAnthropic, mk(PlatformDeepseek, APIProtocolAnthropic).GetAPIProtocol()) require.Equal(t, APIProtocolResponses, mk(PlatformDeepseek, APIProtocolResponses).GetAPIProtocol()) + require.Equal(t, APIProtocolAdaptive, mk(PlatformKimi, APIProtocolAdaptive).GetAPIProtocol()) + require.Equal(t, APIProtocolAdaptive, mk(PlatformZhipu, APIProtocolAdaptive).GetAPIProtocol()) + require.Equal(t, APIProtocolAdaptive, mk(PlatformDeepseek, APIProtocolAdaptive).GetAPIProtocol()) require.Equal(t, APIProtocolChatCompletions, mk(PlatformKimi, APIProtocolResponses).GetAPIProtocol(), "kimi 无 responses 端点") require.Equal(t, APIProtocolChatCompletions, mk(PlatformZhipu, APIProtocolResponses).GetAPIProtocol(), "zhipu 无 responses 端点") require.Equal(t, APIProtocolChatCompletions, mk(PlatformKimi, "bogus").GetAPIProtocol(), "非法值回退默认") require.Equal(t, APIProtocolChatCompletions, (&Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}).GetAPIProtocol(), "非 CN 供应商恒为默认") } +func TestAdaptiveProtocolBaseURLs(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + platform string + mode string + wantChat string + wantAnthropic string + wantResponses string + }{ + {"kimi payg", PlatformKimi, AccountModePayG, DefaultKimiPayGBaseURL, DefaultKimiPayGAnthropicBaseURL, DefaultKimiPayGBaseURL}, + {"kimi coding", PlatformKimi, AccountModeCoding, DefaultKimiCodingBaseURL, DefaultKimiCodingAnthropicBaseURL, DefaultKimiCodingBaseURL}, + {"zhipu payg", PlatformZhipu, AccountModePayG, DefaultZhipuPayGBaseURL, DefaultZhipuAnthropicBaseURL, DefaultZhipuPayGBaseURL}, + {"zhipu coding", PlatformZhipu, AccountModeCoding, DefaultZhipuCodingBaseURL, DefaultZhipuAnthropicBaseURL, DefaultZhipuCodingBaseURL}, + {"deepseek", PlatformDeepseek, AccountModePayG, DefaultDeepseekBaseURL, DefaultDeepseekAnthropicBaseURL, DefaultDeepseekBaseURL}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + account := &Account{Platform: tc.platform, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "api_protocol": APIProtocolAdaptive, + "account_mode": tc.mode, + }} + require.Equal(t, tc.wantChat, account.GetCNProtocolBaseURL(APIProtocolChatCompletions)) + require.Equal(t, tc.wantAnthropic, account.GetCNProtocolBaseURL(APIProtocolAnthropic)) + require.Equal(t, tc.wantResponses, account.GetCNProtocolBaseURL(APIProtocolResponses)) + require.Equal(t, tc.wantAnthropic, account.GetAnthropicProtocolBaseURL()) + }) + } +} + +func TestAdaptiveProtocolBaseURLOverrides(t *testing.T) { + t.Parallel() + + account := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "api_protocol": APIProtocolAdaptive, + "base_url": "https://legacy-chat.example.com", + "api_base_urls": map[string]any{ + APIProtocolChatCompletions: "https://chat.example.com", + APIProtocolAnthropic: "https://anthropic.example.com", + APIProtocolResponses: "https://responses.example.com", + }, + }} + + require.Equal(t, "https://chat.example.com", account.GetOpenAIBaseURL()) + require.Equal(t, "https://chat.example.com", account.GetCNProtocolBaseURL(APIProtocolChatCompletions)) + require.Equal(t, "https://anthropic.example.com", account.GetAnthropicProtocolBaseURL()) + require.Equal(t, "https://responses.example.com", account.GetCNProtocolBaseURL(APIProtocolResponses)) +} + // TestAnthropicProtocolBaseURL 验证 Anthropic 协议默认端点与协议感知的 // OpenAI 格式 base 回退。 func TestAnthropicProtocolBaseURL(t *testing.T) { @@ -600,6 +654,14 @@ func TestNormalizeDeepSeekResponsesRequestBody(t *testing.T) { require.False(t, gjson.GetBytes(normalized, "previous_response_id").Exists()) require.Equal(t, "deepseek-v4-pro", gjson.GetBytes(normalized, "model").String()) + deepseekAdaptive := &Account{ + Platform: PlatformDeepseek, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_protocol": APIProtocolAdaptive}, + } + adaptiveNormalized := normalizeDeepSeekResponsesRequestBody(deepseekAdaptive, body) + require.False(t, gjson.GetBytes(adaptiveNormalized, "store").Bool()) + require.False(t, gjson.GetBytes(adaptiveNormalized, "previous_response_id").Exists()) + // 非 responses 协议(deepseek CC 账号)原样返回 deepseekCC := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey} require.Equal(t, string(body), string(normalizeDeepSeekResponsesRequestBody(deepseekCC, body))) diff --git a/backend/internal/service/composite_platform.go b/backend/internal/service/composite_platform.go index b5def4ff5..e4d836feb 100644 --- a/backend/internal/service/composite_platform.go +++ b/backend/internal/service/composite_platform.go @@ -106,6 +106,12 @@ func DetectModelPlatform(model string) (string, bool) { return PlatformGemini, true case "xai", "x-ai", "grok": return PlatformGrok, true + case "kimi", "moonshot": + return PlatformKimi, true + case "zhipu", "glm", "bigmodel": + return PlatformZhipu, true + case "deepseek": + return PlatformDeepseek, true } if rest != "" { normalized = strings.TrimPrefix(rest, "models/") @@ -133,6 +139,13 @@ func DetectModelPlatform(model string) (string, bool) { return PlatformGemini, true case normalized == "grok" || strings.HasPrefix(normalized, "grok-"): return PlatformGrok, true + case strings.HasPrefix(normalized, "kimi-"), + strings.HasPrefix(normalized, "moonshot-"): + return PlatformKimi, true + case strings.HasPrefix(normalized, "glm-"): + return PlatformZhipu, true + case strings.HasPrefix(normalized, "deepseek-"): + return PlatformDeepseek, true default: return "", false } @@ -179,7 +192,8 @@ func (s *GatewayService) resolveCompositeRouteDecision(ctx context.Context, grou func isConcreteRequestPlatform(platform string) bool { switch platform { - case PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok: + case PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok, + PlatformKimi, PlatformZhipu, PlatformDeepseek: return true default: return false diff --git a/backend/internal/service/composite_platform_test.go b/backend/internal/service/composite_platform_test.go index 290fc4ac0..e14563488 100644 --- a/backend/internal/service/composite_platform_test.go +++ b/backend/internal/service/composite_platform_test.go @@ -25,6 +25,10 @@ func TestDetectModelPlatform(t *testing.T) { {name: "learnlm", model: "learnlm-2.0-flash-experimental", platform: PlatformGemini, ok: true}, {name: "grok", model: "grok-4", platform: PlatformGrok, ok: true}, {name: "xai prefix", model: "xai/grok-4", platform: PlatformGrok, ok: true}, + {name: "kimi", model: "kimi-k2-thinking", platform: PlatformKimi, ok: true}, + {name: "moonshot prefix", model: "moonshot/moonshot-v1-32k", platform: PlatformKimi, ok: true}, + {name: "zhipu", model: "glm-5.2", platform: PlatformZhipu, ok: true}, + {name: "deepseek", model: "deepseek-v4-pro", platform: PlatformDeepseek, ok: true}, {name: "unknown", model: "llama-4-maverick", ok: false}, } @@ -59,7 +63,14 @@ func TestCompositeGroupSchedulerHasAllCanonicalPlatformBuckets(t *testing.T) { platforms = append(platforms, platform) } require.ElementsMatch(t, - []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok}, + []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek}, platforms, ) } + +func TestCompositeConcretePlatformsIncludeCNProviders(t *testing.T) { + for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} { + require.True(t, isConcreteRequestPlatform(platform)) + require.True(t, canCopyAccountsFromGroupPlatform(PlatformComposite, platform)) + } +} diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index ba35301be..860276b75 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -64,6 +64,7 @@ const ( APIProtocolChatCompletions = domain.APIProtocolChatCompletions APIProtocolAnthropic = domain.APIProtocolAnthropic APIProtocolResponses = domain.APIProtocolResponses + APIProtocolAdaptive = domain.APIProtocolAdaptive ) // 国产 OpenAI 兼容供应商各模式的默认 base_url。 diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index 863e4d2e8..d8a105c7d 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -88,11 +88,21 @@ func sleepWithContext(ctx context.Context, d time.Duration) error { } // Forward 转发请求到Claude API -func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (*ForwardResult, error) { +func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (result *ForwardResult, err error) { startTime := time.Now() if parsed == nil { return nil, fmt.Errorf("parse request: empty request") } + // Anthropic Fast is requested with speed=fast rather than OpenAI's + // service_tier. Attach it at this shared boundary so passthrough, OAuth and + // partial-stream results all use the same billing and usage-log path. + defer func() { + if result != nil { + if tier := anthropicSpeedServiceTier(account, parsed.Speed, anthropicSpeedModel(parsed, result)); tier != nil { + result.ServiceTier = tier + } + } + }() beginUpstreamResponseModelObservation(c) // Web Search 模拟:纯 web_search 请求时,直接调用搜索 API 构造响应 @@ -885,6 +895,50 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A }, nil } +func anthropicSpeedModel(parsed *ParsedRequest, result *ForwardResult) string { + if result != nil { + if upstreamModel := strings.TrimSpace(result.UpstreamModel); upstreamModel != "" { + return upstreamModel + } + } + if parsed == nil { + return "" + } + return parsed.Model +} + +// anthropicSpeedServiceTier 把 Anthropic 的 speed=fast 归一成可计费的 "fast" tier。 +// +// Fast mode 目前只在 Claude Opus 5 / Opus 4.8 上存在,且不支持 Bedrock 等第三方 +// 承载(Opus 4.7 的 fast mode 已被移除,传 speed=fast 会直接报错)。这里按模型和 +// 平台收紧,避免上游根本没跑 fast 时仍然按 2x 计费——宁可漏收也不能多收。 +// +// 注:判据是请求参数而非响应里的 usage.speed。等 usage 解析链路统一暴露该字段后, +// 应改为以响应为准。 +func anthropicSpeedServiceTier(account *Account, speed, model string) *string { + if account == nil || account.Platform != PlatformAnthropic || speed != "fast" { + return nil + } + if account.IsBedrock() || !modelSupportsAnthropicFastMode(model) { + return nil + } + tier := "fast" + return &tier +} + +// modelSupportsAnthropicFastMode 判断模型是否属于支持 fast mode 的 Opus 5 / Opus 4.8。 +func modelSupportsAnthropicFastMode(model string) bool { + modelLower := strings.ToLower(strings.TrimSpace(model)) + if !strings.Contains(modelLower, "opus") { + return false + } + // "opus-5" 必须先判:不能用裸 "5" 匹配,否则 claude-opus-4-5 会被误判。 + if strings.Contains(modelLower, "opus-5") || strings.Contains(modelLower, "opus5") { + return true + } + return strings.Contains(modelLower, "4.8") || strings.Contains(modelLower, "4-8") +} + // ResolveChannelMapping 委托渠道服务解析模型映射 func (s *GatewayService) ResolveChannelMapping(ctx context.Context, groupID int64, model string) ChannelMappingResult { if s.channelService == nil { diff --git a/backend/internal/service/gateway_hotpath_optimization_test.go b/backend/internal/service/gateway_hotpath_optimization_test.go index d9a00710a..1bdf48bd4 100644 --- a/backend/internal/service/gateway_hotpath_optimization_test.go +++ b/backend/internal/service/gateway_hotpath_optimization_test.go @@ -158,6 +158,13 @@ func (s *stickyGatewayCacheHotpathStub) ReleaseGrokVideoBilled(_ context.Context return nil } +func (s *stickyGatewayCacheHotpathStub) SetReasoningContent(_ context.Context, _ string, _ string, _ time.Duration) error { + return nil +} +func (s *stickyGatewayCacheHotpathStub) GetReasoningContent(_ context.Context, _ string) (string, error) { + return "", ErrReasoningContentNotFound +} + func (s *modelsListAccountRepoStub) ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]Account, error) { s.listByGroupCalls.Add(1) if s.err != nil { diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index ac734a364..664b6bf08 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -291,6 +291,13 @@ func (m *mockGatewayCacheForPlatform) ReleaseGrokVideoBilled(_ context.Context, return nil } +func (m *mockGatewayCacheForPlatform) SetReasoningContent(_ context.Context, _ string, _ string, _ time.Duration) error { + return nil +} +func (m *mockGatewayCacheForPlatform) GetReasoningContent(_ context.Context, _ string) (string, error) { + return "", ErrReasoningContentNotFound +} + type mockGroupRepoForGateway struct { groups map[int64]*Group getByIDCalls int diff --git a/backend/internal/service/gateway_request.go b/backend/internal/service/gateway_request.go index 4ffd5ee8e..111b807f2 100644 --- a/backend/internal/service/gateway_request.go +++ b/backend/internal/service/gateway_request.go @@ -117,6 +117,7 @@ func clearGatewayRequestDerivedState(parsed *ParsedRequest) { parsed.HasSystem = false parsed.ThinkingEnabled = false parsed.OutputEffort = "" + parsed.Speed = "" parsed.MaxTokens = 0 parsed.systemRange = missingJSONRange() parsed.messagesRange = missingJSONRange() @@ -224,6 +225,9 @@ func parseGatewayRequestCurrentBody(parsed *ParsedRequest, protocol string) erro parsed.ThinkingEnabled = thinkingType == "enabled" || thinkingType == "adaptive" parsed.OutputEffort = strings.TrimSpace(gjson.Get(jsonStr, "output_config.effort").String()) + if protocol == domain.PlatformAnthropic { + parsed.Speed = strings.ToLower(strings.TrimSpace(gjson.Get(jsonStr, "speed").String())) + } maxTokensResult := gjson.Get(jsonStr, "max_tokens") if maxTokensResult.Exists() && maxTokensResult.Type == gjson.Number { @@ -282,6 +286,7 @@ type ParsedRequest struct { HasSystem bool // 是否包含 system 字段(包含 null 也视为显式传入) ThinkingEnabled bool // 是否开启 thinking(部分平台会影响最终模型名) OutputEffort string // output_config.effort(Claude API 的推理强度控制) + Speed string // Anthropic speed(当前可计费值为 "fast") MaxTokens int // max_tokens 值(用于探测请求拦截) SessionContext *SessionContext // 可选:请求上下文区分因子(nil 时行为不变) diff --git a/backend/internal/service/gateway_request_test.go b/backend/internal/service/gateway_request_test.go index d4e010a5e..6074c0299 100644 --- a/backend/internal/service/gateway_request_test.go +++ b/backend/internal/service/gateway_request_test.go @@ -42,6 +42,22 @@ func TestParseGatewayRequest_ThinkingAdaptiveEnabled(t *testing.T) { require.True(t, parsed.ThinkingEnabled) } +func TestParseGatewayRequest_AnthropicFastSpeed(t *testing.T) { + parsed, err := ParseGatewayRequest( + NewRequestBodyRef([]byte(`{"model":"claude-opus-4-8","speed":" FAST "}`)), + domain.PlatformAnthropic, + ) + require.NoError(t, err) + require.Equal(t, "fast", parsed.Speed) + + nonAnthropic, err := ParseGatewayRequest( + NewRequestBodyRef([]byte(`{"model":"gpt-5.4","speed":"fast"}`)), + "responses", + ) + require.NoError(t, err) + require.Empty(t, nonAnthropic.Speed) +} + func TestParseGatewayRequest_MaxTokens(t *testing.T) { body := []byte(`{"model":"claude-haiku-4-5","max_tokens":1}`) parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), "") diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 7e42a74cb..20e7e5b9e 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -452,6 +452,10 @@ var allowedHeaders = map[string]bool{ // cache implementation (e.g. redis.Nil), mirroring ErrRefreshTokenNotFound. var ErrStickySessionNotFound = errors.New("sticky session not found") +// ErrReasoningContentNotFound is returned by GatewayCache.GetReasoningContent +// when no cached reasoning content exists for the reasoning item ID. +var ErrReasoningContentNotFound = errors.New("reasoning content not found") + // GatewayCache 定义网关服务的缓存操作接口。 // 提供粘性会话(Sticky Session)的存储、查询、刷新和删除功能。 // @@ -486,6 +490,16 @@ type GatewayCache interface { ClaimGrokVideoBilled(ctx context.Context, key string, ttl time.Duration) (bool, error) // ReleaseGrokVideoBilled clears a claim so a failed RecordUsage can retry billing. ReleaseGrokVideoBilled(ctx context.Context, key string) error + + // Reasoning content cache (Responses→Chat Completions 桥接)。 + // SetReasoningContent 按 reasoning item id 缓存 reasoning 全文,供后续请求 + // 在客户端不回传明文 summary 时回注 reasoning_content(DeepSeek thinking + // mode 要求回传,否则 400)。 + SetReasoningContent(ctx context.Context, itemID string, content string, ttl time.Duration) error + // GetReasoningContent 返回缓存的 reasoning 全文;未命中返回 + // ErrReasoningContentNotFound,使 service 层无需依赖具体缓存实现即可 + // 区分"未缓存"与真实读取失败。 + GetReasoningContent(ctx context.Context, itemID string) (string, error) } // derefGroupID safely dereferences *int64 to int64, returning 0 if nil @@ -605,6 +619,9 @@ type ForwardResult struct { FirstTokenMs *int // 首字时间(流式请求) ClientDisconnect bool // 客户端是否在流式传输过程中断开 ReasoningEffort *string + // ServiceTier records the billable request tier. OpenAI uses service_tier; + // Anthropic speed=fast is normalized to "fast". + ServiceTier *string // 图片生成计费字段(图片生成模型使用) ImageCount int // 生成的图片数量 diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index c9671328b..e1fac3d6c 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -1157,6 +1157,7 @@ func (s *GatewayService) calculateTokenCost( RequestCount: 1, RateMultiplier: multiplier, PricingAt: pricingAt, + ServiceTier: optionalStringValue(result.ServiceTier), Resolver: s.resolver, Resolved: resolved, }) @@ -1167,7 +1168,8 @@ func (s *GatewayService) calculateTokenCost( gid := apiKey.Group.ID cost, err = s.billingService.CalculateCostUnified(CostInput{ Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, - Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, PricingAt: pricingAt, Resolver: s.resolver, + Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, PricingAt: pricingAt, + ServiceTier: optionalStringValue(result.ServiceTier), Resolver: s.resolver, }) } else { cost, err = s.billingService.CalculateCost(billingModel, tokens, multiplier) @@ -1219,6 +1221,7 @@ func (s *GatewayService) buildRecordUsageLog( UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel), UpstreamResponseModel: optionalTrimmedStringPtr(result.UpstreamResponseModel), UpstreamModelMismatch: upstreamModelMismatch(sentModel, result.UpstreamResponseModel), + ServiceTier: result.ServiceTier, ReasoningEffort: result.ReasoningEffort, InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint), UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint), diff --git a/backend/internal/service/gemini_multiplatform_test.go b/backend/internal/service/gemini_multiplatform_test.go index 63ebc929b..9a89998d5 100644 --- a/backend/internal/service/gemini_multiplatform_test.go +++ b/backend/internal/service/gemini_multiplatform_test.go @@ -319,6 +319,13 @@ func (m *mockGatewayCacheForGemini) ReleaseGrokVideoBilled(_ context.Context, _ return nil } +func (m *mockGatewayCacheForGemini) SetReasoningContent(_ context.Context, _ string, _ string, _ time.Duration) error { + return nil +} +func (m *mockGatewayCacheForGemini) GetReasoningContent(_ context.Context, _ string) (string, error) { + return "", ErrReasoningContentNotFound +} + // TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiPlatform 测试 Gemini 单平台选择 func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiPlatform(t *testing.T) { ctx := context.Background() diff --git a/backend/internal/service/model_pricing_resolver.go b/backend/internal/service/model_pricing_resolver.go index 52c6b930c..62283e81b 100644 --- a/backend/internal/service/model_pricing_resolver.go +++ b/backend/internal/service/model_pricing_resolver.go @@ -120,14 +120,8 @@ func (r *ModelPricingResolver) Resolve(ctx context.Context, input PricingInput) resolved.Source = PricingSourceChannel resolved.channelPricing = chPricing r.applyTokenOverrides(chPricing, resolved) - if !longContextPricingEnabled { - r.applyFirstTokenTier(resolved, chPricing) - } } else if input.GroupID != nil && r.channelService != nil { r.applyChannelOverrides(ctx, *input.GroupID, input.Model, resolved) - if resolved.Source == PricingSourceChannel && !longContextPricingEnabled { - r.applyFirstTokenTier(resolved, resolved.channelPricing) - } } return resolved @@ -172,20 +166,6 @@ func matchGroupModelPricing(group *Group, model string) *ChannelModelPricing { return wildcard } -func (r *ModelPricingResolver) applyFirstTokenTier(resolved *ResolvedPricing, config *ChannelModelPricing) { - if resolved == nil || len(resolved.Intervals) == 0 { - return - } - first := resolved.Intervals[0] - for _, interval := range resolved.Intervals[1:] { - if interval.MinTokens < first.MinTokens { - first = interval - } - } - resolved.BasePricing = intervalToModelPricing(&first, resolved.SupportsCacheBreakdown, config) - resolved.Intervals = nil -} - // resolveBasePricing 从 LiteLLM 或 Fallback 获取基础定价 func (r *ModelPricingResolver) resolveBasePricing(model string) (*ModelPricing, string) { pricing, err := r.billingService.GetModelPricing(model) @@ -221,31 +201,6 @@ func (r *ModelPricingResolver) applyChannelOverrides(ctx context.Context, groupI // applyTokenOverrides 应用 token 模式的渠道覆盖 func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricing, resolved *ResolvedPricing) { - // 过滤掉所有价格字段都为空的无效 interval - validIntervals := filterValidIntervals(chPricing.Intervals) - - // 如果有有效的区间定价,使用区间 - if len(validIntervals) > 0 { - resolved.Intervals = validIntervals - // 区间不匹配时回退到 BasePricing,也需要覆盖图片价格 - if resolved.BasePricing == nil { - resolved.BasePricing = &ModelPricing{} - } else { - // 防止修改 fallbackPrices 中的共享指针 - cloned := *resolved.BasePricing - resolved.BasePricing = &cloned - } - if chPricing.ImageOutputPrice != nil { - resolved.BasePricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice - } else { - resolved.BasePricing.ImageOutputPricePerToken = 0 - } - resolved.BasePricing.ImageOutputPriceExplicit = true - applyChannelImageInputPrice(chPricing, resolved.BasePricing) - return - } - - // 否则用 flat 字段覆盖 BasePricing if resolved.BasePricing == nil { resolved.BasePricing = &ModelPricing{} } else { @@ -254,25 +209,9 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin resolved.BasePricing = &cloned } - if chPricing.InputPrice != nil { - resolved.BasePricing.InputPricePerToken = *chPricing.InputPrice - resolved.BasePricing.InputPricePerTokenPriority = *chPricing.InputPrice - } - if chPricing.OutputPrice != nil { - resolved.BasePricing.OutputPricePerToken = *chPricing.OutputPrice - resolved.BasePricing.OutputPricePerTokenPriority = *chPricing.OutputPrice - } - if chPricing.CacheWritePrice != nil { - resolved.BasePricing.CacheCreationPricePerToken = *chPricing.CacheWritePrice - resolved.BasePricing.CacheCreationPricePerTokenPriority = *chPricing.CacheWritePrice - resolved.BasePricing.CacheCreationPriceExplicit = true - resolved.BasePricing.CacheCreation5mPrice = *chPricing.CacheWritePrice - resolved.BasePricing.CacheCreation1hPrice = *chPricing.CacheWritePrice - } - if chPricing.CacheReadPrice != nil { - resolved.BasePricing.CacheReadPricePerToken = *chPricing.CacheReadPrice - resolved.BasePricing.CacheReadPricePerTokenPriority = *chPricing.CacheReadPrice - } + applyChannelTokenPriceOverrides(resolved.BasePricing, chPricing) + resolved.BasePricing.FastMultiplier = chPricing.FastMultiplier + resolved.BasePricing.FlexMultiplier = chPricing.FlexMultiplier // 渠道定价覆盖一切:显式配置则用配置值,未配置则归零(不回退到 LiteLLM) if chPricing.ImageOutputPrice != nil { resolved.BasePricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice @@ -281,6 +220,9 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin } resolved.BasePricing.ImageOutputPriceExplicit = true applyChannelImageInputPrice(chPricing, resolved.BasePricing) + + // 区间未命中时回退到上面已经应用渠道覆盖的基础价。 + resolved.Intervals = filterValidIntervals(chPricing.Intervals) } // applyChannelImageInputPrice 应用渠道图片输入价:显式配置则用配置值; @@ -311,7 +253,9 @@ func filterValidIntervals(intervals []PricingInterval) []PricingInterval { for _, iv := range intervals { if iv.InputPrice != nil || iv.OutputPrice != nil || iv.CacheWritePrice != nil || iv.CacheReadPrice != nil || - iv.PerRequestPrice != nil { + iv.PerRequestPrice != nil || iv.InputMultiplier != nil || + iv.OutputMultiplier != nil || iv.CacheWriteMultiplier != nil || + iv.CacheReadMultiplier != nil { valid = append(valid, iv) } } @@ -330,32 +274,57 @@ func (r *ModelPricingResolver) GetIntervalPricing(resolved *ResolvedPricing, tot return resolved.BasePricing } - return intervalToModelPricing(iv, resolved.SupportsCacheBreakdown, resolved.channelPricing) + pricing := intervalToModelPricing(iv, resolved.BasePricing, resolved.channelPricing) + // BasePricing 为 nil(仅配置区间)时拷贝不到该标志,从 resolved 回填, + // 保证 computeCacheCreationCost 的 5m/1h 分档判断不被区间路径吞掉。 + pricing.SupportsCacheBreakdown = resolved.SupportsCacheBreakdown + return pricing } // intervalToModelPricing 将区间定价转换为 ModelPricing -func intervalToModelPricing(iv *PricingInterval, supportsCacheBreakdown bool, chPricing *ChannelModelPricing) *ModelPricing { - pricing := &ModelPricing{ - SupportsCacheBreakdown: supportsCacheBreakdown, +func intervalToModelPricing(iv *PricingInterval, base *ModelPricing, chPricing *ChannelModelPricing) *ModelPricing { + pricing := &ModelPricing{} + if base != nil { + *pricing = *base + } + applyMultiplier := func(value float64, multiplier *float64) float64 { + if multiplier == nil { + return value + } + return value * *multiplier } if iv.InputPrice != nil { + pricing.InputPricePerTokenPriority = channelTierOverridePrice(pricing.InputPricePerToken, pricing.InputPricePerTokenPriority, *iv.InputPrice) pricing.InputPricePerToken = *iv.InputPrice - pricing.InputPricePerTokenPriority = *iv.InputPrice + } else if iv.InputMultiplier != nil { + pricing.InputPricePerToken = applyMultiplier(pricing.InputPricePerToken, iv.InputMultiplier) + pricing.InputPricePerTokenPriority = applyMultiplier(pricing.InputPricePerTokenPriority, iv.InputMultiplier) } if iv.OutputPrice != nil { + pricing.OutputPricePerTokenPriority = channelTierOverridePrice(pricing.OutputPricePerToken, pricing.OutputPricePerTokenPriority, *iv.OutputPrice) pricing.OutputPricePerToken = *iv.OutputPrice - pricing.OutputPricePerTokenPriority = *iv.OutputPrice + } else if iv.OutputMultiplier != nil { + pricing.OutputPricePerToken = applyMultiplier(pricing.OutputPricePerToken, iv.OutputMultiplier) + pricing.OutputPricePerTokenPriority = applyMultiplier(pricing.OutputPricePerTokenPriority, iv.OutputMultiplier) } if iv.CacheWritePrice != nil { + pricing.CacheCreationPricePerTokenPriority = channelTierOverridePrice(pricing.CacheCreationPricePerToken, pricing.CacheCreationPricePerTokenPriority, *iv.CacheWritePrice) pricing.CacheCreationPricePerToken = *iv.CacheWritePrice - pricing.CacheCreationPricePerTokenPriority = *iv.CacheWritePrice pricing.CacheCreationPriceExplicit = true pricing.CacheCreation5mPrice = *iv.CacheWritePrice pricing.CacheCreation1hPrice = *iv.CacheWritePrice + } else if iv.CacheWriteMultiplier != nil { + pricing.CacheCreationPricePerToken = applyMultiplier(pricing.CacheCreationPricePerToken, iv.CacheWriteMultiplier) + pricing.CacheCreationPricePerTokenPriority = applyMultiplier(pricing.CacheCreationPricePerTokenPriority, iv.CacheWriteMultiplier) + pricing.CacheCreation5mPrice = applyMultiplier(pricing.CacheCreation5mPrice, iv.CacheWriteMultiplier) + pricing.CacheCreation1hPrice = applyMultiplier(pricing.CacheCreation1hPrice, iv.CacheWriteMultiplier) } if iv.CacheReadPrice != nil { + pricing.CacheReadPricePerTokenPriority = channelTierOverridePrice(pricing.CacheReadPricePerToken, pricing.CacheReadPricePerTokenPriority, *iv.CacheReadPrice) pricing.CacheReadPricePerToken = *iv.CacheReadPrice - pricing.CacheReadPricePerTokenPriority = *iv.CacheReadPrice + } else if iv.CacheReadMultiplier != nil { + pricing.CacheReadPricePerToken = applyMultiplier(pricing.CacheReadPricePerToken, iv.CacheReadMultiplier) + pricing.CacheReadPricePerTokenPriority = applyMultiplier(pricing.CacheReadPricePerTokenPriority, iv.CacheReadMultiplier) } // 渠道定价存在时,ImageOutputPrice 显式覆盖;图片输入价用渠道级配置 // (区间不携带图片输入价,与 image_output 一致)。 diff --git a/backend/internal/service/model_pricing_resolver_test.go b/backend/internal/service/model_pricing_resolver_test.go index 11613fa9e..3fb3e1c63 100644 --- a/backend/internal/service/model_pricing_resolver_test.go +++ b/backend/internal/service/model_pricing_resolver_test.go @@ -142,7 +142,7 @@ func TestGPT56ExplicitZeroCacheWritePriceIsPreserved(t *testing.T) { }) t.Run("interval price", func(t *testing.T) { - pricing := intervalToModelPricing(&PricingInterval{CacheWritePrice: &zero}, false, nil) + pricing := intervalToModelPricing(&PricingInterval{CacheWritePrice: &zero}, &ModelPricing{}, nil) require.True(t, pricing.CacheCreationPriceExplicit) cost, err := bs.CalculateCostUnified(CostInput{ @@ -261,9 +261,9 @@ func TestResolve_WithChannelOverride_TokenFlat(t *testing.T) { require.Equal(t, "channel", resolved.Source) require.NotNil(t, resolved.BasePricing) require.InDelta(t, 10e-6, resolved.BasePricing.InputPricePerToken, 1e-12) - require.InDelta(t, 10e-6, resolved.BasePricing.InputPricePerTokenPriority, 1e-12) + require.Zero(t, resolved.BasePricing.InputPricePerTokenPriority) require.InDelta(t, 50e-6, resolved.BasePricing.OutputPricePerToken, 1e-12) - require.InDelta(t, 50e-6, resolved.BasePricing.OutputPricePerTokenPriority, 1e-12) + require.Zero(t, resolved.BasePricing.OutputPricePerTokenPriority) } func TestResolve_WithChannelOverride_TokenPartialOverride(t *testing.T) { @@ -304,10 +304,12 @@ func TestResolve_WithChannelOverride_TokenWithIntervals(t *testing.T) { resolved := r.Resolve(context.Background(), PricingInput{ Model: "claude-sonnet-4", GroupID: groupIDPtr(), + Group: &Group{LongContextPricingEnabled: false}, }) require.NotNil(t, resolved) require.Equal(t, "channel", resolved.Source) + require.False(t, resolved.longContextPricingEnabled) require.Len(t, resolved.Intervals, 2) // GetIntervalPricing should use channel intervals @@ -532,6 +534,7 @@ func TestGetIntervalPricing_ChannelIntervalsNoMatch(t *testing.T) { Platform: "anthropic", Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(4e-6), Intervals: []PricingInterval{ // Only covers tokens > 50000 {MinTokens: 50000, MaxTokens: testPtrInt(200000), InputPrice: testPtrFloat64(9e-6)}, @@ -545,10 +548,10 @@ func TestGetIntervalPricing_ChannelIntervalsNoMatch(t *testing.T) { // Token count 1000 doesn't match any interval (1000 <= 50000 minTokens) pricing := r.GetIntervalPricing(resolved, 1000) - // Should fall back to BasePricing (from the billing service fallback) + // Should fall back to BasePricing after applying the channel default. require.NotNil(t, pricing) require.Equal(t, resolved.BasePricing, pricing) - require.InDelta(t, 3e-6, pricing.InputPricePerToken, 1e-12) // original base price + require.InDelta(t, 4e-6, pricing.InputPricePerToken, 1e-12) } // =========================================================================== @@ -689,6 +692,13 @@ func TestFilterValidIntervals(t *testing.T) { }, wantLen: 1, }, + { + name: "interval with only multiplier kept", + intervals: []PricingInterval{ + {MinTokens: 272000, InputMultiplier: testPtrFloat64(2)}, + }, + wantLen: 1, + }, { name: "mixed valid and invalid", intervals: []PricingInterval{ diff --git a/backend/internal/service/openai_account_runtime_block_fastpath.go b/backend/internal/service/openai_account_runtime_block_fastpath.go index f8c9153a8..d08281c86 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath.go @@ -55,6 +55,11 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont if s != nil { scheduleOllamaCloudUsageActivity(s.deferredService, account) } + // Capacity shedding describes this request, not account health. Keep the + // account schedulable while the request-local retry budget handles recovery. + if account != nil && account.Platform == PlatformOpenAI && isOpenAIRequestScopedCapacityShed("", responseBody) { + return false + } stateCtx, cancel := openAIAccountStateContext(ctx) defer cancel() diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go index 3b3a64856..cd914da0f 100644 --- a/backend/internal/service/openai_account_scheduler_test.go +++ b/backend/internal/service/openai_account_scheduler_test.go @@ -196,6 +196,13 @@ func (c *schedulerTestGatewayCache) ReleaseGrokVideoBilled(_ context.Context, _ return nil } +func (c *schedulerTestGatewayCache) SetReasoningContent(_ context.Context, _ string, _ string, _ time.Duration) error { + return nil +} +func (c *schedulerTestGatewayCache) GetReasoningContent(_ context.Context, _ string) (string, error) { + return "", ErrReasoningContentNotFound +} + func newSchedulerTestOpenAIWSV2Config() *config.Config { cfg := &config.Config{} cfg.Gateway.OpenAIWS.Enabled = true diff --git a/backend/internal/service/openai_apikey_responses_probe.go b/backend/internal/service/openai_apikey_responses_probe.go index 645a58169..329cfebad 100644 --- a/backend/internal/service/openai_apikey_responses_probe.go +++ b/backend/internal/service/openai_apikey_responses_probe.go @@ -125,9 +125,10 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte if account.IsCNProvider() { // 国产 OpenAI 兼容上游(kimi/zhipu/deepseek)普遍仅支持 /v1/chat/completions, // 不存在 /v1/responses 端点。直接落标 false 走 Chat Completions 直转,跳过网络探测。 - // 例外:deepseek 的 responses 协议账号(api_protocol=responses)使用官方原生 - // /responses 端点,落标 force_responses 强制走 Responses 路径。 - if account.GetAPIProtocol() == APIProtocolResponses { + // 例外:deepseek 的固定 responses 和 adaptive 账号使用官方原生 /responses + // 端点,落标 force_responses;其余协议显式重置为 auto,避免切换后残留强制模式。 + if account.GetAPIProtocol() == APIProtocolResponses || + (account.Platform == PlatformDeepseek && account.IsAdaptiveAPIProtocol()) { _ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{ openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceResponses), openai_compat.ExtraKeyResponsesSupported: true, @@ -135,6 +136,7 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte return } _ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeAuto), openai_compat.ExtraKeyResponsesSupported: false, }) return diff --git a/backend/internal/service/openai_apikey_responses_probe_test.go b/backend/internal/service/openai_apikey_responses_probe_test.go index fac351a0e..f3b4eb0e6 100644 --- a/backend/internal/service/openai_apikey_responses_probe_test.go +++ b/backend/internal/service/openai_apikey_responses_probe_test.go @@ -49,6 +49,46 @@ func TestProbeOpenAIAPIKeyResponsesSupportUsesCodexProbeHeaders(t *testing.T) { require.Equal(t, true, updates[openai_compat.ExtraKeyResponsesSupported]) } +func TestProbeOpenAIAPIKeyResponsesSupportCNProviders(t *testing.T) { + tests := []struct { + name string + id int64 + platform string + protocol string + wantSupport bool + wantMode string + }{ + {name: "deepseek adaptive supports responses", id: 201, platform: PlatformDeepseek, protocol: APIProtocolAdaptive, wantSupport: true, wantMode: string(openai_compat.ResponsesSupportModeForceResponses)}, + {name: "deepseek chat clears forced responses", id: 202, platform: PlatformDeepseek, protocol: APIProtocolChatCompletions, wantSupport: false, wantMode: string(openai_compat.ResponsesSupportModeAuto)}, + {name: "kimi adaptive falls back to chat", id: 203, platform: PlatformKimi, protocol: APIProtocolAdaptive, wantSupport: false, wantMode: string(openai_compat.ResponsesSupportModeAuto)}, + {name: "zhipu adaptive falls back to chat", id: 204, platform: PlatformZhipu, protocol: APIProtocolAdaptive, wantSupport: false, wantMode: string(openai_compat.ResponsesSupportModeAuto)}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + updateCalls := make(chan map[string]any, 1) + account := Account{ + ID: tc.id, Platform: tc.platform, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-test", "api_protocol": tc.protocol}, + Extra: map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceResponses), + }, + } + repo := &snapshotUpdateAccountRepo{ + stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}}, + updateExtraCalls: updateCalls, + } + svc := &AccountTestService{accountRepo: repo} + + svc.ProbeOpenAIAPIKeyResponsesSupport(context.Background(), account.ID) + + updates := <-updateCalls + require.Equal(t, tc.wantSupport, updates[openai_compat.ExtraKeyResponsesSupported]) + require.Equal(t, tc.wantMode, updates[openai_compat.ExtraKeyResponsesMode]) + }) + } +} + func TestDecideResponsesProbeSupport(t *testing.T) { fnCall := []byte(`{"output":[{"type":"reasoning"},{"type":"function_call","name":"probe_ping"}]}`) reasoningOnly := []byte(`{"output":[{"type":"reasoning"}]}`) diff --git a/backend/internal/service/openai_capacity_shed_test.go b/backend/internal/service/openai_capacity_shed_test.go index eef8261bb..3f4f27305 100644 --- a/backend/internal/service/openai_capacity_shed_test.go +++ b/backend/internal/service/openai_capacity_shed_test.go @@ -77,6 +77,37 @@ func TestStreamFailedEventCapacityShedRetriesOnSameAccount(t *testing.T) { require.False(t, openAIStreamFailedEventRetryableOnSameAccount(nonPool, other, "boom")) } +func TestOpenAIHTTPCapacityShedIsRequestScopedForOAuthAccounts(t *testing.T) { + payload := []byte(`{"error":{"type":"server_error","message":"Our servers are currently overloaded. Please try again later."}}`) + failoverErr := newOpenAIUpstreamFailoverError( + http.StatusBadRequest, + http.Header{"X-Request-Id": []string{"rid-http-capacity"}}, + payload, + "Our servers are currently overloaded. Please try again later.", + false, + ) + + require.True(t, failoverErr.RetryableOnSameAccount) + require.True(t, failoverErr.RequestScopedTransient) + + repo := &capacityShedAccountRepoStub{} + (&GatewayService{accountRepo: repo}).TempUnscheduleRetryableError(context.Background(), 1, failoverErr) + require.Zero(t, repo.tempUnschedCalls) + + rateLimitService := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + gateway := &OpenAIGatewayService{rateLimitService: rateLimitService} + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + require.False(t, gateway.handleOpenAIAccountUpstreamError( + context.Background(), + account, + http.StatusBadRequest, + nil, + payload, + "gpt-5", + )) + require.Zero(t, repo.tempUnschedCalls) +} + // 上游降载的真实序列是「event: error → event: response.failed」。error 帧不算 // 客户端输出:若把它当首输出 flush,clientOutputStarted 被固化,随后的 failed // 事件就进不了 pre-output failover 分支,只能把致命错误原样转发给客户端。 @@ -94,6 +125,11 @@ func TestOpenAIStreamErrorFrameDoesNotStartClientOutput(t *testing.T) { {`{"type":"response.failed","response":{"error":{"code":"server_is_overloaded"}}}`, "response.failed", false}, {`{"type":"response.created","response":{"id":"resp_1"}}`, "response.created", false}, {`{"type":"response.in_progress","response":{"id":"resp_1"}}`, "response.in_progress", false}, + {`{"type":"response.output_item.added","item":{"type":"reasoning","summary":[]}}`, "response.output_item.added", false}, + {`{"type":"response.output_item.added","item":{"type":"reasoning","encrypted_content":"ciphertext"}}`, "response.output_item.added", true}, + {`{"type":"response.reasoning_summary_part.added","part":{"type":"summary_text","text":""}}`, "response.reasoning_summary_part.added", false}, + {`{"type":"response.reasoning_summary_part.added","part":{"type":"summary_text","text":"thinking"}}`, "response.reasoning_summary_part.added", true}, + {`{"type":"response.content_part.added","part":{"type":"output_text","text":""}}`, "response.content_part.added", false}, {`{"type":"response.output_text.delta","delta":"hi"}`, "response.output_text.delta", true}, {`[DONE]`, "", true}, } @@ -102,6 +138,69 @@ func TestOpenAIStreamErrorFrameDoesNotStartClientOutput(t *testing.T) { } } +func TestOpenAIStreamMetadataPreambleAndMessageOnlyOverloadFailOver(t *testing.T) { + gin.SetMode(gin.TestMode) + largeMetadata := strings.Repeat("x", 16*1024) + stream := strings.Join([]string{ + "event: response.created", + `data: {"type":"response.created","response":{"id":"resp_1","metadata":{"padding":"` + largeMetadata + `"}}}`, + "", + "event: response.output_item.added", + `data: {"type":"response.output_item.added","item":{"type":"reasoning","summary":[]}}`, + "", + "event: response.reasoning_summary_part.added", + `data: {"type":"response.reasoning_summary_part.added","part":{"type":"summary_text","text":""}}`, + "", + "event: error", + `data: {"type":"error","error":{"type":"service_unavailable_error","message":"Our servers are currently overloaded. Please try again later."}}`, + "", + }, "\n") + + tests := []struct { + name string + run func(*OpenAIGatewayService, *gin.Context, *http.Response, *Account) error + }{ + { + name: "native", + run: func(svc *OpenAIGatewayService, c *gin.Context, resp *http.Response, account *Account) error { + _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, account, time.Now(), "model", "model") + return err + }, + }, + { + name: "passthrough", + run: func(svc *OpenAIGatewayService, c *gin.Context, resp *http.Response, account *Account) error { + _, err := svc.handleStreamingResponsePassthrough(c.Request.Context(), resp, c, account, time.Now(), "model", "model") + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}} + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(stream)), + Header: http.Header{"X-Request-Id": []string{"rid-message-only-overload"}}, + } + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Name: "acc"} + + err := tt.run(svc, c, resp, account) + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.True(t, failoverErr.RetryableOnSameAccount) + require.True(t, failoverErr.RequestScopedTransient) + require.False(t, c.Writer.Written()) + require.Empty(t, rec.Body.String()) + }) + } +} + // 回归用例(真实上游降载序列):created → in_progress → error 帧 → response.failed。 // 期望仍然走 pre-output failover(同账号重试 + 请求级瞬时标记),且不向客户端写出任何字节。 func TestOpenAIStreamCapacityShedErrorFramePrecedingFailedStillFailsOver(t *testing.T) { @@ -149,6 +248,8 @@ func TestOpenAIStreamCapacityShedErrorFramePrecedingFailedStillFailsOver(t *test // 并终止会话,对其余错误码执行内置退避重试。消息原样保留。 func TestOpenAIStreamCapacityShedAfterOutputRewritesCodeForClient(t *testing.T) { gin.SetMode(gin.TestMode) + logSink, restore := captureStructuredLog(t) + defer restore() cfg := &config.Config{ Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}, } @@ -188,6 +289,9 @@ func TestOpenAIStreamCapacityShedAfterOutputRewritesCodeForClient(t *testing.T) require.Contains(t, body, `"code":"server_error"`) require.NotContains(t, body, "server_is_overloaded") require.Contains(t, body, "Our servers are currently overloaded") + require.True(t, logSink.ContainsMessage("gateway.failover_suppressed_after_semantic_output")) + require.True(t, logSink.ContainsFieldValue("path", "native_sse")) + require.True(t, logSink.ContainsFieldValue("upstream_request_id", "rid-shed-after-output")) } // helper 单测:只有降载码被改写,其余错误码(尤其 rate_limit_exceeded,客户端 @@ -211,6 +315,18 @@ func TestSanitizeOpenAICapacityShedErrorCodeForClient(t *testing.T) { wantChanged: true, wantContain: `"code":"server_error"`, }, + { + name: "failed事件只有过载文案时补充code", + payload: `{"type":"response.failed","response":{"error":{"message":"Our servers are currently overloaded. Please try again later."}}}`, + wantChanged: true, + wantContain: `"code":"server_error"`, + }, + { + name: "error帧只有过载文案时补充code", + payload: `{"type":"error","error":{"message":"Server is overloaded. Please try again later."}}`, + wantChanged: true, + wantContain: `"code":"server_error"`, + }, { name: "rate_limit不改写", payload: `{"type":"response.failed","response":{"error":{"code":"rate_limit_exceeded","message":"try again in 3s"}}}`, diff --git a/backend/internal/service/openai_compact_sse_keepalive.go b/backend/internal/service/openai_compact_sse_keepalive.go index 70e6af945..360c1d55a 100644 --- a/backend/internal/service/openai_compact_sse_keepalive.go +++ b/backend/internal/service/openai_compact_sse_keepalive.go @@ -161,21 +161,28 @@ func OpenAICompactKeepaliveAdjustedWrittenSize(c *gin.Context) int { if c == nil || c.Writer == nil { return -1 } - value, ok := c.Get(openAICompactSSEKeepaliveKey) - if !ok { - return c.Writer.Size() + streamKeepaliveBytes := 0 + if value, ok := c.Get(openAIStreamKeepaliveBytesKey); ok { + streamKeepaliveBytes, _ = value.(int) } - k, ok := value.(*openAICompactSSEKeepalive) - if !ok || k == nil { - return c.Writer.Size() + size := c.Writer.Size() + compactKeepaliveBytes := 0 + if value, ok := c.Get(openAICompactSSEKeepaliveKey); ok { + if k, valid := value.(*openAICompactSSEKeepalive); valid && k != nil { + k.mu.Lock() + size = k.writer.Size() + compactKeepaliveBytes = k.bytes + k.mu.Unlock() + } } - k.mu.Lock() - defer k.mu.Unlock() - size := k.writer.Size() if size < 0 { return size } - if real := size - k.bytes; real > 0 { + keepaliveBytes := compactKeepaliveBytes + streamKeepaliveBytes + if keepaliveBytes <= 0 { + return size + } + if real := size - keepaliveBytes; real > 0 { return real } return -1 diff --git a/backend/internal/service/openai_compact_sse_keepalive_test.go b/backend/internal/service/openai_compact_sse_keepalive_test.go index 95ee4ce76..3703ea9f2 100644 --- a/backend/internal/service/openai_compact_sse_keepalive_test.go +++ b/backend/internal/service/openai_compact_sse_keepalive_test.go @@ -71,6 +71,20 @@ func TestOpenAICompactSSEKeepalive_StopBeforeFirstBeatKeepsWriterUntouched(t *te require.False(t, StopOpenAICompactSSEKeepaliveCommitted(c)) } +func TestOpenAIAdjustedWrittenSizeExcludesResponsesStreamKeepalive(t *testing.T) { + c, rec := newCompactBridgeTestContext(t, false) + n, err := c.Writer.Write([]byte(":\n\n")) + require.NoError(t, err) + recordOpenAIStreamKeepaliveBytes(c, n) + + require.Equal(t, -1, OpenAICompactKeepaliveAdjustedWrittenSize(c)) + + _, err = c.Writer.Write([]byte("data: semantic\n\n")) + require.NoError(t, err) + require.Equal(t, len("data: semantic\n\n"), OpenAICompactKeepaliveAdjustedWrittenSize(c)) + require.Equal(t, ":\n\ndata: semantic\n\n", rec.Body.String()) +} + // 心跳已提交后,2xx 桥接续写事件而不重复提交响应头。 func TestWriteOpenAICompactSSEBridge_AfterKeepaliveCommitAppendsEvents(t *testing.T) { c, rec := newCompactBridgeTestContext(t, true) diff --git a/backend/internal/service/openai_cyber_session_block_test.go b/backend/internal/service/openai_cyber_session_block_test.go index 6d6ad8bad..66ee99fa7 100644 --- a/backend/internal/service/openai_cyber_session_block_test.go +++ b/backend/internal/service/openai_cyber_session_block_test.go @@ -148,6 +148,13 @@ func (c *comboCacheAndStore) ReleaseGrokVideoBilled(_ context.Context, _ string) return nil } +func (c *comboCacheAndStore) SetReasoningContent(_ context.Context, _ string, _ string, _ time.Duration) error { + return nil +} +func (c *comboCacheAndStore) GetReasoningContent(_ context.Context, _ string) (string, error) { + return "", ErrReasoningContentNotFound +} + func (c *comboCacheAndStore) SetCyberSessionBlocked(ctx context.Context, key string, ttl time.Duration) error { return c.store.SetCyberSessionBlocked(ctx, key, ttl) } diff --git a/backend/internal/service/openai_first_output_timeout_test.go b/backend/internal/service/openai_first_output_timeout_test.go index 799f7af12..6783de90d 100644 --- a/backend/internal/service/openai_first_output_timeout_test.go +++ b/backend/internal/service/openai_first_output_timeout_test.go @@ -542,7 +542,7 @@ func TestOpenAINativeFirstOutputScannerAllowsLargeEventAfterSemanticBoundary(t * require.Equal(t, "request-large-image", rec.Result().Header.Get("X-Request-Id")) } -func TestOpenAINativeFirstOutputTimeoutDisabledPreservesKeepaliveFlush(t *testing.T) { +func TestOpenAINativeFirstOutputTimeoutDisabledKeepsPreamblePrivateAcrossKeepalive(t *testing.T) { svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{ StreamKeepaliveInterval: 1, MaxLineSize: defaultMaxLineSize, @@ -552,7 +552,7 @@ func TestOpenAINativeFirstOutputTimeoutDisabledPreservesKeepaliveFlush(t *testin defer func() { _ = pw.Close() }() _, _ = pw.Write([]byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_stalled\"}}\n\n")) _, _ = pw.Write([]byte("data: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_stalled\"}}\n\n")) - time.Sleep(1100 * time.Millisecond) + time.Sleep(2100 * time.Millisecond) }() rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) @@ -561,10 +561,11 @@ func TestOpenAINativeFirstOutputTimeoutDisabledPreservesKeepaliveFlush(t *testin _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI}, time.Now(), "model", "model") - require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) require.Contains(t, rec.Body.String(), ":\n\n") - require.Contains(t, rec.Body.String(), "response.created") - require.Contains(t, rec.Body.String(), "response.in_progress") + require.NotContains(t, rec.Body.String(), "response.created") + require.NotContains(t, rec.Body.String(), "response.in_progress") } func TestOpenAINativeFirstOutputFailoverKeepsAttemptHeadersPrivateAfterKeepaliveCommit(t *testing.T) { diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index fed651bd7..9c413f0bc 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -1,6 +1,7 @@ package service import ( + "bufio" "context" "encoding/json" "errors" @@ -45,11 +46,11 @@ var cursorResponsesUnsupportedFields = []string{ // 正确的,但 sub2api 接入 DeepSeek/Kimi/GLM 等第三方 OpenAI 兼容上游后假设破裂: // 这些上游普遍只支持 /v1/chat/completions,无 /v1/responses 端点。 // -// 当前路由策略(基于账号覆盖模式/探测标记,详见 openai_compat.ShouldUseResponsesAPI): -// - APIKey 账号 + 强制或探测确认不支持 Responses → 走 forwardAsRawChatCompletions -// 直转上游 /v1/chat/completions,不做协议转换 -// - 其他所有情况(OAuth、APIKey 强制/探测确认支持、未探测)→ 走原有 CC→Responses -// 转换路径(保留旧行为,存量未探测账号零兼容破坏) +// 当前路由策略: +// - CN 账号以 credentials.api_protocol 为权威;adaptive/chat_completions 入站 Chat +// 直转原生 CC,anthropic 走原生 Anthropic,responses 走 Responses +// - 其他 APIKey 账号仍按覆盖模式/探测标记分流(详见 +// openai_compat.ShouldUseResponsesAPI) func (s *OpenAIGatewayService) ForwardAsChatCompletions( ctx context.Context, c *gin.Context, @@ -87,6 +88,39 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) } + // Cursor compatibility: some clients send a Responses-shaped body to the + // /v1/chat/completions URL. Detect it before adaptive routing so adaptive + // accounts never forward the body unchanged to a Chat Completions endpoint. + isResponsesShape := !gjson.GetBytes(body, "messages").Exists() && gjson.GetBytes(body, "input").Exists() + + // 自适应账号的标准 Chat Completions 入站使用供应商原生 CC 端点。 + // Responses 形状下,DeepSeek 继续走下方原生 Responses 链;Kimi/GLM + // 没有 Responses 端点,先转换成 Chat Completions 再直转。 + if account.IsAdaptiveAPIProtocol() { + if !isResponsesShape { + return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) + } + if account.Platform != PlatformDeepseek { + var responsesReq apicompat.ResponsesRequest + if err := json.Unmarshal(body, &responsesReq); err != nil { + return nil, fmt.Errorf("parse responses-shaped chat completions request: %w", err) + } + chatReq, err := apicompat.ResponsesToChatCompletionsRequestWithOptions( + &responsesReq, + &apicompat.ResponsesToChatOptions{ReasoningContentByID: s.reasoningContentByID}, + ) + if err != nil { + return nil, fmt.Errorf("convert responses-shaped chat completions request: %w", err) + } + chatBody, err := json.Marshal(chatReq) + if err != nil { + return nil, fmt.Errorf("marshal converted chat completions request: %w", err) + } + return s.forwardAsRawChatCompletions(ctx, c, account, chatBody, defaultMappedModel) + } + // DeepSeek 原生 Responses 请求继续走下方 Responses→Chat 回程转换。 + } + // 入口分流(国产供应商 Anthropic 协议):上游为供应商原生 Anthropic 端点, // CC 入站请求经 CC→Responses→Anthropic 转换链直通该端点。必须先于 // ShouldUseResponsesAPI 分流:该类账号经 probe 落标 @@ -95,9 +129,9 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( return s.forwardChatCompletionsViaNativeAnthropic(ctx, c, account, body, defaultMappedModel) } - // 入口分流:APIKey 账号 + 强制或已探测确认上游不支持 Responses,走 CC 直转。 - // 自动模式下标记缺失(未探测)按"现状即证据"原则继续走下方原 Responses 转换路径。 - if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) { + // 固定 chat_completions 的 CN 账号,以及强制或已探测确认不支持 Responses + // 的其他 APIKey 账号,均走 CC 直转。 + if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) } @@ -132,11 +166,9 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( // and produce `input: null`, which Codex upstreams reject with // "Invalid type for 'input': expected a string, but got an object". // - // Detect that shape and forward the raw body as-is, only rewriting `model` + // Forward that shape as-is, only rewriting `model` // to the resolved upstream model. The downstream codex OAuth transform will // still normalize store/stream/instructions/etc. - isResponsesShape := !gjson.GetBytes(body, "messages").Exists() && gjson.GetBytes(body, "input").Exists() - var ( responsesReq *apicompat.ResponsesRequest responsesBody []byte @@ -422,7 +454,7 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( finalResponse, usage, acc, err := s.readOpenAICompatBufferedTerminal(resp, "openai chat_completions buffered", requestID) if err != nil { - return nil, err + return nil, s.newOpenAICompatBufferedReadFailoverError(c, account, resp, requestID, err) } if finalResponse == nil { @@ -518,6 +550,47 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( return result, nil } +func (s *OpenAIGatewayService) newOpenAICompatBufferedReadFailoverError( + c *gin.Context, + account *Account, + resp *http.Response, + requestID string, + err error, +) error { + var readErr *openAICompatBufferedReadError + if !errors.As(err, &readErr) || readErr == nil || errors.Is(readErr.cause, bufio.ErrTooLong) { + return err + } + var requestContext context.Context + if c != nil && c.Request != nil { + requestContext = c.Request.Context() + } + if !shouldClassifyOpenAIUpstreamStreamReadError(readErr.cause, requestContext) { + return err + } + + classifiedErr := newOpenAIUpstreamStreamReadError(readErr.cause) + code, message, ok := OpenAIUpstreamStreamReadErrorDetails(classifiedErr) + if !ok { + return err + } + payload, _ := json.Marshal(gin.H{ + "error": gin.H{ + "type": "upstream_error", + "code": code, + "message": message, + }, + }) + var responseHeaders http.Header + if resp != nil { + responseHeaders = resp.Header + } + failoverErr := s.newOpenAIStreamFailoverError(c, account, false, requestID, payload, message, responseHeaders) + // 保留稳定错误码,确保重试耗尽后客户端和错误透传规则仍能识别传输故障。 + failoverErr.ResponseBody = payload + return failoverErr +} + // handleChatStreamingResponse reads Responses SSE events from upstream, // converts each to Chat Completions SSE chunks, and writes them to the client. func (s *OpenAIGatewayService) handleChatStreamingResponse( diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index 34c281b28..3c4619418 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -105,6 +105,13 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( return nil, policyErr } upstreamBody = updatedBody + if account.Platform == PlatformGrok { + strippedBody, stripErr := stripRedundantGrokChatViewImageTool(upstreamBody) + if stripErr != nil { + return nil, fmt.Errorf("strip redundant Grok Chat view_image tool: %w", stripErr) + } + upstreamBody = strippedBody + } // Grok Composer does not accept image_url parts directly, but Grok Build // can describe the images first. Bridge only this exact failure mode. diff --git a/backend/internal/service/openai_gateway_compat_buffered_read_failover_test.go b/backend/internal/service/openai_gateway_compat_buffered_read_failover_test.go new file mode 100644 index 000000000..c28bef796 --- /dev/null +++ b/backend/internal/service/openai_gateway_compat_buffered_read_failover_test.go @@ -0,0 +1,152 @@ +package service + +import ( + "bufio" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +type openAICompatBufferedReadErrorCloser struct { + err error +} + +func (r *openAICompatBufferedReadErrorCloser) Read([]byte) (int, error) { return 0, r.err } +func (r *openAICompatBufferedReadErrorCloser) Close() error { return nil } + +func TestChatCompletionsBufferedResponsesReadErrorReturnsFailover(t *testing.T) { + gin.SetMode(gin.TestMode) + + readErrors := []struct { + name string + err error + expectedCode string + }{ + {name: "unexpected_eof", err: io.ErrUnexpectedEOF, expectedCode: OpenAIUpstreamStreamReadErrorCode}, + {name: "http2_reset", err: errors.New("stream error: stream ID 7; INTERNAL_ERROR; received from peer"), expectedCode: OpenAIUpstreamHTTP2StreamErrorCode}, + } + + for _, readError := range readErrors { + t.Run(readError.name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "X-Request-Id": []string{"upstream-rid"}}, + Body: &openAICompatBufferedReadErrorCloser{err: readError.err}, + } + account := &Account{ID: 40, Name: "openai-oauth", Platform: PlatformOpenAI} + + result, err := (&OpenAIGatewayService{}).handleChatBufferedStreamingResponse( + resp, c, account, "gpt-5.6-sol", "gpt-5.6-sol", "gpt-5.6-sol", time.Now(), + ) + + require.Error(t, err) + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode) + require.Equal(t, "upstream-rid", failoverErr.ResponseHeaders.Get("x-request-id")) + require.Equal(t, readError.expectedCode, gjson.GetBytes(failoverErr.ResponseBody, "error.code").String()) + require.Empty(t, rec.Body.String()) + require.False(t, c.Writer.Written()) + }) + } +} + +func TestChatCompletionsBufferedResponsesReadErrorDoesNotFailoverAfterClientCancel(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + requestContext, cancel := context.WithCancel(context.Background()) + cancel() + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil).WithContext(requestContext) + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: &openAICompatBufferedReadErrorCloser{err: io.ErrUnexpectedEOF}, + } + + result, err := (&OpenAIGatewayService{}).handleChatBufferedStreamingResponse( + resp, + c, + &Account{ID: 40, Name: "openai-oauth", Platform: PlatformOpenAI}, + "gpt-5.6-sol", + "gpt-5.6-sol", + "gpt-5.6-sol", + time.Now(), + ) + + require.Error(t, err) + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.NotErrorAs(t, err, &failoverErr) + require.Empty(t, rec.Body.String()) +} + +func TestChatCompletionsBufferedResponsesOversizedLineDoesNotFailover(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: &openAICompatBufferedReadErrorCloser{err: bufio.ErrTooLong}, + } + + result, err := (&OpenAIGatewayService{}).handleChatBufferedStreamingResponse( + resp, + c, + &Account{ID: 40, Name: "openai-oauth", Platform: PlatformOpenAI}, + "gpt-5.6-sol", + "gpt-5.6-sol", + "gpt-5.6-sol", + time.Now(), + ) + + require.ErrorIs(t, err, bufio.ErrTooLong) + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.NotErrorAs(t, err, &failoverErr) +} + +func TestAnthropicBufferedResponsesReadErrorKeepsExistingBehavior(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: &openAICompatBufferedReadErrorCloser{err: io.ErrUnexpectedEOF}, + } + + result, err := (&OpenAIGatewayService{}).handleAnthropicBufferedStreamingResponse( + resp, + c, + &Account{ID: 40, Name: "openai-oauth", Platform: PlatformOpenAI}, + "gpt-5.6-sol", + "gpt-5.6-sol", + "gpt-5.6-sol", + time.Now(), + ) + + require.ErrorIs(t, err, io.ErrUnexpectedEOF) + require.Equal(t, io.ErrUnexpectedEOF, err, "Messages 路径必须保持原始读取错误,不引入 Chat failover 包装") + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.NotErrorAs(t, err, &failoverErr) +} diff --git a/backend/internal/service/openai_gateway_count_tokens.go b/backend/internal/service/openai_gateway_count_tokens.go index bcdaa457b..2cd4519d0 100644 --- a/backend/internal/service/openai_gateway_count_tokens.go +++ b/backend/internal/service/openai_gateway_count_tokens.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" @@ -39,6 +40,177 @@ type openAIInputTokensCountPrepared struct { UpstreamModel string } +// ForwardResponsesInputTokens handles the native OpenAI +// POST /v1/responses/input_tokens shape. Custom OpenAI-compatible relays often +// implement /responses but not this preflight endpoint, so those accounts use +// the local estimator instead of receiving a request that is known to fail. +func (s *OpenAIGatewayService) ForwardResponsesInputTokens( + ctx context.Context, + c *gin.Context, + account *Account, + body []byte, +) error { + if account == nil { + writeOpenAIResponsesInputTokensError(c, http.StatusServiceUnavailable, "api_error", "No available OpenAI accounts") + return fmt.Errorf("responses input_tokens: missing account") + } + + prepared, err := prepareNativeOpenAIInputTokensCountRequest(body, account) + if err != nil { + writeOpenAIResponsesInputTokensError(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body") + return err + } + + if shouldEstimateOpenAIInputTokensLocally(account) { + writeOpenAIResponsesInputTokensFallback(c, account, prepared, 0, "custom_relay") + return nil + } + + token, _, err := s.GetAccessToken(ctx, account) + if err != nil { + writeOpenAIResponsesInputTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to get access token") + return fmt.Errorf("responses input_tokens: get access token: %w", err) + } + + upstreamBody := ReplaceModelInBody(body, prepared.UpstreamModel) + upstreamReq, err := s.buildInputTokensUpstreamRequest(ctx, c, account, upstreamBody, token) + if err != nil { + writeOpenAIResponsesInputTokensError(c, http.StatusInternalServerError, "api_error", "Failed to build request") + return fmt.Errorf("responses input_tokens: build upstream request: %w", err) + } + + proxyURL := "" + if account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) + if err != nil { + safeErr := sanitizeUpstreamErrorMessage(err.Error()) + setOpsUpstreamError(c, 0, safeErr, "") + writeOpenAIResponsesInputTokensError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed") + return fmt.Errorf("responses input_tokens: upstream request failed: %s", safeErr) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + writeOpenAIResponsesInputTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to read response") + return fmt.Errorf("responses input_tokens: read upstream response: %w", err) + } + + if resp.StatusCode >= 400 { + if isOpenAIResponsesInputTokensUnsupported(account, resp.StatusCode, respBody) { + writeOpenAIResponsesInputTokensFallback(c, account, prepared, resp.StatusCode, "upstream_unsupported") + return nil + } + if s.rateLimitService != nil { + s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) + } + upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) + setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, "") + writeOpenAIResponsesInputTokensError(c, resp.StatusCode, "upstream_error", "Upstream request failed") + if upstreamMsg == "" { + return fmt.Errorf("responses input_tokens: upstream error: %d", resp.StatusCode) + } + return fmt.Errorf("responses input_tokens: upstream error: %d message=%s", resp.StatusCode, upstreamMsg) + } + + inputTokens := gjson.GetBytes(respBody, "input_tokens") + if !inputTokens.Exists() { + writeOpenAIResponsesInputTokensError(c, http.StatusBadGateway, "upstream_error", "Upstream response missing input_tokens") + return fmt.Errorf("responses input_tokens: upstream response missing input_tokens") + } + contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) + if contentType == "" { + contentType = "application/json" + } + c.Data(http.StatusOK, contentType, respBody) + return nil +} + +func prepareNativeOpenAIInputTokensCountRequest(body []byte, account *Account) (*openAIInputTokensCountPrepared, error) { + var req openAIInputTokensCountRequest + if err := json.Unmarshal(body, &req); err != nil { + return nil, fmt.Errorf("parse responses input_tokens request: %w", err) + } + originalModel := strings.TrimSpace(req.Model) + if originalModel == "" { + return nil, fmt.Errorf("parse responses input_tokens request: model is required") + } + billingModel := resolveOpenAIForwardModel(account, originalModel, "") + upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) + req.Model = upstreamModel + return &openAIInputTokensCountPrepared{ + Request: req, + OriginalModel: originalModel, + NormalizedModel: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + }, nil +} + +func shouldEstimateOpenAIInputTokensLocally(account *Account) bool { + if account == nil || account.IsGrok() || account.IsCNProvider() || account.Type == AccountTypeUpstream { + return true + } + if account.Type != AccountTypeAPIKey { + return false + } + rawBaseURL := strings.TrimSpace(account.GetCredential("base_url")) + if rawBaseURL == "" { + return false + } + parsed, err := url.Parse(rawBaseURL) + if err != nil { + return true + } + return !strings.EqualFold(parsed.Hostname(), "api.openai.com") +} + +func isOpenAIResponsesInputTokensUnsupported(account *Account, statusCode int, body []byte) bool { + if statusCode == http.StatusNotFound { + return true + } + return account != nil && account.Type == AccountTypeOAuth && isOpenAIOAuthInputTokensUnsupported(statusCode, body) +} + +func writeOpenAIResponsesInputTokensFallback(c *gin.Context, account *Account, prepared *openAIInputTokensCountPrepared, statusCode int, reason string) { + estimated := openAIInputTokensFallbackMinimum + if prepared != nil { + if got, err := estimateOpenAIInputTokens(prepared.Request); err == nil && got > 0 { + estimated = got + } + } + accountID := int64(0) + upstreamModel := "" + if account != nil { + accountID = account.ID + } + if prepared != nil { + upstreamModel = prepared.UpstreamModel + } + logger.L().Info("openai responses input_tokens: local estimate fallback", + zap.Int64("account_id", accountID), + zap.Int("upstream_status", statusCode), + zap.Int("estimated_input_tokens", estimated), + zap.String("upstream_model", upstreamModel), + zap.String("reason", reason), + ) + c.JSON(http.StatusOK, gin.H{ + "object": "response.input_tokens", + "input_tokens": estimated, + }) +} + +func writeOpenAIResponsesInputTokensError(c *gin.Context, status int, errType, message string) { + c.JSON(status, gin.H{ + "error": gin.H{ + "type": errType, + "message": message, + }, + }) +} + // EstimateGrokCountTokens estimates an Anthropic-compatible count_tokens request // locally. Grok does not expose a compatible token-counting endpoint, so this // path deliberately avoids account selection, credentials, and upstream calls. diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 11beffbaf..7e3829c9f 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -104,6 +104,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco requestView := newOpenAIRequestView(body) reqModel, reqStream, promptCacheKey := requestView.Model, requestView.Stream, requestView.PromptCacheKey originalModel := reqModel + nativeDeepSeekResponses := account.Platform == PlatformDeepseek && + (account.GetAPIProtocol() == APIProtocolResponses || account.IsAdaptiveAPIProtocol()) if account.Platform == PlatformGrok { return s.forwardGrokResponses(ctx, c, account, body, originalModel, reqStream, startTime) @@ -286,7 +288,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco instructions := gjson.GetBytes(body, "instructions") instructionsEmpty := !instructions.Exists() || instructions.Type != gjson.String || strings.TrimSpace(instructions.String()) == "" - if instructionsEmpty && !compatMessagesBridge { + if instructionsEmpty && !compatMessagesBridge && !nativeDeepSeekResponses { markPatchSet("instructions", defaultCodexSynthInstructions(reqModel)) } @@ -459,7 +461,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco maxOutputTokens := gjson.GetBytes(body, "max_output_tokens") if maxOutputTokens.Exists() { switch account.Platform { - case PlatformOpenAI: + case PlatformOpenAI, PlatformDeepseek: // Preserve Responses-native output limits unless the selected upstream // explicitly rejects the field in the bounded HTTP retry loop below. case PlatformAnthropic: @@ -1040,9 +1042,22 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } func shouldForwardOpenAIResponsesViaRawChatCompletions(account *Account) bool { - return account != nil && - account.Type == AccountTypeAPIKey && - !openai_compat.ShouldUseResponsesAPI(account.Extra) + if account == nil || account.Type != AccountTypeAPIKey { + return false + } + if account.IsCNProvider() { + // CN 的显式协议配置优先于异步探针 Extra;adaptive 仅 DeepSeek 有原生 + // Responses,Kimi/GLM 回退 Chat Completions。 + switch account.GetAPIProtocol() { + case APIProtocolChatCompletions: + return true + case APIProtocolAdaptive: + return account.Platform != PlatformDeepseek + default: + return false + } + } + return !openai_compat.ShouldUseResponsesAPI(account.Extra) } func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token string, isStream bool, promptCacheKey string, isCodexCLI bool) (*http.Request, error) { @@ -1056,6 +1071,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. case AccountTypeAPIKey: // API Key accounts use Platform API or custom base URL baseURL := account.GetOpenAIBaseURL() + if account.Platform == PlatformDeepseek && account.IsAdaptiveAPIProtocol() { + baseURL = account.GetCNProtocolBaseURL(APIProtocolResponses) + } if baseURL == "" { targetURL = openaiPlatformAPIURL } else { diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index adb5f8fbc..d9c7c5b0e 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -495,6 +495,10 @@ func patchGrokResponsesBodyBase(body []byte, upstreamModel string) ([]byte, erro if err != nil { return nil, err } + out, err = stripRedundantGrokViewImageTool(out) + if err != nil { + return nil, err + } out, err = sanitizeGrokReasoningNullContent(out) if err != nil { return nil, err @@ -558,7 +562,7 @@ func normalizeGrokResponsesReasoningEffort(body []byte, upstreamModel string) ([ if !value.Exists() { continue } - normalized, keep := normalizeGrokReasoningEffortValue(value.String()) + normalized, keep := normalizeGrokReasoningEffortValue(value.String(), upstreamModel) if !supportsEffort || !keep { out, err = sjson.DeleteBytes(out, field) } else { @@ -569,7 +573,7 @@ func normalizeGrokResponsesReasoningEffort(body []byte, upstreamModel string) ([ } } if camel := gjson.GetBytes(out, "reasoningEffort"); camel.Exists() { - normalized, keep := normalizeGrokReasoningEffortValue(camel.String()) + normalized, keep := normalizeGrokReasoningEffortValue(camel.String(), upstreamModel) out, err = sjson.DeleteBytes(out, "reasoningEffort") if err != nil { return nil, fmt.Errorf("remove Grok reasoningEffort: %w", err) @@ -595,7 +599,7 @@ func normalizeGrokChatReasoningEffort(body []byte, upstreamModel string) ([]byte if raw == "" { raw = strings.TrimSpace(gjson.GetBytes(body, "reasoningEffort").String()) } - normalized, keep := normalizeGrokReasoningEffortValue(raw) + normalized, keep := normalizeGrokReasoningEffortValue(raw, upstreamModel) keep = keep && grokSupportsReasoningEffort(upstreamModel) out := body var err error @@ -615,20 +619,30 @@ func normalizeGrokChatReasoningEffort(body []byte, upstreamModel string) ([]byte return out, err } -func normalizeGrokReasoningEffortValue(raw string) (string, bool) { +func normalizeGrokReasoningEffortValue(raw, model string) (string, bool) { value := strings.NewReplacer("-", "", "_", "", " ", "").Replace(strings.ToLower(strings.TrimSpace(raw))) switch value { case "none", "low", "medium", "high": return value, true case "minimal": return "low", true - case "xhigh", "extrahigh", "max", "ultra": + case "xhigh", "extrahigh": + if grokSupportsXHighReasoningEffort(model) { + return "xhigh", true + } + return "high", true + case "max", "ultra": return "high", true default: return "", false } } +func grokSupportsXHighReasoningEffort(model string) bool { + model = strings.ToLower(xai.StripGrokProviderPrefix(strings.TrimSpace(model))) + return model == "grok-4.6" || model == "grok-4.6-latest" +} + func grokSupportsReasoningEffort(model string) bool { model = strings.ToLower(xai.StripGrokProviderPrefix(strings.TrimSpace(model))) switch model { @@ -757,6 +771,69 @@ func sanitizeGrokResponsesInput(body []byte) ([]byte, error) { return sjson.SetRawBytes(body, "tools", encodedTools) } +// An inline input_image is already visible to Grok. Keeping Codex's local +// view_image tool in the same turn can make Grok announce a tool call without +// actually calling it, so remove only that redundant automatic choice. +func stripRedundantGrokViewImageTool(body []byte) ([]byte, error) { + input := gjson.GetBytes(body, "input") + if !input.IsArray() { + return body, nil + } + items := input.Array() + if len(items) == 0 { + return body, nil + } + current := items[len(items)-1] + if strings.TrimSpace(current.Get("role").String()) != "user" || + !openAIJSONValueMayContainImageInput(current) { + return body, nil + } + + toolChoice := gjson.GetBytes(body, "tool_choice") + if toolChoice.IsObject() && strings.TrimSpace(toolChoice.Get("type").String()) == "function" { + choiceName := strings.TrimSpace(toolChoice.Get("name").String()) + if choiceName == "" { + choiceName = strings.TrimSpace(toolChoice.Get("function.name").String()) + } + if choiceName == "view_image" { + return body, nil + } + } + + tools := gjson.GetBytes(body, "tools") + if !tools.IsArray() { + return body, nil + } + filtered := make([]json.RawMessage, 0, len(tools.Array())) + changed := false + for _, tool := range tools.Array() { + if strings.TrimSpace(tool.Get("type").String()) == "function" && + strings.TrimSpace(tool.Get("name").String()) == "view_image" { + changed = true + continue + } + filtered = append(filtered, json.RawMessage(tool.Raw)) + } + if !changed { + return body, nil + } + if len(filtered) == 0 && strings.TrimSpace(toolChoice.String()) == "required" { + return body, nil + } + + if len(filtered) == 0 { + out, err := sjson.DeleteBytes(body, "tools") + if err != nil { + return nil, err + } + return sjson.DeleteBytes(out, "parallel_tool_calls") + } + encoded, err := json.Marshal(filtered) + if err != nil { + return nil, err + } + return sjson.SetRawBytes(body, "tools", encoded) +} func grokResponsesToolDedupKey(tool gjson.Result) string { toolType := strings.TrimSpace(tool.Get("type").String()) if toolType != "" { diff --git a/backend/internal/service/openai_gateway_grok_chat_image_tools.go b/backend/internal/service/openai_gateway_grok_chat_image_tools.go new file mode 100644 index 000000000..2e15faa21 --- /dev/null +++ b/backend/internal/service/openai_gateway_grok_chat_image_tools.go @@ -0,0 +1,83 @@ +package service + +import ( + "encoding/json" + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// 当前轮的内联 image_url 已可由 Grok 直接读取;若同时保留客户端本地 +// view_image,Grok 可能只预告调用工具而不继续作答,因此只移除这一冗余自动选择。 +func stripRedundantGrokChatViewImageTool(body []byte) ([]byte, error) { + messages := gjson.GetBytes(body, "messages") + if !messages.IsArray() { + return body, nil + } + items := messages.Array() + if len(items) == 0 { + return body, nil + } + current := items[len(items)-1] + if strings.TrimSpace(current.Get("role").String()) != "user" || + !openAIJSONValueMayContainImageInput(current) { + return body, nil + } + + toolChoice := gjson.GetBytes(body, "tool_choice") + if toolChoice.IsObject() && strings.TrimSpace(toolChoice.Get("type").String()) == "function" { + choiceName := strings.TrimSpace(toolChoice.Get("function.name").String()) + if choiceName == "" { + choiceName = strings.TrimSpace(toolChoice.Get("name").String()) + } + if choiceName == "view_image" { + return body, nil + } + } + + tools := gjson.GetBytes(body, "tools") + if !tools.IsArray() { + return body, nil + } + filtered := make([]json.RawMessage, 0, len(tools.Array())) + changed := false + for _, tool := range tools.Array() { + toolName := strings.TrimSpace(tool.Get("function.name").String()) + if toolName == "" { + toolName = strings.TrimSpace(tool.Get("name").String()) + } + if strings.TrimSpace(tool.Get("type").String()) == "function" && toolName == "view_image" { + changed = true + continue + } + filtered = append(filtered, json.RawMessage(tool.Raw)) + } + if !changed { + return body, nil + } + if len(filtered) == 0 && strings.TrimSpace(toolChoice.String()) == "required" { + return body, nil + } + + if len(filtered) > 0 { + encoded, err := json.Marshal(filtered) + if err != nil { + return nil, err + } + return sjson.SetRawBytes(body, "tools", encoded) + } + + out, err := sjson.DeleteBytes(body, "tools") + if err != nil { + return nil, err + } + out, err = sjson.DeleteBytes(out, "parallel_tool_calls") + if err != nil { + return nil, err + } + if strings.TrimSpace(toolChoice.String()) == "auto" { + out, err = sjson.DeleteBytes(out, "tool_choice") + } + return out, err +} diff --git a/backend/internal/service/openai_gateway_grok_inline_image_tool_test.go b/backend/internal/service/openai_gateway_grok_inline_image_tool_test.go new file mode 100644 index 000000000..51df1823c --- /dev/null +++ b/backend/internal/service/openai_gateway_grok_inline_image_tool_test.go @@ -0,0 +1,184 @@ +//go:build unit + +package service + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestForwardGrokChatViaResponsesDropsRedundantViewImage(t *testing.T) { + gin.SetMode(gin.TestMode) + body := grokChatInlineImageRequest() + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Set("api_key", &APIKey{ID: 7991}) + + account := grokChatBridgeTestAccount(799) + repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{ + accountsByID: map[int64]*Account{account.ID: account}, + }} + upstream := &httpUpstreamRecorder{resp: grokChatBridgeCompletedResponse("resp_chat_image", 0)} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + grokTokenProvider: NewGrokTokenProvider(repo, nil), + accountRepo: repo, + } + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String()) + require.Equal(t, "input_image", gjson.GetBytes(upstream.lastBody, "input.0.content.1.type").String()) + assertGrokUpstreamKeepsOtherToolAndDropsViewImage(t, upstream.lastBody, "tools.#(name==\"%s\")") +} + +func TestForwardGrokRawChatDropsRedundantViewImage(t *testing.T) { + gin.SetMode(gin.TestMode) + body := grokChatInlineImageRequest() + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + + account := &Account{ + ID: 800, Platform: PlatformGrok, Type: AccountTypeAPIKey, Concurrency: 1, + Credentials: map[string]any{"api_key": "test-key", "base_url": "https://grok.example.test/v1"}, + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"chatcmpl","object":"chat.completion","model":"grok-4.6","choices":[],"usage":{"prompt_tokens":1,"completion_tokens":1}}`, + )), + }} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "https://grok.example.test/v1/chat/completions", upstream.lastReq.URL.String()) + require.Equal(t, "image_url", gjson.GetBytes(upstream.lastBody, "messages.0.content.1.type").String()) + assertGrokUpstreamKeepsOtherToolAndDropsViewImage(t, upstream.lastBody, "tools.#(function.name==\"%s\")") +} + +func TestForwardGrokMessagesDropsRedundantViewImage(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{ + "model":"grok-4.6","max_tokens":32,"stream":false, + "messages":[{"role":"user","content":[ + {"type":"text","text":"What text is in this image?"}, + {"type":"image","source":{"type":"base64","media_type":"image/png","data":"AA=="}} + ]}], + "tools":[ + {"name":"view_image","input_schema":{"type":"object","properties":{"path":{"type":"string"}}}}, + {"name":"shell_command","input_schema":{"type":"object","properties":{"cmd":{"type":"string"}}}} + ], + "tool_choice":{"type":"auto"} + }`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Set("api_key", &APIKey{ID: 7992}) + + account := healthyGrokOAuthGatewayTestAccount(801, "access-token") + repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{ + accountsByID: map[int64]*Account{account.ID: account}, + }} + upstream := &httpUpstreamRecorder{resp: grokMessagesSSECompletedResponse("resp_messages_image", 0)} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + grokTokenProvider: NewGrokTokenProvider(repo, nil), + accountRepo: repo, + } + + result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String()) + require.Equal(t, "input_image", gjson.GetBytes(upstream.lastBody, "input.0.content.1.type").String()) + assertGrokUpstreamKeepsOtherToolAndDropsViewImage(t, upstream.lastBody, "tools.#(name==\"%s\")") +} + +func TestStripRedundantGrokChatViewImageToolLeavesNonTargetRequestsByteExact(t *testing.T) { + t.Parallel() + tests := []struct { + name string + body string + }{ + { + name: "current turn has no inline image", + body: `{"messages":[{"role":"user","content":"Inspect a local image"}],"tools":[{"type":"function","function":{"name":"view_image"}}]}`, + }, + { + name: "inline image is only historical", + body: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,AA=="}}]},{"role":"assistant","content":"Done"},{"role":"user","content":"Inspect another local image"}],"tools":[{"type":"function","function":{"name":"view_image"}}]}`, + }, + { + name: "view image is explicitly selected", + body: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,AA=="}}]}],"tools":[{"type":"function","function":{"name":"view_image"}}],"tool_choice":{"type":"function","function":{"name":"view_image"}}}`, + }, + { + name: "required with view image as the only tool", + body: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,AA=="}}]}],"tools":[{"type":"function","function":{"name":"view_image"}}],"tool_choice":"required"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := []byte(tt.body) + patched, err := stripRedundantGrokChatViewImageTool(body) + require.NoError(t, err) + require.Equal(t, body, patched) + }) + } +} + +func TestStripRedundantGrokChatViewImageToolDropsOnlyToolMetadata(t *testing.T) { + t.Parallel() + body := []byte(`{ + "messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,AA=="}}]}], + "tools":[{"type":"function","function":{"name":"view_image"}}], + "tool_choice":"auto", + "parallel_tool_calls":true + }`) + + patched, err := stripRedundantGrokChatViewImageTool(body) + require.NoError(t, err) + require.False(t, gjson.GetBytes(patched, "tools").Exists()) + require.False(t, gjson.GetBytes(patched, "tool_choice").Exists()) + require.False(t, gjson.GetBytes(patched, "parallel_tool_calls").Exists()) +} + +func grokChatInlineImageRequest() []byte { + return []byte(`{ + "model":"grok-4.6", + "messages":[{"role":"user","content":[ + {"type":"text","text":"What text is in this image?"}, + {"type":"image_url","image_url":{"url":"data:image/png;base64,AA=="}} + ]}], + "stream":false, + "tools":[ + {"type":"function","function":{"name":"view_image","parameters":{"type":"object","properties":{"path":{"type":"string"}}}}}, + {"type":"function","function":{"name":"shell_command","parameters":{"type":"object","properties":{"cmd":{"type":"string"}}}}} + ], + "tool_choice":"auto" + }`) +} + +func assertGrokUpstreamKeepsOtherToolAndDropsViewImage(t *testing.T, body []byte, pathTemplate string) { + t.Helper() + require.False(t, gjson.GetBytes(body, strings.Replace(pathTemplate, "%s", "view_image", 1)).Exists(), string(body)) + require.True(t, gjson.GetBytes(body, strings.Replace(pathTemplate, "%s", "shell_command", 1)).Exists(), string(body)) +} diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index 243736b1c..7b30e683a 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -43,6 +43,107 @@ func TestPatchGrokResponsesBodySetsMappedModelAndDropsUnsupportedFields(t *testi require.Equal(t, "high", gjson.GetBytes(patched, "reasoning.effort").String()) } +func TestPatchGrokResponsesBodyDropsRedundantViewImageForCurrentInlineImage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + }{ + { + name: "top-level tools", + body: `{ + "model":"grok-4.6", + "input":[{"type":"message","role":"user","content":[ + {"type":"input_text","text":"What text is in this image?"}, + {"type":"input_image","image_url":"data:image/png;base64,AA=="} + ]}], + "tools":[ + {"type":"function","name":"view_image","parameters":{"type":"object"}}, + {"type":"function","name":"shell_command","parameters":{"type":"object"}} + ] + }`, + }, + { + name: "Responses Lite additional tools", + body: `{ + "model":"grok-4.6", + "input":[ + {"type":"additional_tools","role":"developer","tools":[ + {"type":"function","name":"view_image","parameters":{"type":"object"}}, + {"type":"function","name":"shell_command","parameters":{"type":"object"}} + ]}, + {"type":"message","role":"user","content":[ + {"type":"input_text","text":"What text is in this image?"}, + {"type":"input_image","image_url":"data:image/png;base64,AA=="} + ]} + ] + }`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + patched, err := patchGrokResponsesBody([]byte(tt.body), "grok-4.6") + require.NoError(t, err) + require.False(t, gjson.GetBytes(patched, `tools.#(name=="view_image")`).Exists()) + require.Equal(t, "shell_command", gjson.GetBytes(patched, "tools.0.name").String()) + }) + } +} + +func TestPatchGrokResponsesBodyKeepsNonRedundantViewImage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + }{ + { + name: "current turn has no inline image", + body: `{"input":[{"role":"user","content":[{"type":"input_text","text":"Inspect a local image"}]}],"tools":[{"type":"function","name":"view_image"}]}`, + }, + { + name: "inline image is only historical", + body: `{"input":[{"role":"user","content":[{"type":"input_image","image_url":"data:image/png;base64,AA=="}]},{"role":"assistant","content":[{"type":"output_text","text":"Done"}]},{"role":"user","content":[{"type":"input_text","text":"Inspect another local image"}]}],"tools":[{"type":"function","name":"view_image"}]}`, + }, + { + name: "view image is explicitly selected", + body: `{"input":[{"role":"user","content":[{"type":"input_image","image_url":"data:image/png;base64,AA=="}]}],"tools":[{"type":"function","name":"view_image"}],"tool_choice":{"type":"function","name":"view_image"}}`, + }, + { + name: "required with view image as the only tool", + body: `{"input":[{"role":"user","content":[{"type":"input_image","image_url":"data:image/png;base64,AA=="}]}],"tools":[{"type":"function","name":"view_image"}],"tool_choice":"required"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + patched, err := patchGrokResponsesBody([]byte(tt.body), "grok-4.6") + require.NoError(t, err) + require.Equal(t, "view_image", gjson.GetBytes(patched, "tools.0.name").String()) + }) + } +} + +func TestPatchGrokResponsesBodyDropsViewImageOnlyToolMetadata(t *testing.T) { + t.Parallel() + + body := []byte(`{ + "input":[{"role":"user","content":[{"type":"input_image","image_url":"data:image/png;base64,AA=="}]}], + "tools":[{"type":"function","name":"view_image"}], + "tool_choice":"auto", + "parallel_tool_calls":true + }`) + patched, err := patchGrokResponsesBody(body, "grok-4.6") + require.NoError(t, err) + require.False(t, gjson.GetBytes(patched, "tools").Exists()) + require.False(t, gjson.GetBytes(patched, "tool_choice").Exists()) + require.False(t, gjson.GetBytes(patched, "parallel_tool_calls").Exists()) +} + func TestPatchGrokResponsesBodySanitizesComposerReasoningParameters(t *testing.T) { t.Parallel() @@ -157,18 +258,20 @@ func TestPatchGrokResponsesBodyNormalizesReasoningEffortAliases(t *testing.T) { t.Parallel() tests := []struct { - name string - body string - path string - want string + name string + body string + upstreamModel string + path string + want string }{ - {name: "minimal nested", body: `{"input":"hi","reasoning":{"effort":"minimal"}}`, path: "reasoning.effort", want: "low"}, - {name: "xhigh snake", body: `{"input":"hi","reasoning_effort":"xhigh"}`, path: "reasoning_effort", want: "high"}, - {name: "max camel", body: `{"input":"hi","reasoningEffort":"max"}`, path: "reasoning_effort", want: "high"}, + {name: "minimal nested", body: `{"input":"hi","reasoning":{"effort":"minimal"}}`, upstreamModel: "grok-4.5", path: "reasoning.effort", want: "low"}, + {name: "xhigh stays high for 4.5", body: `{"input":"hi","reasoning_effort":"xhigh"}`, upstreamModel: "grok-4.5", path: "reasoning_effort", want: "high"}, + {name: "xhigh nested for 4.6", body: `{"input":"hi","reasoning":{"effort":"xhigh"}}`, upstreamModel: "grok-4.6", path: "reasoning.effort", want: "xhigh"}, + {name: "xhigh snake for 4.6 latest", body: `{"input":"hi","reasoning_effort":"xhigh"}`, upstreamModel: "grok-4.6-latest", path: "reasoning_effort", want: "xhigh"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - patched, err := patchGrokResponsesBody([]byte(tt.body), "grok-4.5") + patched, err := patchGrokResponsesBody([]byte(tt.body), tt.upstreamModel) require.NoError(t, err) require.Equal(t, tt.want, gjson.GetBytes(patched, tt.path).String(), string(patched)) require.False(t, gjson.GetBytes(patched, "reasoningEffort").Exists()) @@ -194,6 +297,10 @@ func TestNormalizeGrokChatReasoningEffort(t *testing.T) { require.Equal(t, "high", gjson.GetBytes(patched, "reasoning_effort").String()) require.False(t, gjson.GetBytes(patched, "reasoningEffort").Exists()) + patched, err = normalizeGrokChatReasoningEffort([]byte(`{"reasoning_effort":"xhigh"}`), "grok-4.6") + require.NoError(t, err) + require.Equal(t, "xhigh", gjson.GetBytes(patched, "reasoning_effort").String()) + patched, err = normalizeGrokChatReasoningEffort([]byte(`{"reasoning_effort":"high"}`), "grok-composer-2.5-fast") require.NoError(t, err) require.False(t, gjson.GetBytes(patched, "reasoning_effort").Exists()) diff --git a/backend/internal/service/openai_gateway_grok_tool_protocol.go b/backend/internal/service/openai_gateway_grok_tool_protocol.go index 11122172a..9f876a1a6 100644 --- a/backend/internal/service/openai_gateway_grok_tool_protocol.go +++ b/backend/internal/service/openai_gateway_grok_tool_protocol.go @@ -16,6 +16,19 @@ import ( const grokResponsesClientToolMappingContextKey = "grok_responses_client_tool_mapping" func adaptResponsesClientToolsForFunctionUpstream(body []byte, upstream string) ([]byte, apicompat.ResponsesClientToolMapping, error) { + return adaptResponsesClientToolsForFunctionUpstreamWithMapping( + body, + upstream, + apicompat.ResponsesClientToolMapping{}, + ) +} + +func adaptResponsesClientToolsForFunctionUpstreamWithMapping( + body []byte, + upstream string, + inherited apicompat.ResponsesClientToolMapping, + inheritedLoweredTools ...[]any, +) ([]byte, apicompat.ResponsesClientToolMapping, error) { decoder := json.NewDecoder(bytes.NewReader(body)) decoder.UseNumber() var requestBody map[string]any @@ -23,7 +36,7 @@ func adaptResponsesClientToolsForFunctionUpstream(body []byte, upstream string) return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("decode %s Responses client tools: %w", upstream, err) } - mapping, changed, err := apicompat.AdaptResponsesClientTools(requestBody) + mapping, changed, err := apicompat.AdaptResponsesClientToolsWithInheritedMapping(requestBody, inherited, inheritedLoweredTools...) if err != nil { return body, apicompat.ResponsesClientToolMapping{}, err } diff --git a/backend/internal/service/openai_gateway_grok_tool_protocol_test.go b/backend/internal/service/openai_gateway_grok_tool_protocol_test.go index bca3d3f2b..cc25cb192 100644 --- a/backend/internal/service/openai_gateway_grok_tool_protocol_test.go +++ b/backend/internal/service/openai_gateway_grok_tool_protocol_test.go @@ -65,6 +65,60 @@ func TestPatchGrokResponsesBodyWithClientToolsLowersCodexProtocol(t *testing.T) require.False(t, gjson.GetBytes(patched, "input.4.namespace").Exists()) } +func TestPatchGrokResponsesBodyWithClientToolsLowersDiscoveredToolsOutput(t *testing.T) { + t.Parallel() + + body := []byte(`{ + "model":"grok-4.5", + "tools":[{"type":"tool_search"}], + "input":[ + {"type":"tool_search_call","id":"tsc_fixture","call_id":"call_fixture","arguments":{"query":"subagent"},"execution":"client","status":"completed"}, + {"type":"tool_search_output","id":"tso_fixture","call_id":"call_fixture","execution":"client","status":"completed","tools":[ + {"type":"namespace","name":"codex_app","tools":[{"type":"function","name":"load_workspace_dependencies","parameters":{"type":"object","properties":{},"additionalProperties":false}}]}, + {"type":"namespace","name":"multi_agent_v1","tools":[ + {"type":"function","name":"spawn_agent","parameters":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false}}, + {"type":"function","name":"wait_agent","parameters":{"type":"object","properties":{"timeout_ms":{"type":"integer"}},"additionalProperties":false}} + ]} + ]} + ] + }`) + + patched, mapping, err := patchGrokResponsesBodyWithClientTools(body, "grok-4.5") + require.NoError(t, err) + require.True(t, mapping.ToolSearch) + require.Equal(t, apicompat.ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "spawn_agent"}, mapping.NamespaceTools["multi_agent_v1__spawn_agent"]) + require.Equal(t, apicompat.ResponsesNamespaceName{Namespace: "multi_agent_v1", Name: "wait_agent"}, mapping.NamespaceTools["multi_agent_v1__wait_agent"]) + output := gjson.GetBytes(patched, "input.1.output").String() + require.JSONEq(t, `[ + {"type":"namespace","name":"codex_app","tools":[{"type":"function","name":"load_workspace_dependencies","parameters":{"type":"object","properties":{},"additionalProperties":false}}]}, + {"type":"namespace","name":"multi_agent_v1","tools":[ + {"type":"function","name":"spawn_agent","parameters":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false}}, + {"type":"function","name":"wait_agent","parameters":{"type":"object","properties":{"timeout_ms":{"type":"integer"}},"additionalProperties":false}} + ]} + ]`, output) + + require.JSONEq(t, `{ + "model":"grok-4.5", + "tools":[ + {"type":"function","name":"tool_search","description":"Search and load Codex tools, plugins, connectors, and MCP namespaces for the current task.","parameters":{"type":"object","properties":{"query":{"type":"string","description":"Search query for tools or connectors to load."},"limit":{"type":"integer","description":"Maximum number of tool groups to return."}},"required":["query"]}}, + {"type":"function","name":"codex_app__load_workspace_dependencies","parameters":{"type":"object","properties":{},"additionalProperties":false}}, + {"type":"function","name":"multi_agent_v1__spawn_agent","parameters":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false}}, + {"type":"function","name":"multi_agent_v1__wait_agent","parameters":{"type":"object","properties":{"timeout_ms":{"type":"integer"}},"additionalProperties":false}} + ], + "input":[ + {"type":"function_call","call_id":"call_fixture","name":"tool_search","arguments":"{\"query\":\"subagent\"}","status":"completed"}, + {"type":"function_call_output","call_id":"call_fixture","output":`+string(mustMarshalJSONForTest(t, output))+`} + ] + }`, string(patched)) +} + +func mustMarshalJSONForTest(t *testing.T, value string) []byte { + t.Helper() + encoded, err := json.Marshal(value) + require.NoError(t, err) + return encoded +} + func TestPatchGrokResponsesBodyWithClientToolsRewritesEveryToolChoice(t *testing.T) { t.Parallel() @@ -174,6 +228,32 @@ func TestForwardGrokResponsesClientToolNameConflictReturns400(t *testing.T) { require.Empty(t, upstream.requests, "an ambiguous request must not reach xAI") } +func TestForwardGrokResponsesMalformedToolSearchOutputReturns400BeforeUpstream(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{ + "model":"grok","stream":false, + "tools":[{"type":"tool_search"}], + "input":[{"type":"tool_search_output","status":"completed"}] + }`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + upstream := &httpUpstreamRecorder{} + svc := &OpenAIGatewayService{httpUpstream: upstream} + account := grokProtocolAPIKeyAccount(7103) + + result, err := svc.forwardGrokResponses(context.Background(), c, account, body, "grok", false, time.Now()) + + require.Error(t, err) + require.Nil(t, result) + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Equal(t, "invalid_request_error", gjson.Get(recorder.Body.String(), "error.type").String()) + require.Equal(t, "tools", gjson.Get(recorder.Body.String(), "error.param").String()) + require.Contains(t, gjson.Get(recorder.Body.String(), "error.message").String(), "call_id") + require.Empty(t, upstream.requests, "malformed lowered output must not reach xAI") +} + func TestForwardGrokResponsesOAuthRestoresClientToolsNonStreaming(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 43f4977f2..f1ae729ad 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -15,7 +15,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" - "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" @@ -41,15 +40,13 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( // thinking / tool_use / cache 语义,适配 Claude Code 等原生客户端。 // 必须先于 ShouldUseResponsesAPI 分流:Anthropic 协议账号经 probe 落标 // openai_responses_supported=false,会先命中下方的 CC 直转分支。 - if account.IsAnthropicProtocol() { + if account.IsAnthropicProtocol() || account.IsAdaptiveAPIProtocol() { return s.forwardAnthropicViaNativeAnthropicEndpoint(ctx, c, account, body, defaultMappedModel) } - // 入口分流:APIKey 账号 + 上游不支持 Responses API → 走 CC 直转(与 - // ForwardAsChatCompletions 对称)。缺少此分流时,/v1/messages 入站请求 - // 会被无条件转为 Responses 格式发往上游 /v1/responses,导致只支持 - // /v1/chat/completions 的第三方 OpenAI 兼容上游全部 400。 - if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) { + // 固定 chat_completions 的 CN 账号,以及不支持 Responses 的其他 APIKey + // 账号,均将 Messages 转为 CC;固定 responses 的 CN 账号不受探针旧值覆盖。 + if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { return s.forwardAnthropicViaRawChatCompletions(ctx, c, account, body, defaultMappedModel) } @@ -553,6 +550,10 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( finalResponse, usage, acc, err := s.readOpenAICompatBufferedTerminal(resp, "openai messages buffered", requestID) if err != nil { + var readErr *openAICompatBufferedReadError + if errors.As(err, &readErr) && readErr != nil { + return nil, readErr.cause + } return nil, err } @@ -675,6 +676,15 @@ func isOpenAICompatDoneSentinelLine(line string) bool { return ok && strings.TrimSpace(payload) == "[DONE]" } +// openAICompatBufferedReadError 只标记错误发生在上游响应体读取阶段; +// 具体端点自行决定是否允许重放请求,避免共享读取器扩大重试范围。 +type openAICompatBufferedReadError struct { + cause error +} + +func (e *openAICompatBufferedReadError) Error() string { return e.cause.Error() } +func (e *openAICompatBufferedReadError) Unwrap() error { return e.cause } + func (s *OpenAIGatewayService) readOpenAICompatBufferedTerminal( resp *http.Response, logPrefix string, @@ -783,7 +793,7 @@ func (s *OpenAIGatewayService) readOpenAICompatBufferedTerminal( zap.String("request_id", requestID), ) } - return nil, usage, acc, ev.err + return nil, usage, acc, &openAICompatBufferedReadError{cause: ev.err} } if isOpenAICompatDoneSentinelLine(ev.line) { diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index 6d8b6c8e9..c5b227fea 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -909,6 +909,19 @@ type openaiNonStreamingResultPassthrough struct { imageOutputSizes []string } +const openAIStreamKeepaliveBytesKey = "openai_stream_keepalive_bytes" + +func recordOpenAIStreamKeepaliveBytes(c *gin.Context, written int) { + if c == nil || written <= 0 { + return + } + current := 0 + if value, ok := c.Get(openAIStreamKeepaliveBytesKey); ok { + current, _ = value.(int) + } + c.Set(openAIStreamKeepaliveBytesKey, current+written) +} + func openAIStreamClientOutputStarted(c *gin.Context, localStarted bool) bool { if localStarted { return true @@ -931,6 +944,85 @@ func openAIStreamEventIsPreamble(eventType string) bool { } } +func openAIStreamAddedEventStartsClientOutput(payload []byte, eventType string) bool { + if len(payload) == 0 || !gjson.ValidBytes(payload) { + return true + } + + switch strings.TrimSpace(eventType) { + case "response.output_item.added": + item := gjson.GetBytes(payload, "item") + if !item.Exists() || !item.IsObject() { + return true + } + switch strings.TrimSpace(item.Get("type").String()) { + case "reasoning": + if item.Get("encrypted_content").String() != "" { + return true + } + summary := item.Get("summary") + if !summary.IsArray() { + return false + } + for _, part := range summary.Array() { + if strings.TrimSpace(part.Get("type").String()) != "summary_text" || part.Get("text").String() != "" { + return true + } + } + return false + case "message": + content := item.Get("content") + if !content.IsArray() { + return false + } + for _, part := range content.Array() { + switch strings.TrimSpace(part.Get("type").String()) { + case "output_text": + if part.Get("text").String() != "" { + return true + } + case "refusal": + if part.Get("refusal").String() != "" { + return true + } + default: + return true + } + } + return false + case "function_call": + return item.Get("arguments").String() != "" + case "custom_tool_call": + return item.Get("input").String() != "" + case "compaction": + return item.Get("encrypted_content").String() != "" + default: + return true + } + case "response.content_part.added": + part := gjson.GetBytes(payload, "part") + if !part.Exists() || !part.IsObject() { + return true + } + switch strings.TrimSpace(part.Get("type").String()) { + case "output_text": + return part.Get("text").String() != "" + case "refusal": + return part.Get("refusal").String() != "" + default: + return true + } + case "response.reasoning_summary_part.added": + part := gjson.GetBytes(payload, "part") + if !part.Exists() || !part.IsObject() || strings.TrimSpace(part.Get("type").String()) != "summary_text" { + return true + } + return part.Get("text").String() != "" + default: + return true + } +} + func openAIStreamDataStartsClientOutput(data, eventType string) bool { trimmed := strings.TrimSpace(data) if trimmed == "" { @@ -947,6 +1039,8 @@ func openAIStreamDataStartsClientOutput(data, eventType string) bool { // (content_policy / invalid_request 等)维持原样转发,保留上游错误细节。 payload := []byte(trimmed) return !openAIStreamFailedEventShouldFailover(payload, extractOpenAISSEErrorMessage(payload)) + case "response.output_item.added", "response.content_part.added", "response.reasoning_summary_part.added": + return openAIStreamAddedEventStartsClientOutput([]byte(trimmed), eventType) } return !openAIStreamEventIsPreamble(eventType) } @@ -1025,9 +1119,34 @@ func isOpenAIUpstreamCapacityShedEvent(payload []byte) bool { switch openAIStreamFailedEventErrorCode(payload) { case "server_is_overloaded", "slow_down": return true - default: - return false } + for _, path := range []string{"response.error.message", "error.message", "message"} { + if isOpenAICapacityShedMessage(gjson.GetBytes(payload, path).String()) { + return true + } + } + return false +} + +func logOpenAICapacityFailoverSuppressed( + ctx context.Context, + account *Account, + path string, + upstreamRequestID string, + eventType string, +) { + fields := []zap.Field{ + zap.String("path", path), + zap.String("event_type", strings.TrimSpace(eventType)), + zap.String("upstream_request_id", strings.TrimSpace(upstreamRequestID)), + } + if account != nil { + fields = append(fields, + zap.Int64("account_id", account.ID), + zap.String("platform", account.Platform), + ) + } + logger.FromContext(ctx).Warn("gateway.failover_suppressed_after_semantic_output", fields...) } // openAICapacityShedRetryableClientCode 是把上游容量降载错误转发给客户端时改写 @@ -1050,9 +1169,12 @@ func sanitizeOpenAICapacityShedErrorCodeForClient(payload []byte) ([]byte, bool) updated := payload changed := false for _, path := range []string{"response.error.code", "error.code"} { - switch strings.ToLower(strings.TrimSpace(gjson.GetBytes(updated, path).String())) { - case "server_is_overloaded", "slow_down": - default: + parent := strings.TrimSuffix(path, ".code") + if !gjson.GetBytes(updated, parent).Exists() { + continue + } + code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(updated, path).String())) + if code != "" && code != "server_is_overloaded" && code != "slow_down" { continue } next, err := sjson.SetBytes(updated, path, openAICapacityShedRetryableClientCode) @@ -1085,7 +1207,7 @@ func openAIStreamFailedEventSemanticStatus(payload []byte, message string) int { return http.StatusUnauthorized case strings.Contains(combined, "permission") || strings.Contains(combined, "forbidden") || strings.Contains(combined, "access denied"): return http.StatusForbidden - case code == "server_is_overloaded" || code == "slow_down": + case isOpenAIUpstreamCapacityShedEvent(payload): return http.StatusServiceUnavailable default: return http.StatusBadGateway @@ -1219,6 +1341,16 @@ func openAIStreamFailedEventShouldFailover(payload []byte, message string) bool return true } +func openAIStreamErrorEventShouldFailover(payload []byte, message string) bool { + if strings.TrimSpace(gjson.GetBytes(payload, "type").String()) != "error" { + return false + } + if isOpenAIContextWindowError(message, payload) { + return false + } + return isOpenAITransientProcessingError(http.StatusBadRequest, message, payload) +} + func openAIStreamFailedEventRetryableOnSameAccount(account *Account, payload []byte, message string) bool { if account == nil { return false @@ -1361,6 +1493,7 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( sawTerminalEvent := false sawFailedEvent := false semanticOutputSeen := false + capacityFailoverSuppressedLogged := false failedMessage := "" clientOutputStarted := false upstreamRequestID := strings.TrimSpace(resp.Header.Get("x-request-id")) @@ -1447,6 +1580,32 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( } } eventType := strings.TrimSpace(gjson.Get(trimmedData, "type").String()) + if !capacityFailoverSuppressedLogged && account != nil && account.Platform == PlatformOpenAI && + (eventType == "error" || eventType == "response.failed") && + openAIStreamClientOutputStarted(c, clientOutputStarted) && + isOpenAIUpstreamCapacityShedEvent(dataBytes) { + logOpenAICapacityFailoverSuppressed(ctx, account, "passthrough_sse", upstreamRequestID, eventType) + capacityFailoverSuppressedLogged = true + } + if eventType == "error" && !openAIStreamClientOutputStarted(c, clientOutputStarted) { + errorMessage := extractOpenAISSEErrorMessage(dataBytes) + if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, errorMessage); matched { + s.recordOpenAIStreamUpstreamError(c, account, true, upstreamRequestID, "http_error", dataBytes, errorMessage) + MarkResponseCommitted(c) + c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") + c.JSON(status, gin.H{ + "error": gin.H{ + "type": errType, + "message": errMsg, + }, + }) + return resultWithUsage(), fmt.Errorf("upstream error event: passthrough rule matched message=%s", errMsg) + } + if openAIStreamErrorEventShouldFailover(dataBytes, errorMessage) { + return resultWithUsage(), + s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, dataBytes, errorMessage, resp.Header) + } + } if eventType == "response.failed" { failedMessage = extractOpenAISSEErrorMessage(dataBytes) // response.failed 自带上游已消耗的 usage(input token 通常已扣);必须先解析 diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index 21e5cd409..9cca81040 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -1148,7 +1148,7 @@ func TestOpenAIGatewayServiceRecordUsage_GPT56SeparatesCacheWriteForBillingAndSt require.InDelta(t, usageRepo.lastLog.TotalCost*1.1, usageRepo.lastLog.ActualCost, 1e-12) } -func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingDisabledByDefault(t *testing.T) { +func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingDisabledWhenGroupAndAccountOff(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} subRepo := &openAIRecordUsageSubRepoStub{} @@ -1164,7 +1164,7 @@ func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingDisabledByDefaul Model: "gpt-5.4-2026-03-05", Duration: time.Second, }, - APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1014, true), + APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1014, false), User: &User{ID: 2014}, Account: &Account{ID: 3014, Platform: PlatformOpenAI}, }) @@ -1219,7 +1219,7 @@ func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingEnabledPerAccoun require.True(t, usageRepo.lastLog.LongContextBillingApplied) } -func TestOpenAIGatewayServiceRecordUsage_GroupAndAccountLongContextMustBothAllow(t *testing.T) { +func TestOpenAIGatewayServiceRecordUsage_GroupOrAccountLongContextAllows(t *testing.T) { tokens := OpenAIUsage{InputTokens: 300000, OutputTokens: 2000} baseInput := 300000 * 2.5e-6 baseOutput := 2000 * 15e-6 @@ -1234,9 +1234,9 @@ func TestOpenAIGatewayServiceRecordUsage_GroupAndAccountLongContextMustBothAllow Account: &Account{ID: 3020, Platform: PlatformOpenAI}, }) require.NoError(t, err) - require.False(t, usageRepo.lastLog.LongContextBillingApplied) - require.InDelta(t, baseInput, usageRepo.lastLog.InputCost, 1e-10) - require.InDelta(t, baseOutput, usageRepo.lastLog.OutputCost, 1e-10) + require.True(t, usageRepo.lastLog.LongContextBillingApplied) + require.InDelta(t, baseInput*2, usageRepo.lastLog.InputCost, 1e-10) + require.InDelta(t, baseOutput*1.5, usageRepo.lastLog.OutputCost, 1e-10) }) t.Run("group off account on", func(t *testing.T) { @@ -1252,8 +1252,9 @@ func TestOpenAIGatewayServiceRecordUsage_GroupAndAccountLongContextMustBothAllow }, }) require.NoError(t, err) - require.False(t, usageRepo.lastLog.LongContextBillingApplied) - require.InDelta(t, baseInput, usageRepo.lastLog.InputCost, 1e-10) + require.True(t, usageRepo.lastLog.LongContextBillingApplied) + require.InDelta(t, baseInput*2, usageRepo.lastLog.InputCost, 1e-10) + require.InDelta(t, baseOutput*1.5, usageRepo.lastLog.OutputCost, 1e-10) }) t.Run("group on account on", func(t *testing.T) { @@ -1361,7 +1362,7 @@ func TestOpenAIGatewayServiceRecordUsage_SparkShadowUsesCurrentParentBillingSett Model: "gpt-5.4-2026-03-05", Duration: time.Second, }, - APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1016, true), + APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1016, false), User: &User{ID: 2016}, Account: &Account{ ID: 3016, diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 3559fb400..59199cea0 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -59,7 +59,8 @@ func buildOpenAIResponsesURLForPlatform(platform string, base string) string { // 强制 store=false 并清除 previous_response_id(官方 /responses 不支持服务端 // 状态存储,携带这些字段会被拒绝)。非 deepseek responses 协议账号原样返回。 func normalizeDeepSeekResponsesRequestBody(account *Account, body []byte) []byte { - if account == nil || account.Platform != PlatformDeepseek || account.GetAPIProtocol() != APIProtocolResponses { + if account == nil || account.Platform != PlatformDeepseek || + (account.GetAPIProtocol() != APIProtocolResponses && !account.IsAdaptiveAPIProtocol()) { return body } normalized, err := sjson.SetBytes(body, "store", false) @@ -420,6 +421,12 @@ func IsForwardableOpenAIResponsesRequestPath(c *gin.Context) bool { return ok } +// IsOpenAIResponsesInputTokensRequestPath reports whether the request targets +// the native Responses input-token counting endpoint. +func IsOpenAIResponsesInputTokensRequestPath(c *gin.Context) bool { + return openAIResponsesRequestPathSuffix(c) == "/input_tokens" +} + // rawOpenAIResponsesRequestPathSuffix 仅做提取,不做任何安全判断。 func rawOpenAIResponsesRequestPathSuffix(c *gin.Context) string { if c == nil || c.Request == nil || c.Request.URL == nil { diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index 82cd89f69..5577e90e4 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -54,8 +54,9 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. firstOutputTimeout = s.openAIFirstOutputTimeout(reasoningEffort) } guardFirstOutput := firstOutputTimeout > 0 + stageFirstOutput := account != nil && account.Platform == PlatformOpenAI var attemptResponseHeaders http.Header - if guardFirstOutput { + if stageFirstOutput { if s.responseHeaderFilter != nil { attemptResponseHeaders = responseheaders.FilterHeaders(resp.Header, s.responseHeaderFilter) } else if requestID := strings.TrimSpace(resp.Header.Get("x-request-id")); requestID != "" { @@ -66,8 +67,8 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. } // x-codex-turn-state 不在通用响应头白名单内,按 Codex 协议显式回传: // 客户端会在同回合的后续请求中回带(openai_codex_turn_state.go)。 - // 首输出守卫模式下只暂存,溯源在 applyAttemptResponseHeaders 真正提交时记录。 - if guardFirstOutput { + // OpenAI 首个语义输出前只暂存,溯源在 applyAttemptResponseHeaders 真正提交时记录。 + if stageFirstOutput { stageOpenAICodexTurnState(&attemptResponseHeaders, resp.Header) } else { s.relayOpenAICodexTurnState(c, account, resp.Header) @@ -80,12 +81,12 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. c.Header("X-Accel-Buffering", "no") // Pass through other headers - if !guardFirstOutput && resp.Header.Get("x-request-id") != "" { + if !stageFirstOutput && resp.Header.Get("x-request-id") != "" { v := resp.Header.Get("x-request-id") c.Header("x-request-id", v) } applyAttemptResponseHeaders := func() { - if !guardFirstOutput || len(attemptResponseHeaders) == 0 || c.Writer.Written() { + if !stageFirstOutput || len(attemptResponseHeaders) == 0 || c.Writer.Written() { return } for key, values := range attemptResponseHeaders { @@ -117,7 +118,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. firstOutputProgressObserved := false bufferedWriter := bufio.NewWriterSize(w, 4*1024) var firstOutputStage *openAIFirstOutputStage - if guardFirstOutput { + if stageFirstOutput { firstOutputStage = newDefaultOpenAIFirstOutputStage() defer func() { if err := firstOutputStage.Close(); err != nil { @@ -126,19 +127,19 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. }() } writePendingString := func(value string) (int, error) { - if firstOutputStage != nil && !firstOutputProgressObserved && !firstOutputStage.closed { + if firstOutputStage != nil && !firstOutputStage.closed { return firstOutputStage.WriteString(value) } return bufferedWriter.WriteString(value) } pendingBytes := func() int64 { - if firstOutputStage != nil && !firstOutputProgressObserved && !firstOutputStage.closed { + if firstOutputStage != nil && !firstOutputStage.closed { return firstOutputStage.Buffered() } return int64(bufferedWriter.Buffered()) } flushBuffered := func() error { - if firstOutputStage != nil && !firstOutputProgressObserved && !firstOutputStage.closed { + if firstOutputStage != nil && !firstOutputStage.closed { if err := firstOutputStage.CommitTo(w); err != nil { return err } @@ -155,11 +156,11 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. imageCounter := newOpenAIImageOutputCounter() responseID := "" var firstOutputScanGuard atomic.Bool - firstOutputScanGuard.Store(guardFirstOutput) + firstOutputScanGuard.Store(stageFirstOutput) scanner := bufio.NewScanner(resp.Body) scanBuf := getSSEScannerBuf64K() scanner.Buffer(scanBuf[:0], maxLineSize) - if guardFirstOutput { + if stageFirstOutput { scanner.Split(openAIFirstOutputDynamicScanLines(&firstOutputScanGuard)) } documentScanner := newOpenAISSEJSONDocumentScanner(scanner) @@ -241,6 +242,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. sawTerminalEvent := false sawFailedEvent := false responsesSemanticOutputSeen := false + capacityFailoverSuppressedLogged := false failedMessage := "" clientOutputStarted := false upstreamRequestID := strings.TrimSpace(resp.Header.Get("x-request-id")) @@ -250,7 +252,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. eventStartsVisibleOutput := false eventShouldFlush := false handlePendingWriteError := func(err error) { - if firstOutputStage != nil && !firstOutputProgressObserved && !firstOutputStage.closed { + if firstOutputStage != nil && !firstOutputStage.closed { message := "OpenAI first-output staging failed" if errors.Is(err, errOpenAIFirstOutputStageLimit) { message = "OpenAI first-output staging limit exceeded" @@ -350,7 +352,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. lastDownstreamWriteAt = time.Now() } finalizeStream := func() (*openaiStreamingResult, error) { - if guardFirstOutput && eventInProgress { + if stageFirstOutput && eventInProgress { // EOF dispatches the final SSE event even without a trailing blank line. completeGuardedEvent(true) } @@ -392,7 +394,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. failoverErr.SafeToFailoverAfterWrite = true return resultWithUsage(), failoverErr, true } - if errors.Is(scanErr, bufio.ErrTooLong) && guardFirstOutput && !firstOutputProgressObserved { + if errors.Is(scanErr, bufio.ErrTooLong) && stageFirstOutput && !firstOutputProgressObserved { logger.LegacyPrintf("service.openai_gateway", "SSE line too long before first output: account=%d max_size=%d error=%v", account.ID, maxLineSize, scanErr) failoverErr := s.newOpenAIStreamFailoverError( c, account, false, upstreamRequestID, nil, @@ -455,6 +457,33 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. responseID = extractOpenAIResponseIDFromJSONBytes(dataBytes) } forceFlushFailedEvent := false + if !capacityFailoverSuppressedLogged && account != nil && account.Platform == PlatformOpenAI && + (eventType == "error" || eventType == "response.failed") && + openAIStreamClientOutputStarted(c, clientOutputStarted) && + isOpenAIUpstreamCapacityShedEvent(dataBytes) { + logOpenAICapacityFailoverSuppressed(ctx, account, "native_sse", upstreamRequestID, eventType) + capacityFailoverSuppressedLogged = true + } + if eventType == "error" && !openAIStreamClientOutputStarted(c, clientOutputStarted) { + errorMessage := extractOpenAISSEErrorMessage(dataBytes) + if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, errorMessage); matched { + s.recordOpenAIStreamUpstreamError(c, account, false, upstreamRequestID, "http_error", dataBytes, errorMessage) + MarkResponseCommitted(c) + c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") + c.JSON(status, gin.H{ + "error": gin.H{ + "type": errType, + "message": errMsg, + }, + }) + streamEarlyErr = fmt.Errorf("upstream error event: passthrough rule matched message=%s", errMsg) + return + } + if openAIStreamErrorEventShouldFailover(dataBytes, errorMessage) { + streamEarlyErr = s.newOpenAIStreamFailoverError(c, account, false, upstreamRequestID, dataBytes, errorMessage, resp.Header) + return + } + } if eventType == "response.failed" { failedMessage = extractOpenAISSEErrorMessage(dataBytes) // response.failed 自带上游已消耗的 usage(input token 通常已扣);必须先解析 @@ -559,9 +588,12 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. } startsClientOutput := forceFlushFailedEvent || openAIStreamDataStartsClientOutput(data, eventType) startsVisibleOutput := openAIStreamDataStartsVisibleOutput(data, eventType) - if guardFirstOutput { + if stageFirstOutput { eventStartsClientOutput = eventStartsClientOutput || startsClientOutput eventStartsVisibleOutput = eventStartsVisibleOutput || startsVisibleOutput + if startsClientOutput { + firstOutputScanGuard.Store(false) + } } if startsClientOutput && !openAIStreamEventTypeIsTerminal(eventType) { responsesSemanticOutputSeen = true @@ -607,7 +639,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. } // A blank line dispatches a guarded event from the attempt-local stage. - if guardFirstOutput && line == "" { + if stageFirstOutput && line == "" { if !clientDisconnected { if _, err := writePendingString("\n"); err != nil { handlePendingWriteError(err) @@ -672,7 +704,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. events := make(chan scanEvent, openAIFirstOutputEventQueueSize(guardFirstOutput)) done := make(chan struct{}) sendEvent := func(ev scanEvent) bool { - if guardFirstOutput { + if firstOutputScanGuard.Load() { ev.processed = make(chan struct{}) } select { @@ -716,7 +748,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. select { case ev, ok := <-events: if !ok { - if guardFirstOutput && eventInProgress { + if stageFirstOutput && eventInProgress { // EOF dispatches the final SSE event even without a trailing blank // line. Do not synthesize extra bytes on the downstream wire. completeGuardedEvent(true) @@ -783,10 +815,12 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. if time.Since(lastDownstreamWriteAt) < keepaliveInterval { continue } - if guardFirstOutput { + if stageFirstOutput { // Bypass attempt-local buffered frames. The stable SSE headers may be // committed here, but account headers remain private until semantic output. - if _, err := w.Write([]byte(":\n\n")); err != nil { + n, err := w.Write([]byte(":\n\n")) + recordOpenAIStreamKeepaliveBytes(c, n) + if err != nil { clientDisconnected = true logger.LegacyPrintf("service.openai_gateway", "Client disconnected during streaming, continuing to drain upstream for billing") continue diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go index 1082b020d..084f9e502 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go @@ -1,6 +1,7 @@ package service import ( + "bytes" "context" "encoding/json" "errors" @@ -52,7 +53,13 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( toolSearch := apicompat.HasToolSearchTool(effectiveTools) namespaceTools := apicompat.NamespaceToolNames(effectiveTools) - chatReq, err := apicompat.ResponsesToChatCompletionsRequest(&responsesReq) + // 自愈回写:历史里带明文 summary 的 reasoning item 刷新进缓存,覆盖 Redis + // 被 flush / 跨实例漂移后同 id 的 encrypted-only 副本无法再取明文的情况。 + s.recacheReasoningItemsFromInput(responsesReq.Input) + + chatReq, err := apicompat.ResponsesToChatCompletionsRequestWithOptions(&responsesReq, &apicompat.ResponsesToChatOptions{ + ReasoningContentByID: s.reasoningContentByID, + }) if err != nil { writeOpenAIResponsesFallbackError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) return nil, fmt.Errorf("convert responses to chat completions: %w", err) @@ -136,6 +143,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( return nil, err } responsesResp := apicompat.ChatCompletionsResponseToResponses(ccResp, originalModel, customTools, toolSearch, namespaceTools) + s.cacheReasoningItemsFromOutput(responsesResp.Output) if s.responseHeaderFilter != nil { responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) @@ -204,7 +212,9 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( } scan := s.scanCCStream(resp, "openai responses chat fallback", requestID, startTime, func(chunk *apicompat.ChatCompletionsChunk) { - writeEvents(apicompat.ChatCompletionsChunkToResponsesEvents(chunk, state)) + events := apicompat.ChatCompletionsChunkToResponsesEvents(chunk, state) + s.cacheReasoningItemsFromEvents(events) + writeEvents(events) }) if scan.Err != nil { @@ -222,7 +232,9 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( }, fmt.Errorf("stream usage incomplete: %w", scan.Err) } - writeEvents(apicompat.FinalizeChatCompletionsResponsesStream(state)) + finalEvents := apicompat.FinalizeChatCompletionsResponsesStream(state) + s.cacheReasoningItemsFromEvents(finalEvents) + writeEvents(finalEvents) if !clientDisconnected { writeStreamHeaders() if _, err := fmt.Fprint(c.Writer, "data: [DONE]\n\n"); err != nil { @@ -261,3 +273,100 @@ func chatChunkStartsResponsesOutput(chunk *apicompat.ChatCompletionsChunk) bool } return false } + +// responsesReasoningCacheTTL 是 reasoning 缓存(按 reasoning item id)的过期时间。 +// Codex 会话可能跨多天恢复历史,取 7 天。 +const responsesReasoningCacheTTL = 7 * 24 * time.Hour + +// reasoningContentByID 按 reasoning item id 回查缓存的 reasoning 全文,供 +// Responses→CC 桥接在客户端不回传明文 summary(encrypted-only reasoning +// item)时回注 reasoning_content。任何失败都 fail-open 返回 ""(维持桥接原 +// 行为),因为缓存只是优化而非正确性前提。 +func (s *OpenAIGatewayService) reasoningContentByID(itemID string) string { + if s == nil || s.cache == nil { + return "" + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + content, err := s.cache.GetReasoningContent(ctx, itemID) + if err != nil { + return "" + } + return content +} + +// recacheReasoningItemsFromInput 把请求历史里带明文 summary 的 reasoning item +// 重新写入缓存(best-effort)。Codex 多数时候会原样回传明文 summary,借机 +// 刷新 TTL 并自愈 Redis 被 flush / 跨实例漂移造成的缓存缺失。 +func (s *OpenAIGatewayService) recacheReasoningItemsFromInput(inputRaw json.RawMessage) { + if s == nil || s.cache == nil { + return + } + inputRaw = bytes.TrimSpace(inputRaw) + if len(inputRaw) == 0 || inputRaw[0] != '[' { + return + } + var items []json.RawMessage + if err := json.Unmarshal(inputRaw, &items); err != nil { + return + } + for _, raw := range items { + id, text, ok := apicompat.ExtractResponsesReasoningItem(raw) + if !ok || id == "" || text == "" { + continue + } + s.setReasoningContent(id, text) + } +} + +// cacheReasoningItemsFromEvents 从 Responses 流事件里提取完成的 reasoning +// item 写入缓存(覆盖一个流中的多个 reasoning item)。 +func (s *OpenAIGatewayService) cacheReasoningItemsFromEvents(events []apicompat.ResponsesStreamEvent) { + for _, event := range events { + if event.Type != "response.output_item.done" || event.Item == nil { + continue + } + s.cacheReasoningItem(event.Item) + } +} + +// cacheReasoningItemsFromOutput 从非流式 Responses 响应的 output 里提取 +// reasoning item 写入缓存。 +func (s *OpenAIGatewayService) cacheReasoningItemsFromOutput(output []apicompat.ResponsesOutput) { + for i := range output { + s.cacheReasoningItem(&output[i]) + } +} + +func (s *OpenAIGatewayService) cacheReasoningItem(item *apicompat.ResponsesOutput) { + if item == nil || item.Type != "reasoning" || item.ID == "" { + return + } + var parts []string + for _, sum := range item.Summary { + if t := strings.TrimSpace(sum.Text); t != "" { + parts = append(parts, t) + } + } + if len(parts) == 0 { + return + } + s.setReasoningContent(item.ID, strings.Join(parts, "\n")) +} + +// setReasoningContent 写入缓存,使用 detached ctx:客户端断连后仍在 drain +// 上游流(计费需要),此时的 reasoning 也是后续轮次回注所依赖的,不能随 +// 请求 ctx 一起取消。失败仅记日志,不影响转发。 +func (s *OpenAIGatewayService) setReasoningContent(itemID, content string) { + if s == nil || s.cache == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := s.cache.SetReasoningContent(ctx, itemID, content, responsesReasoningCacheTTL); err != nil { + logger.L().Warn("openai responses chat fallback: cache reasoning content failed", + zap.Error(err), + zap.String("item_id", itemID), + ) + } +} diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback_test.go b/backend/internal/service/openai_gateway_responses_chat_fallback_test.go index 3682335c1..15952f3a0 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback_test.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback_test.go @@ -9,7 +9,9 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" "testing" + "time" "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" "github.com/gin-gonic/gin" @@ -227,3 +229,143 @@ func forceChatResponsesFallbackAccount() *Account { } return account } + +// reasoningRecordingCache 记录 reasoning 缓存写入、并按需响应回查。 +type reasoningRecordingCache struct { + stubGatewayCache + mu sync.Mutex + sets map[string]string + getResp map[string]string +} + +func (c *reasoningRecordingCache) SetReasoningContent(_ context.Context, itemID string, content string, _ time.Duration) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.sets == nil { + c.sets = make(map[string]string) + } + c.sets[itemID] = content + return nil +} + +func (c *reasoningRecordingCache) GetReasoningContent(_ context.Context, itemID string) (string, error) { + if v, ok := c.getResp[itemID]; ok { + return v, nil + } + return "", ErrReasoningContentNotFound +} + +func (c *reasoningRecordingCache) snapshotSets() map[string]string { + c.mu.Lock() + defer c.mu.Unlock() + out := make(map[string]string, len(c.sets)) + for k, v := range c.sets { + out[k] = v + } + return out +} + +// 流式响应里的 reasoning_content 应按 reasoning item id 写入缓存,供后续轮次 +// 客户端不回传明文 summary 时回注(DeepSeek thinking mode 400 修复的写入侧)。 +func TestForwardResponses_ChatFallbackCachesStreamedReasoning(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"deepseek-reasoner","input":"hello","stream":true}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstreamBody := strings.Join([]string{ + `data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}`, + "", + `data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"reasoning_content":"think "},"finish_reason":null}]}`, + "", + `data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"reasoning_content":"first"},"finish_reason":null}]}`, + "", + `data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"content":"answer"},"finish_reason":"stop"}]}`, + "", + `data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[],"usage":{"prompt_tokens":4,"completion_tokens":3,"total_tokens":7}}`, + "", + "data: [DONE]", + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_reasoning_cache_stream"}}, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }} + cache := &reasoningRecordingCache{} + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + cache: cache, + } + + result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body) + require.NoError(t, err) + require.NotNil(t, result) + + sets := cache.snapshotSets() + require.Len(t, sets, 1, "应恰好缓存一个 reasoning item") + for itemID, content := range sets { + require.NotEmpty(t, itemID) + require.Equal(t, "think first", content) + } +} + +// 请求侧:encrypted-only reasoning item(无明文 summary)经缓存回查补回 +// reasoning_content;带明文 summary 的 item 顺手回写缓存(自愈)。 +func TestForwardResponses_ChatFallbackRestoresReasoningFromCache(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{ + "model":"deepseek-reasoner", + "stream":false, + "input":[ + {"type":"reasoning","id":"item_plain","summary":[{"type":"summary_text","text":"plain thinking"}]}, + {"type":"function_call","call_id":"call_0","name":"get_value","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_0","output":"ok"}, + {"type":"reasoning","id":"item_enc1","summary":[],"encrypted_content":"opaque"}, + {"type":"function_call","call_id":"call_1","name":"get_value","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_1","output":"ok"}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]} + ] + }`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_reasoning_cache_restore"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"chatcmpl_restore","object":"chat.completion","model":"deepseek-reasoner","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}`, + )), + }} + cache := &reasoningRecordingCache{ + getResp: map[string]string{"item_enc1": "cached thinking"}, + } + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + cache: cache, + } + + result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body) + require.NoError(t, err) + require.NotNil(t, result) + + // 明文 summary 的 assistant 工具调用消息:reasoning_content 来自 summary 本身。 + require.Equal(t, "plain thinking", gjson.GetBytes(upstream.lastBody, "messages.0.reasoning_content").String()) + require.Equal(t, "call_0", gjson.GetBytes(upstream.lastBody, "messages.0.tool_calls.0.id").String()) + require.Equal(t, "tool", gjson.GetBytes(upstream.lastBody, "messages.1.role").String()) + // encrypted-only 的 assistant 工具调用消息:reasoning_content 来自缓存回查。 + require.Equal(t, "cached thinking", gjson.GetBytes(upstream.lastBody, "messages.2.reasoning_content").String()) + require.Equal(t, "call_1", gjson.GetBytes(upstream.lastBody, "messages.2.tool_calls.0.id").String()) + require.Equal(t, "tool", gjson.GetBytes(upstream.lastBody, "messages.3.role").String()) + + // 明文 summary 的 item 被回写进缓存(自愈)。 + require.Equal(t, "plain thinking", cache.snapshotSets()["item_plain"]) +} diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 1c4493c04..045069db6 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -284,8 +284,9 @@ type OpenAIForwardResult struct { // AudioUsage carries Voice billing units when present. AudioUsage *AudioUsage - wsReplayInput []json.RawMessage - wsReplayInputExists bool + wsReplayInput []json.RawMessage + wsReplayInputExists bool + wsAccountFailoverReplayInput []json.RawMessage } // SucceededForScheduling reports whether this result is an upstream success diff --git a/backend/internal/service/openai_gateway_service_codex_cli_only_test.go b/backend/internal/service/openai_gateway_service_codex_cli_only_test.go index 846529828..c8acab035 100644 --- a/backend/internal/service/openai_gateway_service_codex_cli_only_test.go +++ b/backend/internal/service/openai_gateway_service_codex_cli_only_test.go @@ -287,6 +287,24 @@ func TestIsOpenAITransientProcessingError(t *testing.T) { []byte(`{"error":{"code":"slow_down","message":"Please retry later."}}`), )) + require.True(t, isOpenAITransientProcessingError( + http.StatusBadRequest, + "", + []byte(`{"error":{"message":"Our servers are currently overloaded. Please try again later."}}`), + )) + + require.True(t, isOpenAITransientProcessingError( + http.StatusServiceUnavailable, + "Server is overloaded. Please try again later.", + nil, + )) + + require.True(t, isOpenAITransientProcessingError( + http.StatusBadGateway, + "", + []byte(`{"error":{"message":"Our servers are currently overloaded. Please try again later."}}`), + )) + require.True(t, isOpenAITransientProcessingError( http.StatusBadRequest, "", diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index 617c07c06..1d18d3d70 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -115,7 +115,7 @@ func (r stubOpenAIAccountRepo) ListSchedulableUngroupedByPlatform(ctx context.Co return r.ListSchedulableByPlatform(ctx, platform) } -func TestOpenAIGatewayService_ForwardAsAnthropic_TempUnschedulableReturnsFailoverWithoutCommit(t *testing.T) { +func TestOpenAIGatewayService_ForwardAsAnthropic_CapacityShedReturnsRequestScopedFailoverWithoutCommit(t *testing.T) { gin.SetMode(gin.TestMode) body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`) @@ -173,8 +173,10 @@ func TestOpenAIGatewayService_ForwardAsAnthropic_TempUnschedulableReturnsFailove require.ErrorAs(t, err, &failoverErr) require.Equal(t, http.StatusBadRequest, failoverErr.StatusCode) require.True(t, failoverErr.ShouldRetryNextAccount()) - require.Equal(t, account.ID, repo.modelRateLimitAccountID, "temporary unschedulability should exclude this account from reselection") - require.Equal(t, "gpt-5.4", repo.modelRateLimitKey) + require.True(t, failoverErr.RetryableOnSameAccount) + require.True(t, failoverErr.RequestScopedTransient) + require.Zero(t, repo.modelRateLimitAccountID, "request-scoped capacity shedding must not change account health") + require.Empty(t, repo.modelRateLimitKey) require.False(t, IsResponseCommitted(c)) require.Equal(t, http.StatusOK, rec.Code) require.Empty(t, rec.Body.String()) @@ -204,17 +206,17 @@ func TestFailoverOpenAIUpstreamHTTPError_NilContextSkipsTempUnschedulablePolicy( "temp_unschedulable_enabled": true, "temp_unschedulable_rules": []any{map[string]any{ "error_code": float64(http.StatusBadRequest), - "keywords": []any{"servers are currently overloaded"}, + "keywords": []any{"custom temporary outage"}, "duration_minutes": float64(1), }}, }, } - body := []byte(`{"error":{"message":"Our servers are currently overloaded."}}`) + body := []byte(`{"error":{"message":"Custom temporary outage."}}`) resp := &http.Response{StatusCode: http.StatusBadRequest, Header: http.Header{}} got := svc.failoverOpenAIUpstreamHTTPError( context.Background(), nil, account, resp, body, - "Our servers are currently overloaded.", "gpt-5.4", + "Custom temporary outage.", "gpt-5.4", ) require.Nil(t, got) @@ -710,6 +712,13 @@ func (c *stubGatewayCache) ReleaseGrokVideoBilled(_ context.Context, _ string) e return nil } +func (c *stubGatewayCache) SetReasoningContent(_ context.Context, _ string, _ string, _ time.Duration) error { + return nil +} +func (c *stubGatewayCache) GetReasoningContent(_ context.Context, _ string) (string, error) { + return "", ErrReasoningContentNotFound +} + func TestOpenAISelectAccountWithLoadAwareness_FiltersUnschedulable(t *testing.T) { now := time.Now() resetAt := now.Add(10 * time.Minute) @@ -2288,7 +2297,7 @@ func TestOpenAIStreamingMissingTerminalEventReturnsIncompleteError(t *testing.T) go func() { defer func() { _ = pw.Close() }() - _, _ = pw.Write([]byte("data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\"},\"output_index\":0}\n\n")) + _, _ = pw.Write([]byte("data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\",\"output_index\":0}\n\n")) }() _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1}, time.Now(), "model", "model") @@ -2320,7 +2329,7 @@ func TestOpenAIStreamingPassthroughMissingTerminalEventReturnsIncompleteError(t go func() { defer func() { _ = pw.Close() }() - _, _ = pw.Write([]byte("data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\"},\"output_index\":0}\n\n")) + _, _ = pw.Write([]byte("data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\",\"output_index\":0}\n\n")) }() _, err := svc.handleStreamingResponsePassthrough(c.Request.Context(), resp, c, &Account{ID: 1}, time.Now(), "", "") diff --git a/backend/internal/service/openai_gateway_upstream_errors.go b/backend/internal/service/openai_gateway_upstream_errors.go index 00a62f3f3..32f04b2c6 100644 --- a/backend/internal/service/openai_gateway_upstream_errors.go +++ b/backend/internal/service/openai_gateway_upstream_errors.go @@ -117,7 +117,7 @@ func isOpenAIInstructionsRequiredError(upstreamStatusCode int, upstreamMsg strin } func isOpenAITransientProcessingError(upstreamStatusCode int, upstreamMsg string, upstreamBody []byte) bool { - if upstreamStatusCode != http.StatusBadRequest && upstreamStatusCode != http.StatusServiceUnavailable { + if upstreamStatusCode < http.StatusBadRequest { return false } @@ -132,6 +132,15 @@ func isOpenAITransientProcessingError(upstreamStatusCode int, upstreamMsg string if len(upstreamBody) > 0 && hasOpenAIServerOverloadedCode(upstreamBody) { return true } + if isOpenAICapacityShedMessage(upstreamMsg) || + isOpenAICapacityShedMessage(gjson.GetBytes(upstreamBody, "error.message").String()) || + isOpenAICapacityShedMessage(gjson.GetBytes(upstreamBody, "response.error.message").String()) || + isOpenAICapacityShedMessage(string(upstreamBody)) { + return true + } + if upstreamStatusCode != http.StatusBadRequest && upstreamStatusCode != http.StatusServiceUnavailable { + return false + } if upstreamStatusCode != http.StatusBadRequest { return false } @@ -164,6 +173,19 @@ func isOpenAITransientProcessingError(upstreamStatusCode int, upstreamMsg string return match(string(upstreamBody)) } +func isOpenAICapacityShedMessage(text string) bool { + lower := strings.ToLower(strings.TrimSpace(text)) + return strings.Contains(lower, "server is overloaded") || + strings.Contains(lower, "servers are overloaded") || + strings.Contains(lower, "servers are currently overloaded") +} + +func isOpenAIRequestScopedCapacityShed(upstreamMsg string, upstreamBody []byte) bool { + return isOpenAIUpstreamCapacityShedEvent(upstreamBody) || + isOpenAICapacityShedMessage(upstreamMsg) || + isOpenAICapacityShedMessage(string(upstreamBody)) +} + func isOpenAIContextWindowError(upstreamMsg string, upstreamBody []byte) bool { match := func(text string) bool { lower := strings.ToLower(strings.TrimSpace(text)) @@ -248,14 +270,17 @@ func newOpenAIUpstreamFailoverError( upstreamMsg string, retryableOnSameAccount bool, ) *UpstreamFailoverError { + requestScopedCapacity := isOpenAIRequestScopedCapacityShed(upstreamMsg, responseBody) failoverErr := &UpstreamFailoverError{ StatusCode: statusCode, ResponseBody: responseBody, ResponseHeaders: responseHeaders.Clone(), - RetryableOnSameAccount: retryableOnSameAccount, + RetryableOnSameAccount: retryableOnSameAccount || requestScopedCapacity, + RequestScopedTransient: requestScopedCapacity, } if isOpenAIRequestBodyTooLargeError(statusCode, upstreamMsg, responseBody) { failoverErr.RetryableOnSameAccount = false + failoverErr.RequestScopedTransient = false failoverErr.Scope = GatewayFailureScopeAccount failoverErr.Reason = openAIRequestBodyTooLargeReason failoverErr.NextAccountAction = NextAccountRetry diff --git a/backend/internal/service/openai_responses_input_tokens_test.go b/backend/internal/service/openai_responses_input_tokens_test.go new file mode 100644 index 000000000..5258d4bb7 --- /dev/null +++ b/backend/internal/service/openai_responses_input_tokens_test.go @@ -0,0 +1,103 @@ +package service + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestForwardResponsesInputTokensCustomRelayUsesLocalEstimate(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/input_tokens", nil) + + upstream := &httpUpstreamRecorder{} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + httpUpstream: upstream, + } + account := &Account{ + ID: 159, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "relay-key", + "base_url": "https://relay.example/v1", + }, + } + body := []byte(`{"model":"gpt-5.4","instructions":"Be concise.","input":"hello world","tools":[{"type":"function","name":"lookup","description":"Look up a value","parameters":{"type":"object"}}]}`) + + err := svc.ForwardResponsesInputTokens(context.Background(), c, account, body) + + require.NoError(t, err) + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, "response.input_tokens", gjson.Get(recorder.Body.String(), "object").String()) + require.Positive(t, gjson.Get(recorder.Body.String(), "input_tokens").Int()) + require.Nil(t, upstream.lastReq, "custom relay must not receive /v1/responses/input_tokens") +} + +func TestForwardResponsesInputTokensGrokOAuthUsesLocalEstimate(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/input_tokens", nil) + + upstream := &httpUpstreamRecorder{} + svc := &OpenAIGatewayService{httpUpstream: upstream} + account := &Account{ID: 160, Platform: PlatformGrok, Type: AccountTypeOAuth} + body := []byte(`{"model":"grok-4.1","input":"hello world"}`) + + err := svc.ForwardResponsesInputTokens(context.Background(), c, account, body) + + require.NoError(t, err) + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, "response.input_tokens", gjson.Get(recorder.Body.String(), "object").String()) + require.Positive(t, gjson.Get(recorder.Body.String(), "input_tokens").Int()) + require.Nil(t, upstream.lastReq) +} + +func TestForwardResponsesInputTokensUpstream404FallsBackLocally(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/input_tokens", nil) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusNotFound, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"Invalid URL (POST /v1/responses/input_tokens)"}}`)), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + httpUpstream: upstream, + } + account := &Account{ + ID: 171, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "official-key", + "base_url": "https://api.openai.com/v1", + }, + } + body := []byte(`{"model":"gpt-5.4","instructions":"Be concise.","input":"hello world"}`) + + err := svc.ForwardResponsesInputTokens(context.Background(), c, account, body) + + require.NoError(t, err) + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, "response.input_tokens", gjson.Get(recorder.Body.String(), "object").String()) + require.Positive(t, gjson.Get(recorder.Body.String(), "input_tokens").Int()) + require.NotNil(t, upstream.lastReq) +} diff --git a/backend/internal/service/openai_visible_ttft_test.go b/backend/internal/service/openai_visible_ttft_test.go index 8fd243c39..4d811dfbb 100644 --- a/backend/internal/service/openai_visible_ttft_test.go +++ b/backend/internal/service/openai_visible_ttft_test.go @@ -71,11 +71,38 @@ func TestOpenAIResponsesTTFTStartsAtCompletedImage(t *testing.T) { } } -func TestOpenAINativeProgressDisarmsTimeoutWithoutStartingTTFT(t *testing.T) { - result := runSyntheticVisibleTTFTStream(t, false, 1200*time.Millisecond, 1, - `{"type":"response.output_text.delta","delta":"test output"}`) - require.NotNil(t, result.firstTokenMs) - require.GreaterOrEqual(t, *result.firstTokenMs, 1100) +func TestOpenAINativeMetadataDoesNotDisarmFirstOutputTimeout(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{ + MaxLineSize: defaultMaxLineSize, + OpenAIFirstOutputTimeoutSeconds: 1, + }}} + reader, writer := io.Pipe() + writerDone := make(chan struct{}) + go func() { + defer close(writerDone) + defer func() { _ = writer.Close() }() + _, _ = io.WriteString(writer, "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_test\"}}\n\n") + _, _ = io.WriteString(writer, "data: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"item_test\",\"type\":\"reasoning\",\"summary\":[]}}\n\n") + time.Sleep(1200 * time.Millisecond) + }() + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: reader} + account := &Account{ID: 1, Name: "account_test", Platform: PlatformOpenAI} + + _, err := svc.handleStreamingResponse(context.Background(), resp, c, account, time.Now(), "test-model", "test-model") + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.True(t, failoverErr.SafeToFailoverAfterWrite) + require.Empty(t, recorder.Body.String()) + select { + case <-writerDone: + case <-time.After(time.Second): + t.Fatal("synthetic upstream writer did not exit") + } } func runSyntheticVisibleTTFTStream(t *testing.T, passthrough bool, visibleDelay time.Duration, timeoutSeconds int, visibleEvent string) *openaiStreamingResult { diff --git a/backend/internal/service/openai_ws_forwarder.go b/backend/internal/service/openai_ws_forwarder.go index 1928c1d6e..099444662 100644 --- a/backend/internal/service/openai_ws_forwarder.go +++ b/backend/internal/service/openai_ws_forwarder.go @@ -96,6 +96,42 @@ type openAIWSIngressTurnError struct { wroteDownstream bool } +type openAIWSCurrentTurnFailoverError struct { + cause error + retryPayload []byte +} + +func (e *openAIWSCurrentTurnFailoverError) Error() string { + if e == nil || e.cause == nil { + return "openai websocket current-turn failover" + } + return e.cause.Error() +} + +func (e *openAIWSCurrentTurnFailoverError) Unwrap() error { + if e == nil { + return nil + } + return e.cause +} + +func newOpenAIWSCurrentTurnFailoverError(cause error, retryPayload []byte) error { + return &openAIWSCurrentTurnFailoverError{ + cause: cause, + retryPayload: append([]byte(nil), retryPayload...), + } +} + +// OpenAIWSCurrentTurnRetryPayload returns an isolated copy of the payload that +// may be retried on a replacement account without replaying the first turn. +func OpenAIWSCurrentTurnRetryPayload(err error) ([]byte, bool) { + var retryErr *openAIWSCurrentTurnFailoverError + if !errors.As(err, &retryErr) || retryErr == nil { + return nil, false + } + return append([]byte(nil), retryErr.retryPayload...), true +} + func (e *openAIWSIngressTurnError) Error() string { if e == nil { return "" diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 176fadec5..97de78fc9 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -493,6 +493,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( grokCacheSeedPayload := firstPayload.payloadRaw var bridgeReplayInput []json.RawMessage bridgeReplayInputExists := false + var bridgeAccountFailoverInput []json.RawMessage + bridgeAccountFailoverInputExists := false for turn := 1; ; turn++ { if turn > 1 && hooks != nil && hooks.BeforeRequest != nil { if err := hooks.BeforeRequest(turn, currentBridgePayload.payloadRaw, currentBridgePayload.originalModel); err != nil { @@ -519,6 +521,15 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( if replayInputErr != nil { return fmt.Errorf("build websocket http bridge replay input: %w", replayInputErr) } + turnAccountFailoverInput, turnAccountFailoverInputExists, failoverInputErr := buildOpenAIWSReplayInputSequence( + bridgeAccountFailoverInput, + bridgeAccountFailoverInputExists, + currentBridgePayload.payloadRaw, + needsBridgeReplay, + ) + if failoverInputErr != nil { + return fmt.Errorf("build websocket account failover input: %w", failoverInputErr) + } if needsBridgeReplay && turnReplayInputExists { updatedPayload, setInputErr := setOpenAIWSPayloadInputSequence( currentBridgePayload.payloadRaw, @@ -571,6 +582,22 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( hooks.AfterTurn(turn, result, bridgeErr) } if bridgeErr != nil { + var failoverErr *UpstreamFailoverError + if turn > 1 && errors.As(bridgeErr, &failoverErr) && failoverErr != nil { + retryPayload, retrySafe, retryPayloadErr := buildOpenAIWSCurrentTurnRetryPayload( + bridgePayloadRaw, + turnAccountFailoverInput, + turnAccountFailoverInputExists, + currentBridgePayload.originalModel, + ) + if retryPayloadErr != nil { + return fmt.Errorf("build websocket current-turn failover payload: %w", retryPayloadErr) + } + if !retrySafe { + retryPayload = nil + } + return newOpenAIWSCurrentTurnFailoverError(bridgeErr, retryPayload) + } return bridgeErr } if result == nil { @@ -582,6 +609,15 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( bridgeReplayInput = append(bridgeReplayInput, cloneOpenAIWSRawMessages(result.wsReplayInput)...) bridgeReplayInputExists = true } + bridgeAccountFailoverInput = cloneOpenAIWSRawMessages(turnAccountFailoverInput) + bridgeAccountFailoverInputExists = turnAccountFailoverInputExists + if len(result.wsAccountFailoverReplayInput) > 0 { + bridgeAccountFailoverInput = append( + bridgeAccountFailoverInput, + cloneOpenAIWSRawMessages(result.wsAccountFailoverReplayInput)..., + ) + bridgeAccountFailoverInputExists = true + } if bridgeTurnState := strings.TrimSpace(result.ResponseHeaders.Get(openAIWSTurnStateHeader)); bridgeTurnState != "" { turnState = bridgeTurnState if stateStore != nil && sessionHash != "" { diff --git a/backend/internal/service/openai_ws_forwarder_payload.go b/backend/internal/service/openai_ws_forwarder_payload.go index 7eabe7d31..3d6fef10b 100644 --- a/backend/internal/service/openai_ws_forwarder_payload.go +++ b/backend/internal/service/openai_ws_forwarder_payload.go @@ -674,6 +674,33 @@ func setOpenAIWSPayloadInputSequence( return sjson.SetRawBytes(payload, "input", inputRaw) } +func buildOpenAIWSCurrentTurnRetryPayload( + payload []byte, + fullInput []json.RawMessage, + fullInputExists bool, + originalModel string, +) ([]byte, bool, error) { + if !fullInputExists { + return nil, false, nil + } + retryPayload, err := setOpenAIWSPayloadInputSequence(payload, fullInput, true) + if err != nil { + return nil, false, err + } + retryPayload = RemovePreviousResponseIDFromBody(retryPayload) + if model := strings.TrimSpace(originalModel); model != "" { + retryPayload, err = sjson.SetBytes(retryPayload, "model", model) + if err != nil { + return nil, false, err + } + } + coverage := AnalyzeToolCallOutputContextCoverageBytes(retryPayload) + if coverage.HasFunctionCallOutput && !coverage.ContextCoversAllCallIDs { + return nil, false, nil + } + return retryPayload, true, nil +} + func shouldKeepIngressPreviousResponseID( previousPayload []byte, currentPayload []byte, diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index 98eee9c7d..d68e26c3d 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -23,6 +23,57 @@ const ( openAIWSHTTPBridgeErrorBodyLimitBytes = 64 * 1024 ) +const openAIWSHTTPBridgeToolStateContextKey = "openai_ws_http_bridge_tool_state" + +type openAIWSHTTPBridgeToolState struct { + ClientMapping apicompat.ResponsesClientToolMapping + LoweredTools json.RawMessage +} + +func openAIWSHTTPBridgeToolStateFromContext(c *gin.Context) (openAIWSHTTPBridgeToolState, bool) { + if c == nil { + return openAIWSHTTPBridgeToolState{}, false + } + value, ok := c.Get(openAIWSHTTPBridgeToolStateContextKey) + state, typed := value.(openAIWSHTTPBridgeToolState) + return state, ok && typed +} + +func setOpenAIWSHTTPBridgeToolState(c *gin.Context, state openAIWSHTTPBridgeToolState) { + if c == nil { + return + } + state.LoweredTools = append(json.RawMessage(nil), state.LoweredTools...) + c.Set(openAIWSHTTPBridgeToolStateContextKey, state) +} + +func decodeOpenAIWSHTTPBridgeLoweredTools(raw json.RawMessage) []any { + if len(raw) == 0 { + return nil + } + var tools []any + if err := json.Unmarshal(raw, &tools); err != nil { + return nil + } + return tools +} + +func openAIWSHTTPBridgeRawField(body []byte, name string) (json.RawMessage, bool) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(body, &fields); err != nil { + return nil, false + } + raw, present := fields[name] + return append(json.RawMessage(nil), raw...), present +} + +func openAIWSHTTPBridgeToolUpstreamName(account *Account) string { + if account != nil && account.Platform == PlatformGrok { + return "Grok WS HTTP bridge" + } + return "OpenAI WS HTTP bridge" +} + // ResolveOpenAIWSClientFirstMessageTimeout returns the effective client ingress deadline. func ResolveOpenAIWSClientFirstMessageTimeout(cfg *config.Config) time.Duration { seconds := config.DefaultOpenAIWSClientFirstMessageTimeoutSeconds @@ -80,20 +131,25 @@ func prepareOpenAIWSHTTPBridgeBody(payload []byte) ([]byte, error) { } type openAIWSToolCallReplayCollector struct { - items []json.RawMessage - seen map[string]struct{} + items []json.RawMessage + seen map[string]struct{} + allItems []json.RawMessage + allSeen map[string]struct{} } func (c *openAIWSToolCallReplayCollector) AddEvent(eventType string, message []byte) { switch strings.TrimSpace(eventType) { case "response.output_item.done": - c.addItem(gjson.GetBytes(message, "item")) + item := gjson.GetBytes(message, "item") + c.addAllItem(item) + c.addItem(item) case "response.completed", "response.done": output := gjson.GetBytes(message, "response.output") if !output.IsArray() { return } for _, item := range output.Array() { + c.addAllItem(item) c.addItem(item) } } @@ -103,6 +159,35 @@ func (c *openAIWSToolCallReplayCollector) Items() []json.RawMessage { return cloneOpenAIWSRawMessages(c.items) } +func (c *openAIWSToolCallReplayCollector) AllItems() []json.RawMessage { + return cloneOpenAIWSRawMessages(c.allItems) +} + +func (c *openAIWSToolCallReplayCollector) addAllItem(item gjson.Result) { + if !item.Exists() || item.Type != gjson.JSON { + return + } + raw := strings.TrimSpace(item.Raw) + if raw == "" || !strings.HasPrefix(raw, "{") || strings.TrimSpace(item.Get("type").String()) == "" { + return + } + key := strings.TrimSpace(item.Get("id").String()) + if key == "" { + key = strings.TrimSpace(item.Get("call_id").String()) + } + if key == "" { + key = raw + } + if c.allSeen == nil { + c.allSeen = make(map[string]struct{}) + } + if _, ok := c.allSeen[key]; ok { + return + } + c.allSeen[key] = struct{}{} + c.allItems = append(c.allItems, json.RawMessage(raw)) +} + func (c *openAIWSToolCallReplayCollector) addItem(item gjson.Result) { if !item.Exists() || item.Type != gjson.JSON { return @@ -187,19 +272,51 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( if err != nil { return nil, fmt.Errorf("prepare http bridge body: %w", err) } + grokIntentSourceBody := append([]byte(nil), body...) + _, grokExplicitToolsField := openAIWSHTTPBridgeRawField(grokIntentSourceBody, "tools") + grokExplicitToolIntent := account.Platform == PlatformGrok && hasGrokResponsesToolIntent(grokIntentSourceBody) var clientToolMapping apicompat.ResponsesClientToolMapping - if account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey { - body, clientToolMapping, err = adaptResponsesClientToolsForFunctionUpstream(body, "OpenAI WS HTTP bridge") - if err != nil { - return nil, fmt.Errorf("adapt OpenAI WS HTTP bridge client tools: %w", err) + functionToolUpstream := (account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey) || account.Platform == PlatformGrok + if functionToolUpstream { + if account.Platform == PlatformGrok { + body, err = sanitizeGrokResponsesInput(body) + if err != nil { + return nil, fmt.Errorf("sanitize Grok WS HTTP bridge input: %w", err) + } } + inheritedState, _ := openAIWSHTTPBridgeToolStateFromContext(c) + inheritedLoweredTools := decodeOpenAIWSHTTPBridgeLoweredTools(inheritedState.LoweredTools) + body, clientToolMapping, err = adaptResponsesClientToolsForFunctionUpstreamWithMapping( + body, + openAIWSHTTPBridgeToolUpstreamName(account), + inheritedState.ClientMapping, + inheritedLoweredTools, + ) + if err != nil { + return nil, fmt.Errorf("adapt %s client tools: %w", openAIWSHTTPBridgeToolUpstreamName(account), err) + } + if account.Platform == PlatformGrok && !grokExplicitToolsField && !grokExplicitToolIntent && len(inheritedLoweredTools) > 0 && hasGrokResponsesToolIntent(body) { + // This continuation omitted tools, so the pre-adapter source cannot + // represent the effective inherited declarations. Cache routing must + // see the rehydrated tool intent or it will replace client functions + // with the native-search tool-free route. Explicit current-turn tool + // intent still uses the original pre-sanitization source above. + grokIntentSourceBody = append(grokIntentSourceBody[:0], body...) + } + loweredTools := inheritedState.LoweredTools + if currentTools, present := openAIWSHTTPBridgeRawField(body, "tools"); present { + loweredTools = currentTools + } + setOpenAIWSHTTPBridgeToolState(c, openAIWSHTTPBridgeToolState{ + ClientMapping: clientToolMapping, + LoweredTools: loweredTools, + }) } upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) var upstreamReq *http.Request if account.Platform == PlatformGrok { upstreamModel := resolveGrokWSUpstreamModel(account, body, originalModel) - grokIntentSourceBody := body body, err = patchGrokResponsesBody(body, upstreamModel) if err != nil { releaseUpstreamCtx() @@ -259,10 +376,10 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( if account.Platform == PlatformGrok { shouldFailover = s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) s.handleGrokAccountUpstreamError(withGrokTeamRateLimitModel(ctx, resolveGrokWSUpstreamModel(account, body, originalModel)), account, resp.StatusCode, resp.Header, respBody) - if turn == 1 && shouldFailover { + if shouldFailover && (turn == 1 || resp.StatusCode == http.StatusTooManyRequests) { return nil, newOpenAIUpstreamFailoverError(resp.StatusCode, resp.Header, respBody, upstreamMsg, false) } - } else if turn == 1 && shouldFailover { + } else if shouldFailover && (turn == 1 || resp.StatusCode == http.StatusTooManyRequests) { return nil, s.handleFailoverErrorResponsePassthrough(ctx, resp, c, account, body, respBody) } if account.Platform != PlatformGrok && (shouldFailover || shouldCooldownOpenAITransientUpstreamError(resp.StatusCode, respBody)) { @@ -290,6 +407,9 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( upstreamTerminalEvent := "" sawDone := false wroteDownstream := false + pendingClientMessages := make([][]byte, 0, 4) + pendingClientMessageBytes := int64(0) + capacityFailoverSuppressedLogged := false clientDisconnected := false mappedModel := "" needModelReplace := false @@ -327,6 +447,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( result.wsReplayInput = replayInput result.wsReplayInputExists = true } + result.wsAccountFailoverReplayInput = replayCollector.AllItems() if imageCount > 0 { result.ImageCount = imageCount result.ImageSize = imageSizeTier @@ -403,15 +524,20 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( replayCollector.AddEvent(eventType, upstreamMessage) var upstreamEventErr error - if eventType == "error" { - errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(upstreamMessage) - errMessage := strings.TrimSpace(errMsgRaw) + if eventType == "error" || eventType == "response.failed" { + errMessage := extractOpenAISSEErrorMessage(upstreamMessage) if errMessage == "" { errMessage = "upstream error event" } - statusCode := openAIWSErrorHTTPStatusFromRaw(errCodeRaw, errTypeRaw) - shouldFailover := s.shouldFailoverOpenAIUpstreamResponse(statusCode, errMessage, upstreamMessage) - if account.Platform == PlatformGrok { + statusCode := openAIStreamFailureStatus(upstreamMessage, errMessage) + shouldFailover := openAIStreamFailedEventShouldFailover(upstreamMessage, errMessage) + if eventType == "error" { + errCodeRaw, errTypeRaw, _ := parseOpenAIWSErrorEventFields(upstreamMessage) + statusCode = openAIWSErrorHTTPStatusFromRaw(errCodeRaw, errTypeRaw) + shouldFailover = s.shouldFailoverOpenAIUpstreamResponse(statusCode, errMessage, upstreamMessage) + } + requestScopedCapacity := isOpenAIUpstreamCapacityShedEvent(upstreamMessage) + if account.Platform == PlatformGrok && eventType == "error" { // SSE error events do not carry an HTTP status. The local status // mapper therefore defaults unknown xAI codes (for example // new_sensitive) to 502; classify the body as a request-scoped @@ -422,7 +548,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( shouldFailover = s.shouldFailoverGrokUpstreamError(statusCode, upstreamMessage) s.handleGrokAccountUpstreamError(ctx, account, statusCode, resp.Header, upstreamMessage) } - } else if shouldFailover { + } else if eventType == "error" && shouldFailover && !requestScopedCapacity { accountStatus := statusCode if transientStatus := openAIWSPayloadTransientStatus(upstreamMessage); transientStatus != 0 { accountStatus = transientStatus @@ -430,10 +556,19 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( canonicalModel := canonicalOpenAIAccountSchedulingModel(account, originalModel) s.handleOpenAIAccountUpstreamError(ctx, account, accountStatus, resp.Header, upstreamMessage, canonicalModel) } - if turn == 1 && !wroteDownstream && shouldFailover { - return nil, newOpenAIUpstreamFailoverError(statusCode, resp.Header, upstreamMessage, errMessage, false) + if !wroteDownstream && shouldFailover && (turn == 1 || statusCode == http.StatusTooManyRequests) { + if account.Platform == PlatformGrok { + return nil, newOpenAIUpstreamFailoverError(statusCode, resp.Header, upstreamMessage, errMessage, false) + } + return nil, s.newOpenAIStreamFailoverError(c, account, true, resp.Header.Get("x-request-id"), upstreamMessage, errMessage, resp.Header) + } + if wroteDownstream && requestScopedCapacity && !capacityFailoverSuppressedLogged { + logOpenAICapacityFailoverSuppressed(ctx, account, "ws_http_bridge", resp.Header.Get("x-request-id"), eventType) + capacityFailoverSuppressedLogged = true + } + if eventType == "error" { + upstreamEventErr = errors.New(errMessage) } - upstreamEventErr = errors.New(errMessage) } // 客户端写出副本改写容量降载码:Codex 对 error/response.failed 中的 @@ -447,26 +582,50 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( } } if !clientDisconnected { - if err := writeClientMessage(clientMessage); err != nil { - if isOpenAIWSClientDisconnectError(err) { - clientDisconnected = true - closeStatus, closeReason := summarizeOpenAIWSReadCloseError(err) - logOpenAIWSModeInfo( - "ingress_ws_http_bridge_client_disconnected_drain account_id=%d turn=%d close_status=%s close_reason=%s", - account.ID, - turn, - closeStatus, - truncateOpenAIWSLogValue(closeReason, openAIWSHeaderValueMaxLen), - ) - } else { - return nil, wrapOpenAIWSIngressTurnError( - "write_client", - fmt.Errorf("write client websocket event: %w", err), - wroteDownstream, + stageBeforeSemanticOutput := turn == 1 && account.Platform == PlatformOpenAI && !wroteDownstream + commitStagedMessages := !stageBeforeSemanticOutput || + openAIStreamDataStartsClientOutput(string(clientMessage), eventType) || + isOpenAIWSTerminalEvent(eventType) + if stageBeforeSemanticOutput && !commitStagedMessages { + if pendingClientMessageBytes+int64(len(clientMessage)) > openAIFirstOutputStageMaxBytes { + return nil, s.newOpenAIStreamFailoverError( + c, + account, + true, + resp.Header.Get("x-request-id"), + nil, + "OpenAI WS HTTP bridge first-output staging limit exceeded", + resp.Header, ) } + pendingClientMessages = append(pendingClientMessages, append([]byte(nil), clientMessage...)) + pendingClientMessageBytes += int64(len(clientMessage)) } else { - wroteDownstream = true + messages := append(pendingClientMessages, clientMessage) + pendingClientMessages = nil + pendingClientMessageBytes = 0 + for _, message := range messages { + if err := writeClientMessage(message); err != nil { + if isOpenAIWSClientDisconnectError(err) { + clientDisconnected = true + closeStatus, closeReason := summarizeOpenAIWSReadCloseError(err) + logOpenAIWSModeInfo( + "ingress_ws_http_bridge_client_disconnected_drain account_id=%d turn=%d close_status=%s close_reason=%s", + account.ID, + turn, + closeStatus, + truncateOpenAIWSLogValue(closeReason, openAIWSHeaderValueMaxLen), + ) + break + } + return nil, wrapOpenAIWSIngressTurnError( + "write_client", + fmt.Errorf("write client websocket event: %w", err), + wroteDownstream, + ) + } + wroteDownstream = true + } } } diff --git a/backend/internal/service/openai_ws_http_bridge_resume_test.go b/backend/internal/service/openai_ws_http_bridge_resume_test.go new file mode 100644 index 000000000..b6719d493 --- /dev/null +++ b/backend/internal/service/openai_ws_http_bridge_resume_test.go @@ -0,0 +1,252 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/coder/websocket" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestBuildOpenAIWSCurrentTurnRetryPayloadRejectsOrphanToolOutput(t *testing.T) { + payload := []byte(`{"type":"response.create","model":"mapped-model","previous_response_id":"resp_old"}`) + fullInput := []json.RawMessage{ + json.RawMessage(`{"type":"function_call_output","call_id":"missing_call","output":"done"}`), + } + + retryPayload, retrySafe, err := buildOpenAIWSCurrentTurnRetryPayload(payload, fullInput, true, "gpt-5.6-sol") + + require.NoError(t, err) + require.False(t, retrySafe) + require.Nil(t, retryPayload) +} + +func TestProxyOpenAIWSHTTPBridgeTurnLaterTurn429FailsOverBeforeClientWrite(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{"Retry-After": []string{"60"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"type":"usage_limit_reached","message":"The usage limit has been reached"}}`)), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 129, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + payload := []byte(`{"type":"response.create","model":"gpt-5.6-sol","previous_response_id":"resp_old","input":[{"role":"user","content":"continue"}]}`) + writes := 0 + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "access-token", payload, len(payload), + "gpt-5.6-sol", "", "", "", "", 281, + func([]byte) error { + writes++ + return nil + }, + ) + + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode) + require.Zero(t, writes) +} + +func TestProxyOpenAIWSHTTPBridgeTurnLaterTurnDoesNotFailOverAfterDownstreamOutput(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n" + + "data: {\"type\":\"error\",\"error\":{\"type\":\"rate_limit_error\",\"message\":\"limited\"}}\n\n", + )), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 10, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + payload := []byte(`{"type":"response.create","model":"gpt-5","input":"hi"}`) + var writes [][]byte + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "sk-test", payload, len(payload), + "gpt-5", "", "", "", "", 281, + func(message []byte) error { + writes = append(writes, append([]byte(nil), message...)) + return nil + }, + ) + + require.NotNil(t, result) + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.False(t, errors.As(err, &failoverErr)) + require.Len(t, writes, 2) + require.Equal(t, "response.output_text.delta", gjson.GetBytes(writes[0], "type").String()) + require.Equal(t, "error", gjson.GetBytes(writes[1], "type").String()) +} + +func TestOpenAIWSHTTPBridgeLaterTurn429RetriesCurrentTurnOnReplacementAccount(t *testing.T) { + gin.SetMode(gin.TestMode) + + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + cfg.Gateway.OpenAIWS.Enabled = true + cfg.Gateway.OpenAIWS.OAuthEnabled = true + cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true + cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true + cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 + + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"text/event-stream"}, + openAIWSTurnStateHeader: []string{"old-account-state"}, + }, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_first\",\"output\":[{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"first-ok\"}]},{\"id\":\"fc_1\",\"type\":\"function_call\",\"call_id\":\"call_1\",\"name\":\"inspect\",\"arguments\":\"{}\"}],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n", + )), + }, + { + StatusCode: http.StatusTooManyRequests, + Header: http.Header{"Retry-After": []string{"60"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"type":"usage_limit_reached","message":"The usage limit has been reached"}}`)), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_second\",\"output\":[{\"id\":\"msg_2\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"second-ok\"}]}],\"usage\":{\"input_tokens\":4,\"output_tokens\":1}}}\n\n", + )), + }, + }} + svc := &OpenAIGatewayService{ + cfg: cfg, + httpUpstream: upstream, + cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + toolCorrector: NewCodexToolCorrector(), + } + account := &Account{ + ID: 129, Name: "limited", Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Schedulable: true, Concurrency: 1, + Extra: map[string]any{"openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeHTTPBridge}, + } + nextAccount := *account + nextAccount.ID = 130 + nextAccount.Name = "replacement" + + serverErrCh := make(chan error, 1) + failoverCh := make(chan []byte, 1) + wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, nil) + if err != nil { + serverErrCh <- err + return + } + defer func() { _ = conn.CloseNow() }() + + rec := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(rec) + ginCtx.Request = r.Clone(r.Context()) + readCtx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + _, firstMessage, readErr := conn.Read(readCtx) + cancel() + if readErr != nil { + serverErrCh <- readErr + return + } + proxyErr := svc.ProxyResponsesWebSocketFromClient(r.Context(), ginCtx, conn, account, "access-token-a", firstMessage, nil) + var failoverErr *UpstreamFailoverError + if !errors.As(proxyErr, &failoverErr) { + serverErrCh <- proxyErr + return + } + retryPayload, retryCurrentTurn := OpenAIWSCurrentTurnRetryPayload(proxyErr) + if !retryCurrentTurn || len(retryPayload) == 0 { + serverErrCh <- errors.New("missing current-turn retry payload") + return + } + failoverCh <- retryPayload + serverErrCh <- svc.ProxyResponsesWebSocketFromClient( + r.Context(), ginCtx, conn, &nextAccount, "access-token-b", retryPayload, nil, + ) + })) + defer wsServer.Close() + + dialCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + clientConn, _, err := websocket.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http"), nil) + cancel() + require.NoError(t, err) + defer func() { _ = clientConn.CloseNow() }() + + writeCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + err = clientConn.Write(writeCtx, websocket.MessageText, []byte(`{"type":"response.create","model":"gpt-5.6-sol","input":[{"role":"user","content":"first"}]}`)) + cancel() + require.NoError(t, err) + + readCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + _, completed, err := clientConn.Read(readCtx) + cancel() + require.NoError(t, err) + require.Equal(t, "response.completed", gjson.GetBytes(completed, "type").String()) + + writeCtx, cancel = context.WithTimeout(context.Background(), 3*time.Second) + err = clientConn.Write(writeCtx, websocket.MessageText, []byte(`{"type":"response.create","model":"gpt-5.6-sol","previous_response_id":"resp_first","input":[{"type":"function_call_output","call_id":"call_1","output":"second"}]}`)) + cancel() + require.NoError(t, err) + + readCtx, cancel = context.WithTimeout(context.Background(), 3*time.Second) + _, retriedCompleted, err := clientConn.Read(readCtx) + cancel() + require.NoError(t, err) + require.Equal(t, "response.completed", gjson.GetBytes(retriedCompleted, "type").String()) + require.Equal(t, "resp_second", gjson.GetBytes(retriedCompleted, "response.id").String()) + _ = clientConn.Close(websocket.StatusNormalClosure, "done") + + select { + case retryPayload := <-failoverCh: + require.NotEmpty(t, retryPayload) + require.False(t, gjson.GetBytes(retryPayload, "previous_response_id").Exists()) + require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(retryPayload, "model").String()) + input := gjson.GetBytes(retryPayload, "input") + require.True(t, input.IsArray()) + require.Len(t, input.Array(), 4) + require.Contains(t, input.Raw, "first") + require.Contains(t, input.Raw, "first-ok") + require.Contains(t, input.Raw, "second") + require.Equal(t, 1, strings.Count(input.Raw, `"id":"fc_1"`)) + require.Equal(t, 2, strings.Count(input.Raw, `"call_id":"call_1"`)) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for current-turn failover") + } + + select { + case proxyErr := <-serverErrCh: + require.NoError(t, proxyErr) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for replacement-account completion") + } + require.Len(t, upstream.bodies, 3) + require.Contains(t, string(upstream.bodies[0]), "first") + require.NotContains(t, string(upstream.bodies[2]), "previous_response_id") + require.Contains(t, string(upstream.bodies[2]), "second") + require.Empty(t, upstream.requests[2].Header.Get(openAIWSTurnStateHeader)) +} diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index 547867231..49b4c7073 100644 --- a/backend/internal/service/openai_ws_http_bridge_test.go +++ b/backend/internal/service/openai_ws_http_bridge_test.go @@ -170,6 +170,249 @@ func TestProxyOpenAIWSHTTPBridgeTurnAPIKeyRestoresClientToolsInResponseDone(t *t require.Equal(t, "custom_tool_call", gjson.GetBytes(result.wsReplayInput[0], "type").String()) } +func TestProxyOpenAIWSHTTPBridgeTurnGrokPromotesDiscoveryAndRestoresNamespaceSSE(t *testing.T) { + gin.SetMode(gin.TestMode) + + sse := strings.Join([]string{ + `data: {"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"type":"function_call","id":"item_spawn","call_id":"call_spawn","name":"multi_agent_v1__spawn_agent","status":"in_progress"}}`, + "", + `data: {"type":"response.function_call_arguments.done","sequence_number":1,"output_index":0,"item_id":"item_spawn","call_id":"call_spawn","name":"multi_agent_v1__spawn_agent","arguments":"{\"message\":\"work\"}"}`, + "", + `data: {"type":"response.output_item.done","sequence_number":2,"output_index":0,"item":{"type":"function_call","id":"item_spawn","call_id":"call_spawn","name":"multi_agent_v1__spawn_agent","arguments":"{\"message\":\"work\"}","status":"completed"}}`, + "", + `data: {"type":"response.completed","sequence_number":3,"response":{"id":"resp_spawn","status":"completed","output":[{"type":"function_call","id":"item_spawn","call_id":"call_spawn","name":"multi_agent_v1__spawn_agent","arguments":"{\"message\":\"work\"}","status":"completed"}],"usage":{"input_tokens":1,"output_tokens":1}}}`, + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(sse)), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}, + httpUpstream: upstream, + } + account := &Account{ + ID: 5765, Platform: PlatformGrok, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{"base_url": xai.DefaultCLIBaseURL}, + } + payload := []byte(`{ + "type":"response.create","model":"grok-4.5","stream":true, + "tools":[{"type":"tool_search"}], + "input":[ + {"type":"tool_search_call","call_id":"call_search","arguments":{"query":"subagent"},"status":"completed"}, + {"type":"tool_search_output","call_id":"call_search","execution":"client","status":"completed","tools":[ + {"type":"namespace","name":"multi_agent_v1","tools":[{"type":"function","name":"spawn_agent","parameters":{"type":"object"}}]} + ]} + ] + }`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + var events [][]byte + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "access-token", payload, len(payload), + "grok-4.5", "", "", "", "", 1, + func(message []byte) error { + events = append(events, append([]byte(nil), message...)) + return nil + }, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "multi_agent_v1__spawn_agent", gjson.GetBytes(upstream.lastBody, "tools.1.name").String()) + require.Equal(t, "function_call_output", gjson.GetBytes(upstream.lastBody, "input.1.type").String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input.1.tools").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input.1.status").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input.1.execution").Exists()) + state, ok := openAIWSHTTPBridgeToolStateFromContext(c) + require.True(t, ok) + require.Equal(t, "multi_agent_v1", state.ClientMapping.NamespaceTools["multi_agent_v1__spawn_agent"].Namespace) + require.Len(t, events, 4) + require.Equal(t, "spawn_agent", gjson.GetBytes(events[0], "item.name").String()) + require.Equal(t, "multi_agent_v1", gjson.GetBytes(events[0], "item.namespace").String()) + require.Equal(t, "spawn_agent", gjson.GetBytes(events[2], "item.name").String()) + require.Equal(t, "multi_agent_v1", gjson.GetBytes(events[2], "item.namespace").String()) + require.Equal(t, "spawn_agent", gjson.GetBytes(events[3], "response.output.0.name").String()) + require.Equal(t, "multi_agent_v1", gjson.GetBytes(events[3], "response.output.0.namespace").String()) +} + +func TestProxyOpenAIWSHTTPBridgeTurnGrokInheritsToolSearchAndPromotesFollowupDiscovery(t *testing.T) { + gin.SetMode(gin.TestMode) + + firstSSE := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_first\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + secondSSE := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_second\",\"output\":[{\"type\":\"function_call\",\"id\":\"item_spawn\",\"call_id\":\"call_spawn\",\"name\":\"multi_agent_v1__spawn_agent\",\"arguments\":\"{}\"}],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(firstSSE))}, + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(secondSSE))}, + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}, + httpUpstream: upstream, + } + account := &Account{ + ID: 5766, Platform: PlatformGrok, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{"base_url": xai.DefaultCLIBaseURL, "subscription_tier": "free"}, + } + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + + first := []byte(`{"type":"response.create","model":"grok-4.5","stream":true,"tools":[{"type":"tool_search"}],"input":"discover tools"}`) + _, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "access-token", first, len(first), + "grok-4.5", "", "", "", "grok-ws-cache", 1, func([]byte) error { return nil }, + ) + require.NoError(t, err) + + second := []byte(`{ + "type":"response.create","model":"grok-4.5","stream":true, + "input":[{"type":"tool_search_output","call_id":"call_search","status":"completed","tools":[ + {"type":"namespace","name":"multi_agent_v1","tools":[{"type":"function","name":"spawn_agent","parameters":{"type":"object"}}]} + ]}] + }`) + var events [][]byte + _, err = svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "access-token", second, len(second), + "grok-4.5", "", "", "", "grok-ws-cache", 2, + func(message []byte) error { + events = append(events, append([]byte(nil), message...)) + return nil + }, + ) + + require.NoError(t, err) + require.Len(t, upstream.bodies, 2) + require.Equal(t, "tool_search", gjson.GetBytes(upstream.bodies[1], "tools.0.name").String()) + require.Equal(t, "multi_agent_v1__spawn_agent", gjson.GetBytes(upstream.bodies[1], "tools.1.name").String()) + require.NotEqual(t, grokFreeCacheDisabledToolChoice, gjson.GetBytes(upstream.bodies[1], "tool_choice").String()) + require.Equal(t, "grok-ws-cache", gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").String()) + require.Equal(t, "function_call_output", gjson.GetBytes(upstream.bodies[1], "input.0.type").String()) + require.Len(t, events, 1) + require.Equal(t, "spawn_agent", gjson.GetBytes(events[0], "response.output.0.name").String()) + require.Equal(t, "multi_agent_v1", gjson.GetBytes(events[0], "response.output.0.namespace").String()) +} + +func TestOpenAIWSHTTPBridgeAPIKeyReusesClientToolMappingWhenFollowupOmitsTools(t *testing.T) { + gin.SetMode(gin.TestMode) + + firstSSEBody := strings.Join([]string{ + `data: {"type":"response.completed","response":{"id":"resp_custom_first","model":"gpt-5.6-sol","output":[{"type":"function_call","id":"fc_custom_1","call_id":"call_custom_1","name":"exec","arguments":"{\"input\":\"pwd\"}"}],"usage":{"input_tokens":9,"output_tokens":1}}}`, + "", + }, "\n") + secondSSEBody := strings.Join([]string{ + `data: {"type":"response.completed","response":{"id":"resp_custom_second","model":"gpt-5.6-sol","output":[],"usage":{"input_tokens":1,"output_tokens":1}}}`, + "", + }, "\n") + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(firstSSEBody))}, + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(secondSSEBody))}, + }} + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + cfg.Gateway.OpenAIWS.Enabled = true + cfg.Gateway.OpenAIWS.APIKeyEnabled = true + cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true + cfg.Gateway.OpenAIWS.HTTPBridgeEnabled = true + cfg.Gateway.OpenAIWS.HTTPBridgeThresholdBytes = 1 + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 + cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8 + cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 + + svc := &OpenAIGatewayService{ + cfg: cfg, httpUpstream: upstream, cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), toolCorrector: NewCodexToolCorrector(), + } + account := &Account{ + ID: 9001, Name: "api-key-custom-followup", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-upstream"}, Extra: map[string]any{"responses_websockets_v2_enabled": true}, + Concurrency: 1, Status: StatusActive, Schedulable: true, + } + + errCh := make(chan error, 1) + wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := coderws.Accept(w, r, nil) + if err != nil { + errCh <- err + return + } + defer func() { _ = conn.CloseNow() }() + readCtx, cancelRead := context.WithTimeout(r.Context(), 3*time.Second) + _, firstMessage, err := conn.Read(readCtx) + cancelRead() + if err != nil { + errCh <- err + return + } + rec := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(rec) + ginCtx.Request = r.Clone(r.Context()) + errCh <- svc.ProxyResponsesWebSocketFromClient(r.Context(), ginCtx, conn, account, "sk-test", firstMessage, nil) + })) + defer wsServer.Close() + + dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second) + clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http"), nil) + cancelDial() + require.NoError(t, err) + defer func() { _ = clientConn.CloseNow() }() + + writeMessage := func(payload string) { + writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) + defer cancelWrite() + require.NoError(t, clientConn.Write(writeCtx, coderws.MessageText, []byte(payload))) + } + readMessage := func() []byte { + readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second) + defer cancelRead() + messageType, event, readErr := clientConn.Read(readCtx) + require.NoError(t, readErr) + require.Equal(t, coderws.MessageText, messageType) + return event + } + + writeMessage(`{"type":"response.create","model":"gpt-5.6-sol","stream":true,"tools":[{"type":"custom","name":"exec"}],"input":"run pwd"}`) + firstEvent := readMessage() + require.Equal(t, "response.completed", gjson.GetBytes(firstEvent, "type").String()) + require.Equal(t, "custom_tool_call", gjson.GetBytes(firstEvent, "response.output.0.type").String()) + require.Equal(t, "pwd", gjson.GetBytes(firstEvent, "response.output.0.input").String()) + + writeMessage(`{"type":"response.create","model":"gpt-5.6-sol","stream":true,"previous_response_id":"resp_custom_first","input":[{"type":"custom_tool_call_output","id":"ctco_client_output_1","call_id":"call_custom_1","output":"ok"}]}`) + secondEvent := readMessage() + require.Equal(t, "response.completed", gjson.GetBytes(secondEvent, "type").String()) + + require.NoError(t, clientConn.Close(coderws.StatusNormalClosure, "done")) + select { + case proxyErr := <-errCh: + require.NoError(t, proxyErr) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for websocket bridge proxy to finish") + } + + require.Len(t, upstream.bodies, 2) + firstTools := gjson.GetBytes(upstream.bodies[0], "tools").Array() + require.Len(t, firstTools, 1) + require.Equal(t, "function", firstTools[0].Get("type").String()) + secondTools := gjson.GetBytes(upstream.bodies[1], "tools").Array() + require.Len(t, secondTools, 1) + require.Equal(t, "function", secondTools[0].Get("type").String()) + require.Equal(t, "exec", secondTools[0].Get("name").String()) + secondInput := gjson.GetBytes(upstream.bodies[1], "input").Array() + require.Len(t, secondInput, 3) + require.Equal(t, "run pwd", secondInput[0].String()) + require.Equal(t, "function_call", secondInput[1].Get("type").String()) + require.Equal(t, "fc_custom_1", secondInput[1].Get("id").String()) + require.JSONEq(t, `{"input":"pwd"}`, secondInput[1].Get("arguments").String()) + require.False(t, secondInput[1].Get("input").Exists()) + require.Equal(t, "function_call_output", secondInput[2].Get("type").String()) + require.False(t, secondInput[2].Get("id").Exists()) +} + func TestOpenAIWSHTTPBridgeDecisionKeepsSmallFramesOnWS(t *testing.T) { svc := &OpenAIGatewayService{ cfg: &config.Config{ @@ -334,17 +577,10 @@ func TestProxyOpenAIWSHTTPBridgeTurnSSEErrorFailoverSafety(t *testing.T) { ) var failoverErr *UpstreamFailoverError - if turn == 1 { - require.Nil(t, result) - require.ErrorAs(t, err, &failoverErr) - require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode) - require.Empty(t, writes) - } else { - require.NotNil(t, result) - require.Error(t, err) - require.False(t, errors.As(err, &failoverErr)) - require.Len(t, writes, 1) - } + require.Nil(t, result) + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode) + require.Empty(t, writes) }) } } @@ -368,10 +604,9 @@ func TestProxyOpenAIWSHTTPBridgeTurnRewritesCapacityShedCodeForClient(t *testing wantErr: true, }, { - // response.failed 不走 error 事件分支:即便 turn 1 也会被当终止事件 - // 原样转发(不 failover),因此改写必须在这里同样生效。 - name: "turn1_bare_response_failed", - turn: 1, + // 后续 turn 不允许 replay,容量错误必须改写后交给客户端重试。 + name: "turn2_bare_response_failed", + turn: 2, body: "data: {\"type\":\"response.failed\",\"response\":{\"id\":\"resp_shed\",\"status\":\"failed\",\"error\":{\"code\":\"server_is_overloaded\",\"message\":\"Our servers are currently overloaded. Please try again later.\"}}}\n\n", }, } @@ -412,6 +647,91 @@ func TestProxyOpenAIWSHTTPBridgeTurnRewritesCapacityShedCodeForClient(t *testing } } +func TestProxyOpenAIWSHTTPBridgeTurnStagesMetadataBeforeCapacityFailover(t *testing.T) { + gin.SetMode(gin.TestMode) + body := strings.Join([]string{ + `data: {"type":"response.created","response":{"id":"resp_shed"}}`, + "", + `data: {"type":"response.in_progress","response":{"id":"resp_shed"}}`, + "", + `data: {"type":"response.failed","response":{"id":"resp_shed","status":"failed","error":{"message":"Our servers are currently overloaded. Please try again later."}}}`, + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"X-Request-Id": []string{"rid-ws-bridge-capacity"}}, + Body: io.NopCloser(strings.NewReader(body)), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 12, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + payload := []byte(`{"type":"response.create","model":"gpt-5","input":"hi"}`) + var writes [][]byte + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "sk-test", payload, len(payload), + "gpt-5", "", "", "", "", 1, + func(message []byte) error { + writes = append(writes, append([]byte(nil), message...)) + return nil + }, + ) + + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.True(t, failoverErr.RetryableOnSameAccount) + require.True(t, failoverErr.RequestScopedTransient) + require.Empty(t, writes) +} + +func TestProxyOpenAIWSHTTPBridgeTurnDoesNotReplayCapacityAfterSemanticOutput(t *testing.T) { + gin.SetMode(gin.TestMode) + logSink, restore := captureStructuredLog(t) + defer restore() + body := strings.Join([]string{ + `data: {"type":"response.created","response":{"id":"resp_partial"}}`, + "", + `data: {"type":"response.output_text.delta","delta":"partial"}`, + "", + `data: {"type":"response.failed","response":{"id":"resp_partial","status":"failed","error":{"code":"server_is_overloaded","message":"Our servers are currently overloaded. Please try again later."}}}`, + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"X-Request-Id": []string{"rid-ws-bridge-post-output"}}, + Body: io.NopCloser(strings.NewReader(body)), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 13, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + payload := []byte(`{"type":"response.create","model":"gpt-5","input":"hi"}`) + var writes [][]byte + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "sk-test", payload, len(payload), + "gpt-5", "", "", "", "", 1, + func(message []byte) error { + writes = append(writes, append([]byte(nil), message...)) + return nil + }, + ) + + require.NotNil(t, result) + require.NoError(t, err) + require.Len(t, writes, 3) + var failoverErr *UpstreamFailoverError + require.False(t, errors.As(err, &failoverErr)) + require.Contains(t, string(writes[2]), `"code":"server_error"`) + require.NotContains(t, string(writes[2]), "server_is_overloaded") + require.True(t, logSink.ContainsMessage("gateway.failover_suppressed_after_semantic_output")) + require.True(t, logSink.ContainsFieldValue("path", "ws_http_bridge")) +} + func TestProxyOpenAIWSHTTPBridgeTurnRequiresTerminalEvent(t *testing.T) { gin.SetMode(gin.TestMode) @@ -423,10 +743,10 @@ func TestProxyOpenAIWSHTTPBridgeTurnRequiresTerminalEvent(t *testing.T) { }{ {name: "done_without_events_fails_over", body: "data: [DONE]\n\n", wantFailover: true}, { - name: "created_then_done_is_truncated_not_success", + name: "created_then_done_fails_over_before_semantic_output", body: "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_truncated\"}}\n\n" + "data: [DONE]\n\n", - wantWrites: 1, + wantFailover: true, }, } for _, tt := range tests { @@ -699,11 +1019,13 @@ func TestProxyOpenAIWSHTTPBridgeTurnPromotesCodexAdditionalToolsForMixedCache(t require.Len(t, events, 2) require.False(t, gjson.GetBytes(upstream.lastBody, `input.#(type=="additional_tools")`).Exists()) tools := gjson.GetBytes(upstream.lastBody, "tools").Array() - require.Len(t, tools, 3) + require.Len(t, tools, 4) require.Equal(t, "function", tools[0].Get("type").String()) require.Equal(t, "lookup", tools[0].Get("name").String()) require.Equal(t, "web_search", tools[1].Get("type").String()) - require.Equal(t, "x_search", tools[2].Get("type").String()) + require.Equal(t, "function", tools[2].Get("type").String()) + require.Equal(t, "apply_patch", tools[2].Get("name").String()) + require.Equal(t, "x_search", tools[3].Get("type").String()) require.False(t, gjson.GetBytes(upstream.lastBody, "tool_choice").Exists()) require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="custom")`).Exists()) require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="namespace")`).Exists()) diff --git a/backend/internal/service/openai_ws_state_store_test.go b/backend/internal/service/openai_ws_state_store_test.go index 9c98a1ea8..869e5e0fd 100644 --- a/backend/internal/service/openai_ws_state_store_test.go +++ b/backend/internal/service/openai_ws_state_store_test.go @@ -207,6 +207,13 @@ func (c *openAIWSStateStoreTimeoutProbeCache) ReleaseGrokVideoBilled(_ context.C return nil } +func (c *openAIWSStateStoreTimeoutProbeCache) SetReasoningContent(_ context.Context, _ string, _ string, _ time.Duration) error { + return nil +} +func (c *openAIWSStateStoreTimeoutProbeCache) GetReasoningContent(_ context.Context, _ string) (string, error) { + return "", ErrReasoningContentNotFound +} + func TestOpenAIWSStateStore_RedisOpsUseShortTimeout(t *testing.T) { probe := &openAIWSStateStoreTimeoutProbeCache{} store := NewOpenAIWSStateStore(probe) diff --git a/backend/internal/service/ops_upstream_context.go b/backend/internal/service/ops_upstream_context.go index 35a97cc2f..5d23ef60f 100644 --- a/backend/internal/service/ops_upstream_context.go +++ b/backend/internal/service/ops_upstream_context.go @@ -44,13 +44,14 @@ const ( // ensureForwardErrorResponse 检查此 key,为 true 时跳过兜底写入,避免在已完成的 JSON 后追加 SSE。 ResponseCommittedKey = "response_committed" - OpsClientBusinessLimitedKey = "ops_client_business_limited" - OpsClientBusinessLimitedReasonKey = "ops_client_business_limited_reason" - OpsClientBusinessLimitedReasonIPRestriction = "api_key_ip_restriction" - OpsClientBusinessLimitedReasonAPIKeyGroupUnavailable = "api_key_group_unavailable" - OpsClientBusinessLimitedReasonAPIKeyGroupUnassigned = "api_key_group_unassigned" - OpsClientBusinessLimitedReasonLocalFeatureGate = "local_feature_gate" - OpsClientBusinessLimitedReasonLocalPolicyDenied = "local_policy_denied" + OpsClientBusinessLimitedKey = "ops_client_business_limited" + OpsClientBusinessLimitedReasonKey = "ops_client_business_limited_reason" + OpsClientBusinessLimitedReasonIPRestriction = "api_key_ip_restriction" + OpsClientBusinessLimitedReasonAPIKeyGroupUnavailable = "api_key_group_unavailable" + OpsClientBusinessLimitedReasonAPIKeyGroupUnassigned = "api_key_group_unassigned" + OpsClientBusinessLimitedReasonLocalFeatureGate = "local_feature_gate" + OpsClientBusinessLimitedReasonLocalPolicyDenied = "local_policy_denied" + OpsClientBusinessLimitedReasonLocalModelConfiguration = "local_model_configuration" ) func MarkResponseCommitted(c *gin.Context) { c.Set(ResponseCommittedKey, true) } @@ -93,6 +94,18 @@ func HasOpsClientBusinessLimited(c *gin.Context) bool { return marked } +func OpsClientBusinessLimitedReason(c *gin.Context) string { + if c == nil { + return "" + } + v, ok := c.Get(OpsClientBusinessLimitedReasonKey) + if !ok { + return "" + } + reason, _ := v.(string) + return strings.TrimSpace(reason) +} + // OpsStreamError 描述网关在「响应状态已固化为 200」之后(keepalive ping 或部分数据 // 已 flush)就地以 SSE error 帧形式返回的错误。由于 HTTP 状态码停留在 200, // 而 ops_error_logger 以 status>=400 为采集触发条件,这类流内失败 diff --git a/backend/internal/service/scheduler_snapshot_bulk_event_test.go b/backend/internal/service/scheduler_snapshot_bulk_event_test.go index 00454a498..0f6003a01 100644 --- a/backend/internal/service/scheduler_snapshot_bulk_event_test.go +++ b/backend/internal/service/scheduler_snapshot_bulk_event_test.go @@ -114,6 +114,21 @@ func TestSchedulerBulkAccountEventScopesOpenAIRebuildToFreshPlatform(t *testing. require.Empty(t, deleted) } +func TestSchedulerBulkAccountEventScopesCNRebuildToFreshPlatform(t *testing.T) { + for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} { + t.Run(platform, func(t *testing.T) { + cache := newBulkEventSnapshotCache() + repo := newBulkEventAccountRepo(&Account{ID: 1, Platform: platform, GroupIDs: []int64{12}}) + svc := newBulkEventTestService(cache, repo) + + err := svc.handleBulkAccountEvent(context.Background(), bulkEventPayload([]int64{1}, []int64{11}), make(map[batchSeenKey]struct{})) + + require.NoError(t, err) + require.ElementsMatch(t, schedulerBucketsForTest([]int64{11, 12}, platform), cache.capturedBuckets()) + }) + } +} + func TestSchedulerBulkAccountEventRebuildsOpenAIUngroupedBucket(t *testing.T) { cache := newBulkEventSnapshotCache() repo := newBulkEventAccountRepo(&Account{ID: 6, Platform: PlatformOpenAI}) diff --git a/backend/internal/service/scheduler_snapshot_full_rebuild_lifecycle_test.go b/backend/internal/service/scheduler_snapshot_full_rebuild_lifecycle_test.go index fa918a086..e52adbb4c 100644 --- a/backend/internal/service/scheduler_snapshot_full_rebuild_lifecycle_test.go +++ b/backend/internal/service/scheduler_snapshot_full_rebuild_lifecycle_test.go @@ -290,13 +290,13 @@ func TestSchedulerFullRebuildActiveTombstoneDoesNotBlockFollowingGroupEvent(t *t require.Equal(t, 1, activeCalls) require.Zero(t, fallbackCalls) require.Equal(t, []int64{groupID, groupID}, freshCalls) - require.Len(t, cache.tokens(), 24, "full rebuild and the following group event must each run fresh authority") + require.Len(t, cache.tokens(), 36, "full rebuild and the following group event must each run fresh authority") _, reopenHeld := cache.lifecycleMutationLeaseStates() - require.Len(t, reopenHeld, 24) + require.Len(t, reopenHeld, 36) for _, held := range reopenHeld { require.True(t, held) } - require.Equal(t, 21, accounts.callCount()) + require.Equal(t, 30, accounts.callCount()) } func TestSchedulerFullRebuildGlobalReadErrorsFailBeforeMutationOrDB(t *testing.T) { @@ -367,15 +367,15 @@ func TestSchedulerFullRebuildFreshActivePreparesEveryTokenBeforeFirstDB(t *testi capturesAtFirstDB = cache.captureAttemptCount() held, reopenCount := cache.leaseHeldAndTokenCount() require.False(t, held) - require.Equal(t, 12, reopenCount) - require.Equal(t, 13, capturesAtFirstDB, "C(0) and the historical bucket must be captured before DB") + require.Equal(t, 18, reopenCount) + require.Equal(t, 19, capturesAtFirstDB, "C(0) and the historical bucket must be captured before DB") } svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard) require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test")) require.Equal(t, capturesAtFirstDB, cache.captureAttemptCount()) - require.Equal(t, 15, accounts.callCount()) - require.Equal(t, 8, accounts.groupCallCount(groupID)) + require.Equal(t, 21, accounts.callCount()) + require.Equal(t, 11, accounts.groupCallCount(groupID)) _, historicalPublished := cache.counts(historical) require.Equal(t, 1, historicalPublished) activeCalls, fallbackCalls, freshCalls := groups.stats() @@ -398,7 +398,7 @@ func TestSchedulerFullRebuildOrdinaryCaptureErrorReturnsBeforeFirstDB(t *testing err := svc.rebuildFullSnapshot(context.Background(), "test") require.ErrorIs(t, err, wantErr) - require.Equal(t, 14, cache.captureAttemptCount(), "all canonical and ordinary captures must be attempted before returning") + require.Equal(t, 20, cache.captureAttemptCount(), "all canonical and ordinary captures must be attempted before returning") require.Zero(t, accounts.callCount()) require.Zero(t, cache.totalSetAttempts()) } @@ -416,8 +416,8 @@ func TestSchedulerFullRebuildPreservesGroupZeroActiveHistoricalAndInvalidRegistr svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard) require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test")) - require.Equal(t, 27, cache.captureAttemptCount()) - require.Equal(t, 17, accounts.callCount()) + require.Equal(t, 39, cache.captureAttemptCount()) + require.Equal(t, 23, accounts.callCount()) groups.mu.Lock() require.Equal(t, 1, groups.listCalls) groups.mu.Unlock() @@ -456,7 +456,7 @@ func TestSchedulerFullRebuildActiveTombstoneFreshInactiveOrMissingFiltersAllGrou require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test")) require.Zero(t, accounts.groupCallCount(groupID)) - require.Equal(t, 7, accounts.groupCallCount(0)) + require.Equal(t, 10, accounts.groupCallCount(0)) require.Empty(t, cache.tokens()) require.Equal(t, bucketStrings(append(canonical, historical)), bucketStrings(cache.retiredBuckets())) for _, bucket := range append(canonical, historical) { @@ -528,7 +528,7 @@ func TestSchedulerFullRebuildPartialLifecycleFailureReturnsBeforeDBAndRetries(t require.Equal(t, []int64{1, 2}, freshCalls) require.Zero(t, accounts.callCount()) require.Zero(t, cache.totalSetAttempts()) - require.Equal(t, 13, len(cache.retiredBuckets())) + require.Equal(t, 19, len(cache.retiredBuckets())) groups.mu.Lock() delete(groups.freshErr, 2) @@ -537,8 +537,8 @@ func TestSchedulerFullRebuildPartialLifecycleFailureReturnsBeforeDBAndRetries(t require.NoError(t, svc.triggerFullRebuild("retry")) _, _, freshCalls = groups.stats() require.Equal(t, []int64{1, 2, 2, 3}, freshCalls) - require.Equal(t, 39, len(cache.retiredBuckets())) - require.Equal(t, 7, accounts.callCount()) + require.Equal(t, 57, len(cache.retiredBuckets())) + require.Equal(t, 10, accounts.callCount()) require.Empty(t, cache.tokens()) } @@ -561,14 +561,14 @@ func TestSchedulerFullRebuildActiveTombstoneLazyRecoveryDiscardsPartialCaptureTa capturesAtFirstDB = cache.captureAttemptCount() held, reopenCount := cache.leaseHeldAndTokenCount() require.False(t, held) - require.Equal(t, 12, reopenCount) - require.Equal(t, 19, capturesAtFirstDB) + require.Equal(t, 18, reopenCount) + require.Equal(t, 25, capturesAtFirstDB) } svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard) require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test")) require.Equal(t, capturesAtFirstDB, cache.captureAttemptCount()) - require.Equal(t, 15, accounts.callCount()) + require.Equal(t, 21, accounts.callCount()) for _, bucket := range canonical { attempts, published := cache.counts(bucket) require.Equal(t, 1, attempts, "discarded pre-recovery tokens must never publish: %s", bucket.String()) @@ -600,9 +600,9 @@ func TestSchedulerFullRebuildSimpleModePreservesRegistryWithoutLifecycleAuthorit require.Zero(t, activeCalls) require.Zero(t, fallbackCalls) require.Empty(t, freshCalls) - require.Equal(t, 15, cache.captureAttemptCount()) - require.Equal(t, 10, accounts.callCount()) - require.Equal(t, 10, accounts.groupCallCount(0)) + require.Equal(t, 21, cache.captureAttemptCount()) + require.Equal(t, 13, accounts.callCount()) + require.Equal(t, 13, accounts.groupCallCount(0)) require.Empty(t, cache.retiredBuckets()) require.Empty(t, cache.tokens()) for _, bucket := range registered { @@ -632,11 +632,11 @@ func TestSchedulerFullRebuildFreshReopenLockBusyRetriesWithoutBlockingOrdinaryTa require.Zero(t, cache.currentWatermark()) _, groupZeroPublished := cache.counts(schedulerCanonicalBuckets(0)[0]) require.Equal(t, 1, groupZeroPublished, "ordinary tasks must still run when one strict Reopen task is busy") - require.Equal(t, 14, accounts.callCount()) + require.Equal(t, 20, accounts.callCount()) svc.pollOutbox() require.Equal(t, int64(1), cache.currentWatermark()) - require.Equal(t, 28, accounts.callCount()) + require.Equal(t, 40, accounts.callCount()) _, busyBucketPublished := cache.counts(canonical[0]) require.Equal(t, 1, busyBucketPublished) activeCalls, fallbackCalls, freshCalls := groups.stats() @@ -654,7 +654,7 @@ func TestSchedulerFullRebuildOrdinaryLockBusyKeepsExistingSkipSemantics(t *testi svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard) require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test")) - require.Equal(t, 7, accounts.callCount()) + require.Equal(t, 10, accounts.callCount()) attempts, published := cache.counts(busyBucket) require.Zero(t, attempts) require.Zero(t, published) diff --git a/backend/internal/service/scheduler_snapshot_group_lifecycle_test.go b/backend/internal/service/scheduler_snapshot_group_lifecycle_test.go index ffc697eaf..f9e75ed15 100644 --- a/backend/internal/service/scheduler_snapshot_group_lifecycle_test.go +++ b/backend/internal/service/scheduler_snapshot_group_lifecycle_test.go @@ -325,8 +325,8 @@ func newGroupLifecycleTestService(cache SchedulerCache, accounts AccountReposito } func expectedGroupLifecycleBuckets(groupID int64) []SchedulerBucket { - platforms := []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok} - buckets := make([]SchedulerBucket, 0, 12) + platforms := schedulerSnapshotPlatforms() + buckets := make([]SchedulerBucket, 0, 18) for _, platform := range platforms { buckets = append(buckets, SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeSingle}, @@ -441,7 +441,7 @@ func TestSchedulerGroupLifecycleActiveReopensAndRebuildsAllCurrentBuckets(t *tes accounts.beforeLoad = func() { held, tokenCount := cache.leaseHeldAndTokenCount() require.False(t, held, "the group lifecycle lease must be released before the first account query") - require.Equal(t, 12, tokenCount, "all reopen tokens must be prepared before the first account query") + require.Equal(t, 18, tokenCount, "all reopen tokens must be prepared before the first account query") } svc := newGroupLifecycleTestService(cache, accounts, groups, config.RunModeStandard) seen := make(map[batchSeenKey]struct{}) @@ -453,8 +453,8 @@ func TestSchedulerGroupLifecycleActiveReopensAndRebuildsAllCurrentBuckets(t *tes registered, err := cache.retirementRaceCache.ListBuckets(context.Background()) require.NoError(t, err) require.Contains(t, bucketStrings(registered), historical.String()) - require.Len(t, cache.tokens(), 12) - require.Equal(t, 7, accounts.callCount()) + require.Len(t, cache.tokens(), 18) + require.Equal(t, 10, accounts.callCount()) require.Equal(t, 1, accounts.platformCallCount(PlatformOpenAI)) for _, bucket := range current { _, published := cache.counts(bucket) @@ -472,16 +472,16 @@ func TestSchedulerGroupLifecycleActiveReopensAndRebuildsAllCurrentBuckets(t *tes require.True(t, cache.releaseDeadline) require.NoError(t, cache.releaseCtxErr) _, reopenHeld := cache.lifecycleMutationLeaseStates() - require.Len(t, reopenHeld, 12) + require.Len(t, reopenHeld, 18) for _, held := range reopenHeld { require.True(t, held) } lockTTLs, unlockCalls := cache.lockStats() - require.Len(t, lockTTLs, 12) + require.Len(t, lockTTLs, 18) for _, ttl := range lockTTLs { require.Equal(t, 30*time.Second, ttl) } - require.Equal(t, 12, unlockCalls) + require.Equal(t, 18, unlockCalls) requireLifecycleSeen(t, seen, groupID) } @@ -497,8 +497,8 @@ func TestSchedulerGroupLifecycleInactiveThenActiveAuthoritativelyReopens(t *test groups.set(&Group{ID: groupID, Status: StatusActive, Hydrated: true}, nil) require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), make(map[batchSeenKey]struct{}))) - require.Len(t, cache.tokens(), 12) - require.Equal(t, 7, accounts.callCount()) + require.Len(t, cache.tokens(), 18) + require.Equal(t, 10, accounts.callCount()) for _, bucket := range expectedGroupLifecycleBuckets(groupID) { _, published := cache.counts(bucket) require.Equal(t, 1, published, bucket.String()) @@ -546,15 +546,15 @@ func TestSchedulerGroupLifecycleEpochPreventsABA(t *testing.T) { groups.set(&Group{ID: groupID, Status: StatusActive, Hydrated: true}, nil) require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), make(map[batchSeenKey]struct{}))) firstActiveTokens := cache.tokens() - require.Len(t, firstActiveTokens, 12) + require.Len(t, firstActiveTokens, 18) groups.set(&Group{ID: groupID, Status: StatusDisabled, Hydrated: true}, nil) require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), make(map[batchSeenKey]struct{}))) groups.set(&Group{ID: groupID, Status: StatusActive, Hydrated: true}, nil) require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), make(map[batchSeenKey]struct{}))) allTokens := cache.tokens() - require.Len(t, allTokens, 24) - require.Greater(t, allTokens[12].Epoch, firstActiveTokens[0].Epoch) + require.Len(t, allTokens, 36) + require.Greater(t, allTokens[18].Epoch, firstActiveTokens[0].Epoch) require.ErrorIs(t, cache.SetSnapshot(context.Background(), firstActiveTokens[0].Bucket, firstActiveTokens[0], nil), ErrSchedulerBucketWriteFenced) } @@ -571,11 +571,11 @@ func TestSchedulerGroupLifecycleSeenIsIndependentAndDeduplicatesGroupEvents(t *t require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), seen)) require.Equal(t, 1, groups.callCount()) - require.Equal(t, 7, accounts.callCount()) + require.Equal(t, 10, accounts.callCount()) requireLifecycleSeen(t, seen, groupID) require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), seen)) require.Equal(t, 1, groups.callCount()) - require.Equal(t, 7, accounts.callCount()) + require.Equal(t, 10, accounts.callCount()) } func TestSchedulerGroupLifecycleFailuresDoNotMarkSeen(t *testing.T) { diff --git a/backend/internal/service/scheduler_snapshot_retirement_test.go b/backend/internal/service/scheduler_snapshot_retirement_test.go index 3d2417975..6fa2b7ddc 100644 --- a/backend/internal/service/scheduler_snapshot_retirement_test.go +++ b/backend/internal/service/scheduler_snapshot_retirement_test.go @@ -176,7 +176,7 @@ func TestSchedulerFullRebuildCapturesAllRegistryTokensBeforeDBLoad(t *testing.T) } captures, reopens := cache.captureAndReopenCounts() - require.Equal(t, 24, captures, "group0 and active-group canonical tokens must be captured before the first DB load") + require.Equal(t, 36, captures, "group0 and active-group canonical tokens must be captured before the first DB load") require.Zero(t, reopens) require.NoError(t, cache.RetireBucket(context.Background(), queued)) _, err := cache.ReopenBucket(context.Background(), queued) diff --git a/backend/internal/service/scheduler_snapshot_service.go b/backend/internal/service/scheduler_snapshot_service.go index 9655f54f2..e38d0291b 100644 --- a/backend/internal/service/scheduler_snapshot_service.go +++ b/backend/internal/service/scheduler_snapshot_service.go @@ -587,7 +587,7 @@ func (s *SchedulerSnapshotService) handleBulkAccountEvent(ctx context.Context, p rebuildGroupIDs = append(rebuildGroupIDs, gid) } - // 缺失账户无法确定原平台,保留五平台重建以避免遗留旧快照。 + // 缺失账户无法确定原平台,保留全平台重建以避免遗留旧快照。 if !allAccountsFound { return s.rebuildByGroupIDs(ctx, rebuildGroupIDs, "account_bulk_change", seen) } @@ -609,7 +609,7 @@ func (s *SchedulerSnapshotService) handleBulkAccountEvent(ctx context.Context, p } accountGroupIDs := s.normalizeGroupIDs(account.GroupIDs) switch account.Platform { - case PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformGrok: + case PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek: addPlatformGroups(account.Platform, accountGroupIDs) case PlatformAntigravity: // 批量更新可能刚关闭 mixed_scheduling,仍需清理两个兼容平台的旧快照。 @@ -824,8 +824,8 @@ func (s *SchedulerSnapshotService) rebuildByAccount(ctx context.Context, account return s.rebuildBuckets(ctx, buckets, reason) } -func schedulerSnapshotPlatforms() [5]string { - return [5]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok} +func schedulerSnapshotPlatforms() [8]string { + return [8]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek} } // 生命周期辅助函数有意排除 group0;full rebuild 构造 group0 canonical 集时必须显式调用 canonical helper。 @@ -837,7 +837,7 @@ func schedulerBucketsForGroup(groupID int64) []SchedulerBucket { } func schedulerCanonicalBuckets(groupID int64) []SchedulerBucket { - buckets := make([]SchedulerBucket, 0, 12) + buckets := make([]SchedulerBucket, 0, 18) for _, platform := range schedulerSnapshotPlatforms() { buckets = append(buckets, SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeSingle}, @@ -855,7 +855,7 @@ func (s *SchedulerSnapshotService) rebuildByGroupIDs(ctx context.Context, groupI if len(groupIDs) == 0 { return nil } - buckets := make([]SchedulerBucket, 0, len(groupIDs)*12) + buckets := make([]SchedulerBucket, 0, len(groupIDs)*18) for _, platform := range schedulerSnapshotPlatforms() { buckets = append(buckets, s.bucketsForPlatform(platform, groupIDs, seen)...) } diff --git a/backend/internal/service/upstream_billing_probe.go b/backend/internal/service/upstream_billing_probe.go index c6a62bab0..7255464c0 100644 --- a/backend/internal/service/upstream_billing_probe.go +++ b/backend/internal/service/upstream_billing_probe.go @@ -597,6 +597,9 @@ func (s *UpstreamBillingProbeService) probeLoadedAccount(ctx context.Context, ac return s.persistProbeFailure(ctx, account, intervalMinutes, now, 0, "missing_api_key", 0) } baseURL := account.GetCredential("base_url") + if account.IsCNProvider() && account.IsAdaptiveAPIProtocol() { + baseURL = account.GetCNProtocolBaseURL(APIProtocolChatCompletions) + } if account.Platform == PlatformOpenAI { if baseURL == "" { // 保持官方语义:OpenAI 账号无自定义 base 时探官方域(404 → unsupported)。 diff --git a/backend/internal/service/upstream_billing_probe_test.go b/backend/internal/service/upstream_billing_probe_test.go index e032e9aa1..9a5f774f4 100644 --- a/backend/internal/service/upstream_billing_probe_test.go +++ b/backend/internal/service/upstream_billing_probe_test.go @@ -340,6 +340,38 @@ func TestUpstreamBillingProbeSuccessPersistsSanitizedSnapshot(t *testing.T) { require.Equal(t, snapshot.Status, persisted.Status) } +func TestUpstreamBillingProbeAdaptiveCNUsesChatProtocolBaseURL(t *testing.T) { + account := &Account{ + ID: 18, + Platform: PlatformKimi, + Type: AccountTypeAPIKey, + Status: StatusActive, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-sensitive", + "api_protocol": APIProtocolAdaptive, + "base_url": "https://legacy-relay.example/v1", + "api_base_urls": map[string]any{ + APIProtocolChatCompletions: "https://chat-relay.example/v1", + }, + }, + Extra: map[string]any{UpstreamBillingProbeEnabledExtraKey: true}, + } + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}} + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: upstreamBillingProbeValidBody(), + }} + svc := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{}) + + snapshot, err := svc.ProbeAccount(context.Background(), account.ID) + + require.NoError(t, err) + require.Equal(t, UpstreamBillingProbeStatusOK, snapshot.Status) + require.Equal(t, "https://chat-relay.example/v1/sub2api/billing", upstream.lastReq.URL.String()) +} + func TestUpstreamBillingProbeSyncsResolvedRateForAllAPIKeyPlatforms(t *testing.T) { for _, platform := range []string{ PlatformOpenAI, diff --git a/backend/internal/service/upstream_path_guard_test.go b/backend/internal/service/upstream_path_guard_test.go index e04781e6a..f915ecd39 100644 --- a/backend/internal/service/upstream_path_guard_test.go +++ b/backend/internal/service/upstream_path_guard_test.go @@ -134,6 +134,7 @@ func TestOpenAIResponsesRequestPathSuffixRejectsNonConformingSubpaths(t *testing for path, want := range map[string]string{ "/v1/responses": "", "/v1/responses/compact": "/compact", + "/v1/responses/input_tokens": "/input_tokens", "/responses/compact/": "/compact", "/backend-api/codex/responses/compact": "/compact", } { @@ -145,6 +146,15 @@ func TestOpenAIResponsesRequestPathSuffixRejectsNonConformingSubpaths(t *testing } } +func TestIsOpenAIResponsesInputTokensRequestPath(t *testing.T) { + for _, path := range []string{"/v1/responses/input_tokens", "/responses/input_tokens", "/backend-api/codex/responses/input_tokens"} { + c := newResponsesSuffixTestContext(t, path) + require.True(t, IsOpenAIResponsesInputTokensRequestPath(c), "path=%s", path) + } + c := newResponsesSuffixTestContext(t, "/v1/responses/compact") + require.False(t, IsOpenAIResponsesInputTokensRequestPath(c)) +} + func TestIsOpenAIResponsesCompactPathUsesLegacyEndpointShape(t *testing.T) { legacyPaths := []string{ "/v1/responses/compact", diff --git a/backend/internal/service/usage_log.go b/backend/internal/service/usage_log.go index 7a41555ec..b6dc2a9d0 100644 --- a/backend/internal/service/usage_log.go +++ b/backend/internal/service/usage_log.go @@ -128,7 +128,8 @@ type UsageLog struct { BillingTier *string // BillingMode 计费模式:token/image BillingMode *string - // ServiceTier records the OpenAI service tier used for billing, e.g. "priority" / "flex". + // ServiceTier records the billable request tier, e.g. OpenAI "priority" / "flex" + // or Anthropic "fast". ServiceTier *string // ReasoningEffort is the request's reasoning effort level. // OpenAI: "low" / "medium" / "high" / "xhigh"; Claude: "low" / "medium" / "high" / "max". diff --git a/backend/internal/service/usage_log_helpers.go b/backend/internal/service/usage_log_helpers.go index b431b50ae..deb102e73 100644 --- a/backend/internal/service/usage_log_helpers.go +++ b/backend/internal/service/usage_log_helpers.go @@ -10,6 +10,13 @@ func optionalTrimmedStringPtr(raw string) *string { return &trimmed } +func optionalStringValue(value *string) string { + if value == nil { + return "" + } + return strings.TrimSpace(*value) +} + func forwardResultBillingModel(requestedModel, upstreamModel string) string { if trimmed := strings.TrimSpace(requestedModel); trimmed != "" { return trimmed diff --git a/backend/internal/testutil/stubs.go b/backend/internal/testutil/stubs.go index 9890ec14a..339d57d86 100644 --- a/backend/internal/testutil/stubs.go +++ b/backend/internal/testutil/stubs.go @@ -118,6 +118,13 @@ func (c StubGatewayCache) ReleaseGrokVideoBilled(_ context.Context, _ string) er return nil } +func (c StubGatewayCache) SetReasoningContent(_ context.Context, _ string, _ string, _ time.Duration) error { + return nil +} +func (c StubGatewayCache) GetReasoningContent(_ context.Context, _ string) (string, error) { + return "", service.ErrReasoningContentNotFound +} + // ============================================================ // StubSessionLimitCache — service.SessionLimitCache 的空实现 // ============================================================ diff --git a/backend/migrations/226_add_usage_log_effective_model_indexes_notx.sql b/backend/migrations/226_add_usage_log_effective_model_indexes_notx.sql new file mode 100644 index 000000000..7bb6250fb --- /dev/null +++ b/backend/migrations/226_add_usage_log_effective_model_indexes_notx.sql @@ -0,0 +1,13 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_effective_requested_model_created + ON usage_logs ( + (COALESCE(NULLIF(BTRIM(requested_model), ''), model)), + created_at DESC, + id DESC + ); + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_effective_upstream_model_created + ON usage_logs ( + (COALESCE(NULLIF(BTRIM(upstream_model), ''), model)), + created_at DESC, + id DESC + ); diff --git a/backend/migrations/227_composite_routes_add_cn_providers.sql b/backend/migrations/227_composite_routes_add_cn_providers.sql new file mode 100644 index 000000000..ec4b22824 --- /dev/null +++ b/backend/migrations/227_composite_routes_add_cn_providers.sql @@ -0,0 +1,8 @@ +-- Allow Composite model routes to target the three concrete CN providers. +ALTER TABLE composite_model_routes + DROP CONSTRAINT IF EXISTS composite_model_routes_target_platform_check; + +ALTER TABLE composite_model_routes + ADD CONSTRAINT composite_model_routes_target_platform_check + CHECK (target_platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', + 'kimi', 'zhipu', 'deepseek')); diff --git a/backend/migrations/228_channel_pricing_multipliers.sql b/backend/migrations/228_channel_pricing_multipliers.sql new file mode 100644 index 000000000..33322ba1a --- /dev/null +++ b/backend/migrations/228_channel_pricing_multipliers.sql @@ -0,0 +1,56 @@ +ALTER TABLE channel_model_pricing + ADD COLUMN IF NOT EXISTS fast_multiplier NUMERIC(12,6), + ADD COLUMN IF NOT EXISTS flex_multiplier NUMERIC(12,6); + +ALTER TABLE channel_pricing_intervals + ADD COLUMN IF NOT EXISTS input_multiplier NUMERIC(12,6), + ADD COLUMN IF NOT EXISTS output_multiplier NUMERIC(12,6), + ADD COLUMN IF NOT EXISTS cache_write_multiplier NUMERIC(12,6), + ADD COLUMN IF NOT EXISTS cache_read_multiplier NUMERIC(12,6); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_model_pricing_fast_multiplier_positive' AND conrelid = 'channel_model_pricing'::regclass) THEN + ALTER TABLE channel_model_pricing + ADD CONSTRAINT channel_model_pricing_fast_multiplier_positive + CHECK (fast_multiplier IS NULL OR fast_multiplier > 0); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_model_pricing_flex_multiplier_positive' AND conrelid = 'channel_model_pricing'::regclass) THEN + ALTER TABLE channel_model_pricing + ADD CONSTRAINT channel_model_pricing_flex_multiplier_positive + CHECK (flex_multiplier IS NULL OR flex_multiplier > 0); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_pricing_intervals_input_multiplier_positive' AND conrelid = 'channel_pricing_intervals'::regclass) THEN + ALTER TABLE channel_pricing_intervals + ADD CONSTRAINT channel_pricing_intervals_input_multiplier_positive + CHECK (input_multiplier IS NULL OR input_multiplier > 0); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_pricing_intervals_output_multiplier_positive' AND conrelid = 'channel_pricing_intervals'::regclass) THEN + ALTER TABLE channel_pricing_intervals + ADD CONSTRAINT channel_pricing_intervals_output_multiplier_positive + CHECK (output_multiplier IS NULL OR output_multiplier > 0); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_pricing_intervals_cache_write_multiplier_positive' AND conrelid = 'channel_pricing_intervals'::regclass) THEN + ALTER TABLE channel_pricing_intervals + ADD CONSTRAINT channel_pricing_intervals_cache_write_multiplier_positive + CHECK (cache_write_multiplier IS NULL OR cache_write_multiplier > 0); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_pricing_intervals_cache_read_multiplier_positive' AND conrelid = 'channel_pricing_intervals'::regclass) THEN + ALTER TABLE channel_pricing_intervals + ADD CONSTRAINT channel_pricing_intervals_cache_read_multiplier_positive + CHECK (cache_read_multiplier IS NULL OR cache_read_multiplier > 0); + END IF; +END $$; + +COMMENT ON COLUMN channel_model_pricing.fast_multiplier IS + 'Fast/priority service tier multiplier applied to the selected standard channel price'; +COMMENT ON COLUMN channel_model_pricing.flex_multiplier IS + 'Flex service tier multiplier applied to the selected standard channel price'; +COMMENT ON COLUMN channel_pricing_intervals.input_multiplier IS + 'Interval input multiplier applied to the channel base input price when input_price is NULL'; +COMMENT ON COLUMN channel_pricing_intervals.output_multiplier IS + 'Interval output multiplier applied to the channel base output price when output_price is NULL'; +COMMENT ON COLUMN channel_pricing_intervals.cache_write_multiplier IS + 'Interval cache-write multiplier applied to the channel base cache-write price when cache_write_price is NULL'; +COMMENT ON COLUMN channel_pricing_intervals.cache_read_multiplier IS + 'Interval cache-read multiplier applied to the channel base cache-read price when cache_read_price is NULL'; diff --git a/backend/migrations/channel_pricing_multipliers_migration_test.go b/backend/migrations/channel_pricing_multipliers_migration_test.go new file mode 100644 index 000000000..96ff90f53 --- /dev/null +++ b/backend/migrations/channel_pricing_multipliers_migration_test.go @@ -0,0 +1,43 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestChannelPricingMultipliersMigration(t *testing.T) { + content, err := FS.ReadFile("228_channel_pricing_multipliers.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + for _, column := range []string{ + "fast_multiplier NUMERIC(12,6)", + "flex_multiplier NUMERIC(12,6)", + "input_multiplier NUMERIC(12,6)", + "output_multiplier NUMERIC(12,6)", + "cache_write_multiplier NUMERIC(12,6)", + "cache_read_multiplier NUMERIC(12,6)", + } { + require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS "+column) + } + + constraints := []struct { + table string + name string + column string + }{ + {"channel_model_pricing", "channel_model_pricing_fast_multiplier_positive", "fast_multiplier"}, + {"channel_model_pricing", "channel_model_pricing_flex_multiplier_positive", "flex_multiplier"}, + {"channel_pricing_intervals", "channel_pricing_intervals_input_multiplier_positive", "input_multiplier"}, + {"channel_pricing_intervals", "channel_pricing_intervals_output_multiplier_positive", "output_multiplier"}, + {"channel_pricing_intervals", "channel_pricing_intervals_cache_write_multiplier_positive", "cache_write_multiplier"}, + {"channel_pricing_intervals", "channel_pricing_intervals_cache_read_multiplier_positive", "cache_read_multiplier"}, + } + for _, constraint := range constraints { + require.Contains(t, sql, "conname = '"+constraint.name+"' AND conrelid = '"+constraint.table+"'::regclass") + require.Contains(t, sql, "ALTER TABLE "+constraint.table+" ADD CONSTRAINT "+constraint.name+ + " CHECK ("+constraint.column+" IS NULL OR "+constraint.column+" > 0)") + } +} diff --git a/backend/migrations/composite_routes_cn_providers_migration_test.go b/backend/migrations/composite_routes_cn_providers_migration_test.go new file mode 100644 index 000000000..7b6bba747 --- /dev/null +++ b/backend/migrations/composite_routes_cn_providers_migration_test.go @@ -0,0 +1,18 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCompositeRoutesCNProvidersMigration(t *testing.T) { + content, err := FS.ReadFile("227_composite_routes_add_cn_providers.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + require.Contains(t, sql, "DROP CONSTRAINT IF EXISTS composite_model_routes_target_platform_check") + require.Contains(t, sql, + "CHECK (target_platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'kimi', 'zhipu', 'deepseek'))") +} diff --git a/backend/migrations/usage_log_effective_model_index_test.go b/backend/migrations/usage_log_effective_model_index_test.go new file mode 100644 index 000000000..9505889dc --- /dev/null +++ b/backend/migrations/usage_log_effective_model_index_test.go @@ -0,0 +1,19 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUsageLogEffectiveModelIndexesMigration(t *testing.T) { + content, err := FS.ReadFile("226_add_usage_log_effective_model_indexes_notx.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + require.Contains(t, sql, "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_effective_requested_model_created") + require.Contains(t, sql, "(COALESCE(NULLIF(BTRIM(requested_model), ''), model)), created_at DESC, id DESC") + require.Contains(t, sql, "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_effective_upstream_model_created") + require.Contains(t, sql, "(COALESCE(NULLIF(BTRIM(upstream_model), ''), model)), created_at DESC, id DESC") +} diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index cd71e4a3d..b5eb16aa2 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -186,6 +186,16 @@ security: # Allow skipping TLS verification for proxy probe (debug only) # 允许代理探测时跳过 TLS 证书验证(仅用于调试) insecure_skip_verify: false + # Optional ordered probe targets. Leave empty to use the built-in ip-api/ipify fallback. + # parser supports: ip-api, ipify, chatgpt-trace + # 可选的有序探测目标。留空时使用内置 ip-api/ipify 回退。 + # parser 支持:ip-api、ipify、chatgpt-trace + urls: [] + # urls: + # - url: "https://chatgpt.com/cdn-cgi/trace" + # parser: "chatgpt-trace" + # - url: "https://api64.ipify.org?format=json" + # parser: "ipify" proxy_fallback: # Allow auxiliary services (update check, pricing data) to fallback to direct # connection when proxy initialization fails. Does NOT affect AI gateway connections. @@ -228,33 +238,6 @@ gateway: # Enable Gemini upstream response header debug logs (default: false) # 是否开启 Gemini 上游响应头调试日志(默认 false) gemini_debug_response_headers: false - # Sora max request body size in bytes (0=use max_body_size) - # Sora 请求体最大字节数(0=使用 max_body_size) - sora_max_body_size: 268435456 - # Sora stream timeout (seconds, 0=disable) - # Sora 流式请求总超时(秒,0=禁用) - sora_stream_timeout_seconds: 900 - # Sora non-stream timeout (seconds, 0=disable) - # Sora 非流式请求超时(秒,0=禁用) - sora_request_timeout_seconds: 180 - # Sora stream enforcement mode: force/error - # Sora stream 强制策略:force/error - sora_stream_mode: "force" - # Sora model filters - # Sora 模型过滤配置 - sora_model_filters: - # Hide prompt-enhance models by default - # 默认隐藏 prompt-enhance 模型 - hide_prompt_enhance: true - # Require API key for /sora/media proxy (default: false) - # /sora/media 是否强制要求 API Key(默认 true) - sora_media_require_api_key: true - # Sora media temporary signing key (empty disables signed URL) - # Sora 媒体临时签名密钥(为空则禁用签名) - sora_media_signing_key: "" - # Signed URL TTL seconds (<=0 disables) - # 临时签名 URL 有效期(秒,<=0 表示禁用) - sora_media_signed_url_ttl_seconds: 900 # Connection pool isolation strategy: # 连接池隔离策略: # - proxy: Isolate by proxy, same proxy shares connection pool (suitable for few proxies, many accounts) @@ -654,112 +637,9 @@ log: # 之后每 N 条保留 1 条 thereafter: 100 -# ============================================================================= -# Sora Direct Client Configuration -# Sora 直连配置 -# ============================================================================= -sora: - client: - # Sora backend base URL - # Sora 上游 Base URL - base_url: "https://sora.chatgpt.com/backend" - # Request timeout (seconds) - # 请求超时(秒) - timeout_seconds: 120 - # Max retries for upstream requests - # 上游请求最大重试次数 - max_retries: 3 - # Account+proxy cooldown window after Cloudflare challenge (seconds, 0 to disable) - # Cloudflare challenge 后按账号+代理冷却窗口(秒,0 表示关闭) - cloudflare_challenge_cooldown_seconds: 900 - # Poll interval (seconds) - # 轮询间隔(秒) - poll_interval_seconds: 2 - # Max poll attempts - # 最大轮询次数 - max_poll_attempts: 600 - # Recent task query limit (image) - # 最近任务查询数量(图片轮询) - recent_task_limit: 50 - # Recent task query max limit (fallback) - # 最近任务查询最大数量(回退) - recent_task_limit_max: 200 - # Enable debug logs for Sora upstream requests - # 启用 Sora 直连调试日志 - # 调试日志会输出上游请求尝试、重试、响应摘要;Authorization/openai-sentinel-token 等敏感头会自动脱敏 - debug: false - # Allow Sora client to fetch token via OpenAI token provider - # 是否允许 Sora 客户端通过 OpenAI token provider 取 token(默认 false,避免误走 OpenAI 刷新链路) - use_openai_token_provider: false - # Optional custom headers (key-value) - # 额外请求头(键值对) - headers: {} - # Default User-Agent for Sora requests - # Sora 默认 User-Agent - user_agent: "Sora/1.2026.007 (Android 15; 24122RKC7C; build 2600700)" - # Disable TLS fingerprint for Sora upstream - # 关闭 Sora 上游 TLS 指纹伪装 - disable_tls_fingerprint: false - # curl_cffi sidecar for Sora only (required) - # 仅 Sora 链路使用的 curl_cffi sidecar(必需) - curl_cffi_sidecar: - # Sora 强制通过 sidecar 请求,必须启用 - # Sora is forced to use sidecar only; keep enabled=true - enabled: true - # Sidecar base URL (default endpoint: /request) - # sidecar 基础地址(默认请求端点:/request) - base_url: "http://sora-curl-cffi-sidecar:8080" - # curl_cffi impersonate profile, e.g. chrome131/chrome124/safari18_0 - # curl_cffi 指纹伪装 profile,例如 chrome131/chrome124/safari18_0 - impersonate: "chrome131" - # Sidecar request timeout (seconds) - # sidecar 请求超时(秒) - timeout_seconds: 60 - # Reuse session key per account+proxy to let sidecar persist cookies/session - # 按账号+代理复用 session key,让 sidecar 持久化 cookies/session - session_reuse_enabled: true - # Session TTL in sidecar (seconds) - # sidecar 会话 TTL(秒) - session_ttl_seconds: 3600 - storage: - # Storage type (local only for now) - # 存储类型(首发仅支持 local) - type: "local" - # Local base path; empty uses /app/data/sora - # 本地存储基础路径;为空使用 /app/data/sora - local_path: "" - # Fallback to upstream URL when download fails - # 下载失败时回退到上游 URL - fallback_to_upstream: true - # Max concurrent downloads - # 并发下载上限 - max_concurrent_downloads: 4 - # Download timeout (seconds) - # 下载超时(秒) - download_timeout_seconds: 120 - # Max download bytes - # 最大下载字节数 - max_download_bytes: 209715200 - # Enable debug logs for media storage - # 启用媒体存储调试日志 - debug: false - cleanup: - # Enable cleanup task - # 启用清理任务 - enabled: true - # Retention days - # 保留天数 - retention_days: 7 - # Cron schedule - # Cron 调度表达式 - schedule: "0 3 * * *" - # Token refresh behavior # token 刷新行为控制 token_refresh: - # Whether OpenAI refresh flow is allowed to sync linked Sora accounts - # 是否允许 OpenAI 刷新流程同步覆盖 linked_openai_account_id 关联的 Sora 账号 token - sync_linked_sora_accounts: false # Candidate accounts loaded per cursor page (maximum 1000) # 每个游标分页加载的候选账号数量(最大 1000) candidate_page_size: 200 diff --git a/docs/COMPOSITE_GROUPS.md b/docs/COMPOSITE_GROUPS.md index c0e849e53..de6d0cf07 100644 --- a/docs/COMPOSITE_GROUPS.md +++ b/docs/COMPOSITE_GROUPS.md @@ -53,6 +53,12 @@ route's `upstream_model` before dispatch. For Gemini native paths such as `/v1beta/models/{model}:generateContent`, the gateway resolves `{model}` and the handler forwards the resolved upstream model. +Codex Alpha Search and Live requests use the `responses` route domain. Live +requests resolve the model from `session.model`, including multipart `session` +payloads, and apply the configured `upstream_model` before dispatch. +Codex model manifest requests reuse the existing OpenAI account selection and +failover path within the Composite group. + ## Built-In Detection Composite routing detects common public model IDs and provider-prefixed IDs: diff --git a/frontend/src/api/admin/channelMonitor.ts b/frontend/src/api/admin/channelMonitor.ts index f3353ebd4..bf9e1c88f 100644 --- a/frontend/src/api/admin/channelMonitor.ts +++ b/frontend/src/api/admin/channelMonitor.ts @@ -134,7 +134,9 @@ export interface CreateParams { api_key: string /** 缺省 probe;antigravity 仅支持 quota */ check_mode?: CheckMode - /** 配额模式必填:数据源账号(provider 需与账号平台一致) */ + /** 配额模式必填:数据源账号(provider 需与账号平台一致)。 + * update 语义:>0=换绑,0=解绑(切回 probe 模式时前端发 0 清空存量关联); + * create 绝不发 0——后端会把 0 存成 &0 触发外键违约。 */ account_id?: number | null primary_model: string extra_models?: string[] @@ -148,7 +150,8 @@ export interface CreateParams { body_override?: Record | null } -// Update request: api_key 空串 = 不修改;clear_template=true 时把 template_id 置空 +// Update request: api_key 空串 = 不修改;clear_template=true 时把 template_id 置空; +// account_id=0 显式解绑关联账号(null = 不动,见 CreateParams 注释) export type UpdateParams = Partial & { clear_template?: boolean } diff --git a/frontend/src/api/admin/channels.ts b/frontend/src/api/admin/channels.ts index 6556417ce..9ce50bcd0 100644 --- a/frontend/src/api/admin/channels.ts +++ b/frontend/src/api/admin/channels.ts @@ -17,6 +17,10 @@ export interface PricingInterval { output_price: number | null cache_write_price: number | null cache_read_price: number | null + input_multiplier: number | null + output_multiplier: number | null + cache_write_multiplier: number | null + cache_read_multiplier: number | null per_request_price: number | null sort_order: number } @@ -41,6 +45,8 @@ export interface ChannelModelPricing { output_price: number | null cache_write_price: number | null cache_read_price: number | null + fast_multiplier?: number | null + flex_multiplier?: number | null image_input_price: number | null image_output_price: number | null per_request_price: number | null diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index e8159c7bf..057619424 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -602,7 +602,7 @@ - +
@@ -1599,7 +1599,7 @@ const allBillingProbeCapable = computed(() => { ) }) -// 是否全部为 anthropic/openai 平台的 apikey 账号(请求头覆写仅在此条件下显示) +// 是否全部为支持请求头覆写的平台/账号类型 // 所选平台 × 所选类型的全组合均需具备覆写资格(实际选中账号是该组合的子集, // 按交叉积判定偏保守但绝不放行不合资格的账号) const allHeaderOverrideCapable = computed(() => { diff --git a/frontend/src/components/account/CNProviderQuotaCell.vue b/frontend/src/components/account/CNProviderQuotaCell.vue index 9871a8b48..c9f0f2e90 100644 --- a/frontend/src/components/account/CNProviderQuotaCell.vue +++ b/frontend/src/components/account/CNProviderQuotaCell.vue @@ -1,10 +1,14 @@