Merge remote-tracking branch 'upstream/main' into codex-overdraft
# Conflicts: # backend/internal/handler/openai_gateway_handler.go
This commit is contained in:
commit
7b9de6ce02
214 files changed
+9579
-1494
No files matched your search
@@ -148,18 +148,6 @@ Please read the following carefully before using this project:
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
|
||||
<td>Thanks to Claude API for sponsoring this project! <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">Claude API</a> 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 <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">exclusive link</a> and contact customer support to receive free trial credits; invoicing and team onboarding are also supported.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/code0.jpg" alt="code0" width="150"></a></td>
|
||||
<td>Thanks to code0.ai for sponsoring this project! <a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">code0.ai</a> 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 <a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">exclusive link</a> and contact customer support to receive free trial credits and experience a more efficient AI coding workflow.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://nagora.ai/"><img src="assets/partners/logos/nagora.png" alt="Nagora" width="150"></a></td>
|
||||
<td><a href="https://nagora.ai/">Nagora</a> 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.</td>
|
||||
@@ -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
|
||||
|
||||
<a href="https://star-history.com/#Wei-Shaw/sub2api&Date">
|
||||
<a href="https://star-history.dera.page/#Wei-Shaw/sub2api&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=Wei-Shaw/sub2api&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=Wei-Shaw/sub2api&type=Date" />
|
||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=Wei-Shaw/sub2api&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
+4
-43
@@ -183,18 +183,6 @@ docker logs --since 30m sub2api 2>&1 | \
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
|
||||
<td>感谢 Claude API 对本项目的赞助! <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">Claude API</a> 是专注 Claude 模型的官方渠道 API 服务商,基于 Anthropic 官方 Key 与 AWS Bedrock 官方渠道,提供稳定的 Claude Code 与 Agent 应用接入体验,支持 Claude 全系列模型,保留 Tool Use、长上下文等官方能力。服务非逆向、非降智,适合 Claude Code 深度用户、Agent 工程师与企业技术团队使用。通过<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">[专属链接]</a>注册后联系客服,可领取免费测试额度,并支持开票和团队对接。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/code0.jpg" alt="code0" width="150"></a></td>
|
||||
<td>感谢 code0.ai 对本项目的赞助! <a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">code0.ai</a> 是面向开发者与技术团队的 AI 编程工作台,聚合 Claude Code、Codex 等主流 Agent 编程能力,支持代码生成、项目理解、调试修复、代码审查与文档生成等常见研发场景。适合独立开发者、Agent 工程师、开源项目维护者和企业研发团队使用,支持开票和团队对接。通过<a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">[专属链接]</a>注册后联系客服,可领取免费测试额度,体验更高效的 AI 编程工作流。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://nagora.ai/"><img src="assets/partners/logos/nagora.png" alt="Nagora" width="150"></a></td>
|
||||
<td><a href="https://nagora.ai/">Nagora</a> 是专为开发者和团队打造的多模型 AI API 网关。通过一个账户和一枚 API Key,即可统一调用 26+ 款主流文本与图像模型,兼容 OpenAI、Anthropic 与 Gemini 协议,并可无缝接入 Claude Code、Codex、Gemini CLI 等开发工具。平台提供智能路由、自动故障转移、透明计费与统一账单,同时支持预算、限速、并发控制,让个人开发、团队协作和生产环境中的 AI 调用更稳定、更可控。无需改造现有应用,只需替换 Base URL 与 API Key,最快 1 分钟即可完成接入。</td>
|
||||
@@ -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
|
||||
|
||||
<a href="https://star-history.com/#Wei-Shaw/sub2api&Date">
|
||||
<a href="https://star-history.dera.page/#Wei-Shaw/sub2api&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=Wei-Shaw/sub2api&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=Wei-Shaw/sub2api&type=Date" />
|
||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=Wei-Shaw/sub2api&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
+4
-22
@@ -144,18 +144,6 @@ docker compose \
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
|
||||
<td>Claude API のご支援に感謝します!<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">Claude API</a> は Claude モデルに特化した公式チャネルの API サービスプロバイダーで、Anthropic 公式キーと AWS Bedrock 公式チャネルをベースに、Claude Code や Agent アプリケーションへの安定した接続体験を提供します。Claude 全シリーズのモデルに対応し、Tool Use や長文コンテキストなどの公式機能もそのまま利用可能。リバースエンジニアリングやモデル劣化のないサービスで、Claude Code のヘビーユーザー、Agent エンジニア、企業の技術チームに最適です。<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">専用リンク</a>から登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取れます。請求書発行やチーム導入にも対応しています。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/code0.jpg" alt="code0" width="150"></a></td>
|
||||
<td>code0.ai のご支援に感謝します!<a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">code0.ai</a> は開発者と技術チーム向けの AI プログラミングワークベンチで、Claude Code や Codex などの主要な Agent コーディング能力を集約し、コード生成、プロジェクト理解、デバッグと修正、コードレビュー、ドキュメント生成といった一般的な開発シーンをサポートします。個人開発者、Agent エンジニア、OSS メンテナー、企業の開発チームに最適で、請求書発行やチーム導入にも対応。<a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">専用リンク</a>から登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取り、より効率的な AI プログラミングワークフローを体験できます。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://nagora.ai/"><img src="assets/partners/logos/nagora.png" alt="Nagora" width="150"></a></td>
|
||||
<td><a href="https://nagora.ai/">Nagora</a>は、開発者やチーム向けに設計されたマルチモデルAI APIゲートウェイです。1つのアカウントと1つのAPIキーだけで、26種類以上の主要なテキストモデルおよび画像モデルを一元的に利用できます。OpenAI、Anthropic、Geminiの各プロトコルに対応し、Claude Code、Codex、Gemini CLIなどの開発ツールにもシームレスに接続できます。 プラットフォームには、インテリジェントルーティング、自動フェイルオーバー、透明性の高い料金体系、請求の一元管理に加え、予算管理、レート制限、同時実行数の制御機能が備わっています。これにより、個人開発、チームでの共同作業、本番環境におけるAI APIの利用を、より安定的かつ柔軟に管理できます。 既存のアプリケーションを改修する必要はありません。Base URLとAPIキーを置き換えるだけで、最短1分で導入を完了できます。</td>
|
||||
@@ -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/
|
||||
|
||||
## スター履歴
|
||||
|
||||
<a href="https://star-history.com/#Wei-Shaw/sub2api&Date">
|
||||
<a href="https://star-history.dera.page/#Wei-Shaw/sub2api&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=Wei-Shaw/sub2api&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=Wei-Shaw/sub2api&type=Date" />
|
||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=Wei-Shaw/sub2api&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.2 KiB |
@@ -1 +1 @@
|
||||
0.1.178
|
||||
0.1.179
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 验证规则配置的有效性
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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 自动过期)。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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, "")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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, `<html>not an Anthropic stream</html>`),
|
||||
)
|
||||
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"`))
|
||||
}
|
||||
@@ -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 链路,其他账号复用现有主动查询逻辑。
|
||||
// 单个账号失败不会中断整批请求,错误会按账号返回。
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,7 @@ const (
|
||||
APIProtocolChatCompletions = domain.APIProtocolChatCompletions
|
||||
APIProtocolAnthropic = domain.APIProtocolAnthropic
|
||||
APIProtocolResponses = domain.APIProtocolResponses
|
||||
APIProtocolAdaptive = domain.APIProtocolAdaptive
|
||||
)
|
||||
|
||||
// 国产 OpenAI 兼容供应商各模式的默认 base_url。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 时行为不变)
|
||||
|
||||
|
||||
@@ -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), "")
|
||||
|
||||
@@ -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 // 生成的图片数量
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 一致)。
|
||||
|
||||
Loaded 100 of 214 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user