mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 20:12:39 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa23648835 | ||
|
|
1a33dc8cc4 | ||
|
|
3510aa22b5 | ||
|
|
77729e2723 | ||
|
|
05ea883e26 | ||
|
|
6566039bc8 | ||
|
|
e50bffb7e3 | ||
|
|
f2804eb2c8 | ||
|
|
559960865a | ||
|
|
a4fb58e428 | ||
|
|
a668aa8b36 | ||
|
|
8d0b5ede28 | ||
|
|
2786ae9de6 | ||
|
|
34b8bf1a6e | ||
|
|
52c7d88340 | ||
|
|
9e2d97f250 | ||
|
|
bfe0a5a873 | ||
|
|
504919a054 | ||
|
|
0d27f45ead | ||
|
|
65380be9c8 | ||
|
|
d596d0844f | ||
|
|
cd04848b97 | ||
|
|
e377c4358c | ||
|
|
3438580213 | ||
|
|
e93e6368fc | ||
|
|
200b1406d7 | ||
|
|
421a83282a | ||
|
|
a2fb09260a | ||
|
|
aa7a811e66 | ||
|
|
2ac784c51a | ||
|
|
6b39d0b453 | ||
|
|
f218c8d40a | ||
|
|
1cce2b38ee | ||
|
|
9d9ed1cc62 | ||
|
|
2d7767eaf2 | ||
|
|
064d9b74e8 | ||
|
|
e87c47c957 | ||
|
|
bdedd6c544 | ||
|
|
9908d3ca2f | ||
|
|
4df6b0636b | ||
|
|
498e06c582 | ||
|
|
e6722126ba | ||
|
|
e619ca386e | ||
|
|
8224434d21 | ||
|
|
fd55f3248f | ||
|
|
08d6c153b7 | ||
|
|
636d6be690 | ||
|
|
7dcf728461 | ||
|
|
fb409787c6 | ||
|
|
e7caedc22a | ||
|
|
eebf4e3ffb | ||
|
|
c7cf84ad39 | ||
|
|
0681aa2566 | ||
|
|
17747df842 | ||
|
|
3fbce499d8 | ||
|
|
1a40e5690f | ||
|
|
7903716ad1 | ||
|
|
f8eae45048 | ||
|
|
a44a1019f8 | ||
|
|
9fba8ed43b | ||
|
|
7f5e915dd1 | ||
|
|
7e06816571 | ||
|
|
e9e66da5a8 | ||
|
|
94bb9354ce | ||
|
|
95982a508f | ||
|
|
563979e91e | ||
|
|
1cb28fd429 | ||
|
|
aa679efe41 | ||
|
|
e2cfaa46e7 | ||
|
|
593fc93659 | ||
|
|
530fb20f25 | ||
|
|
200602b41b | ||
|
|
e2624fb65c | ||
|
|
cc6a8e5174 | ||
|
|
6d5f027848 | ||
|
|
e21b849a92 | ||
|
|
ba345f1052 | ||
|
|
7c01ec9be8 | ||
|
|
52374af940 | ||
|
|
b6a7b8b7df | ||
|
|
1dc0a09009 | ||
|
|
863667ce67 | ||
|
|
1be69e56aa | ||
|
|
e7c029875c | ||
|
|
9eabd2a5b7 | ||
|
|
57c76584a6 |
@@ -172,6 +172,11 @@ Please read the following carefully before using this project:
|
||||
<td>Thanks to APIMart for sponsoring this project! <a href="https://go.apimart.ai/gh-sub2api">APIMart</a> is a low-cost API platform for AI image and video generation — GPT-Image-2 from $0.006 per image, with 160+ images per dollar. One async API covers both image and video: submit a task, get an ID, and retrieve results via polling or callback. Batch tens of thousands of images without timeouts, and switch models without changing code. Pay as you go with no monthly fee — <a href="https://go.apimart.ai/gh-sub2api">sign up here</a> to get started.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.axisnow.io/"><img src="assets/partners/logos/axisnow.jpg" alt="AxisNow" width="150"></a></td>
|
||||
<td>Thanks to AxisNow for sponsoring this project! <a href="https://www.axisnow.io/">AxisNow</a> protects and accelerates websites and APIs, delivering an optimal access experience across mainland China and globally, while extending acceleration and security capabilities to native/mobile apps through client SDKs — <strong>self-hosted private-deployment CDN</strong> | <strong>subscription-based DDoS-protected CDN</strong> | <strong>autonomous, flexibly composable CDN network</strong>.</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -175,6 +175,11 @@
|
||||
<td>感谢 APIMart 赞助了本项目!<a href="https://go.apimart.ai/gh-sub2api">APIMart</a> 是专注于 AI 图片/视频生成的低价 API 平台,GPT-Image-2 低至 $0.006/张,1 美元可生成 160+ 张图片。图片、视频一套异步 API 通吃:提交任务获取 ID,通过轮询或回调获取结果;批量生成上万张图片也不会超时,切换模型无需修改代码。按量付费、无月费,通过<a href="https://go.apimart.ai/gh-sub2api">此注册链接</a>注册即可开始使用。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.axisnow.io/"><img src="assets/partners/logos/axisnow.jpg" alt="AxisNow" width="150"></a></td>
|
||||
<td>感谢 AxisNow 赞助了本项目!<a href="https://www.axisnow.io/">AxisNow</a> 保护并加速网站与 API,兼顾中国大陆及全球的访问体验,并通过客户端 SDK,将加速与安全能力延伸至原生/移动 App — <strong>自建私有部署 CDN</strong>|<strong>订阅式高防 CDN</strong>|<strong>自主可控、灵活组合的 CDN 网络</strong>。</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## 项目概述
|
||||
|
||||
@@ -174,6 +174,11 @@
|
||||
<td>APIMart のご支援に感謝します!<a href="https://go.apimart.ai/gh-sub2api">APIMart</a> は AI 画像・動画生成に特化した低価格 API プラットフォームです。GPT-Image-2 は 1 枚 $0.006 から、1 ドルで 160 枚以上の画像を生成できます。画像と動画の両方に対応する非同期 API を 1 つで利用でき、タスクを送信して ID を取得し、ポーリングまたはコールバックで結果を取得できます。数万枚規模のバッチ処理でもタイムアウトせず、モデルを変更してもコードの変更は不要です。月額料金なしの従量課金制で、<a href="https://go.apimart.ai/gh-sub2api">こちらの登録リンク</a>からすぐに利用を開始できます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.axisnow.io/"><img src="assets/partners/logos/axisnow.jpg" alt="AxisNow" width="150"></a></td>
|
||||
<td>AxisNow のご支援に感謝します!<a href="https://www.axisnow.io/">AxisNow</a> はウェブサイトと API を保護・高速化し、中国本土および世界中で快適なアクセス体験を実現します。クライアント SDK により、ネイティブ/モバイルアプリにも高速化・セキュリティ機能を拡張できます — <strong>自社構築・プライベートデプロイ CDN</strong>|<strong>サブスクリプション型高防御 CDN</strong>|<strong>自律制御で柔軟に組み合わせられる CDN ネットワーク</strong>。</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## 概要
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
@@ -1 +1 @@
|
||||
0.1.183
|
||||
0.1.185
|
||||
|
||||
+35
-2
@@ -122,6 +122,10 @@ type Group struct {
|
||||
AllowMessagesDispatch bool `json:"allow_messages_dispatch,omitempty"`
|
||||
// 是否允许此 OpenAI 分组访问 Live 接口
|
||||
AllowLive bool `json:"allow_live,omitempty"`
|
||||
// 是否强制此 OpenAI/Composite 分组请求使用 service_tier=priority
|
||||
ForceOpenaiFast bool `json:"force_openai_fast,omitempty"`
|
||||
// 是否让此 OpenAI/Composite 分组的 Fast 请求按 Standard 价格计费
|
||||
FreeOpenaiFast bool `json:"free_openai_fast,omitempty"`
|
||||
// 仅允许非 apikey 类型账号关联到此分组
|
||||
RequireOauthOnly bool `json:"require_oauth_only,omitempty"`
|
||||
// 调度时仅允许 privacy 已成功设置的账号
|
||||
@@ -136,6 +140,8 @@ type Group struct {
|
||||
RpmLimit int `json:"rpm_limit,omitempty"`
|
||||
// OpenAI reasoning effort 上限;可选 minimal/low/medium/high/xhigh/max
|
||||
MaxReasoningEffort string `json:"max_reasoning_effort,omitempty"`
|
||||
// 超过推理强度上限时的访问控制:downgrade 自动降档,deny 拒绝访问
|
||||
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit,omitempty"`
|
||||
// OpenAI reasoning effort 自定义精确映射;先映射再应用上限
|
||||
ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings,omitempty"`
|
||||
// 是否启用利润控制:调度时仅允许账号计费倍率满足毛利率要求的账号进入候选池
|
||||
@@ -252,13 +258,13 @@ func (*Group) scanValues(columns []string) ([]any, error) {
|
||||
switch columns[i] {
|
||||
case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings:
|
||||
values[i] = new([]byte)
|
||||
case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldLongContextPricingEnabled, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled:
|
||||
case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldLongContextPricingEnabled, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldForceOpenaiFast, group.FieldFreeOpenaiFast, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled:
|
||||
values[i] = new(sql.NullBool)
|
||||
case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p, group.FieldWebSearchPricePerCall, group.FieldSearchPricePer1k, group.FieldAudioRealtimePricePerMin, group.FieldAudioTtsPricePerMillionChars, group.FieldAudioSttPricePerHour, group.FieldProfitMinMargin, group.FieldProfitSafetyBuffer:
|
||||
values[i] = new(sql.NullFloat64)
|
||||
case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit:
|
||||
values[i] = new(sql.NullInt64)
|
||||
case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldDuplicateOperationID, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel, group.FieldMaxReasoningEffort:
|
||||
case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldDuplicateOperationID, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel, group.FieldMaxReasoningEffort, group.FieldMaxReasoningEffortOverLimit:
|
||||
values[i] = new(sql.NullString)
|
||||
case group.FieldCreatedAt, group.FieldUpdatedAt, group.FieldDeletedAt:
|
||||
values[i] = new(sql.NullTime)
|
||||
@@ -616,6 +622,18 @@ func (_m *Group) assignValues(columns []string, values []any) error {
|
||||
} else if value.Valid {
|
||||
_m.AllowLive = value.Bool
|
||||
}
|
||||
case group.FieldForceOpenaiFast:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field force_openai_fast", values[i])
|
||||
} else if value.Valid {
|
||||
_m.ForceOpenaiFast = value.Bool
|
||||
}
|
||||
case group.FieldFreeOpenaiFast:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field free_openai_fast", values[i])
|
||||
} else if value.Valid {
|
||||
_m.FreeOpenaiFast = value.Bool
|
||||
}
|
||||
case group.FieldRequireOauthOnly:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field require_oauth_only", values[i])
|
||||
@@ -662,6 +680,12 @@ func (_m *Group) assignValues(columns []string, values []any) error {
|
||||
} else if value.Valid {
|
||||
_m.MaxReasoningEffort = value.String
|
||||
}
|
||||
case group.FieldMaxReasoningEffortOverLimit:
|
||||
if value, ok := values[i].(*sql.NullString); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field max_reasoning_effort_over_limit", values[i])
|
||||
} else if value.Valid {
|
||||
_m.MaxReasoningEffortOverLimit = value.String
|
||||
}
|
||||
case group.FieldReasoningEffortMappings:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field reasoning_effort_mappings", values[i])
|
||||
@@ -955,6 +979,12 @@ func (_m *Group) String() string {
|
||||
builder.WriteString("allow_live=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.AllowLive))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("force_openai_fast=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.ForceOpenaiFast))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("free_openai_fast=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.FreeOpenaiFast))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("require_oauth_only=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.RequireOauthOnly))
|
||||
builder.WriteString(", ")
|
||||
@@ -976,6 +1006,9 @@ func (_m *Group) String() string {
|
||||
builder.WriteString("max_reasoning_effort=")
|
||||
builder.WriteString(_m.MaxReasoningEffort)
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("max_reasoning_effort_over_limit=")
|
||||
builder.WriteString(_m.MaxReasoningEffortOverLimit)
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("reasoning_effort_mappings=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.ReasoningEffortMappings))
|
||||
builder.WriteString(", ")
|
||||
|
||||
@@ -118,6 +118,10 @@ const (
|
||||
FieldAllowMessagesDispatch = "allow_messages_dispatch"
|
||||
// FieldAllowLive holds the string denoting the allow_live field in the database.
|
||||
FieldAllowLive = "allow_live"
|
||||
// FieldForceOpenaiFast holds the string denoting the force_openai_fast field in the database.
|
||||
FieldForceOpenaiFast = "force_openai_fast"
|
||||
// FieldFreeOpenaiFast holds the string denoting the free_openai_fast field in the database.
|
||||
FieldFreeOpenaiFast = "free_openai_fast"
|
||||
// FieldRequireOauthOnly holds the string denoting the require_oauth_only field in the database.
|
||||
FieldRequireOauthOnly = "require_oauth_only"
|
||||
// FieldRequirePrivacySet holds the string denoting the require_privacy_set field in the database.
|
||||
@@ -132,6 +136,8 @@ const (
|
||||
FieldRpmLimit = "rpm_limit"
|
||||
// FieldMaxReasoningEffort holds the string denoting the max_reasoning_effort field in the database.
|
||||
FieldMaxReasoningEffort = "max_reasoning_effort"
|
||||
// FieldMaxReasoningEffortOverLimit holds the string denoting the max_reasoning_effort_over_limit field in the database.
|
||||
FieldMaxReasoningEffortOverLimit = "max_reasoning_effort_over_limit"
|
||||
// FieldReasoningEffortMappings holds the string denoting the reasoning_effort_mappings field in the database.
|
||||
FieldReasoningEffortMappings = "reasoning_effort_mappings"
|
||||
// FieldProfitControlEnabled holds the string denoting the profit_control_enabled field in the database.
|
||||
@@ -266,6 +272,8 @@ var Columns = []string{
|
||||
FieldSortOrder,
|
||||
FieldAllowMessagesDispatch,
|
||||
FieldAllowLive,
|
||||
FieldForceOpenaiFast,
|
||||
FieldFreeOpenaiFast,
|
||||
FieldRequireOauthOnly,
|
||||
FieldRequirePrivacySet,
|
||||
FieldDefaultMappedModel,
|
||||
@@ -273,6 +281,7 @@ var Columns = []string{
|
||||
FieldModelsListConfig,
|
||||
FieldRpmLimit,
|
||||
FieldMaxReasoningEffort,
|
||||
FieldMaxReasoningEffortOverLimit,
|
||||
FieldReasoningEffortMappings,
|
||||
FieldProfitControlEnabled,
|
||||
FieldProfitMinMargin,
|
||||
@@ -386,6 +395,10 @@ var (
|
||||
DefaultAllowMessagesDispatch bool
|
||||
// DefaultAllowLive holds the default value on creation for the "allow_live" field.
|
||||
DefaultAllowLive bool
|
||||
// DefaultForceOpenaiFast holds the default value on creation for the "force_openai_fast" field.
|
||||
DefaultForceOpenaiFast bool
|
||||
// DefaultFreeOpenaiFast holds the default value on creation for the "free_openai_fast" field.
|
||||
DefaultFreeOpenaiFast bool
|
||||
// DefaultRequireOauthOnly holds the default value on creation for the "require_oauth_only" field.
|
||||
DefaultRequireOauthOnly bool
|
||||
// DefaultRequirePrivacySet holds the default value on creation for the "require_privacy_set" field.
|
||||
@@ -404,6 +417,10 @@ var (
|
||||
DefaultMaxReasoningEffort string
|
||||
// MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save.
|
||||
MaxReasoningEffortValidator func(string) error
|
||||
// DefaultMaxReasoningEffortOverLimit holds the default value on creation for the "max_reasoning_effort_over_limit" field.
|
||||
DefaultMaxReasoningEffortOverLimit string
|
||||
// MaxReasoningEffortOverLimitValidator is a validator for the "max_reasoning_effort_over_limit" field. It is called by the builders before save.
|
||||
MaxReasoningEffortOverLimitValidator func(string) error
|
||||
// DefaultReasoningEffortMappings holds the default value on creation for the "reasoning_effort_mappings" field.
|
||||
DefaultReasoningEffortMappings []domain.ReasoningEffortMapping
|
||||
// DefaultProfitControlEnabled holds the default value on creation for the "profit_control_enabled" field.
|
||||
@@ -657,6 +674,16 @@ func ByAllowLive(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldAllowLive, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByForceOpenaiFast orders the results by the force_openai_fast field.
|
||||
func ByForceOpenaiFast(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldForceOpenaiFast, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByFreeOpenaiFast orders the results by the free_openai_fast field.
|
||||
func ByFreeOpenaiFast(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldFreeOpenaiFast, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByRequireOauthOnly orders the results by the require_oauth_only field.
|
||||
func ByRequireOauthOnly(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldRequireOauthOnly, opts...).ToFunc()
|
||||
@@ -682,6 +709,11 @@ func ByMaxReasoningEffort(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldMaxReasoningEffort, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByMaxReasoningEffortOverLimit orders the results by the max_reasoning_effort_over_limit field.
|
||||
func ByMaxReasoningEffortOverLimit(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldMaxReasoningEffortOverLimit, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByProfitControlEnabled orders the results by the profit_control_enabled field.
|
||||
func ByProfitControlEnabled(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldProfitControlEnabled, opts...).ToFunc()
|
||||
|
||||
@@ -290,6 +290,16 @@ func AllowLive(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldAllowLive, v))
|
||||
}
|
||||
|
||||
// ForceOpenaiFast applies equality check predicate on the "force_openai_fast" field. It's identical to ForceOpenaiFastEQ.
|
||||
func ForceOpenaiFast(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldForceOpenaiFast, v))
|
||||
}
|
||||
|
||||
// FreeOpenaiFast applies equality check predicate on the "free_openai_fast" field. It's identical to FreeOpenaiFastEQ.
|
||||
func FreeOpenaiFast(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldFreeOpenaiFast, v))
|
||||
}
|
||||
|
||||
// RequireOauthOnly applies equality check predicate on the "require_oauth_only" field. It's identical to RequireOauthOnlyEQ.
|
||||
func RequireOauthOnly(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldRequireOauthOnly, v))
|
||||
@@ -315,6 +325,11 @@ func MaxReasoningEffort(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffort, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimit applies equality check predicate on the "max_reasoning_effort_over_limit" field. It's identical to MaxReasoningEffortOverLimitEQ.
|
||||
func MaxReasoningEffortOverLimit(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// ProfitControlEnabled applies equality check predicate on the "profit_control_enabled" field. It's identical to ProfitControlEnabledEQ.
|
||||
func ProfitControlEnabled(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldProfitControlEnabled, v))
|
||||
@@ -2270,6 +2285,26 @@ func AllowLiveNEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldAllowLive, v))
|
||||
}
|
||||
|
||||
// ForceOpenaiFastEQ applies the EQ predicate on the "force_openai_fast" field.
|
||||
func ForceOpenaiFastEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldForceOpenaiFast, v))
|
||||
}
|
||||
|
||||
// ForceOpenaiFastNEQ applies the NEQ predicate on the "force_openai_fast" field.
|
||||
func ForceOpenaiFastNEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldForceOpenaiFast, v))
|
||||
}
|
||||
|
||||
// FreeOpenaiFastEQ applies the EQ predicate on the "free_openai_fast" field.
|
||||
func FreeOpenaiFastEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldFreeOpenaiFast, v))
|
||||
}
|
||||
|
||||
// FreeOpenaiFastNEQ applies the NEQ predicate on the "free_openai_fast" field.
|
||||
func FreeOpenaiFastNEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldFreeOpenaiFast, v))
|
||||
}
|
||||
|
||||
// RequireOauthOnlyEQ applies the EQ predicate on the "require_oauth_only" field.
|
||||
func RequireOauthOnlyEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldRequireOauthOnly, v))
|
||||
@@ -2460,6 +2495,71 @@ func MaxReasoningEffortContainsFold(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldContainsFold(FieldMaxReasoningEffort, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitEQ applies the EQ predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitEQ(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitNEQ applies the NEQ predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitNEQ(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitIn applies the In predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitIn(vs ...string) predicate.Group {
|
||||
return predicate.Group(sql.FieldIn(FieldMaxReasoningEffortOverLimit, vs...))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitNotIn applies the NotIn predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitNotIn(vs ...string) predicate.Group {
|
||||
return predicate.Group(sql.FieldNotIn(FieldMaxReasoningEffortOverLimit, vs...))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitGT applies the GT predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitGT(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldGT(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitGTE applies the GTE predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitGTE(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldGTE(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitLT applies the LT predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitLT(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldLT(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitLTE applies the LTE predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitLTE(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldLTE(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitContains applies the Contains predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitContains(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldContains(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitHasPrefix applies the HasPrefix predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitHasPrefix(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldHasPrefix(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitHasSuffix applies the HasSuffix predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitHasSuffix(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldHasSuffix(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitEqualFold applies the EqualFold predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitEqualFold(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEqualFold(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitContainsFold applies the ContainsFold predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitContainsFold(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldContainsFold(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// ProfitControlEnabledEQ applies the EQ predicate on the "profit_control_enabled" field.
|
||||
func ProfitControlEnabledEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldProfitControlEnabled, v))
|
||||
|
||||
@@ -704,6 +704,34 @@ func (_c *GroupCreate) SetNillableAllowLive(v *bool) *GroupCreate {
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetForceOpenaiFast sets the "force_openai_fast" field.
|
||||
func (_c *GroupCreate) SetForceOpenaiFast(v bool) *GroupCreate {
|
||||
_c.mutation.SetForceOpenaiFast(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableForceOpenaiFast sets the "force_openai_fast" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableForceOpenaiFast(v *bool) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetForceOpenaiFast(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetFreeOpenaiFast sets the "free_openai_fast" field.
|
||||
func (_c *GroupCreate) SetFreeOpenaiFast(v bool) *GroupCreate {
|
||||
_c.mutation.SetFreeOpenaiFast(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableFreeOpenaiFast sets the "free_openai_fast" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableFreeOpenaiFast(v *bool) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetFreeOpenaiFast(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetRequireOauthOnly sets the "require_oauth_only" field.
|
||||
func (_c *GroupCreate) SetRequireOauthOnly(v bool) *GroupCreate {
|
||||
_c.mutation.SetRequireOauthOnly(v)
|
||||
@@ -802,6 +830,20 @@ func (_c *GroupCreate) SetNillableMaxReasoningEffort(v *string) *GroupCreate {
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (_c *GroupCreate) SetMaxReasoningEffortOverLimit(v string) *GroupCreate {
|
||||
_c.mutation.SetMaxReasoningEffortOverLimit(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableMaxReasoningEffortOverLimit(v *string) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetMaxReasoningEffortOverLimit(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (_c *GroupCreate) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupCreate {
|
||||
_c.mutation.SetReasoningEffortMappings(v)
|
||||
@@ -1095,6 +1137,14 @@ func (_c *GroupCreate) defaults() error {
|
||||
v := group.DefaultAllowLive
|
||||
_c.mutation.SetAllowLive(v)
|
||||
}
|
||||
if _, ok := _c.mutation.ForceOpenaiFast(); !ok {
|
||||
v := group.DefaultForceOpenaiFast
|
||||
_c.mutation.SetForceOpenaiFast(v)
|
||||
}
|
||||
if _, ok := _c.mutation.FreeOpenaiFast(); !ok {
|
||||
v := group.DefaultFreeOpenaiFast
|
||||
_c.mutation.SetFreeOpenaiFast(v)
|
||||
}
|
||||
if _, ok := _c.mutation.RequireOauthOnly(); !ok {
|
||||
v := group.DefaultRequireOauthOnly
|
||||
_c.mutation.SetRequireOauthOnly(v)
|
||||
@@ -1123,6 +1173,10 @@ func (_c *GroupCreate) defaults() error {
|
||||
v := group.DefaultMaxReasoningEffort
|
||||
_c.mutation.SetMaxReasoningEffort(v)
|
||||
}
|
||||
if _, ok := _c.mutation.MaxReasoningEffortOverLimit(); !ok {
|
||||
v := group.DefaultMaxReasoningEffortOverLimit
|
||||
_c.mutation.SetMaxReasoningEffortOverLimit(v)
|
||||
}
|
||||
if _, ok := _c.mutation.ReasoningEffortMappings(); !ok {
|
||||
v := group.DefaultReasoningEffortMappings
|
||||
_c.mutation.SetReasoningEffortMappings(v)
|
||||
@@ -1286,6 +1340,12 @@ func (_c *GroupCreate) check() error {
|
||||
if _, ok := _c.mutation.AllowLive(); !ok {
|
||||
return &ValidationError{Name: "allow_live", err: errors.New(`ent: missing required field "Group.allow_live"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.ForceOpenaiFast(); !ok {
|
||||
return &ValidationError{Name: "force_openai_fast", err: errors.New(`ent: missing required field "Group.force_openai_fast"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.FreeOpenaiFast(); !ok {
|
||||
return &ValidationError{Name: "free_openai_fast", err: errors.New(`ent: missing required field "Group.free_openai_fast"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.RequireOauthOnly(); !ok {
|
||||
return &ValidationError{Name: "require_oauth_only", err: errors.New(`ent: missing required field "Group.require_oauth_only"`)}
|
||||
}
|
||||
@@ -1317,6 +1377,14 @@ func (_c *GroupCreate) check() error {
|
||||
return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)}
|
||||
}
|
||||
}
|
||||
if _, ok := _c.mutation.MaxReasoningEffortOverLimit(); !ok {
|
||||
return &ValidationError{Name: "max_reasoning_effort_over_limit", err: errors.New(`ent: missing required field "Group.max_reasoning_effort_over_limit"`)}
|
||||
}
|
||||
if v, ok := _c.mutation.MaxReasoningEffortOverLimit(); ok {
|
||||
if err := group.MaxReasoningEffortOverLimitValidator(v); err != nil {
|
||||
return &ValidationError{Name: "max_reasoning_effort_over_limit", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort_over_limit": %w`, err)}
|
||||
}
|
||||
}
|
||||
if _, ok := _c.mutation.ReasoningEffortMappings(); !ok {
|
||||
return &ValidationError{Name: "reasoning_effort_mappings", err: errors.New(`ent: missing required field "Group.reasoning_effort_mappings"`)}
|
||||
}
|
||||
@@ -1560,6 +1628,14 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(group.FieldAllowLive, field.TypeBool, value)
|
||||
_node.AllowLive = value
|
||||
}
|
||||
if value, ok := _c.mutation.ForceOpenaiFast(); ok {
|
||||
_spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value)
|
||||
_node.ForceOpenaiFast = value
|
||||
}
|
||||
if value, ok := _c.mutation.FreeOpenaiFast(); ok {
|
||||
_spec.SetField(group.FieldFreeOpenaiFast, field.TypeBool, value)
|
||||
_node.FreeOpenaiFast = value
|
||||
}
|
||||
if value, ok := _c.mutation.RequireOauthOnly(); ok {
|
||||
_spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value)
|
||||
_node.RequireOauthOnly = value
|
||||
@@ -1588,6 +1664,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value)
|
||||
_node.MaxReasoningEffort = value
|
||||
}
|
||||
if value, ok := _c.mutation.MaxReasoningEffortOverLimit(); ok {
|
||||
_spec.SetField(group.FieldMaxReasoningEffortOverLimit, field.TypeString, value)
|
||||
_node.MaxReasoningEffortOverLimit = value
|
||||
}
|
||||
if value, ok := _c.mutation.ReasoningEffortMappings(); ok {
|
||||
_spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value)
|
||||
_node.ReasoningEffortMappings = value
|
||||
@@ -2618,6 +2698,30 @@ func (u *GroupUpsert) UpdateAllowLive() *GroupUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetForceOpenaiFast sets the "force_openai_fast" field.
|
||||
func (u *GroupUpsert) SetForceOpenaiFast(v bool) *GroupUpsert {
|
||||
u.Set(group.FieldForceOpenaiFast, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateForceOpenaiFast sets the "force_openai_fast" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateForceOpenaiFast() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldForceOpenaiFast)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetFreeOpenaiFast sets the "free_openai_fast" field.
|
||||
func (u *GroupUpsert) SetFreeOpenaiFast(v bool) *GroupUpsert {
|
||||
u.Set(group.FieldFreeOpenaiFast, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateFreeOpenaiFast sets the "free_openai_fast" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateFreeOpenaiFast() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldFreeOpenaiFast)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetRequireOauthOnly sets the "require_oauth_only" field.
|
||||
func (u *GroupUpsert) SetRequireOauthOnly(v bool) *GroupUpsert {
|
||||
u.Set(group.FieldRequireOauthOnly, v)
|
||||
@@ -2708,6 +2812,18 @@ func (u *GroupUpsert) UpdateMaxReasoningEffort() *GroupUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (u *GroupUpsert) SetMaxReasoningEffortOverLimit(v string) *GroupUpsert {
|
||||
u.Set(group.FieldMaxReasoningEffortOverLimit, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateMaxReasoningEffortOverLimit() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldMaxReasoningEffortOverLimit)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (u *GroupUpsert) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsert {
|
||||
u.Set(group.FieldReasoningEffortMappings, v)
|
||||
@@ -3817,6 +3933,34 @@ func (u *GroupUpsertOne) UpdateAllowLive() *GroupUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetForceOpenaiFast sets the "force_openai_fast" field.
|
||||
func (u *GroupUpsertOne) SetForceOpenaiFast(v bool) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetForceOpenaiFast(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateForceOpenaiFast sets the "force_openai_fast" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateForceOpenaiFast() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateForceOpenaiFast()
|
||||
})
|
||||
}
|
||||
|
||||
// SetFreeOpenaiFast sets the "free_openai_fast" field.
|
||||
func (u *GroupUpsertOne) SetFreeOpenaiFast(v bool) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetFreeOpenaiFast(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateFreeOpenaiFast sets the "free_openai_fast" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateFreeOpenaiFast() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateFreeOpenaiFast()
|
||||
})
|
||||
}
|
||||
|
||||
// SetRequireOauthOnly sets the "require_oauth_only" field.
|
||||
func (u *GroupUpsertOne) SetRequireOauthOnly(v bool) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
@@ -3922,6 +4066,20 @@ func (u *GroupUpsertOne) UpdateMaxReasoningEffort() *GroupUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (u *GroupUpsertOne) SetMaxReasoningEffortOverLimit(v string) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetMaxReasoningEffortOverLimit(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateMaxReasoningEffortOverLimit() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateMaxReasoningEffortOverLimit()
|
||||
})
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (u *GroupUpsertOne) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
@@ -5207,6 +5365,34 @@ func (u *GroupUpsertBulk) UpdateAllowLive() *GroupUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetForceOpenaiFast sets the "force_openai_fast" field.
|
||||
func (u *GroupUpsertBulk) SetForceOpenaiFast(v bool) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetForceOpenaiFast(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateForceOpenaiFast sets the "force_openai_fast" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateForceOpenaiFast() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateForceOpenaiFast()
|
||||
})
|
||||
}
|
||||
|
||||
// SetFreeOpenaiFast sets the "free_openai_fast" field.
|
||||
func (u *GroupUpsertBulk) SetFreeOpenaiFast(v bool) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetFreeOpenaiFast(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateFreeOpenaiFast sets the "free_openai_fast" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateFreeOpenaiFast() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateFreeOpenaiFast()
|
||||
})
|
||||
}
|
||||
|
||||
// SetRequireOauthOnly sets the "require_oauth_only" field.
|
||||
func (u *GroupUpsertBulk) SetRequireOauthOnly(v bool) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
@@ -5312,6 +5498,20 @@ func (u *GroupUpsertBulk) UpdateMaxReasoningEffort() *GroupUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (u *GroupUpsertBulk) SetMaxReasoningEffortOverLimit(v string) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetMaxReasoningEffortOverLimit(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateMaxReasoningEffortOverLimit() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateMaxReasoningEffortOverLimit()
|
||||
})
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (u *GroupUpsertBulk) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
|
||||
@@ -989,6 +989,34 @@ func (_u *GroupUpdate) SetNillableAllowLive(v *bool) *GroupUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetForceOpenaiFast sets the "force_openai_fast" field.
|
||||
func (_u *GroupUpdate) SetForceOpenaiFast(v bool) *GroupUpdate {
|
||||
_u.mutation.SetForceOpenaiFast(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableForceOpenaiFast sets the "force_openai_fast" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableForceOpenaiFast(v *bool) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetForceOpenaiFast(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetFreeOpenaiFast sets the "free_openai_fast" field.
|
||||
func (_u *GroupUpdate) SetFreeOpenaiFast(v bool) *GroupUpdate {
|
||||
_u.mutation.SetFreeOpenaiFast(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableFreeOpenaiFast sets the "free_openai_fast" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableFreeOpenaiFast(v *bool) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetFreeOpenaiFast(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetRequireOauthOnly sets the "require_oauth_only" field.
|
||||
func (_u *GroupUpdate) SetRequireOauthOnly(v bool) *GroupUpdate {
|
||||
_u.mutation.SetRequireOauthOnly(v)
|
||||
@@ -1094,6 +1122,20 @@ func (_u *GroupUpdate) SetNillableMaxReasoningEffort(v *string) *GroupUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (_u *GroupUpdate) SetMaxReasoningEffortOverLimit(v string) *GroupUpdate {
|
||||
_u.mutation.SetMaxReasoningEffortOverLimit(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableMaxReasoningEffortOverLimit(v *string) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetMaxReasoningEffortOverLimit(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (_u *GroupUpdate) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpdate {
|
||||
_u.mutation.SetReasoningEffortMappings(v)
|
||||
@@ -1487,6 +1529,11 @@ func (_u *GroupUpdate) check() error {
|
||||
return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok {
|
||||
if err := group.MaxReasoningEffortOverLimitValidator(v); err != nil {
|
||||
return &ValidationError{Name: "max_reasoning_effort_over_limit", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort_over_limit": %w`, err)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1797,6 +1844,12 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if value, ok := _u.mutation.AllowLive(); ok {
|
||||
_spec.SetField(group.FieldAllowLive, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ForceOpenaiFast(); ok {
|
||||
_spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.FreeOpenaiFast(); ok {
|
||||
_spec.SetField(group.FieldFreeOpenaiFast, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.RequireOauthOnly(); ok {
|
||||
_spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value)
|
||||
}
|
||||
@@ -1821,6 +1874,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if value, ok := _u.mutation.MaxReasoningEffort(); ok {
|
||||
_spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok {
|
||||
_spec.SetField(group.FieldMaxReasoningEffortOverLimit, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ReasoningEffortMappings(); ok {
|
||||
_spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value)
|
||||
}
|
||||
@@ -3110,6 +3166,34 @@ func (_u *GroupUpdateOne) SetNillableAllowLive(v *bool) *GroupUpdateOne {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetForceOpenaiFast sets the "force_openai_fast" field.
|
||||
func (_u *GroupUpdateOne) SetForceOpenaiFast(v bool) *GroupUpdateOne {
|
||||
_u.mutation.SetForceOpenaiFast(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableForceOpenaiFast sets the "force_openai_fast" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableForceOpenaiFast(v *bool) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetForceOpenaiFast(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetFreeOpenaiFast sets the "free_openai_fast" field.
|
||||
func (_u *GroupUpdateOne) SetFreeOpenaiFast(v bool) *GroupUpdateOne {
|
||||
_u.mutation.SetFreeOpenaiFast(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableFreeOpenaiFast sets the "free_openai_fast" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableFreeOpenaiFast(v *bool) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetFreeOpenaiFast(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetRequireOauthOnly sets the "require_oauth_only" field.
|
||||
func (_u *GroupUpdateOne) SetRequireOauthOnly(v bool) *GroupUpdateOne {
|
||||
_u.mutation.SetRequireOauthOnly(v)
|
||||
@@ -3215,6 +3299,20 @@ func (_u *GroupUpdateOne) SetNillableMaxReasoningEffort(v *string) *GroupUpdateO
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (_u *GroupUpdateOne) SetMaxReasoningEffortOverLimit(v string) *GroupUpdateOne {
|
||||
_u.mutation.SetMaxReasoningEffortOverLimit(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableMaxReasoningEffortOverLimit(v *string) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetMaxReasoningEffortOverLimit(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (_u *GroupUpdateOne) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpdateOne {
|
||||
_u.mutation.SetReasoningEffortMappings(v)
|
||||
@@ -3621,6 +3719,11 @@ func (_u *GroupUpdateOne) check() error {
|
||||
return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok {
|
||||
if err := group.MaxReasoningEffortOverLimitValidator(v); err != nil {
|
||||
return &ValidationError{Name: "max_reasoning_effort_over_limit", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort_over_limit": %w`, err)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3948,6 +4051,12 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
|
||||
if value, ok := _u.mutation.AllowLive(); ok {
|
||||
_spec.SetField(group.FieldAllowLive, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ForceOpenaiFast(); ok {
|
||||
_spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.FreeOpenaiFast(); ok {
|
||||
_spec.SetField(group.FieldFreeOpenaiFast, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.RequireOauthOnly(); ok {
|
||||
_spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value)
|
||||
}
|
||||
@@ -3972,6 +4081,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
|
||||
if value, ok := _u.mutation.MaxReasoningEffort(); ok {
|
||||
_spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok {
|
||||
_spec.SetField(group.FieldMaxReasoningEffortOverLimit, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ReasoningEffortMappings(); ok {
|
||||
_spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value)
|
||||
}
|
||||
|
||||
@@ -954,6 +954,8 @@ var (
|
||||
{Name: "sort_order", Type: field.TypeInt, Default: 0},
|
||||
{Name: "allow_messages_dispatch", Type: field.TypeBool, Default: false},
|
||||
{Name: "allow_live", Type: field.TypeBool, Default: false},
|
||||
{Name: "force_openai_fast", Type: field.TypeBool, Default: false},
|
||||
{Name: "free_openai_fast", Type: field.TypeBool, Default: false},
|
||||
{Name: "require_oauth_only", Type: field.TypeBool, Default: false},
|
||||
{Name: "require_privacy_set", Type: field.TypeBool, Default: false},
|
||||
{Name: "default_mapped_model", Type: field.TypeString, Size: 100, Default: ""},
|
||||
@@ -961,6 +963,7 @@ var (
|
||||
{Name: "models_list_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "rpm_limit", Type: field.TypeInt, Default: 0},
|
||||
{Name: "max_reasoning_effort", Type: field.TypeString, Size: 20, Default: ""},
|
||||
{Name: "max_reasoning_effort_over_limit", Type: field.TypeString, Size: 20, Default: "downgrade"},
|
||||
{Name: "reasoning_effort_mappings", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "profit_control_enabled", Type: field.TypeBool, Default: false},
|
||||
{Name: "profit_min_margin", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
|
||||
|
||||
+163
-1
@@ -22158,6 +22158,8 @@ type GroupMutation struct {
|
||||
addsort_order *int
|
||||
allow_messages_dispatch *bool
|
||||
allow_live *bool
|
||||
force_openai_fast *bool
|
||||
free_openai_fast *bool
|
||||
require_oauth_only *bool
|
||||
require_privacy_set *bool
|
||||
default_mapped_model *string
|
||||
@@ -22166,6 +22168,7 @@ type GroupMutation struct {
|
||||
rpm_limit *int
|
||||
addrpm_limit *int
|
||||
max_reasoning_effort *string
|
||||
max_reasoning_effort_over_limit *string
|
||||
reasoning_effort_mappings *[]domain.ReasoningEffortMapping
|
||||
appendreasoning_effort_mappings []domain.ReasoningEffortMapping
|
||||
profit_control_enabled *bool
|
||||
@@ -24944,6 +24947,78 @@ func (m *GroupMutation) ResetAllowLive() {
|
||||
m.allow_live = nil
|
||||
}
|
||||
|
||||
// SetForceOpenaiFast sets the "force_openai_fast" field.
|
||||
func (m *GroupMutation) SetForceOpenaiFast(b bool) {
|
||||
m.force_openai_fast = &b
|
||||
}
|
||||
|
||||
// ForceOpenaiFast returns the value of the "force_openai_fast" field in the mutation.
|
||||
func (m *GroupMutation) ForceOpenaiFast() (r bool, exists bool) {
|
||||
v := m.force_openai_fast
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldForceOpenaiFast returns the old "force_openai_fast" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldForceOpenaiFast(ctx context.Context) (v bool, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldForceOpenaiFast is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldForceOpenaiFast requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldForceOpenaiFast: %w", err)
|
||||
}
|
||||
return oldValue.ForceOpenaiFast, nil
|
||||
}
|
||||
|
||||
// ResetForceOpenaiFast resets all changes to the "force_openai_fast" field.
|
||||
func (m *GroupMutation) ResetForceOpenaiFast() {
|
||||
m.force_openai_fast = nil
|
||||
}
|
||||
|
||||
// SetFreeOpenaiFast sets the "free_openai_fast" field.
|
||||
func (m *GroupMutation) SetFreeOpenaiFast(b bool) {
|
||||
m.free_openai_fast = &b
|
||||
}
|
||||
|
||||
// FreeOpenaiFast returns the value of the "free_openai_fast" field in the mutation.
|
||||
func (m *GroupMutation) FreeOpenaiFast() (r bool, exists bool) {
|
||||
v := m.free_openai_fast
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldFreeOpenaiFast returns the old "free_openai_fast" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldFreeOpenaiFast(ctx context.Context) (v bool, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldFreeOpenaiFast is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldFreeOpenaiFast requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldFreeOpenaiFast: %w", err)
|
||||
}
|
||||
return oldValue.FreeOpenaiFast, nil
|
||||
}
|
||||
|
||||
// ResetFreeOpenaiFast resets all changes to the "free_openai_fast" field.
|
||||
func (m *GroupMutation) ResetFreeOpenaiFast() {
|
||||
m.free_openai_fast = nil
|
||||
}
|
||||
|
||||
// SetRequireOauthOnly sets the "require_oauth_only" field.
|
||||
func (m *GroupMutation) SetRequireOauthOnly(b bool) {
|
||||
m.require_oauth_only = &b
|
||||
@@ -25216,6 +25291,42 @@ func (m *GroupMutation) ResetMaxReasoningEffort() {
|
||||
m.max_reasoning_effort = nil
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (m *GroupMutation) SetMaxReasoningEffortOverLimit(s string) {
|
||||
m.max_reasoning_effort_over_limit = &s
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimit returns the value of the "max_reasoning_effort_over_limit" field in the mutation.
|
||||
func (m *GroupMutation) MaxReasoningEffortOverLimit() (r string, exists bool) {
|
||||
v := m.max_reasoning_effort_over_limit
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldMaxReasoningEffortOverLimit returns the old "max_reasoning_effort_over_limit" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldMaxReasoningEffortOverLimit(ctx context.Context) (v string, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldMaxReasoningEffortOverLimit is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldMaxReasoningEffortOverLimit requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldMaxReasoningEffortOverLimit: %w", err)
|
||||
}
|
||||
return oldValue.MaxReasoningEffortOverLimit, nil
|
||||
}
|
||||
|
||||
// ResetMaxReasoningEffortOverLimit resets all changes to the "max_reasoning_effort_over_limit" field.
|
||||
func (m *GroupMutation) ResetMaxReasoningEffortOverLimit() {
|
||||
m.max_reasoning_effort_over_limit = nil
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (m *GroupMutation) SetReasoningEffortMappings(dem []domain.ReasoningEffortMapping) {
|
||||
m.reasoning_effort_mappings = &dem
|
||||
@@ -25773,7 +25884,7 @@ func (m *GroupMutation) Type() string {
|
||||
// order to get all numeric fields that were incremented/decremented, call
|
||||
// AddedFields().
|
||||
func (m *GroupMutation) Fields() []string {
|
||||
fields := make([]string, 0, 62)
|
||||
fields := make([]string, 0, 65)
|
||||
if m.created_at != nil {
|
||||
fields = append(fields, group.FieldCreatedAt)
|
||||
}
|
||||
@@ -25927,6 +26038,12 @@ func (m *GroupMutation) Fields() []string {
|
||||
if m.allow_live != nil {
|
||||
fields = append(fields, group.FieldAllowLive)
|
||||
}
|
||||
if m.force_openai_fast != nil {
|
||||
fields = append(fields, group.FieldForceOpenaiFast)
|
||||
}
|
||||
if m.free_openai_fast != nil {
|
||||
fields = append(fields, group.FieldFreeOpenaiFast)
|
||||
}
|
||||
if m.require_oauth_only != nil {
|
||||
fields = append(fields, group.FieldRequireOauthOnly)
|
||||
}
|
||||
@@ -25948,6 +26065,9 @@ func (m *GroupMutation) Fields() []string {
|
||||
if m.max_reasoning_effort != nil {
|
||||
fields = append(fields, group.FieldMaxReasoningEffort)
|
||||
}
|
||||
if m.max_reasoning_effort_over_limit != nil {
|
||||
fields = append(fields, group.FieldMaxReasoningEffortOverLimit)
|
||||
}
|
||||
if m.reasoning_effort_mappings != nil {
|
||||
fields = append(fields, group.FieldReasoningEffortMappings)
|
||||
}
|
||||
@@ -26070,6 +26190,10 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.AllowMessagesDispatch()
|
||||
case group.FieldAllowLive:
|
||||
return m.AllowLive()
|
||||
case group.FieldForceOpenaiFast:
|
||||
return m.ForceOpenaiFast()
|
||||
case group.FieldFreeOpenaiFast:
|
||||
return m.FreeOpenaiFast()
|
||||
case group.FieldRequireOauthOnly:
|
||||
return m.RequireOauthOnly()
|
||||
case group.FieldRequirePrivacySet:
|
||||
@@ -26084,6 +26208,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.RpmLimit()
|
||||
case group.FieldMaxReasoningEffort:
|
||||
return m.MaxReasoningEffort()
|
||||
case group.FieldMaxReasoningEffortOverLimit:
|
||||
return m.MaxReasoningEffortOverLimit()
|
||||
case group.FieldReasoningEffortMappings:
|
||||
return m.ReasoningEffortMappings()
|
||||
case group.FieldProfitControlEnabled:
|
||||
@@ -26203,6 +26329,10 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
|
||||
return m.OldAllowMessagesDispatch(ctx)
|
||||
case group.FieldAllowLive:
|
||||
return m.OldAllowLive(ctx)
|
||||
case group.FieldForceOpenaiFast:
|
||||
return m.OldForceOpenaiFast(ctx)
|
||||
case group.FieldFreeOpenaiFast:
|
||||
return m.OldFreeOpenaiFast(ctx)
|
||||
case group.FieldRequireOauthOnly:
|
||||
return m.OldRequireOauthOnly(ctx)
|
||||
case group.FieldRequirePrivacySet:
|
||||
@@ -26217,6 +26347,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
|
||||
return m.OldRpmLimit(ctx)
|
||||
case group.FieldMaxReasoningEffort:
|
||||
return m.OldMaxReasoningEffort(ctx)
|
||||
case group.FieldMaxReasoningEffortOverLimit:
|
||||
return m.OldMaxReasoningEffortOverLimit(ctx)
|
||||
case group.FieldReasoningEffortMappings:
|
||||
return m.OldReasoningEffortMappings(ctx)
|
||||
case group.FieldProfitControlEnabled:
|
||||
@@ -26591,6 +26723,20 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetAllowLive(v)
|
||||
return nil
|
||||
case group.FieldForceOpenaiFast:
|
||||
v, ok := value.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetForceOpenaiFast(v)
|
||||
return nil
|
||||
case group.FieldFreeOpenaiFast:
|
||||
v, ok := value.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetFreeOpenaiFast(v)
|
||||
return nil
|
||||
case group.FieldRequireOauthOnly:
|
||||
v, ok := value.(bool)
|
||||
if !ok {
|
||||
@@ -26640,6 +26786,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetMaxReasoningEffort(v)
|
||||
return nil
|
||||
case group.FieldMaxReasoningEffortOverLimit:
|
||||
v, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetMaxReasoningEffortOverLimit(v)
|
||||
return nil
|
||||
case group.FieldReasoningEffortMappings:
|
||||
v, ok := value.([]domain.ReasoningEffortMapping)
|
||||
if !ok {
|
||||
@@ -27332,6 +27485,12 @@ func (m *GroupMutation) ResetField(name string) error {
|
||||
case group.FieldAllowLive:
|
||||
m.ResetAllowLive()
|
||||
return nil
|
||||
case group.FieldForceOpenaiFast:
|
||||
m.ResetForceOpenaiFast()
|
||||
return nil
|
||||
case group.FieldFreeOpenaiFast:
|
||||
m.ResetFreeOpenaiFast()
|
||||
return nil
|
||||
case group.FieldRequireOauthOnly:
|
||||
m.ResetRequireOauthOnly()
|
||||
return nil
|
||||
@@ -27353,6 +27512,9 @@ func (m *GroupMutation) ResetField(name string) error {
|
||||
case group.FieldMaxReasoningEffort:
|
||||
m.ResetMaxReasoningEffort()
|
||||
return nil
|
||||
case group.FieldMaxReasoningEffortOverLimit:
|
||||
m.ResetMaxReasoningEffortOverLimit()
|
||||
return nil
|
||||
case group.FieldReasoningEffortMappings:
|
||||
m.ResetReasoningEffortMappings()
|
||||
return nil
|
||||
|
||||
@@ -1157,52 +1157,66 @@ func init() {
|
||||
groupDescAllowLive := groupFields[47].Descriptor()
|
||||
// group.DefaultAllowLive holds the default value on creation for the allow_live field.
|
||||
group.DefaultAllowLive = groupDescAllowLive.Default.(bool)
|
||||
// groupDescForceOpenaiFast is the schema descriptor for force_openai_fast field.
|
||||
groupDescForceOpenaiFast := groupFields[48].Descriptor()
|
||||
// group.DefaultForceOpenaiFast holds the default value on creation for the force_openai_fast field.
|
||||
group.DefaultForceOpenaiFast = groupDescForceOpenaiFast.Default.(bool)
|
||||
// groupDescFreeOpenaiFast is the schema descriptor for free_openai_fast field.
|
||||
groupDescFreeOpenaiFast := groupFields[49].Descriptor()
|
||||
// group.DefaultFreeOpenaiFast holds the default value on creation for the free_openai_fast field.
|
||||
group.DefaultFreeOpenaiFast = groupDescFreeOpenaiFast.Default.(bool)
|
||||
// groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field.
|
||||
groupDescRequireOauthOnly := groupFields[48].Descriptor()
|
||||
groupDescRequireOauthOnly := groupFields[50].Descriptor()
|
||||
// group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field.
|
||||
group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool)
|
||||
// groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field.
|
||||
groupDescRequirePrivacySet := groupFields[49].Descriptor()
|
||||
groupDescRequirePrivacySet := groupFields[51].Descriptor()
|
||||
// group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field.
|
||||
group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool)
|
||||
// groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field.
|
||||
groupDescDefaultMappedModel := groupFields[50].Descriptor()
|
||||
groupDescDefaultMappedModel := groupFields[52].Descriptor()
|
||||
// group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field.
|
||||
group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string)
|
||||
// group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save.
|
||||
group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error)
|
||||
// groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field.
|
||||
groupDescMessagesDispatchModelConfig := groupFields[51].Descriptor()
|
||||
groupDescMessagesDispatchModelConfig := groupFields[53].Descriptor()
|
||||
// group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field.
|
||||
group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig)
|
||||
// groupDescModelsListConfig is the schema descriptor for models_list_config field.
|
||||
groupDescModelsListConfig := groupFields[52].Descriptor()
|
||||
groupDescModelsListConfig := groupFields[54].Descriptor()
|
||||
// group.DefaultModelsListConfig holds the default value on creation for the models_list_config field.
|
||||
group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig)
|
||||
// groupDescRpmLimit is the schema descriptor for rpm_limit field.
|
||||
groupDescRpmLimit := groupFields[53].Descriptor()
|
||||
groupDescRpmLimit := groupFields[55].Descriptor()
|
||||
// group.DefaultRpmLimit holds the default value on creation for the rpm_limit field.
|
||||
group.DefaultRpmLimit = groupDescRpmLimit.Default.(int)
|
||||
// groupDescMaxReasoningEffort is the schema descriptor for max_reasoning_effort field.
|
||||
groupDescMaxReasoningEffort := groupFields[54].Descriptor()
|
||||
groupDescMaxReasoningEffort := groupFields[56].Descriptor()
|
||||
// group.DefaultMaxReasoningEffort holds the default value on creation for the max_reasoning_effort field.
|
||||
group.DefaultMaxReasoningEffort = groupDescMaxReasoningEffort.Default.(string)
|
||||
// group.MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save.
|
||||
group.MaxReasoningEffortValidator = groupDescMaxReasoningEffort.Validators[0].(func(string) error)
|
||||
// groupDescMaxReasoningEffortOverLimit is the schema descriptor for max_reasoning_effort_over_limit field.
|
||||
groupDescMaxReasoningEffortOverLimit := groupFields[57].Descriptor()
|
||||
// group.DefaultMaxReasoningEffortOverLimit holds the default value on creation for the max_reasoning_effort_over_limit field.
|
||||
group.DefaultMaxReasoningEffortOverLimit = groupDescMaxReasoningEffortOverLimit.Default.(string)
|
||||
// group.MaxReasoningEffortOverLimitValidator is a validator for the "max_reasoning_effort_over_limit" field. It is called by the builders before save.
|
||||
group.MaxReasoningEffortOverLimitValidator = groupDescMaxReasoningEffortOverLimit.Validators[0].(func(string) error)
|
||||
// groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field.
|
||||
groupDescReasoningEffortMappings := groupFields[55].Descriptor()
|
||||
groupDescReasoningEffortMappings := groupFields[58].Descriptor()
|
||||
// group.DefaultReasoningEffortMappings holds the default value on creation for the reasoning_effort_mappings field.
|
||||
group.DefaultReasoningEffortMappings = groupDescReasoningEffortMappings.Default.([]domain.ReasoningEffortMapping)
|
||||
// groupDescProfitControlEnabled is the schema descriptor for profit_control_enabled field.
|
||||
groupDescProfitControlEnabled := groupFields[56].Descriptor()
|
||||
groupDescProfitControlEnabled := groupFields[59].Descriptor()
|
||||
// group.DefaultProfitControlEnabled holds the default value on creation for the profit_control_enabled field.
|
||||
group.DefaultProfitControlEnabled = groupDescProfitControlEnabled.Default.(bool)
|
||||
// groupDescProfitMinMargin is the schema descriptor for profit_min_margin field.
|
||||
groupDescProfitMinMargin := groupFields[57].Descriptor()
|
||||
groupDescProfitMinMargin := groupFields[60].Descriptor()
|
||||
// group.DefaultProfitMinMargin holds the default value on creation for the profit_min_margin field.
|
||||
group.DefaultProfitMinMargin = groupDescProfitMinMargin.Default.(float64)
|
||||
// groupDescProfitSafetyBuffer is the schema descriptor for profit_safety_buffer field.
|
||||
groupDescProfitSafetyBuffer := groupFields[58].Descriptor()
|
||||
groupDescProfitSafetyBuffer := groupFields[61].Descriptor()
|
||||
// group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field.
|
||||
group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64)
|
||||
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
|
||||
|
||||
@@ -242,6 +242,12 @@ func (Group) Fields() []ent.Field {
|
||||
field.Bool("allow_live").
|
||||
Default(false).
|
||||
Comment("是否允许此 OpenAI 分组访问 Live 接口"),
|
||||
field.Bool("force_openai_fast").
|
||||
Default(false).
|
||||
Comment("是否强制此 OpenAI/Composite 分组请求使用 service_tier=priority"),
|
||||
field.Bool("free_openai_fast").
|
||||
Default(false).
|
||||
Comment("是否让此 OpenAI/Composite 分组的 Fast 请求按 Standard 价格计费"),
|
||||
field.Bool("require_oauth_only").
|
||||
Default(false).
|
||||
Comment("仅允许非 apikey 类型账号关联到此分组"),
|
||||
@@ -271,10 +277,14 @@ func (Group) Fields() []ent.Field {
|
||||
MaxLen(20).
|
||||
Default("").
|
||||
Comment("OpenAI reasoning effort 上限;可选 minimal/low/medium/high/xhigh/max"),
|
||||
field.String("max_reasoning_effort_over_limit").
|
||||
MaxLen(20).
|
||||
Default("downgrade").
|
||||
Comment("超过推理强度上限时的访问控制:downgrade 自动降档,deny 拒绝访问"),
|
||||
field.JSON("reasoning_effort_mappings", []domain.ReasoningEffortMapping{}).
|
||||
Default([]domain.ReasoningEffortMapping{}).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
Comment("OpenAI reasoning effort 自定义精确映射;先映射再应用上限"),
|
||||
Comment("OpenAI reasoning effort 自定义映射;可按模型精确名、前缀或后缀限定,先映射再应用上限"),
|
||||
|
||||
// 分组利润控制(migration 192/193):openai/anthropic/gemini/grok/antigravity
|
||||
// 的 token 分组可启用,composite 分组不能直接启用。
|
||||
|
||||
@@ -667,6 +667,8 @@ type PricingConfig struct {
|
||||
DataDir string `mapstructure:"data_dir"`
|
||||
// 回退文件路径
|
||||
FallbackFile string `mapstructure:"fallback_file"`
|
||||
// 覆盖补丁文件路径(可选):条目按字段浅合并覆盖目录/回退数据,优先级最高
|
||||
OverrideFile string `mapstructure:"override_file"`
|
||||
// 更新间隔(小时)
|
||||
UpdateIntervalHours int `mapstructure:"update_interval_hours"`
|
||||
// 哈希校验间隔(分钟)
|
||||
@@ -2279,11 +2281,12 @@ func setDefaults() {
|
||||
viper.SetDefault("rate_limit.overload_cooldown_minutes", 10)
|
||||
viper.SetDefault("rate_limit.oauth_401_cooldown_minutes", 10)
|
||||
|
||||
// Pricing - 从 model-price-repo 同步模型定价和上下文窗口数据(固定到 commit,避免分支漂移)
|
||||
// Pricing - 从 model-price-repo main 分支同步模型定价和上下文窗口数据
|
||||
viper.SetDefault("pricing.remote_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.json")
|
||||
viper.SetDefault("pricing.hash_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.sha256")
|
||||
viper.SetDefault("pricing.data_dir", "./data")
|
||||
viper.SetDefault("pricing.fallback_file", "./resources/model-pricing/model_prices_and_context_window.json")
|
||||
viper.SetDefault("pricing.override_file", "")
|
||||
viper.SetDefault("pricing.update_interval_hours", 24)
|
||||
viper.SetDefault("pricing.hash_check_interval_minutes", 10)
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ const (
|
||||
const (
|
||||
APIProtocolChatCompletions = "chat_completions" // OpenAI Chat Completions(默认)
|
||||
APIProtocolAnthropic = "anthropic" // 原生 Anthropic /v1/messages(适配 Claude Code)
|
||||
APIProtocolResponses = "responses" // OpenAI Responses(仅 deepseek,适配 Codex)
|
||||
APIProtocolResponses = "responses" // OpenAI Responses(deepseek / kimi 原生端点,适配 Codex)
|
||||
APIProtocolAdaptive = "adaptive" // 按入站协议优先选择供应商原生端点
|
||||
)
|
||||
|
||||
@@ -98,6 +98,7 @@ const AntigravityGemini31ProAgentModel = "gemini-pro-agent"
|
||||
// 与前端 useModelWhitelist.ts 中的 antigravityDefaultMappings 保持一致
|
||||
var DefaultAntigravityModelMapping = map[string]string{
|
||||
// Claude 白名单
|
||||
"claude-fable-5-1": "claude-fable-5-1", // 官方模型
|
||||
"claude-fable-5": "claude-fable-5", // 官方模型
|
||||
"claude-opus-4-8": "claude-opus-4-8", // 官方模型
|
||||
"claude-opus-4-7": "claude-opus-4-7", // 官方模型
|
||||
@@ -158,7 +159,8 @@ var DefaultAntigravityModelMapping = map[string]string{
|
||||
// aws_region 自动调整为匹配的区域前缀(如 eu.、apac.、jp. 等)
|
||||
var DefaultBedrockModelMapping = map[string]string{
|
||||
// Claude Fable
|
||||
"claude-fable-5": "anthropic.claude-fable-5",
|
||||
"claude-fable-5-1": "anthropic.claude-fable-5-1",
|
||||
"claude-fable-5": "anthropic.claude-fable-5",
|
||||
// Claude Opus
|
||||
"claude-opus-5": "us.anthropic.claude-opus-5-v1",
|
||||
"claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1",
|
||||
|
||||
@@ -29,8 +29,9 @@ func TestDefaultAntigravityModelMapping_ContainsNewClaudeModels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]string{
|
||||
"claude-fable-5": "claude-fable-5",
|
||||
"claude-opus-4-8": "claude-opus-4-8",
|
||||
"claude-fable-5-1": "claude-fable-5-1",
|
||||
"claude-fable-5": "claude-fable-5",
|
||||
"claude-opus-4-8": "claude-opus-4-8",
|
||||
}
|
||||
for from, want := range cases {
|
||||
got, ok := DefaultAntigravityModelMapping[from]
|
||||
@@ -92,8 +93,9 @@ func TestDefaultBedrockModelMapping_ContainsNewClaudeModels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]string{
|
||||
"claude-fable-5": "anthropic.claude-fable-5",
|
||||
"claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1",
|
||||
"claude-fable-5-1": "anthropic.claude-fable-5-1",
|
||||
"claude-fable-5": "anthropic.claude-fable-5",
|
||||
"claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1",
|
||||
}
|
||||
for from, want := range cases {
|
||||
got, ok := DefaultBedrockModelMapping[from]
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
package domain
|
||||
|
||||
const (
|
||||
ReasoningEffortMatchExact = "exact"
|
||||
ReasoningEffortMatchPrefix = "prefix"
|
||||
ReasoningEffortMatchSuffix = "suffix"
|
||||
)
|
||||
|
||||
// ReasoningEffortMapping rewrites one explicit OpenAI/Codex reasoning effort
|
||||
// value to another before the group ceiling is applied.
|
||||
//
|
||||
// Model and MatchType optionally scope the rewrite to a request model:
|
||||
// exact matches the full model id, prefix/suffix match a model-id affix.
|
||||
// Empty MatchType and empty Model mean the mapping applies to every model.
|
||||
type ReasoningEffortMapping struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
MatchType string `json:"match_type,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
}
|
||||
|
||||
@@ -57,20 +57,21 @@ type updateChannelRequest struct {
|
||||
}
|
||||
|
||||
type channelModelPricingRequest struct {
|
||||
Platform string `json:"platform" binding:"omitempty,max=50"`
|
||||
Models []string `json:"models" binding:"required,min=1,max=100"`
|
||||
BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"`
|
||||
InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"`
|
||||
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"`
|
||||
Intervals []pricingIntervalRequest `json:"intervals"`
|
||||
TimePricing *channelTimePricingRequest `json:"time_pricing"`
|
||||
Platform string `json:"platform" binding:"omitempty,max=50"`
|
||||
Models []string `json:"models" binding:"required,min=1,max=100"`
|
||||
BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"`
|
||||
InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"`
|
||||
OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_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"`
|
||||
Intervals []pricingIntervalRequest `json:"intervals"`
|
||||
TimePricing *channelTimePricingRequest `json:"time_pricing"`
|
||||
}
|
||||
|
||||
type channelTimePricingRequest struct {
|
||||
@@ -92,6 +93,7 @@ type pricingIntervalRequest struct {
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_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"`
|
||||
@@ -127,21 +129,22 @@ type channelResponse struct {
|
||||
}
|
||||
|
||||
type channelModelPricingResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Platform string `json:"platform"`
|
||||
Models []string `json:"models"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
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"`
|
||||
Intervals []pricingIntervalResponse `json:"intervals"`
|
||||
TimePricing *channelTimePricingResponse `json:"time_pricing"`
|
||||
ID int64 `json:"id"`
|
||||
Platform string `json:"platform"`
|
||||
Models []string `json:"models"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_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"`
|
||||
Intervals []pricingIntervalResponse `json:"intervals"`
|
||||
TimePricing *channelTimePricingResponse `json:"time_pricing"`
|
||||
}
|
||||
|
||||
type channelTimePricingResponse struct {
|
||||
@@ -164,6 +167,7 @@ type pricingIntervalResponse struct {
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
InputMultiplier *float64 `json:"input_multiplier"`
|
||||
OutputMultiplier *float64 `json:"output_multiplier"`
|
||||
@@ -254,21 +258,22 @@ func pricingToResponse(p *service.ChannelModelPricing) channelModelPricingRespon
|
||||
intervals = append(intervals, intervalToResponse(iv))
|
||||
}
|
||||
return channelModelPricingResponse{
|
||||
ID: p.ID,
|
||||
Platform: platform,
|
||||
Models: models,
|
||||
BillingMode: billingMode,
|
||||
InputPrice: p.InputPrice,
|
||||
OutputPrice: p.OutputPrice,
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
FastMultiplier: p.FastMultiplier,
|
||||
FlexMultiplier: p.FlexMultiplier,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
TimePricing: timePricingToResponse(p.TimePricing),
|
||||
ID: p.ID,
|
||||
Platform: platform,
|
||||
Models: models,
|
||||
BillingMode: billingMode,
|
||||
InputPrice: p.InputPrice,
|
||||
OutputPrice: p.OutputPrice,
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheWrite1hPrice: p.CacheWrite1hPrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
FastMultiplier: p.FastMultiplier,
|
||||
FlexMultiplier: p.FlexMultiplier,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
TimePricing: timePricingToResponse(p.TimePricing),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +305,7 @@ func intervalToResponse(iv service.PricingInterval) pricingIntervalResponse {
|
||||
InputPrice: iv.InputPrice,
|
||||
OutputPrice: iv.OutputPrice,
|
||||
CacheWritePrice: iv.CacheWritePrice,
|
||||
CacheWrite1hPrice: iv.CacheWrite1hPrice,
|
||||
CacheReadPrice: iv.CacheReadPrice,
|
||||
InputMultiplier: iv.InputMultiplier,
|
||||
OutputMultiplier: iv.OutputMultiplier,
|
||||
@@ -334,6 +340,7 @@ func pricingRequestToService(reqs []channelModelPricingRequest, allowChannelMult
|
||||
InputPrice: iv.InputPrice,
|
||||
OutputPrice: iv.OutputPrice,
|
||||
CacheWritePrice: iv.CacheWritePrice,
|
||||
CacheWrite1hPrice: iv.CacheWrite1hPrice,
|
||||
CacheReadPrice: iv.CacheReadPrice,
|
||||
InputMultiplier: inputMultiplier,
|
||||
OutputMultiplier: outputMultiplier,
|
||||
@@ -349,20 +356,21 @@ func pricingRequestToService(reqs []channelModelPricingRequest, allowChannelMult
|
||||
flexMultiplier = r.FlexMultiplier
|
||||
}
|
||||
result = append(result, service.ChannelModelPricing{
|
||||
Platform: platform,
|
||||
Models: r.Models,
|
||||
BillingMode: billingMode,
|
||||
InputPrice: r.InputPrice,
|
||||
OutputPrice: r.OutputPrice,
|
||||
CacheWritePrice: r.CacheWritePrice,
|
||||
CacheReadPrice: r.CacheReadPrice,
|
||||
FastMultiplier: fastMultiplier,
|
||||
FlexMultiplier: flexMultiplier,
|
||||
ImageInputPrice: r.ImageInputPrice,
|
||||
ImageOutputPrice: r.ImageOutputPrice,
|
||||
PerRequestPrice: r.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
TimePricing: timePricingRequestToService(r.TimePricing),
|
||||
Platform: platform,
|
||||
Models: r.Models,
|
||||
BillingMode: billingMode,
|
||||
InputPrice: r.InputPrice,
|
||||
OutputPrice: r.OutputPrice,
|
||||
CacheWritePrice: r.CacheWritePrice,
|
||||
CacheWrite1hPrice: r.CacheWrite1hPrice,
|
||||
CacheReadPrice: r.CacheReadPrice,
|
||||
FastMultiplier: fastMultiplier,
|
||||
FlexMultiplier: flexMultiplier,
|
||||
ImageInputPrice: r.ImageInputPrice,
|
||||
ImageOutputPrice: r.ImageOutputPrice,
|
||||
PerRequestPrice: r.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
TimePricing: timePricingRequestToService(r.TimePricing),
|
||||
})
|
||||
}
|
||||
return result
|
||||
@@ -597,15 +605,24 @@ func (h *ChannelHandler) GetModelDefaultPricing(c *gin.Context) {
|
||||
response.Success(c, gin.H{"found": false})
|
||||
return
|
||||
}
|
||||
cacheWritePrice := pricing.CacheCreationPricePerToken
|
||||
var cacheWrite1hPrice *float64
|
||||
if pricing.SupportsCacheBreakdown {
|
||||
if pricing.CacheCreation5mPrice > 0 {
|
||||
cacheWritePrice = pricing.CacheCreation5mPrice
|
||||
}
|
||||
cacheWrite1hPrice = &pricing.CacheCreation1hPrice
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"found": true,
|
||||
"input_price": pricing.InputPricePerToken,
|
||||
"output_price": pricing.OutputPricePerToken,
|
||||
"cache_write_price": pricing.CacheCreationPricePerToken,
|
||||
"cache_read_price": pricing.CacheReadPricePerToken,
|
||||
"image_input_price": pricing.ImageInputPricePerToken,
|
||||
"image_output_price": pricing.ImageOutputPricePerToken,
|
||||
"found": true,
|
||||
"input_price": pricing.InputPricePerToken,
|
||||
"output_price": pricing.OutputPricePerToken,
|
||||
"cache_write_price": cacheWritePrice,
|
||||
"cache_write_1h_price": cacheWrite1hPrice,
|
||||
"cache_read_price": pricing.CacheReadPricePerToken,
|
||||
"image_input_price": pricing.ImageInputPricePerToken,
|
||||
"image_output_price": pricing.ImageOutputPricePerToken,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -320,15 +320,16 @@ func TestPricingRequestToService_Defaults(t *testing.T) {
|
||||
func TestPricingRequestToService_WithAllFields(t *testing.T) {
|
||||
reqs := []channelModelPricingRequest{
|
||||
{
|
||||
Platform: "openai",
|
||||
Models: []string{"gpt-4", "gpt-4o"},
|
||||
BillingMode: "per_request",
|
||||
InputPrice: float64Ptr(0.01),
|
||||
OutputPrice: float64Ptr(0.03),
|
||||
CacheWritePrice: float64Ptr(0.005),
|
||||
CacheReadPrice: float64Ptr(0.002),
|
||||
ImageOutputPrice: float64Ptr(0.04),
|
||||
PerRequestPrice: float64Ptr(0.5),
|
||||
Platform: "openai",
|
||||
Models: []string{"gpt-4", "gpt-4o"},
|
||||
BillingMode: "per_request",
|
||||
InputPrice: float64Ptr(0.01),
|
||||
OutputPrice: float64Ptr(0.03),
|
||||
CacheWritePrice: float64Ptr(0.005),
|
||||
CacheWrite1hPrice: float64Ptr(0.008),
|
||||
CacheReadPrice: float64Ptr(0.002),
|
||||
ImageOutputPrice: float64Ptr(0.04),
|
||||
PerRequestPrice: float64Ptr(0.5),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -341,6 +342,7 @@ func TestPricingRequestToService_WithAllFields(t *testing.T) {
|
||||
require.Equal(t, float64Ptr(0.01), r.InputPrice)
|
||||
require.Equal(t, float64Ptr(0.03), r.OutputPrice)
|
||||
require.Equal(t, float64Ptr(0.005), r.CacheWritePrice)
|
||||
require.Equal(t, float64Ptr(0.008), r.CacheWrite1hPrice)
|
||||
require.Equal(t, float64Ptr(0.002), r.CacheReadPrice)
|
||||
require.Equal(t, float64Ptr(0.04), r.ImageOutputPrice)
|
||||
require.Equal(t, float64Ptr(0.5), r.PerRequestPrice)
|
||||
@@ -353,15 +355,16 @@ func TestPricingRequestToService_WithIntervals(t *testing.T) {
|
||||
BillingMode: "per_request",
|
||||
Intervals: []pricingIntervalRequest{
|
||||
{
|
||||
MinTokens: 0,
|
||||
MaxTokens: intPtr(2000),
|
||||
TierLabel: "small",
|
||||
InputPrice: float64Ptr(0.01),
|
||||
OutputPrice: float64Ptr(0.02),
|
||||
CacheWritePrice: float64Ptr(0.003),
|
||||
CacheReadPrice: float64Ptr(0.001),
|
||||
PerRequestPrice: float64Ptr(0.1),
|
||||
SortOrder: 1,
|
||||
MinTokens: 0,
|
||||
MaxTokens: intPtr(2000),
|
||||
TierLabel: "small",
|
||||
InputPrice: float64Ptr(0.01),
|
||||
OutputPrice: float64Ptr(0.02),
|
||||
CacheWritePrice: float64Ptr(0.003),
|
||||
CacheWrite1hPrice: float64Ptr(0.006),
|
||||
CacheReadPrice: float64Ptr(0.001),
|
||||
PerRequestPrice: float64Ptr(0.1),
|
||||
SortOrder: 1,
|
||||
},
|
||||
{
|
||||
MinTokens: 2000,
|
||||
@@ -384,6 +387,7 @@ func TestPricingRequestToService_WithIntervals(t *testing.T) {
|
||||
require.Equal(t, float64Ptr(0.01), iv0.InputPrice)
|
||||
require.Equal(t, float64Ptr(0.02), iv0.OutputPrice)
|
||||
require.Equal(t, float64Ptr(0.003), iv0.CacheWritePrice)
|
||||
require.Equal(t, float64Ptr(0.006), iv0.CacheWrite1hPrice)
|
||||
require.Equal(t, float64Ptr(0.001), iv0.CacheReadPrice)
|
||||
require.Equal(t, float64Ptr(0.1), iv0.PerRequestPrice)
|
||||
require.Equal(t, 1, iv0.SortOrder)
|
||||
@@ -558,3 +562,50 @@ func TestSyncPricingModels_ValidPlatform_EmptyService(t *testing.T) {
|
||||
require.NotNil(t, body.Data.Models, "models must not be null for platform=%s", platform)
|
||||
}
|
||||
}
|
||||
|
||||
func setupModelDefaultPricingRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
h := &ChannelHandler{billingService: service.NewBillingService(nil, nil)}
|
||||
router.GET("/channels/model-pricing", h.GetModelDefaultPricing)
|
||||
return router
|
||||
}
|
||||
|
||||
func TestGetModelDefaultPricing_ReturnsFable51CacheTTLs(t *testing.T) {
|
||||
router := setupModelDefaultPricingRouter()
|
||||
req := httptest.NewRequest(http.MethodGet, "/channels/model-pricing?model=claude-fable-5-1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var body struct {
|
||||
Data struct {
|
||||
Found bool `json:"found"`
|
||||
CacheWritePrice float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
require.True(t, body.Data.Found)
|
||||
require.InDelta(t, 12.5e-6, body.Data.CacheWritePrice, 1e-12)
|
||||
require.NotNil(t, body.Data.CacheWrite1hPrice)
|
||||
require.InDelta(t, 20e-6, *body.Data.CacheWrite1hPrice, 1e-12)
|
||||
}
|
||||
|
||||
func TestGetModelDefaultPricing_OmitsUnsupportedCache1hPrice(t *testing.T) {
|
||||
router := setupModelDefaultPricingRouter()
|
||||
req := httptest.NewRequest(http.MethodGet, "/channels/model-pricing?model=claude-sonnet-4", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var body struct {
|
||||
Data struct {
|
||||
Found bool `json:"found"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
require.True(t, body.Data.Found)
|
||||
require.Nil(t, body.Data.CacheWrite1hPrice)
|
||||
}
|
||||
|
||||
@@ -147,6 +147,8 @@ type CreateGroupRequest struct {
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
AllowMessagesDispatch bool `json:"allow_messages_dispatch"`
|
||||
AllowLive bool `json:"allow_live"`
|
||||
ForceOpenAIFast bool `json:"force_openai_fast"`
|
||||
FreeOpenAIFast bool `json:"free_openai_fast"`
|
||||
RequireOAuthOnly bool `json:"require_oauth_only"`
|
||||
RequirePrivacySet bool `json:"require_privacy_set"`
|
||||
DefaultMappedModel string `json:"default_mapped_model"`
|
||||
@@ -156,7 +158,9 @@ type CreateGroupRequest struct {
|
||||
RPMLimit int `json:"rpm_limit"`
|
||||
// OpenAI/Codex 请求推理强度上限,空字符串表示不限制。
|
||||
MaxReasoningEffort string `json:"max_reasoning_effort"`
|
||||
// OpenAI/Codex 推理强度精确映射。
|
||||
// 超过上限时的访问控制:downgrade(默认)或 deny。
|
||||
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"`
|
||||
// OpenAI/Codex 推理强度映射,可按模型精确名、前缀或后缀限定。
|
||||
ReasoningEffortMappings []service.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
|
||||
// 从指定分组复制账号(创建后自动绑定)
|
||||
CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"`
|
||||
@@ -216,6 +220,8 @@ type UpdateGroupRequest struct {
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
AllowMessagesDispatch *bool `json:"allow_messages_dispatch"`
|
||||
AllowLive *bool `json:"allow_live"`
|
||||
ForceOpenAIFast *bool `json:"force_openai_fast"`
|
||||
FreeOpenAIFast *bool `json:"free_openai_fast"`
|
||||
RequireOAuthOnly *bool `json:"require_oauth_only"`
|
||||
RequirePrivacySet *bool `json:"require_privacy_set"`
|
||||
DefaultMappedModel *string `json:"default_mapped_model"`
|
||||
@@ -225,6 +231,8 @@ type UpdateGroupRequest struct {
|
||||
RPMLimit *int `json:"rpm_limit"`
|
||||
// OpenAI/Codex 请求推理强度上限;空字符串清除,nil 不修改。
|
||||
MaxReasoningEffort *string `json:"max_reasoning_effort"`
|
||||
// 超过上限时的访问控制;空字符串视为 downgrade,nil 不修改。
|
||||
MaxReasoningEffortOverLimit *string `json:"max_reasoning_effort_over_limit"`
|
||||
// nil 不修改,空数组清空,非空数组替换。
|
||||
ReasoningEffortMappings *[]service.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
|
||||
// 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号)
|
||||
@@ -550,6 +558,8 @@ func (h *GroupHandler) Create(c *gin.Context) {
|
||||
SupportedModelScopes: req.SupportedModelScopes,
|
||||
AllowMessagesDispatch: req.AllowMessagesDispatch,
|
||||
AllowLive: req.AllowLive,
|
||||
ForceOpenAIFast: req.ForceOpenAIFast,
|
||||
FreeOpenAIFast: req.FreeOpenAIFast,
|
||||
RequireOAuthOnly: req.RequireOAuthOnly,
|
||||
RequirePrivacySet: req.RequirePrivacySet,
|
||||
DefaultMappedModel: req.DefaultMappedModel,
|
||||
@@ -557,6 +567,7 @@ func (h *GroupHandler) Create(c *gin.Context) {
|
||||
ModelsListConfig: req.ModelsListConfig,
|
||||
RPMLimit: req.RPMLimit,
|
||||
MaxReasoningEffort: req.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: req.MaxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: req.ReasoningEffortMappings,
|
||||
CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs,
|
||||
})
|
||||
@@ -679,6 +690,8 @@ func (h *GroupHandler) Update(c *gin.Context) {
|
||||
SupportedModelScopes: req.SupportedModelScopes,
|
||||
AllowMessagesDispatch: req.AllowMessagesDispatch,
|
||||
AllowLive: req.AllowLive,
|
||||
ForceOpenAIFast: req.ForceOpenAIFast,
|
||||
FreeOpenAIFast: req.FreeOpenAIFast,
|
||||
RequireOAuthOnly: req.RequireOAuthOnly,
|
||||
RequirePrivacySet: req.RequirePrivacySet,
|
||||
DefaultMappedModel: req.DefaultMappedModel,
|
||||
@@ -686,6 +699,7 @@ func (h *GroupHandler) Update(c *gin.Context) {
|
||||
ModelsListConfig: req.ModelsListConfig,
|
||||
RPMLimit: req.RPMLimit,
|
||||
MaxReasoningEffort: req.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: req.MaxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: req.ReasoningEffortMappings,
|
||||
CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGroupRequestsDecodeForceOpenAIFast(t *testing.T) {
|
||||
var createReq CreateGroupRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"name":"fast","force_openai_fast":true,"free_openai_fast":true}`), &createReq))
|
||||
require.True(t, createReq.ForceOpenAIFast)
|
||||
require.True(t, createReq.FreeOpenAIFast)
|
||||
|
||||
var updateReq UpdateGroupRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"force_openai_fast":false,"free_openai_fast":false}`), &updateReq))
|
||||
require.NotNil(t, updateReq.ForceOpenAIFast)
|
||||
require.False(t, *updateReq.ForceOpenAIFast)
|
||||
require.NotNil(t, updateReq.FreeOpenAIFast)
|
||||
require.False(t, *updateReq.FreeOpenAIFast)
|
||||
|
||||
var omitted UpdateGroupRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{}`), &omitted))
|
||||
require.Nil(t, omitted.ForceOpenAIFast)
|
||||
require.Nil(t, omitted.FreeOpenAIFast)
|
||||
}
|
||||
@@ -29,4 +29,15 @@ func TestUpdateGroupRequestReasoningEffortMappingsTriState(t *testing.T) {
|
||||
require.Equal(t, "max", (*req.ReasoningEffortMappings)[0].From)
|
||||
require.Equal(t, "xhigh", (*req.ReasoningEffortMappings)[0].To)
|
||||
})
|
||||
|
||||
t.Run("accepts model scoped mappings", func(t *testing.T) {
|
||||
var req UpdateGroupRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"reasoning_effort_mappings":[{"from":"max","to":"low","match_type":"prefix","model":"gpt"}]}`), &req))
|
||||
require.NotNil(t, req.ReasoningEffortMappings)
|
||||
require.Len(t, *req.ReasoningEffortMappings, 1)
|
||||
require.Equal(t, "max", (*req.ReasoningEffortMappings)[0].From)
|
||||
require.Equal(t, "low", (*req.ReasoningEffortMappings)[0].To)
|
||||
require.Equal(t, "prefix", (*req.ReasoningEffortMappings)[0].MatchType)
|
||||
require.Equal(t, "gpt", (*req.ReasoningEffortMappings)[0].Model)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -66,27 +66,29 @@ type userAvailableGroup struct {
|
||||
|
||||
// userSupportedModelPricing 用户可见的定价字段白名单。
|
||||
type userSupportedModelPricing struct {
|
||||
BillingMode string `json:"billing_mode"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
ImageInputPrice *float64 `json:"image_input_price"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
Intervals []userPricingIntervalDTO `json:"intervals"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
ImageInputPrice *float64 `json:"image_input_price"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
Intervals []userPricingIntervalDTO `json:"intervals"`
|
||||
}
|
||||
|
||||
// userPricingIntervalDTO 定价区间白名单(去掉内部 ID、SortOrder 等前端不渲染的字段)。
|
||||
type userPricingIntervalDTO struct {
|
||||
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"`
|
||||
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"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
}
|
||||
|
||||
// userSupportedModel 用户可见的支持模型条目。
|
||||
@@ -292,14 +294,15 @@ func toUserPricingIntervals(src []service.PricingInterval) []userPricingInterval
|
||||
intervals := make([]userPricingIntervalDTO, 0, len(src))
|
||||
for _, iv := range src {
|
||||
intervals = append(intervals, userPricingIntervalDTO{
|
||||
MinTokens: iv.MinTokens,
|
||||
MaxTokens: iv.MaxTokens,
|
||||
TierLabel: iv.TierLabel,
|
||||
InputPrice: iv.InputPrice,
|
||||
OutputPrice: iv.OutputPrice,
|
||||
CacheWritePrice: iv.CacheWritePrice,
|
||||
CacheReadPrice: iv.CacheReadPrice,
|
||||
PerRequestPrice: iv.PerRequestPrice,
|
||||
MinTokens: iv.MinTokens,
|
||||
MaxTokens: iv.MaxTokens,
|
||||
TierLabel: iv.TierLabel,
|
||||
InputPrice: iv.InputPrice,
|
||||
OutputPrice: iv.OutputPrice,
|
||||
CacheWritePrice: iv.CacheWritePrice,
|
||||
CacheWrite1hPrice: iv.CacheWrite1hPrice,
|
||||
CacheReadPrice: iv.CacheReadPrice,
|
||||
PerRequestPrice: iv.PerRequestPrice,
|
||||
})
|
||||
}
|
||||
return intervals
|
||||
@@ -320,14 +323,15 @@ func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing {
|
||||
billingMode = string(service.BillingModeToken)
|
||||
}
|
||||
return &userSupportedModelPricing{
|
||||
BillingMode: billingMode,
|
||||
InputPrice: p.InputPrice,
|
||||
OutputPrice: p.OutputPrice,
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
BillingMode: billingMode,
|
||||
InputPrice: p.InputPrice,
|
||||
OutputPrice: p.OutputPrice,
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheWrite1hPrice: p.CacheWrite1hPrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -59,17 +60,17 @@ func effectiveAPIKeyPlatform(c *gin.Context, apiKey *service.APIKey) string {
|
||||
return apiKey.Group.Platform
|
||||
}
|
||||
|
||||
func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, bool) {
|
||||
func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, string, bool) {
|
||||
if apiKey == nil || apiKey.Group == nil {
|
||||
return "", nil, false
|
||||
return "", nil, "", false
|
||||
}
|
||||
if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite {
|
||||
return "", nil, false
|
||||
return "", nil, "", false
|
||||
}
|
||||
if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformOpenAI {
|
||||
return "", nil, false
|
||||
return "", nil, "", false
|
||||
}
|
||||
return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, true
|
||||
return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, apiKey.Group.MaxReasoningEffortOverLimit, true
|
||||
}
|
||||
|
||||
func bindRequestedReasoningEffort(c *gin.Context, body []byte, model string) {
|
||||
@@ -100,13 +101,21 @@ func stampForwardRequestedReasoningEffort(result *service.ForwardResult, request
|
||||
result.RequestedReasoningEffort = requested
|
||||
}
|
||||
|
||||
func applyOpenAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool) {
|
||||
func applyOpenAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool, error) {
|
||||
bindRequestedReasoningEffort(c, body, strings.TrimSpace(gjson.GetBytes(body, "model").String()))
|
||||
maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
maxEffort, mappings, overLimit, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
if !ok {
|
||||
return body, false
|
||||
return body, false, nil
|
||||
}
|
||||
return service.ApplyOpenAIReasoningEffortPolicy(body, maxEffort, mappings)
|
||||
return service.ApplyOpenAIReasoningEffortPolicy(body, maxEffort, mappings, overLimit)
|
||||
}
|
||||
|
||||
func respondOpenAIReasoningEffortPolicyError(c *gin.Context, err error, write func(*gin.Context, int, string, string)) {
|
||||
if c == nil || err == nil || write == nil {
|
||||
return
|
||||
}
|
||||
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalPolicyDenied)
|
||||
write(c, http.StatusForbidden, "permission_error", err.Error())
|
||||
}
|
||||
|
||||
func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *service.APIKey, body []byte) {
|
||||
@@ -121,9 +130,9 @@ func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *s
|
||||
if !effort.Exists() || effort.Type != gjson.String || strings.TrimSpace(effort.String()) == "" {
|
||||
return
|
||||
}
|
||||
maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
maxEffort, mappings, overLimit, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c.Request = c.Request.WithContext(service.WithOpenAIReasoningEffortPolicy(c.Request.Context(), maxEffort, mappings))
|
||||
c.Request = c.Request.WithContext(service.WithOpenAIReasoningEffortPolicy(c.Request.Context(), maxEffort, mappings, overLimit))
|
||||
}
|
||||
|
||||
@@ -117,7 +117,8 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
|
||||
openAICtx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
openAICtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
openAICtx.Request = openAICtx.Request.WithContext(service.WithResolvedTargetPlatform(openAICtx.Request.Context(), service.PlatformOpenAI))
|
||||
got, changed := applyOpenAIReasoningEffortPolicyForRequest(openAICtx, apiKey, body)
|
||||
got, changed, err := applyOpenAIReasoningEffortPolicyForRequest(openAICtx, apiKey, body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(got))
|
||||
requested := service.RequestedReasoningEffortFromContext(openAICtx.Request.Context())
|
||||
@@ -125,7 +126,8 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
|
||||
require.Equal(t, "max", *requested)
|
||||
|
||||
bindOpenAIReasoningEffortPolicyForMessagesRequest(openAICtx, apiKey, []byte(`{"output_config":{"effort":"max"}}`))
|
||||
bound, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(openAICtx.Request.Context(), body)
|
||||
bound, changed, err := service.ApplyOpenAIReasoningEffortPolicyFromContext(openAICtx.Request.Context(), body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(bound))
|
||||
|
||||
@@ -133,14 +135,27 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
|
||||
omittedCtx.Request = httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
omittedCtx.Request = omittedCtx.Request.WithContext(service.WithResolvedTargetPlatform(omittedCtx.Request.Context(), service.PlatformOpenAI))
|
||||
bindOpenAIReasoningEffortPolicyForMessagesRequest(omittedCtx, apiKey, []byte(`{"model":"gpt-5"}`))
|
||||
omitted, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(omittedCtx.Request.Context(), body)
|
||||
omitted, changed, err := service.ApplyOpenAIReasoningEffortPolicyFromContext(omittedCtx.Request.Context(), body)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, omitted)
|
||||
|
||||
denyGroup := *group
|
||||
denyGroup.MaxReasoningEffortOverLimit = service.ReasoningEffortOverLimitDeny
|
||||
denyAPIKey := &service.APIKey{Group: &denyGroup}
|
||||
denyCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
denyCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
denyCtx.Request = denyCtx.Request.WithContext(service.WithResolvedTargetPlatform(denyCtx.Request.Context(), service.PlatformOpenAI))
|
||||
_, _, err = applyOpenAIReasoningEffortPolicyForRequest(denyCtx, denyAPIKey, body)
|
||||
require.Error(t, err)
|
||||
var overLimit *service.ReasoningEffortOverLimitError
|
||||
require.ErrorAs(t, err, &overLimit)
|
||||
|
||||
grokCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
grokCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
grokCtx.Request = grokCtx.Request.WithContext(service.WithResolvedTargetPlatform(grokCtx.Request.Context(), service.PlatformGrok))
|
||||
got, changed = applyOpenAIReasoningEffortPolicyForRequest(grokCtx, apiKey, body)
|
||||
got, changed, err = applyOpenAIReasoningEffortPolicyForRequest(grokCtx, apiKey, body)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, got)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGroupMapperExposesForceOpenAIFastOnlyToAdmins(t *testing.T) {
|
||||
group := &service.Group{
|
||||
ID: 7, Name: "fast", Platform: service.PlatformOpenAI, Status: service.StatusActive, ForceOpenAIFast: true, FreeOpenAIFast: true,
|
||||
}
|
||||
|
||||
userJSON, err := json.Marshal(GroupFromService(group))
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(userJSON), "force_openai_fast")
|
||||
require.NotContains(t, string(userJSON), "free_openai_fast")
|
||||
|
||||
adminJSON, err := json.Marshal(GroupFromServiceAdmin(group))
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(adminJSON), `"force_openai_fast":true`)
|
||||
require.Contains(t, string(adminJSON), `"free_openai_fast":true`)
|
||||
}
|
||||
@@ -148,6 +148,8 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup {
|
||||
}
|
||||
out := &AdminGroup{
|
||||
Group: groupFromServiceBase(g),
|
||||
ForceOpenAIFast: g.ForceOpenAIFast,
|
||||
FreeOpenAIFast: g.FreeOpenAIFast,
|
||||
ProfitControlEnabled: g.ProfitControlEnabled,
|
||||
ProfitMinMargin: g.ProfitMinMargin,
|
||||
ProfitSafetyBuffer: g.ProfitSafetyBuffer,
|
||||
@@ -221,6 +223,7 @@ func groupFromServiceBase(g *service.Group) Group {
|
||||
RequirePrivacySet: g.RequirePrivacySet,
|
||||
RPMLimit: g.RPMLimit,
|
||||
MaxReasoningEffort: g.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: g.MaxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: g.ReasoningEffortMappings,
|
||||
CreatedAt: g.CreatedAt,
|
||||
UpdatedAt: g.UpdatedAt,
|
||||
|
||||
@@ -153,7 +153,9 @@ type Group struct {
|
||||
RPMLimit int `json:"rpm_limit"`
|
||||
// MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。
|
||||
MaxReasoningEffort string `json:"max_reasoning_effort"`
|
||||
// ReasoningEffortMappings OpenAI/Codex 推理强度精确映射。
|
||||
// MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。
|
||||
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"`
|
||||
// ReasoningEffortMappings OpenAI/Codex 推理强度映射,可按模型精确名、前缀或后缀限定。
|
||||
ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -164,6 +166,10 @@ type Group struct {
|
||||
// 注意:普通用户接口不得返回 model_routing/account_count/account_groups 等内部信息。
|
||||
type AdminGroup struct {
|
||||
Group
|
||||
// ForceOpenAIFast 是管理端请求策略,用户侧分组 DTO 无需暴露。
|
||||
ForceOpenAIFast bool `json:"force_openai_fast"`
|
||||
// FreeOpenAIFast 是管理端计费策略,用户侧分组 DTO 无需暴露。
|
||||
FreeOpenAIFast bool `json:"free_openai_fast"`
|
||||
|
||||
// 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数存储)。
|
||||
// 仅管理员可见:这三个字段与同响应中的 rate_multiplier 相乘即可反推出
|
||||
|
||||
@@ -279,7 +279,7 @@ func InboundEndpointMiddleware() gin.HandlerFunc {
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Context helpers — used by handlers before building
|
||||
// RecordUsageInput / RecordUsageLongContextInput.
|
||||
// RecordUsageInput.
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// GetInboundEndpoint returns the canonical inbound endpoint stored by
|
||||
|
||||
@@ -72,6 +72,13 @@ func (s *gatewayModelsAccountRepoStub) ListByGroup(ctx context.Context, groupID
|
||||
return s.ListSchedulableByGroupID(ctx, groupID)
|
||||
}
|
||||
|
||||
func (s *gatewayModelsAccountRepoStub) ListModelAvailabilityCandidates(ctx context.Context, groupID *int64, _ []string, _ bool) ([]service.Account, error) {
|
||||
if groupID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.ListSchedulableByGroupID(ctx, *groupID)
|
||||
}
|
||||
|
||||
func newGatewayModelsHandlerForTest(repo service.AccountRepository) *GatewayHandler {
|
||||
return &GatewayHandler{
|
||||
gatewayService: service.NewGatewayService(
|
||||
|
||||
@@ -569,33 +569,25 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
forceCacheBilling := fs.ForceCacheBilling
|
||||
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
|
||||
sessionID := service.ExtractClientSessionID(c)
|
||||
// 长上下文规则由计费服务统一持有(模型广场展示同源),入口只负责声明自己适用该规则。
|
||||
var longContextThreshold int
|
||||
var longContextMultiplier float64
|
||||
if rule := h.gatewayService.LegacyLongContextRule(service.PlatformGemini); rule != nil {
|
||||
longContextThreshold = rule.Threshold
|
||||
longContextMultiplier = rule.Multiplier
|
||||
}
|
||||
// 长上下文阶梯由目录数据驱动,统一在计费路径内生效,入口无需声明。
|
||||
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
|
||||
if err := h.gatewayService.RecordUsageWithLongContext(ctx, &service.RecordUsageLongContextInput{
|
||||
Result: result,
|
||||
QuotaPlatform: quotaPlatform,
|
||||
APIKey: apiKey,
|
||||
User: apiKey.User,
|
||||
Account: account,
|
||||
Subscription: subscription,
|
||||
PricingAt: pricingAt,
|
||||
InboundEndpoint: inboundEndpoint,
|
||||
UpstreamEndpoint: upstreamEndpoint,
|
||||
UserAgent: userAgent,
|
||||
IPAddress: clientIP,
|
||||
RequestPayloadHash: requestPayloadHash,
|
||||
LongContextThreshold: longContextThreshold,
|
||||
LongContextMultiplier: longContextMultiplier,
|
||||
ForceCacheBilling: forceCacheBilling,
|
||||
APIKeyService: h.apiKeyService,
|
||||
SessionID: sessionID,
|
||||
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
|
||||
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
||||
Result: result,
|
||||
QuotaPlatform: quotaPlatform,
|
||||
APIKey: apiKey,
|
||||
User: apiKey.User,
|
||||
Account: account,
|
||||
Subscription: subscription,
|
||||
PricingAt: pricingAt,
|
||||
InboundEndpoint: inboundEndpoint,
|
||||
UpstreamEndpoint: upstreamEndpoint,
|
||||
UserAgent: userAgent,
|
||||
IPAddress: clientIP,
|
||||
RequestPayloadHash: requestPayloadHash,
|
||||
ForceCacheBilling: forceCacheBilling,
|
||||
APIKeyService: h.apiKeyService,
|
||||
SessionID: sessionID,
|
||||
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
|
||||
}); err != nil {
|
||||
logger.L().With(
|
||||
zap.String("component", "handler.gemini_v1beta.models"),
|
||||
|
||||
@@ -42,6 +42,23 @@ func classifySelectionFailureError(err error, fallback noAccountErrorClassificat
|
||||
if err == nil {
|
||||
return fallback
|
||||
}
|
||||
// A 404 model_not_found fallback is authoritative and must not be downgraded
|
||||
// to a rate-limit verdict. classifyNoAccountError only reaches it through
|
||||
// DiagnoseModelAvailabilityForPlatform, a dedicated database query over
|
||||
// persistent eligibility (active + schedulable + model_mapping) that already
|
||||
// established no account in the group can serve this model at all. A transient
|
||||
// per-model cooldown on one of the remaining candidates does not make "all
|
||||
// available accounts are rate-limited" true.
|
||||
//
|
||||
// Reporting 429 here is actively harmful: retrying can never succeed, and
|
||||
// clients that treat 429 as a rate limit retry hard and swallow the body
|
||||
// (Codex surfaces only "exceeded retry limit, last status: 429"), losing the
|
||||
// one message that names the real problem. It also flips the ops attribution
|
||||
// from a local model-configuration issue to routing capacity, because call
|
||||
// sites gate markOpsRoutingCapacityLimitedIfNoAvailable on ModelNotFound.
|
||||
if fallback.ModelNotFound {
|
||||
return fallback
|
||||
}
|
||||
match := selectionModelRateLimitedPattern.FindStringSubmatch(strings.ToLower(err.Error()))
|
||||
if len(match) != 2 {
|
||||
return fallback
|
||||
|
||||
@@ -234,3 +234,72 @@ func TestClassifyNoAccountError_FromGin_NilContextStillSafe(t *testing.T) {
|
||||
require.False(t, service.HasOpsClientBusinessLimited(nil))
|
||||
require.Empty(t, service.OpsClientBusinessLimitedReason(nil))
|
||||
}
|
||||
|
||||
// 权威的 404 model_not_found 不能被"账号被限流"的 429 盖掉。
|
||||
//
|
||||
// 选号失败的错误串同时携带多种过滤原因,例如
|
||||
// "pool=9, filtered: model_not_supported=8 model_rate_limited=1":8 个账号根本不支持该模型,
|
||||
// 剩下 1 个恰好处于模型级冷却。此时 classifyNoAccountError 已通过持久化判据确认整个分组
|
||||
// 没有账号能服务该模型(ModelNotFound=true),改判成 429 "All available accounts are
|
||||
// currently rate-limited" 是错误诊断——重试永远不会成功,而把 429 当限流的客户端会反复
|
||||
// 重试并吞掉 body(Codex 只显示 "exceeded retry limit"),恰好丢掉唯一说明真实原因的信息。
|
||||
func TestClassifySelectionFailureError_ModelNotFoundIsNotOverriddenByRateLimited(t *testing.T) {
|
||||
modelNotFound := noAccountErrorClassification{
|
||||
Status: http.StatusNotFound,
|
||||
ErrType: "model_not_found",
|
||||
Message: `Model "gpt-5.3-codex" is not supported by any configured account in this group`,
|
||||
ModelNotFound: true,
|
||||
}
|
||||
|
||||
got := classifySelectionFailureError(
|
||||
fmt.Errorf("no available OpenAI accounts supporting model: gpt-5.3-codex "+
|
||||
"(pool=9, filtered: model_not_supported=8 model_rate_limited=1)"),
|
||||
modelNotFound,
|
||||
)
|
||||
|
||||
require.Equal(t, modelNotFound, got,
|
||||
"分组里没有任何账号能服务该模型时,模型级冷却不该把 404 改判成 429")
|
||||
}
|
||||
|
||||
// 真实调用点的顺序:先 classifyNoAccountErrorFromGin,再 classifySelectionFailureError。
|
||||
// 覆盖这条链路是为了同时锁住 ops 归因——调用点用 ModelNotFound 决定是否标记
|
||||
// routing capacity limited,一旦 404 被改判成 429,同一个请求会既被标成
|
||||
// local model configuration 又被标成容量问题,自相矛盾。
|
||||
func TestClassifySelectionFailureError_CallSiteChainKeepsModelNotFoundAttribution(t *testing.T) {
|
||||
c := newTestGinContextWithRequest()
|
||||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||||
apiKey := &service.APIKey{GroupID: ptrInt64(43)}
|
||||
|
||||
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5.3-codex", "gpt-5.3-codex", service.PlatformOpenAI)
|
||||
cls = classifySelectionFailureError(
|
||||
fmt.Errorf("no available OpenAI accounts supporting model: gpt-5.3-codex "+
|
||||
"(pool=9, filtered: model_not_supported=8 model_rate_limited=1)"),
|
||||
cls,
|
||||
)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, cls.Status)
|
||||
require.Equal(t, "model_not_found", cls.ErrType)
|
||||
require.True(t, cls.ModelNotFound)
|
||||
require.Contains(t, cls.Message, "gpt-5.3-codex")
|
||||
require.True(t, service.HasOpsClientBusinessLimited(c))
|
||||
require.Equal(t, service.OpsClientBusinessLimitedReasonLocalModelConfiguration, service.OpsClientBusinessLimitedReason(c))
|
||||
}
|
||||
|
||||
// 池子里确实存在能服务该模型、只是全部在冷却的账号时,429 改判仍需保留:
|
||||
// 这种情况 fallback 是 503(HasModelSupport=true),重试是有意义的。
|
||||
func TestClassifySelectionFailureError_StillUpgradesNonModelNotFoundFallback(t *testing.T) {
|
||||
fallback := noAccountErrorClassification{
|
||||
Status: http.StatusServiceUnavailable,
|
||||
ErrType: "api_error",
|
||||
Message: "Service temporarily unavailable",
|
||||
}
|
||||
|
||||
got := classifySelectionFailureError(
|
||||
fmt.Errorf("no available accounts supporting model: gpt-5.6-sol (total=3 eligible=0 model_rate_limited=3)"),
|
||||
fallback,
|
||||
)
|
||||
|
||||
require.Equal(t, http.StatusTooManyRequests, got.Status)
|
||||
require.Equal(t, "rate_limit_error", got.ErrType)
|
||||
require.False(t, got.ModelNotFound)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const automationBootstrapPrompt = "Review the project and report any important changes."
|
||||
|
||||
func codexAutomationBootstrap(automationID, lastRun, prompt string) string {
|
||||
return "Automation: Scheduled project review\n" +
|
||||
"Automation ID: " + automationID + "\n" +
|
||||
"Automation memory: $CODEX_HOME/automations/" + automationID + "/memory.md\n" +
|
||||
"Last run: " + lastRun + "\n\n" + prompt
|
||||
}
|
||||
|
||||
func codexAutomationBootstrapBody(t *testing.T, output, callID string) []byte {
|
||||
t.Helper()
|
||||
return []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` +
|
||||
mustJSON(t, output) + callID + `}]}`)
|
||||
}
|
||||
|
||||
func TestNormalizeCodexAutomationBootstrapSupportedLastRunValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
lastRun string
|
||||
crlf bool
|
||||
}{
|
||||
{name: "never", lastRun: "never"},
|
||||
{name: "timestamp", lastRun: "2026-09-01T02:06:34.536Z (1788228394536)"},
|
||||
{name: "crlf", lastRun: "never", crlf: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := codexAutomationBootstrap("wiki-maintenance", tt.lastRun, automationBootstrapPrompt)
|
||||
if tt.crlf {
|
||||
output = strings.ReplaceAll(output, "\n", "\r\n")
|
||||
}
|
||||
got, changed := normalizeCodexAutomationBootstrap(codexAutomationBootstrapBody(t, output, ""))
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String())
|
||||
require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String())
|
||||
require.Equal(t, output, gjson.GetBytes(got, "input.0.content.0.text").String())
|
||||
require.False(t, gjson.GetBytes(got, "input.0.call_id").Exists())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) {
|
||||
validOutput := codexAutomationBootstrap("wiki", "never", automationBootstrapPrompt)
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
}{
|
||||
{
|
||||
name: "ordinary missing call id",
|
||||
body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"other","output":` + mustJSON(t, validOutput) + `}]}`),
|
||||
},
|
||||
{
|
||||
name: "tui namespace",
|
||||
body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_tui","name":"automation_update","output":` + mustJSON(t, validOutput) + `}]}`),
|
||||
},
|
||||
{
|
||||
name: "valid call id",
|
||||
body: codexAutomationBootstrapBody(t, validOutput, `,"call_id":"call-1"`),
|
||||
},
|
||||
{
|
||||
name: "previous response",
|
||||
body: []byte(`{"model":"gpt-5","previous_response_id":"resp-1","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, validOutput) + `}]}`),
|
||||
},
|
||||
{
|
||||
name: "real call context",
|
||||
body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, validOutput) + `},{"type":"function_call","call_id":"call-1"}]}`),
|
||||
},
|
||||
{
|
||||
name: "heartbeat output",
|
||||
body: codexAutomationBootstrapBody(t, `<heartbeat><automation_id>wiki</automation_id></heartbeat>`, ""),
|
||||
},
|
||||
{
|
||||
name: "mismatched memory id",
|
||||
body: codexAutomationBootstrapBody(t, strings.Replace(validOutput, "/wiki/memory.md", "/other/memory.md", 1), ""),
|
||||
},
|
||||
{
|
||||
name: "unsafe automation id",
|
||||
body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("../wiki", "never", automationBootstrapPrompt), ""),
|
||||
},
|
||||
{
|
||||
name: "invalid timestamp",
|
||||
body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("wiki", "yesterday", automationBootstrapPrompt), ""),
|
||||
},
|
||||
{
|
||||
name: "mismatched timestamp epoch",
|
||||
body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("wiki", "2026-09-01T02:06:34.536Z (1)", automationBootstrapPrompt), ""),
|
||||
},
|
||||
{
|
||||
name: "missing separator",
|
||||
body: codexAutomationBootstrapBody(t, strings.Replace(validOutput, "\n\n"+automationBootstrapPrompt, "\n"+automationBootstrapPrompt, 1), ""),
|
||||
},
|
||||
{
|
||||
name: "empty prompt",
|
||||
body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("wiki", "never", " \n"), ""),
|
||||
},
|
||||
{
|
||||
name: "mixed missing call id output",
|
||||
body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, validOutput) + `},{"type":"computer_call_output","output":"done"}]}`),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, changed := normalizeCodexAutomationBootstrap(tt.body)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, tt.body, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexAutomationBootstrapPreservesOrderAndIsIdempotent(t *testing.T) {
|
||||
output := codexAutomationBootstrap("wiki", "never", automationBootstrapPrompt)
|
||||
body := []byte(`{"model":"gpt-5","input":[{"type":"message","role":"user","content":"before"},{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, output) + `},{"type":"message","role":"user","content":"after"}]}`)
|
||||
|
||||
got, changed := normalizeCodexAutomationBootstrap(body)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "before", gjson.GetBytes(got, "input.0.content").String())
|
||||
require.Equal(t, output, gjson.GetBytes(got, "input.1.content.0.text").String())
|
||||
require.Equal(t, "after", gjson.GetBytes(got, "input.2.content").String())
|
||||
|
||||
again, changedAgain := normalizeCodexAutomationBootstrap(got)
|
||||
require.False(t, changedAgain)
|
||||
require.Equal(t, got, again)
|
||||
}
|
||||
@@ -81,7 +81,10 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
|
||||
return
|
||||
}
|
||||
if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed {
|
||||
if cappedBody, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); err != nil {
|
||||
respondOpenAIReasoningEffortPolicyError(c, err, h.errorResponse)
|
||||
return
|
||||
} else if changed {
|
||||
body = cappedBody
|
||||
}
|
||||
reqStream, ok := parseOpenAICompatibleStream(body)
|
||||
|
||||
@@ -53,6 +53,10 @@ func (r codexModelsFailoverAccountRepo) ListByGroup(_ context.Context, _ int64)
|
||||
return append([]service.Account(nil), r.accounts...), nil
|
||||
}
|
||||
|
||||
func (r codexModelsFailoverAccountRepo) ListModelAvailabilityCandidates(_ context.Context, _ *int64, _ []string, _ bool) ([]service.Account, error) {
|
||||
return append([]service.Account(nil), r.accounts...), nil
|
||||
}
|
||||
|
||||
type codexModelsFailoverHTTPUpstream struct {
|
||||
service.HTTPUpstream
|
||||
mu sync.Mutex
|
||||
@@ -620,8 +624,8 @@ func requireCompleteCodexModelsHandlerResponse(t *testing.T, recorder *httptest.
|
||||
t.Fatalf("truncation_policy must be populated: %v", model["truncation_policy"])
|
||||
}
|
||||
modalities, ok := model["input_modalities"].([]any)
|
||||
if !ok || len(modalities) != 1 || modalities[0] != "text" {
|
||||
t.Fatalf("custom OpenAI-compatible endpoint modalities: got %v, want [text]", model["input_modalities"])
|
||||
if !ok || len(modalities) != 2 || modalities[0] != "text" || modalities[1] != "image" {
|
||||
t.Fatalf("known GPT model modalities: got %v, want [text image]", model["input_modalities"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const delegationEnvelope = `<codex_delegation><source_thread_id>thread-1</source_thread_id><input>do the work</input></codex_delegation>`
|
||||
|
||||
func TestNormalizeCodexDelegationBootstrapSupportedTools(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
namespace string
|
||||
tool string
|
||||
callID string
|
||||
}{
|
||||
{name: "app create missing call id", namespace: "codex_app", tool: "create_thread"},
|
||||
{name: "app send empty call id", namespace: "codex_app", tool: "send_message_to_thread", callID: `,"call_id":""`},
|
||||
{name: "tui create whitespace call id", namespace: "codex_tui", tool: "create_thread", callID: `,"call_id":" "`},
|
||||
{name: "tui send missing call id", namespace: "codex_tui", tool: "send_message_to_thread"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"` + tt.namespace + `","name":"` + tt.tool + `","output":"` + delegationEnvelope + `"` + tt.callID + `}]}`)
|
||||
got, changed := normalizeCodexDelegationBootstrap(body)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String())
|
||||
require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String())
|
||||
require.Equal(t, delegationEnvelope, gjson.GetBytes(got, "input.0.content.0.text").String())
|
||||
require.False(t, gjson.GetBytes(got, "input.0.call_id").Exists())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexDelegationBootstrapRejectsUnsafeShapes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item string
|
||||
extra string
|
||||
}{
|
||||
{name: "ordinary missing call id", item: `{"type":"function_call_output","name":"other","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
|
||||
{name: "valid call id", item: `{"type":"function_call_output","call_id":"call-1","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
|
||||
{name: "previous response", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`, extra: `,"previous_response_id":"resp-1"`},
|
||||
{name: "real call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call","call_id":"call-1"}`},
|
||||
{name: "ambiguous call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call"}`},
|
||||
{name: "item reference context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"item_reference","id":"call-1"}`},
|
||||
{name: "built-in call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call","call_id":"call-1"}`},
|
||||
{name: "ambiguous built-in call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call"}`},
|
||||
{name: "mixed missing call id output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call_output","output":"done"}`},
|
||||
{name: "mixed tool search output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"tool_search_output","output":"done"}`},
|
||||
{name: "mixed invalid delegation output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"not an envelope"}`},
|
||||
{name: "non string call id", item: `{"type":"function_call_output","call_id":42,"name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
|
||||
{name: "padded type", item: `{"type":" function_call_output ","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
|
||||
{name: "padded name", item: `{"type":"function_call_output","name":" create_thread ","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
|
||||
{name: "padded namespace", item: `{"type":"function_call_output","name":"create_thread","namespace":" codex_app ","output":"` + delegationEnvelope + `"}`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5","input":[` + tt.item + `]` + tt.extra + `}`)
|
||||
got, changed := normalizeCodexDelegationBootstrap(body)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexDelegationBootstrapPreviousResponseID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
changed bool
|
||||
}{
|
||||
{name: "absent", changed: true},
|
||||
{name: "blank string", value: `,"previous_response_id":" "`, changed: true},
|
||||
{name: "non-empty string", value: `,"previous_response_id":"resp-1"`},
|
||||
{name: "null", value: `,"previous_response_id":null`},
|
||||
{name: "number", value: `,"previous_response_id":42`},
|
||||
{name: "boolean", value: `,"previous_response_id":false`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]` + tt.value + `}`)
|
||||
got, changed := normalizeCodexDelegationBootstrap(body)
|
||||
require.Equal(t, tt.changed, changed)
|
||||
if !tt.changed {
|
||||
require.Equal(t, body, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexDelegationBootstrapRejectsDuplicateMembers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "top-level previous response id",
|
||||
body: `{"model":"gpt-5","previous_response_id":"","previous_response_id":"resp-1","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]}`,
|
||||
},
|
||||
{
|
||||
name: "candidate discriminator",
|
||||
body: `{"model":"gpt-5","input":[{"type":"message","type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]}`,
|
||||
},
|
||||
{
|
||||
name: "candidate call id",
|
||||
body: `{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","call_id":"call-1","call_id":"","output":"` + delegationEnvelope + `"}]}`,
|
||||
},
|
||||
{
|
||||
name: "candidate output",
|
||||
body: `{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"not a delegation","output":"` + delegationEnvelope + `"}]}`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body := []byte(tt.body)
|
||||
got, changed := normalizeCodexDelegationBootstrap(body)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexDelegationBootstrapPreservesExactNumbers(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5","metadata":{"integer":9007199254740993},"input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]}`)
|
||||
got, changed := normalizeCodexDelegationBootstrap(body)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "9007199254740993", gjson.GetBytes(got, "metadata.integer").Raw)
|
||||
}
|
||||
|
||||
func TestNormalizeCodexDelegationBootstrapRequiresCompleteEnvelope(t *testing.T) {
|
||||
malformed := []string{
|
||||
`<codex_delegation><source_thread_id>thread-1</source_thread_id></codex_delegation>`,
|
||||
`<codex_delegation><source_thread_id></source_thread_id><input>work</input></codex_delegation>`,
|
||||
`<codex_delegation><source_thread_id>thread-1</source_thread_id><input> </input></codex_delegation>`,
|
||||
`prefix<codex_delegation><source_thread_id>thread-1</source_thread_id><input>work</input></codex_delegation>`,
|
||||
`<codex_delegation><source_thread_id>thread-1</source_thread_id><input>work</input></codex_delegation>suffix`,
|
||||
`<codex_delegation><source_thread_id>thread-1</source_thread_id><input>work</input><extra>x</extra></codex_delegation>`,
|
||||
`<codex_delegation><source_thread_id>thread-1</source_thread_id><input><nested>work</nested></input></codex_delegation>`,
|
||||
`<x:codex_delegation xmlns:x="urn:test"><source_thread_id>thread-1</source_thread_id><input>work</input></x:codex_delegation>`,
|
||||
`<x:codex_delegation><source_thread_id>thread-1</source_thread_id><input>work</input></x:codex_delegation>`,
|
||||
`<codex_delegation xmlns="urn:test"><source_thread_id>thread-1</source_thread_id><input>work</input></codex_delegation>`,
|
||||
`<codex_delegation xmlns:x="urn:test"><x:source_thread_id>thread-1</x:source_thread_id><input>work</input></codex_delegation>`,
|
||||
`<codex_delegation><x:source_thread_id>thread-1</x:source_thread_id><input>work</input></codex_delegation>`,
|
||||
`<codex_delegation xmlns=""><source_thread_id>thread-1</source_thread_id><input>work</input></codex_delegation>`,
|
||||
}
|
||||
for _, envelope := range malformed {
|
||||
body := []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":` + mustJSON(t, envelope) + `}]}`)
|
||||
got, changed := normalizeCodexDelegationBootstrap(body)
|
||||
require.False(t, changed, envelope)
|
||||
require.Equal(t, body, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexDelegationBootstrapPreservesOrderAndIsIdempotent(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5","input":[{"type":"message","role":"user","content":"before"},{"type":"function_call_output","namespace":"codex_tui","name":"send_message_to_thread","output":"` + delegationEnvelope + `"},{"type":"message","role":"user","content":"after"}]}`)
|
||||
got, changed := normalizeCodexDelegationBootstrap(body)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "before", gjson.GetBytes(got, "input.0.content").String())
|
||||
require.Equal(t, delegationEnvelope, gjson.GetBytes(got, "input.1.content.0.text").String())
|
||||
require.Equal(t, "after", gjson.GetBytes(got, "input.2.content").String())
|
||||
|
||||
again, changedAgain := normalizeCodexDelegationBootstrap(got)
|
||||
require.False(t, changedAgain)
|
||||
require.Equal(t, got, again)
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, value string) string {
|
||||
t.Helper()
|
||||
encoded, err := json.Marshal(value)
|
||||
require.NoError(t, err)
|
||||
return string(encoded)
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
@@ -432,9 +435,24 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
|
||||
return
|
||||
}
|
||||
if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed {
|
||||
if cappedBody, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); err != nil {
|
||||
respondOpenAIReasoningEffortPolicyError(c, err, h.errorResponse)
|
||||
return
|
||||
} else if changed {
|
||||
body = cappedBody
|
||||
}
|
||||
if normalizedBody, changed := normalizeCodexAutomationBootstrap(body); changed {
|
||||
body = normalizedBody
|
||||
reqLog.Info("openai.codex_automation_bootstrap_normalized",
|
||||
zap.String("normalization", "call_output_to_user_message"),
|
||||
)
|
||||
}
|
||||
if normalizedBody, changed := normalizeCodexDelegationBootstrap(body); changed {
|
||||
body = normalizedBody
|
||||
reqLog.Info("openai.codex_delegation_bootstrap_normalized",
|
||||
zap.String("normalization", "call_output_to_user_message"),
|
||||
)
|
||||
}
|
||||
|
||||
reqStream, ok := parseOpenAICompatibleStream(body)
|
||||
if !ok {
|
||||
@@ -1581,6 +1599,312 @@ func (h *OpenAIGatewayHandler) validateFunctionCallOutputRequest(c *gin.Context,
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeCodexDelegationBootstrap(body []byte) ([]byte, bool) {
|
||||
return normalizeCodexCallOutputBootstrap(body, isCodexDelegationCandidate)
|
||||
}
|
||||
|
||||
func normalizeCodexAutomationBootstrap(body []byte) ([]byte, bool) {
|
||||
return normalizeCodexCallOutputBootstrap(body, isCodexAutomationCandidate)
|
||||
}
|
||||
|
||||
func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]any) bool) ([]byte, bool) {
|
||||
if !hasUniqueJSONMembers(body) {
|
||||
return body, false
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.UseNumber()
|
||||
var request map[string]any
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
return body, false
|
||||
}
|
||||
if previousResponseID, exists := request["previous_response_id"]; exists {
|
||||
value, ok := previousResponseID.(string)
|
||||
if !ok || strings.TrimSpace(value) != "" {
|
||||
return body, false
|
||||
}
|
||||
}
|
||||
input, ok := request["input"].([]any)
|
||||
if !ok {
|
||||
return body, false
|
||||
}
|
||||
|
||||
// Any call/reference anchor makes a call-less output ambiguous. Responses
|
||||
// built-ins follow the *_call / *_call_output naming convention, so classify
|
||||
// by the wire type shape instead of maintaining an incomplete allowlist.
|
||||
for _, raw := range input {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
typ := stringField(item, "type")
|
||||
if typ == "item_reference" || strings.HasSuffix(typ, "_call") {
|
||||
return body, false
|
||||
}
|
||||
if isResponsesCallOutputType(typ) {
|
||||
callIDValue, exists := item["call_id"]
|
||||
callID, isString := callIDValue.(string)
|
||||
if exists && (!isString || strings.TrimSpace(callID) != "") {
|
||||
return body, false
|
||||
}
|
||||
if !isCandidate(item) {
|
||||
return body, false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
changed := false
|
||||
for i, raw := range input {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok || !isCandidate(item) {
|
||||
continue
|
||||
}
|
||||
output, ok := item["output"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
input[i] = map[string]any{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": []any{map[string]any{
|
||||
"type": "input_text",
|
||||
"text": output,
|
||||
}},
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return body, false
|
||||
}
|
||||
normalized, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return body, false
|
||||
}
|
||||
return normalized, true
|
||||
}
|
||||
|
||||
func hasUniqueJSONMembers(body []byte) bool {
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
if !consumeUniqueJSONValue(decoder) {
|
||||
return false
|
||||
}
|
||||
_, err := decoder.Token()
|
||||
return err == io.EOF
|
||||
}
|
||||
|
||||
func consumeUniqueJSONValue(decoder *json.Decoder) bool {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
delim, ok := token.(json.Delim)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
switch delim {
|
||||
case '{':
|
||||
members := make(map[string]struct{})
|
||||
for decoder.More() {
|
||||
keyToken, err := decoder.Token()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
key, ok := keyToken.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if _, duplicate := members[key]; duplicate {
|
||||
return false
|
||||
}
|
||||
members[key] = struct{}{}
|
||||
if !consumeUniqueJSONValue(decoder) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
end, err := decoder.Token()
|
||||
return err == nil && end == json.Delim('}')
|
||||
case '[':
|
||||
for decoder.More() {
|
||||
if !consumeUniqueJSONValue(decoder) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
end, err := decoder.Token()
|
||||
return err == nil && end == json.Delim(']')
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isResponsesCallOutputType(typ string) bool {
|
||||
return strings.HasSuffix(typ, "_call_output") || typ == "tool_search_output"
|
||||
}
|
||||
|
||||
func isCodexDelegationCandidate(item map[string]any) bool {
|
||||
if stringField(item, "type") != "function_call_output" ||
|
||||
!isCodexDelegationTool(stringField(item, "namespace"), stringField(item, "name")) {
|
||||
return false
|
||||
}
|
||||
output, ok := item["output"].(string)
|
||||
return ok && validCodexDelegationEnvelope(output)
|
||||
}
|
||||
|
||||
func isCodexAutomationCandidate(item map[string]any) bool {
|
||||
if stringField(item, "type") != "function_call_output" ||
|
||||
stringField(item, "namespace") != "codex_app" ||
|
||||
stringField(item, "name") != "automation_update" {
|
||||
return false
|
||||
}
|
||||
output, ok := item["output"].(string)
|
||||
return ok && validCodexAutomationBootstrap(output)
|
||||
}
|
||||
|
||||
func stringField(item map[string]any, key string) string {
|
||||
value, _ := item[key].(string)
|
||||
return value
|
||||
}
|
||||
|
||||
func isCodexDelegationTool(namespace, name string) bool {
|
||||
return (namespace == "codex_app" || namespace == "codex_tui") &&
|
||||
(name == "create_thread" || name == "send_message_to_thread")
|
||||
}
|
||||
|
||||
func validCodexAutomationBootstrap(value string) bool {
|
||||
normalized := strings.ReplaceAll(value, "\r\n", "\n")
|
||||
if strings.ContainsRune(normalized, '\r') {
|
||||
return false
|
||||
}
|
||||
lines := strings.Split(normalized, "\n")
|
||||
if len(lines) < 6 {
|
||||
return false
|
||||
}
|
||||
if _, ok := codexAutomationHeaderValue(lines[0], "Automation: "); !ok {
|
||||
return false
|
||||
}
|
||||
automationID, ok := codexAutomationHeaderValue(lines[1], "Automation ID: ")
|
||||
if !ok || !validCodexAutomationID(automationID) {
|
||||
return false
|
||||
}
|
||||
expectedMemory := "Automation memory: $CODEX_HOME/automations/" + automationID + "/memory.md"
|
||||
if lines[2] != expectedMemory {
|
||||
return false
|
||||
}
|
||||
lastRun, ok := codexAutomationHeaderValue(lines[3], "Last run: ")
|
||||
if !ok || !validCodexAutomationLastRun(lastRun) || lines[4] != "" {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(lines[5:], "\n")) != ""
|
||||
}
|
||||
|
||||
func codexAutomationHeaderValue(line, prefix string) (string, bool) {
|
||||
if !strings.HasPrefix(line, prefix) {
|
||||
return "", false
|
||||
}
|
||||
value := strings.TrimPrefix(line, prefix)
|
||||
return value, value != "" && strings.TrimSpace(value) == value
|
||||
}
|
||||
|
||||
func validCodexAutomationID(value string) bool {
|
||||
if len(value) == 0 || len(value) > 128 || value == "." || value == ".." {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(value); i++ {
|
||||
c := value[i]
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validCodexAutomationLastRun(value string) bool {
|
||||
if value == "never" {
|
||||
return true
|
||||
}
|
||||
separator := strings.LastIndex(value, " (")
|
||||
if separator <= 0 || !strings.HasSuffix(value, ")") {
|
||||
return false
|
||||
}
|
||||
runAt, err := time.Parse(time.RFC3339Nano, value[:separator])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
epochMillis, err := strconv.ParseInt(value[separator+2:len(value)-1], 10, 64)
|
||||
return err == nil && runAt.UnixMilli() == epochMillis
|
||||
}
|
||||
|
||||
func validCodexDelegationEnvelope(value string) bool {
|
||||
decoder := xml.NewDecoder(strings.NewReader(value))
|
||||
var rootSeen, sourceSeen, inputSeen bool
|
||||
var childName string
|
||||
var childText bytes.Buffer
|
||||
depth := 0
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
return rootSeen && depth == 0 && sourceSeen && inputSeen
|
||||
}
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
switch current := token.(type) {
|
||||
case xml.StartElement:
|
||||
depth++
|
||||
if current.Name.Space != "" || len(current.Attr) != 0 || (depth == 1 && current.Name.Local != "codex_delegation") || depth > 2 {
|
||||
return false
|
||||
}
|
||||
if depth == 1 {
|
||||
if rootSeen {
|
||||
return false
|
||||
}
|
||||
rootSeen = true
|
||||
continue
|
||||
}
|
||||
if current.Name.Local != "source_thread_id" && current.Name.Local != "input" {
|
||||
return false
|
||||
}
|
||||
childName = current.Name.Local
|
||||
childText.Reset()
|
||||
case xml.EndElement:
|
||||
if current.Name.Space != "" {
|
||||
return false
|
||||
}
|
||||
if depth == 2 {
|
||||
if current.Name.Local != childName || strings.TrimSpace(childText.String()) == "" {
|
||||
return false
|
||||
}
|
||||
if childName == "source_thread_id" {
|
||||
if sourceSeen {
|
||||
return false
|
||||
}
|
||||
sourceSeen = true
|
||||
} else {
|
||||
if inputSeen {
|
||||
return false
|
||||
}
|
||||
inputSeen = true
|
||||
}
|
||||
childName = ""
|
||||
}
|
||||
depth--
|
||||
if depth < 0 {
|
||||
return false
|
||||
}
|
||||
case xml.CharData:
|
||||
if depth == 2 {
|
||||
_, _ = childText.Write(current)
|
||||
} else if len(bytes.TrimSpace(current)) != 0 {
|
||||
return false
|
||||
}
|
||||
case xml.Comment:
|
||||
return false
|
||||
case xml.ProcInst, xml.Directive:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *OpenAIGatewayHandler) acquireResponsesUserSlot(
|
||||
c *gin.Context,
|
||||
userID int64,
|
||||
@@ -2298,7 +2622,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
zap.Int("candidate_count", scheduleDecision.CandidateCount),
|
||||
)
|
||||
|
||||
maxReasoningEffort, reasoningEffortMappings, _ := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
maxReasoningEffort, reasoningEffortMappings, maxReasoningEffortOverLimit, _ := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
var requestPayloadHash string
|
||||
var turnStartsMu sync.Mutex
|
||||
turnStarts := make(map[int]time.Time, 4)
|
||||
@@ -2325,12 +2649,13 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
// passthrough 没有 BeforeTurn 时,AfterTurn 回退到 TurnStarted 的所属 turn 时刻。
|
||||
var turnPricing openAIWSTurnPricing
|
||||
hooks := &service.OpenAIWSIngressHooks{
|
||||
ClientLifecycleContext: clientLifecycleCtx,
|
||||
InitialRequestModel: reqModel,
|
||||
InitialTurnStartedAt: firstTurnStartedAt,
|
||||
MaxReasoningEffort: maxReasoningEffort,
|
||||
ReasoningEffortMappings: reasoningEffortMappings,
|
||||
TurnStarted: recordTurnStart,
|
||||
ClientLifecycleContext: clientLifecycleCtx,
|
||||
InitialRequestModel: reqModel,
|
||||
InitialTurnStartedAt: firstTurnStartedAt,
|
||||
MaxReasoningEffort: maxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: maxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: reasoningEffortMappings,
|
||||
TurnStarted: recordTurnStart,
|
||||
BeforeRequest: func(turn int, payload []byte, originalModel string) error {
|
||||
c.Set(securityAuditWSTurnContextKey, turn)
|
||||
service.BeginOpsStreamTurn(c, turn)
|
||||
|
||||
@@ -2287,6 +2287,7 @@ func isOpsLocalBusinessLimitError(code string, msg string) bool {
|
||||
strings.Contains(msg, "this group is restricted to claude code clients") ||
|
||||
strings.Contains(msg, "this group does not allow /v1/messages dispatch") ||
|
||||
strings.Contains(msg, "image generation is not enabled for this group") ||
|
||||
(strings.Contains(msg, "reasoning effort") && strings.Contains(msg, "exceeds this group's limit")) ||
|
||||
strings.Contains(msg, "token counting is not supported for this platform") ||
|
||||
strings.Contains(msg, "images api is not supported for this platform") ||
|
||||
(strings.Contains(msg, "model ") && strings.Contains(msg, " not in whitelist")) ||
|
||||
|
||||
@@ -1243,6 +1243,15 @@ func TestClassifyOpsLocalBusinessLimitErrorsExcludedFromSLA(t *testing.T) {
|
||||
wantErrType: "permission_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
name: "group reasoning effort over limit deny",
|
||||
errType: "permission_error",
|
||||
message: `reasoning effort "high" exceeds this group's limit of "low"`,
|
||||
code: "",
|
||||
status: http.StatusForbidden,
|
||||
wantErrType: "permission_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
name: "route token counting platform unsupported",
|
||||
errType: "not_found_error",
|
||||
@@ -1504,6 +1513,12 @@ func TestClassifyOpsUpstreamAuthTextStillCountsForSLA(t *testing.T) {
|
||||
code: "403",
|
||||
status: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "provider reasoning effort over limit shaped error",
|
||||
message: `reasoning effort "high" exceeds this group's limit of "low"`,
|
||||
code: "403",
|
||||
status: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "provider token counting unsupported shaped error",
|
||||
message: "Token counting is not supported for this platform",
|
||||
|
||||
@@ -153,6 +153,7 @@ type modelDef struct {
|
||||
|
||||
// Antigravity 支持的 Claude 模型
|
||||
var claudeModels = []modelDef{
|
||||
{ID: "claude-fable-5-1", DisplayName: "Claude Fable 5.1", CreatedAt: "2026-09-01T00:00:00Z"},
|
||||
{ID: "claude-fable-5", DisplayName: "Claude Fable 5", CreatedAt: "2026-06-09T00:00:00Z"},
|
||||
{ID: "claude-opus-4-5-thinking", DisplayName: "Claude Opus 4.5 Thinking", CreatedAt: "2025-11-01T00:00:00Z"},
|
||||
{ID: "claude-sonnet-4-5", DisplayName: "Claude Sonnet 4.5", CreatedAt: "2025-09-29T00:00:00Z"},
|
||||
|
||||
@@ -12,6 +12,7 @@ func TestDefaultModels_ContainsNewAndLegacyImageModels(t *testing.T) {
|
||||
}
|
||||
|
||||
requiredIDs := []string{
|
||||
"claude-fable-5-1",
|
||||
"claude-fable-5",
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-4-6-thinking",
|
||||
|
||||
@@ -222,6 +222,7 @@ type modelInfo struct {
|
||||
// 只有在此映射表中的模型才会注入身份提示词
|
||||
// 注意:模型映射逻辑在网关层完成;这里仅用于按模型前缀判断是否注入身份提示词。
|
||||
var modelInfoMap = map[string]modelInfo{
|
||||
"claude-fable-5-1": {DisplayName: "Claude Fable 5.1", CanonicalID: "claude-fable-5-1"},
|
||||
"claude-fable-5": {DisplayName: "Claude Fable 5", CanonicalID: "claude-fable-5"},
|
||||
"claude-opus-4-8": {DisplayName: "Claude Opus 4.8", CanonicalID: "claude-opus-4-8"},
|
||||
"claude-opus-4-7": {DisplayName: "Claude Opus 4.7", CanonicalID: "claude-opus-4-7"},
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//go:build unit
|
||||
|
||||
package antigravity
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Gemini usageMetadata 只有 cachedContentTokenCount(缓存命中),没有缓存写入的 token 类别:
|
||||
// 两条转换路径产出的 Claude usage 中 cache_creation_input_tokens 恒为 0,缓存命中计入
|
||||
// cache_read_input_tokens 并从 input_tokens 中扣除。
|
||||
func TestGeminiUsageMapping_NoCacheCreationTokens(t *testing.T) {
|
||||
const geminiBody = `{"candidates":[{"content":{"parts":[{"text":"hi"}],"role":"model"},"finishReason":"STOP"}],` +
|
||||
`"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"cachedContentTokenCount":30,"thoughtsTokenCount":5}}`
|
||||
|
||||
t.Run("non-stream", func(t *testing.T) {
|
||||
_, usage, err := TransformGeminiToClaude([]byte(geminiBody), "gemini-3.1-pro-preview")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, usage)
|
||||
require.Equal(t, 70, usage.InputTokens)
|
||||
require.Equal(t, 25, usage.OutputTokens)
|
||||
require.Equal(t, 30, usage.CacheReadInputTokens)
|
||||
require.Zero(t, usage.CacheCreationInputTokens)
|
||||
})
|
||||
|
||||
t.Run("stream", func(t *testing.T) {
|
||||
p := NewStreamingProcessor("gemini-3.1-pro-preview")
|
||||
out := p.ProcessLine(`data: {"response":` + geminiBody + `}`)
|
||||
require.True(t, strings.Contains(string(out), `"message_start"`))
|
||||
require.NotContains(t, string(out), `"cache_creation_input_tokens"`, "message_start 的 usage 不应携带缓存写入分项")
|
||||
_, usage := p.Finish()
|
||||
require.NotNil(t, usage)
|
||||
require.Equal(t, 70, usage.InputTokens)
|
||||
require.Equal(t, 30, usage.CacheReadInputTokens)
|
||||
require.Zero(t, usage.CacheCreationInputTokens)
|
||||
})
|
||||
}
|
||||
@@ -25,6 +25,17 @@ const (
|
||||
BetaRedactThinking = "redact-thinking-2026-02-12"
|
||||
BetaContextManagement = "context-management-2025-06-27"
|
||||
BetaExtendedCacheTTL = "extended-cache-ttl-2025-04-11"
|
||||
|
||||
// server-side refusal fallback beta 字段族(beta Messages API 专有)。
|
||||
// 客户端(Claude Code / SDK / OpenCode 等)会默认透传 body.fallbacks /
|
||||
// body.fallback_credit_token,上游仅在 anthropic-beta 携带对应 token 时接受;
|
||||
// 缺 token 时 Pydantic 拒收:"fallbacks: Extra inputs are not permitted"。
|
||||
// 仅用于 sanitize 的条件判断(strip-or-keep),禁止加入
|
||||
// FullClaudeCodeMimicryBetas / DefaultBetaHeader / APIKeyBetaHeader /
|
||||
// Bedrock 白名单:server-side fallback 会换模型、改计费,不能默认打开。
|
||||
BetaServerSideFallback = "server-side-fallback-2026-07-01"
|
||||
BetaFallbackCredit = "fallback-credit-2026-07-01"
|
||||
BetaFallbackCreditLegacy = "fallback-credit-2026-06-01"
|
||||
)
|
||||
|
||||
// DroppedBetas 是转发时需要从 anthropic-beta header 中移除的 beta token 列表。
|
||||
@@ -117,6 +128,12 @@ type Model struct {
|
||||
|
||||
// DefaultModels Claude Code 客户端支持的默认模型列表
|
||||
var DefaultModels = []Model{
|
||||
{
|
||||
ID: "claude-fable-5-1",
|
||||
Type: "model",
|
||||
DisplayName: "Claude Fable 5.1",
|
||||
CreatedAt: "2026-09-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
ID: "claude-fable-5",
|
||||
Type: "model",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDefaultModelsContainsClaudeFable51(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, model := range DefaultModels {
|
||||
if model.ID == "claude-fable-5-1" {
|
||||
require.Equal(t, "Claude Fable 5.1", model.DisplayName)
|
||||
require.Equal(t, "2026-09-01T00:00:00Z", model.CreatedAt)
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("claude-fable-5-1 missing from DefaultModels")
|
||||
}
|
||||
@@ -210,11 +210,14 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
|
||||
group.FieldSupportedModelScopes,
|
||||
group.FieldAllowMessagesDispatch,
|
||||
group.FieldAllowLive,
|
||||
group.FieldForceOpenaiFast,
|
||||
group.FieldFreeOpenaiFast,
|
||||
group.FieldDefaultMappedModel,
|
||||
group.FieldMessagesDispatchModelConfig,
|
||||
group.FieldModelsListConfig,
|
||||
group.FieldRpmLimit,
|
||||
group.FieldMaxReasoningEffort,
|
||||
group.FieldMaxReasoningEffortOverLimit,
|
||||
group.FieldReasoningEffortMappings,
|
||||
group.FieldPeakRateEnabled,
|
||||
group.FieldPeakStart,
|
||||
@@ -1007,6 +1010,8 @@ func groupEntityToService(g *dbent.Group) *service.Group {
|
||||
SortOrder: g.SortOrder,
|
||||
AllowMessagesDispatch: g.AllowMessagesDispatch,
|
||||
AllowLive: g.AllowLive,
|
||||
ForceOpenAIFast: g.ForceOpenaiFast,
|
||||
FreeOpenAIFast: g.FreeOpenaiFast,
|
||||
RequireOAuthOnly: g.RequireOauthOnly,
|
||||
RequirePrivacySet: g.RequirePrivacySet,
|
||||
DefaultMappedModel: g.DefaultMappedModel,
|
||||
@@ -1014,6 +1019,7 @@ func groupEntityToService(g *dbent.Group) *service.Group {
|
||||
ModelsListConfig: g.ModelsListConfig,
|
||||
RPMLimit: g.RpmLimit,
|
||||
MaxReasoningEffort: g.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: g.MaxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: g.ReasoningEffortMappings,
|
||||
PeakRateEnabled: g.PeakRateEnabled,
|
||||
PeakStart: g.PeakStart,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
//go:build integration
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetByKeyForAuthCarriesGroupForceOpenAIFast(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
suffix := time.Now().UnixNano()
|
||||
group := mustCreateGroup(t, integrationEntClient, &service.Group{
|
||||
Name: fmt.Sprintf("fast-proj-group-%d", suffix), Platform: service.PlatformOpenAI,
|
||||
RateMultiplier: 1, ForceOpenAIFast: true, FreeOpenAIFast: true,
|
||||
})
|
||||
user := mustCreateUser(t, integrationEntClient, &service.User{
|
||||
Email: fmt.Sprintf("fast-proj-%d@example.com", suffix), Concurrency: 5,
|
||||
})
|
||||
groupID := group.ID
|
||||
keyValue := fmt.Sprintf("sk-fast-proj-%d", suffix)
|
||||
apiKeyRepo := NewAPIKeyRepository(integrationEntClient, integrationDB)
|
||||
key := &service.APIKey{UserID: user.ID, GroupID: &groupID, Key: keyValue, Name: "fast-proj", Status: service.StatusActive}
|
||||
require.NoError(t, apiKeyRepo.Create(ctx, key))
|
||||
t.Cleanup(func() {
|
||||
_, err := integrationDB.ExecContext(ctx, "DELETE FROM auth_cache_invalidation_outbox WHERE cache_key = encode(sha256(convert_to($1, 'UTF8')), 'hex')", keyValue)
|
||||
require.NoError(t, err)
|
||||
_, err = integrationDB.ExecContext(ctx, "DELETE FROM api_keys WHERE id = $1", key.ID)
|
||||
require.NoError(t, err)
|
||||
_, err = integrationDB.ExecContext(ctx, "DELETE FROM users WHERE id = $1", user.ID)
|
||||
require.NoError(t, err)
|
||||
_, err = integrationDB.ExecContext(ctx, "DELETE FROM groups WHERE id = $1", group.ID)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
got, err := apiKeyRepo.GetByKeyForAuth(ctx, keyValue)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got.Group)
|
||||
require.True(t, got.Group.ForceOpenAIFast)
|
||||
require.True(t, got.Group.FreeOpenAIFast)
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func (r *channelRepository) batchLoadAccountStatsModelPricing(ctx context.Contex
|
||||
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, rule_id, platform, models, billing_mode, input_price, output_price,
|
||||
cache_write_price, cache_read_price, image_output_price, per_request_price, created_at, updated_at
|
||||
cache_write_price, cache_write_1h_price, cache_read_price, image_output_price, per_request_price, created_at, updated_at
|
||||
FROM channel_account_stats_model_pricing WHERE rule_id = ANY($1) ORDER BY rule_id, id`,
|
||||
pq.Array(ruleIDs),
|
||||
)
|
||||
@@ -83,7 +83,7 @@ func (r *channelRepository) batchLoadAccountStatsModelPricing(ctx context.Contex
|
||||
var modelsJSON []byte
|
||||
if err := rows.Scan(
|
||||
&p.ID, &ruleID, &p.Platform, &modelsJSON, &p.BillingMode,
|
||||
&p.InputPrice, &p.OutputPrice, &p.CacheWritePrice, &p.CacheReadPrice,
|
||||
&p.InputPrice, &p.OutputPrice, &p.CacheWritePrice, &p.CacheWrite1hPrice, &p.CacheReadPrice,
|
||||
&p.ImageOutputPrice, &p.PerRequestPrice, &p.CreatedAt, &p.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan account stats model pricing: %w", err)
|
||||
@@ -178,10 +178,10 @@ func createAccountStatsModelPricingTx(ctx context.Context, tx *sql.Tx, ruleID in
|
||||
}
|
||||
platform := pricing.Platform
|
||||
err = tx.QueryRowContext(ctx,
|
||||
`INSERT INTO channel_account_stats_model_pricing (rule_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_read_price, image_output_price, per_request_price)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at`,
|
||||
`INSERT INTO channel_account_stats_model_pricing (rule_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, image_output_price, per_request_price)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at`,
|
||||
ruleID, platform, modelsJSON, billingMode,
|
||||
pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheReadPrice,
|
||||
pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheWrite1hPrice, pricing.CacheReadPrice,
|
||||
pricing.ImageOutputPrice, pricing.PerRequestPrice,
|
||||
).Scan(&pricing.ID, &pricing.CreatedAt, &pricing.UpdatedAt)
|
||||
if err != nil {
|
||||
@@ -202,10 +202,10 @@ func createAccountStatsModelPricingTx(ctx context.Context, tx *sql.Tx, ruleID in
|
||||
func createAccountStatsIntervalTx(ctx context.Context, tx *sql.Tx, iv *service.PricingInterval) error {
|
||||
return tx.QueryRowContext(ctx,
|
||||
`INSERT INTO channel_account_stats_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_write_1h_price, cache_read_price, per_request_price, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at`,
|
||||
iv.PricingID, iv.MinTokens, iv.MaxTokens, iv.TierLabel,
|
||||
iv.InputPrice, iv.OutputPrice, iv.CacheWritePrice, iv.CacheReadPrice,
|
||||
iv.InputPrice, iv.OutputPrice, iv.CacheWritePrice, iv.CacheWrite1hPrice, iv.CacheReadPrice,
|
||||
iv.PerRequestPrice, iv.SortOrder,
|
||||
).Scan(&iv.ID, &iv.CreatedAt, &iv.UpdatedAt)
|
||||
}
|
||||
@@ -217,7 +217,7 @@ func (r *channelRepository) batchLoadAccountStatsIntervals(ctx context.Context,
|
||||
}
|
||||
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_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price,
|
||||
per_request_price, sort_order, created_at, updated_at
|
||||
FROM channel_account_stats_pricing_intervals
|
||||
WHERE pricing_id = ANY($1) ORDER BY pricing_id, sort_order, id`,
|
||||
@@ -233,7 +233,7 @@ func (r *channelRepository) batchLoadAccountStatsIntervals(ctx context.Context,
|
||||
var iv service.PricingInterval
|
||||
if err := rows.Scan(
|
||||
&iv.ID, &iv.PricingID, &iv.MinTokens, &iv.MaxTokens, &iv.TierLabel,
|
||||
&iv.InputPrice, &iv.OutputPrice, &iv.CacheWritePrice, &iv.CacheReadPrice,
|
||||
&iv.InputPrice, &iv.OutputPrice, &iv.CacheWritePrice, &iv.CacheWrite1hPrice, &iv.CacheReadPrice,
|
||||
&iv.PerRequestPrice, &iv.SortOrder, &iv.CreatedAt, &iv.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan account stats pricing interval: %w", err)
|
||||
|
||||
@@ -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, fast_multiplier, flex_multiplier, 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_write_1h_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,9 +61,9 @@ 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, 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,
|
||||
SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_write_1h_price = $6, cache_read_price = $7, fast_multiplier = $8, flex_multiplier = $9, image_input_price = $10, image_output_price = $11, per_request_price = $12, time_pricing = $13, platform = $14, updated_at = NOW()
|
||||
WHERE id = $15`,
|
||||
modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheWrite1hPrice, pricing.CacheReadPrice,
|
||||
pricing.FastMultiplier, pricing.FlexMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice,
|
||||
timePricingJSON, pricing.Platform, pricing.ID,
|
||||
)
|
||||
@@ -96,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, fast_multiplier, flex_multiplier, 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_write_1h_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,7 +136,7 @@ func (r *channelRepository) batchLoadModelPricing(ctx context.Context, channelID
|
||||
func (r *channelRepository) batchLoadIntervals(ctx context.Context, pricingIDs []int64) (map[int64][]service.PricingInterval, error) {
|
||||
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_price, output_price, cache_write_price, cache_write_1h_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
|
||||
@@ -153,7 +153,7 @@ func (r *channelRepository) batchLoadIntervals(ctx context.Context, pricingIDs [
|
||||
var iv service.PricingInterval
|
||||
if err := rows.Scan(
|
||||
&iv.ID, &iv.PricingID, &iv.MinTokens, &iv.MaxTokens, &iv.TierLabel,
|
||||
&iv.InputPrice, &iv.OutputPrice, &iv.CacheWritePrice, &iv.CacheReadPrice,
|
||||
&iv.InputPrice, &iv.OutputPrice, &iv.CacheWritePrice, &iv.CacheWrite1hPrice, &iv.CacheReadPrice,
|
||||
&iv.InputMultiplier, &iv.OutputMultiplier, &iv.CacheWriteMultiplier, &iv.CacheReadMultiplier,
|
||||
&iv.PerRequestPrice, &iv.SortOrder, &iv.CreatedAt, &iv.UpdatedAt,
|
||||
); err != nil {
|
||||
@@ -179,7 +179,7 @@ func scanModelPricingRows(rows *sql.Rows) ([]service.ChannelModelPricing, []int6
|
||||
var timePricingJSON []byte
|
||||
if err := rows.Scan(
|
||||
&p.ID, &p.ChannelID, &p.Platform, &modelsJSON, &p.BillingMode,
|
||||
&p.InputPrice, &p.OutputPrice, &p.CacheWritePrice, &p.CacheReadPrice,
|
||||
&p.InputPrice, &p.OutputPrice, &p.CacheWritePrice, &p.CacheWrite1hPrice, &p.CacheReadPrice,
|
||||
&p.FastMultiplier, &p.FlexMultiplier,
|
||||
&p.ImageInputPrice, &p.ImageOutputPrice, &p.PerRequestPrice, &timePricingJSON, &p.CreatedAt, &p.UpdatedAt,
|
||||
); err != nil {
|
||||
@@ -247,10 +247,10 @@ 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, 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`,
|
||||
`INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_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, $15) RETURNING id, created_at, updated_at`,
|
||||
pricing.ChannelID, platform, modelsJSON, billingMode,
|
||||
pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheReadPrice,
|
||||
pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheWrite1hPrice, pricing.CacheReadPrice,
|
||||
pricing.FastMultiplier, pricing.FlexMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice,
|
||||
pricing.PerRequestPrice, timePricingJSON,
|
||||
).Scan(&pricing.ID, &pricing.CreatedAt, &pricing.UpdatedAt)
|
||||
@@ -293,10 +293,10 @@ 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, 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`,
|
||||
(pricing_id, min_tokens, max_tokens, tier_label, input_price, output_price, cache_write_price, cache_write_1h_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, $15) RETURNING id, created_at, updated_at`,
|
||||
iv.PricingID, iv.MinTokens, iv.MaxTokens, iv.TierLabel,
|
||||
iv.InputPrice, iv.OutputPrice, iv.CacheWritePrice, iv.CacheReadPrice,
|
||||
iv.InputPrice, iv.OutputPrice, iv.CacheWritePrice, iv.CacheWrite1hPrice, 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", "fast_multiplier", "flex_multiplier", "image_input_price", "image_output_price",
|
||||
"cache_write_price", "cache_write_1h_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, nil, nil, timePricing,
|
||||
nil, 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, fast_multiplier, flex_multiplier, 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_write_1h_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, channelModelPricingTimePricingJSON,
|
||||
nil, 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 = \$11, time_pricing = \$12, platform = \$13.*WHERE id = \$14`).
|
||||
mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$12, time_pricing = \$13, platform = \$14.*WHERE id = \$15`).
|
||||
WithArgs(
|
||||
[]byte(`["gpt-5"]`), service.BillingModeToken,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, "openai", int64(11),
|
||||
nil, 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, fast_multiplier, flex_multiplier, 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_write_1h_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, 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 = \$11, time_pricing = \$12, platform = \$13.*WHERE id = \$14`).
|
||||
mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$12, time_pricing = \$13, platform = \$14.*WHERE id = \$15`).
|
||||
WithArgs(
|
||||
[]byte(`["gpt-5"]`), service.BillingModeToken,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, "openai", int64(11),
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, "openai", int64(11),
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/ent"
|
||||
@@ -18,6 +21,69 @@ import (
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
const (
|
||||
maxDatabaseInitializationRetries = 8
|
||||
databaseInitializationRetryBase = time.Second
|
||||
databaseInitializationRetryMax = 30 * time.Second
|
||||
)
|
||||
|
||||
// initializeDatabaseWithRetry retries only errors that indicate PostgreSQL is
|
||||
// temporarily unavailable during startup. Permanent configuration, migration,
|
||||
// and data errors are returned immediately so they remain visible to operators.
|
||||
func initializeDatabaseWithRetry(ctx context.Context, initialize func(context.Context) error) error {
|
||||
return initializeDatabaseWithRetryWithWait(ctx, initialize, waitForDatabaseInitializationRetry)
|
||||
}
|
||||
|
||||
func initializeDatabaseWithRetryWithWait(
|
||||
ctx context.Context,
|
||||
initialize func(context.Context) error,
|
||||
wait func(context.Context, time.Duration) error,
|
||||
) error {
|
||||
for attempt := 1; ; attempt++ {
|
||||
if err := initialize(ctx); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
if !isTransientDatabaseInitializationError(err) || attempt > maxDatabaseInitializationRetries {
|
||||
return err
|
||||
}
|
||||
|
||||
delay := databaseInitializationRetryBase * time.Duration(1<<(attempt-1))
|
||||
if delay > databaseInitializationRetryMax {
|
||||
delay = databaseInitializationRetryMax
|
||||
}
|
||||
slog.Warn("database initialization temporarily unavailable; retrying",
|
||||
"retry", attempt,
|
||||
"max_retries", maxDatabaseInitializationRetries,
|
||||
"retry_in", delay,
|
||||
"error", err,
|
||||
)
|
||||
if err := wait(ctx, delay); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitForDatabaseInitializationRetry(ctx context.Context, delay time.Duration) error {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func isTransientDatabaseInitializationError(err error) bool {
|
||||
var pqErr *pq.Error
|
||||
if !errors.As(err, &pqErr) {
|
||||
return false
|
||||
}
|
||||
code := string(pqErr.Code)
|
||||
return code == "57P03" || strings.HasPrefix(code, "08")
|
||||
}
|
||||
|
||||
// InitEnt 初始化 Ent ORM 客户端并返回客户端实例和底层的 *sql.DB。
|
||||
//
|
||||
// 该函数执行以下操作:
|
||||
@@ -69,7 +135,9 @@ func InitEnt(cfg *config.Config) (*ent.Client, *sql.DB, error) {
|
||||
// 这种方式比 Ent 的自动迁移更可控,支持复杂的迁移场景。
|
||||
migrationCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
if err := applyMigrationsFS(migrationCtx, drv.DB(), migrations.FS); err != nil {
|
||||
if err := initializeDatabaseWithRetry(migrationCtx, func(ctx context.Context) error {
|
||||
return applyMigrationsFS(ctx, drv.DB(), migrations.FS)
|
||||
}); err != nil {
|
||||
_ = drv.Close() // 迁移失败时关闭驱动,避免资源泄露
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lib/pq"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func transientDatabaseError(code string) error {
|
||||
return &pq.Error{Code: pq.ErrorCode(code), Message: "database is temporarily unavailable"}
|
||||
}
|
||||
|
||||
func TestIsTransientDatabaseInitializationError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{name: "postgres is starting", err: transientDatabaseError("57P03"), want: true},
|
||||
{name: "connection failure", err: fmt.Errorf("wrapped: %w", transientDatabaseError("08006")), want: true},
|
||||
{name: "authentication failure", err: transientDatabaseError("28P01"), want: false},
|
||||
{name: "migration error", err: errors.New("migration checksum mismatch"), want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, isTransientDatabaseInitializationError(tt.err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitializeDatabaseWithRetryEventuallySucceeds(t *testing.T) {
|
||||
attempts := 0
|
||||
var delays []time.Duration
|
||||
err := initializeDatabaseWithRetryWithWait(context.Background(), func(context.Context) error {
|
||||
attempts++
|
||||
if attempts <= 3 {
|
||||
return transientDatabaseError("57P03")
|
||||
}
|
||||
return nil
|
||||
}, func(_ context.Context, delay time.Duration) error {
|
||||
delays = append(delays, delay)
|
||||
return nil
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 4, attempts)
|
||||
require.Equal(t, []time.Duration{time.Second, 2 * time.Second, 4 * time.Second}, delays)
|
||||
}
|
||||
|
||||
func TestInitializeDatabaseWithRetryFailsFastForPermanentError(t *testing.T) {
|
||||
attempts := 0
|
||||
waitCalled := false
|
||||
permanentErr := transientDatabaseError("28P01")
|
||||
err := initializeDatabaseWithRetryWithWait(context.Background(), func(context.Context) error {
|
||||
attempts++
|
||||
return permanentErr
|
||||
}, func(_ context.Context, _ time.Duration) error {
|
||||
waitCalled = true
|
||||
return nil
|
||||
})
|
||||
|
||||
require.ErrorIs(t, err, permanentErr)
|
||||
require.Equal(t, 1, attempts)
|
||||
require.False(t, waitCalled)
|
||||
}
|
||||
|
||||
func TestInitializeDatabaseWithRetryStopsWhenContextIsCanceled(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
attempts := 0
|
||||
err := initializeDatabaseWithRetryWithWait(ctx, func(context.Context) error {
|
||||
attempts++
|
||||
return transientDatabaseError("57P03")
|
||||
}, func(ctx context.Context, _ time.Duration) error {
|
||||
cancel()
|
||||
return ctx.Err()
|
||||
})
|
||||
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
require.Equal(t, 1, attempts)
|
||||
}
|
||||
|
||||
func TestInitializeDatabaseWithRetryReturnsLastErrorAfterLimit(t *testing.T) {
|
||||
attempts := 0
|
||||
lastErr := transientDatabaseError("08006")
|
||||
var delays []time.Duration
|
||||
err := initializeDatabaseWithRetryWithWait(context.Background(), func(context.Context) error {
|
||||
attempts++
|
||||
return lastErr
|
||||
}, func(_ context.Context, delay time.Duration) error {
|
||||
delays = append(delays, delay)
|
||||
return nil
|
||||
})
|
||||
|
||||
require.ErrorIs(t, err, lastErr)
|
||||
require.Equal(t, maxDatabaseInitializationRetries+1, attempts)
|
||||
require.Len(t, delays, maxDatabaseInitializationRetries)
|
||||
require.Equal(t, databaseInitializationRetryMax, delays[len(delays)-1])
|
||||
}
|
||||
|
||||
func TestInitializeDatabaseWithRetryAllowsIdempotentMigrationRetry(t *testing.T) {
|
||||
applied := make(map[string]bool)
|
||||
attempts := 0
|
||||
err := initializeDatabaseWithRetryWithWait(context.Background(), func(context.Context) error {
|
||||
attempts++
|
||||
if !applied["001_init.sql"] {
|
||||
applied["001_init.sql"] = true
|
||||
return transientDatabaseError("57P03")
|
||||
}
|
||||
return nil
|
||||
}, func(_ context.Context, _ time.Duration) error { return nil })
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, attempts)
|
||||
require.True(t, applied["001_init.sql"])
|
||||
}
|
||||
@@ -90,6 +90,8 @@ func mustCreateGroup(t *testing.T, client *dbent.Client, g *service.Group) *serv
|
||||
SetSubscriptionType(g.SubscriptionType).
|
||||
SetRateMultiplier(g.RateMultiplier).
|
||||
SetIsExclusive(g.IsExclusive).
|
||||
SetForceOpenaiFast(g.ForceOpenAIFast).
|
||||
SetFreeOpenaiFast(g.FreeOpenAIFast).
|
||||
SetProfitControlEnabled(g.ProfitControlEnabled).
|
||||
SetProfitMinMargin(g.ProfitMinMargin).
|
||||
SetProfitSafetyBuffer(g.ProfitSafetyBuffer)
|
||||
|
||||
@@ -103,6 +103,8 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi
|
||||
SetMcpXMLInject(groupIn.MCPXMLInject).
|
||||
SetAllowMessagesDispatch(groupIn.AllowMessagesDispatch).
|
||||
SetAllowLive(groupIn.AllowLive).
|
||||
SetForceOpenaiFast(groupIn.ForceOpenAIFast).
|
||||
SetFreeOpenaiFast(groupIn.FreeOpenAIFast).
|
||||
SetRequireOauthOnly(groupIn.RequireOAuthOnly).
|
||||
SetRequirePrivacySet(groupIn.RequirePrivacySet).
|
||||
SetDefaultMappedModel(groupIn.DefaultMappedModel).
|
||||
@@ -110,6 +112,7 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi
|
||||
SetModelsListConfig(groupIn.ModelsListConfig).
|
||||
SetRpmLimit(groupIn.RPMLimit).
|
||||
SetMaxReasoningEffort(groupIn.MaxReasoningEffort).
|
||||
SetMaxReasoningEffortOverLimit(groupIn.MaxReasoningEffortOverLimit).
|
||||
SetReasoningEffortMappings(groupIn.ReasoningEffortMappings).
|
||||
SetPeakRateEnabled(groupIn.PeakRateEnabled).
|
||||
SetPeakStart(groupIn.PeakStart).
|
||||
@@ -279,6 +282,8 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
|
||||
SetMcpXMLInject(groupIn.MCPXMLInject).
|
||||
SetAllowMessagesDispatch(groupIn.AllowMessagesDispatch).
|
||||
SetAllowLive(groupIn.AllowLive).
|
||||
SetForceOpenaiFast(groupIn.ForceOpenAIFast).
|
||||
SetFreeOpenaiFast(groupIn.FreeOpenAIFast).
|
||||
SetRequireOauthOnly(groupIn.RequireOAuthOnly).
|
||||
SetRequirePrivacySet(groupIn.RequirePrivacySet).
|
||||
SetDefaultMappedModel(groupIn.DefaultMappedModel).
|
||||
@@ -286,6 +291,7 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
|
||||
SetModelsListConfig(groupIn.ModelsListConfig).
|
||||
SetRpmLimit(groupIn.RPMLimit).
|
||||
SetMaxReasoningEffort(groupIn.MaxReasoningEffort).
|
||||
SetMaxReasoningEffortOverLimit(groupIn.MaxReasoningEffortOverLimit).
|
||||
SetReasoningEffortMappings(groupIn.ReasoningEffortMappings).
|
||||
SetPeakRateEnabled(groupIn.PeakRateEnabled).
|
||||
SetPeakStart(groupIn.PeakStart).
|
||||
|
||||
@@ -55,8 +55,10 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) {
|
||||
requireColumn(t, tx, "accounts", "session_window_status", "character varying", 20, true)
|
||||
requireIndex(t, tx, "accounts", "idx_accounts_autopause_expiry_due")
|
||||
|
||||
// groups: OpenAI Live 默认关闭,管理员显式开启后才可访问。
|
||||
// groups: OpenAI Live 与 Fast 强制策略都默认关闭,管理员显式开启后才生效。
|
||||
requireColumn(t, tx, "groups", "allow_live", "boolean", 0, false)
|
||||
requireColumn(t, tx, "groups", "force_openai_fast", "boolean", 0, false)
|
||||
requireColumn(t, tx, "groups", "free_openai_fast", "boolean", 0, false)
|
||||
|
||||
// api_keys: key length should be 128
|
||||
requireColumn(t, tx, "api_keys", "key", "character varying", 128, false)
|
||||
|
||||
@@ -1000,6 +1000,13 @@ func filterSchedulerExtra(extra map[string]any) map[string]any {
|
||||
"openai_ws_force_http",
|
||||
"openai_responses_mode",
|
||||
"openai_responses_supported",
|
||||
// 透传开关必须进投影:候选过滤(ListSchedulableAccounts)读的是本投影,
|
||||
// 而 Account.IsModelSupported 靠 extra 上的这两个键短路 model_mapping 白名单。
|
||||
// 裁掉它们,透传账号在选号阶段会退回按(常为过期的)白名单判定并被误判为
|
||||
// model_not_supported —— 转发阶段却仍按透传工作,表现为"单独测账号能通、
|
||||
// 走网关报 no available accounts"。
|
||||
"openai_passthrough",
|
||||
"openai_oauth_passthrough",
|
||||
"codex_fingerprint_mode",
|
||||
"codex_fingerprint_seed",
|
||||
"codex_5h_used_percent",
|
||||
|
||||
@@ -1079,3 +1079,45 @@ func schedulerCacheBenchmarkAccounts(size int) []service.Account {
|
||||
}
|
||||
return accounts
|
||||
}
|
||||
|
||||
// 调度投影必须保留 OpenAI 透传开关。
|
||||
//
|
||||
// 候选过滤走 ListSchedulableAccounts,读的是 buildSchedulerMetadataAccount 产出的精简投影;
|
||||
// Account.IsModelSupported 又靠 extra 上的透传开关短路 model_mapping 白名单(#4936)。
|
||||
// 一旦投影把开关裁掉、却保留了白名单,透传账号在选号阶段就会退回白名单判定并被误判成
|
||||
// model_not_supported,而转发阶段(读完整账号)仍按透传工作 —— 表现为"单独测这个账号能通、
|
||||
// 走网关却报 no available accounts"。#4936 修的是判定逻辑,这里守的是喂给判定的输入。
|
||||
func TestBuildSchedulerMetadataAccount_KeepsOpenAIPassthroughForModelGate(t *testing.T) {
|
||||
for _, key := range []string{"openai_passthrough", "openai_oauth_passthrough"} {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
account := service.Account{
|
||||
ID: 383,
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
// 账号从白名单模式切到透传后常见的残留映射,未列出请求的模型。
|
||||
"model_mapping": map[string]any{"gpt-5.5": "gpt-5.5"},
|
||||
"access_token": "drop-me",
|
||||
},
|
||||
Extra: map[string]any{key: true},
|
||||
}
|
||||
require.True(t, account.IsModelSupported("gpt-5.6-sol"),
|
||||
"前置条件:透传账号本应放行白名单外的模型")
|
||||
|
||||
meta := buildSchedulerMetadataAccount(account)
|
||||
|
||||
// 走一遍真实的序列化/反序列化路径(写入 sched:meta 再由 decodeCachedAccount 读回)。
|
||||
payload, err := json.Marshal(meta)
|
||||
require.NoError(t, err)
|
||||
var restored service.Account
|
||||
require.NoError(t, json.Unmarshal(payload, &restored))
|
||||
|
||||
require.Equal(t, true, restored.Extra[key])
|
||||
require.True(t, restored.IsOpenAIPassthroughEnabled())
|
||||
require.True(t, restored.IsModelSupported("gpt-5.6-sol"),
|
||||
"投影裁掉透传开关会让透传账号在候选过滤阶段被误判为 model_not_supported")
|
||||
// 白名单本身仍需保留:非透传账号依赖它做模型门。
|
||||
require.Equal(t, map[string]any{"gpt-5.5": "gpt-5.5"}, restored.Credentials["model_mapping"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,6 +392,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"require_oauth_only": false,
|
||||
"require_privacy_set": false,
|
||||
"max_reasoning_effort": "",
|
||||
"max_reasoning_effort_over_limit": "",
|
||||
"reasoning_effort_mappings": null,
|
||||
"rpm_limit": 0,
|
||||
"created_at": "2025-01-02T03:04:05Z",
|
||||
|
||||
@@ -1387,8 +1387,8 @@ func (a *Account) IsCodingPlan() bool {
|
||||
|
||||
// GetAPIProtocol 返回国产供应商账号的上游 API 协议。存储于
|
||||
// credentials["api_protocol"];缺失或与平台不匹配时回退 chat_completions
|
||||
// (与既有行为完全一致)。responses 协议仅 deepseek 支持(官方原生 /responses
|
||||
// 端点,适配 Codex);kimi/zhipu 无此端点。
|
||||
// (与既有行为完全一致)。responses 协议仅 deepseek / kimi 支持(官方原生
|
||||
// Responses 端点,适配 Codex);zhipu 无此端点。
|
||||
func (a *Account) GetAPIProtocol() string {
|
||||
if a == nil || !a.IsCNProvider() {
|
||||
return APIProtocolChatCompletions
|
||||
@@ -1399,7 +1399,7 @@ func (a *Account) GetAPIProtocol() string {
|
||||
case APIProtocolAnthropic:
|
||||
return APIProtocolAnthropic
|
||||
case APIProtocolResponses:
|
||||
if a.Platform == PlatformDeepseek {
|
||||
if a.SupportsNativeCNResponses() {
|
||||
return APIProtocolResponses
|
||||
}
|
||||
case APIProtocolChatCompletions:
|
||||
@@ -1408,6 +1408,35 @@ func (a *Account) GetAPIProtocol() string {
|
||||
return APIProtocolChatCompletions
|
||||
}
|
||||
|
||||
// SupportsNativeCNResponses 报告该国产供应商是否提供原生 Responses 端点。
|
||||
// DeepSeek 官方为 /responses(无 /v1);Kimi 按量付费与 Coding Plan 均为
|
||||
// /v1/responses(moonshot.cn / kimi.com/coding)。
|
||||
func (a *Account) SupportsNativeCNResponses() bool {
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
switch a.Platform {
|
||||
case PlatformDeepseek, PlatformKimi:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// UsesNativeCNResponses 报告当前账号是否应按原生 Responses 协议转发
|
||||
// (显式 responses,或 adaptive 且平台具备原生端点)。
|
||||
func (a *Account) UsesNativeCNResponses() bool {
|
||||
if a == nil || !a.SupportsNativeCNResponses() {
|
||||
return false
|
||||
}
|
||||
switch a.GetAPIProtocol() {
|
||||
case APIProtocolResponses, APIProtocolAdaptive:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsAdaptiveAPIProtocol 报告账号是否按入站协议动态选择供应商原生端点。
|
||||
func (a *Account) IsAdaptiveAPIProtocol() bool {
|
||||
return a.GetAPIProtocol() == APIProtocolAdaptive
|
||||
|
||||
@@ -62,29 +62,17 @@ func resolveAccountStatsCost(
|
||||
}
|
||||
|
||||
// tryModelFilePricing 使用模型定价文件(LiteLLM/fallback)中的价格计算费用。
|
||||
// 与用户计费共用同一条定价管线,避免这里维护第二份"单价 × token 数"实现后,
|
||||
// 每加一个定价特性都要手工镜像一次。channelPricing 为 nil,保持优先级 3 的
|
||||
// 语义:只取模型定价文件,不引入渠道自定义定价。
|
||||
func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string) *float64 {
|
||||
pricing, err := billingService.GetModelPricing(model)
|
||||
if err != nil || pricing == nil {
|
||||
breakdown, err := billingService.CalculateCostWithServiceTier(
|
||||
model, tokens, 1, normalizeBillingServiceTier(serviceTier),
|
||||
)
|
||||
if err != nil || breakdown == nil || breakdown.TotalCost <= 0 {
|
||||
return nil
|
||||
}
|
||||
normalizedTier := normalizeBillingServiceTier(serviceTier)
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
return &breakdown.TotalCost
|
||||
}
|
||||
cost := float64(tokens.InputTokens)*pricing.InputPricePerToken +
|
||||
float64(tokens.OutputTokens)*pricing.OutputPricePerToken +
|
||||
float64(tokens.CacheCreationTokens)*pricing.CacheCreationPricePerToken +
|
||||
float64(tokens.CacheReadTokens)*pricing.CacheReadPricePerToken +
|
||||
float64(tokens.ImageOutputTokens)*pricing.ImageOutputPricePerToken
|
||||
if cost <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &cost
|
||||
return &breakdown.TotalCost
|
||||
}
|
||||
|
||||
// tryCustomRules 遍历自定义规则,按数组顺序先命中为准。
|
||||
@@ -200,11 +188,12 @@ func calculateTokenStatsCost(pricing *ChannelModelPricing, tokens UsageTokens) *
|
||||
totalTokens := tokens.InputTokens + tokens.OutputTokens + tokens.CacheCreationTokens + tokens.CacheReadTokens
|
||||
if iv := FindMatchingInterval(pricing.Intervals, totalTokens); iv != nil {
|
||||
p = &ChannelModelPricing{
|
||||
InputPrice: iv.InputPrice,
|
||||
OutputPrice: iv.OutputPrice,
|
||||
CacheWritePrice: iv.CacheWritePrice,
|
||||
CacheReadPrice: iv.CacheReadPrice,
|
||||
PerRequestPrice: iv.PerRequestPrice,
|
||||
InputPrice: iv.InputPrice,
|
||||
OutputPrice: iv.OutputPrice,
|
||||
CacheWritePrice: iv.CacheWritePrice,
|
||||
CacheWrite1hPrice: iv.CacheWrite1hPrice,
|
||||
CacheReadPrice: iv.CacheReadPrice,
|
||||
PerRequestPrice: iv.PerRequestPrice,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,9 +203,17 @@ func calculateTokenStatsCost(pricing *ChannelModelPricing, tokens UsageTokens) *
|
||||
}
|
||||
return *ptr
|
||||
}
|
||||
cacheCreationCost := float64(tokens.CacheCreationTokens) * deref(p.CacheWritePrice)
|
||||
if p.CacheWrite1hPrice != nil {
|
||||
cache5m, cache1h := normalizeCacheCreationBreakdown(tokens)
|
||||
if cache5m > 0 || cache1h > 0 {
|
||||
cacheCreationCost = float64(cache5m)*deref(p.CacheWritePrice) +
|
||||
float64(cache1h)*deref(p.CacheWrite1hPrice)
|
||||
}
|
||||
}
|
||||
cost := float64(tokens.InputTokens)*deref(p.InputPrice) +
|
||||
float64(tokens.OutputTokens)*deref(p.OutputPrice) +
|
||||
float64(tokens.CacheCreationTokens)*deref(p.CacheWritePrice) +
|
||||
cacheCreationCost +
|
||||
float64(tokens.CacheReadTokens)*deref(p.CacheReadPrice) +
|
||||
float64(tokens.ImageOutputTokens)*deref(p.ImageOutputPrice)
|
||||
if cost <= 0 {
|
||||
|
||||
@@ -228,6 +228,24 @@ func TestCalculateStatsCost_TokenBilling_WithCache(t *testing.T) {
|
||||
require.InDelta(t, 0.95, *result, 1e-12)
|
||||
}
|
||||
|
||||
func TestCalculateStatsCost_TokenBilling_WithCacheTTLPrices(t *testing.T) {
|
||||
pricing := &ChannelModelPricing{
|
||||
BillingMode: BillingModeToken,
|
||||
CacheWritePrice: testPtrFloat64(0.003),
|
||||
CacheWrite1hPrice: testPtrFloat64(0.005),
|
||||
}
|
||||
tokens := UsageTokens{
|
||||
CacheCreationTokens: 200,
|
||||
CacheCreation5mTokens: 80,
|
||||
CacheCreation1hTokens: 120,
|
||||
}
|
||||
|
||||
result := calculateStatsCost(pricing, tokens, 1)
|
||||
require.NotNil(t, result)
|
||||
// 80*0.003 + 120*0.005 = 0.84
|
||||
require.InDelta(t, 0.84, *result, 1e-12)
|
||||
}
|
||||
|
||||
func TestCalculateStatsCost_TokenBilling_WithImageOutput(t *testing.T) {
|
||||
pricing := &ChannelModelPricing{
|
||||
BillingMode: BillingModeToken,
|
||||
@@ -594,8 +612,9 @@ func TestTryModelFilePricing_WithImageOutput(t *testing.T) {
|
||||
}
|
||||
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "")
|
||||
require.NotNil(t, result)
|
||||
// 100*0.001 + 50*0.002 + 10*0.01 = 0.1 + 0.1 + 0.1 = 0.3
|
||||
require.InDelta(t, 0.3, *result, 1e-12)
|
||||
// ImageOutputTokens 是 OutputTokens 的子集,先扣除再按图片单价计。
|
||||
// 100*0.001 + (50-10)*0.002 + 10*0.01 = 0.1 + 0.08 + 0.1 = 0.28
|
||||
require.InDelta(t, 0.28, *result, 1e-12)
|
||||
}
|
||||
|
||||
func TestTryModelFilePricing_WithCacheTokens(t *testing.T) {
|
||||
|
||||
@@ -18,8 +18,8 @@ import (
|
||||
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.
|
||||
// adaptive CN-provider account. Zhipu uses Chat Completions plus Anthropic;
|
||||
// DeepSeek and Kimi additionally use their native Responses endpoints.
|
||||
func (s *AccountTestService) testCNProviderAdaptiveConnection(c *gin.Context, account *Account, modelID string, prompt string) error {
|
||||
testModelID := strings.TrimSpace(modelID)
|
||||
if testModelID == "" {
|
||||
@@ -44,7 +44,7 @@ func (s *AccountTestService) testCNProviderAdaptiveConnection(c *gin.Context, ac
|
||||
return err
|
||||
}
|
||||
|
||||
if account.Platform == PlatformDeepseek {
|
||||
if account.SupportsNativeCNResponses() {
|
||||
if err := s.testCNProviderAdaptiveResponsesConnection(c, account, testModelID, authToken); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -159,8 +159,8 @@ func (s *AccountTestService) testCNProviderAdaptiveResponsesConnection(c *gin.Co
|
||||
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.
|
||||
// DeepSeek / Kimi native Responses endpoints are stateless and do not need
|
||||
// the OpenAI probe's synthetic instructions.
|
||||
delete(payload, "instructions")
|
||||
payloadBytes, _ := json.Marshal(payload)
|
||||
payloadBytes = normalizeDeepSeekResponsesRequestBody(account, payloadBytes)
|
||||
|
||||
@@ -83,36 +83,25 @@ 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()
|
||||
account := adaptiveCNAccountTestAccount(301, PlatformZhipu)
|
||||
svc, upstream := adaptiveCNAccountTestService(
|
||||
account,
|
||||
adaptiveCNChatTestResponse(),
|
||||
adaptiveCNAnthropicTestResponse(),
|
||||
)
|
||||
c, recorder := newTestContext()
|
||||
|
||||
err := svc.TestAccountConnection(c, account.ID, testCase.model, "hello", AccountTestModeDefault)
|
||||
err := svc.TestAccountConnection(c, account.ID, "glm-4.7", "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 验证")
|
||||
})
|
||||
}
|
||||
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) {
|
||||
@@ -139,6 +128,30 @@ func TestAccountTestService_AdaptiveDeepSeekAlsoTestsResponsesEndpoint(t *testin
|
||||
require.Contains(t, recorder.Body.String(), "已通过原生 /responses 验证")
|
||||
}
|
||||
|
||||
func TestAccountTestService_AdaptiveKimiAlsoTestsResponsesEndpoint(t *testing.T) {
|
||||
account := adaptiveCNAccountTestAccount(306, PlatformKimi)
|
||||
svc, upstream := adaptiveCNAccountTestService(
|
||||
account,
|
||||
adaptiveCNChatTestResponse(),
|
||||
adaptiveCNAnthropicTestResponse(),
|
||||
adaptiveCNResponsesTestResponse(),
|
||||
)
|
||||
c, recorder := newTestContext()
|
||||
|
||||
err := svc.TestAccountConnection(c, account.ID, "k3-256k", "", AccountTestModeDefault)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, upstream.requests, 3)
|
||||
require.Equal(t, "http://responses.example/v1/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(
|
||||
|
||||
@@ -116,10 +116,10 @@ func TestAdaptiveProtocolRoutesResponsesShapedChatToNativeResponses(t *testing.T
|
||||
|
||||
func TestAdaptiveProtocolConvertsResponsesShapedChatForChatOnlyProvider(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"kimi-k2.5","input":"hello","max_output_tokens":32,"stream":false}`)
|
||||
body := []byte(`{"model":"glm-4.7","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{
|
||||
account := adaptiveProtocolTestAccount(PlatformZhipu, map[string]any{
|
||||
APIProtocolChatCompletions: "http://chat.example",
|
||||
APIProtocolAnthropic: "http://anthropic.example",
|
||||
})
|
||||
@@ -131,6 +131,24 @@ func TestAdaptiveProtocolConvertsResponsesShapedChatForChatOnlyProvider(t *testi
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
|
||||
}
|
||||
|
||||
func TestAdaptiveProtocolRoutesKimiResponsesShapedChatToNativeResponses(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"k3-256k","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",
|
||||
APIProtocolResponses: "http://responses.example/v1",
|
||||
})
|
||||
|
||||
_, err := svc.ForwardAsChatCompletions(context.Background(), adaptiveProtocolTestContext("/v1/chat/completions", body), account, body, "", "")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "http://responses.example/v1/responses", upstream.lastReq.URL.String())
|
||||
require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "messages").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}`)
|
||||
@@ -147,21 +165,42 @@ func TestAdaptiveProtocolRoutesMessagesToNativeAnthropic(t *testing.T) {
|
||||
require.Equal(t, "glm-4.7", gjson.GetBytes(upstream.lastBody, "model").String())
|
||||
}
|
||||
|
||||
func TestAdaptiveProtocolConvertsKimiResponsesToChatCompletions(t *testing.T) {
|
||||
func TestAdaptiveProtocolRoutesKimiResponsesToNativeResponses(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"kimi-k2.5","input":"hello","stream":false}`)
|
||||
body := []byte(`{"model":"k3-256k","input":"hello","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(PlatformKimi, map[string]any{
|
||||
APIProtocolChatCompletions: "http://chat.example",
|
||||
APIProtocolAnthropic: "http://anthropic.example",
|
||||
APIProtocolResponses: "http://responses.example/v1",
|
||||
})
|
||||
|
||||
_, 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())
|
||||
require.Equal(t, "http://responses.example/v1/responses", upstream.lastReq.URL.String())
|
||||
require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "messages").Exists())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "store").Bool())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists())
|
||||
}
|
||||
|
||||
func TestAdaptiveProtocolRoutesKimiCodingResponsesToNativeResponses(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"k3-256k","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: "https://api.kimi.com/coding/v1",
|
||||
APIProtocolAnthropic: "https://api.kimi.com/coding",
|
||||
APIProtocolResponses: "https://api.kimi.com/coding/v1",
|
||||
})
|
||||
account.Credentials["account_mode"] = AccountModeCoding
|
||||
|
||||
_, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "https://api.kimi.com/coding/v1/responses", upstream.lastReq.URL.String())
|
||||
require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
|
||||
}
|
||||
|
||||
func TestAdaptiveProtocolRoutesDeepSeekResponsesToNativeResponses(t *testing.T) {
|
||||
|
||||
@@ -295,6 +295,19 @@ func groupSupportsOAuthOnlyFilter(platform string) bool {
|
||||
platform == PlatformComposite
|
||||
}
|
||||
|
||||
func groupSupportsOpenAIFast(platform string) bool {
|
||||
return platform == PlatformOpenAI || platform == PlatformComposite
|
||||
}
|
||||
|
||||
func sanitizeGroupOpenAIFast(group *Group) {
|
||||
if group == nil || !groupSupportsOpenAIFast(group.Platform) {
|
||||
if group != nil {
|
||||
group.ForceOpenAIFast = false
|
||||
group.FreeOpenAIFast = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) {
|
||||
if input.RateMultiplier <= 0 {
|
||||
return nil, errors.New("rate_multiplier must be > 0")
|
||||
@@ -309,6 +322,10 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
|
||||
if err != nil {
|
||||
return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_MAX_REASONING_EFFORT", "%v", err)
|
||||
}
|
||||
maxReasoningEffortOverLimit, err := normalizeMaxReasoningEffortOverLimitForPlatform(platform, input.MaxReasoningEffortOverLimit)
|
||||
if err != nil {
|
||||
return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_MAX_REASONING_EFFORT_OVER_LIMIT", "%v", err)
|
||||
}
|
||||
reasoningEffortMappings, err := NormalizeReasoningEffortMappings(platform, input.ReasoningEffortMappings)
|
||||
if err != nil {
|
||||
return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_REASONING_EFFORT_MAPPING", "%v", err)
|
||||
@@ -500,6 +517,8 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
|
||||
SupportedModelScopes: input.SupportedModelScopes,
|
||||
AllowMessagesDispatch: input.AllowMessagesDispatch,
|
||||
AllowLive: input.AllowLive,
|
||||
ForceOpenAIFast: input.ForceOpenAIFast,
|
||||
FreeOpenAIFast: input.FreeOpenAIFast,
|
||||
RequireOAuthOnly: input.RequireOAuthOnly,
|
||||
RequirePrivacySet: input.RequirePrivacySet,
|
||||
DefaultMappedModel: input.DefaultMappedModel,
|
||||
@@ -507,9 +526,11 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
|
||||
ModelsListConfig: normalizeGroupModelsListConfig(input.ModelsListConfig),
|
||||
RPMLimit: input.RPMLimit,
|
||||
MaxReasoningEffort: maxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: maxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: reasoningEffortMappings,
|
||||
}
|
||||
sanitizeGroupMessagesDispatchFields(group)
|
||||
sanitizeGroupOpenAIFast(group)
|
||||
if group.Platform != PlatformOpenAI && group.Platform != PlatformComposite {
|
||||
group.AllowLive = false
|
||||
}
|
||||
@@ -862,6 +883,12 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
|
||||
if input.AllowLive != nil {
|
||||
group.AllowLive = *input.AllowLive
|
||||
}
|
||||
if input.ForceOpenAIFast != nil {
|
||||
group.ForceOpenAIFast = *input.ForceOpenAIFast
|
||||
}
|
||||
if input.FreeOpenAIFast != nil {
|
||||
group.FreeOpenAIFast = *input.FreeOpenAIFast
|
||||
}
|
||||
if input.RequireOAuthOnly != nil {
|
||||
group.RequireOAuthOnly = *input.RequireOAuthOnly
|
||||
}
|
||||
@@ -887,6 +914,13 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
|
||||
}
|
||||
group.MaxReasoningEffort = maxReasoningEffort
|
||||
}
|
||||
if input.MaxReasoningEffortOverLimit != nil {
|
||||
maxReasoningEffortOverLimit, err := normalizeMaxReasoningEffortOverLimitForPlatform(group.Platform, *input.MaxReasoningEffortOverLimit)
|
||||
if err != nil {
|
||||
return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_MAX_REASONING_EFFORT_OVER_LIMIT", "%v", err)
|
||||
}
|
||||
group.MaxReasoningEffortOverLimit = maxReasoningEffortOverLimit
|
||||
}
|
||||
if input.ReasoningEffortMappings != nil {
|
||||
reasoningEffortMappings, err := NormalizeReasoningEffortMappings(group.Platform, *input.ReasoningEffortMappings)
|
||||
if err != nil {
|
||||
@@ -895,6 +929,7 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
|
||||
group.ReasoningEffortMappings = reasoningEffortMappings
|
||||
}
|
||||
sanitizeGroupMessagesDispatchFields(group)
|
||||
sanitizeGroupOpenAIFast(group)
|
||||
if group.Platform != PlatformOpenAI && group.Platform != PlatformComposite {
|
||||
group.AllowLive = false
|
||||
}
|
||||
|
||||
@@ -144,6 +144,8 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group {
|
||||
SortOrder: source.SortOrder,
|
||||
AllowMessagesDispatch: source.AllowMessagesDispatch,
|
||||
AllowLive: source.AllowLive,
|
||||
ForceOpenAIFast: source.ForceOpenAIFast,
|
||||
FreeOpenAIFast: source.FreeOpenAIFast,
|
||||
RequireOAuthOnly: source.RequireOAuthOnly,
|
||||
RequirePrivacySet: source.RequirePrivacySet,
|
||||
DefaultMappedModel: source.DefaultMappedModel,
|
||||
@@ -152,9 +154,10 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group {
|
||||
Enabled: source.ModelsListConfig.Enabled,
|
||||
Models: append([]string(nil), source.ModelsListConfig.Models...),
|
||||
},
|
||||
RPMLimit: source.RPMLimit,
|
||||
MaxReasoningEffort: source.MaxReasoningEffort,
|
||||
ReasoningEffortMappings: append([]ReasoningEffortMapping(nil), source.ReasoningEffortMappings...),
|
||||
RPMLimit: source.RPMLimit,
|
||||
MaxReasoningEffort: source.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: source.MaxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: append([]ReasoningEffortMapping(nil), source.ReasoningEffortMappings...),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +166,8 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing
|
||||
SortOrder: 9,
|
||||
AllowMessagesDispatch: true,
|
||||
AllowLive: true,
|
||||
ForceOpenAIFast: true,
|
||||
FreeOpenAIFast: true,
|
||||
RequireOAuthOnly: true,
|
||||
RequirePrivacySet: true,
|
||||
DefaultMappedModel: "gpt-5.4",
|
||||
@@ -175,17 +177,18 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing
|
||||
HaikuMappedModel: "gpt-5-mini",
|
||||
ExactModelMappings: map[string]string{"claude-special": "gpt-special"},
|
||||
},
|
||||
ModelsListConfig: GroupModelsListConfig{Enabled: true, Models: []string{"gpt-5.4", "gpt-5-mini"}},
|
||||
RPMLimit: 99,
|
||||
MaxReasoningEffort: "medium",
|
||||
ReasoningEffortMappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}},
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
AccountCount: 12,
|
||||
ActiveAccountCount: 8,
|
||||
RateLimitedAccountCount: 2,
|
||||
DuplicateOperationID: "old-operation-must-not-copy",
|
||||
AccountGroups: []AccountGroup{{AccountID: 13, GroupID: 41, Priority: 37}},
|
||||
ModelsListConfig: GroupModelsListConfig{Enabled: true, Models: []string{"gpt-5.4", "gpt-5-mini"}},
|
||||
RPMLimit: 99,
|
||||
MaxReasoningEffort: "medium",
|
||||
MaxReasoningEffortOverLimit: ReasoningEffortOverLimitDeny,
|
||||
ReasoningEffortMappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}},
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
AccountCount: 12,
|
||||
ActiveAccountCount: 8,
|
||||
RateLimitedAccountCount: 2,
|
||||
DuplicateOperationID: "old-operation-must-not-copy",
|
||||
AccountGroups: []AccountGroup{{AccountID: 13, GroupID: 41, Priority: 37}},
|
||||
}
|
||||
repo := newDuplicateGroupRepoStub(source)
|
||||
repo.sourceBindings[source.ID] = []AccountGroup{
|
||||
@@ -212,9 +215,12 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing
|
||||
require.Equal(t, source.FallbackGroupID, duplicate.FallbackGroupID)
|
||||
require.Equal(t, source.ModelRouting, duplicate.ModelRouting)
|
||||
require.Equal(t, source.MessagesDispatchModelConfig, duplicate.MessagesDispatchModelConfig)
|
||||
require.Equal(t, source.ForceOpenAIFast, duplicate.ForceOpenAIFast)
|
||||
require.Equal(t, source.FreeOpenAIFast, duplicate.FreeOpenAIFast)
|
||||
require.Equal(t, source.ModelsListConfig, duplicate.ModelsListConfig)
|
||||
require.Equal(t, source.RPMLimit, duplicate.RPMLimit)
|
||||
require.Equal(t, source.MaxReasoningEffort, duplicate.MaxReasoningEffort)
|
||||
require.Equal(t, source.MaxReasoningEffortOverLimit, duplicate.MaxReasoningEffortOverLimit)
|
||||
require.Equal(t, source.ReasoningEffortMappings, duplicate.ReasoningEffortMappings)
|
||||
require.EqualValues(t, 2, duplicate.AccountCount)
|
||||
require.EqualValues(t, 2, duplicate.ActiveAccountCount)
|
||||
|
||||
@@ -266,6 +266,8 @@ type CreateGroupInput struct {
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
AllowMessagesDispatch bool
|
||||
AllowLive bool
|
||||
ForceOpenAIFast bool
|
||||
FreeOpenAIFast bool
|
||||
DefaultMappedModel string
|
||||
RequireOAuthOnly bool
|
||||
RequirePrivacySet bool
|
||||
@@ -275,6 +277,8 @@ type CreateGroupInput struct {
|
||||
RPMLimit int
|
||||
// MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。
|
||||
MaxReasoningEffort string
|
||||
// MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。
|
||||
MaxReasoningEffortOverLimit string
|
||||
// ReasoningEffortMappings OpenAI/Codex 推理强度精确映射。
|
||||
ReasoningEffortMappings []ReasoningEffortMapping
|
||||
// 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数,nil 按 0 处理)
|
||||
@@ -341,6 +345,8 @@ type UpdateGroupInput struct {
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
AllowMessagesDispatch *bool
|
||||
AllowLive *bool
|
||||
ForceOpenAIFast *bool
|
||||
FreeOpenAIFast *bool
|
||||
DefaultMappedModel *string
|
||||
RequireOAuthOnly *bool
|
||||
RequirePrivacySet *bool
|
||||
@@ -350,6 +356,8 @@ type UpdateGroupInput struct {
|
||||
RPMLimit *int
|
||||
// MaxReasoningEffort 空字符串表示清除上限;nil 表示未提供不改动。
|
||||
MaxReasoningEffort *string
|
||||
// MaxReasoningEffortOverLimit 空字符串视为 downgrade;nil 表示未提供不改动。
|
||||
MaxReasoningEffortOverLimit *string
|
||||
// ReasoningEffortMappings nil 表示不修改,空数组表示清空,非空数组表示替换。
|
||||
ReasoningEffortMappings *[]ReasoningEffortMapping
|
||||
// 分组利润控制(nil 表示不修改;margin/buffer 为小数)
|
||||
|
||||
@@ -42,10 +42,11 @@ func TestAdminService_CreateCompositeGroupCopiesAccountsFromConcreteGroups(t *te
|
||||
svc := &adminServiceImpl{groupRepo: groupRepo}
|
||||
|
||||
group, err := svc.CreateGroup(context.Background(), &CreateGroupInput{
|
||||
Name: "Composite",
|
||||
Platform: PlatformComposite,
|
||||
RateMultiplier: 1,
|
||||
MaxReasoningEffort: "medium",
|
||||
Name: "Composite",
|
||||
Platform: PlatformComposite,
|
||||
RateMultiplier: 1,
|
||||
MaxReasoningEffort: "medium",
|
||||
MaxReasoningEffortOverLimit: ReasoningEffortOverLimitDeny,
|
||||
ReasoningEffortMappings: []ReasoningEffortMapping{
|
||||
{From: "max", To: "xhigh"},
|
||||
},
|
||||
@@ -55,6 +56,7 @@ func TestAdminService_CreateCompositeGroupCopiesAccountsFromConcreteGroups(t *te
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, PlatformComposite, groupRepo.created.Platform)
|
||||
require.Equal(t, "medium", groupRepo.created.MaxReasoningEffort)
|
||||
require.Equal(t, ReasoningEffortOverLimitDeny, groupRepo.created.MaxReasoningEffortOverLimit)
|
||||
require.Equal(t, []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, groupRepo.created.ReasoningEffortMappings)
|
||||
require.Equal(t, int64(99), group.ID)
|
||||
require.Equal(t, int64(2), group.AccountCount)
|
||||
@@ -90,17 +92,20 @@ func TestAdminService_UpdateCompositeGroupCopiesAccountsFromConcreteGroups(t *te
|
||||
}
|
||||
svc := &adminServiceImpl{groupRepo: groupRepo}
|
||||
maxReasoningEffort := "low"
|
||||
maxReasoningEffortOverLimit := ReasoningEffortOverLimitDeny
|
||||
reasoningEffortMappings := []ReasoningEffortMapping{{From: "max", To: "high"}}
|
||||
|
||||
group, err := svc.UpdateGroup(context.Background(), 99, &UpdateGroupInput{
|
||||
MaxReasoningEffort: &maxReasoningEffort,
|
||||
ReasoningEffortMappings: &reasoningEffortMappings,
|
||||
CopyAccountsFromGroupIDs: []int64{10, 20},
|
||||
MaxReasoningEffort: &maxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: &maxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: &reasoningEffortMappings,
|
||||
CopyAccountsFromGroupIDs: []int64{10, 20},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, PlatformComposite, group.Platform)
|
||||
require.Equal(t, "low", group.MaxReasoningEffort)
|
||||
require.Equal(t, ReasoningEffortOverLimitDeny, group.MaxReasoningEffortOverLimit)
|
||||
require.Equal(t, reasoningEffortMappings, group.ReasoningEffortMappings)
|
||||
require.Equal(t, int64(99), clearedGroupID)
|
||||
require.ElementsMatch(t, []int64{10, 20}, copiedFrom)
|
||||
|
||||
@@ -886,6 +886,20 @@ func TestAdminService_UpdateGroup_ReasoningEffortMappingsTriState(t *testing.T)
|
||||
}(),
|
||||
want: []ReasoningEffortMapping{{From: "xhigh", To: "high"}},
|
||||
},
|
||||
{
|
||||
name: "model scoped mappings are canonicalized independently",
|
||||
input: func() *UpdateGroupInput {
|
||||
replacement := []ReasoningEffortMapping{
|
||||
{From: " MAX ", To: " low ", MatchType: "PREFIX", Model: " gpt "},
|
||||
{From: "max", To: "medium", Model: "gpt-5.4"},
|
||||
}
|
||||
return &UpdateGroupInput{ReasoningEffortMappings: &replacement}
|
||||
}(),
|
||||
want: []ReasoningEffortMapping{
|
||||
{From: "max", To: "low", MatchType: "prefix", Model: "gpt"},
|
||||
{From: "max", To: "medium", MatchType: "exact", Model: "gpt-5.4"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -935,12 +949,13 @@ func TestAdminService_UpdateGroup_RejectsInvalidReasoningEffortMappings(t *testi
|
||||
|
||||
func TestAdminService_UpdateGroup_ClearsReasoningPolicyForUnsupportedPlatform(t *testing.T) {
|
||||
existing := &Group{
|
||||
ID: 1,
|
||||
Name: "openai-group",
|
||||
Platform: PlatformOpenAI,
|
||||
Status: StatusActive,
|
||||
MaxReasoningEffort: "medium",
|
||||
ReasoningEffortMappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}},
|
||||
ID: 1,
|
||||
Name: "openai-group",
|
||||
Platform: PlatformOpenAI,
|
||||
Status: StatusActive,
|
||||
MaxReasoningEffort: "medium",
|
||||
MaxReasoningEffortOverLimit: ReasoningEffortOverLimitDeny,
|
||||
ReasoningEffortMappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}},
|
||||
}
|
||||
repo := &groupRepoStubForAdmin{getByID: existing}
|
||||
svc := &adminServiceImpl{groupRepo: repo}
|
||||
@@ -949,6 +964,7 @@ func TestAdminService_UpdateGroup_ClearsReasoningPolicyForUnsupportedPlatform(t
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, repo.updated.MaxReasoningEffort)
|
||||
require.Equal(t, ReasoningEffortOverLimitDowngrade, repo.updated.MaxReasoningEffortOverLimit)
|
||||
require.Empty(t, repo.updated.ReasoningEffortMappings)
|
||||
}
|
||||
|
||||
@@ -1082,6 +1098,70 @@ func TestAdminService_CreateCompositeGroupPreservesLive(t *testing.T) {
|
||||
require.True(t, repo.created.AllowLive)
|
||||
}
|
||||
|
||||
func TestAdminService_CreateGroup_NormalizesForceOpenAIFastByPlatform(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
platform string
|
||||
want bool
|
||||
}{
|
||||
{name: "openai", platform: PlatformOpenAI, want: true},
|
||||
{name: "composite", platform: PlatformComposite, want: true},
|
||||
{name: "anthropic", platform: PlatformAnthropic, want: false},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
repo := &groupRepoStubForAdmin{}
|
||||
svc := &adminServiceImpl{groupRepo: repo}
|
||||
|
||||
group, err := svc.CreateGroup(context.Background(), &CreateGroupInput{
|
||||
Name: "fast-" + tt.name, Platform: tt.platform, RateMultiplier: 1, ForceOpenAIFast: true, FreeOpenAIFast: true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, group)
|
||||
require.Equal(t, tt.want, repo.created.ForceOpenAIFast)
|
||||
require.Equal(t, tt.want, repo.created.FreeOpenAIFast)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminService_UpdateGroup_ClearsForceOpenAIFastWhenPlatformChanges(t *testing.T) {
|
||||
existingGroup := &Group{
|
||||
ID: 1, Name: "existing-fast", Platform: PlatformOpenAI, Status: StatusActive, ForceOpenAIFast: true, FreeOpenAIFast: true,
|
||||
}
|
||||
repo := &groupRepoStubForAdmin{getByID: existingGroup}
|
||||
svc := &adminServiceImpl{groupRepo: repo}
|
||||
|
||||
group, err := svc.UpdateGroup(context.Background(), existingGroup.ID, &UpdateGroupInput{
|
||||
Platform: PlatformAnthropic,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, group)
|
||||
require.False(t, repo.updated.ForceOpenAIFast)
|
||||
require.False(t, repo.updated.FreeOpenAIFast)
|
||||
}
|
||||
|
||||
func TestAdminService_UpdateGroup_ForceOpenAIFastInvalidatesAuthCache(t *testing.T) {
|
||||
existingGroup := &Group{
|
||||
ID: 1, Name: "existing-fast", Platform: PlatformOpenAI, Status: StatusActive,
|
||||
}
|
||||
repo := &groupRepoStubForAdmin{getByID: existingGroup}
|
||||
invalidator := &authCacheInvalidatorStub{}
|
||||
svc := &adminServiceImpl{groupRepo: repo, authCacheInvalidator: invalidator}
|
||||
enabled := true
|
||||
|
||||
group, err := svc.UpdateGroup(context.Background(), existingGroup.ID, &UpdateGroupInput{
|
||||
ForceOpenAIFast: &enabled,
|
||||
FreeOpenAIFast: &enabled,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, group)
|
||||
require.True(t, repo.updated.ForceOpenAIFast)
|
||||
require.True(t, repo.updated.FreeOpenAIFast)
|
||||
require.Equal(t, []int64{existingGroup.ID}, invalidator.groupIDs)
|
||||
}
|
||||
|
||||
func TestAdminService_UpdateCompositeGroupPreservesLive(t *testing.T) {
|
||||
existingGroup := &Group{
|
||||
ID: 1,
|
||||
|
||||
@@ -76,6 +76,12 @@ func TestAntigravityGatewayService_GetMappedModel(t *testing.T) {
|
||||
},
|
||||
|
||||
// 3. 默认映射中的透传(映射到自己)
|
||||
{
|
||||
name: "默认映射透传 - claude-fable-5-1",
|
||||
requestedModel: "claude-fable-5-1",
|
||||
accountMapping: nil,
|
||||
expected: "claude-fable-5-1",
|
||||
},
|
||||
{
|
||||
name: "默认映射透传 - claude-fable-5",
|
||||
requestedModel: "claude-fable-5",
|
||||
@@ -233,6 +239,7 @@ func TestAntigravityGatewayService_IsModelSupported(t *testing.T) {
|
||||
expected bool
|
||||
}{
|
||||
// 直接支持
|
||||
{"直接支持 - claude-fable-5-1", "claude-fable-5-1", true},
|
||||
{"直接支持 - claude-fable-5", "claude-fable-5", true},
|
||||
{"直接支持 - claude-sonnet-4-5", "claude-sonnet-4-5", true},
|
||||
{"直接支持 - gemini-3-flash", "gemini-3-flash", true},
|
||||
|
||||
@@ -103,6 +103,8 @@ type APIKeyAuthGroupSnapshot struct {
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
AllowMessagesDispatch bool `json:"allow_messages_dispatch"`
|
||||
AllowLive bool `json:"allow_live"`
|
||||
ForceOpenAIFast bool `json:"force_openai_fast"`
|
||||
FreeOpenAIFast bool `json:"free_openai_fast"`
|
||||
DefaultMappedModel string `json:"default_mapped_model,omitempty"`
|
||||
MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config,omitempty"`
|
||||
ModelsListConfig GroupModelsListConfig `json:"models_list_config,omitempty"`
|
||||
@@ -112,6 +114,8 @@ type APIKeyAuthGroupSnapshot struct {
|
||||
|
||||
// MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。
|
||||
MaxReasoningEffort string `json:"max_reasoning_effort,omitempty"`
|
||||
// MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。
|
||||
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit,omitempty"`
|
||||
// ReasoningEffortMappings rewrites explicit effort values before the ceiling.
|
||||
ReasoningEffortMappings []ReasoningEffortMapping `json:"reasoning_effort_mappings"`
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/dgraph-io/ristretto"
|
||||
)
|
||||
|
||||
const apiKeyAuthSnapshotVersion = 20 // v20: group long-context and model pricing fields (force refresh of pre-fix snapshots)
|
||||
const apiKeyAuthSnapshotVersion = 22 // v22: group free_openai_fast field
|
||||
|
||||
type apiKeyAuthCacheConfig struct {
|
||||
l1Size int
|
||||
@@ -418,11 +418,14 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey)
|
||||
SupportedModelScopes: apiKey.Group.SupportedModelScopes,
|
||||
AllowMessagesDispatch: apiKey.Group.AllowMessagesDispatch,
|
||||
AllowLive: apiKey.Group.AllowLive,
|
||||
ForceOpenAIFast: apiKey.Group.ForceOpenAIFast,
|
||||
FreeOpenAIFast: apiKey.Group.FreeOpenAIFast,
|
||||
DefaultMappedModel: apiKey.Group.DefaultMappedModel,
|
||||
MessagesDispatchModelConfig: apiKey.Group.MessagesDispatchModelConfig,
|
||||
ModelsListConfig: apiKey.Group.ModelsListConfig,
|
||||
RPMLimit: apiKey.Group.RPMLimit,
|
||||
MaxReasoningEffort: apiKey.Group.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: apiKey.Group.MaxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: apiKey.Group.ReasoningEffortMappings,
|
||||
PeakRateEnabled: apiKey.Group.PeakRateEnabled,
|
||||
PeakStart: apiKey.Group.PeakStart,
|
||||
@@ -516,11 +519,14 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
|
||||
SupportedModelScopes: snapshot.Group.SupportedModelScopes,
|
||||
AllowMessagesDispatch: snapshot.Group.AllowMessagesDispatch,
|
||||
AllowLive: snapshot.Group.AllowLive,
|
||||
ForceOpenAIFast: snapshot.Group.ForceOpenAIFast,
|
||||
FreeOpenAIFast: snapshot.Group.FreeOpenAIFast,
|
||||
DefaultMappedModel: snapshot.Group.DefaultMappedModel,
|
||||
MessagesDispatchModelConfig: snapshot.Group.MessagesDispatchModelConfig,
|
||||
ModelsListConfig: snapshot.Group.ModelsListConfig,
|
||||
RPMLimit: snapshot.Group.RPMLimit,
|
||||
MaxReasoningEffort: snapshot.Group.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: snapshot.Group.MaxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: snapshot.Group.ReasoningEffortMappings,
|
||||
PeakRateEnabled: snapshot.Group.PeakRateEnabled,
|
||||
PeakStart: snapshot.Group.PeakStart,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAPIKeyAuthSnapshotGroupForceOpenAIFastRoundtrip(t *testing.T) {
|
||||
groupID := int64(50)
|
||||
apiKey := &APIKey{
|
||||
ID: 82, UserID: 40, GroupID: &groupID, Key: "sk-fast-roundtrip", Status: StatusActive,
|
||||
User: &User{ID: 40, Status: StatusActive},
|
||||
Group: &Group{
|
||||
ID: groupID, Name: "fast-roundtrip", Platform: PlatformOpenAI, Status: StatusActive,
|
||||
Hydrated: true, ForceOpenAIFast: true, FreeOpenAIFast: true,
|
||||
},
|
||||
}
|
||||
svc := &APIKeyService{}
|
||||
|
||||
payload, err := json.Marshal(&APIKeyAuthCacheEntry{Snapshot: svc.snapshotFromAPIKey(context.Background(), apiKey)})
|
||||
require.NoError(t, err)
|
||||
var cached APIKeyAuthCacheEntry
|
||||
require.NoError(t, json.Unmarshal(payload, &cached))
|
||||
|
||||
materialized, used, err := svc.applyAuthCacheEntry(apiKey.Key, &cached)
|
||||
require.NoError(t, err)
|
||||
require.True(t, used)
|
||||
require.NotNil(t, materialized.Group)
|
||||
require.True(t, materialized.Group.Hydrated)
|
||||
require.True(t, materialized.Group.ForceOpenAIFast)
|
||||
require.True(t, materialized.Group.FreeOpenAIFast)
|
||||
require.Equal(t, 22, cached.Snapshot.Version)
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func TestAPIKeyAuthSnapshotProfitControlRoundtrip(t *testing.T) {
|
||||
snapshot := svc.snapshotFromAPIKey(context.Background(), apiKey)
|
||||
require.NotNil(t, snapshot)
|
||||
require.Equal(t, apiKeyAuthSnapshotVersion, snapshot.Version)
|
||||
require.Equal(t, 20, snapshot.Version, "v20 起认证快照携带分组长上下文与模型定价字段")
|
||||
require.Equal(t, 22, snapshot.Version, "v22 起认证快照携带分组免费 Fast 开关")
|
||||
|
||||
// 模拟 L2 缓存的完整 JSON 往返(与 apiKeyCache.SetAuthCache/GetAuthCache 同构)。
|
||||
payload, err := json.Marshal(&APIKeyAuthCacheEntry{Snapshot: snapshot})
|
||||
|
||||
@@ -295,13 +295,14 @@ func TestAPIKeyService_SnapshotRoundTrip_PreservesReasoningEffortPolicy(t *testi
|
||||
Concurrency: 3,
|
||||
},
|
||||
Group: &Group{
|
||||
ID: groupID,
|
||||
Name: "composite",
|
||||
Platform: PlatformComposite,
|
||||
Status: StatusActive,
|
||||
SubscriptionType: SubscriptionTypeStandard,
|
||||
RateMultiplier: 1,
|
||||
MaxReasoningEffort: "medium",
|
||||
ID: groupID,
|
||||
Name: "composite",
|
||||
Platform: PlatformComposite,
|
||||
Status: StatusActive,
|
||||
SubscriptionType: SubscriptionTypeStandard,
|
||||
RateMultiplier: 1,
|
||||
MaxReasoningEffort: "medium",
|
||||
MaxReasoningEffortOverLimit: ReasoningEffortOverLimitDeny,
|
||||
ReasoningEffortMappings: []ReasoningEffortMapping{
|
||||
{From: "max", To: "xhigh"},
|
||||
},
|
||||
@@ -315,6 +316,7 @@ func TestAPIKeyService_SnapshotRoundTrip_PreservesReasoningEffortPolicy(t *testi
|
||||
require.NotNil(t, roundTrip.Group)
|
||||
require.Equal(t, PlatformComposite, roundTrip.Group.Platform)
|
||||
require.Equal(t, "medium", roundTrip.Group.MaxReasoningEffort)
|
||||
require.Equal(t, ReasoningEffortOverLimitDeny, roundTrip.Group.MaxReasoningEffortOverLimit)
|
||||
require.Equal(t, apiKey.Group.ReasoningEffortMappings, roundTrip.Group.ReasoningEffortMappings)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
@@ -638,10 +639,26 @@ func filterBedrockBetaTokens(tokens []string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// sanitizeBedrockFieldsForBetaTokens 与直连路径的 sanitizeAnthropicBodyForBetaTokens
|
||||
// 对称:按最终 Bedrock beta tokens 决定是否保留 body 中的 beta 字段。
|
||||
// - context_management 缺 context-management beta → strip
|
||||
// - fallbacks 缺 server-side-fallback beta → strip
|
||||
// - fallback_credit_token 缺 server-side-fallback / 任一 fallback-credit beta → strip
|
||||
//
|
||||
// 注意:fallback beta token 均不在 bedrockSupportedBetaTokens 白名单内
|
||||
// (会被 filterBedrockBetaTokens 过滤掉),因此条件 strip 实际总会剥除——这是预期:
|
||||
// Bedrock Invoke 不支持这些 beta 字段。
|
||||
func sanitizeBedrockFieldsForBetaTokens(body []byte, betaTokens []string) []byte {
|
||||
if !containsBedrockBetaToken(betaTokens, bedrockContextManagementBetaToken) && gjson.GetBytes(body, "context_management").Exists() {
|
||||
body, _ = sjson.DeleteBytes(body, "context_management")
|
||||
}
|
||||
if !containsBedrockBetaToken(betaTokens, claude.BetaServerSideFallback) && gjson.GetBytes(body, "fallbacks").Exists() {
|
||||
body, _ = sjson.DeleteBytes(body, "fallbacks")
|
||||
}
|
||||
if !containsAnyBedrockBetaToken(betaTokens, claude.BetaServerSideFallback, claude.BetaFallbackCredit, claude.BetaFallbackCreditLegacy) &&
|
||||
gjson.GetBytes(body, "fallback_credit_token").Exists() {
|
||||
body, _ = sjson.DeleteBytes(body, "fallback_credit_token")
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
@@ -654,6 +671,16 @@ func containsBedrockBetaToken(tokens []string, target string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// containsAnyBedrockBetaToken 判断 tokens 是否包含 targets 中的任意一个 token。
|
||||
func containsAnyBedrockBetaToken(tokens []string, targets ...string) bool {
|
||||
for _, target := range targets {
|
||||
if containsBedrockBetaToken(tokens, target) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// bedrockToolUseIDRe 匹配 Bedrock 允许的 tool_use ID 字符(字母、数字、下划线、连字符)
|
||||
var bedrockToolUseIDRe = regexp.MustCompile(`[^a-zA-Z0-9_-]`)
|
||||
|
||||
@@ -760,6 +787,8 @@ const defaultCCMaxTokens = 81920
|
||||
// - 移除 service_tier(Anthropic API 专有,Bedrock 不支持)
|
||||
// - 移除 interface_geo(Anthropic API 专有,Bedrock 不支持)
|
||||
// - 移除 context_management(Anthropic API 专有,Bedrock 不支持,CC v2.1.87+ 默认携带)
|
||||
// - 无条件移除 fallbacks / fallback_credit_token(server-side refusal fallback,
|
||||
// Anthropic 直连 beta API 专有;Bedrock Invoke 无对应 beta,永不支持)
|
||||
// - 注入 max_tokens 默认值 81920(CC 可能省略,Bedrock 要求必须提供)
|
||||
// - 注入 anthropic_version(CC 通过 HTTP 头发送,Bedrock 需要放在请求体中)
|
||||
func sanitizeBedrockCCFields(body []byte) []byte {
|
||||
@@ -772,6 +801,12 @@ func sanitizeBedrockCCFields(body []byte) []byte {
|
||||
if gjson.GetBytes(body, "context_management").Exists() {
|
||||
body, _ = sjson.DeleteBytes(body, "context_management")
|
||||
}
|
||||
if gjson.GetBytes(body, "fallbacks").Exists() {
|
||||
body, _ = sjson.DeleteBytes(body, "fallbacks")
|
||||
}
|
||||
if gjson.GetBytes(body, "fallback_credit_token").Exists() {
|
||||
body, _ = sjson.DeleteBytes(body, "fallback_credit_token")
|
||||
}
|
||||
if !gjson.GetBytes(body, "max_tokens").Exists() {
|
||||
body, _ = sjson.SetBytes(body, "max_tokens", defaultCCMaxTokens)
|
||||
}
|
||||
|
||||
@@ -541,6 +541,20 @@ func TestResolveBedrockModelID(t *testing.T) {
|
||||
assert.Equal(t, "anthropic.claude-fable-5", modelID)
|
||||
})
|
||||
|
||||
t.Run("默认 Fable 5.1 映射使用官方 Bedrock 模型 ID", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeBedrock,
|
||||
Credentials: map[string]any{
|
||||
"aws_region": "eu-west-1",
|
||||
},
|
||||
}
|
||||
|
||||
modelID, ok := ResolveBedrockModelID(account, "claude-fable-5-1")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "anthropic.claude-fable-5-1", modelID)
|
||||
})
|
||||
|
||||
t.Run("force global rewrites anthropic regional model id", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformAnthropic,
|
||||
|
||||
@@ -9,15 +9,12 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ContextPricingBasis 阶梯的计价基准。
|
||||
// ContextPricingBasis 阶梯的计价基准。当前只有整单口径;历史上的
|
||||
// Gemini 边际口径("marginal")已随平台旧规则一并移除。
|
||||
type ContextPricingBasis string
|
||||
|
||||
const (
|
||||
// ContextPricingBasisWholeRequest 整单按所在档单价计价(目录阶梯、渠道区间)。
|
||||
ContextPricingBasisWholeRequest ContextPricingBasis = "whole_request"
|
||||
// ContextPricingBasisMarginal 仅超出阈值的部分按该档单价计价(平台旧规则)。
|
||||
ContextPricingBasisMarginal ContextPricingBasis = "marginal"
|
||||
)
|
||||
// ContextPricingBasisWholeRequest 整单按所在档单价计价(目录阶梯、渠道区间)。
|
||||
const ContextPricingBasisWholeRequest ContextPricingBasis = "whole_request"
|
||||
|
||||
// ContextPricingTier (MinTokens, MaxTokens] 区间内的有效 per-token 单价(USD)。
|
||||
// nil 表示该项无价/不计费;MaxTokens 为 nil 表示无上限。
|
||||
@@ -73,8 +70,8 @@ const contextProbeDelta = 1000
|
||||
// ResolveContextPricingSchedule 解析分组+模型的上下文阶梯单价表。
|
||||
//
|
||||
// 解析链与扣费完全一致:Resolver.Resolve(分组卡 → 渠道 → 目录 → 策略)给出定价,
|
||||
// CalculateTokenCostForRequest 给出路径(分组/渠道定价 → 平台旧规则 → 内置目录)。
|
||||
// 断点只取自计费自身的规则输入(渠道区间边界、目录阶梯阈值、旧规则阈值),
|
||||
// CalculateTokenCostForRequest 给出路径(分组/渠道定价 → 内置目录)。
|
||||
// 断点只取自计费自身的规则输入(渠道区间边界、目录阶梯阈值),
|
||||
// 每一段的单价由真实计费函数在该段内两点探针的差商得到,因此倍率、策略等
|
||||
// 规则变更无需同步到这里;相邻同价段会合并。
|
||||
//
|
||||
@@ -103,22 +100,13 @@ func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, reso
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var legacy *LegacyLongContextRule
|
||||
if in.Group != nil {
|
||||
legacy = s.LegacyLongContextRule(in.Platform)
|
||||
}
|
||||
if !legacyLongContextApplies(resolved, in.Group, legacy) {
|
||||
legacy = nil
|
||||
}
|
||||
|
||||
req := TokenCostRequest{
|
||||
Ctx: ctx,
|
||||
Model: in.Model,
|
||||
Group: in.Group,
|
||||
RateMultiplier: 1,
|
||||
Resolver: resolver,
|
||||
Resolved: resolved,
|
||||
LegacyLongContext: legacy,
|
||||
Ctx: ctx,
|
||||
Model: in.Model,
|
||||
Group: in.Group,
|
||||
RateMultiplier: 1,
|
||||
Resolver: resolver,
|
||||
Resolved: resolved,
|
||||
}
|
||||
probe := func(tokens UsageTokens) (*CostBreakdown, error) {
|
||||
r := req
|
||||
@@ -126,7 +114,7 @@ func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, reso
|
||||
return s.CalculateTokenCostForRequest(r)
|
||||
}
|
||||
|
||||
plan := s.contextPricingBreakpoints(resolver, resolved, in.Model, legacy)
|
||||
plan := s.contextPricingBreakpoints(resolver, resolved, in.Model)
|
||||
segments := buildContextSegments(plan.bounds)
|
||||
|
||||
tiers := make([]ContextPricingTier, 0, len(segments))
|
||||
@@ -140,11 +128,7 @@ func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, reso
|
||||
tiers = mergeEqualContextTiers(tiers)
|
||||
applyContextTierLabels(tiers, plan)
|
||||
|
||||
basis := ContextPricingBasisWholeRequest
|
||||
if legacy != nil {
|
||||
basis = ContextPricingBasisMarginal
|
||||
}
|
||||
return &ContextPricingSchedule{Basis: basis, Tiers: tiers, TimePricing: resolvedTimePricingSchedule(resolved)}, nil
|
||||
return &ContextPricingSchedule{Basis: ContextPricingBasisWholeRequest, Tiers: tiers, TimePricing: resolvedTimePricingSchedule(resolved)}, nil
|
||||
}
|
||||
|
||||
// resolvedTimePricingSchedule 列出计费会生效的分时倍率时段。
|
||||
@@ -210,14 +194,8 @@ type contextBreakpointPlan struct {
|
||||
}
|
||||
|
||||
// contextPricingBreakpoints 从计费自身的规则输入收集价格断点(不读取任何倍率)。
|
||||
func (s *BillingService) contextPricingBreakpoints(resolver *ModelPricingResolver, resolved *ResolvedPricing, model string, legacy *LegacyLongContextRule) contextBreakpointPlan {
|
||||
func (s *BillingService) contextPricingBreakpoints(resolver *ModelPricingResolver, resolved *ResolvedPricing, model string) contextBreakpointPlan {
|
||||
plan := contextBreakpointPlan{}
|
||||
if legacy != nil {
|
||||
plan.bounds = []int{legacy.Threshold}
|
||||
plan.thresholdBound = legacy.Threshold
|
||||
plan.threshold = legacy.Threshold
|
||||
return plan
|
||||
}
|
||||
if !resolved.longContextPricingEnabled {
|
||||
return plan
|
||||
}
|
||||
|
||||
@@ -69,12 +69,25 @@ func requirePrice(t *testing.T, want, got *float64, field string) {
|
||||
require.InDelta(t, *want, *got, 1e-15, field)
|
||||
}
|
||||
|
||||
// mustCatalogFromJSON 走生产解析路径(含 above_XXXk 折算)构造目录 stub;场景表无 *testing.T,解析失败直接 panic。
|
||||
func mustCatalogFromJSON(body string) *PricingService {
|
||||
s := &PricingService{}
|
||||
data, err := s.parsePricingData([]byte(body))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
s.pricingData = data
|
||||
return s
|
||||
}
|
||||
|
||||
func openAILadderCatalog() *PricingService { return mustCatalogFromJSON(openAILadderCatalogJSON) }
|
||||
|
||||
func scheduleScenarios() []scheduleScenario {
|
||||
p := testPtrFloat64
|
||||
return []scheduleScenario{
|
||||
{
|
||||
name: "官方阶梯 gpt-5.4 整单两档", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI,
|
||||
group: enabledGroup(PlatformOpenAI), wantBasis: ContextPricingBasisWholeRequest,
|
||||
group: enabledGroup(PlatformOpenAI), catalog: openAILadderCatalog(), wantBasis: ContextPricingBasisWholeRequest,
|
||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||
require.Len(t, s.Tiers, 2)
|
||||
requireTier(t, s.Tiers[0], 0, intPtr(272000), "≤272K", p(2.5e-6), p(15e-6), p(2.5e-6), p(0.25e-6))
|
||||
@@ -92,7 +105,7 @@ func scheduleScenarios() []scheduleScenario {
|
||||
},
|
||||
{
|
||||
name: "分组关闭阶梯只剩基础档", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI,
|
||||
group: disabledGroup(PlatformOpenAI), wantBasis: ContextPricingBasisWholeRequest,
|
||||
group: disabledGroup(PlatformOpenAI), catalog: openAILadderCatalog(), wantBasis: ContextPricingBasisWholeRequest,
|
||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||
require.Len(t, s.Tiers, 1)
|
||||
requireTier(t, s.Tiers[0], 0, nil, "", p(2.5e-6), p(15e-6), p(2.5e-6), p(0.25e-6))
|
||||
@@ -100,7 +113,7 @@ func scheduleScenarios() []scheduleScenario {
|
||||
},
|
||||
{
|
||||
name: "官方参考价(无分组)带目录阶梯", model: "gpt-5.4", platform: "", groupPlatform: PlatformOpenAI,
|
||||
group: nil, wantBasis: ContextPricingBasisWholeRequest,
|
||||
group: nil, catalog: openAILadderCatalog(), wantBasis: ContextPricingBasisWholeRequest,
|
||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||
require.Len(t, s.Tiers, 2)
|
||||
requireTier(t, s.Tiers[1], 272000, nil, ">272K", p(5e-6), p(22.5e-6), p(5e-6), p(0.5e-6))
|
||||
@@ -210,6 +223,7 @@ func scheduleScenarios() []scheduleScenario {
|
||||
group: &Group{ID: 100, Platform: PlatformOpenAI, LongContextPricingEnabled: true, ModelPricing: []ChannelModelPricing{{
|
||||
Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken, InputPrice: p(1e-6),
|
||||
}}},
|
||||
catalog: openAILadderCatalog(),
|
||||
wantBasis: ContextPricingBasisWholeRequest,
|
||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||
require.Len(t, s.Tiers, 2)
|
||||
@@ -219,46 +233,56 @@ func scheduleScenarios() []scheduleScenario {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Gemini 旧规则按超出部分计价", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
|
||||
group: enabledGroup(PlatformGemini), catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisMarginal,
|
||||
name: "Gemini 目录阶梯整单换档", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
|
||||
group: enabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), wantBasis: ContextPricingBasisWholeRequest,
|
||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||
require.Len(t, s.Tiers, 2)
|
||||
requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(1.25e-6), p(10e-6), nil, p(0.3125e-6))
|
||||
requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(2.5e-6), p(10e-6), nil, p(0.625e-6))
|
||||
// 缓存写入按标准输入价,与 input 一同整单换档
|
||||
requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(1.25e-6), p(10e-6), p(1.25e-6), p(0.125e-6))
|
||||
requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(2.5e-6), p(15e-6), p(2.5e-6), p(0.25e-6))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Gemini 分组关闭时不用旧规则", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
|
||||
group: disabledGroup(PlatformGemini), catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisWholeRequest,
|
||||
name: "Gemini 分组关闭时无阶梯", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
|
||||
group: disabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), wantBasis: ContextPricingBasisWholeRequest,
|
||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||
require.Len(t, s.Tiers, 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Gemini 有渠道定价时旧规则让位", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
|
||||
group: enabledGroup(PlatformGemini), catalog: geminiCatalogStub(),
|
||||
name: "Gemini 渠道平价之上叠加目录阶梯", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
|
||||
group: enabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON),
|
||||
channel: []ChannelModelPricing{{
|
||||
Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken, InputPrice: p(3e-6),
|
||||
}},
|
||||
wantBasis: ContextPricingBasisWholeRequest,
|
||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||
require.Len(t, s.Tiers, 1)
|
||||
require.Len(t, s.Tiers, 2)
|
||||
requirePrice(t, p(3e-6), s.Tiers[0].Input, "input")
|
||||
requirePrice(t, p(6e-6), s.Tiers[1].Input, "input")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Gemini 官方参考不套用站内旧规则", model: "gemini-2.5-pro", platform: "", groupPlatform: PlatformGemini,
|
||||
group: nil, catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisWholeRequest,
|
||||
name: "Gemini 目录无阶梯字段时开关开启也无阶梯", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
|
||||
group: enabledGroup(PlatformGemini), catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisWholeRequest,
|
||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||
require.Len(t, s.Tiers, 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Gemini 官方参考价与目录数据同源", model: "gemini-2.5-pro", platform: "", groupPlatform: PlatformGemini,
|
||||
group: nil, catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), wantBasis: ContextPricingBasisWholeRequest,
|
||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||
require.Len(t, s.Tiers, 2)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "composite 分组按模型平台取渠道定价并叠加官方阶梯", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformComposite,
|
||||
group: enabledGroup(PlatformComposite),
|
||||
channel: []ChannelModelPricing{{
|
||||
Platform: PlatformOpenAI, Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken, InputPrice: p(1e-6),
|
||||
}},
|
||||
catalog: openAILadderCatalog(),
|
||||
wantBasis: ContextPricingBasisWholeRequest,
|
||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||
require.Len(t, s.Tiers, 2)
|
||||
@@ -283,9 +307,11 @@ func scheduleScenarios() []scheduleScenario {
|
||||
{
|
||||
name: "gpt-5.6 缺 cache_write 时按策略补 1.25 倍并带阶梯", model: "gpt-5.6-sol", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI,
|
||||
group: enabledGroup(PlatformOpenAI),
|
||||
catalog: newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
|
||||
"gpt-5.6-sol": {Mode: "chat", InputCostPerToken: 5e-6, OutputCostPerToken: 30e-6, CacheReadInputTokenCost: 0.5e-6},
|
||||
}),
|
||||
catalog: mustCatalogFromJSON(`{"gpt-5.6-sol": {"litellm_provider": "openai", "mode": "chat",
|
||||
"input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, "cache_read_input_token_cost": 5e-07,
|
||||
"input_cost_per_token_above_272k_tokens": 1e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 4.5e-05,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 1e-06}}`),
|
||||
wantBasis: ContextPricingBasisWholeRequest,
|
||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||
require.Len(t, s.Tiers, 2)
|
||||
@@ -398,22 +424,8 @@ func tierAt(tiers []ContextPricingTier, contextTokens int) ContextPricingTier {
|
||||
}
|
||||
|
||||
// expectedCostFromSchedule 按阶梯表推算 contextTokens 个某类 token 的费用:
|
||||
// 整单基准取所在档单价 × 全量;边际基准逐段累加。
|
||||
// 整单基准取所在档单价 × 全量。
|
||||
func expectedCostFromSchedule(s *ContextPricingSchedule, kind tokenKind, contextTokens int) float64 {
|
||||
if s.Basis == ContextPricingBasisMarginal {
|
||||
total := 0.0
|
||||
for _, tier := range s.Tiers {
|
||||
if contextTokens <= tier.MinTokens {
|
||||
break
|
||||
}
|
||||
upper := contextTokens
|
||||
if tier.MaxTokens != nil && *tier.MaxTokens < upper {
|
||||
upper = *tier.MaxTokens
|
||||
}
|
||||
total += float64(upper-tier.MinTokens) * tierPrice(tier, kind)
|
||||
}
|
||||
return total
|
||||
}
|
||||
return float64(contextTokens) * tierPrice(tierAt(s.Tiers, contextTokens), kind)
|
||||
}
|
||||
|
||||
@@ -443,17 +455,10 @@ func TestResolveContextPricingSchedule_ParityWithBilling(t *testing.T) {
|
||||
pricingInput.GroupID = &gid
|
||||
}
|
||||
resolved := resolver.Resolve(ctx, pricingInput)
|
||||
var legacy *LegacyLongContextRule
|
||||
if sc.group != nil {
|
||||
legacy = bs.LegacyLongContextRule(sc.platform)
|
||||
}
|
||||
if !legacyLongContextApplies(resolved, sc.group, legacy) {
|
||||
legacy = nil
|
||||
}
|
||||
cost := func(tokens UsageTokens) float64 {
|
||||
bd, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
|
||||
Ctx: ctx, Model: sc.model, Group: sc.group, Tokens: tokens, RateMultiplier: 1,
|
||||
Resolver: resolver, Resolved: resolved, LegacyLongContext: legacy,
|
||||
Resolver: resolver, Resolved: resolved,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return bd.ActualCost
|
||||
|
||||
@@ -116,12 +116,6 @@ type ModelPricing struct {
|
||||
ImageOutputPriceExplicit bool // 是否由渠道定价显式设定(为 true 时即使 == 0 也不回退)
|
||||
}
|
||||
|
||||
const (
|
||||
openAIGPT54LongContextInputThreshold = 272000
|
||||
openAIGPT54LongContextInputMultiplier = 2.0
|
||||
openAIGPT54LongContextOutputMultiplier = 1.5
|
||||
)
|
||||
|
||||
func normalizeBillingServiceTier(serviceTier string) string {
|
||||
return strings.ToLower(strings.TrimSpace(serviceTier))
|
||||
}
|
||||
@@ -365,6 +359,27 @@ func (s *BillingService) initFallbackPricing() {
|
||||
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)
|
||||
|
||||
// Claude Fable 5.x uses the same input/output and cache-write prices, while
|
||||
// Fable 5.1 reduces cache reads from $1 to $0.25 per MTok.
|
||||
s.fallbackPrices["claude-fable-5"] = &ModelPricing{
|
||||
InputPricePerToken: 10e-6,
|
||||
OutputPricePerToken: 50e-6,
|
||||
CacheCreationPricePerToken: 12.5e-6,
|
||||
CacheCreation5mPrice: 12.5e-6,
|
||||
CacheCreation1hPrice: 20e-6,
|
||||
CacheReadPricePerToken: 1e-6,
|
||||
SupportsCacheBreakdown: true,
|
||||
}
|
||||
s.fallbackPrices["claude-fable-5-1"] = &ModelPricing{
|
||||
InputPricePerToken: 10e-6,
|
||||
OutputPricePerToken: 50e-6,
|
||||
CacheCreationPricePerToken: 12.5e-6,
|
||||
CacheCreation5mPrice: 12.5e-6,
|
||||
CacheCreation1hPrice: 20e-6,
|
||||
CacheReadPricePerToken: 0.25e-6,
|
||||
SupportsCacheBreakdown: true,
|
||||
}
|
||||
|
||||
// Gemini 3.1 Pro
|
||||
s.fallbackPrices["gemini-3.1-pro"] = &ModelPricing{
|
||||
InputPricePerToken: 2e-6, // $2 per MTok
|
||||
@@ -395,9 +410,6 @@ func (s *BillingService) initFallbackPricing() {
|
||||
CacheReadPricePerToken: 0.25e-6, // $0.25 per MTok
|
||||
CacheReadPricePerTokenPriority: 0.5e-6, // $0.5 per MTok
|
||||
SupportsCacheBreakdown: false,
|
||||
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
|
||||
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
|
||||
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
|
||||
}
|
||||
// OpenAI GPT-5.5 官方价格;Fast 为标准价 2.5 倍。
|
||||
// Source: https://platform.openai.com/docs/pricing
|
||||
@@ -405,24 +417,18 @@ func (s *BillingService) initFallbackPricing() {
|
||||
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,
|
||||
CacheCreationPricePerToken: 5e-6,
|
||||
CacheReadPricePerToken: 0.5e-6,
|
||||
SupportsCacheBreakdown: false,
|
||||
}, 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,
|
||||
CacheCreationPricePerToken: 30e-6,
|
||||
CacheReadPricePerToken: 30e-6,
|
||||
SupportsCacheBreakdown: false,
|
||||
}
|
||||
|
||||
// OpenAI GPT-5.6 官方价格(USD/token)。缓存写入为输入价的 1.25 倍。
|
||||
@@ -435,9 +441,6 @@ func (s *BillingService) initFallbackPricing() {
|
||||
CacheCreationPricePerTokenPriority: 12.5e-6,
|
||||
CacheReadPricePerToken: 0.5e-6,
|
||||
CacheReadPricePerTokenPriority: 1e-6,
|
||||
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
|
||||
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
|
||||
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
|
||||
}
|
||||
s.fallbackPrices["gpt-5.6-terra"] = &ModelPricing{
|
||||
InputPricePerToken: 2e-6,
|
||||
@@ -448,9 +451,6 @@ func (s *BillingService) initFallbackPricing() {
|
||||
CacheCreationPricePerTokenPriority: 5e-6,
|
||||
CacheReadPricePerToken: 0.2e-6,
|
||||
CacheReadPricePerTokenPriority: 0.4e-6,
|
||||
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
|
||||
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
|
||||
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
|
||||
}
|
||||
s.fallbackPrices["gpt-5.6-luna"] = &ModelPricing{
|
||||
InputPricePerToken: 0.2e-6,
|
||||
@@ -461,9 +461,6 @@ func (s *BillingService) initFallbackPricing() {
|
||||
CacheCreationPricePerTokenPriority: 0.5e-6,
|
||||
CacheReadPricePerToken: 0.02e-6,
|
||||
CacheReadPricePerTokenPriority: 0.04e-6,
|
||||
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
|
||||
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
|
||||
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
|
||||
}
|
||||
|
||||
s.fallbackPrices["gpt-5.4-mini"] = &ModelPricing{
|
||||
@@ -806,6 +803,13 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
|
||||
modelLower := strings.ToLower(model)
|
||||
|
||||
// 按模型系列匹配
|
||||
if strings.Contains(modelLower, "fable-5-1") || strings.Contains(modelLower, "fable-5.1") ||
|
||||
strings.Contains(modelLower, "fable5.1") || strings.Contains(modelLower, "fable51") {
|
||||
return s.fallbackPrices["claude-fable-5-1"]
|
||||
}
|
||||
if strings.Contains(modelLower, "fable-5") || strings.Contains(modelLower, "fable5") {
|
||||
return s.fallbackPrices["claude-fable-5"]
|
||||
}
|
||||
if strings.Contains(modelLower, "opus") {
|
||||
// "opus-5" 必须先判:不能用裸 "5" 匹配,否则 claude-opus-4-5 会被误判。
|
||||
if strings.Contains(modelLower, "opus-5") || strings.Contains(modelLower, "opus5") {
|
||||
@@ -1125,10 +1129,12 @@ func (s *BillingService) GetModelPricing(model string) (*ModelPricing, error) {
|
||||
CacheCreation1hPrice: price1h,
|
||||
SupportsCacheBreakdown: enableBreakdown,
|
||||
LongContextInputThreshold: litellmPricing.LongContextInputTokenThreshold,
|
||||
LongContextInputMultiplier: litellmPricing.LongContextInputCostMultiplier,
|
||||
LongContextOutputMultiplier: litellmPricing.LongContextOutputCostMultiplier,
|
||||
ImageInputPricePerToken: litellmPricing.InputCostPerImageToken,
|
||||
ImageOutputPricePerToken: litellmPricing.OutputCostPerImageToken,
|
||||
// xAI 的长上下文阈值语义为"达到即进高档"(LiteLLM 同口径),其余提供商为严格大于。
|
||||
LongContextThresholdInclusive: strings.EqualFold(litellmPricing.LiteLLMProvider, "xai"),
|
||||
LongContextInputMultiplier: litellmPricing.LongContextInputCostMultiplier,
|
||||
LongContextOutputMultiplier: litellmPricing.LongContextOutputCostMultiplier,
|
||||
ImageInputPricePerToken: litellmPricing.InputCostPerImageToken,
|
||||
ImageOutputPricePerToken: litellmPricing.OutputCostPerImageToken,
|
||||
}), nil
|
||||
}
|
||||
}
|
||||
@@ -1203,7 +1209,15 @@ func applyChannelTokenPriceOverrides(pricing *ModelPricing, channelPricing *Chan
|
||||
pricing.CacheCreationPricePerTokenPriority = priority
|
||||
pricing.CacheCreationPriceExplicit = true
|
||||
pricing.CacheCreation5mPrice = *channelPricing.CacheWritePrice
|
||||
pricing.CacheCreation1hPrice = *channelPricing.CacheWritePrice
|
||||
if channelPricing.CacheWrite1hPrice == nil {
|
||||
// Preserve the pre-split behavior for existing configurations: a lone
|
||||
// cache_write_price continues to override both TTL tiers.
|
||||
pricing.CacheCreation1hPrice = *channelPricing.CacheWritePrice
|
||||
}
|
||||
}
|
||||
if channelPricing.CacheWrite1hPrice != nil {
|
||||
pricing.CacheCreation1hPrice = *channelPricing.CacheWrite1hPrice
|
||||
pricing.SupportsCacheBreakdown = true
|
||||
}
|
||||
if channelPricing.CacheReadPrice != nil {
|
||||
priority := channelTierOverridePrice(pricing.CacheReadPricePerToken, pricing.CacheReadPricePerTokenPriority, *channelPricing.CacheReadPrice)
|
||||
@@ -1375,15 +1389,18 @@ func (s *BillingService) computeTokenBreakdown(
|
||||
var baselineCost *CostBreakdown
|
||||
if longContextPricingEligible {
|
||||
baselineCost = s.computeTokenBreakdown(pricing, tokens, rateMultiplier, serviceTier, false)
|
||||
inputPrice *= pricing.LongContextInputMultiplier
|
||||
outputPrice *= pricing.LongContextOutputMultiplier
|
||||
// 倍率 ≤0 表示该项未配置(目录/覆写条目可能只写了 input 或 output 一侧),
|
||||
// 按 1 计而不是乘 0:乘 0 会把超阈值请求的对应分项算成免费。
|
||||
longCtxInputMultiplier := longContextMultiplierOrOne(pricing.LongContextInputMultiplier)
|
||||
inputPrice *= longCtxInputMultiplier
|
||||
outputPrice *= longContextMultiplierOrOne(pricing.LongContextOutputMultiplier)
|
||||
// 缓存读取本质上是输入侧的复用,应与 input 一同应用长上下文倍率;
|
||||
// 否则 cache hit 越多,少计的费用越多(见 #2293)。
|
||||
cacheReadPrice *= pricing.LongContextInputMultiplier
|
||||
cacheReadPrice *= longCtxInputMultiplier
|
||||
// 缓存创建(cache_write)也是输入侧操作,三档价格(标准 / 5m / 1h)
|
||||
// 都通过 computeCacheCreationCost 直接读取 pricing.*,不会经过这里
|
||||
// 的倍率修改,因此显式向下传一个倍率,避免长上下文场景下被漏乘。
|
||||
cacheCreationMultiplier = pricing.LongContextInputMultiplier
|
||||
cacheCreationMultiplier = longCtxInputMultiplier
|
||||
}
|
||||
|
||||
bd := &CostBreakdown{}
|
||||
@@ -1568,10 +1585,13 @@ func (s *BillingService) calculateCostInternalWithPolicy(
|
||||
return s.computeTokenBreakdown(pricing, tokens, rateMultiplier, serviceTier, longContextBillingEnabled), nil
|
||||
}
|
||||
|
||||
// applyModelSpecificPricingPolicy 应用模型特定定价策略(GPT-5.6 长上下文/缓存写入、
|
||||
// DeepSeek 官方价强制覆盖等)。默认强制 DeepSeek 官方价——该路径仅被默认价卡
|
||||
// (GetModelPricing 内部)调用;分组/渠道自定义定价路径用带参数的
|
||||
// applyModelSpecificPricingPolicyEx 关闭强制,保留运营者配置。
|
||||
// applyModelSpecificPricingPolicy 对目录数据做模型特定修正:DeepSeek 官方价
|
||||
// 强制覆盖;GPT-5.6 缺 cache_write 价时按官方规则补 1.25 倍输入价;Fast/priority
|
||||
// 档按业务倍率改写(本地/远程目录的 priority 价可能沿用官方旧口径)。长上下文
|
||||
// 阶梯不在此处补齐:一律由目录数据(above_XXXk 折算或显式 long_context_* 字段)
|
||||
// 驱动。默认强制 DeepSeek 官方价——该路径仅被默认价卡(GetModelPricing 内部)
|
||||
// 调用;分组/渠道自定义定价路径用带参数的 applyModelSpecificPricingPolicyEx
|
||||
// 关闭强制,保留运营者配置。
|
||||
func (s *BillingService) applyModelSpecificPricingPolicy(model string, pricing *ModelPricing) *ModelPricing {
|
||||
return s.applyModelSpecificPricingPolicyEx(model, pricing, true)
|
||||
}
|
||||
@@ -1607,16 +1627,10 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing
|
||||
}
|
||||
normalized := normalizeKnownOpenAICodexModel(model)
|
||||
isGPT56 := isOpenAIGPT56Model(normalized)
|
||||
usesLegacyLongContextPricing := usesOpenAILegacyLongContextPricing(normalized)
|
||||
if !isGPT56 && !usesLegacyLongContextPricing {
|
||||
return pricing
|
||||
}
|
||||
needsLongContextPolicy := (isGPT56 || usesLegacyLongContextPricing) &&
|
||||
(pricing.LongContextInputThreshold <= 0 || pricing.LongContextInputMultiplier <= 0 || pricing.LongContextOutputMultiplier <= 0)
|
||||
needsCacheCreationPolicy := isGPT56 && !pricing.CacheCreationPriceExplicit && (pricing.CacheCreationPricePerToken <= 0 ||
|
||||
(pricing.InputPricePerTokenPriority > 0 && pricing.CacheCreationPricePerTokenPriority <= 0))
|
||||
fastRatio := openAIModelFastPricingRatio(normalized)
|
||||
if !needsLongContextPolicy && !needsCacheCreationPolicy && fastRatio <= 0 {
|
||||
if !needsCacheCreationPolicy && fastRatio <= 0 {
|
||||
return pricing
|
||||
}
|
||||
cloned := *pricing
|
||||
@@ -1628,17 +1642,6 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing
|
||||
cloned.CacheCreationPricePerTokenPriority = cloned.InputPricePerTokenPriority * 1.25
|
||||
}
|
||||
}
|
||||
if isGPT56 || usesLegacyLongContextPricing {
|
||||
if cloned.LongContextInputThreshold <= 0 {
|
||||
cloned.LongContextInputThreshold = openAIGPT54LongContextInputThreshold
|
||||
}
|
||||
if cloned.LongContextInputMultiplier <= 0 {
|
||||
cloned.LongContextInputMultiplier = openAIGPT54LongContextInputMultiplier
|
||||
}
|
||||
if cloned.LongContextOutputMultiplier <= 0 {
|
||||
cloned.LongContextOutputMultiplier = openAIGPT54LongContextOutputMultiplier
|
||||
}
|
||||
}
|
||||
if fastRatio > 0 {
|
||||
enforceOpenAIFastPricingRatio(&cloned, fastRatio)
|
||||
}
|
||||
@@ -1678,6 +1681,14 @@ func enforceOpenAIFastPricingRatio(pricing *ModelPricing, ratio float64) {
|
||||
}
|
||||
}
|
||||
|
||||
// longContextMultiplierOrOne 把未配置(≤0)的长上下文倍率归一为 1。
|
||||
func longContextMultiplierOrOne(m float64) float64 {
|
||||
if m <= 0 {
|
||||
return 1
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *BillingService) shouldApplySessionLongContextPricing(tokens UsageTokens, pricing *ModelPricing) bool {
|
||||
if pricing == nil || pricing.LongContextInputThreshold <= 0 {
|
||||
return false
|
||||
@@ -1692,10 +1703,6 @@ func (s *BillingService) shouldApplySessionLongContextPricing(tokens UsageTokens
|
||||
return totalInputTokens > pricing.LongContextInputThreshold
|
||||
}
|
||||
|
||||
func usesOpenAILegacyLongContextPricing(normalized string) bool {
|
||||
return normalized == "gpt-5.4" || normalized == "gpt-5.5" || normalized == "gpt-5.5-pro"
|
||||
}
|
||||
|
||||
// CalculateCostWithConfig 使用配置中的默认倍率计算费用
|
||||
func (s *BillingService) CalculateCostWithConfig(model string, tokens UsageTokens) (*CostBreakdown, error) {
|
||||
multiplier := s.cfg.Default.RateMultiplier
|
||||
@@ -1705,82 +1712,6 @@ func (s *BillingService) CalculateCostWithConfig(model string, tokens UsageToken
|
||||
return s.CalculateCost(model, tokens, multiplier)
|
||||
}
|
||||
|
||||
// CalculateCostWithLongContext 计算费用,支持长上下文双倍计费
|
||||
// threshold: 阈值(如 200000),超过此值的部分按 extraMultiplier 倍计费
|
||||
// extraMultiplier: 超出部分的倍率(如 2.0 表示双倍)
|
||||
//
|
||||
// 示例:缓存 210k + 输入 10k = 220k,阈值 200k,倍率 2.0
|
||||
// 拆分为:范围内 (200k, 0) + 范围外 (10k, 10k)
|
||||
// 范围内正常计费,范围外 × 2 计费
|
||||
func (s *BillingService) CalculateCostWithLongContext(model string, tokens UsageTokens, rateMultiplier float64, threshold int, extraMultiplier float64) (*CostBreakdown, error) {
|
||||
// 未启用长上下文计费,直接走正常计费
|
||||
if threshold <= 0 || extraMultiplier <= 1 {
|
||||
return s.CalculateCost(model, tokens, rateMultiplier)
|
||||
}
|
||||
|
||||
// 计算总输入 token(缓存读取 + 新输入)
|
||||
total := tokens.CacheReadTokens + tokens.InputTokens
|
||||
if total <= threshold {
|
||||
return s.CalculateCost(model, tokens, rateMultiplier)
|
||||
}
|
||||
|
||||
// 拆分成范围内和范围外
|
||||
var inRangeCacheTokens, inRangeInputTokens int
|
||||
var outRangeCacheTokens, outRangeInputTokens int
|
||||
|
||||
if tokens.CacheReadTokens >= threshold {
|
||||
// 缓存已超过阈值:范围内只有缓存,范围外是超出的缓存+全部输入
|
||||
inRangeCacheTokens = threshold
|
||||
inRangeInputTokens = 0
|
||||
outRangeCacheTokens = tokens.CacheReadTokens - threshold
|
||||
outRangeInputTokens = tokens.InputTokens
|
||||
} else {
|
||||
// 缓存未超过阈值:范围内是全部缓存+部分输入,范围外是剩余输入
|
||||
inRangeCacheTokens = tokens.CacheReadTokens
|
||||
inRangeInputTokens = threshold - tokens.CacheReadTokens
|
||||
outRangeCacheTokens = 0
|
||||
outRangeInputTokens = tokens.InputTokens - inRangeInputTokens
|
||||
}
|
||||
|
||||
// 范围内部分:正常计费
|
||||
inRangeTokens := UsageTokens{
|
||||
InputTokens: inRangeInputTokens,
|
||||
OutputTokens: tokens.OutputTokens, // 输出只算一次
|
||||
CacheCreationTokens: tokens.CacheCreationTokens,
|
||||
CacheReadTokens: inRangeCacheTokens,
|
||||
CacheCreation5mTokens: tokens.CacheCreation5mTokens,
|
||||
CacheCreation1hTokens: tokens.CacheCreation1hTokens,
|
||||
ImageOutputTokens: tokens.ImageOutputTokens,
|
||||
}
|
||||
inRangeCost, err := s.CalculateCost(model, inRangeTokens, rateMultiplier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 范围外部分:× extraMultiplier 计费
|
||||
outRangeTokens := UsageTokens{
|
||||
InputTokens: outRangeInputTokens,
|
||||
CacheReadTokens: outRangeCacheTokens,
|
||||
}
|
||||
outRangeCost, err := s.CalculateCost(model, outRangeTokens, rateMultiplier*extraMultiplier)
|
||||
if err != nil {
|
||||
return inRangeCost, fmt.Errorf("out-range cost: %w", err)
|
||||
}
|
||||
|
||||
// 合并成本
|
||||
return &CostBreakdown{
|
||||
InputCost: inRangeCost.InputCost + outRangeCost.InputCost,
|
||||
ImageInputCost: inRangeCost.ImageInputCost + outRangeCost.ImageInputCost,
|
||||
OutputCost: inRangeCost.OutputCost,
|
||||
ImageOutputCost: inRangeCost.ImageOutputCost,
|
||||
CacheCreationCost: inRangeCost.CacheCreationCost,
|
||||
CacheReadCost: inRangeCost.CacheReadCost + outRangeCost.CacheReadCost,
|
||||
TotalCost: inRangeCost.TotalCost + outRangeCost.TotalCost,
|
||||
ActualCost: inRangeCost.ActualCost + outRangeCost.ActualCost,
|
||||
LongContextBillingApplied: outRangeCost.ActualCost > 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListSupportedModels 列出所有支持的模型(现在总是返回true,因为有模糊匹配)
|
||||
func (s *BillingService) ListSupportedModels() []string {
|
||||
models := make([]string, 0)
|
||||
|
||||
@@ -33,6 +33,11 @@ func newTestBillingService() *BillingService {
|
||||
return NewBillingService(&config.Config{}, nil)
|
||||
}
|
||||
|
||||
func newTestBillingServiceWithOpenAILadderCatalog(t *testing.T) *BillingService {
|
||||
t.Helper()
|
||||
return NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, openAILadderCatalogJSON))
|
||||
}
|
||||
|
||||
func TestCalculateCost_BasicComputation(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
@@ -200,9 +205,21 @@ func TestGetModelPricing_OpenAIGPT54Fallback(t *testing.T) {
|
||||
require.InDelta(t, 2.5e-6, pricing.InputPricePerToken, 1e-12)
|
||||
require.InDelta(t, 15e-6, pricing.OutputPricePerToken, 1e-12)
|
||||
require.InDelta(t, 0.25e-6, pricing.CacheReadPricePerToken, 1e-12)
|
||||
// 静态兜底价不携带长上下文阶梯:阶梯一律由目录数据(above_272k 折算)驱动。
|
||||
require.Zero(t, pricing.LongContextInputThreshold)
|
||||
require.Zero(t, pricing.LongContextInputMultiplier)
|
||||
require.Zero(t, pricing.LongContextOutputMultiplier)
|
||||
}
|
||||
|
||||
func TestGetModelPricing_CatalogAboveTierFieldsDriveLongContext(t *testing.T) {
|
||||
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
|
||||
|
||||
pricing, err := svc.GetModelPricing("gpt-5.4")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 272000, pricing.LongContextInputThreshold)
|
||||
require.InDelta(t, 2.0, pricing.LongContextInputMultiplier, 1e-12)
|
||||
require.InDelta(t, 1.5, pricing.LongContextOutputMultiplier, 1e-12)
|
||||
require.False(t, pricing.LongContextThresholdInclusive, "openai 阈值语义为严格大于")
|
||||
}
|
||||
|
||||
func TestGetModelPricing_OpenAICompactAliasesFallback(t *testing.T) {
|
||||
@@ -215,8 +232,8 @@ func TestGetModelPricing_OpenAICompactAliasesFallback(t *testing.T) {
|
||||
cacheRead float64
|
||||
longContext int
|
||||
}{
|
||||
{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.5", inputPrice: 5e-6, outputPrice: 30e-6, cacheRead: 0.5e-6, longContext: 0},
|
||||
{model: "openai/gpt5.4", inputPrice: 2.5e-6, outputPrice: 15e-6, cacheRead: 0.25e-6, longContext: 0},
|
||||
{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},
|
||||
}
|
||||
@@ -247,7 +264,7 @@ func TestGetModelPricing_OpenAIGPT54MiniFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCalculateCost_OpenAIGPT54LongContextAppliesWholeSessionMultipliers(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
|
||||
|
||||
tokens := UsageTokens{
|
||||
InputTokens: 300000,
|
||||
@@ -267,7 +284,7 @@ func TestCalculateCost_OpenAIGPT54LongContextAppliesWholeSessionMultipliers(t *t
|
||||
}
|
||||
|
||||
func TestCalculateCost_OpenAIGPT54LongContextMarkerRequiresActualCostIncrease(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
|
||||
|
||||
cost, err := svc.calculateCostWithServiceTierPolicy(
|
||||
"gpt-5.4-2026-03-05",
|
||||
@@ -283,7 +300,7 @@ func TestCalculateCost_OpenAIGPT54LongContextMarkerRequiresActualCostIncrease(t
|
||||
}
|
||||
|
||||
func TestCalculateCost_OpenAIGPT55ProUsesGPT55PricingPolicy(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
|
||||
|
||||
tokens := UsageTokens{
|
||||
InputTokens: 300000,
|
||||
@@ -331,7 +348,7 @@ func TestFallbackPricing_OpenAIGPT55ProUsesOfficialPrices(t *testing.T) {
|
||||
// 修复前:CacheReadCost = tokens * 0.25e-6 (漏乘倍率,少计费用)。
|
||||
// 修复后:CacheReadCost = tokens * 0.25e-6 * LongContextInputMultiplier(=2.0)。
|
||||
func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheRead(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
|
||||
|
||||
// InputTokens + CacheReadTokens = 1000 + 300000 = 301000 > 272000 阈值
|
||||
tokens := UsageTokens{
|
||||
@@ -359,7 +376,7 @@ func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheRead(t *tes
|
||||
|
||||
// 阴性测试:未触发长上下文时,cache_read_price 不应被错误地乘以倍率。
|
||||
func TestCalculateCost_OpenAIGPT54NoLongContextKeepsCacheReadAtBasePrice(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
|
||||
|
||||
// InputTokens + CacheReadTokens = 1000 + 100000 = 101000 < 272000 阈值,不触发长上下文
|
||||
tokens := UsageTokens{
|
||||
@@ -381,7 +398,7 @@ func TestCalculateCost_OpenAIGPT54NoLongContextKeepsCacheReadAtBasePrice(t *test
|
||||
// 不经过 computeTokenBreakdown 内的 inputPrice / cacheReadPrice 倍率修改,因此
|
||||
// 修复前 cache_creation 部分会按基础价计算,少计费用约 50%(默认倍率 2.0)。
|
||||
func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheCreation(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
|
||||
|
||||
// InputTokens + CacheReadTokens = 1000 + 300000 = 301000 > 272000 阈值
|
||||
tokens := UsageTokens{
|
||||
@@ -402,7 +419,7 @@ func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheCreation(t
|
||||
|
||||
// 阴性测试:未触发长上下文时,cache_creation_price 不应被错误地乘以倍率。
|
||||
func TestCalculateCost_OpenAIGPT54NoLongContextKeepsCacheCreationAtBasePrice(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
|
||||
|
||||
// InputTokens + CacheReadTokens = 1000 + 100000 = 101000 < 272000 阈值,不触发长上下文
|
||||
tokens := UsageTokens{
|
||||
@@ -921,114 +938,6 @@ func TestComputeTokenBreakdown_GptImage2ImageEditIssue4386(t *testing.T) {
|
||||
require.InDelta(t, wantImageOutput, cost.ImageOutputCost, 1e-15)
|
||||
require.InDelta(t, 0.016081, cost.TotalCost, 1e-9, "总额应为 $0.016081(修复前为 $0.015025)")
|
||||
}
|
||||
func TestCalculateCostWithLongContext_BelowThreshold(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
tokens := UsageTokens{
|
||||
InputTokens: 50000,
|
||||
OutputTokens: 1000,
|
||||
CacheReadTokens: 100000,
|
||||
}
|
||||
// 总输入 150k < 200k 阈值,应走正常计费
|
||||
cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 200000, 2.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
normalCost, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.InDelta(t, normalCost.ActualCost, cost.ActualCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestCalculateCostWithLongContext_AboveThreshold_CacheExceedsThreshold(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
// 缓存 210k + 输入 10k = 220k > 200k 阈值
|
||||
// 缓存已超阈值:范围内 200k 缓存,范围外 10k 缓存 + 10k 输入
|
||||
tokens := UsageTokens{
|
||||
InputTokens: 10000,
|
||||
OutputTokens: 1000,
|
||||
CacheReadTokens: 210000,
|
||||
}
|
||||
cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 200000, 2.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 范围内:200k cache + 0 input + 1k output
|
||||
inRange, _ := svc.CalculateCost("claude-sonnet-4", UsageTokens{
|
||||
InputTokens: 0,
|
||||
OutputTokens: 1000,
|
||||
CacheReadTokens: 200000,
|
||||
}, 1.0)
|
||||
|
||||
// 范围外:10k cache + 10k input,倍率 2.0
|
||||
outRange, _ := svc.CalculateCost("claude-sonnet-4", UsageTokens{
|
||||
InputTokens: 10000,
|
||||
CacheReadTokens: 10000,
|
||||
}, 2.0)
|
||||
|
||||
require.InDelta(t, inRange.ActualCost+outRange.ActualCost, cost.ActualCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestCalculateCostWithLongContext_AboveThreshold_CacheBelowThreshold(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
// 缓存 100k + 输入 150k = 250k > 200k 阈值
|
||||
// 缓存未超阈值:范围内 100k 缓存 + 100k 输入,范围外 50k 输入
|
||||
tokens := UsageTokens{
|
||||
InputTokens: 150000,
|
||||
OutputTokens: 1000,
|
||||
CacheReadTokens: 100000,
|
||||
}
|
||||
cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 200000, 2.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, cost.ActualCost > 0, "费用应大于 0")
|
||||
|
||||
// 正常费用不含长上下文
|
||||
normalCost, _ := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
|
||||
require.True(t, cost.ActualCost > normalCost.ActualCost, "长上下文费用应高于正常费用")
|
||||
}
|
||||
|
||||
func TestCalculateCostWithLongContext_MarkerRequiresActualCostIncrease(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
tokens := UsageTokens{InputTokens: 300000}
|
||||
|
||||
cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 0, 200000, 2.0)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, cost.ActualCost)
|
||||
require.False(t, cost.LongContextBillingApplied)
|
||||
}
|
||||
|
||||
func TestCalculateCostWithLongContext_DisabledThreshold(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
tokens := UsageTokens{InputTokens: 300000, CacheReadTokens: 0}
|
||||
|
||||
// threshold <= 0 应禁用长上下文计费
|
||||
cost1, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 0, 2.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
cost2, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.InDelta(t, cost2.ActualCost, cost1.ActualCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestCalculateCostWithLongContext_ExtraMultiplierLessEqualOne(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
tokens := UsageTokens{InputTokens: 300000}
|
||||
|
||||
// extraMultiplier <= 1 应禁用长上下文计费
|
||||
cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 200000, 1.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
normalCost, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.InDelta(t, normalCost.ActualCost, cost.ActualCost, 1e-10)
|
||||
}
|
||||
|
||||
func TestCalculateImageCost(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
@@ -1178,19 +1087,6 @@ func TestForceUpdatePricing_NilService(t *testing.T) {
|
||||
require.Contains(t, err.Error(), "not initialized")
|
||||
}
|
||||
|
||||
func TestCalculateCostWithLongContext_PropagatesError(t *testing.T) {
|
||||
// 使用空的 fallback prices 让 GetModelPricing 失败
|
||||
svc := &BillingService{
|
||||
cfg: &config.Config{},
|
||||
fallbackPrices: make(map[string]*ModelPricing),
|
||||
}
|
||||
|
||||
tokens := UsageTokens{InputTokens: 300000, CacheReadTokens: 0}
|
||||
_, err := svc.CalculateCostWithLongContext("unknown-model", tokens, 1.0, 200000, 2.0)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "pricing not found")
|
||||
}
|
||||
|
||||
func TestGetModelPricing_Grok45OfficialFallback(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
@@ -1850,6 +1746,32 @@ func TestGetModelPricingWithChannel_CacheWritePriceAffects5mAnd1h(t *testing.T)
|
||||
require.InDelta(t, 7e-6, pricing.CacheCreation1hPrice, 1e-12)
|
||||
}
|
||||
|
||||
func TestGetModelPricingWithChannel_CacheWriteTTLPricesCanDiffer(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
pricing, err := svc.GetModelPricingWithChannel("claude-fable-5-1", &ChannelModelPricing{
|
||||
CacheWritePrice: testPtrFloat64(13e-6),
|
||||
CacheWrite1hPrice: testPtrFloat64(21e-6),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, pricing.SupportsCacheBreakdown)
|
||||
require.InDelta(t, 13e-6, pricing.CacheCreationPricePerToken, 1e-12)
|
||||
require.InDelta(t, 13e-6, pricing.CacheCreation5mPrice, 1e-12)
|
||||
require.InDelta(t, 21e-6, pricing.CacheCreation1hPrice, 1e-12)
|
||||
}
|
||||
|
||||
func TestGetModelPricing_Fable51FallbackPricing(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
pricing, err := svc.GetModelPricing("claude-fable-5-1")
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, 10e-6, pricing.InputPricePerToken, 1e-12)
|
||||
require.InDelta(t, 50e-6, pricing.OutputPricePerToken, 1e-12)
|
||||
require.InDelta(t, 12.5e-6, pricing.CacheCreation5mPrice, 1e-12)
|
||||
require.InDelta(t, 20e-6, pricing.CacheCreation1hPrice, 1e-12)
|
||||
require.InDelta(t, 0.25e-6, pricing.CacheReadPricePerToken, 1e-12)
|
||||
}
|
||||
|
||||
func TestGetModelPricingWithChannel_CacheReadPriceAffectsPriority(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
|
||||
@@ -5,33 +5,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// LegacyLongContextRule 平台级"超出阈值部分按倍率计费"的旧规则。
|
||||
//
|
||||
// 语义为边际计费:仅 input/cache_read 中超过 Threshold 的部分乘 Multiplier,
|
||||
// output 与 cache_write 不受影响(见 CalculateCostWithLongContext)。
|
||||
// 只有 Gemini 原生 /v1beta 入口在该分组对该模型没有分组/渠道定价时才使用;
|
||||
// 规则常量由 BillingService 统一持有,网关与模型广场都从这里读取。
|
||||
type LegacyLongContextRule struct {
|
||||
Threshold int
|
||||
Multiplier float64
|
||||
}
|
||||
|
||||
const (
|
||||
geminiLegacyLongContextThreshold = 200000
|
||||
geminiLegacyLongContextMultiplier = 2.0
|
||||
)
|
||||
|
||||
// LegacyLongContextRule 返回平台的旧长上下文规则;无规则的平台返回 nil。
|
||||
func (s *BillingService) LegacyLongContextRule(platform string) *LegacyLongContextRule {
|
||||
if platform == PlatformGemini {
|
||||
return &LegacyLongContextRule{
|
||||
Threshold: geminiLegacyLongContextThreshold,
|
||||
Multiplier: geminiLegacyLongContextMultiplier,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TokenCostRequest 通用网关 token 计费请求。
|
||||
type TokenCostRequest struct {
|
||||
Ctx context.Context
|
||||
@@ -44,27 +17,12 @@ type TokenCostRequest struct {
|
||||
Resolver *ModelPricingResolver
|
||||
// Resolved 为调用方预先解析的定价(Resolver.Resolve 的结果),nil 表示未解析。
|
||||
Resolved *ResolvedPricing
|
||||
// LegacyLongContext 入口携带的旧长上下文规则,nil 表示该入口不使用。
|
||||
LegacyLongContext *LegacyLongContextRule
|
||||
}
|
||||
|
||||
// legacyLongContextApplies 判定请求是否走旧长上下文规则:
|
||||
// 分组/渠道显式定价优先;否则在规则存在且分组长上下文开关开启时生效。
|
||||
func legacyLongContextApplies(resolved *ResolvedPricing, group *Group, rule *LegacyLongContextRule) bool {
|
||||
if rule == nil || rule.Threshold <= 0 {
|
||||
return false
|
||||
}
|
||||
if resolved != nil && (resolved.Source == PricingSourceGroup || resolved.Source == PricingSourceChannel) {
|
||||
return false
|
||||
}
|
||||
return group == nil || group.LongContextPricingEnabled
|
||||
}
|
||||
|
||||
// CalculateTokenCostForRequest 按通用网关的路径选择计算 token 费用:
|
||||
// 1. 分组/渠道显式定价 → 统一计费(区间、分组卡、目录阶梯均在其中);
|
||||
// 2. 否则入口带旧长上下文规则且分组开关开启 → 旧边际计费;
|
||||
// 3. 否则有解析器与分组 → 统一计费(内置目录定价);
|
||||
// 4. 否则按模型目录直接计费。
|
||||
// 1. 分组/渠道显式定价,或有解析器与分组 → 统一计费
|
||||
// (区间、分组卡、目录长上下文阶梯均在其中,阶梯由目录数据驱动);
|
||||
// 2. 否则按模型目录直接计费。
|
||||
//
|
||||
// 模型广场的阶梯表查询与网关使用同一入口,保证展示与扣费同源。
|
||||
func (s *BillingService) CalculateTokenCostForRequest(req TokenCostRequest) (*CostBreakdown, error) {
|
||||
@@ -72,10 +30,6 @@ func (s *BillingService) CalculateTokenCostForRequest(req TokenCostRequest) (*Co
|
||||
if resolved != nil && (resolved.Source == PricingSourceGroup || resolved.Source == PricingSourceChannel) {
|
||||
return s.CalculateCostUnified(s.tokenCostInput(req, resolved))
|
||||
}
|
||||
if legacyLongContextApplies(resolved, req.Group, req.LegacyLongContext) {
|
||||
return s.CalculateCostWithLongContext(req.Model, req.Tokens, req.RateMultiplier,
|
||||
req.LegacyLongContext.Threshold, req.LegacyLongContext.Multiplier)
|
||||
}
|
||||
if req.Resolver != nil && req.Group != nil {
|
||||
return s.CalculateCostUnified(s.tokenCostInput(req, resolved))
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ func newTokenCostTestEnv(t *testing.T, groupPlatform string, pricing []ChannelMo
|
||||
return bs, NewModelPricingResolver(cs, bs)
|
||||
}
|
||||
|
||||
// geminiCatalogStub 无阶梯字段的 gemini 目录条目(用于验证"无数据即无阶梯")。
|
||||
func geminiCatalogStub() *PricingService {
|
||||
return newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
|
||||
"gemini-2.5-pro": {
|
||||
@@ -39,22 +40,32 @@ func geminiCatalogStub() *PricingService {
|
||||
})
|
||||
}
|
||||
|
||||
func TestLegacyLongContextRule_OnlyGemini(t *testing.T) {
|
||||
bs := NewBillingService(&config.Config{}, nil)
|
||||
rule := bs.LegacyLongContextRule(PlatformGemini)
|
||||
require.NotNil(t, rule)
|
||||
require.Equal(t, 200000, rule.Threshold)
|
||||
require.InDelta(t, 2.0, rule.Multiplier, 1e-12)
|
||||
for _, platform := range []string{PlatformOpenAI, PlatformAnthropic, PlatformAntigravity, PlatformComposite, ""} {
|
||||
require.Nil(t, bs.LegacyLongContextRule(platform), platform)
|
||||
}
|
||||
// geminiLadderCatalogJSON 镜像真实目录的 gemini-2.5-pro 条目(含 cache_creation 基础价修正后的
|
||||
// 数值):above_200k 绝对价由解析层折算成 200K 阈值 + 输入 ×2 / 输出 ×1.5;cache 侧 above 档
|
||||
// 恰为基础价 × 输入倍率(缓存写入按标准输入价,Google 对缓存写入不另收费)。
|
||||
const geminiLadderCatalogJSON = `{
|
||||
"gemini-2.5-pro": {"litellm_provider": "vertex_ai-language-models", "mode": "chat",
|
||||
"input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05,
|
||||
"cache_read_input_token_cost": 1.25e-07,
|
||||
"cache_creation_input_token_cost": 1.25e-06,
|
||||
"input_cost_per_token_above_200k_tokens": 2.5e-06,
|
||||
"output_cost_per_token_above_200k_tokens": 1.5e-05,
|
||||
"cache_read_input_token_cost_above_200k_tokens": 2.5e-07,
|
||||
"cache_creation_input_token_cost_above_200k_tokens": 2.5e-06}
|
||||
}`
|
||||
|
||||
func geminiLadderCatalogStub(t *testing.T) *PricingService {
|
||||
t.Helper()
|
||||
return newStubPricingServiceFromJSON(t, geminiLadderCatalogJSON)
|
||||
}
|
||||
|
||||
func TestCalculateTokenCostForRequest_ChannelPricingWinsOverLegacyRule(t *testing.T) {
|
||||
// 渠道平价之上叠加目录阶梯:与分组价卡/OpenAI 渠道价的既有语义一致,
|
||||
// 超阈值整单按渠道价 × 目录倍率。
|
||||
func TestCalculateTokenCostForRequest_ChannelFlatPriceStacksCatalogLadder(t *testing.T) {
|
||||
bs, resolver := newTokenCostTestEnv(t, PlatformGemini, []ChannelModelPricing{{
|
||||
Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken,
|
||||
InputPrice: testPtrFloat64(10e-6), OutputPrice: testPtrFloat64(40e-6),
|
||||
}}, geminiCatalogStub())
|
||||
}}, geminiLadderCatalogStub(t))
|
||||
group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true}
|
||||
gid := group.ID
|
||||
resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group})
|
||||
@@ -63,23 +74,41 @@ func TestCalculateTokenCostForRequest_ChannelPricingWinsOverLegacyRule(t *testin
|
||||
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
|
||||
got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
|
||||
Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1,
|
||||
Resolver: resolver, Resolved: resolved, LegacyLongContext: bs.LegacyLongContextRule(PlatformGemini),
|
||||
Resolver: resolver, Resolved: resolved,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
want, err := bs.CalculateCostUnified(CostInput{
|
||||
Ctx: context.Background(), Model: "gemini-2.5-pro", GroupID: &gid, Group: group, Tokens: tokens,
|
||||
RequestCount: 1, RateMultiplier: 1, Resolver: resolver, Resolved: resolved,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want, got)
|
||||
// 渠道平价 10e-6 × 300K,旧规则未叠加
|
||||
require.InDelta(t, 3.0, got.InputCost, 1e-9)
|
||||
require.InDelta(t, 300000*10e-6*2, got.InputCost, 1e-9)
|
||||
require.InDelta(t, 1000*40e-6*1.5, got.OutputCost, 1e-9)
|
||||
require.True(t, got.LongContextBillingApplied)
|
||||
}
|
||||
|
||||
func TestCalculateTokenCostForRequest_LegacyRuleFollowsGroupToggle(t *testing.T) {
|
||||
bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiCatalogStub())
|
||||
// 渠道配置了定价区间时以渠道区间为准:目录阶梯(倍率)不再叠加。
|
||||
func TestCalculateTokenCostForRequest_ChannelIntervalsOverrideCatalogLadder(t *testing.T) {
|
||||
bs, resolver := newTokenCostTestEnv(t, PlatformGemini, []ChannelModelPricing{{
|
||||
Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken,
|
||||
Intervals: []PricingInterval{{MinTokens: 0, InputPrice: testPtrFloat64(10e-6), OutputPrice: testPtrFloat64(40e-6)}},
|
||||
}}, geminiLadderCatalogStub(t))
|
||||
group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true}
|
||||
gid := group.ID
|
||||
resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group})
|
||||
require.Equal(t, PricingSourceChannel, resolved.Source)
|
||||
require.NotEmpty(t, resolved.Intervals)
|
||||
|
||||
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
|
||||
got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
|
||||
Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1,
|
||||
Resolver: resolver, Resolved: resolved,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, 300000*10e-6, got.InputCost, 1e-9)
|
||||
require.InDelta(t, 1000*40e-6, got.OutputCost, 1e-9)
|
||||
require.False(t, got.LongContextBillingApplied)
|
||||
}
|
||||
|
||||
// 目录阶梯跟随分组长上下文开关;开启时为整单换档(输入 ×2、输出 ×1.5)。
|
||||
func TestCalculateTokenCostForRequest_CatalogLadderFollowsGroupToggle(t *testing.T) {
|
||||
bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiLadderCatalogStub(t))
|
||||
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
|
||||
rule := bs.LegacyLongContextRule(PlatformGemini)
|
||||
|
||||
for _, enabled := range []bool{true, false} {
|
||||
group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: enabled}
|
||||
@@ -89,32 +118,74 @@ func TestCalculateTokenCostForRequest_LegacyRuleFollowsGroupToggle(t *testing.T)
|
||||
|
||||
got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
|
||||
Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1,
|
||||
Resolver: resolver, Resolved: resolved, LegacyLongContext: rule,
|
||||
Resolver: resolver, Resolved: resolved,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if enabled {
|
||||
want, err := bs.CalculateCostWithLongContext("gemini-2.5-pro", tokens, 1, rule.Threshold, rule.Multiplier)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want, got)
|
||||
// 输入 200K × 1.25e-6 + 超出 100K × 1.25e-6 × 2 = 0.5;输出 1000 × 10e-6 = 0.01。
|
||||
// 旧路径的加倍只体现在 ActualCost(分项 InputCost 不含倍率),探针也据此取值。
|
||||
require.InDelta(t, 0.51, got.ActualCost, 1e-9)
|
||||
// 300K × 1.25e-6 × 2 = 0.75;1000 × 10e-6 × 1.5 = 0.015
|
||||
require.InDelta(t, 0.765, got.ActualCost, 1e-9)
|
||||
require.True(t, got.LongContextBillingApplied)
|
||||
} else {
|
||||
want, err := bs.CalculateCostUnified(CostInput{
|
||||
Ctx: context.Background(), Model: "gemini-2.5-pro", GroupID: &gid, Group: group, Tokens: tokens,
|
||||
RequestCount: 1, RateMultiplier: 1, Resolver: resolver, Resolved: resolved,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want, got)
|
||||
require.InDelta(t, 0.385, got.ActualCost, 1e-9)
|
||||
require.False(t, got.LongContextBillingApplied)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 目录阶梯对缓存分项同样生效:cache_read / cache_creation 随输入倍率整单换档,
|
||||
// 阈值判定计入全部输入侧 token(input + cache_creation + cache_read)。
|
||||
func TestCalculateTokenCostForRequest_GeminiLadderAppliesToCacheItems(t *testing.T) {
|
||||
bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiLadderCatalogStub(t))
|
||||
group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true}
|
||||
gid := group.ID
|
||||
resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group})
|
||||
require.Equal(t, PricingSourceLiteLLM, resolved.Source)
|
||||
|
||||
calc := func(tokens UsageTokens) *CostBreakdown {
|
||||
got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
|
||||
Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1,
|
||||
Resolver: resolver, Resolved: resolved,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return got
|
||||
}
|
||||
|
||||
// 输入侧合计 90K + 100K + 20K = 210K > 200K:所有分项按高档计。
|
||||
// 不计 cache_creation 时只有 110K,不会过阈值——用例同时守住"缓存写入 token 计入阈值"。
|
||||
above := calc(UsageTokens{InputTokens: 90000, CacheCreationTokens: 100000, CacheReadTokens: 20000, OutputTokens: 1000})
|
||||
require.True(t, above.LongContextBillingApplied)
|
||||
require.InDelta(t, 90000*1.25e-6*2, above.InputCost, 1e-9)
|
||||
require.InDelta(t, 100000*1.25e-6*2, above.CacheCreationCost, 1e-9)
|
||||
require.InDelta(t, 20000*1.25e-7*2, above.CacheReadCost, 1e-9)
|
||||
require.InDelta(t, 1000*1e-5*1.5, above.OutputCost, 1e-9)
|
||||
|
||||
// 输入侧合计 50K + 100K + 40K = 190K ≤ 200K:按基础价计
|
||||
below := calc(UsageTokens{InputTokens: 50000, CacheCreationTokens: 100000, CacheReadTokens: 40000, OutputTokens: 1000})
|
||||
require.False(t, below.LongContextBillingApplied)
|
||||
require.InDelta(t, 50000*1.25e-6, below.InputCost, 1e-9)
|
||||
require.InDelta(t, 100000*1.25e-6, below.CacheCreationCost, 1e-9)
|
||||
require.InDelta(t, 40000*1.25e-7, below.CacheReadCost, 1e-9)
|
||||
}
|
||||
|
||||
// 目录条目没有阶梯字段时,开关开启也不产生阶梯。
|
||||
func TestCalculateTokenCostForRequest_NoLadderFieldsMeansNoLadder(t *testing.T) {
|
||||
bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiCatalogStub())
|
||||
group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true}
|
||||
gid := group.ID
|
||||
resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group})
|
||||
|
||||
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
|
||||
got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
|
||||
Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1,
|
||||
Resolver: resolver, Resolved: resolved,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, 0.385, got.ActualCost, 1e-9)
|
||||
require.False(t, got.LongContextBillingApplied)
|
||||
}
|
||||
|
||||
func TestCalculateTokenCostForRequest_BuiltInPricingUsesUnifiedPath(t *testing.T) {
|
||||
bs, resolver := newTokenCostTestEnv(t, PlatformOpenAI, nil, nil)
|
||||
bs, resolver := newTokenCostTestEnv(t, PlatformOpenAI, nil, newStubPricingServiceFromJSON(t, openAILadderCatalogJSON))
|
||||
group := &Group{ID: 100, Platform: PlatformOpenAI, LongContextPricingEnabled: true}
|
||||
gid := group.ID
|
||||
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
|
||||
|
||||
@@ -87,24 +87,25 @@ type AccountStatsPricingRule struct {
|
||||
|
||||
// ChannelModelPricing 渠道模型定价条目
|
||||
type ChannelModelPricing struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
ChannelID int64 `json:"channel_id,omitempty"`
|
||||
Platform string `json:"platform"` // 所属平台(anthropic/openai/gemini/...)
|
||||
Models []string `json:"models"`
|
||||
BillingMode BillingMode `json:"billing_mode"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
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"`
|
||||
Intervals []PricingInterval `json:"intervals"`
|
||||
TimePricing *ChannelTimePricing `json:"time_pricing,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
ID int64 `json:"id,omitempty"`
|
||||
ChannelID int64 `json:"channel_id,omitempty"`
|
||||
Platform string `json:"platform"` // 所属平台(anthropic/openai/gemini/...)
|
||||
Models []string `json:"models"`
|
||||
BillingMode BillingMode `json:"billing_mode"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_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"`
|
||||
Intervals []PricingInterval `json:"intervals"`
|
||||
TimePricing *ChannelTimePricing `json:"time_pricing,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// ChannelTimePricing 渠道模型定价的分时倍率配置。
|
||||
@@ -131,6 +132,7 @@ type PricingInterval struct {
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
InputMultiplier *float64 `json:"input_multiplier"`
|
||||
OutputMultiplier *float64 `json:"output_multiplier"`
|
||||
@@ -377,6 +379,7 @@ func validateIntervalPrices(iv *PricingInterval, idx int) error {
|
||||
{"input_price", iv.InputPrice},
|
||||
{"output_price", iv.OutputPrice},
|
||||
{"cache_write_price", iv.CacheWritePrice},
|
||||
{"cache_write_1h_price", iv.CacheWrite1hPrice},
|
||||
{"cache_read_price", iv.CacheReadPrice},
|
||||
{"per_request_price", iv.PerRequestPrice},
|
||||
}
|
||||
|
||||
@@ -141,13 +141,13 @@ func pricingNeedsFallback(p *ChannelModelPricing) bool {
|
||||
return true
|
||||
}
|
||||
if p.InputPrice != nil || p.OutputPrice != nil ||
|
||||
p.CacheWritePrice != nil || p.CacheReadPrice != nil ||
|
||||
p.CacheWritePrice != nil || p.CacheWrite1hPrice != nil || p.CacheReadPrice != nil ||
|
||||
p.ImageOutputPrice != nil || p.PerRequestPrice != nil {
|
||||
return false
|
||||
}
|
||||
for _, iv := range p.Intervals {
|
||||
if iv.InputPrice != nil || iv.OutputPrice != nil ||
|
||||
iv.CacheWritePrice != nil || iv.CacheReadPrice != nil ||
|
||||
iv.CacheWritePrice != nil || iv.CacheWrite1hPrice != nil || iv.CacheReadPrice != nil ||
|
||||
iv.PerRequestPrice != nil {
|
||||
return false
|
||||
}
|
||||
@@ -188,12 +188,13 @@ func synthesizePricingFromLiteLLM(lp *LiteLLMModelPricing, existing *ChannelMode
|
||||
}
|
||||
}
|
||||
return &ChannelModelPricing{
|
||||
BillingMode: mode,
|
||||
InputPrice: nonZeroPtr(lp.InputCostPerToken),
|
||||
OutputPrice: nonZeroPtr(lp.OutputCostPerToken),
|
||||
CacheWritePrice: nonZeroPtr(lp.CacheCreationInputTokenCost),
|
||||
CacheReadPrice: nonZeroPtr(lp.CacheReadInputTokenCost),
|
||||
ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken),
|
||||
BillingMode: mode,
|
||||
InputPrice: nonZeroPtr(lp.InputCostPerToken),
|
||||
OutputPrice: nonZeroPtr(lp.OutputCostPerToken),
|
||||
CacheWritePrice: nonZeroPtr(lp.CacheCreationInputTokenCost),
|
||||
CacheWrite1hPrice: nonZeroPtr(lp.CacheCreationInputTokenCostAbove1hr),
|
||||
CacheReadPrice: nonZeroPtr(lp.CacheReadInputTokenCost),
|
||||
ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -725,6 +725,7 @@ func checkPricesNotNegative(p ChannelModelPricing) error {
|
||||
{"input_price", p.InputPrice},
|
||||
{"output_price", p.OutputPrice},
|
||||
{"cache_write_price", p.CacheWritePrice},
|
||||
{"cache_write_1h_price", p.CacheWrite1hPrice},
|
||||
{"cache_read_price", p.CacheReadPrice},
|
||||
{"image_input_price", p.ImageInputPrice},
|
||||
{"image_output_price", p.ImageOutputPrice},
|
||||
@@ -752,7 +753,7 @@ func checkPricesNotNegative(p ChannelModelPricing) error {
|
||||
func checkIntervalsHavePrices(p ChannelModelPricing) error {
|
||||
for _, iv := range p.Intervals {
|
||||
if iv.InputPrice == nil && iv.OutputPrice == nil &&
|
||||
iv.CacheWritePrice == nil && iv.CacheReadPrice == nil &&
|
||||
iv.CacheWritePrice == nil && iv.CacheWrite1hPrice == nil && iv.CacheReadPrice == nil &&
|
||||
iv.PerRequestPrice == nil && iv.InputMultiplier == nil &&
|
||||
iv.OutputMultiplier == nil && iv.CacheWriteMultiplier == nil &&
|
||||
iv.CacheReadMultiplier == nil {
|
||||
|
||||
@@ -469,7 +469,7 @@ func TestBuildUpstreamModelsRequest_CNProviders(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestGetAPIProtocol 验证协议凭证维度的平台校验矩阵:
|
||||
// responses 仅 deepseek;缺失/非法值回退 chat_completions(与旧行为一致)。
|
||||
// responses 仅 deepseek / kimi;缺失/非法值回退 chat_completions(与旧行为一致)。
|
||||
func TestGetAPIProtocol(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -486,15 +486,24 @@ 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, APIProtocolResponses, mk(PlatformKimi, 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 TestSupportsNativeCNResponses(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.True(t, (&Account{Platform: PlatformDeepseek}).SupportsNativeCNResponses())
|
||||
require.True(t, (&Account{Platform: PlatformKimi}).SupportsNativeCNResponses())
|
||||
require.True(t, (&Account{Platform: PlatformKimi, Credentials: map[string]any{"account_mode": AccountModeCoding}}).SupportsNativeCNResponses())
|
||||
require.False(t, (&Account{Platform: PlatformZhipu}).SupportsNativeCNResponses())
|
||||
require.False(t, (&Account{Platform: PlatformOpenAI}).SupportsNativeCNResponses())
|
||||
}
|
||||
|
||||
func TestAdaptiveProtocolBaseURLs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -639,10 +648,12 @@ func TestBuildOpenAIResponsesURLForPlatform(t *testing.T) {
|
||||
require.Equal(t, "https://relay.example.com/v1/responses", buildOpenAIResponsesURLForPlatform(PlatformDeepseek, "https://relay.example.com/v1"))
|
||||
require.Equal(t, "https://api.openai.com/v1/responses", buildOpenAIResponsesURLForPlatform(PlatformOpenAI, "https://api.openai.com"))
|
||||
require.Equal(t, "https://open.bigmodel.cn/api/paas/v4/responses", buildOpenAIResponsesURLForPlatform(PlatformZhipu, "https://open.bigmodel.cn/api/paas/v4"))
|
||||
require.Equal(t, "https://api.moonshot.cn/v1/responses", buildOpenAIResponsesURLForPlatform(PlatformKimi, "https://api.moonshot.cn/v1"))
|
||||
require.Equal(t, "https://api.kimi.com/coding/v1/responses", buildOpenAIResponsesURLForPlatform(PlatformKimi, "https://api.kimi.com/coding/v1"))
|
||||
}
|
||||
|
||||
// TestNormalizeDeepSeekResponsesRequestBody 无状态适配:强制 store=false、
|
||||
// 清除 previous_response_id;非 deepseek responses 协议原样返回。
|
||||
// 清除 previous_response_id;非原生 CN Responses 协议原样返回。
|
||||
func TestNormalizeDeepSeekResponsesRequestBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -668,6 +679,22 @@ func TestNormalizeDeepSeekResponsesRequestBody(t *testing.T) {
|
||||
deepseekCC := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey}
|
||||
require.Equal(t, string(body), string(normalizeDeepSeekResponsesRequestBody(deepseekCC, body)))
|
||||
|
||||
kimiResponses := &Account{
|
||||
Platform: PlatformKimi, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_protocol": APIProtocolResponses},
|
||||
}
|
||||
kimiNormalized := normalizeDeepSeekResponsesRequestBody(kimiResponses, body)
|
||||
require.False(t, gjson.GetBytes(kimiNormalized, "store").Bool())
|
||||
require.False(t, gjson.GetBytes(kimiNormalized, "previous_response_id").Exists())
|
||||
|
||||
kimiCodingAdaptive := &Account{
|
||||
Platform: PlatformKimi, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_protocol": APIProtocolAdaptive, "account_mode": AccountModeCoding},
|
||||
}
|
||||
kimiCodingNormalized := normalizeDeepSeekResponsesRequestBody(kimiCodingAdaptive, body)
|
||||
require.False(t, gjson.GetBytes(kimiCodingNormalized, "store").Bool())
|
||||
require.False(t, gjson.GetBytes(kimiCodingNormalized, "previous_response_id").Exists())
|
||||
|
||||
// openai 账号原样返回
|
||||
openai := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
require.Equal(t, string(body), string(normalizeDeepSeekResponsesRequestBody(openai, body)))
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// 背景
|
||||
// ============================================================================
|
||||
//
|
||||
// Anthropic 上游对 body.fallbacks / body.fallback_credit_token 字段实施
|
||||
// Pydantic extra='forbid' 校验:当且仅当 anthropic-beta header 含
|
||||
// server-side-fallback-2026-07-01(fallback_credit_token 额外接受
|
||||
// fallback-credit-2026-07-01 / fallback-credit-2026-06-01)时接受。
|
||||
// 否则报:
|
||||
// "fallbacks: Extra inputs are not permitted"
|
||||
//
|
||||
// fallbacks 是 beta Messages API 的 server-side refusal fallback 字段;本仓
|
||||
// 不写入该字段,全部来自客户端(Claude Code / SDK / OpenCode 等)透传。
|
||||
// OAuth mimic 用 FullClaudeCodeMimicryBetas 覆盖客户端 beta(不含 fallback
|
||||
// beta),因此必须在出口按最终 beta header 条件 strip,与 context_management
|
||||
// 的对称约束同构。策略是"剥字段,不注入 beta":fallback 会换模型、改计费,
|
||||
// 不允许当默认打开。
|
||||
//
|
||||
// 本文件覆盖:
|
||||
// 1) sanitizeAnthropicBodyForBetaTokens 对 fallbacks / fallback_credit_token
|
||||
// 的条件 strip(以及与 context_management 的组合行为)
|
||||
// 2) buildUpstreamRequest OAuth mimic / API-key passthrough 端到端
|
||||
// 3) Bedrock 路径的对称 strip(PrepareBedrockRequestBodyWithTokens /
|
||||
// sanitizeBedrockCCFields)
|
||||
|
||||
// ============================================================================
|
||||
// sanitizeAnthropicBodyForBetaTokens — fallbacks / fallback_credit_token
|
||||
// ============================================================================
|
||||
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_NoFallbackFieldsNoChange(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-haiku-4-5","messages":[]}`)
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens(body, "oauth-2025-04-20")
|
||||
require.False(t, changed)
|
||||
require.Equal(t, string(body), string(out))
|
||||
}
|
||||
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_FallbacksKeptWhenBetaPresent(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-opus-4-7","fallbacks":"default","messages":[]}`)
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens(body,
|
||||
"claude-code-20250219,oauth-2025-04-20,server-side-fallback-2026-07-01")
|
||||
require.False(t, changed, "客户端 header 已带 server-side-fallback beta → 字段保留(不过度删除)")
|
||||
require.True(t, gjson.GetBytes(out, "fallbacks").Exists())
|
||||
require.Equal(t, "default", gjson.GetBytes(out, "fallbacks").String())
|
||||
}
|
||||
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_FallbacksStrippedWhenBetaMissing(t *testing.T) {
|
||||
// 客户端透传的两种形态:字符串 "default" 与模型数组
|
||||
for name, fallbacks := range map[string]string{
|
||||
"string_default": `"fallbacks":"default"`,
|
||||
"model_array": `"fallbacks":["claude-opus-4-6","claude-sonnet-4-6"]`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-haiku-4-5",` + fallbacks + `,"messages":[]}`)
|
||||
// 模拟 OAuth mimic / 默认 API-key beta:只有 oauth/interleaved,无 fallback beta
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens(body,
|
||||
"oauth-2025-04-20,interleaved-thinking-2025-05-14")
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(out, "fallbacks").Exists(),
|
||||
"header 不含 server-side-fallback beta 时必须 strip fallbacks,否则上游 400")
|
||||
require.True(t, gjson.GetBytes(out, "messages").Exists(), "strip 不得误伤其他字段")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_FallbacksStrippedWhenHeaderEmpty(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-haiku-4-5","fallbacks":"default","messages":[]}`)
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens(body, "")
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(out, "fallbacks").Exists())
|
||||
}
|
||||
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_FallbackCreditTokenStrippedWhenCreditBetaMissing(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-haiku-4-5","fallback_credit_token":"tok_123","messages":[]}`)
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens(body, "oauth-2025-04-20,interleaved-thinking-2025-05-14")
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(out, "fallback_credit_token").Exists(),
|
||||
"缺 credit/fallback beta 时必须 strip fallback_credit_token")
|
||||
}
|
||||
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_FallbackCreditTokenKeptWithAnyAcceptedBeta(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-haiku-4-5","fallback_credit_token":"tok_123","messages":[]}`)
|
||||
// 三个 beta token 任意一个在 header 中都必须保留字段
|
||||
for _, beta := range []string{
|
||||
claude.BetaServerSideFallback,
|
||||
claude.BetaFallbackCredit,
|
||||
claude.BetaFallbackCreditLegacy,
|
||||
} {
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens(body, "oauth-2025-04-20,"+beta)
|
||||
require.Falsef(t, changed, "header 含 %s 时 fallback_credit_token 必须保留", beta)
|
||||
require.Truef(t, gjson.GetBytes(out, "fallback_credit_token").Exists(),
|
||||
"header 含 %s 时 fallback_credit_token 必须保留", beta)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 组合场景:只带 context-management beta → 剥 fallbacks,保留 context_management
|
||||
// (守住"早退导致 fallbacks 漏洗"与"过度删除 context_management"两个方向的回归)
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_StripsFallbacksKeepsContextManagement(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-haiku-4-5","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"fallbacks":"default","messages":[]}`)
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens(body, "context-management-2025-06-27")
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(out, "fallbacks").Exists(),
|
||||
"header 只有 context-management beta → fallbacks 必须 strip")
|
||||
require.True(t, gjson.GetBytes(out, "context_management").Exists(),
|
||||
"context-management beta 在 header 中 → context_management 不得被误删")
|
||||
}
|
||||
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_KeepsBothWhenBothBetasPresent(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-opus-4-7","context_management":{"edits":[{"type":"clear_thinking_20251015"}]},"fallbacks":"default","fallback_credit_token":"tok_123","messages":[]}`)
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens(body,
|
||||
"context-management-2025-06-27,server-side-fallback-2026-07-01")
|
||||
require.False(t, changed, "两个 beta 都在 header 中 → 所有字段保留")
|
||||
require.True(t, gjson.GetBytes(out, "context_management").Exists())
|
||||
require.True(t, gjson.GetBytes(out, "fallbacks").Exists())
|
||||
require.True(t, gjson.GetBytes(out, "fallback_credit_token").Exists())
|
||||
}
|
||||
|
||||
func TestSanitizeAnthropicBodyForBetaTokens_EmptyBodyUnchanged(t *testing.T) {
|
||||
out, changed := sanitizeAnthropicBodyForBetaTokens([]byte{}, "server-side-fallback-2026-07-01")
|
||||
require.False(t, changed)
|
||||
require.Empty(t, out)
|
||||
|
||||
out, changed = sanitizeAnthropicBodyForBetaTokens(nil, "server-side-fallback-2026-07-01")
|
||||
require.False(t, changed)
|
||||
require.Empty(t, out)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// buildUpstreamRequest 端到端
|
||||
// 挡住未来某人忘调 sanitize / 将 sanitize 挪到 CCH 之后 等 regression。
|
||||
// ============================================================================
|
||||
|
||||
// OAuth mimic:FullClaudeCodeMimicryBetas 不含 fallback beta → body.fallbacks
|
||||
// 必须被 strip,且 outgoing anthropic-beta 不得注入 server-side-fallback beta
|
||||
// (剥字段,不注入 beta)。
|
||||
func TestBuildUpstreamRequest_OAuthMimicHaiku_StripsFallbacksEndToEnd(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
account := &Account{ID: 601, Platform: PlatformAnthropic, Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{"access_token": "oauth-tok"},
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
}
|
||||
// 客户端默认透传 "fallbacks":"default"(Claude Code / SDK / OpenCode 等)
|
||||
body := []byte(`{"model":"claude-haiku-4-5","fallbacks":"default","messages":[]}`)
|
||||
svc := &GatewayService{cfg: &config.Config{}}
|
||||
req, _, err := svc.buildUpstreamRequest(
|
||||
context.Background(), c, account, body,
|
||||
"oauth-tok", "oauth", "claude-haiku-4-5", false, true, // mimicClaudeCode=true
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
outBody := readUpstreamBodyForTest(t, req)
|
||||
outBeta := getHeaderRaw(req.Header, "anthropic-beta")
|
||||
|
||||
require.False(t, gjson.GetBytes(outBody, "fallbacks").Exists(),
|
||||
"OAuth mimic 端到端:mimic beta 集合不含 fallback beta → outgoing body 必须没有 fallbacks,"+
|
||||
"否则上游报 fallbacks: Extra inputs are not permitted")
|
||||
require.False(t, anthropicBetaTokensContains(outBeta, claude.BetaServerSideFallback),
|
||||
"修复策略是剥字段而非注入 beta:outgoing anthropic-beta 不得含 server-side-fallback beta")
|
||||
require.True(t, anthropicBetaTokensContains(outBeta, claude.BetaContextManagement),
|
||||
"mimic beta 集合本身不受影响")
|
||||
}
|
||||
|
||||
// API-key passthrough + 客户端 header 未带 fallback beta → strip
|
||||
func TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_StripsFallbacksWhenClientHeaderMissingBeta(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
// 客户端仅带 oauth beta,不带 server-side-fallback-2026-07-01
|
||||
c.Request.Header.Set("Anthropic-Beta", "oauth-2025-04-20")
|
||||
|
||||
body := []byte(`{"model":"claude-haiku-4-5","fallbacks":"default","messages":[]}`)
|
||||
svc := &GatewayService{cfg: &config.Config{}}
|
||||
req, _, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(
|
||||
context.Background(), c, newAnthropicAPIKeyPassthroughAccountForBetaTest(), body, "token",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.False(t, gjson.GetBytes(readUpstreamBodyForTest(t, req), "fallbacks").Exists(),
|
||||
"API-key passthrough + 客户端未带 fallback beta → strip body 字段")
|
||||
}
|
||||
|
||||
// API-key passthrough + 客户端 header 带 fallback beta → 保留(不过度删除)
|
||||
func TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_PreservesFallbacksWhenClientHeaderHasBeta(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
c.Request.Header.Set("Anthropic-Beta", "oauth-2025-04-20,server-side-fallback-2026-07-01")
|
||||
|
||||
// 模型数组形态:有 beta 时必须原样保留
|
||||
body := []byte(`{"model":"claude-opus-4-7","fallbacks":["claude-opus-4-6","claude-sonnet-4-6"],"messages":[]}`)
|
||||
svc := &GatewayService{cfg: &config.Config{}}
|
||||
req, _, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(
|
||||
context.Background(), c, newAnthropicAPIKeyPassthroughAccountForBetaTest(), body, "token",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
outBody := readUpstreamBodyForTest(t, req)
|
||||
require.True(t, gjson.GetBytes(outBody, "fallbacks").Exists(),
|
||||
"客户端 header 带 server-side-fallback beta → 字段保留(不过度删除)")
|
||||
fallbacks := gjson.GetBytes(outBody, "fallbacks").Array()
|
||||
require.Len(t, fallbacks, 2)
|
||||
require.Equal(t, "claude-opus-4-6", fallbacks[0].String())
|
||||
require.Equal(t, "claude-sonnet-4-6", fallbacks[1].String())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Bedrock 对称 strip
|
||||
// ============================================================================
|
||||
|
||||
// fallback beta token 不在 bedrockSupportedBetaTokens 白名单内(会被
|
||||
// filterBedrockBetaTokens 过滤),因此条件 strip 实际总会剥除——这是预期。
|
||||
func TestPrepareBedrockRequestBodyWithTokens_FallbacksRequireSupportedBeta(t *testing.T) {
|
||||
modelID := "us.anthropic.claude-opus-4-6-v1"
|
||||
|
||||
t.Run("strips fallbacks when final tokens omit server-side-fallback beta", func(t *testing.T) {
|
||||
input := `{
|
||||
"messages":[{"role":"user","content":"hi"}],
|
||||
"max_tokens":100,
|
||||
"fallbacks":"default",
|
||||
"fallback_credit_token":"tok_123"
|
||||
}`
|
||||
betaTokens := []string{"context-1m-2025-08-07"}
|
||||
|
||||
result, err := PrepareBedrockRequestBodyWithTokens([]byte(input), modelID, betaTokens, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, gjson.GetBytes(result, "fallbacks").Exists())
|
||||
assert.False(t, gjson.GetBytes(result, "fallback_credit_token").Exists())
|
||||
assert.Equal(t, "hi", gjson.GetBytes(result, "messages.0.content").String())
|
||||
assert.Equal(t, int64(100), gjson.GetBytes(result, "max_tokens").Int())
|
||||
})
|
||||
|
||||
t.Run("strips fallbacks even when client passes server-side-fallback token (not whitelisted)", func(t *testing.T) {
|
||||
input := `{"messages":[{"role":"user","content":"hi"}],"max_tokens":100,"fallbacks":"default"}`
|
||||
|
||||
result, err := PrepareBedrockRequestBodyWithTokens(
|
||||
[]byte(input), modelID, []string{claude.BetaServerSideFallback}, false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, gjson.GetBytes(result, "fallbacks").Exists(),
|
||||
"Bedrock 白名单不含 fallback beta → 条件 strip 总会剥除(预期)")
|
||||
for _, name := range bedrockAnthropicBetaNames(result) {
|
||||
assert.NotEqual(t, claude.BetaServerSideFallback, name,
|
||||
"fallback beta 不得进入 Bedrock anthropic_beta 白名单输出")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("leaves body without fallback fields otherwise intact", func(t *testing.T) {
|
||||
input := `{"messages":[{"role":"user","content":"hi"}],"max_tokens":100}`
|
||||
|
||||
result, err := PrepareBedrockRequestBodyWithTokens([]byte(input), modelID, nil, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, gjson.GetBytes(result, "fallbacks").Exists())
|
||||
assert.False(t, gjson.GetBytes(result, "fallback_credit_token").Exists())
|
||||
assert.False(t, gjson.GetBytes(result, "context_management").Exists())
|
||||
assert.Equal(t, "hi", gjson.GetBytes(result, "messages.0.content").String())
|
||||
})
|
||||
}
|
||||
|
||||
// sanitizeBedrockCCFields:fallbacks / fallback_credit_token 是 Anthropic 直连
|
||||
// beta API 专有字段,Bedrock 无对应 beta → 无条件剥除。
|
||||
func TestSanitizeBedrockCCFields_StripsFallbacksUnconditionally(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-opus-4-6","context_management":{"edits":[]},"fallbacks":"default","fallback_credit_token":"tok_123","messages":[]}`)
|
||||
result := sanitizeBedrockCCFields(body)
|
||||
|
||||
assert.False(t, gjson.GetBytes(result, "fallbacks").Exists())
|
||||
assert.False(t, gjson.GetBytes(result, "fallback_credit_token").Exists())
|
||||
assert.False(t, gjson.GetBytes(result, "context_management").Exists())
|
||||
assert.True(t, gjson.GetBytes(result, "messages").Exists())
|
||||
}
|
||||
@@ -456,45 +456,6 @@ func TestGatewayServiceRecordUsage_UsageLogWriteErrorDoesNotSkipBilling(t *testi
|
||||
require.Equal(t, 1, quotaSvc.quotaCalls)
|
||||
}
|
||||
|
||||
func TestGatewayServiceRecordUsageWithLongContext_BillingUsesDetachedContext(t *testing.T) {
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: false, err: context.DeadlineExceeded}
|
||||
userRepo := &openAIRecordUsageUserRepoStub{}
|
||||
subRepo := &openAIRecordUsageSubRepoStub{}
|
||||
quotaSvc := &openAIRecordUsageAPIKeyQuotaStub{}
|
||||
svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, subRepo)
|
||||
|
||||
reqCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := svc.RecordUsageWithLongContext(reqCtx, &RecordUsageLongContextInput{
|
||||
Result: &ForwardResult{
|
||||
RequestID: "gateway_long_context_detached_ctx",
|
||||
Usage: ClaudeUsage{
|
||||
InputTokens: 12,
|
||||
OutputTokens: 8,
|
||||
},
|
||||
Model: "claude-sonnet-4",
|
||||
Duration: time.Second,
|
||||
},
|
||||
APIKey: &APIKey{
|
||||
ID: 502,
|
||||
Quota: 100,
|
||||
},
|
||||
User: &User{ID: 602},
|
||||
Account: &Account{ID: 702},
|
||||
LongContextThreshold: 200000,
|
||||
LongContextMultiplier: 2,
|
||||
APIKeyService: quotaSvc,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, usageRepo.calls)
|
||||
require.Equal(t, 1, userRepo.deductCalls)
|
||||
require.NoError(t, userRepo.lastCtxErr)
|
||||
require.Equal(t, 1, quotaSvc.quotaCalls)
|
||||
require.NoError(t, quotaSvc.lastQuotaCtxErr)
|
||||
}
|
||||
|
||||
func TestGatewayServiceRecordUsage_UsesFallbackRequestIDForUsageLog(t *testing.T) {
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{}
|
||||
userRepo := &openAIRecordUsageUserRepoStub{}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
@@ -898,9 +899,22 @@ const anthropicBetaContextManagementToken = "context-management-2025-06-27"
|
||||
// - 若两侧不一致上游 Pydantic schema 拒收:
|
||||
// "context_management: Extra inputs are not permitted"
|
||||
//
|
||||
// 本函数按最终发送的 anthropic-beta header 决定是否保留 body 中的
|
||||
// context_management 字段:缺 beta token → strip。这将限制完全建立在
|
||||
// "能力维度" 上,与 model 名 / token type / mimicry 子路径无关。
|
||||
// fallbacks 场景(与 context_management 同构):
|
||||
// - `fallbacks` / `fallback_credit_token` 是 beta Messages API 的
|
||||
// server-side refusal fallback 字段;标准 Messages schema 没有它们,
|
||||
// 客户端(Claude Code / SDK / OpenCode 等)最近开始默认透传
|
||||
// `"fallbacks":"default"`(或模型列表)
|
||||
// - 上游接受的前提是 anthropic-beta 含 `server-side-fallback-2026-07-01`
|
||||
// (fallback_credit_token 额外接受 credit beta,见下)
|
||||
// - 缺 token 时上游 Pydantic extra='forbid' 拒收:
|
||||
// "fallbacks: Extra inputs are not permitted"
|
||||
// - 本仓不写入该字段,全部来自客户端透传;OAuth mimic 用
|
||||
// FullClaudeCodeMimicryBetas 覆盖客户端 beta(该列表不含 fallback beta),
|
||||
// 若不 strip,body 字段与 header 不对称 → 所有模型 400
|
||||
//
|
||||
// 本函数按最终发送的 anthropic-beta header 决定是否保留 body 中的上述字段:
|
||||
// 缺对应 beta token → strip;客户端 header 已带对应 beta → 保留(不过度删除)。
|
||||
// 这将限制完全建立在 "能力维度" 上,与 model 名 / token type / mimicry 子路径无关。
|
||||
//
|
||||
// 调用约束:必须在 CCH 签名之前调用,否则签名 hash 与最终 body
|
||||
// 不一致,上游会以 third-party 拒收。
|
||||
@@ -911,23 +925,58 @@ func sanitizeAnthropicBodyForBetaTokens(body []byte, anthropicBetaHeader string)
|
||||
if len(body) == 0 {
|
||||
return body, false
|
||||
}
|
||||
if !gjson.GetBytes(body, "context_management").Exists() {
|
||||
|
||||
changed := false
|
||||
|
||||
// context_management:需要 context-management beta。
|
||||
if b, deleted := stripAnthropicBodyFieldUnlessBeta(
|
||||
body, "context_management", anthropicBetaHeader, anthropicBetaContextManagementToken,
|
||||
); deleted {
|
||||
body, changed = b, true
|
||||
}
|
||||
|
||||
// fallbacks:server-side refusal fallback,仅接受 server-side-fallback beta。
|
||||
if b, deleted := stripAnthropicBodyFieldUnlessBeta(
|
||||
body, "fallbacks", anthropicBetaHeader, claude.BetaServerSideFallback,
|
||||
); deleted {
|
||||
body, changed = b, true
|
||||
}
|
||||
|
||||
// fallback_credit_token:server-side-fallback 或(新旧任一)fallback-credit beta
|
||||
// 任意一个即可保留。
|
||||
if b, deleted := stripAnthropicBodyFieldUnlessBeta(
|
||||
body, "fallback_credit_token", anthropicBetaHeader,
|
||||
claude.BetaServerSideFallback, claude.BetaFallbackCredit, claude.BetaFallbackCreditLegacy,
|
||||
); deleted {
|
||||
body, changed = b, true
|
||||
}
|
||||
|
||||
return body, changed
|
||||
}
|
||||
|
||||
// stripAnthropicBodyFieldUnlessBeta 当 field 存在且 anthropic-beta header 不含
|
||||
// requiredTokens 中**任何一个** token 时删除该字段(保留条件:含任一 required token)。
|
||||
// 单 token 调用即「缺该 beta 则 strip」。返回 (newBody, deleted)。
|
||||
func stripAnthropicBodyFieldUnlessBeta(body []byte, field, anthropicBetaHeader string, requiredTokens ...string) ([]byte, bool) {
|
||||
if !gjson.GetBytes(body, field).Exists() {
|
||||
return body, false
|
||||
}
|
||||
if anthropicBetaTokensContains(anthropicBetaHeader, anthropicBetaContextManagementToken) {
|
||||
return body, false
|
||||
for _, token := range requiredTokens {
|
||||
if anthropicBetaTokensContains(anthropicBetaHeader, token) {
|
||||
return body, false
|
||||
}
|
||||
}
|
||||
if b, err := sjson.DeleteBytes(body, "context_management"); err == nil {
|
||||
return b, true
|
||||
} else {
|
||||
b, err := sjson.DeleteBytes(body, field)
|
||||
if err != nil {
|
||||
// 不应发生:gjson 刚验证过字段存在 + body 是合法 JSON。如果 sjson 仍报错,
|
||||
// 调用方会拿到 (body, false),但此前 computeFinalAnthropicBeta 已按“strip 后”
|
||||
// 计算了 finalBeta——两侧会不一致。记录 warning 最小限度提醒运维。
|
||||
// 调用方会拿到原 body(视为未删除),但此前 computeFinalAnthropicBeta 可能已按
|
||||
// "strip 后" 计算了 finalBeta——两侧会不一致。记录 warning 最小限度提醒运维。
|
||||
logger.LegacyPrintf("service.gateway",
|
||||
"[CtxMgmtSanitize] sjson.DeleteBytes failed unexpectedly: %v (body len=%d). "+
|
||||
"body and final anthropic-beta header may be out of sync.", err, len(body))
|
||||
"[BetaFieldSanitize] sjson.DeleteBytes(%s) failed unexpectedly: %v (body len=%d). "+
|
||||
"body and final anthropic-beta header may be out of sync.", field, err, len(body))
|
||||
return body, false
|
||||
}
|
||||
return body, false
|
||||
return b, true
|
||||
}
|
||||
|
||||
// anthropicBetaTokensContains 检测逗号分隔的 anthropic-beta header 是否含指定 token。
|
||||
|
||||
@@ -596,13 +596,6 @@ func writeUsageLogBestEffort(ctx context.Context, repo UsageLogRepository, usage
|
||||
}
|
||||
}
|
||||
|
||||
// recordUsageOpts 内部选项,参数化普通计费与长上下文计费的差异点。
|
||||
type recordUsageOpts struct {
|
||||
// 长上下文计费(仅 Gemini 路径需要)
|
||||
LongContextThreshold int
|
||||
LongContextMultiplier float64
|
||||
}
|
||||
|
||||
// RecordUsage 记录使用量并扣费(或更新订阅用量)
|
||||
func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInput) error {
|
||||
return s.recordUsageCore(ctx, &recordUsageCoreInput{
|
||||
@@ -622,54 +615,6 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
|
||||
APIKeyService: input.APIKeyService,
|
||||
QuotaPlatform: input.QuotaPlatform,
|
||||
ChannelUsageFields: input.ChannelUsageFields,
|
||||
}, &recordUsageOpts{})
|
||||
}
|
||||
|
||||
// RecordUsageLongContextInput 记录使用量的输入参数(支持长上下文双倍计费)
|
||||
type RecordUsageLongContextInput struct {
|
||||
Result *ForwardResult
|
||||
APIKey *APIKey
|
||||
User *User
|
||||
Account *Account
|
||||
Subscription *UserSubscription // 可选:订阅信息
|
||||
PricingAt time.Time // token 售价固定时刻;零值保持既有的记录时刻语义
|
||||
InboundEndpoint string // 入站端点(客户端请求路径)
|
||||
UpstreamEndpoint string // 上游端点(标准化后的上游路径)
|
||||
UserAgent string // 请求的 User-Agent
|
||||
IPAddress string // 请求的客户端 IP 地址
|
||||
SessionID string // 客户端显式会话标识(session_id / X-Session-Id 等请求头),仅用于用量行会话关联
|
||||
RequestPayloadHash string // 请求体语义哈希,用于降低 request_id 误复用时的静默误去重风险
|
||||
LongContextThreshold int // 长上下文阈值(如 200000)
|
||||
LongContextMultiplier float64 // 超出阈值部分的倍率(如 2.0)
|
||||
ForceCacheBilling bool // 强制缓存计费:将 input_tokens 转为 cache_read 计费(用于粘性会话切换)
|
||||
APIKeyService APIKeyQuotaUpdater // API Key 配额服务(可选)
|
||||
QuotaPlatform string // user×platform 配额计量平台:handler 在请求 ctx 内经 QuotaPlatform() 算定后传入(后扣运行在 worker 池 background ctx 上,取不到 ForcePlatform)
|
||||
|
||||
ChannelUsageFields // 渠道映射信息(由 handler 在 Forward 前解析)
|
||||
}
|
||||
|
||||
// RecordUsageWithLongContext 记录使用量并扣费,支持长上下文双倍计费(用于 Gemini)
|
||||
func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *RecordUsageLongContextInput) error {
|
||||
return s.recordUsageCore(ctx, &recordUsageCoreInput{
|
||||
Result: input.Result,
|
||||
APIKey: input.APIKey,
|
||||
User: input.User,
|
||||
Account: input.Account,
|
||||
Subscription: input.Subscription,
|
||||
PricingAt: input.PricingAt,
|
||||
InboundEndpoint: input.InboundEndpoint,
|
||||
UpstreamEndpoint: input.UpstreamEndpoint,
|
||||
UserAgent: input.UserAgent,
|
||||
IPAddress: input.IPAddress,
|
||||
SessionID: input.SessionID,
|
||||
RequestPayloadHash: input.RequestPayloadHash,
|
||||
ForceCacheBilling: input.ForceCacheBilling,
|
||||
APIKeyService: input.APIKeyService,
|
||||
QuotaPlatform: input.QuotaPlatform,
|
||||
ChannelUsageFields: input.ChannelUsageFields,
|
||||
}, &recordUsageOpts{
|
||||
LongContextThreshold: input.LongContextThreshold,
|
||||
LongContextMultiplier: input.LongContextMultiplier,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -767,9 +712,8 @@ func logResponseModelBillingApplied(component string, account *Account, requestI
|
||||
slog.Info("billing.response_model_applied", attrs...)
|
||||
}
|
||||
|
||||
// recordUsageCore 是 RecordUsage 和 RecordUsageWithLongContext 的统一实现。
|
||||
// LongContextThreshold > 0 时 Token 计费回退走 CalculateCostWithLongContext。
|
||||
func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsageCoreInput, opts *recordUsageOpts) error {
|
||||
// recordUsageCore 是 RecordUsage 的核心实现。
|
||||
func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsageCoreInput) error {
|
||||
result := input.Result
|
||||
apiKey := input.APIKey
|
||||
user := input.User
|
||||
@@ -839,7 +783,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
|
||||
}
|
||||
|
||||
// 计算费用
|
||||
cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, imageMultiplier, pricingAt, opts)
|
||||
cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, imageMultiplier, pricingAt)
|
||||
// response_model:按上游成功响应自报的模型计费(渠道显式开启才生效)。
|
||||
// 采纳条件见 responseModelBillingDeclaration + hasIdentifiedResponseModelPricing
|
||||
// + responseModelBillingAdoptable。任一条件不满足都静默回落基线,即开启本模式前的
|
||||
@@ -851,7 +795,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
|
||||
result.ImageCount > 0 || result.AudioUsage != nil || result.SearchCount > 0,
|
||||
); responseModel != "" && !strings.EqualFold(responseModel, strings.TrimSpace(billingModel)) {
|
||||
if identified, responseChannelPriced := s.hasIdentifiedResponseModelPricing(ctx, responseModel, apiKey); identified {
|
||||
responseCost := s.calculateRecordUsageCost(ctx, result, apiKey, responseModel, multiplier, imageMultiplier, pricingAt, opts)
|
||||
responseCost := s.calculateRecordUsageCost(ctx, result, apiKey, responseModel, multiplier, imageMultiplier, pricingAt)
|
||||
baselineChannelPriced := s.resolveChannelPricing(ctx, billingModel, apiKey) != nil
|
||||
if responseModelBillingAdoptable(cost, responseCost, baselineChannelPriced, responseChannelPriced) {
|
||||
// billingModel 到此为止只是定价查表的入参,后续流程只消费 cost,
|
||||
@@ -872,7 +816,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
|
||||
// 创建使用日志
|
||||
accountRateMultiplier := account.BillingRateMultiplier()
|
||||
usageLog := s.buildRecordUsageLog(ctx, input, result, apiKey, user, account, subscription,
|
||||
requestedModel, multiplier, imageMultiplier, accountRateMultiplier, billingType, cacheTTLOverridden, cost, opts)
|
||||
requestedModel, multiplier, imageMultiplier, accountRateMultiplier, billingType, cacheTTLOverridden, cost)
|
||||
|
||||
// 计算账号统计定价费用(使用最终上游模型匹配自定义规则)
|
||||
if apiKey.GroupID != nil {
|
||||
@@ -932,7 +876,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
|
||||
return nil
|
||||
}
|
||||
|
||||
// calculateRecordUsageCost 根据请求类型和选项计算费用。
|
||||
// calculateRecordUsageCost 根据请求类型计算费用。
|
||||
func (s *GatewayService) calculateRecordUsageCost(
|
||||
ctx context.Context,
|
||||
result *ForwardResult,
|
||||
@@ -941,12 +885,11 @@ func (s *GatewayService) calculateRecordUsageCost(
|
||||
multiplier float64,
|
||||
imageMultiplier float64,
|
||||
pricingAt time.Time,
|
||||
opts *recordUsageOpts,
|
||||
) *CostBreakdown {
|
||||
// 图片生成:渠道定价为 token 计费时走 token 路径,否则走图片计费
|
||||
if result.ImageCount > 0 {
|
||||
if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil && resolved.Mode == BillingModeToken {
|
||||
return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt, opts)
|
||||
return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt)
|
||||
}
|
||||
return s.calculateImageCost(ctx, result, apiKey, billingModel, imageMultiplier)
|
||||
}
|
||||
@@ -970,7 +913,7 @@ func (s *GatewayService) calculateRecordUsageCost(
|
||||
}
|
||||
|
||||
// Token 计费;SearchCount 为叠加 surcharge(不替代 token)。
|
||||
tokenCost := s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt, opts)
|
||||
tokenCost := s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt)
|
||||
if result.SearchCount > 0 {
|
||||
price := groupSearchPricePer1kFromAPIKey(apiKey)
|
||||
if price != nil && *price == 0 {
|
||||
@@ -1122,7 +1065,7 @@ func (s *GatewayService) calculateImageCost(
|
||||
return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier)
|
||||
}
|
||||
|
||||
// calculateTokenCost 计算 Token 计费:路径选择(分组/渠道定价 → 旧长上下文规则 → 内置定价)
|
||||
// calculateTokenCost 计算 Token 计费:路径选择(分组/渠道定价 → 内置定价)
|
||||
// 统一交给 BillingService.CalculateTokenCostForRequest,与模型广场的阶梯表查询同源。
|
||||
func (s *GatewayService) calculateTokenCost(
|
||||
ctx context.Context,
|
||||
@@ -1131,7 +1074,6 @@ func (s *GatewayService) calculateTokenCost(
|
||||
billingModel string,
|
||||
multiplier float64,
|
||||
pricingAt time.Time,
|
||||
opts *recordUsageOpts,
|
||||
) *CostBreakdown {
|
||||
tokens := UsageTokens{
|
||||
InputTokens: result.Usage.InputTokens,
|
||||
@@ -1148,22 +1090,17 @@ func (s *GatewayService) calculateTokenCost(
|
||||
gid := apiKey.Group.ID
|
||||
resolved = s.resolver.Resolve(ctx, PricingInput{Model: billingModel, GroupID: &gid, Group: apiKey.Group})
|
||||
}
|
||||
var legacy *LegacyLongContextRule
|
||||
if opts.LongContextThreshold > 0 {
|
||||
legacy = &LegacyLongContextRule{Threshold: opts.LongContextThreshold, Multiplier: opts.LongContextMultiplier}
|
||||
}
|
||||
|
||||
cost, err := s.billingService.CalculateTokenCostForRequest(TokenCostRequest{
|
||||
Ctx: ctx,
|
||||
Model: billingModel,
|
||||
Group: apiKey.Group,
|
||||
Tokens: tokens,
|
||||
RateMultiplier: multiplier,
|
||||
PricingAt: pricingAt,
|
||||
ServiceTier: optionalStringValue(result.ServiceTier),
|
||||
Resolver: s.resolver,
|
||||
Resolved: resolved,
|
||||
LegacyLongContext: legacy,
|
||||
Ctx: ctx,
|
||||
Model: billingModel,
|
||||
Group: apiKey.Group,
|
||||
Tokens: tokens,
|
||||
RateMultiplier: multiplier,
|
||||
PricingAt: pricingAt,
|
||||
ServiceTier: optionalStringValue(result.ServiceTier),
|
||||
Resolver: s.resolver,
|
||||
Resolved: resolved,
|
||||
})
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.gateway", "Calculate cost failed: %v", err)
|
||||
@@ -1172,14 +1109,6 @@ func (s *GatewayService) calculateTokenCost(
|
||||
return cost
|
||||
}
|
||||
|
||||
// LegacyLongContextRule 透传 BillingService 的平台旧长上下文规则,供入口 handler 取用。
|
||||
func (s *GatewayService) LegacyLongContextRule(platform string) *LegacyLongContextRule {
|
||||
if s == nil || s.billingService == nil {
|
||||
return nil
|
||||
}
|
||||
return s.billingService.LegacyLongContextRule(platform)
|
||||
}
|
||||
|
||||
// buildRecordUsageLog 构建使用日志并设置计费模式。
|
||||
func (s *GatewayService) buildRecordUsageLog(
|
||||
ctx context.Context,
|
||||
@@ -1196,7 +1125,6 @@ func (s *GatewayService) buildRecordUsageLog(
|
||||
billingType int8,
|
||||
cacheTTLOverridden bool,
|
||||
cost *CostBreakdown,
|
||||
opts *recordUsageOpts,
|
||||
) *UsageLog {
|
||||
durationMs := int(result.Duration.Milliseconds())
|
||||
requestID := resolveUsageBillingRequestID(ctx, result.RequestID)
|
||||
|
||||
@@ -898,6 +898,11 @@ func TestExtractGeminiUsage(t *testing.T) {
|
||||
if got.CacheReadInputTokens != tt.wantUsage.CacheReadInputTokens {
|
||||
t.Errorf("CacheReadInputTokens: 期望 %d,实际 %d", tt.wantUsage.CacheReadInputTokens, got.CacheReadInputTokens)
|
||||
}
|
||||
// Gemini usageMetadata 只有 cachedContentTokenCount(缓存命中),没有缓存写入
|
||||
// 的 token 类别:cache_creation_input_tokens 恒为 0,计费侧不会产生缓存创建分项。
|
||||
if got.CacheCreationInputTokens != 0 {
|
||||
t.Errorf("CacheCreationInputTokens: 期望 0,实际 %d", got.CacheCreationInputTokens)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,8 @@ type Group struct {
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
AllowMessagesDispatch bool
|
||||
AllowLive bool
|
||||
ForceOpenAIFast bool // 强制 OpenAI 网关请求使用 service_tier=priority
|
||||
FreeOpenAIFast bool // OpenAI Fast 请求按 Standard 价格向用户计费
|
||||
RequireOAuthOnly bool // 仅允许非 apikey 类型账号关联(OpenAI/Antigravity/Anthropic/Gemini)
|
||||
RequirePrivacySet bool // 调度时仅允许 privacy 已成功设置的账号(OpenAI/Antigravity/Anthropic/Gemini)
|
||||
DefaultMappedModel string
|
||||
@@ -113,6 +115,9 @@ type Group struct {
|
||||
// MaxReasoningEffort limits the effective OpenAI/Codex reasoning effort.
|
||||
// Empty means unlimited; supported values are minimal/low/medium/high/xhigh/max.
|
||||
MaxReasoningEffort string
|
||||
// MaxReasoningEffortOverLimit is the access control when an explicit effort
|
||||
// exceeds the ceiling: downgrade (default) or deny.
|
||||
MaxReasoningEffortOverLimit string
|
||||
// ReasoningEffortMappings rewrites explicit request values before applying the ceiling.
|
||||
ReasoningEffortMappings []ReasoningEffortMapping
|
||||
|
||||
|
||||
@@ -362,7 +362,8 @@ func TestListGroups_TokenLadderFollowsGroupToggle(t *testing.T) {
|
||||
{ID: 10, Name: "on", Platform: PlatformOpenAI, RateMultiplier: 1, LongContextPricingEnabled: true},
|
||||
{ID: 20, Name: "off", Platform: PlatformOpenAI, RateMultiplier: 2, LongContextPricingEnabled: false},
|
||||
}
|
||||
svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformOpenAI, 20: PlatformOpenAI}, nil)
|
||||
svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformOpenAI, 20: PlatformOpenAI},
|
||||
newStubPricingServiceFromJSON(t, openAILadderCatalogJSON))
|
||||
out, err := svc.ListGroups(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 2)
|
||||
@@ -395,26 +396,27 @@ func TestListGroups_TokenLadderFollowsGroupToggle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListGroups_GeminiLegacyRuleShownAsMarginal(t *testing.T) {
|
||||
func TestListGroups_GeminiCatalogLadderShownWholeRequest(t *testing.T) {
|
||||
channels := []Channel{{
|
||||
ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10},
|
||||
ModelMapping: map[string]map[string]string{PlatformGemini: {"gemini-2.5-pro": "gemini-2.5-pro"}},
|
||||
}}
|
||||
groups := []Group{{ID: 10, Name: "g", Platform: PlatformGemini, RateMultiplier: 1, LongContextPricingEnabled: true}}
|
||||
svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformGemini}, geminiCatalogStub())
|
||||
svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformGemini},
|
||||
newStubPricingServiceFromJSON(t, geminiLadderCatalogJSON))
|
||||
out, err := svc.ListGroups(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 1)
|
||||
m := out[0].Models[0]
|
||||
require.Equal(t, ContextPricingBasisMarginal, m.LongContextBasis)
|
||||
require.Equal(t, ContextPricingBasisWholeRequest, m.LongContextBasis)
|
||||
require.Len(t, m.Pricing.Intervals, 2)
|
||||
require.Equal(t, "≤200K", m.Pricing.Intervals[0].TierLabel)
|
||||
require.Equal(t, ">200K", m.Pricing.Intervals[1].TierLabel)
|
||||
require.InDelta(t, 2.5e-6, *m.Pricing.Intervals[1].InputPrice, 1e-15)
|
||||
require.InDelta(t, 10e-6, *m.Pricing.Intervals[1].OutputPrice, 1e-15)
|
||||
// 官方参考不套用站内旧规则
|
||||
require.InDelta(t, 15e-6, *m.Pricing.Intervals[1].OutputPrice, 1e-15)
|
||||
// 官方参考价与实付同源:都来自目录数据的阶梯字段
|
||||
require.NotNil(t, m.OfficialPricing)
|
||||
require.Empty(t, m.OfficialPricing.Intervals)
|
||||
require.Len(t, m.OfficialPricing.Intervals, 2)
|
||||
}
|
||||
|
||||
func TestListGroups_GroupTokenCardOverridesChannelPricing(t *testing.T) {
|
||||
|
||||
@@ -234,6 +234,17 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin
|
||||
}
|
||||
|
||||
applyChannelTokenPriceOverrides(resolved.BasePricing, chPricing)
|
||||
if chPricing.CacheWrite1hPrice != nil {
|
||||
resolved.SupportsCacheBreakdown = true
|
||||
resolved.BasePricing.SupportsCacheBreakdown = true
|
||||
}
|
||||
for i := range chPricing.Intervals {
|
||||
if chPricing.Intervals[i].CacheWrite1hPrice != nil {
|
||||
resolved.SupportsCacheBreakdown = true
|
||||
resolved.BasePricing.SupportsCacheBreakdown = true
|
||||
break
|
||||
}
|
||||
}
|
||||
resolved.BasePricing.FastMultiplier = chPricing.FastMultiplier
|
||||
resolved.BasePricing.FlexMultiplier = chPricing.FlexMultiplier
|
||||
// 渠道定价覆盖一切:显式配置则用配置值,未配置则归零(不回退到 LiteLLM)
|
||||
@@ -276,7 +287,7 @@ func filterValidIntervals(intervals []PricingInterval) []PricingInterval {
|
||||
var valid []PricingInterval
|
||||
for _, iv := range intervals {
|
||||
if iv.InputPrice != nil || iv.OutputPrice != nil ||
|
||||
iv.CacheWritePrice != nil || iv.CacheReadPrice != nil ||
|
||||
iv.CacheWritePrice != nil || iv.CacheWrite1hPrice != nil || iv.CacheReadPrice != nil ||
|
||||
iv.PerRequestPrice != nil || iv.InputMultiplier != nil ||
|
||||
iv.OutputMultiplier != nil || iv.CacheWriteMultiplier != nil ||
|
||||
iv.CacheReadMultiplier != nil {
|
||||
@@ -336,13 +347,19 @@ func intervalToModelPricing(iv *PricingInterval, base *ModelPricing, chPricing *
|
||||
pricing.CacheCreationPricePerToken = *iv.CacheWritePrice
|
||||
pricing.CacheCreationPriceExplicit = true
|
||||
pricing.CacheCreation5mPrice = *iv.CacheWritePrice
|
||||
pricing.CacheCreation1hPrice = *iv.CacheWritePrice
|
||||
if iv.CacheWrite1hPrice == nil {
|
||||
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.CacheWrite1hPrice != nil {
|
||||
pricing.CacheCreation1hPrice = *iv.CacheWrite1hPrice
|
||||
pricing.SupportsCacheBreakdown = true
|
||||
}
|
||||
if iv.CacheReadPrice != nil {
|
||||
pricing.CacheReadPricePerTokenPriority = channelTierOverridePrice(pricing.CacheReadPricePerToken, pricing.CacheReadPricePerTokenPriority, *iv.CacheReadPrice)
|
||||
pricing.CacheReadPricePerToken = *iv.CacheReadPrice
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestAccountTestServiceOpenAICompactAgentIdentityUsesFreshAssertion(t *testing.T) {
|
||||
@@ -441,6 +442,51 @@ func TestOpenAIAgentIdentityCompatRoutesRecoverInvalidTaskOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIAgentIdentityChatRecoveryKeepsAutoDerivedSessionIsolationStable(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
key, privateKey := newTestAgentIdentityKey(t)
|
||||
account := &Account{
|
||||
ID: 52, Name: "agent-identity", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
|
||||
Status: StatusActive, Schedulable: true, Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"auth_mode": OpenAIAuthModeAgentIdentity,
|
||||
"agent_runtime_id": key.runtimeID,
|
||||
"agent_private_key": privateKey,
|
||||
"task_id": "task-cache-old",
|
||||
},
|
||||
Extra: map[string]any{"openai_responses_supported": true},
|
||||
}
|
||||
repo := &agentIdentityForwardRepo{account: account}
|
||||
registerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"task_id":"task-cache-new"}`)
|
||||
}))
|
||||
defer registerServer.Close()
|
||||
oldBase := openAIAgentIdentityAuthAPIBaseURL
|
||||
openAIAgentIdentityAuthAPIBaseURL = registerServer.URL
|
||||
t.Cleanup(func() { openAIAgentIdentityAuthAPIBaseURL = oldBase })
|
||||
|
||||
invalidTask := func() *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusUnauthorized, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"error":{"code":"invalid_task_id"}}`))}
|
||||
}
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{invalidTask(), invalidTask()}}
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{}, accountRepo: repo, httpUpstream: upstream}
|
||||
body := []byte(`{"model":"gpt-5.4","stream":false,"messages":[{"role":"user","content":"hi"}]}`)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
|
||||
c.Set("api_key", &APIKey{ID: 99})
|
||||
|
||||
_, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.4")
|
||||
require.Error(t, err)
|
||||
require.Len(t, upstream.requests, 2)
|
||||
firstKey := gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").String()
|
||||
secondKey := gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").String()
|
||||
require.NotEmpty(t, firstKey)
|
||||
require.Equal(t, firstKey, secondKey)
|
||||
require.Equal(t, generateSessionUUID(isolateOpenAIUpstreamSessionID(99, codexAccountIdentitySource(c, account), firstKey)), upstream.requests[0].Header.Get("session_id"))
|
||||
require.Equal(t, upstream.requests[0].Header.Get("session_id"), upstream.requests[1].Header.Get("session_id"))
|
||||
}
|
||||
|
||||
func decodeAgentAssertionTask(t *testing.T, header string) string {
|
||||
t.Helper()
|
||||
encoded := strings.TrimPrefix(header, "AgentAssertion ")
|
||||
|
||||
@@ -123,12 +123,12 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte
|
||||
return
|
||||
}
|
||||
if account.IsCNProvider() {
|
||||
// 国产 OpenAI 兼容上游(kimi/zhipu/deepseek)普遍仅支持 /v1/chat/completions,
|
||||
// 不存在 /v1/responses 端点。直接落标 false 走 Chat Completions 直转,跳过网络探测。
|
||||
// 例外:deepseek 的固定 responses 和 adaptive 账号使用官方原生 /responses
|
||||
// 端点,落标 force_responses;其余协议显式重置为 auto,避免切换后残留强制模式。
|
||||
if account.GetAPIProtocol() == APIProtocolResponses ||
|
||||
(account.Platform == PlatformDeepseek && account.IsAdaptiveAPIProtocol()) {
|
||||
// 国产 OpenAI 兼容上游默认仅支持 /v1/chat/completions。直接落标 false
|
||||
// 走 Chat Completions 直转,跳过网络探测。
|
||||
// 例外:deepseek / kimi 的固定 responses 和 adaptive 账号使用官方原生
|
||||
// Responses 端点,落标 force_responses;其余协议显式重置为 auto,避免
|
||||
// 切换后残留强制模式。
|
||||
if account.UsesNativeCNResponses() {
|
||||
_ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{
|
||||
openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceResponses),
|
||||
openai_compat.ExtraKeyResponsesSupported: true,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user