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} >