diff --git a/README.en.md b/README.en.md
index 68a19b5..cdbd97e 100644
--- a/README.en.md
+++ b/README.en.md
@@ -732,7 +732,7 @@ Reference frontend embed code (React / Tailwind CSS; keep either item as needed)
By meeting either attribution requirement above and keeping the link clearly visible, you automatically receive a temporary commercial license for this project, with no separate application or approval required. This is not a perpetual license and remains valid only while the display requirement continues to be met. Its validity period and any later changes are governed by the latest notice published in this README or by the official community.
-This project is developed from [One API](https://github.com/songquanpeng/one-api) and [New API](https://github.com/QuantumNous/new-api). Commercial use must also comply with the One API MIT License and the New API AGPLv3 License, subject to each upstream project's current `LICENSE` file. The temporary commercial license provided by this project does not replace or waive any upstream open-source license obligations.
+This project is developed from [One API](https://github.com/songquanpeng/one-api) and [New API](https://github.com/QuantumNous/new-api). At this stage, MAX API builds on those foundations by strengthening its AI API gateway and governance capabilities, expanding features, and fixing bugs. Commercial use must also comply with the One API MIT License and the New API AGPLv3 License, subject to each upstream project's current `LICENSE` file. The temporary commercial license provided by this project does not replace or waive any upstream open-source license obligations.
If the display requirement is no longer met, or the temporary license expires, is amended, or is terminated by notice, you must comply with AGPLv3 or obtain separate written authorization. For a long-term commercial license, contact: maxapi@max-api.ai.
@@ -744,6 +744,8 @@ Issues, documentation improvements, provider adaptation experience, deployment p
This project is licensed under the [GNU Affero General Public License v3.0 (AGPLv3)](./LICENSE).
+In addition to the default AGPLv3 license, self-use derivative projects that meet the “Derivative Use and Community Acknowledgement” conditions above automatically receive the non-perpetual temporary commercial license described there. This temporary license covers only additions and modifications that the MAX API project maintainers have the right to license; it does not include or replace licensing from upstream projects such as One API and New API.
+
If you modify this project and provide it to users over a network, please understand and comply with AGPLv3 source availability obligations. For commercial cooperation, institutional cooperation, or other licensing questions, contact: maxapi@max-api.ai.
---
diff --git a/README.fr.md b/README.fr.md
index df6d93b..7051455 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -649,7 +649,7 @@ Exemple de code d'intégration frontend (React / Tailwind CSS ; conservez l'un o
En respectant l'une des exigences d'affichage ci-dessus et en maintenant le lien clairement visible, vous obtenez automatiquement une licence commerciale temporaire pour ce projet, sans demande ni approbation supplémentaire. Cette licence n'est pas permanente et reste valable uniquement tant que l'exigence d'affichage est respectée. Sa période de validité et toute modification ultérieure sont régies par le dernier avis publié dans ce README ou par la communauté officielle.
-Ce projet est développé à partir de [One API](https://github.com/songquanpeng/one-api) et de [New API](https://github.com/QuantumNous/new-api). Toute utilisation commerciale doit également respecter la licence MIT de One API et la licence AGPLv3 de New API, conformément au fichier `LICENSE` actuellement publié par chaque projet en amont. La licence commerciale temporaire fournie par ce projet ne remplace ni ne supprime aucune obligation liée aux licences open source des projets en amont.
+Ce projet est développé à partir de [One API](https://github.com/songquanpeng/one-api) et de [New API](https://github.com/QuantumNous/new-api). À ce stade, MAX API s'appuie sur ces bases pour renforcer ses capacités de passerelle d'API d'IA et de gouvernance, enrichir ses fonctionnalités et corriger les anomalies. Toute utilisation commerciale doit également respecter la licence MIT de One API et la licence AGPLv3 de New API, conformément au fichier `LICENSE` actuellement publié par chaque projet en amont. La licence commerciale temporaire fournie par ce projet ne remplace ni ne supprime aucune obligation liée aux licences open source des projets en amont.
Si l'exigence d'affichage n'est plus respectée, ou si la licence temporaire expire, est modifiée ou prend fin par avis, vous devez respecter l'AGPLv3 ou obtenir une autorisation écrite distincte. Pour une licence commerciale à long terme, contactez : maxapi@max-api.ai.
@@ -661,6 +661,8 @@ Contributions de code, documentation, expériences d'adaptation fournisseur et p
Ce projet est sous [GNU Affero General Public License v3.0 (AGPLv3)](./LICENSE).
+Outre la licence AGPLv3 par défaut, les projets dérivés à usage propre qui respectent les conditions « Développements dérivés et remerciements à la communauté » ci-dessus reçoivent automatiquement la licence commerciale temporaire et non permanente qui y est décrite. Cette licence temporaire ne couvre que les ajouts et modifications que les responsables du projet MAX API sont en droit de concéder ; elle n'inclut ni ne remplace les licences des projets en amont tels que One API et New API.
+
Si vous modifiez le projet et le fournissez aux utilisateurs via le réseau, comprenez et respectez les obligations AGPLv3 de disponibilité du code source. Pour coopération commerciale, institutionnelle ou autres questions de licence : maxapi@max-api.ai.
---
diff --git a/README.ja.md b/README.ja.md
index 812b172..a283052 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -670,7 +670,7 @@ SQLite、MySQL ≥ 5.7.8、PostgreSQL ≥ 9.6 に対応しています。ロー
上記いずれかの表示要件を満たし、リンクを明確に表示し続けることで、申請や承認を別途行うことなく、本プロジェクトの一時的な商用ライセンスを自動的に取得できます。このライセンスは永久ライセンスではなく、表示要件を継続して満たしている期間に限り有効です。有効期間および今後の変更は、本 README または公式コミュニティで公開される最新のお知らせに従います。
-本プロジェクトは [One API](https://github.com/songquanpeng/one-api) および [New API](https://github.com/QuantumNous/new-api) をベースに開発されています。商用利用にあたっては、各上流プロジェクトが現在公開している `LICENSE` ファイルに従い、One API の MIT ライセンスおよび New API の AGPLv3 ライセンスも遵守する必要があります。本プロジェクトが提供する一時的な商用ライセンスは、上流プロジェクトのオープンソースライセンス上の義務を置き換えたり免除したりするものではありません。
+本プロジェクトは [One API](https://github.com/songquanpeng/one-api) および [New API](https://github.com/QuantumNous/new-api) をベースに開発されています。現段階の MAX API は、これらを基盤として AI API ゲートウェイおよびガバナンス機能の強化、機能拡張、バグ修正を継続しています。商用利用にあたっては、各上流プロジェクトが現在公開している `LICENSE` ファイルに従い、One API の MIT ライセンスおよび New API の AGPLv3 ライセンスも遵守する必要があります。本プロジェクトが提供する一時的な商用ライセンスは、上流プロジェクトのオープンソースライセンス上の義務を置き換えたり免除したりするものではありません。
表示要件を満たさなくなった場合、または一時的なライセンスが期限切れ、告知により変更もしくは終了した場合は、AGPLv3 に従うか、別途書面による許諾を取得する必要があります。長期商用ライセンスについては、maxapi@max-api.ai までお問い合わせください。
@@ -682,6 +682,8 @@ Issue、ドキュメント改善、プロバイダー適応経験、デプロイ
本プロジェクトは [GNU Affero General Public License v3.0 (AGPLv3)](./LICENSE) で提供されます。
+標準の AGPLv3 ライセンスに加え、上記「二次開発とコミュニティへの謝辞」の条件を満たす自己利用の二次開発プロジェクトには、そこで説明されている非永久の一時的な商用ライセンスが自動的に付与されます。この一時的なライセンスが対象とするのは、MAX API プロジェクト運営者がライセンスする権利を有する追加・変更部分のみであり、One API や New API などの上流プロジェクトのライセンスを含む、または代替するものではありません。
+
本プロジェクトを変更し、ネットワーク経由でユーザーにサービスとして提供する場合は、AGPLv3 のソース提供義務などを理解し遵守してください。商用協力、機関協力、その他ライセンスに関する問い合わせは maxapi@max-api.ai までご連絡ください。
---
diff --git a/README.md b/README.md
index de30ef0..f55879f 100644
--- a/README.md
+++ b/README.md
@@ -739,7 +739,7 @@ MAX API 不替代 Dify、LangChain、MCP Server、工作流引擎或业务 Agent
满足上述任一展示要求并保持链接清晰可见,即自动获得本项目的临时商用授权,无需另行申请或等待确认。该授权为非永久授权,仅在持续满足展示要求期间有效;有效期及后续调整以项目方在本 README 或官方社区发布的最新说明为准。
-本项目基于 [One API](https://github.com/songquanpeng/one-api) 和 [New API](https://github.com/QuantumNous/new-api) 开发。商业使用本项目时,还须分别遵守 One API 的 MIT 许可证与 New API 的 AGPLv3 许可证,具体以各上游项目的 `LICENSE` 文件为准;本项目提供的临时商用授权不替代、也不免除相关上游项目的开源许可义务。
+本项目基于 [One API](https://github.com/songquanpeng/one-api) 和 [New API](https://github.com/QuantumNous/new-api) 开发。现阶段,MAX API 主要在上述项目基础上持续增强 AI API 网关与治理能力、扩展功能并修复问题。商业使用本项目时,还须分别遵守 One API 的 MIT 许可证与 New API 的 AGPLv3 许可证,具体以各上游项目的 `LICENSE` 文件为准;本项目提供的临时商用授权不替代、也不免除相关上游项目的开源许可义务。
如不再满足展示要求,或临时授权到期、被公告调整或终止,仍需按 AGPLv3 或项目方另行书面授权使用本项目。如需长期商用授权,请联系:maxapi@max-api.ai。
@@ -751,6 +751,8 @@ MAX API 不替代 Dify、LangChain、MCP Server、工作流引擎或业务 Agent
本项目采用 [GNU Affero 通用公共许可证 v3.0 (AGPLv3)](./LICENSE) 授权。
+除默认的 AGPLv3 授权外,符合上文“二次开发与社区鸣谢”条件的自用二开项目,可按该说明自动获得非永久的临时商用授权。该临时授权仅覆盖 MAX API 项目方有权授权的新增与修改部分,并不包含或代替 One API、New API 等上游项目的许可授权。
+
如果你修改并通过网络向用户提供本项目服务,请理解并遵守 AGPLv3 对应源码提供等义务。商业合作、机构合作或其他授权问题,请联系:maxapi@max-api.ai。
diff --git a/README.zh_CN.md b/README.zh_CN.md
index 19ca044..791e780 100644
--- a/README.zh_CN.md
+++ b/README.zh_CN.md
@@ -732,7 +732,7 @@ MAX API 不替代 Dify、LangChain、MCP Server、工作流引擎或业务 Agent
满足上述任一展示要求并保持链接清晰可见,即自动获得本项目的临时商用授权,无需另行申请或等待确认。该授权为非永久授权,仅在持续满足展示要求期间有效;有效期及后续调整以项目方在本 README 或官方社区发布的最新说明为准。
-本项目基于 [One API](https://github.com/songquanpeng/one-api) 和 [New API](https://github.com/QuantumNous/new-api) 开发。商业使用本项目时,还须分别遵守 One API 的 MIT 许可证与 New API 的 AGPLv3 许可证,具体以各上游项目的 `LICENSE` 文件为准;本项目提供的临时商用授权不替代、也不免除相关上游项目的开源许可义务。
+本项目基于 [One API](https://github.com/songquanpeng/one-api) 和 [New API](https://github.com/QuantumNous/new-api) 开发。现阶段,MAX API 主要在上述项目基础上持续增强 AI API 网关与治理能力、扩展功能并修复问题。商业使用本项目时,还须分别遵守 One API 的 MIT 许可证与 New API 的 AGPLv3 许可证,具体以各上游项目的 `LICENSE` 文件为准;本项目提供的临时商用授权不替代、也不免除相关上游项目的开源许可义务。
如不再满足展示要求,或临时授权到期、被公告调整或终止,仍需按 AGPLv3 或项目方另行书面授权使用本项目。如需长期商用授权,请联系:maxapi@max-api.ai。
@@ -744,6 +744,8 @@ MAX API 不替代 Dify、LangChain、MCP Server、工作流引擎或业务 Agent
本项目采用 [GNU Affero 通用公共许可证 v3.0 (AGPLv3)](./LICENSE) 授权。
+除默认的 AGPLv3 授权外,符合上文“二次开发与社区鸣谢”条件的自用二开项目,可按该说明自动获得非永久的临时商用授权。该临时授权仅覆盖 MAX API 项目方有权授权的新增与修改部分,并不包含或代替 One API、New API 等上游项目的许可授权。
+
如果你修改并通过网络向用户提供本项目服务,请理解并遵守 AGPLv3 对应源码提供等义务。商业合作、机构合作或其他授权问题,请联系:maxapi@max-api.ai。
---
diff --git a/README.zh_TW.md b/README.zh_TW.md
index 1367227..9d773f1 100644
--- a/README.zh_TW.md
+++ b/README.zh_TW.md
@@ -732,7 +732,7 @@ MAX API 不替代 Dify、LangChain、MCP Server、工作流引擎或業務 Agent
滿足上述任一展示要求並保持連結清晰可見,即自動獲得本專案的臨時商用授權,無需另行申請或等待確認。該授權為非永久授權,僅在持續滿足展示要求期間有效;有效期及後續調整以專案方在本 README 或官方社群發布的最新說明為準。
-本專案基於 [One API](https://github.com/songquanpeng/one-api) 和 [New API](https://github.com/QuantumNous/new-api) 開發。商業使用本專案時,還須分別遵守 One API 的 MIT 許可證與 New API 的 AGPLv3 許可證,具體以各上游專案的 `LICENSE` 檔案為準;本專案提供的臨時商用授權不替代、也不免除相關上游專案的開源許可義務。
+本專案基於 [One API](https://github.com/songquanpeng/one-api) 和 [New API](https://github.com/QuantumNous/new-api) 開發。現階段,MAX API 主要在上述專案基礎上持續強化 AI API 閘道與治理能力、擴充功能並修正問題。商業使用本專案時,還須分別遵守 One API 的 MIT 許可證與 New API 的 AGPLv3 許可證,具體以各上游專案的 `LICENSE` 檔案為準;本專案提供的臨時商用授權不替代、也不免除相關上游專案的開源許可義務。
如不再滿足展示要求,或臨時授權到期、被公告調整或終止,仍需按 AGPLv3 或專案方另行書面授權使用本專案。如需長期商用授權,請聯絡:maxapi@max-api.ai。
@@ -744,6 +744,8 @@ MAX API 不替代 Dify、LangChain、MCP Server、工作流引擎或業務 Agent
本專案采用 [GNU Affero 通用公共許可證 v3.0 (AGPLv3)](./LICENSE) 授權。
+除預設的 AGPLv3 授權外,符合上文「二次開發與社群鳴謝」條件的自用二開專案,可按該說明自動獲得非永久的臨時商用授權。該臨時授權僅涵蓋 MAX API 專案方有權授權的新增與修改部分,並不包含或代替 One API、New API 等上游專案的許可授權。
+
如果你修改并通過網絡向使用者提供本專案服務,請理解并遵守 AGPLv3 對應源碼提供等義務。商業合作、機構合作或其他授權問題,請聯系:maxapi@max-api.ai。
---
diff --git a/dto/billing_usage.go b/dto/billing_usage.go
index 6773d4d..b246e21 100644
--- a/dto/billing_usage.go
+++ b/dto/billing_usage.go
@@ -97,6 +97,13 @@ func HasOpenAIUsageTokens(usage *Usage) bool {
usage.CompletionTokenDetails.AudioTokens != 0 {
return true
}
+ if usage.OutputTokensDetails != nil &&
+ (usage.OutputTokensDetails.ReasoningTokens != 0 ||
+ usage.OutputTokensDetails.TextTokens != 0 ||
+ usage.OutputTokensDetails.ImageTokens != 0 ||
+ usage.OutputTokensDetails.AudioTokens != 0) {
+ return true
+ }
return usage.InputTokensDetails != nil
}
@@ -108,15 +115,49 @@ func NewEstimatedGeminiChatBillingUsage(usage *Usage) *BillingUsage {
if usage == nil {
return nil
}
+ metadata := GeminiUsageMetadata{}
+ if usage.BillingUsage != nil && usage.BillingUsage.GeminiUsageMetadata != nil {
+ metadata = cloneGeminiUsageMetadata(*usage.BillingUsage.GeminiUsageMetadata)
+ }
+
+ if usage.PromptTokens > 0 {
+ promptTokens := usage.PromptTokens - metadata.ToolUsePromptTokenCount
+ if promptTokens < 0 {
+ promptTokens = 0
+ }
+ metadata.PromptTokenCount = promptTokens
+ }
+ if usage.CompletionTokenDetails.ReasoningTokens > 0 {
+ metadata.ThoughtsTokenCount = usage.CompletionTokenDetails.ReasoningTokens
+ }
+ candidateTokens := usage.CompletionTokens - metadata.ThoughtsTokenCount
+ if candidateTokens < 0 {
+ candidateTokens = 0
+ }
+ metadata.CandidatesTokenCount = candidateTokens
+ metadata.CandidatesTokensDetails = mergeGeminiCandidateTokenDetail(metadata.CandidatesTokensDetails, "TEXT", usage.CompletionTokenDetails.TextTokens)
+ metadata.CandidatesTokensDetails = mergeGeminiCandidateTokenDetail(metadata.CandidatesTokensDetails, "IMAGE", usage.CompletionTokenDetails.ImageTokens)
+ metadata.CandidatesTokensDetails = mergeGeminiCandidateTokenDetail(metadata.CandidatesTokensDetails, "AUDIO", usage.CompletionTokenDetails.AudioTokens)
+
totalTokens := usage.TotalTokens
if totalTokens == 0 {
totalTokens = usage.PromptTokens + usage.CompletionTokens
}
- return newGeminiChatBillingUsage(&GeminiUsageMetadata{
- PromptTokenCount: usage.PromptTokens,
- CandidatesTokenCount: usage.CompletionTokens,
- TotalTokenCount: totalTokens,
- }, true)
+ metadata.TotalTokenCount = totalTokens
+ return newGeminiChatBillingUsage(&metadata, true)
+}
+
+func mergeGeminiCandidateTokenDetail(details []GeminiPromptTokensDetails, modality string, tokenCount int) []GeminiPromptTokensDetails {
+ if tokenCount <= 0 {
+ return details
+ }
+ for i := range details {
+ if details[i].Modality == modality {
+ details[i].TokenCount = tokenCount
+ return details
+ }
+ }
+ return append(details, GeminiPromptTokensDetails{Modality: modality, TokenCount: tokenCount})
}
func newGeminiChatBillingUsage(metadata *GeminiUsageMetadata, estimated bool) *BillingUsage {
@@ -156,6 +197,10 @@ func cloneOpenAIUsage(usage *Usage) *Usage {
inputTokensDetails := *usage.InputTokensDetails
clone.InputTokensDetails = &inputTokensDetails
}
+ if usage.OutputTokensDetails != nil {
+ outputTokensDetails := *usage.OutputTokensDetails
+ clone.OutputTokensDetails = &outputTokensDetails
+ }
return &clone
}
diff --git a/dto/billing_usage_test.go b/dto/billing_usage_test.go
index 26b1b49..51ca23c 100644
--- a/dto/billing_usage_test.go
+++ b/dto/billing_usage_test.go
@@ -49,6 +49,17 @@ func TestNewOpenAIChatBillingUsageRequiresTokenContent(t *testing.T) {
assert.Equal(t, 1, billingUsage.OpenAIUsage.PromptTokens)
}
+func TestNewOpenAIResponsesBillingUsageSupportsOutputTokenDetails(t *testing.T) {
+ source := &Usage{OutputTokensDetails: &OutputTokenDetails{AudioTokens: 9}}
+ billingUsage := NewOpenAIResponsesBillingUsage(source)
+
+ require.NotNil(t, billingUsage)
+ require.NotNil(t, billingUsage.OpenAIUsage)
+ require.NotNil(t, billingUsage.OpenAIUsage.OutputTokensDetails)
+ assert.Equal(t, 9, billingUsage.OpenAIUsage.OutputTokensDetails.AudioTokens)
+ assert.NotSame(t, source.OutputTokensDetails, billingUsage.OpenAIUsage.OutputTokensDetails)
+}
+
func TestNewEstimatedGeminiChatBillingUsage(t *testing.T) {
billingUsage := NewEstimatedGeminiChatBillingUsage(&Usage{
PromptTokens: 11,
diff --git a/dto/openai_response.go b/dto/openai_response.go
index 6e536aa..720ac56 100644
--- a/dto/openai_response.go
+++ b/dto/openai_response.go
@@ -229,11 +229,12 @@ type Usage struct {
UsageSource string `json:"usage_source,omitempty"`
BillingUsage *BillingUsage `json:"billing_usage,omitempty"`
- PromptTokensDetails InputTokenDetails `json:"prompt_tokens_details"`
- CompletionTokenDetails OutputTokenDetails `json:"completion_tokens_details"`
- InputTokens int `json:"input_tokens"`
- OutputTokens int `json:"output_tokens"`
- InputTokensDetails *InputTokenDetails `json:"input_tokens_details"`
+ PromptTokensDetails InputTokenDetails `json:"prompt_tokens_details"`
+ CompletionTokenDetails OutputTokenDetails `json:"completion_tokens_details"`
+ OutputTokensDetails *OutputTokenDetails `json:"output_tokens_details,omitempty"`
+ InputTokens int `json:"input_tokens"`
+ OutputTokens int `json:"output_tokens"`
+ InputTokensDetails *InputTokenDetails `json:"input_tokens_details"`
// claude cache 1h
ClaudeCacheCreation5mTokens int `json:"claude_cache_creation_5_m_tokens"`
@@ -304,6 +305,24 @@ type OutputTokenDetails struct {
ReasoningTokens int `json:"reasoning_tokens"`
}
+func CopyOutputTokenDetails(dst *OutputTokenDetails, src *OutputTokenDetails, overwriteExisting bool) {
+ if dst == nil || src == nil {
+ return
+ }
+ if overwriteExisting || dst.TextTokens == 0 {
+ dst.TextTokens = src.TextTokens
+ }
+ if overwriteExisting || dst.AudioTokens == 0 {
+ dst.AudioTokens = src.AudioTokens
+ }
+ if overwriteExisting || dst.ImageTokens == 0 {
+ dst.ImageTokens = src.ImageTokens
+ }
+ if overwriteExisting || dst.ReasoningTokens == 0 {
+ dst.ReasoningTokens = src.ReasoningTokens
+ }
+}
+
type OpenAIResponsesResponse struct {
ID string `json:"id"`
Object string `json:"object"`
diff --git a/relay/channel/claude/message_delta_usage_patch_test.go b/relay/channel/claude/message_delta_usage_patch_test.go
index b0c2d52..144ee86 100644
--- a/relay/channel/claude/message_delta_usage_patch_test.go
+++ b/relay/channel/claude/message_delta_usage_patch_test.go
@@ -30,6 +30,15 @@ func TestPatchClaudeMessageDeltaUsageDataPreserveUnknownFields(t *testing.T) {
require.EqualValues(t, 50, gjson.Get(patchedData, "usage.cache_creation_input_tokens").Int())
}
+func TestPatchClaudeMessageDeltaUsageDataAddsMissingOutputTokens(t *testing.T) {
+ originalData := `{"type":"message_delta","usage":{}}`
+ usage := &dto.ClaudeUsage{OutputTokens: 99}
+
+ patchedData := patchClaudeMessageDeltaUsageData(originalData, usage)
+
+ require.EqualValues(t, 99, gjson.Get(patchedData, "usage.output_tokens").Int())
+}
+
func TestPatchClaudeMessageDeltaUsageDataZeroValueChecks(t *testing.T) {
originalData := `{"type":"message_delta","usage":{"output_tokens":53,"input_tokens":9,"cache_read_input_tokens":0}}`
usage := &dto.ClaudeUsage{
@@ -89,14 +98,26 @@ func TestBuildMessageDeltaPatchUsage(t *testing.T) {
require.EqualValues(t, 20, usage.CacheCreation.Ephemeral1hInputTokens)
})
+ t.Run("patch missing OutputTokens from CompletionTokens", func(t *testing.T) {
+ claudeResponse := &dto.ClaudeResponse{Usage: &dto.ClaudeUsage{}}
+ claudeInfo := &ClaudeResponseInfo{Usage: &dto.Usage{
+ CompletionTokens: 99,
+ }}
+
+ usage := buildMessageDeltaPatchUsage(claudeResponse, claudeInfo)
+ require.EqualValues(t, 99, usage.OutputTokens)
+ })
+
t.Run("keep upstream non-zero values", func(t *testing.T) {
claudeResponse := &dto.ClaudeResponse{Usage: &dto.ClaudeUsage{
InputTokens: 9,
+ OutputTokens: 8,
CacheReadInputTokens: 7,
CacheCreationInputTokens: 6,
}}
claudeInfo := &ClaudeResponseInfo{Usage: &dto.Usage{
- PromptTokens: 100,
+ PromptTokens: 100,
+ CompletionTokens: 99,
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 30,
CachedCreationTokens: 50,
@@ -105,6 +126,7 @@ func TestBuildMessageDeltaPatchUsage(t *testing.T) {
usage := buildMessageDeltaPatchUsage(claudeResponse, claudeInfo)
require.EqualValues(t, 9, usage.InputTokens)
+ require.EqualValues(t, 8, usage.OutputTokens)
require.EqualValues(t, 7, usage.CacheReadInputTokens)
require.EqualValues(t, 6, usage.CacheCreationInputTokens)
})
diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go
index aeb89f8..ceb0431 100644
--- a/relay/channel/claude/relay-claude.go
+++ b/relay/channel/claude/relay-claude.go
@@ -695,6 +695,9 @@ func buildMessageDeltaPatchUsage(claudeResponse *dto.ClaudeResponse, claudeInfo
if usage.InputTokens == 0 && claudeInfo.Usage.PromptTokens > 0 {
usage.InputTokens = claudeInfo.Usage.PromptTokens
}
+ if usage.OutputTokens == 0 && claudeInfo.Usage.CompletionTokens > 0 {
+ usage.OutputTokens = claudeInfo.Usage.CompletionTokens
+ }
if usage.CacheReadInputTokens == 0 && claudeInfo.Usage.PromptTokensDetails.CachedTokens > 0 {
usage.CacheReadInputTokens = claudeInfo.Usage.PromptTokensDetails.CachedTokens
}
@@ -741,6 +744,7 @@ func patchClaudeMessageDeltaUsageData(data string, usage *dto.ClaudeUsage) strin
}
data = setMessageDeltaUsageInt(data, "usage.input_tokens", usage.InputTokens)
+ data = setMessageDeltaUsageInt(data, "usage.output_tokens", usage.OutputTokens)
data = setMessageDeltaUsageInt(data, "usage.cache_read_input_tokens", usage.CacheReadInputTokens)
data = setMessageDeltaUsageInt(data, "usage.cache_creation_input_tokens", usage.CacheCreationInputTokens)
diff --git a/relay/channel/claude/relay_claude_test.go b/relay/channel/claude/relay_claude_test.go
index c4a15c8..0d55105 100644
--- a/relay/channel/claude/relay_claude_test.go
+++ b/relay/channel/claude/relay_claude_test.go
@@ -2,11 +2,15 @@ package claude
import (
"encoding/base64"
+ "net/http/httptest"
"strings"
"testing"
"github.com/MAX-API-Next/MAX-API/common"
"github.com/MAX-API-Next/MAX-API/dto"
+ relaycommon "github.com/MAX-API-Next/MAX-API/relay/common"
+ "github.com/MAX-API-Next/MAX-API/types"
+ "github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
@@ -151,6 +155,40 @@ func TestFormatClaudeResponseInfo_MessageDelta_OnlyOutputTokens(t *testing.T) {
}
}
+func TestHandleStreamFinalResponsePreservesOutputTokensInBillingUsage(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
+ info := &relaycommon.RelayInfo{
+ RelayFormat: types.RelayFormatClaude,
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: "claude-sonnet-test",
+ },
+ }
+ claudeInfo := &ClaudeResponseInfo{
+ ResponseText: strings.Builder{},
+ Usage: &dto.Usage{},
+ }
+
+ require.True(t, FormatClaudeResponseInfo(&dto.ClaudeResponse{
+ Type: "message_start",
+ Message: &dto.ClaudeMediaMessage{
+ Model: "claude-sonnet-test",
+ Usage: &dto.ClaudeUsage{InputTokens: 100},
+ },
+ }, nil, claudeInfo))
+ require.True(t, FormatClaudeResponseInfo(&dto.ClaudeResponse{
+ Type: "message_delta",
+ Usage: &dto.ClaudeUsage{OutputTokens: 53},
+ }, nil, claudeInfo))
+
+ HandleStreamFinalResponse(ctx, info, claudeInfo)
+
+ require.Equal(t, 53, claudeInfo.Usage.CompletionTokens)
+ require.NotNil(t, claudeInfo.Usage.BillingUsage)
+ require.NotNil(t, claudeInfo.Usage.BillingUsage.ClaudeUsage)
+ require.Equal(t, 53, claudeInfo.Usage.BillingUsage.ClaudeUsage.OutputTokens)
+}
+
func TestFormatClaudeResponseInfo_NilClaudeInfo(t *testing.T) {
claudeResponse := &dto.ClaudeResponse{Type: "message_start"}
ok := FormatClaudeResponseInfo(claudeResponse, nil, nil)
diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go
index 85d4b8f..0bde747 100644
--- a/relay/channel/gemini/relay-gemini.go
+++ b/relay/channel/gemini/relay-gemini.go
@@ -1069,15 +1069,28 @@ func attachEstimatedGeminiBillingUsage(usage *dto.Usage) *dto.Usage {
return usage
}
-// patchGeminiZeroCompletionUsage estimates completion tokens locally when
-// upstream usageMetadata was billable but reported zero completion tokens even
-// though output content was received. Without replacing BillingUsage, settlement
-// would still prefer the prompt-only metadata and bill zero completion.
+func geminiCandidateDetailTokens(details dto.OutputTokenDetails) int {
+ total := 0
+ if details.TextTokens > 0 {
+ total += details.TextTokens
+ }
+ if details.ImageTokens > 0 {
+ total += details.ImageTokens
+ }
+ if details.AudioTokens > 0 {
+ total += details.AudioTokens
+ }
+ return total
+}
+
+// patchGeminiZeroCompletionUsage repairs a missing candidate count from visible
+// output and any modality details already reported by upstream. Reasoning tokens
+// and the original detailed metadata are retained.
func patchGeminiZeroCompletionUsage(c *gin.Context, info *relaycommon.RelayInfo, usage *dto.Usage, responseText string, imageCount int) {
- if usage == nil || usage.CompletionTokens > 0 {
+ if usage == nil || geminiCandidateTokenCount(usage) > 0 {
return
}
- if responseText == "" && imageCount == 0 {
+ if responseText == "" && imageCount == 0 && geminiCandidateDetailTokens(usage.CompletionTokenDetails) == 0 {
return
}
promptTokens := usage.PromptTokens
@@ -1088,14 +1101,38 @@ func patchGeminiZeroCompletionUsage(c *gin.Context, info *relaycommon.RelayInfo,
if usage.PromptTokens == 0 {
usage.PromptTokens = estimated.PromptTokens
}
- usage.CompletionTokens = estimated.CompletionTokens
- if imageCount != 0 && usage.CompletionTokens == 0 {
- usage.CompletionTokens = imageCount * 1400
+ if responseText != "" && estimated.CompletionTokens > 0 && usage.CompletionTokenDetails.TextTokens == 0 {
+ usage.CompletionTokenDetails.TextTokens = estimated.CompletionTokens
}
+ if imageCount > 0 && usage.CompletionTokenDetails.ImageTokens == 0 {
+ usage.CompletionTokenDetails.ImageTokens = imageCount * 1400
+ }
+ candidateTokens := geminiCandidateDetailTokens(usage.CompletionTokenDetails)
+ if candidateTokens == 0 {
+ candidateTokens = estimated.CompletionTokens
+ }
+ usage.CompletionTokens = usage.CompletionTokenDetails.ReasoningTokens + candidateTokens
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
usage.BillingUsage = dto.NewEstimatedGeminiChatBillingUsage(usage)
}
+func geminiCandidateTokenCount(usage *dto.Usage) int {
+ if usage == nil {
+ return 0
+ }
+ if usage.BillingUsage != nil && usage.BillingUsage.GeminiUsageMetadata != nil {
+ if count := usage.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount; count > 0 {
+ return count
+ }
+ return 0
+ }
+ count := usage.CompletionTokens - usage.CompletionTokenDetails.ReasoningTokens
+ if count < 0 {
+ return 0
+ }
+ return count
+}
+
func geminiResponseUsageText(response *dto.GeminiChatResponse) string {
if response == nil {
return ""
@@ -1118,7 +1155,7 @@ func geminiResponseInlineImageCount(response *dto.GeminiChatResponse) int {
count := 0
for _, candidate := range response.Candidates {
for _, part := range candidate.Content.Parts {
- if part.InlineData != nil && strings.HasPrefix(part.InlineData.MimeType, "image") {
+ if isGeminiInlineImage(part.InlineData) {
count++
}
}
@@ -1126,6 +1163,10 @@ func geminiResponseInlineImageCount(response *dto.GeminiChatResponse) int {
return count
}
+func isGeminiInlineImage(data *dto.GeminiInlineData) bool {
+ return data != nil && strings.HasPrefix(data.MimeType, "image/")
+}
+
func buildUsageFromGeminiResponse(c *gin.Context, info *relaycommon.RelayInfo, response *dto.GeminiChatResponse) dto.Usage {
if response == nil {
return dto.Usage{}
@@ -1452,7 +1493,7 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
// 统计图片数量
for _, candidate := range geminiResponse.Candidates {
for _, part := range candidate.Content.Parts {
- if part.InlineData != nil && part.InlineData.MimeType != "" {
+ if isGeminiInlineImage(part.InlineData) {
imageCount++
}
if part.Text != "" {
diff --git a/relay/channel/gemini/relay_gemini_usage_test.go b/relay/channel/gemini/relay_gemini_usage_test.go
index 91b2f3b..105f68e 100644
--- a/relay/channel/gemini/relay_gemini_usage_test.go
+++ b/relay/channel/gemini/relay_gemini_usage_test.go
@@ -377,6 +377,192 @@ func TestGeminiTextGenerationHandlerUsesEstimatedPromptTokensWhenUsagePromptMiss
require.Equal(t, 110, usage.TotalTokens)
}
+func newGeminiUsagePatchTestContext(t *testing.T) (*gin.Context, *relaycommon.RelayInfo) {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-test:generateContent", nil)
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "gemini-test",
+ ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gemini-test"},
+ }
+ return c, info
+}
+
+func TestPatchGeminiZeroCompletionUsagePreservesBillingDetails(t *testing.T) {
+ c, info := newGeminiUsagePatchTestContext(t)
+ metadata := dto.GeminiUsageMetadata{
+ PromptTokenCount: 100,
+ ToolUsePromptTokenCount: 5,
+ ThoughtsTokenCount: 10,
+ TotalTokenCount: 115,
+ CachedContentTokenCount: 20,
+ PromptTokensDetails: []dto.GeminiPromptTokensDetails{
+ {Modality: "TEXT", TokenCount: 70},
+ {Modality: "IMAGE", TokenCount: 10},
+ {Modality: "AUDIO", TokenCount: 20},
+ },
+ ToolUsePromptTokensDetails: []dto.GeminiPromptTokensDetails{
+ {Modality: "TEXT", TokenCount: 5},
+ },
+ }
+ usage := buildUsageFromGeminiMetadata(metadata, 0)
+ require.Equal(t, 10, usage.CompletionTokens)
+
+ patchGeminiZeroCompletionUsage(c, info, &usage, "", 2)
+
+ require.Equal(t, 2810, usage.CompletionTokens)
+ require.Equal(t, 10, usage.CompletionTokenDetails.ReasoningTokens)
+ require.Equal(t, 2800, usage.CompletionTokenDetails.ImageTokens)
+ require.NotNil(t, usage.BillingUsage)
+ require.True(t, usage.BillingUsage.Estimated)
+ require.NotNil(t, usage.BillingUsage.GeminiUsageMetadata)
+ patched := usage.BillingUsage.GeminiUsageMetadata
+ require.Equal(t, 20, patched.CachedContentTokenCount)
+ require.Equal(t, metadata.PromptTokensDetails, patched.PromptTokensDetails)
+ require.Equal(t, metadata.ToolUsePromptTokensDetails, patched.ToolUsePromptTokensDetails)
+ require.Equal(t, 10, patched.ThoughtsTokenCount)
+ require.Equal(t, 2800, patched.CandidatesTokenCount)
+ require.Contains(t, patched.CandidatesTokensDetails, dto.GeminiPromptTokensDetails{Modality: "IMAGE", TokenCount: 2800})
+}
+
+func TestPatchGeminiZeroCompletionUsageEstimatesTextAlongsideReasoning(t *testing.T) {
+ c, info := newGeminiUsagePatchTestContext(t)
+ metadata := dto.GeminiUsageMetadata{
+ PromptTokenCount: 100,
+ ThoughtsTokenCount: 10,
+ TotalTokenCount: 110,
+ CachedContentTokenCount: 20,
+ PromptTokensDetails: []dto.GeminiPromptTokensDetails{
+ {Modality: "TEXT", TokenCount: 100},
+ },
+ }
+ usage := buildUsageFromGeminiMetadata(metadata, 0)
+
+ patchGeminiZeroCompletionUsage(c, info, &usage, "visible response text", 0)
+
+ estimatedCandidates := usage.CompletionTokens - 10
+ require.Positive(t, estimatedCandidates)
+ require.Equal(t, estimatedCandidates, usage.CompletionTokenDetails.TextTokens)
+ require.Equal(t, 10, usage.CompletionTokenDetails.ReasoningTokens)
+ require.NotNil(t, usage.BillingUsage)
+ require.Equal(t, estimatedCandidates, usage.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
+ require.Equal(t, 10, usage.BillingUsage.GeminiUsageMetadata.ThoughtsTokenCount)
+ require.Equal(t, 20, usage.BillingUsage.GeminiUsageMetadata.CachedContentTokenCount)
+ require.Contains(t, usage.BillingUsage.GeminiUsageMetadata.CandidatesTokensDetails, dto.GeminiPromptTokensDetails{
+ Modality: "TEXT",
+ TokenCount: estimatedCandidates,
+ })
+}
+
+func TestPatchGeminiZeroCompletionUsageCombinesTextAndImageEstimates(t *testing.T) {
+ c, info := newGeminiUsagePatchTestContext(t)
+ metadata := dto.GeminiUsageMetadata{
+ PromptTokenCount: 100,
+ ThoughtsTokenCount: 10,
+ TotalTokenCount: 110,
+ CachedContentTokenCount: 20,
+ }
+ usage := buildUsageFromGeminiMetadata(metadata, 0)
+
+ patchGeminiZeroCompletionUsage(c, info, &usage, "visible response text", 2)
+
+ textTokens := usage.CompletionTokenDetails.TextTokens
+ require.Positive(t, textTokens)
+ require.Equal(t, 2800, usage.CompletionTokenDetails.ImageTokens)
+ require.Equal(t, 10+textTokens+2800, usage.CompletionTokens)
+ require.NotNil(t, usage.BillingUsage)
+ require.Equal(t, textTokens+2800, usage.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
+ require.Contains(t, usage.BillingUsage.GeminiUsageMetadata.CandidatesTokensDetails, dto.GeminiPromptTokensDetails{
+ Modality: "TEXT",
+ TokenCount: textTokens,
+ })
+ require.Contains(t, usage.BillingUsage.GeminiUsageMetadata.CandidatesTokensDetails, dto.GeminiPromptTokensDetails{
+ Modality: "IMAGE",
+ TokenCount: 2800,
+ })
+}
+
+func TestPatchGeminiZeroCompletionUsagePreservesExistingImageDetails(t *testing.T) {
+ c, info := newGeminiUsagePatchTestContext(t)
+ metadata := dto.GeminiUsageMetadata{
+ PromptTokenCount: 100,
+ ThoughtsTokenCount: 10,
+ TotalTokenCount: 110,
+ CandidatesTokensDetails: []dto.GeminiPromptTokensDetails{
+ {Modality: "IMAGE", TokenCount: 321},
+ },
+ }
+ usage := buildUsageFromGeminiMetadata(metadata, 0)
+
+ patchGeminiZeroCompletionUsage(c, info, &usage, "", 2)
+
+ require.Equal(t, 321, usage.CompletionTokenDetails.ImageTokens)
+ require.Equal(t, 331, usage.CompletionTokens)
+ require.NotNil(t, usage.BillingUsage)
+ require.True(t, usage.BillingUsage.Estimated)
+ require.Equal(t, 321, usage.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
+}
+
+func TestPatchGeminiZeroCompletionUsageUsesExistingAudioDetails(t *testing.T) {
+ c, info := newGeminiUsagePatchTestContext(t)
+ metadata := dto.GeminiUsageMetadata{
+ PromptTokenCount: 100,
+ ThoughtsTokenCount: 10,
+ TotalTokenCount: 110,
+ CandidatesTokensDetails: []dto.GeminiPromptTokensDetails{
+ {Modality: "AUDIO", TokenCount: 55},
+ },
+ }
+ usage := buildUsageFromGeminiMetadata(metadata, 0)
+
+ patchGeminiZeroCompletionUsage(c, info, &usage, "", 0)
+
+ require.Equal(t, 55, usage.CompletionTokenDetails.AudioTokens)
+ require.Equal(t, 65, usage.CompletionTokens)
+ require.NotNil(t, usage.BillingUsage)
+ require.True(t, usage.BillingUsage.Estimated)
+ require.Equal(t, 55, usage.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
+}
+
+func TestGeminiStreamHandlerDoesNotCountAudioInlineDataAsImage(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "gemini-test",
+ ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gemini-test"},
+ }
+ chunk := dto.GeminiChatResponse{
+ Candidates: []dto.GeminiChatCandidate{{
+ Content: dto.GeminiChatContent{
+ Role: "model",
+ Parts: []dto.GeminiPart{{
+ InlineData: &dto.GeminiInlineData{MimeType: "audio/wav", Data: "AA=="},
+ }},
+ },
+ }},
+ UsageMetadata: dto.GeminiUsageMetadata{
+ PromptTokenCount: 100,
+ TotalTokenCount: 100,
+ },
+ }
+ chunkData, err := common.Marshal(chunk)
+ require.NoError(t, err)
+ resp := &http.Response{
+ Body: io.NopCloser(bytes.NewReader([]byte("data: " + string(chunkData) + "\n" + "data: [DONE]\n"))),
+ }
+
+ usage, maxAPIError := geminiStreamHandler(c, info, resp, func(_ string, _ *dto.GeminiChatResponse) bool {
+ return true
+ })
+
+ require.Nil(t, maxAPIError)
+ require.NotNil(t, usage)
+ require.Zero(t, usage.CompletionTokenDetails.ImageTokens)
+ require.NotEqual(t, 1400, usage.CompletionTokens)
+}
+
func sseDataForEvent(t *testing.T, body string, eventType string) string {
t.Helper()
for _, event := range strings.Split(body, "\n\n") {
diff --git a/relay/channel/minimax/adaptor_test.go b/relay/channel/minimax/adaptor_test.go
index 10493e2..068fea7 100644
--- a/relay/channel/minimax/adaptor_test.go
+++ b/relay/channel/minimax/adaptor_test.go
@@ -120,6 +120,57 @@ func TestDoResponseForImageGeneration(t *testing.T) {
}
}
+func TestHandleTTSResponseUsesUpstreamCharacterUsage(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}}
+ info.SetEstimatePromptTokens(7)
+ resp := &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: ioNopCloser(`{"data":{"audio":"00"},"extra_info":{"usage_characters":42},"base_resp":{"status_code":0}}`),
+ }
+
+ usageAny, maxAPIError := handleTTSResponse(c, resp, info)
+
+ if maxAPIError != nil {
+ t.Fatalf("handleTTSResponse returned error: %v", maxAPIError)
+ }
+ usage, ok := usageAny.(*dto.Usage)
+ if !ok {
+ t.Fatalf("usage type = %T, want *dto.Usage", usageAny)
+ }
+ if usage.PromptTokens != 42 || usage.TotalTokens != 42 {
+ t.Fatalf("usage = %#v, want upstream character count as prompt and total", usage)
+ }
+ if usage.PromptTokensDetails.TextTokens != 42 {
+ t.Fatalf("text tokens = %d, want 42", usage.PromptTokensDetails.TextTokens)
+ }
+}
+
+func TestHandleTTSResponseFallsBackWhenCharacterUsageMissing(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}}
+ info.SetEstimatePromptTokens(7)
+ resp := &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: ioNopCloser(`{"data":{"audio":"00"},"extra_info":{"usage_characters":0},"base_resp":{"status_code":0}}`),
+ }
+
+ usageAny, maxAPIError := handleTTSResponse(c, resp, info)
+
+ if maxAPIError != nil {
+ t.Fatalf("handleTTSResponse returned error: %v", maxAPIError)
+ }
+ usage := usageAny.(*dto.Usage)
+ if usage.PromptTokens != 7 || usage.TotalTokens != 7 || usage.PromptTokensDetails.TextTokens != 7 {
+ t.Fatalf("usage = %#v, want local estimate fallback", usage)
+ }
+}
+
type nopReadCloser struct {
*strings.Reader
}
diff --git a/relay/channel/minimax/tts.go b/relay/channel/minimax/tts.go
index d7c8c89..a19713f 100644
--- a/relay/channel/minimax/tts.go
+++ b/relay/channel/minimax/tts.go
@@ -2,13 +2,13 @@ package minimax
import (
"encoding/hex"
- "encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
+ "github.com/MAX-API-Next/MAX-API/common"
"github.com/MAX-API-Next/MAX-API/dto"
relaycommon "github.com/MAX-API-Next/MAX-API/relay/common"
"github.com/MAX-API-Next/MAX-API/service"
@@ -118,7 +118,7 @@ func handleTTSResponse(c *gin.Context, resp *http.Response, info *relaycommon.Re
// Parse response
var minimaxResp MiniMaxTTSResponse
- if unmarshalErr := json.Unmarshal(body, &minimaxResp); unmarshalErr != nil {
+ if unmarshalErr := common.Unmarshal(body, &minimaxResp); unmarshalErr != nil {
return nil, types.NewErrorWithStatusCode(
fmt.Errorf("failed to unmarshal minimax TTS response: %w", unmarshalErr),
types.ErrorCodeBadResponseBody,
@@ -163,10 +163,17 @@ func handleTTSResponse(c *gin.Context, resp *http.Response, info *relaycommon.Re
c.Data(http.StatusOK, contentType, audioData)
}
+ promptTokens := info.GetEstimatePromptTokens()
+ if minimaxResp.ExtraInfo.UsageCharacters > 0 {
+ promptTokens = int(minimaxResp.ExtraInfo.UsageCharacters)
+ }
usage = &dto.Usage{
- PromptTokens: info.GetEstimatePromptTokens(),
+ PromptTokens: promptTokens,
CompletionTokens: 0,
- TotalTokens: int(minimaxResp.ExtraInfo.UsageCharacters),
+ TotalTokens: promptTokens,
+ PromptTokensDetails: dto.InputTokenDetails{
+ TextTokens: promptTokens,
+ },
}
return usage, nil
diff --git a/relay/channel/openai/audio.go b/relay/channel/openai/audio.go
index 76bb136..f63977f 100644
--- a/relay/channel/openai/audio.go
+++ b/relay/channel/openai/audio.go
@@ -18,6 +18,23 @@ import (
"github.com/gin-gonic/gin"
)
+func normalizeOpenAIAudioUsage(usage *dto.Usage) {
+ if usage == nil {
+ return
+ }
+ if usage.PromptTokens == 0 {
+ usage.PromptTokens = usage.InputTokens
+ }
+ if usage.CompletionTokens == 0 {
+ usage.CompletionTokens = usage.OutputTokens
+ }
+ if usage.TotalTokens == 0 {
+ usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
+ }
+ dto.CopyInputTokenDetails(&usage.PromptTokensDetails, usage.InputTokensDetails, false)
+ dto.CopyOutputTokenDetails(&usage.CompletionTokenDetails, usage.OutputTokensDetails, false)
+}
+
func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) *dto.Usage {
// the status code has been judged before, if there is a body reading failure,
// it should be regarded as a non-recoverable error, so it should not return err for external retry.
@@ -44,10 +61,9 @@ func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
if err := common.Unmarshal([]byte(data), &simpleResponse); err != nil {
logger.LogError(c, err.Error())
sr.Error(err)
- } else if simpleResponse.Usage.TotalTokens != 0 {
- usage.PromptTokens = simpleResponse.Usage.InputTokens
- usage.CompletionTokens = simpleResponse.OutputTokens
- usage.TotalTokens = simpleResponse.TotalTokens
+ } else if dto.HasOpenAIUsageTokens(&simpleResponse.Usage) {
+ *usage = simpleResponse.Usage
+ normalizeOpenAIAudioUsage(usage)
}
}
if err := helper.StringData(c, data); err != nil {
@@ -127,17 +143,10 @@ func OpenaiSTTHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
var responseData struct {
Usage *dto.Usage `json:"usage"`
}
- if err := common.Unmarshal(responseBody, &responseData); err == nil && responseData.Usage != nil {
- if responseData.Usage.TotalTokens > 0 {
- usage := responseData.Usage
- if usage.PromptTokens == 0 {
- usage.PromptTokens = usage.InputTokens
- }
- if usage.CompletionTokens == 0 {
- usage.CompletionTokens = usage.OutputTokens
- }
- return nil, usage
- }
+ if err := common.Unmarshal(responseBody, &responseData); err == nil && dto.HasOpenAIUsageTokens(responseData.Usage) {
+ usage := responseData.Usage
+ normalizeOpenAIAudioUsage(usage)
+ return nil, usage
}
usage := &dto.Usage{}
diff --git a/relay/channel/openai/audio_usage_test.go b/relay/channel/openai/audio_usage_test.go
new file mode 100644
index 0000000..b3795aa
--- /dev/null
+++ b/relay/channel/openai/audio_usage_test.go
@@ -0,0 +1,63 @@
+package openai
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/MAX-API-Next/MAX-API/dto"
+ relaycommon "github.com/MAX-API-Next/MAX-API/relay/common"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestOpenaiTTSStreamPreservesAudioUsageDetails(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/audio/speech", nil)
+ info := &relaycommon.RelayInfo{
+ IsStream: true,
+ DisablePing: true,
+ ChannelMeta: &relaycommon.ChannelMeta{},
+ }
+ resp := &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader(
+ `data: {"usage":{"input_tokens":100,"output_tokens":25,"total_tokens":125,"input_tokens_details":{"text_tokens":80,"audio_tokens":20},"output_tokens_details":{"audio_tokens":25}}}` + "\n" +
+ "data: [DONE]\n",
+ )),
+ }
+
+ usage := OpenaiTTSHandler(c, resp, info)
+
+ require.Equal(t, 100, usage.PromptTokens)
+ require.Equal(t, 25, usage.CompletionTokens)
+ require.Equal(t, 20, usage.PromptTokensDetails.AudioTokens)
+ require.Equal(t, 80, usage.PromptTokensDetails.TextTokens)
+ require.Equal(t, 25, usage.CompletionTokenDetails.AudioTokens)
+}
+
+func TestOpenaiSTTPreservesInputTokenDetails(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/audio/transcriptions", nil)
+ info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}}
+ resp := &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader(
+ `{"text":"hello","usage":{"input_tokens":100,"output_tokens":5,"total_tokens":105,"input_tokens_details":{"text_tokens":10,"audio_tokens":90}}}`,
+ )),
+ }
+
+ maxAPIError, usage := OpenaiSTTHandler(c, resp, info, "json")
+
+ require.Nil(t, maxAPIError)
+ require.NotNil(t, usage)
+ require.Equal(t, 100, usage.PromptTokens)
+ require.Equal(t, 5, usage.CompletionTokens)
+ require.Equal(t, dto.InputTokenDetails{TextTokens: 10, AudioTokens: 90}, usage.PromptTokensDetails)
+}
diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go
index ab5ea4f..8ec2ffa 100644
--- a/relay/channel/openai/relay_responses.go
+++ b/relay/channel/openai/relay_responses.go
@@ -19,6 +19,43 @@ import (
const maxPendingResponsesStreamEvents = 32
+func applyResponsesUsage(dst *dto.Usage, src *dto.Usage) {
+ if dst == nil || src == nil {
+ return
+ }
+
+ promptTokens := src.InputTokens
+ if promptTokens == 0 {
+ promptTokens = src.PromptTokens
+ }
+ completionTokens := src.OutputTokens
+ if completionTokens == 0 {
+ completionTokens = src.CompletionTokens
+ }
+
+ dst.PromptTokens = promptTokens
+ dst.CompletionTokens = completionTokens
+ dst.TotalTokens = src.TotalTokens
+ if dst.TotalTokens == 0 {
+ dst.TotalTokens = promptTokens + completionTokens
+ }
+ dst.InputTokens = src.InputTokens
+ dst.OutputTokens = src.OutputTokens
+ dst.PromptCacheHitTokens = src.PromptCacheHitTokens
+ dst.PromptTokensDetails = src.PromptTokensDetails
+ dst.CompletionTokenDetails = src.CompletionTokenDetails
+ if src.OutputTokensDetails != nil {
+ outputDetails := *src.OutputTokensDetails
+ dst.OutputTokensDetails = &outputDetails
+ dto.CopyOutputTokenDetails(&dst.CompletionTokenDetails, &outputDetails, false)
+ }
+ if src.InputTokensDetails != nil {
+ inputDetails := *src.InputTokensDetails
+ dst.InputTokensDetails = &inputDetails
+ dto.CopyInputTokenDetails(&dst.PromptTokensDetails, &inputDetails, true)
+ }
+}
+
func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.MaxAPIError) {
defer service.CloseResponseBodyGracefully(resp)
@@ -47,15 +84,7 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
// compute usage
usage := dto.Usage{}
- if responsesResponse.Usage != nil {
- usage.PromptTokens = responsesResponse.Usage.InputTokens
- usage.CompletionTokens = responsesResponse.Usage.OutputTokens
- usage.TotalTokens = responsesResponse.Usage.TotalTokens
- if responsesResponse.Usage.InputTokensDetails != nil {
- usage.PromptTokensDetails.CachedTokens = responsesResponse.Usage.InputTokensDetails.CachedTokens
- usage.PromptTokensDetails.CacheWriteTokens = responsesResponse.Usage.InputTokensDetails.CacheWriteTokens
- }
- }
+ applyResponsesUsage(&usage, responsesResponse.Usage)
emptyCompletion := isEmptyResponsesCompletion(&responsesResponse)
if emptyCompletion {
willRetry := shouldRetryEmptyCompletion(c, info)
@@ -135,21 +164,7 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
switch streamResponse.Type {
case "response.completed":
if streamResponse.Response != nil {
- if streamResponse.Response.Usage != nil {
- if streamResponse.Response.Usage.InputTokens != 0 {
- usage.PromptTokens = streamResponse.Response.Usage.InputTokens
- }
- if streamResponse.Response.Usage.OutputTokens != 0 {
- usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens
- }
- if streamResponse.Response.Usage.TotalTokens != 0 {
- usage.TotalTokens = streamResponse.Response.Usage.TotalTokens
- }
- if streamResponse.Response.Usage.InputTokensDetails != nil {
- usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens
- usage.PromptTokensDetails.CacheWriteTokens = streamResponse.Response.Usage.InputTokensDetails.CacheWriteTokens
- }
- }
+ applyResponsesUsage(usage, streamResponse.Response.Usage)
if streamResponse.Response.HasImageGenerationCall() {
c.Set("image_generation_call", true)
c.Set("image_generation_call_quality", streamResponse.Response.GetQuality())
diff --git a/relay/channel/openai/relay_responses_compact.go b/relay/channel/openai/relay_responses_compact.go
index b24a19c..1439a73 100644
--- a/relay/channel/openai/relay_responses_compact.go
+++ b/relay/channel/openai/relay_responses_compact.go
@@ -35,15 +35,7 @@ func OaiResponsesCompactionHandler(c *gin.Context, info *relaycommon.RelayInfo,
service.IOCopyBytesGracefully(c, resp, responseBody)
usage := dto.Usage{}
- if compactResp.Usage != nil {
- usage.PromptTokens = compactResp.Usage.InputTokens
- usage.CompletionTokens = compactResp.Usage.OutputTokens
- usage.TotalTokens = compactResp.Usage.TotalTokens
- if compactResp.Usage.InputTokensDetails != nil {
- usage.PromptTokensDetails.CachedTokens = compactResp.Usage.InputTokensDetails.CachedTokens
- usage.PromptTokensDetails.CacheWriteTokens = compactResp.Usage.InputTokensDetails.CacheWriteTokens
- }
- }
+ applyResponsesUsage(&usage, compactResp.Usage)
return &usage, nil
}
diff --git a/relay/channel/openai/responses_usage_test.go b/relay/channel/openai/responses_usage_test.go
new file mode 100644
index 0000000..3fe5fd1
--- /dev/null
+++ b/relay/channel/openai/responses_usage_test.go
@@ -0,0 +1,147 @@
+package openai
+
+import (
+ "bytes"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/MAX-API-Next/MAX-API/common"
+ "github.com/MAX-API-Next/MAX-API/dto"
+ relaycommon "github.com/MAX-API-Next/MAX-API/relay/common"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func detailedResponsesUsage() *dto.Usage {
+ return &dto.Usage{
+ InputTokens: 100,
+ OutputTokens: 50,
+ TotalTokens: 150,
+ InputTokensDetails: &dto.InputTokenDetails{
+ CachedTokens: 1,
+ CachedCreationTokens: 2,
+ CacheWriteTokens: 3,
+ TextTokens: 4,
+ ImageTokens: 5,
+ AudioTokens: 6,
+ },
+ OutputTokensDetails: &dto.OutputTokenDetails{
+ ReasoningTokens: 7,
+ TextTokens: 8,
+ ImageTokens: 9,
+ AudioTokens: 10,
+ },
+ }
+}
+
+func assertDetailedResponsesUsage(t *testing.T, usage *dto.Usage) {
+ t.Helper()
+ require.NotNil(t, usage)
+ require.Equal(t, 100, usage.PromptTokens)
+ require.Equal(t, 50, usage.CompletionTokens)
+ require.Equal(t, 150, usage.TotalTokens)
+ require.Equal(t, dto.InputTokenDetails{
+ CachedTokens: 1,
+ CachedCreationTokens: 2,
+ CacheWriteTokens: 3,
+ TextTokens: 4,
+ ImageTokens: 5,
+ AudioTokens: 6,
+ }, usage.PromptTokensDetails)
+ require.NotNil(t, usage.InputTokensDetails)
+ require.Equal(t, usage.PromptTokensDetails, *usage.InputTokensDetails)
+ require.Equal(t, dto.OutputTokenDetails{
+ ReasoningTokens: 7,
+ TextTokens: 8,
+ ImageTokens: 9,
+ AudioTokens: 10,
+ }, usage.CompletionTokenDetails)
+}
+
+func newResponsesUsageTestContext() (*gin.Context, *relaycommon.RelayInfo) {
+ gin.SetMode(gin.TestMode)
+ c, _ := gin.CreateTestContext(httptest.NewRecorder())
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
+ info := &relaycommon.RelayInfo{
+ DisablePing: true,
+ ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gpt-test"},
+ }
+ return c, info
+}
+
+func TestOaiResponsesHandlerPreservesDetailedUsage(t *testing.T) {
+ c, info := newResponsesUsageTestContext()
+ payload := dto.OpenAIResponsesResponse{
+ Output: []dto.ResponsesOutput{{
+ Type: "message",
+ Role: "assistant",
+ Content: []dto.ResponsesOutputContent{{
+ Type: "output_text",
+ Text: "ok",
+ }},
+ }},
+ Usage: detailedResponsesUsage(),
+ }
+ body, err := common.Marshal(payload)
+ require.NoError(t, err)
+
+ usage, maxAPIError := OaiResponsesHandler(c, info, &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(bytes.NewReader(body)),
+ })
+
+ require.Nil(t, maxAPIError)
+ assertDetailedResponsesUsage(t, usage)
+}
+
+func TestOaiResponsesStreamHandlerPreservesDetailedUsage(t *testing.T) {
+ c, info := newResponsesUsageTestContext()
+ completed := dto.ResponsesStreamResponse{
+ Type: "response.completed",
+ Response: &dto.OpenAIResponsesResponse{
+ Output: []dto.ResponsesOutput{{
+ Type: "message",
+ Role: "assistant",
+ Content: []dto.ResponsesOutputContent{{
+ Type: "output_text",
+ Text: "ok",
+ }},
+ }},
+ Usage: detailedResponsesUsage(),
+ },
+ }
+ completedData, err := common.Marshal(completed)
+ require.NoError(t, err)
+ body := append([]byte("data: "), completedData...)
+ body = append(body, []byte("\ndata: [DONE]\n")...)
+
+ usage, maxAPIError := OaiResponsesStreamHandler(c, info, &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(bytes.NewReader(body)),
+ })
+
+ require.Nil(t, maxAPIError)
+ assertDetailedResponsesUsage(t, usage)
+}
+
+func TestOaiResponsesCompactionHandlerPreservesDetailedUsage(t *testing.T) {
+ c, info := newResponsesUsageTestContext()
+ payload := dto.OpenAIResponsesCompactionResponse{
+ Usage: detailedResponsesUsage(),
+ }
+ body, err := common.Marshal(payload)
+ require.NoError(t, err)
+
+ usage, maxAPIError := OaiResponsesCompactionHandler(c, info, &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(bytes.NewReader(body)),
+ })
+
+ require.Nil(t, maxAPIError)
+ assertDetailedResponsesUsage(t, usage)
+}
diff --git a/relay/responses_handler.go b/relay/responses_handler.go
index 1e48a9b..5ce1ee0 100644
--- a/relay/responses_handler.go
+++ b/relay/responses_handler.go
@@ -157,10 +157,19 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (maxAPIError *
return nil
}
- if strings.HasPrefix(info.OriginModelName, "gpt-4o-audio") {
+ if shouldUseResponsesAudioBilling(info.OriginModelName, usageDto) {
service.PostAudioConsumeQuota(c, info, usageDto, "")
} else {
service.PostTextConsumeQuota(c, info, usageDto, nil)
}
return nil
}
+
+func shouldUseResponsesAudioBilling(modelName string, usage *dto.Usage) bool {
+ if usage != nil && (usage.PromptTokensDetails.AudioTokens > 0 || usage.CompletionTokenDetails.AudioTokens > 0) {
+ return true
+ }
+ return strings.HasPrefix(modelName, "gpt-4o-audio") ||
+ strings.HasPrefix(modelName, "gpt-4o-mini-audio") ||
+ strings.HasPrefix(modelName, "gpt-audio")
+}
diff --git a/relay/responses_handler_test.go b/relay/responses_handler_test.go
new file mode 100644
index 0000000..4640aa8
--- /dev/null
+++ b/relay/responses_handler_test.go
@@ -0,0 +1,36 @@
+package relay
+
+import (
+ "testing"
+
+ "github.com/MAX-API-Next/MAX-API/dto"
+ "github.com/stretchr/testify/require"
+)
+
+func TestShouldUseResponsesAudioBilling(t *testing.T) {
+ tests := []struct {
+ name string
+ modelName string
+ usage *dto.Usage
+ want bool
+ }{
+ {name: "legacy audio model", modelName: "gpt-4o-audio-preview", usage: &dto.Usage{}, want: true},
+ {name: "current audio model", modelName: "gpt-audio-1.5", usage: &dto.Usage{}, want: true},
+ {name: "mini audio model", modelName: "gpt-4o-mini-audio-preview", usage: &dto.Usage{}, want: true},
+ {
+ name: "usage details identify mapped audio model",
+ modelName: "custom-model-alias",
+ usage: &dto.Usage{PromptTokensDetails: dto.InputTokenDetails{
+ AudioTokens: 10,
+ }},
+ want: true,
+ },
+ {name: "text model", modelName: "gpt-5.4", usage: &dto.Usage{}, want: false},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ require.Equal(t, test.want, shouldUseResponsesAudioBilling(test.modelName, test.usage))
+ })
+ }
+}
diff --git a/service/billing_usage.go b/service/billing_usage.go
index f236120..0baf143 100644
--- a/service/billing_usage.go
+++ b/service/billing_usage.go
@@ -118,6 +118,7 @@ func usageFromOpenAIBillingUsage(billingUsage *dto.BillingUsage) *dto.Usage {
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
}
fillPromptTokenDetailsFromInputDetails(&usage.PromptTokensDetails, usage.InputTokensDetails)
+ dto.CopyOutputTokenDetails(&usage.CompletionTokenDetails, usage.OutputTokensDetails, false)
usage.UsageSemantic = dto.BillingUsageSemanticOpenAI
usage.UsageSource = billingUsage.Source
usage.BillingUsage = dto.CloneBillingUsage(billingUsage)
diff --git a/service/openaicompat/responses_compat_test.go b/service/openaicompat/responses_compat_test.go
index b14a44c..c959793 100644
--- a/service/openaicompat/responses_compat_test.go
+++ b/service/openaicompat/responses_compat_test.go
@@ -294,7 +294,7 @@ func TestResponsesResponseToChatCompletionsResponsePreservesUsageDetails(t *test
AudioTokens: 4,
ImageTokens: 5,
},
- CompletionTokenDetails: dto.OutputTokenDetails{
+ OutputTokensDetails: &dto.OutputTokenDetails{
ReasoningTokens: 6,
TextTokens: 7,
AudioTokens: 8,
diff --git a/service/openaicompat/responses_to_chat.go b/service/openaicompat/responses_to_chat.go
index 9f2986c..dd9d65d 100644
--- a/service/openaicompat/responses_to_chat.go
+++ b/service/openaicompat/responses_to_chat.go
@@ -137,6 +137,7 @@ func UsageFromResponsesUsage(src *dto.Usage) *dto.Usage {
}
dto.CopyInputTokenDetails(&usage.PromptTokensDetails, src.InputTokensDetails, true)
usage.CompletionTokenDetails = src.CompletionTokenDetails
+ dto.CopyOutputTokenDetails(&usage.CompletionTokenDetails, src.OutputTokensDetails, false)
usage.BillingUsage = dto.CloneBillingUsage(src.BillingUsage)
if usage.BillingUsage == nil {
usage.BillingUsage = dto.NewOpenAIResponsesBillingUsage(src)
diff --git a/service/quota.go b/service/quota.go
index bfc89c2..c32d330 100644
--- a/service/quota.go
+++ b/service/quota.go
@@ -158,12 +158,12 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelName string,
usage *dto.RealtimeUsage, extraContent string) {
+ var tieredUsedVars map[string]bool
+ if snap := relayInfo.TieredBillingSnapshot; snap != nil {
+ tieredUsedVars = billingexpr.UsedVars(snap.ExprString)
+ }
var tieredResult *billingexpr.TieredResult
- tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, billingexpr.TokenParams{
- P: float64(usage.InputTokens),
- C: float64(usage.OutputTokens),
- Len: float64(usage.InputTokens),
- })
+ tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, BuildRealtimeTieredTokenParams(usage, tieredUsedVars))
if tieredOk {
tieredResult = tieredRes
}
diff --git a/service/tiered_settle.go b/service/tiered_settle.go
index 6077952..e914dad 100644
--- a/service/tiered_settle.go
+++ b/service/tiered_settle.go
@@ -88,6 +88,20 @@ func BuildTieredTokenParams(usage *dto.Usage, isClaudeUsageSemantic bool, usedVa
}
}
+func BuildRealtimeTieredTokenParams(usage *dto.RealtimeUsage, usedVars map[string]bool) billingexpr.TokenParams {
+ if usage == nil {
+ return billingexpr.TokenParams{}
+ }
+ normalized := &dto.Usage{
+ PromptTokens: usage.InputTokens,
+ CompletionTokens: usage.OutputTokens,
+ TotalTokens: usage.TotalTokens,
+ PromptTokensDetails: usage.InputTokenDetails,
+ CompletionTokenDetails: usage.OutputTokenDetails,
+ }
+ return BuildTieredTokenParams(normalized, false, usedVars)
+}
+
// TryTieredSettle checks if the request uses tiered_expr billing and, if so,
// computes the actual quota using the frozen BillingSnapshot. Returns:
// - ok=true, quota, result when tiered billing applies
diff --git a/service/tiered_settle_test.go b/service/tiered_settle_test.go
index 7d47f62..83c4ba4 100644
--- a/service/tiered_settle_test.go
+++ b/service/tiered_settle_test.go
@@ -10,6 +10,7 @@ import (
"github.com/MAX-API-Next/MAX-API/pkg/billingexpr"
relaycommon "github.com/MAX-API-Next/MAX-API/relay/common"
"github.com/shopspring/decimal"
+ "github.com/stretchr/testify/require"
)
// Claude Sonnet-style tiered expression: standard vs long-context
@@ -560,6 +561,35 @@ func TestBuildTieredTokenParams_GPT_AudioOutputNoVar(t *testing.T) {
}
}
+func TestBuildRealtimeTieredTokenParamsIncludesAudioAndCache(t *testing.T) {
+ usage := &dto.RealtimeUsage{
+ InputTokens: 100,
+ OutputTokens: 40,
+ InputTokenDetails: dto.InputTokenDetails{
+ CachedTokens: 20,
+ TextTokens: 50,
+ AudioTokens: 30,
+ },
+ OutputTokenDetails: dto.OutputTokenDetails{
+ TextTokens: 30,
+ AudioTokens: 10,
+ },
+ }
+ expr := `tier("base", p + c + cr + ai + ao)`
+ params := BuildRealtimeTieredTokenParams(usage, billingexpr.UsedVars(expr))
+
+ require.Equal(t, float64(50), params.P)
+ require.Equal(t, float64(30), params.C)
+ require.Equal(t, float64(100), params.Len)
+ require.Equal(t, float64(20), params.CR)
+ require.Equal(t, float64(30), params.AI)
+ require.Equal(t, float64(10), params.AO)
+
+ params = BuildRealtimeTieredTokenParams(usage, billingexpr.UsedVars(`tier("base", p + c)`))
+ require.Equal(t, float64(100), params.P)
+ require.Equal(t, float64(40), params.C)
+}
+
func TestBuildTieredTokenParams_ParityWithRatio(t *testing.T) {
// GPT-5.4 prices: input=$2.5, output=$15, cacheRead=$0.25
// Ratio equivalents: modelRatio=1.25, completionRatio=6, cacheRatio=0.1
diff --git a/tools/jsonwrapcheck/allowlist.txt b/tools/jsonwrapcheck/allowlist.txt
index db17322..8dcbd1c 100644
--- a/tools/jsonwrapcheck/allowlist.txt
+++ b/tools/jsonwrapcheck/allowlist.txt
@@ -130,7 +130,6 @@ relay/channel/minimax/adaptor.go|*Adaptor.ConvertAudioRequest|Unmarshal|8b12a952
relay/channel/minimax/adaptor.go|*Adaptor.ConvertAudioRequest|Marshal|72d25474abe9f1d6088ac2f4b0c0d941244b899f1e21acdbe100a5287861e319
relay/channel/minimax/adaptor_test.go|TestConvertImageRequest|Marshal|f9f22be55e3cc256321948a6831e6d8f59f28dca147ab5d96d122bb05bb77b67
relay/channel/minimax/adaptor_test.go|TestConvertImageRequest|Unmarshal|fbd89e33afec938e40c3b365fc286bb279c1440145c68098c54ab715bd85b119
-relay/channel/minimax/tts.go|handleTTSResponse|Unmarshal|a21e188e4e9abf7a1f053fc091b08e965a26ee545d76cb79622dcfc91d23a9eb
relay/channel/mokaai/relay-mokaai.go|mokaEmbeddingHandler|Unmarshal|6c88b37cda6b69cb880852f57074d792626b82bd771acbab3a46754f80631684
relay/channel/ollama/relay-ollama.go|openAIChatToOllamaChat|Unmarshal|cdc0ef0788af1c25be7a997463473148c1a5660910f5d793f0405b3ce78d3343
relay/channel/ollama/relay-ollama.go|openAIChatToOllamaChat|Unmarshal|07a078b20e64cb7aab1e475cef9ea2651d4a2c279ae38cdec64dd0bf01ea3083
diff --git a/web/default/src/components/ai-elements/conversation.tsx b/web/default/src/components/ai-elements/conversation.tsx
index bfbdca7..9a8248f 100644
--- a/web/default/src/components/ai-elements/conversation.tsx
+++ b/web/default/src/components/ai-elements/conversation.tsx
@@ -98,6 +98,7 @@ export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
+ const { t } = useTranslation()
const { isAtBottom, scrollToBottom } = useStickToBottomContext()
const handleScrollToBottom = useCallback(() => {
@@ -115,7 +116,7 @@ export const ConversationScrollButton = ({
size='icon'
type='button'
variant='outline'
- aria-label='Scroll to bottom'
+ aria-label={t('Scroll to bottom')}
{...props}
>
diff --git a/web/default/src/components/ai-elements/tool.tsx b/web/default/src/components/ai-elements/tool.tsx
index 8e41e3f..c8a0195 100644
--- a/web/default/src/components/ai-elements/tool.tsx
+++ b/web/default/src/components/ai-elements/tool.tsx
@@ -135,7 +135,7 @@ export const ToolInput = ({ className, input, ...props }: ToolInputProps) => {
const { t } = useTranslation()
return (
-
+
{t('Parameters')}
@@ -172,7 +172,7 @@ export const ToolOutput = ({
return (
-
+
{errorText ? 'Error' : 'Result'}
void
+ showTrigger?: boolean
+}
+
+export function ConfigDrawer({
+ open,
+ onOpenChange,
+ showTrigger = true,
+}: ConfigDrawerProps) {
const { t } = useTranslation()
const { setOpen } = useSidebar()
const { resetDir } = useDirection()
@@ -81,20 +91,22 @@ export function ConfigDrawer() {
}
return (
-
-
- }
- >
-
-
+
+ {showTrigger ? (
+
+ }
+ >
+
+
+ ) : null}
{t('Theme Settings')}
@@ -133,6 +145,8 @@ function SectionTitle(props: {
onReset?: () => void
className?: string
}) {
+ const { t } = useTranslation()
+
return (
@@ -164,12 +178,13 @@ function RadioGroupItem(props: {
}
isTheme?: boolean
}) {
+ const { t } = useTranslation()
const isTheme = props.isTheme ?? false
return (
-
({
// Announce selection changes to screen readers
useEffect(() => {
if (selectedCount > 0) {
- const message = `${selectedCount} ${selectedEntityName} selected. Bulk actions toolbar is available.`
+ const message = t(
+ '{{count}} {{entity}} selected. Bulk actions toolbar is available.',
+ { count: selectedCount, entity: selectedEntityName }
+ )
// eslint-disable-next-line react-hooks/set-state-in-effect
setAnnouncement(message)
@@ -72,7 +75,7 @@ export function DataTableBulkActions({
const timer = setTimeout(() => setAnnouncement(''), 3000)
return () => clearTimeout(timer)
}
- }, [selectedCount, selectedEntityName])
+ }, [selectedCount, selectedEntityName, t])
const handleClearSelection = () => {
table.resetRowSelection()
@@ -159,13 +162,16 @@ export function DataTableBulkActions({
@@ -174,7 +180,7 @@ export function DataTableBulkActions
({
'p-2 shadow-xl',
'rounded-xl border',
'bg-background/95 supports-[backdrop-filter]:bg-background/60 backdrop-blur-lg',
- 'flex items-center gap-x-2'
+ 'flex max-w-full items-center gap-x-2 overflow-x-auto'
)}
>
@@ -211,7 +217,7 @@ export function DataTableBulkActions({
{selectedCount}
{' '}
diff --git a/web/default/src/components/data-table/data-table-page.tsx b/web/default/src/components/data-table/data-table-page.tsx
index 27a718c..075f16a 100644
--- a/web/default/src/components/data-table/data-table-page.tsx
+++ b/web/default/src/components/data-table/data-table-page.tsx
@@ -23,7 +23,6 @@ import {
type Row,
type Table as TanstackTable,
} from '@tanstack/react-table'
-import { useMediaQuery } from '@/hooks'
import { cn } from '@/lib/utils'
import {
Table,
@@ -105,7 +104,8 @@ export type DataTablePageProps = {
/**
* Bulk action bar — typically a wrapped {@link DataTableBulkActions} component.
- * Rendered only on desktop (mobile selection is uncommon).
+ * Rendered whenever the consumer provides it. Mobile lists expose the same
+ * selection cells as the desktop table.
*/
bulkActions?: React.ReactNode
@@ -216,7 +216,7 @@ export type DataTablePageProps = {
* `toolbar` / `mobile` / `renderRow` slots instead of the `*Props` variants.
*/
export function DataTablePage(props: DataTablePageProps) {
- const isMobile = useMediaQuery('(max-width: 640px)')
+ const [containerRef, isMobile] = useNarrowTableContainer()
const showMobile = isMobile && !props.hideMobile
const toolbarNode = renderToolbar(props)
@@ -225,16 +225,17 @@ export function DataTablePage(props: DataTablePageProps) {
return (
<>
-
+
{toolbarNode}
{mobileNode}
{desktopNode}
{props.afterTable}
- {/* Bulk actions are typically a fixed-position toolbar; let the consumer
- handle its own visibility, we just gate it to non-mobile. */}
- {!showMobile && props.bulkActions}
+ {props.bulkActions}
{props.showPagination !== false &&
(props.paginationInFooter !== false ? (
@@ -250,6 +251,31 @@ export function DataTablePage
(props: DataTablePageProps) {
)
}
+function useNarrowTableContainer() {
+ const containerRef = React.useRef(null)
+ const [isNarrow, setIsNarrow] = React.useState(false)
+
+ React.useLayoutEffect(() => {
+ const container = containerRef.current
+ if (!container) return
+
+ const update = () =>
+ setIsNarrow(container.getBoundingClientRect().width <= 640)
+ update()
+
+ if (typeof ResizeObserver === 'undefined') {
+ window.addEventListener('resize', update)
+ return () => window.removeEventListener('resize', update)
+ }
+
+ const observer = new ResizeObserver(update)
+ observer.observe(container)
+ return () => observer.disconnect()
+ }, [])
+
+ return [containerRef, isNarrow] as const
+}
+
function renderToolbar(
props: DataTablePageProps
): React.ReactNode {
@@ -316,6 +342,7 @@ function renderDesktop(
({
className={className}
>
{row.getVisibleCells().map((cell) => (
-
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
))}
diff --git a/web/default/src/components/data-table/mobile-card-list.tsx b/web/default/src/components/data-table/mobile-card-list.tsx
index 8e204e1..d856f7b 100644
--- a/web/default/src/components/data-table/mobile-card-list.tsx
+++ b/web/default/src/components/data-table/mobile-card-list.tsx
@@ -124,9 +124,9 @@ function FallbackListSkeleton() {
* [Actions ⋯]
*/
function CompactRow({ row }: { row: Row }) {
- const allCells = row
- .getVisibleCells()
- .filter((cell) => cell.column.id !== 'select')
+ const visibleCells = row.getVisibleCells()
+ const selectCell = visibleCells.find((cell) => cell.column.id === 'select')
+ const allCells = visibleCells.filter((cell) => cell.column.id !== 'select')
const titleCell = allCells.find((c) => getCellMeta(c)?.mobileTitle)
const badgeCell = allCells.find((c) => getCellMeta(c)?.mobileBadge)
@@ -144,13 +144,23 @@ function CompactRow({ row }: { row: Row }) {
<>
{/* Row 1: Title + Badge */}
- {titleCell && (
-
- {renderCellContent(titleCell)}
+ {(selectCell || titleCell) && (
+
+ {selectCell && (
+
{renderCellContent(selectCell)}
+ )}
+ {titleCell && (
+
+ {renderCellContent(titleCell)}
+
+ )}
)}
- {badgeCell && (
-
{renderCellContent(badgeCell)}
+ {(badgeCell || actionsCell) && (
+
+ {badgeCell && renderCellContent(badgeCell)}
+ {actionsCell && renderCellContent(actionsCell)}
+
)}
@@ -174,13 +184,6 @@ function CompactRow
({ row }: { row: Row }) {
})}
)}
-
- {/* Actions */}
- {actionsCell && (
-
- {renderCellContent(actionsCell)}
-
- )}
>
)
}
@@ -190,9 +193,9 @@ function CompactRow({ row }: { row: Row }) {
* mobileTitle/mobileBadge. Still respects mobileHidden.
*/
function FallbackRow({ row }: { row: Row }) {
- const allCells = row
- .getVisibleCells()
- .filter((cell) => cell.column.id !== 'select')
+ const visibleCells = row.getVisibleCells()
+ const selectCell = visibleCells.find((cell) => cell.column.id === 'select')
+ const allCells = visibleCells.filter((cell) => cell.column.id !== 'select')
const actionsCell = allCells.find((c) => c.column.id === 'actions')
const contentCells = allCells.filter(
@@ -201,6 +204,16 @@ function FallbackRow({ row }: { row: Row }) {
return (
<>
+ {(selectCell || actionsCell) && (
+
+
+ {selectCell ? renderCellContent(selectCell) : null}
+
+ {actionsCell && (
+
{renderCellContent(actionsCell)}
+ )}
+
+ )}
{contentCells.map((cell) => {
const label = getCellLabel(cell)
const content = renderCellContent(cell)
@@ -227,11 +240,6 @@ function FallbackRow({ row }: { row: Row }) {
)
})}
- {actionsCell && (
-
- {renderCellContent(actionsCell)}
-
- )}
>
)
}
@@ -303,7 +311,7 @@ export function MobileCardList(props: MobileCardListProps) {
key={key}
data-state={row.getIsSelected() ? 'selected' : undefined}
className={cn(
- 'bg-card px-3 py-2.5 transition-colors data-[state=selected]:bg-muted',
+ 'bg-card data-[state=selected]:bg-muted px-3 py-2.5 transition-colors',
getRowClassName?.(row)
)}
>
diff --git a/web/default/src/components/data-table/pagination.tsx b/web/default/src/components/data-table/pagination.tsx
index 23f546a..821f544 100644
--- a/web/default/src/components/data-table/pagination.tsx
+++ b/web/default/src/components/data-table/pagination.tsx
@@ -132,7 +132,9 @@ export function DataTablePagination({
className='h-8 min-w-8 px-2'
onClick={() => table.setPageIndex((pageNumber as number) - 1)}
>
- Go to page {pageNumber}
+
+ {t('Go to page {{page}}', { page: pageNumber })}
+
{pageNumber}
)}
diff --git a/web/default/src/components/datetime-picker.tsx b/web/default/src/components/datetime-picker.tsx
index 8231aaf..0b05839 100644
--- a/web/default/src/components/datetime-picker.tsx
+++ b/web/default/src/components/datetime-picker.tsx
@@ -63,6 +63,8 @@ export function DateTimePicker({
const [time, setTime] = React.useState('00:00')
React.useEffect(() => {
+ // Keep the editable draft synchronized with the controlled value.
+ // eslint-disable-next-line react-hooks/set-state-in-effect
setDate(value)
setMonth(value)
if (value) {
@@ -151,7 +153,7 @@ export function DateTimePicker({
size='icon'
onClick={handleClear}
className='shrink-0'
- aria-label='Clear'
+ aria-label={t('Clear')}
>
✕
diff --git a/web/default/src/components/json-editor.tsx b/web/default/src/components/json-editor.tsx
index fc1d3b6..2129cb8 100644
--- a/web/default/src/components/json-editor.tsx
+++ b/web/default/src/components/json-editor.tsx
@@ -89,6 +89,8 @@ export function JsonEditor({
// Parse JSON to rows when value changes externally
useEffect(() => {
if (value !== jsonValue) {
+ // External updates replace the local visual/JSON draft together.
+ // eslint-disable-next-line react-hooks/set-state-in-effect
setJsonValue(value)
parseJsonToRows(value)
}
@@ -255,7 +257,7 @@ export function JsonEditor({
type='button'
variant='ghost'
size='icon'
- aria-label='Delete row'
+ aria-label={t('Delete row')}
onClick={() => handleDeleteRow(row.id)}
disabled={disabled}
className='h-10 w-10'
diff --git a/web/default/src/components/language-switcher.tsx b/web/default/src/components/language-switcher.tsx
index cc7a53c..d87b1bf 100644
--- a/web/default/src/components/language-switcher.tsx
+++ b/web/default/src/components/language-switcher.tsx
@@ -30,12 +30,13 @@ import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
+ DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
-export function LanguageSwitcher() {
- const { i18n, t } = useTranslation()
+export function LanguageMenuGroup() {
+ const { i18n } = useTranslation()
const user = useAuthStore((s) => s.auth.user)
const currentLanguage = normalizeInterfaceLanguage(i18n.language)
@@ -53,30 +54,37 @@ export function LanguageSwitcher() {
[i18n, user]
)
+ return (
+
+ {INTERFACE_LANGUAGE_OPTIONS.map((lang) => (
+ handleChangeLanguage(lang.code)}
+ >
+ {lang.label}
+
+
+ ))}
+
+ )
+}
+
+export function LanguageSwitcher() {
+ const { t } = useTranslation()
+
return (
}
+ render={}
>
-
+
{t('Change language')}
- {INTERFACE_LANGUAGE_OPTIONS.map((lang) => (
- handleChangeLanguage(lang.code)}
- >
- {lang.label}
-
-
- ))}
+
)
diff --git a/web/default/src/components/layout/components/app-header.tsx b/web/default/src/components/layout/components/app-header.tsx
index 75b8230..8343e59 100644
--- a/web/default/src/components/layout/components/app-header.tsx
+++ b/web/default/src/components/layout/components/app-header.tsx
@@ -26,6 +26,7 @@ import { Search } from '@/components/search'
import { defaultTopNavLinks } from '../config/top-nav.config'
import { type TopNavLink } from '../types'
import { Header } from './header'
+import { HeaderToolsMenu } from './header-tools-menu'
import { SystemBrand } from './system-brand'
import { TopNav } from './top-nav'
@@ -120,8 +121,8 @@ export function AppHeader({
{rightContent ?? (
- {showTopNav && (
-
+ {showTopNav && links.length > 0 && (
+
)}
@@ -139,8 +140,13 @@ export function AppHeader({
loading={notifications.loading}
/>
)}
-
- {showConfigDrawer &&
}
+
+
+ {showConfigDrawer && }
+
+
+
+
{showProfileDropdown &&
}
)}
diff --git a/web/default/src/components/layout/components/footer.tsx b/web/default/src/components/layout/components/footer.tsx
index fd289b5..eae25bf 100644
--- a/web/default/src/components/layout/components/footer.tsx
+++ b/web/default/src/components/layout/components/footer.tsx
@@ -260,13 +260,13 @@ export function Footer(props: FooterProps) {
alt={displayName}
className='size-7 rounded-lg object-contain'
/>
-
+
{displayName}
-
+
{t('AI Models · Agents governance')}
@@ -280,7 +280,7 @@ export function Footer(props: FooterProps) {
{displayColumns.map((column, index) => (
-
+
{t(column.title)}
diff --git a/web/default/src/components/layout/components/header-tools-menu.tsx b/web/default/src/components/layout/components/header-tools-menu.tsx
new file mode 100644
index 0000000..967b093
--- /dev/null
+++ b/web/default/src/components/layout/components/header-tools-menu.tsx
@@ -0,0 +1,157 @@
+/*
+Copyright (C) 2023-2026 MAX-API-Next
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues
+*/
+import { useState } from 'react'
+import { Ellipsis, Languages, Palette, SunMoon } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { IconGithub } from '@/assets/brand-icons'
+import { Button } from '@/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { ConfigDrawer } from '@/components/config-drawer'
+import { LanguageMenuGroup } from '@/components/language-switcher'
+import { ThemeMenuGroup } from '@/components/theme-switch'
+
+type HeaderToolsMenuProps = {
+ showConfigDrawer: boolean
+}
+
+export function HeaderToolsMenu({ showConfigDrawer }: HeaderToolsMenuProps) {
+ const { t } = useTranslation()
+ const [configOpen, setConfigOpen] = useState(false)
+
+ return (
+ <>
+
+
+ }
+ >
+
+ {t('Open menu')}
+
+
+
+
+
+
+ {t('Change language')}
+
+
+
+
+
+ {showConfigDrawer ? (
+ setConfigOpen(true)}>
+
+ {t('Theme Settings')}
+
+ ) : null}
+
+
+
+
+ {showConfigDrawer ? (
+
+ ) : null}
+ >
+ )
+}
+
+type PublicHeaderToolsMenuProps = {
+ githubUrl: string
+ showLanguageSwitcher: boolean
+ showThemeSwitch: boolean
+}
+
+export function PublicHeaderToolsMenu({
+ githubUrl,
+ showLanguageSwitcher,
+ showThemeSwitch,
+}: PublicHeaderToolsMenuProps) {
+ const { t } = useTranslation()
+
+ return (
+
+
+ }
+ >
+
+ {t('Open menu')}
+
+
+
+
+ }
+ >
+
+ GitHub
+
+ {showLanguageSwitcher ? (
+
+
+
+ {t('Change language')}
+
+
+
+
+
+ ) : null}
+ {showThemeSwitch ? (
+
+
+
+ {t('Theme')}
+
+
+
+
+
+ ) : null}
+
+
+
+ )
+}
diff --git a/web/default/src/components/layout/components/nav-group.tsx b/web/default/src/components/layout/components/nav-group.tsx
index d9f318f..43dda6a 100644
--- a/web/default/src/components/layout/components/nav-group.tsx
+++ b/web/default/src/components/layout/components/nav-group.tsx
@@ -64,7 +64,7 @@ export function NavGroup({ title, items }: NavGroupProps) {
return (
-
+
{title}
diff --git a/web/default/src/components/layout/components/public-header.tsx b/web/default/src/components/layout/components/public-header.tsx
index f66ef77..5f758fd 100644
--- a/web/default/src/components/layout/components/public-header.tsx
+++ b/web/default/src/components/layout/components/public-header.tsx
@@ -42,9 +42,11 @@ import { ThemeSwitch } from '@/components/theme-switch'
import { defaultTopNavLinks } from '../config/top-nav.config'
import type { TopNavLink } from '../types'
import { HeaderLogo } from './header-logo'
+import { PublicHeaderToolsMenu } from './header-tools-menu'
const AUTH_PROMPT_SECONDS = 5
const MAX_API_GITHUB_URL = 'https://github.com/MAX-API-Next/MAX-API'
+const DESKTOP_NAVIGATION_MEDIA_QUERY = '(min-width: 1024px)'
type AuthPromptTarget = {
title: string
@@ -119,6 +121,17 @@ export function PublicHeader(props: PublicHeaderProps) {
}
}, [mobileOpen])
+ useEffect(() => {
+ const mediaQuery = window.matchMedia(DESKTOP_NAVIGATION_MEDIA_QUERY)
+ const closeMobileMenuOnDesktop = (event: MediaQueryListEvent) => {
+ if (event.matches) setMobileOpen(false)
+ }
+
+ mediaQuery.addEventListener('change', closeMobileMenuOnDesktop)
+ return () =>
+ mediaQuery.removeEventListener('change', closeMobileMenuOnDesktop)
+ }, [])
+
useEffect(() => {
if (!authPromptTarget) return
@@ -191,7 +204,7 @@ export function PublicHeader(props: PublicHeaderProps) {
-
+
{loading ? : displaySiteName}
{/* Desktop nav */}
-
+
{links.map((link, i) => {
const isActive = pathname === link.href
if (link.external) {
@@ -308,9 +321,25 @@ export function PublicHeader(props: PublicHeaderProps) {
{/* Mobile: compact actions + hamburger */}
-
-
- {showThemeSwitch &&
}
+
+ {showNotifications && (
+
+ )}
+
{showAuthButtons && !loading && isAuthenticated && (
)}
@@ -351,7 +380,7 @@ export function PublicHeader(props: PublicHeaderProps) {
{/* Mobile full-screen overlay */}
{
const isActive = pathname === link.href
const linkClassName = cn(
- 'flex items-center gap-3 py-3 text-base font-medium tracking-tight transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)]',
+ 'flex items-center gap-3 py-3 text-base font-medium transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)]',
mobileOpen
? 'translate-y-0 opacity-100'
: 'translate-y-4 opacity-0',
diff --git a/web/default/src/components/layout/components/section-page-layout.tsx b/web/default/src/components/layout/components/section-page-layout.tsx
index 7e761bd..a6da60c 100644
--- a/web/default/src/components/layout/components/section-page-layout.tsx
+++ b/web/default/src/components/layout/components/section-page-layout.tsx
@@ -83,7 +83,7 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
)}
-
+
{title}
diff --git a/web/default/src/components/layout/components/top-nav.tsx b/web/default/src/components/layout/components/top-nav.tsx
index e4ca39c..b725d42 100644
--- a/web/default/src/components/layout/components/top-nav.tsx
+++ b/web/default/src/components/layout/components/top-nav.tsx
@@ -19,11 +19,13 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API
import { useMemo } from 'react'
import { Link } from '@tanstack/react-router'
import { Menu } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
+ DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
@@ -38,6 +40,7 @@ type TopNavProps = React.HTMLAttributes
& {
* 在大屏幕显示水平导航,在小屏幕显示下拉菜单
*/
export function TopNav({ className, links, ...props }: TopNavProps) {
+ const { t } = useTranslation()
// 规范化链接,确保所有可选属性都有默认值
const normalizedLinks = useMemo(
() =>
@@ -56,38 +59,48 @@ export function TopNav({ className, links, ...props }: TopNavProps) {
}
+ render={
+
+ }
>
+ {t('Toggle navigation menu')}
- {normalizedLinks.map(
- ({ title, href, isActive, disabled, external }) => (
-
- {title}
-
- ) : (
-
- {title}
-
- )
- }
- >
- )
- )}
+
+ {normalizedLinks.map(
+ ({ title, href, isActive, disabled, external }) => (
+
+ {title}
+
+ ) : (
+
+ {title}
+
+ )
+ }
+ />
+ )
+ )}
+
diff --git a/web/default/src/components/password-input.tsx b/web/default/src/components/password-input.tsx
index e879a4f..ba92b08 100644
--- a/web/default/src/components/password-input.tsx
+++ b/web/default/src/components/password-input.tsx
@@ -18,6 +18,7 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API
*/
import * as React from 'react'
import { Eye, EyeOff } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Button } from './ui/button'
import { Input } from './ui/input'
@@ -35,6 +36,7 @@ export function PasswordInput({
ref,
...props
}: PasswordInputProps) {
+ const { t } = useTranslation()
const [showPassword, setShowPassword] = React.useState(false)
return (
@@ -52,7 +54,7 @@ export function PasswordInput({
disabled={disabled}
className='text-muted-foreground absolute end-1 top-1/2 h-6 w-6 -translate-y-1/2 rounded-md'
onClick={() => setShowPassword((prev) => !prev)}
- aria-label='Toggle password visibility'
+ aria-label={t('Toggle password visibility')}
>
{showPassword ? (
diff --git a/web/default/src/components/tag-input.tsx b/web/default/src/components/tag-input.tsx
index 43792a9..c4257ec 100644
--- a/web/default/src/components/tag-input.tsx
+++ b/web/default/src/components/tag-input.tsx
@@ -86,7 +86,7 @@ export function TagInput({
type='button'
variant='ghost'
size='icon-sm'
- aria-label='Remove tag'
+ aria-label={t('Remove tag')}
onClick={(e) => {
e.stopPropagation()
removeTag(tag)
diff --git a/web/default/src/components/theme-switch.tsx b/web/default/src/components/theme-switch.tsx
index 7de9119..fb1ada9 100644
--- a/web/default/src/components/theme-switch.tsx
+++ b/web/default/src/components/theme-switch.tsx
@@ -25,14 +25,46 @@ import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
+ DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
-export function ThemeSwitch() {
+export function ThemeMenuGroup() {
const { t } = useTranslation()
const { theme, setTheme } = useTheme()
+ return (
+
+ setTheme('light')}>
+ {t('Light')}
+
+
+ setTheme('dark')}>
+ {t('Dark')}
+
+
+ setTheme('system')}>
+ {t('System')}
+
+
+
+ )
+}
+
+export function ThemeSwitch() {
+ const { t } = useTranslation()
+ const { theme } = useTheme()
+
/* Update theme-color meta tag
* when theme is updated */
useEffect(() => {
@@ -51,27 +83,7 @@ export function ThemeSwitch() {
{t('Toggle theme')}
- setTheme('light')}>
- {t('Light')}{' '}
-
-
- setTheme('dark')}>
- {t('Dark')}
-
-
- setTheme('system')}>
- {t('System')}
-
-
+
)
diff --git a/web/default/src/components/ui/breadcrumb.tsx b/web/default/src/components/ui/breadcrumb.tsx
index 439b40d..16ed42f 100644
--- a/web/default/src/components/ui/breadcrumb.tsx
+++ b/web/default/src/components/ui/breadcrumb.tsx
@@ -24,12 +24,15 @@ import {
MoreHorizontalCircle01Icon,
} from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
+import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
function Breadcrumb({ className, ...props }: React.ComponentProps<'nav'>) {
+ const { t } = useTranslation()
+
return (