diff --git a/README.en.md b/README.en.md index b5da0c8..994ae13 100644 --- a/README.en.md +++ b/README.en.md @@ -1,4 +1,4 @@ -
+
![max-api](/web/default/public/logo.png) @@ -98,7 +98,7 @@ MAX API brings AI model and AI Agent execution into a configurable, observable, | Upstream channels | Provider channels, weights, groups, status, keys, Base URL, path overrides, capability matrix, configuration validation, model discovery, and failure retry | Reduce risks from single-provider outages, price changes, rate limits, misconfiguration, or API changes | | Protocol formats | OpenAI Compatible, Responses, Claude Messages, Gemini, Realtime, generic video task protocol, and protocol conversion | Let applications face stable interfaces instead of directly handling provider-specific protocol differences | | Agent tokens | API keys, token groups, model scopes, quota limits, expiration, and access control | Assign independent, revocable, and quota-limited credentials to Agents, workflows, and tool calls | -| Usage and cost | Request logs, usage statistics, expression billing, tiered billing JSON, task rate-cards, pre-charge, and failure refund | Attribute model costs to users, tokens, models, channels, and groups | +| Usage and cost | Request logs, usage statistics, expression billing, tiered billing JSON, task rate-cards, pre-charge, and failure refund | Attribute model costs to users, groups, tokens, models, channels, and nodes | | Asynchronous tasks | Video task submission, polling, status mapping, result proxying, and task billing | Govern long-running, multi-state, multi-provider multimodal tasks uniformly | | Audit and security | Admin-side log audit, error logs, request limits, streaming timeout, login, and permission control | Provide controlled audit boundaries in private deployment and compliance scenarios; sensitive content audit is managed under Security & Limits | | Organization operations | Users, groups, balance, payment, system settings, dashboards, and operations configuration | Support continuous operations for teams, research institutions, enterprises, or community services | @@ -163,8 +163,8 @@ docker compose up -d | Channel capability matrix | Channel editing view shows capability status for `chat/completions`, `responses`, `Claude Messages`, `Gemini native`, `embeddings`, `images`, `audio`, `rerank`, `video tasks`, `model discovery`, and more | | Channel configuration validation | Checks API Key, model list, Base URL, extra configuration, JSON objects, Vertex AI region, Codex credentials, model discovery capability, and video task path placeholders before saving | | Multimodal model governance | Supports chat, images, video, audio, embeddings, rerank, realtime conversation, and submission/polling/status mapping/result proxying for asynchronous video tasks | -| Generic video task protocol | Allows task submission, query, progress, status mapping, error message, and result URL paths from different video upstreams to be configured uniformly in a channel; default paths are `/v1/videos/create` and `/v1/videos/{task_id}` | -| Protocol conversion and custom upstreams | Supports conversion and adaptation among OpenAI Compatible, Claude Messages, Gemini, and other formats, as well as legally authorized upstream URLs, path overrides, and task protocol parsing rules | +| Generic video task protocol | Allows task submission, query, progress, status mapping, error message, and result URL paths from different video upstreams to be configured uniformly in a channel; request-body passthrough and rewrites use the existing channel settings; default paths are `/v1/videos/create` and `/v1/videos/{task_id}` | +| Protocol conversion and custom upstreams | Supports conversion and adaptation among OpenAI Compatible, Responses, Chat Completions, Claude Messages, Gemini, and other formats, as well as legally authorized upstream URLs, path overrides, and task protocol parsing rules | ### AI Agent Governance / AgentOps @@ -173,7 +173,7 @@ docker compose up -d | Agent token isolation | Create independent API keys for Agents, workflows, plugins, tool calls, or users, with model scope, quota, expiration, and group settings | | Model access control | Control which models an Agent can call, which channels it can use, and how much quota it can consume through users, tokens, groups, model restrictions, and channel policies | | Call-chain observability | Provides request logs, usage statistics, channel hits, latency, errors, and retry information to diagnose Agent failures, cost anomalies, and upstream instability | -| Cost attribution | Tracks cost and usage by model, channel, user, group, and token, making it easier to calculate costs for different Agents or business lines | +| Cost attribution | Tracks cost and usage by model, channel, user, group, token, and node, making it easier to calculate costs for different Agents, business lines, or deployment nodes | | Admin audit | Private deployments can enable admin-side log audit according to compliance requirements; normal user log APIs filter admin-only audit fields | | Operations dashboard | Provides admin-facing analytics, user management, channel management, system settings, and operations analysis | @@ -275,17 +275,17 @@ flowchart LR | Type | Description | |------|------| | OpenAI-Compatible | Compatible APIs such as Chat Completions, Embeddings, Images, and Audio, usable as a general model entry point for most applications and Agents | -| OpenAI Responses | Responses-format requests, relay, and compatibility support for gradually adopting newer OpenAI application protocols | +| OpenAI Responses | Responses-format requests, relay, and Responses ↔ Chat Completions compatibility conversion for gradually adopting newer OpenAI application protocols | | Claude Messages | Conversion between Claude Messages and OpenAI-compatible formats to reduce multi-protocol maintenance on the application side | -| Google Gemini | Gemini chat, text, and partial conversion capabilities | +| Google Gemini | Gemini chat, text, and `/v1/responses` compatibility conversion | | Azure OpenAI | Azure OpenAI and Realtime related APIs | | AWS Bedrock | Bedrock Runtime model access | | Upstream platforms and application ecosystem | AWS, Azure, Vertex, Ollama, Codex, Dify, RAGFlow, Kling, Seedance, and similar platforms or applications can be governed according to channel capabilities | | Domestic models and platforms | Built-in adapters or compatible access for DeepSeek, Qwen / Alibaba Cloud Model Studio, Zhipu GLM, Kimi, Doubao / Volcano Engine, Tencent Hunyuan, Baidu ERNIE / Qianfan, iFlytek Spark, MiniMax, 01.AI, SiliconFlow, and more | | `rerank` | Reranking models such as Cohere and Jina for retrieval augmentation and Agent retrieval chains | | Midjourney / Suno / Dify | Adapters for image, music, workflow, and related services | -| Video task APIs | Supports submission, polling, status mapping, result proxying, and parameterized billing for video generation tasks such as `/v1/videos/create` and `/v1/videos/{task_id}` | -| Custom upstreams | Supports legally authorized upstream URLs, protocol adaptation rules, path overrides, status mapping, error message paths, and task result parsing | +| Video task APIs | Supports submission, body passthrough or parameter overrides, polling, status mapping, result proxying, and parameterized billing for video generation tasks such as `/v1/videos/create` and `/v1/videos/{task_id}` | +| Custom upstreams | Supports legally authorized upstream URLs, protocol adaptation rules, Responses / Chat conversion, path overrides, status mapping, error message paths, and task result parsing | ### Main Supported Interfaces @@ -365,10 +365,11 @@ Validation covers common issues including: Video model providers often differ in paths, task IDs, status fields, progress fields, error fields, and result URL fields. MAX API extends the task protocol capability from a single-model feature into a generic video task protocol for OpenAI, Ali, Gemini, MiniMax, Vertex AI, VolcEngine, Kling, Jimeng, Vidu, Doubao Video, Sora, and other video task channels. -Two configuration levels are supported: +Supported configuration levels: - **Path override only**: configure only `submit_path` and `query_path`; the system still uses the official response parser of the corresponding channel. This is suitable for compatible channels that only change upstream paths. - **Full protocol parsing**: set `task_protocol = "generic_video_task"` and configure paths for task ID, status, progress, result URL, error message, and status mapping. This is suitable for non-standard video task responses. +- **Request-body handling**: the generic video task protocol no longer defines a separate request-body generation mode. Use the channel-level `Pass Through Body` setting to forward client JSON as-is, and use the existing `Param Override` feature for field rewrites, defaults, or header coordination. Default task paths: @@ -409,6 +410,8 @@ Model billing in system settings supports two unified JSON maintenance entries: - **Tiered billing JSON**: maintain `{ enabled, expr }` configuration for multiple models in one `Tiered billing JSON` window; saving updates `billing_mode` and `billing_expr` together. - **Task rate-card JSON**: maintain asynchronous task billing rules through `task_billing_setting.rate_cards`, with `vendor` partitions for video models such as Sora, Veo, Seedance, and Kling. +Video models such as Seedance 2.0 can use request parameters such as resolution and video input in multiplier or rate-card billing. When using passthrough or parameter overrides, keep the final upstream request fields aligned with the billing fields. + Example structure: ```json @@ -490,7 +493,7 @@ A task rate-card can match prices by request parameters: | `MAX_REQUEST_BODY_MB` | Maximum request body size after decompression; returns `413` when exceeded | `32` | | `AZURE_DEFAULT_API_VERSION` | Default Azure API version | `2025-04-01-preview` | | `ERROR_LOG_ENABLED` | Error log switch | `false` | -| `NODE_NAME` | Node name for multi-node log identification | - | +| `NODE_NAME` | Node name for multi-node log identification and asynchronous task settlement attribution | - | | `PYROSCOPE_URL` | Pyroscope service URL | - | | `PYROSCOPE_APP_NAME` | Pyroscope application name | `max-api` | | `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope Basic Auth username | - | @@ -553,7 +556,7 @@ docker build -t cscitechtop/max-api:latest . > [!WARNING] > - All nodes must use the same `SESSION_SECRET`; otherwise login state will be inconsistent across nodes. > - If shared Redis is used, all nodes must use the same `CRYPTO_SECRET`; otherwise encrypted data cannot be decrypted. -> - Set `NODE_NAME` for each node to locate source nodes in logs and audit information. +> - Set a stable `NODE_NAME` for each node to locate source nodes in logs, audit information, and asynchronous task settlement. > - Production environments should use an external database, external Redis, HTTPS reverse proxy, and reliable backup strategy. --- diff --git a/README.fr.md b/README.fr.md index fda6c0f..1d60fc6 100644 --- a/README.fr.md +++ b/README.fr.md @@ -1,4 +1,4 @@ -
+
![max-api](/web/default/public/logo.png) @@ -9,7 +9,7 @@

简体中文 | 繁體中文 | - English | + English | Français | 日本語

@@ -90,7 +90,7 @@ MAX API place l'exécution des modèles IA et des AI Agents dans un cadre config | Canaux amont | Fournisseurs, poids, groupes, état, clés, Base URL, overrides de chemins, matrice de capacités, validation, découverte et retry | Réduire les risques d'indisponibilité, hausse de prix, limites, erreurs de configuration ou changements d'API | | Formats de protocole | OpenAI Compatible, Responses, Claude Messages, Gemini, Realtime, protocole vidéo générique et conversions | Donner aux applications des interfaces stables plutôt que les exposer aux différences de fournisseurs | | Jetons Agent | API Key, groupes de jetons, périmètre de modèles, quotas, expiration et contrôle d'accès | Fournir aux Agents et workflows des identifiants indépendants, révocables et limitables | -| Usage et coûts | Journaux, statistiques, facturation par expression, JSON par paliers, rate-card de tâches, préfacturation et remboursements | Attribuer les coûts par utilisateur, jeton, modèle, canal et groupe | +| Usage et coûts | Journaux, statistiques, facturation par expression, JSON par paliers, rate-card de tâches, préfacturation et remboursements | Attribuer les coûts par utilisateur, groupe, jeton, modèle, canal et nœud | | Tâches asynchrones | Soumission, polling, mapping d'état, proxy de résultat et facturation de tâches vidéo | Gouverner les tâches multimodales longues, multi-états et multi-fournisseurs | | Audit et sécurité | Audit admin, journaux d'erreurs, limites de requêtes, timeout streaming, login et permissions | Fournir une frontière d'audit contrôlée en déploiement privé et contexte conforme | | Exploitation organisationnelle | Utilisateurs, groupes, solde, paiement, paramètres système, tableaux de bord et configuration ops | Soutenir l'exploitation continue d'équipes, institutions, entreprises ou communautés | @@ -155,8 +155,8 @@ docker compose up -d | Matrice de capacités des canaux | Affiche `chat/completions`, `responses`, `Claude Messages`, `Gemini native`, `embeddings`, `images`, `audio`, `rerank`, `video tasks`, `model discovery` | | Validation des canaux | Vérifie API Key, modèles, Base URL, configuration JSON, région Vertex AI, identifiants Codex, découverte de modèles et placeholders vidéo | | Gouvernance multimodale | Chat, image, vidéo, audio, embeddings, rerank, temps réel, et gestion des tâches asynchrones vidéo | -| Protocole vidéo générique | Configure soumission, requête, progression, mapping d'état, erreur et chemins de résultats ; chemins par défaut `/v1/videos/create` et `/v1/videos/{task_id}` | -| Conversion protocolaire et amonts personnalisés | Conversions OpenAI Compatible, Claude Messages, Gemini, ainsi que URL amont autorisées, overrides et règles de parsing de tâches | +| Protocole vidéo générique | Configure soumission, requête, progression, mapping d'état, erreur et chemins de résultats ; le passthrough et les réécritures du corps utilisent les paramètres de canal existants ; chemins par défaut `/v1/videos/create` et `/v1/videos/{task_id}` | +| Conversion protocolaire et amonts personnalisés | Conversions OpenAI Compatible, Responses, Chat Completions, Claude Messages, Gemini, ainsi que URL amont autorisées, overrides et règles de parsing de tâches | ### Gouvernance AI Agent / AgentOps @@ -165,7 +165,7 @@ docker compose up -d | Isolation des jetons Agent | Créer des API Key indépendantes pour Agents, workflows, plugins, appels d'outils ou utilisateurs | | Contrôle d'accès modèle | Contrôler modèles, canaux et quota par utilisateur, jeton, groupe, restriction de modèle et politique de canal | | Observabilité de chaîne d'appels | Journaux, statistiques, canal touché, latence, erreurs et retries pour diagnostiquer les Agents | -| Attribution des coûts | Statistiques par modèle, canal, utilisateur, groupe et jeton | +| Attribution des coûts | Statistiques par modèle, canal, utilisateur, groupe, jeton et nœud | | Audit administrateur | Audit côté admin en déploiement privé ; les API de journaux utilisateur filtrent les champs réservés aux administrateurs | | Tableau de bord ops | Analyses, gestion utilisateurs, gestion canaux, paramètres système et analyse d'exploitation | @@ -267,17 +267,17 @@ flowchart LR | Type | Description | |------|------| | OpenAI-Compatible | Chat Completions, Embeddings, Images, Audio et interfaces compatibles | -| OpenAI Responses | Requêtes Responses, relay et compatibilité pour les nouveaux protocoles OpenAI | +| OpenAI Responses | Requêtes Responses, relay et conversion compatible Responses ↔ Chat Completions | | Claude Messages | Conversion Claude Messages ↔ format OpenAI-compatible | -| Google Gemini | Chat, texte et conversions partielles Gemini | +| Google Gemini | Chat, texte et conversion compatible `/v1/responses` | | Azure OpenAI | Azure OpenAI et Realtime | | AWS Bedrock | Accès Bedrock Runtime | | Plateformes et applications amont | AWS, Azure, Vertex, Ollama, Codex, Dify, RAGFlow, Kling, Seedance, etc. | | Modèles et plateformes chinoises | DeepSeek, Qwen / Alibaba Cloud Model Studio, Zhipu GLM, Kimi, Doubao / Volcano Engine, Tencent Hunyuan, Baidu ERNIE / Qianfan, iFlytek Spark, MiniMax, 01.AI, SiliconFlow, etc. | | `rerank` | Modèles Cohere, Jina et autres pour RAG et chaînes de recherche Agent | | Midjourney / Suno / Dify | Adaptateurs image, musique et workflow | -| API de tâches vidéo | `/v1/videos/create`, `/v1/videos/{task_id}` : soumission, polling, mapping d'état, proxy de résultat et facturation paramétrée | -| Amonts personnalisés | URL autorisées, règles d'adaptation, overrides, mapping d'état, chemin d'erreur et parsing de résultat | +| API de tâches vidéo | `/v1/videos/create`, `/v1/videos/{task_id}` : soumission, passthrough du corps ou overrides de paramètres, polling, mapping d'état, proxy de résultat et facturation paramétrée | +| Amonts personnalisés | URL autorisées, règles d'adaptation, conversion Responses / Chat, overrides, mapping d'état, chemin d'erreur et parsing de résultat | ### Interfaces principales @@ -336,6 +336,7 @@ Les fournisseurs vidéo diffèrent souvent par chemins, task ID, état, progress - **Override de chemins uniquement** : configurer `submit_path` et `query_path` tout en conservant le parseur officiel du canal. - **Parsing complet** : définir `task_protocol = "generic_video_task"` et configurer task ID, état, progression, URL de résultat, erreur et mapping d'état. +- **Traitement du corps** : le protocole vidéo générique ne définit plus de mode de génération du corps séparé. Utilisez `Pass Through Body` au niveau du canal pour transmettre le JSON client tel quel, et `Param Override` pour les réécritures, valeurs par défaut ou coordinations de headers. Chemins par défaut : @@ -362,6 +363,8 @@ Les chemins de requête prennent en charge `{task_id}`, `{operation_name}` et `{ - **Tiered billing JSON** : maintient plusieurs règles `{ enabled, expr }` et synchronise `billing_mode` / `billing_expr`. - **Task rate-card JSON** : maintient `task_billing_setting.rate_cards` pour les tâches asynchrones, avec partition `vendor` pour Sora, Veo, Seedance, Kling, etc. +Les modèles vidéo comme Seedance 2.0 peuvent utiliser la résolution, l'entrée vidéo et d'autres paramètres de requête dans les multiplicateurs ou les rate-cards. Avec le passthrough ou les overrides de paramètres, gardez les champs finaux envoyés en amont alignés avec les champs de facturation. + ```json { "model-name": { @@ -427,7 +430,7 @@ Les chemins de requête prennent en charge `{task_id}`, `{operation_name}` et `{ | `MAX_REQUEST_BODY_MB` | Taille max du body décompressé | `32` | | `AZURE_DEFAULT_API_VERSION` | Version API Azure par défaut | `2025-04-01-preview` | | `ERROR_LOG_ENABLED` | Activation des journaux d'erreurs | `false` | -| `NODE_NAME` | Nom du nœud | - | +| `NODE_NAME` | Nom du nœud pour logs multi-nœuds et attribution des règlements de tâches asynchrones | - | | `PYROSCOPE_URL` | URL Pyroscope | - | | `PYROSCOPE_APP_NAME` | Nom d'application Pyroscope | `max-api` | | `PYROSCOPE_BASIC_AUTH_USER` | Utilisateur Basic Auth | - | @@ -482,7 +485,7 @@ docker build -t cscitechtop/max-api:latest . > [!WARNING] > - Tous les nœuds doivent partager `SESSION_SECRET`. > - Avec Redis partagé, tous les nœuds doivent partager `CRYPTO_SECRET`. -> - Définissez `NODE_NAME` pour identifier les nœuds dans logs et audit. +> - Définissez un `NODE_NAME` stable pour identifier les nœuds dans les logs, l'audit et les règlements de tâches asynchrones. > - En production, utilisez DB externe, Redis externe, HTTPS et sauvegardes fiables. --- diff --git a/README.ja.md b/README.ja.md index e81b8e8..b76c421 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,4 +1,4 @@ -
+
![max-api](/web/default/public/logo.png) @@ -9,7 +9,7 @@

简体中文 | 繁體中文 | - English | + English | Français | 日本語

@@ -90,7 +90,7 @@ MAX API は、AI モデルと AI Agent の実行プロセスを、設定可能 | 上流チャネル | プロバイダーチャネル、重み、グループ、状態、キー、Base URL、パス上書き、能力マトリクス、設定検証、モデル発見、失敗時 retry | 単一プロバイダー障害、値上げ、制限、設定ミス、API 変更のリスクを下げる | | プロトコル形式 | OpenAI Compatible、Responses、Claude Messages、Gemini、Realtime、汎用動画タスクプロトコルなど | アプリケーション側が各社の差分を直接負担しないようにする | | Agent トークン | API Key、トークングループ、モデル範囲、クォータ、期限、アクセス制御 | Agent、ワークフロー、ツール呼び出しに独立・回収可能・制限可能な認証情報を割り当てる | -| 利用量とコスト | リクエストログ、利用統計、式ベース課金、段階課金 JSON、タスク rate-card、事前課金、失敗時返金 | コストをユーザー、トークン、モデル、チャネル、グループ単位に分解する | +| 利用量とコスト | リクエストログ、利用統計、式ベース課金、段階課金 JSON、タスク rate-card、事前課金、失敗時返金 | コストをユーザー、グループ、トークン、モデル、チャネル、ノード単位に分解する | | 非同期タスク | 動画タスク送信、ポーリング、状態マッピング、結果プロキシ、タスク課金 | 長時間・多状態・多プロバイダー形式のマルチモーダルタスクを統一管理する | | 監査と安全 | 管理者側ログ監査、エラーログ、リクエスト制限、ストリーミング timeout、ログインと権限制御 | プライベートデプロイとコンプライアンス環境に制御可能な監査境界を提供する | | 組織運用 | ユーザー、グループ、残高、決済、システム設定、ダッシュボード、運用設定 | チーム、研究機関、企業、コミュニティサービスの継続運用を支える | @@ -155,8 +155,8 @@ docker compose up -d | チャネル能力マトリクス | `chat/completions`、`responses`、`Claude Messages`、`Gemini native`、`embeddings`、`images`、`audio`、`rerank`、`video tasks`、`model discovery` を表示 | | チャネル設定検証 | API Key、モデル一覧、Base URL、JSON、Vertex AI リージョン、Codex 認証情報、モデル発見、動画タスクパスを保存前に検証 | | マルチモーダル治理 | chat、images、video、audio、embeddings、rerank、realtime と非同期動画タスクを管理 | -| 汎用動画タスクプロトコル | 送信、問い合わせ、進捗、状態マッピング、エラー、結果 URL のパスを統一設定。既定は `/v1/videos/create` と `/v1/videos/{task_id}` | -| プロトコル変換とカスタム上流 | OpenAI Compatible、Claude Messages、Gemini 間の変換、合法的に認可された上流 URL、パス上書き、タスク解析ルール | +| 汎用動画タスクプロトコル | 送信、問い合わせ、進捗、状態マッピング、エラー、結果 URL のパスを統一設定。body の透過送信と書き換えは既存のチャネル設定を利用します。既定は `/v1/videos/create` と `/v1/videos/{task_id}` | +| プロトコル変換とカスタム上流 | OpenAI Compatible、Responses、Chat Completions、Claude Messages、Gemini 間の変換、合法的に認可された上流 URL、パス上書き、タスク解析ルール | ### AI Agent ガバナンス / AgentOps @@ -165,7 +165,7 @@ docker compose up -d | Agent トークン分離 | Agent、ワークフロー、プラグイン、ツール呼び出し、ユーザーごとに独立 API Key を作成 | | モデルアクセス制御 | ユーザー、トークン、グループ、モデル制限、チャネルポリシーで利用可能モデル・チャネル・クォータを制御 | | 呼び出しチェーン観測 | リクエストログ、利用統計、チャネル命中、遅延、エラー、retry 情報 | -| コスト帰属 | モデル、チャネル、ユーザー、グループ、トークン単位でコストと利用量を集計 | +| コスト帰属 | モデル、チャネル、ユーザー、グループ、トークン、ノード単位でコストと利用量を集計 | | 管理者監査 | プライベートデプロイで管理者側ログ監査を有効化可能。通常ユーザーログ API は管理者専用フィールドを除外 | | 運用ダッシュボード | 管理者向け統計、ユーザー管理、チャネル管理、システム設定、運用分析 | @@ -267,17 +267,17 @@ flowchart LR | 種別 | 説明 | |------|------| | OpenAI-Compatible | Chat Completions、Embeddings、Images、Audio などの互換 API | -| OpenAI Responses | Responses 形式のリクエスト、relay、互換能力 | +| OpenAI Responses | Responses 形式のリクエスト、relay、Responses ↔ Chat Completions 互換変換 | | Claude Messages | Claude Messages と OpenAI-compatible 形式の変換 | -| Google Gemini | Gemini chat、text、一部変換能力 | +| Google Gemini | Gemini chat、text、`/v1/responses` 互換変換 | | Azure OpenAI | Azure OpenAI と Realtime 関連 API | | AWS Bedrock | Bedrock Runtime モデル接続 | | 上流平台・アプリケーションエコシステム | AWS、Azure、Vertex、Ollama、Codex、Dify、RAGFlow、Kling、Seedance など | | 中国国内モデル・平台 | DeepSeek、Qwen / Alibaba Cloud Model Studio、Zhipu GLM、Kimi、Doubao / Volcano Engine、Tencent Hunyuan、Baidu ERNIE / Qianfan、iFlytek Spark、MiniMax、01.AI、SiliconFlow など | | `rerank` | Cohere、Jina などの rerank モデル。RAG や Agent 検索チェーンに利用 | | Midjourney / Suno / Dify | 画像、音楽、ワークフローなどのサービス適応 | -| 動画タスク API | `/v1/videos/create`、`/v1/videos/{task_id}` による送信、ポーリング、状態マッピング、結果プロキシ、パラメータ化課金 | -| カスタム上流 | 認可済み上流 URL、プロトコル適応、パス上書き、状態マッピング、エラー経路、結果解析 | +| 動画タスク API | `/v1/videos/create`、`/v1/videos/{task_id}` による送信、body 透過送信またはパラメータ上書き、ポーリング、状態マッピング、結果プロキシ、パラメータ化課金 | +| カスタム上流 | 認可済み上流 URL、プロトコル適応、Responses / Chat 変換、パス上書き、状態マッピング、エラー経路、結果解析 | ### 主な対応インターフェース @@ -336,6 +336,7 @@ flowchart LR - **パス上書きのみ**:`submit_path` と `query_path` のみを設定し、公式レスポンスパーサーを継続利用します。 - **完全プロトコル解析**:`task_protocol = "generic_video_task"` を設定し、task ID、状態、進捗、結果 URL、エラー、状態マッピングのパスを設定します。 +- **リクエスト body 処理**:汎用動画タスクプロトコルは個別の body 生成モードを持ちません。クライアント JSON をそのまま上流へ送る場合はチャネル設定の `Pass Through Body` を使い、フィールド書き換え、既定値、header 連動には既存の `Param Override` を使います。 既定パス: @@ -362,6 +363,8 @@ flowchart LR - **Tiered billing JSON**:複数モデルの `{ enabled, expr }` を一括管理し、`billing_mode` と `billing_expr` を同期更新します。 - **Task rate-card JSON**:`task_billing_setting.rate_cards` で非同期タスク課金ルールを管理し、`vendor` で Sora、Veo、Seedance、Kling などを分けます。 +Seedance 2.0 などの動画モデルでは、解像度や動画入力などのリクエストパラメータを倍率または rate-card 課金に利用できます。透過送信またはパラメータ上書きを使う場合は、最終的に上流へ送るフィールドと課金フィールドを揃えてください。 + ```json { "model-name": { @@ -427,7 +430,7 @@ flowchart LR | `MAX_REQUEST_BODY_MB` | 解凍後リクエストボディ最大サイズ。超過時 `413` | `32` | | `AZURE_DEFAULT_API_VERSION` | Azure API 既定バージョン | `2025-04-01-preview` | | `ERROR_LOG_ENABLED` | エラーログスイッチ | `false` | -| `NODE_NAME` | 複数ノード時のノード名 | - | +| `NODE_NAME` | 複数ノード時のログ識別と非同期タスク精算帰属に使うノード名 | - | | `PYROSCOPE_URL` | Pyroscope サービス URL | - | | `PYROSCOPE_APP_NAME` | Pyroscope アプリ名 | `max-api` | | `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope Basic Auth ユーザー名 | - | @@ -490,7 +493,7 @@ docker build -t cscitechtop/max-api:latest . > [!WARNING] > - 全ノードで同じ `SESSION_SECRET` を設定してください。異なるとログイン状態がノード間で一致しません。 > - 共有 Redis を使用する場合は、全ノードで同じ `CRYPTO_SECRET` を設定してください。異なると暗号化データを復号できません。 -> - ログと監査情報でノードを特定しやすくするため、`NODE_NAME` を設定することを推奨します。 +> - ログ、監査情報、非同期タスク精算でノードを特定しやすくするため、安定した `NODE_NAME` を設定することを推奨します。 > - 本番環境では外部データベース、外部 Redis、HTTPS リバースプロキシ、信頼できるバックアップ戦略を使用してください。 --- diff --git a/README.md b/README.md index 03db2c7..92a83ef 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -
+
![max-api](/web/default/public/logo.png) @@ -100,7 +100,7 @@ MAX API 的设计把 AI 模型和 AI Agent 的运行过程纳入可配置、可 | 上游渠道 | 供应商渠道、权重、分组、状态、密钥、Base URL、路径覆盖、能力矩阵、配置校验、模型发现和失败重试 | 降低单一供应商不可用、涨价、限流、误配置或接口变化带来的风险 | | 协议格式 | OpenAI Compatible、Responses、Claude Messages、Gemini、Realtime、通用视频任务协议等协议入口和转换 | 让应用侧尽量面对稳定接口,而不是直接承担各家协议差异 | | Agent 令牌 | API Key、令牌分组、模型范围、额度限制、过期时间和访问控制 | 为 Agent、工作流和工具调用分配独立、可回收、可限额的访问凭据 | -| 用量与成本 | 请求日志、用量统计、表达式计费、分阶段计费 JSON、任务 rate-card、预扣费和失败退款 | 把模型调用成本拆到用户、令牌、模型、渠道和分组维度 | +| 用量与成本 | 请求日志、用量统计、表达式计费、分阶段计费 JSON、任务 rate-card、预扣费和失败退款 | 把模型调用成本拆到用户、分组、令牌、模型、渠道和节点维度 | | 异步任务 | 视频等任务提交、轮询、状态映射、结果代理和任务计费 | 统一治理长耗时、多状态、多上游格式的多模态任务 | | 审计与安全 | 管理员侧日志审计、错误日志、请求限制、流式超时、登录与权限控制 | 在私有化部署和合规场景中提供可控的审计边界,敏感内容审计集中放在安全与限制中管理 | | 组织运营 | 用户、分组、余额、支付、系统设置、数据看板和运维配置 | 支撑团队、研究机构、企业或社区服务的持续运营 | @@ -165,8 +165,8 @@ docker compose up -d | 渠道能力矩阵 | 渠道编辑界面展示 `chat/completions`、`responses`、`Claude Messages`、`Gemini native`、`embeddings`、`images`、`audio`、`rerank`、`video tasks`、`model discovery` 等能力状态,减少管理员对渠道能力的猜测 | | 渠道配置校验 | 在保存前检查 API Key、模型列表、Base URL、额外配置、JSON 对象、Vertex AI 区域、Codex 凭证、模型发现能力和视频任务路径占位符等常见问题 | | 多模态模型治理 | 支持聊天、图像、视频、音频、嵌入、重排序、实时对话等场景,并对视频等异步任务提供提交、轮询、状态映射和结果代理 | -| 通用视频任务协议 | 支持将不同视频上游的任务提交、查询、进度、状态映射、错误消息和结果 URL 路径统一配置到渠道中,默认使用 `/v1/videos/create` 与 `/v1/videos/{task_id}` | -| 协议转换与自定义上游 | 支持 OpenAI Compatible、Claude Messages、Gemini 等格式之间的转换与适配,也支持配置合法授权的上游地址、路径覆盖和任务协议解析规则 | +| 通用视频任务协议 | 支持将不同视频上游的任务提交、查询、进度、状态映射、错误消息和结果 URL 路径统一配置到渠道中;请求体透传和改写复用渠道设置,默认路径为 `/v1/videos/create` 与 `/v1/videos/{task_id}` | +| 协议转换与自定义上游 | 支持 OpenAI Compatible、Responses、Chat Completions、Claude Messages、Gemini 等格式之间的转换与适配,也支持配置合法授权的上游地址、路径覆盖和任务协议解析规则 | ### AI Agent 治理 / AgentOps @@ -175,7 +175,7 @@ docker compose up -d | Agent 令牌隔离 | 可为 Agent、工作流、插件、工具调用或用户创建独立 API Key,并配置模型范围、额度、过期时间和分组 | | 模型访问控制 | 通过用户、令牌、分组、模型限制和渠道策略控制 Agent 能调用哪些模型、走哪些渠道、消耗多少额度 | | 调用链路观测 | 提供请求日志、用量统计、渠道命中、耗时、错误和重试信息,帮助定位 Agent 调用失败、成本异常和上游波动 | -| 成本归因 | 支持按模型、渠道、用户、分组和令牌维度统计成本与用量,方便核算不同 Agent 或业务线成本 | +| 成本归因 | 支持按模型、渠道、用户、分组、令牌和节点维度统计成本与用量,方便核算不同 Agent、业务线或部署节点成本 | | 管理员审计 | 私有部署场景可按合规要求启用管理员侧日志审计能力,普通用户日志接口会过滤管理员专用审计字段 | | 运营看板 | 提供面向管理员的统计分析、用户管理、渠道管理、系统设置和运维分析能力 | @@ -277,17 +277,17 @@ flowchart LR | 类型 | 说明 | |------|------| | OpenAI-Compatible | Chat Completions、Embeddings、Images、Audio 等兼容接口,可作为多数应用和 Agent 的通用模型入口 | -| OpenAI Responses | Responses 格式请求、中继与兼容能力,适合逐步接入新的 OpenAI 应用协议 | +| OpenAI Responses | Responses 格式请求、中继与 Responses ↔ Chat Completions 兼容转换,适合逐步接入新的 OpenAI 应用协议 | | Claude Messages | Claude Messages 格式与 OpenAI 兼容格式转换,降低应用侧多协议维护成本 | -| Google Gemini | Gemini 聊天、文本和部分转换能力 | +| Google Gemini | Gemini 聊天、文本,以及 `/v1/responses` 兼容转换能力 | | Azure OpenAI | Azure OpenAI 与 Realtime 相关接口 | | AWS Bedrock | Bedrock Runtime 相关模型接入 | | 上游平台和应用生态 | AWS、Azure、Vertex、Ollama、Codex、Dify、RAGFlow、Kling、Seedance 等平台或应用形态可按渠道能力接入治理 | | 国产模型与平台 | 内置 DeepSeek、通义千问 / 阿里云百炼、智谱 GLM、Kimi、豆包 / 火山引擎、腾讯混元、百度文心 / 千帆、讯飞星火、MiniMax、零一万物、硅基流动等适配器或兼容接入能力 | | `rerank` | Cohere、Jina 等重排序模型,可用于检索增强和 Agent 检索链路 | | Midjourney / Suno / Dify | 图像、音乐、工作流等服务适配 | -| 视频任务接口 | 支持 `/v1/videos/create`、`/v1/videos/{task_id}` 等视频生成任务的提交、轮询、状态映射、结果代理和参数化任务计费 | -| 自定义上游 | 支持配置合法授权的上游接口地址、协议适配规则、路径覆盖、状态映射、错误消息路径和任务结果解析 | +| 视频任务接口 | 支持 `/v1/videos/create`、`/v1/videos/{task_id}` 等视频生成任务的提交、请求体透传或参数覆盖、轮询、状态映射、结果代理和参数化任务计费 | +| 自定义上游 | 支持配置合法授权的上游接口地址、协议适配规则、Responses / Chat 转换、路径覆盖、状态映射、错误消息路径和任务结果解析 | ### 支持的主要接口 @@ -367,10 +367,11 @@ flowchart LR 视频模型供应商的接口经常在路径、任务 ID、状态字段、进度字段、错误字段和结果 URL 字段上不一致。MAX API 将原先面向单一模型的任务协议能力扩展为通用视频任务协议,适用于 OpenAI、Ali、Gemini、MiniMax、Vertex AI、VolcEngine、Kling、Jimeng、Vidu、Doubao Video、Sora 等视频任务渠道。 -支持两种配置层级: +支持的配置层级: - **仅路径覆盖**:只配置 `submit_path` 和 `query_path`,系统仍使用对应渠道的官方响应解析逻辑,适合只改上游路径的兼容渠道。 - **完整协议解析**:设置 `task_protocol = "generic_video_task"`,同时配置任务 ID、状态、进度、结果 URL、错误消息和状态映射路径,适合非标准视频任务响应。 +- **请求体处理**:通用视频任务协议不再单独定义请求体生成模式。需要原样透传客户端 JSON 时使用渠道设置里的 `Pass Through Body`;需要字段改写、默认值或 header 联动时使用已有的 `Param Override`。 默认任务路径: @@ -411,6 +412,8 @@ flowchart LR - **分阶段计费 JSON**:通过 `Tiered billing JSON` 统一维护多个模型的 `{ enabled, expr }` 配置,保存时同步更新 `billing_mode` 与 `billing_expr`。 - **任务 rate-card JSON**:通过 `task_billing_setting.rate_cards` 统一维护异步任务计费规则,可按 `vendor` 分区维护 Sora、Veo、Seedance、Kling 等视频模型的不同计费表。 +Seedance 2.0 等视频模型可按分辨率、视频输入等请求参数参与倍率或 rate-card 计算;使用透传或参数覆盖时,应确保最终提交给上游的字段与计费字段保持一致。 + 示例结构: ```json @@ -492,7 +495,7 @@ flowchart LR | `MAX_REQUEST_BODY_MB` | 请求体最大大小,按解压后大小计算,超出返回 `413` | `32` | | `AZURE_DEFAULT_API_VERSION` | Azure API 默认版本 | `2025-04-01-preview` | | `ERROR_LOG_ENABLED` | 错误日志开关 | `false` | -| `NODE_NAME` | 节点名称,多机部署时便于日志定位 | - | +| `NODE_NAME` | 节点名称,多机部署时用于日志定位和异步任务结算归属 | - | | `PYROSCOPE_URL` | Pyroscope 服务地址 | - | | `PYROSCOPE_APP_NAME` | Pyroscope 应用名 | `max-api` | | `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope Basic Auth 用户名 | - | @@ -555,7 +558,7 @@ docker build -t cscitechtop/max-api:latest . > [!WARNING] > - 必须设置相同的 `SESSION_SECRET`,否则不同节点之间登录状态不一致。 > - 使用共享 Redis 时必须设置相同的 `CRYPTO_SECRET`,否则加密数据无法解密。 -> - 多节点建议设置 `NODE_NAME`,便于在日志和审计信息中定位来源节点。 +> - 多节点建议设置稳定的 `NODE_NAME`,便于在日志、审计信息和异步任务结算中定位来源节点。 > - 生产环境应使用外部数据库、外部 Redis、HTTPS 反向代理和可靠的备份策略。 --- diff --git a/README.zh_CN.md b/README.zh_CN.md index 2a4dfd5..71b9934 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -1,4 +1,4 @@ -
+
![max-api](/web/default/public/logo.png) @@ -9,7 +9,7 @@

简体中文 | 繁體中文 | - English | + English | Français | 日本語

@@ -98,7 +98,7 @@ MAX API 的设计把 AI 模型和 AI Agent 的运行过程纳入可配置、可 | 上游渠道 | 供应商渠道、权重、分组、状态、密钥、Base URL、路径覆盖、能力矩阵、配置校验、模型发现和失败重试 | 降低单一供应商不可用、涨价、限流、误配置或接口变化带来的风险 | | 协议格式 | OpenAI Compatible、Responses、Claude Messages、Gemini、Realtime、通用视频任务协议等协议入口和转换 | 让应用侧尽量面对稳定接口,而不是直接承担各家协议差异 | | Agent 令牌 | API Key、令牌分组、模型范围、额度限制、过期时间和访问控制 | 为 Agent、工作流和工具调用分配独立、可回收、可限额的访问凭据 | -| 用量与成本 | 请求日志、用量统计、表达式计费、分阶段计费 JSON、任务 rate-card、预扣费和失败退款 | 把模型调用成本拆到用户、令牌、模型、渠道和分组维度 | +| 用量与成本 | 请求日志、用量统计、表达式计费、分阶段计费 JSON、任务 rate-card、预扣费和失败退款 | 把模型调用成本拆到用户、分组、令牌、模型、渠道和节点维度 | | 异步任务 | 视频等任务提交、轮询、状态映射、结果代理和任务计费 | 统一治理长耗时、多状态、多上游格式的多模态任务 | | 审计与安全 | 管理员侧日志审计、错误日志、请求限制、流式超时、登录与权限控制 | 在私有化部署和合规场景中提供可控的审计边界,敏感内容审计集中放在安全与限制中管理 | | 组织运营 | 用户、分组、余额、支付、系统设置、数据看板和运维配置 | 支撑团队、研究机构、企业或社区服务的持续运营 | @@ -163,8 +163,8 @@ docker compose up -d | 渠道能力矩阵 | 渠道编辑界面展示 `chat/completions`、`responses`、`Claude Messages`、`Gemini native`、`embeddings`、`images`、`audio`、`rerank`、`video tasks`、`model discovery` 等能力状态,减少管理员对渠道能力的猜测 | | 渠道配置校验 | 在保存前检查 API Key、模型列表、Base URL、额外配置、JSON 对象、Vertex AI 区域、Codex 凭证、模型发现能力和视频任务路径占位符等常见问题 | | 多模态模型治理 | 支持聊天、图像、视频、音频、嵌入、重排序、实时对话等场景,并对视频等异步任务提供提交、轮询、状态映射和结果代理 | -| 通用视频任务协议 | 支持将不同视频上游的任务提交、查询、进度、状态映射、错误消息和结果 URL 路径统一配置到渠道中,默认使用 `/v1/videos/create` 与 `/v1/videos/{task_id}` | -| 协议转换与自定义上游 | 支持 OpenAI Compatible、Claude Messages、Gemini 等格式之间的转换与适配,也支持配置合法授权的上游地址、路径覆盖和任务协议解析规则 | +| 通用视频任务协议 | 支持将不同视频上游的任务提交、查询、进度、状态映射、错误消息和结果 URL 路径统一配置到渠道中;请求体透传和改写复用渠道设置,默认路径为 `/v1/videos/create` 与 `/v1/videos/{task_id}` | +| 协议转换与自定义上游 | 支持 OpenAI Compatible、Responses、Chat Completions、Claude Messages、Gemini 等格式之间的转换与适配,也支持配置合法授权的上游地址、路径覆盖和任务协议解析规则 | ### AI Agent 治理 / AgentOps @@ -173,7 +173,7 @@ docker compose up -d | Agent 令牌隔离 | 可为 Agent、工作流、插件、工具调用或用户创建独立 API Key,并配置模型范围、额度、过期时间和分组 | | 模型访问控制 | 通过用户、令牌、分组、模型限制和渠道策略控制 Agent 能调用哪些模型、走哪些渠道、消耗多少额度 | | 调用链路观测 | 提供请求日志、用量统计、渠道命中、耗时、错误和重试信息,帮助定位 Agent 调用失败、成本异常和上游波动 | -| 成本归因 | 支持按模型、渠道、用户、分组和令牌维度统计成本与用量,方便核算不同 Agent 或业务线成本 | +| 成本归因 | 支持按模型、渠道、用户、分组、令牌和节点维度统计成本与用量,方便核算不同 Agent、业务线或部署节点成本 | | 管理员审计 | 私有部署场景可按合规要求启用管理员侧日志审计能力,普通用户日志接口会过滤管理员专用审计字段 | | 运营看板 | 提供面向管理员的统计分析、用户管理、渠道管理、系统设置和运维分析能力 | @@ -275,17 +275,17 @@ flowchart LR | 类型 | 说明 | |------|------| | OpenAI-Compatible | Chat Completions、Embeddings、Images、Audio 等兼容接口,可作为多数应用和 Agent 的通用模型入口 | -| OpenAI Responses | Responses 格式请求、中继与兼容能力,适合逐步接入新的 OpenAI 应用协议 | +| OpenAI Responses | Responses 格式请求、中继与 Responses ↔ Chat Completions 兼容转换,适合逐步接入新的 OpenAI 应用协议 | | Claude Messages | Claude Messages 格式与 OpenAI 兼容格式转换,降低应用侧多协议维护成本 | -| Google Gemini | Gemini 聊天、文本和部分转换能力 | +| Google Gemini | Gemini 聊天、文本,以及 `/v1/responses` 兼容转换能力 | | Azure OpenAI | Azure OpenAI 与 Realtime 相关接口 | | AWS Bedrock | Bedrock Runtime 相关模型接入 | | 上游平台和应用生态 | AWS、Azure、Vertex、Ollama、Codex、Dify、RAGFlow、Kling、Seedance 等平台或应用形态可按渠道能力接入治理 | | 国产模型与平台 | 内置 DeepSeek、通义千问 / 阿里云百炼、智谱 GLM、Kimi、豆包 / 火山引擎、腾讯混元、百度文心 / 千帆、讯飞星火、MiniMax、零一万物、硅基流动等适配器或兼容接入能力 | | `rerank` | Cohere、Jina 等重排序模型,可用于检索增强和 Agent 检索链路 | | Midjourney / Suno / Dify | 图像、音乐、工作流等服务适配 | -| 视频任务接口 | 支持 `/v1/videos/create`、`/v1/videos/{task_id}` 等视频生成任务的提交、轮询、状态映射、结果代理和参数化任务计费 | -| 自定义上游 | 支持配置合法授权的上游接口地址、协议适配规则、路径覆盖、状态映射、错误消息路径和任务结果解析 | +| 视频任务接口 | 支持 `/v1/videos/create`、`/v1/videos/{task_id}` 等视频生成任务的提交、请求体透传或参数覆盖、轮询、状态映射、结果代理和参数化任务计费 | +| 自定义上游 | 支持配置合法授权的上游接口地址、协议适配规则、Responses / Chat 转换、路径覆盖、状态映射、错误消息路径和任务结果解析 | ### 支持的主要接口 @@ -365,10 +365,11 @@ flowchart LR 视频模型供应商的接口经常在路径、任务 ID、状态字段、进度字段、错误字段和结果 URL 字段上不一致。MAX API 将原先面向单一模型的任务协议能力扩展为通用视频任务协议,适用于 OpenAI、Ali、Gemini、MiniMax、Vertex AI、VolcEngine、Kling、Jimeng、Vidu、Doubao Video、Sora 等视频任务渠道。 -支持两种配置层级: +支持的配置层级: - **仅路径覆盖**:只配置 `submit_path` 和 `query_path`,系统仍使用对应渠道的官方响应解析逻辑,适合只改上游路径的兼容渠道。 - **完整协议解析**:设置 `task_protocol = "generic_video_task"`,同时配置任务 ID、状态、进度、结果 URL、错误消息和状态映射路径,适合非标准视频任务响应。 +- **请求体处理**:通用视频任务协议不再单独定义请求体生成模式。需要原样透传客户端 JSON 时使用渠道设置里的 `Pass Through Body`;需要字段改写、默认值或 header 联动时使用已有的 `Param Override`。 默认任务路径: @@ -409,6 +410,8 @@ flowchart LR - **分阶段计费 JSON**:通过 `Tiered billing JSON` 统一维护多个模型的 `{ enabled, expr }` 配置,保存时同步更新 `billing_mode` 与 `billing_expr`。 - **任务 rate-card JSON**:通过 `task_billing_setting.rate_cards` 统一维护异步任务计费规则,可按 `vendor` 分区维护 Sora、Veo、Seedance、Kling 等视频模型的不同计费表。 +Seedance 2.0 等视频模型可按分辨率、视频输入等请求参数参与倍率或 rate-card 计算;使用透传或参数覆盖时,应确保最终提交给上游的字段与计费字段保持一致。 + 示例结构: ```json @@ -490,7 +493,7 @@ flowchart LR | `MAX_REQUEST_BODY_MB` | 请求体最大大小,按解压后大小计算,超出返回 `413` | `32` | | `AZURE_DEFAULT_API_VERSION` | Azure API 默认版本 | `2025-04-01-preview` | | `ERROR_LOG_ENABLED` | 错误日志开关 | `false` | -| `NODE_NAME` | 节点名称,多机部署时便于日志定位 | - | +| `NODE_NAME` | 节点名称,多机部署时用于日志定位和异步任务结算归属 | - | | `PYROSCOPE_URL` | Pyroscope 服务地址 | - | | `PYROSCOPE_APP_NAME` | Pyroscope 应用名 | `max-api` | | `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope Basic Auth 用户名 | - | @@ -553,7 +556,7 @@ docker build -t cscitechtop/max-api:latest . > [!WARNING] > - 必须设置相同的 `SESSION_SECRET`,否则不同节点之间登录状态不一致。 > - 使用共享 Redis 时必须设置相同的 `CRYPTO_SECRET`,否则加密数据无法解密。 -> - 多节点建议设置 `NODE_NAME`,便于在日志和审计信息中定位来源节点。 +> - 多节点建议设置稳定的 `NODE_NAME`,便于在日志、审计信息和异步任务结算中定位来源节点。 > - 生产环境应使用外部数据库、外部 Redis、HTTPS 反向代理和可靠的备份策略。 --- diff --git a/README.zh_TW.md b/README.zh_TW.md index f2291d4..7b502ae 100644 --- a/README.zh_TW.md +++ b/README.zh_TW.md @@ -1,4 +1,4 @@ -
+
![max-api](/web/default/public/logo.png) @@ -9,7 +9,7 @@

簡體中文 | 繁體中文 | - English | + English | Français | 日本語

@@ -98,7 +98,7 @@ MAX API 的設計把 AI 模型和 AI Agent 的運行過程納入可配置、可 | 上游渠道 | 供應商渠道、權重、分組、狀態、密鑰、Base URL、路徑覆蓋、能力矩陣、配置校驗、模型發現和失敗重試 | 降低單一供應商不可用、漲價、限流、誤配置或介面變化帶來的風險 | | 協議格式 | OpenAI Compatible、Responses、Claude Messages、Gemini、Realtime、通用視頻任務協議等協議入口和轉換 | 讓應用側盡量面對穩定介面,而不是直接承擔各家協議差異 | | Agent 令牌 | API Key、令牌分組、模型范圍、額度限制、過期時間和訪問控制 | 為 Agent、工作流和工具調用分配獨立、可回收、可限額的訪問憑據 | -| 用量與成本 | 請求日志、用量統計、表達式計費、分階段計費 JSON、任務 rate-card、預扣費和失敗退款 | 把模型調用成本拆到使用者、令牌、模型、渠道和分組維度 | +| 用量與成本 | 請求日志、用量統計、表達式計費、分階段計費 JSON、任務 rate-card、預扣費和失敗退款 | 把模型調用成本拆到使用者、分組、令牌、模型、渠道和節點維度 | | 異步任務 | 視頻等任務提交、輪詢、狀態映射、結果代理和任務計費 | 統一治理長耗時、多狀態、多上游格式的多模態任務 | | 審計與安全 | 管理員側日志審計、錯誤日志、請求限制、流式超時、登錄與權限控制 | 在私有化部署和合規場景中提供可控的審計邊界,敏感內容審計集中放在安全與限制中管理 | | 組織營運 | 使用者、分組、余額、支付、系統設置、資料看板和運維配置 | 支撐團隊、研究機構、企業或社區服務的持續營運 | @@ -163,8 +163,8 @@ docker compose up -d | 渠道能力矩陣 | 渠道編輯界面展示 `chat/completions`、`responses`、`Claude Messages`、`Gemini native`、`embeddings`、`images`、`audio`、`rerank`、`video tasks`、`model discovery` 等能力狀態,減少管理員對渠道能力的猜測 | | 渠道配置校驗 | 在保存前檢查 API Key、模型列表、Base URL、額外配置、JSON 對象、Vertex AI 區域、Codex 憑證、模型發現能力和視頻任務路徑占位符等常見問題 | | 多模態模型治理 | 支持聊天、圖像、視頻、音頻、嵌入、重排序、實時對話等場景,并對視頻等異步任務提供提交、輪詢、狀態映射和結果代理 | -| 通用視頻任務協議 | 支持將不同視頻上游的任務提交、查詢、進度、狀態映射、錯誤消息和結果 URL 路徑統一配置到渠道中,默認使用 `/v1/videos/create` 與 `/v1/videos/{task_id}` | -| 協議轉換與自定義上游 | 支持 OpenAI Compatible、Claude Messages、Gemini 等格式之間的轉換與適配,也支持配置合法授權的上游地址、路徑覆蓋和任務協議解析規則 | +| 通用視頻任務協議 | 支持將不同視頻上游的任務提交、查詢、進度、狀態映射、錯誤消息和結果 URL 路徑統一配置到渠道中;請求體透傳和改寫復用渠道設置,默認路徑為 `/v1/videos/create` 與 `/v1/videos/{task_id}` | +| 協議轉換與自定義上游 | 支持 OpenAI Compatible、Responses、Chat Completions、Claude Messages、Gemini 等格式之間的轉換與適配,也支持配置合法授權的上游地址、路徑覆蓋和任務協議解析規則 | ### AI Agent 治理 / AgentOps @@ -173,7 +173,7 @@ docker compose up -d | Agent 令牌隔離 | 可為 Agent、工作流、插件、工具調用或使用者創建獨立 API Key,并配置模型范圍、額度、過期時間和分組 | | 模型訪問控制 | 通過使用者、令牌、分組、模型限制和渠道策略控制 Agent 能調用哪些模型、走哪些渠道、消耗多少額度 | | 調用鏈路觀測 | 提供請求日志、用量統計、渠道命中、耗時、錯誤和重試信息,幫助定位 Agent 調用失敗、成本異常和上游波動 | -| 成本歸因 | 支持按模型、渠道、使用者、分組和令牌維度統計成本與用量,方便核算不同 Agent 或業務線成本 | +| 成本歸因 | 支持按模型、渠道、使用者、分組、令牌和節點維度統計成本與用量,方便核算不同 Agent、業務線或部署節點成本 | | 管理員審計 | 私有部署場景可按合規要求啟用管理員側日志審計能力,普通使用者日志介面會過濾管理員專用審計字段 | | 營運看板 | 提供面向管理員的統計分析、使用者管理、渠道管理、系統設置和運維分析能力 | @@ -275,17 +275,17 @@ flowchart LR | 類型 | 說明 | |------|------| | OpenAI-Compatible | Chat Completions、Embeddings、Images、Audio 等兼容介面,可作為多數應用和 Agent 的通用模型入口 | -| OpenAI Responses | Responses 格式請求、中繼與兼容能力,適合逐步接入新的 OpenAI 應用協議 | +| OpenAI Responses | Responses 格式請求、中繼與 Responses ↔ Chat Completions 兼容轉換,適合逐步接入新的 OpenAI 應用協議 | | Claude Messages | Claude Messages 格式與 OpenAI 兼容格式轉換,降低應用側多協議維護成本 | -| Google Gemini | Gemini 聊天、文本和部分轉換能力 | +| Google Gemini | Gemini 聊天、文本,以及 `/v1/responses` 兼容轉換能力 | | Azure OpenAI | Azure OpenAI 與 Realtime 相關介面 | | AWS Bedrock | Bedrock Runtime 相關模型接入 | | 上游平臺和應用生態 | AWS、Azure、Vertex、Ollama、Codex、Dify、RAGFlow、Kling、Seedance 等平臺或應用形態可按渠道能力接入治理 | | 國產模型與平臺 | 內置 DeepSeek、通義千問 / 阿里云百煉、智譜 GLM、Kimi、豆包 / 火山引擎、騰訊混元、百度文心 / 千帆、訊飛星火、MiniMax、零一萬物、硅基流動等適配器或兼容接入能力 | | `rerank` | Cohere、Jina 等重排序模型,可用于檢索增強和 Agent 檢索鏈路 | | Midjourney / Suno / Dify | 圖像、音樂、工作流等服務適配 | -| 視頻任務介面 | 支持 `/v1/videos/create`、`/v1/videos/{task_id}` 等視頻生成任務的提交、輪詢、狀態映射、結果代理和參數化任務計費 | -| 自定義上游 | 支持配置合法授權的上游介面地址、協議適配規則、路徑覆蓋、狀態映射、錯誤消息路徑和任務結果解析 | +| 視頻任務介面 | 支持 `/v1/videos/create`、`/v1/videos/{task_id}` 等視頻生成任務的提交、請求體透傳或參數覆蓋、輪詢、狀態映射、結果代理和參數化任務計費 | +| 自定義上游 | 支持配置合法授權的上游介面地址、協議適配規則、Responses / Chat 轉換、路徑覆蓋、狀態映射、錯誤消息路徑和任務結果解析 | ### 支持的主要介面 @@ -365,10 +365,11 @@ flowchart LR 視頻模型供應商的介面經常在路徑、任務 ID、狀態字段、進度字段、錯誤字段和結果 URL 字段上不一致。MAX API 將原先面向單一模型的任務協議能力擴展為通用視頻任務協議,適用于 OpenAI、Ali、Gemini、MiniMax、Vertex AI、VolcEngine、Kling、Jimeng、Vidu、Doubao Video、Sora 等視頻任務渠道。 -支持兩種配置層級: +支持的配置層級: - **僅路徑覆蓋**:只配置 `submit_path` 和 `query_path`,系統仍使用對應渠道的官方響應解析邏輯,適合只改上游路徑的兼容渠道。 - **完整協議解析**:設置 `task_protocol = "generic_video_task"`,同時配置任務 ID、狀態、進度、結果 URL、錯誤消息和狀態映射路徑,適合非標準視頻任務響應。 +- **請求體處理**:通用視頻任務協議不再單獨定義請求體生成模式。需要原樣透傳客戶端 JSON 時使用渠道設置里的 `Pass Through Body`;需要字段改寫、默認值或 header 聯動時使用已有的 `Param Override`。 默認任務路徑: @@ -409,6 +410,8 @@ flowchart LR - **分階段計費 JSON**:通過 `Tiered billing JSON` 統一維護多個模型的 `{ enabled, expr }` 配置,保存時同步更新 `billing_mode` 與 `billing_expr`。 - **任務 rate-card JSON**:通過 `task_billing_setting.rate_cards` 統一維護異步任務計費規則,可按 `vendor` 分區維護 Sora、Veo、Seedance、Kling 等視頻模型的不同計費表。 +Seedance 2.0 等視頻模型可按分辨率、視頻輸入等請求參數參與倍率或 rate-card 計算;使用透傳或參數覆蓋時,應確保最終提交給上游的字段與計費字段保持一致。 + 示例結構: ```json @@ -490,7 +493,7 @@ flowchart LR | `MAX_REQUEST_BODY_MB` | 請求體最大大小,按解壓后大小計算,超出返回 `413` | `32` | | `AZURE_DEFAULT_API_VERSION` | Azure API 默認版本 | `2025-04-01-preview` | | `ERROR_LOG_ENABLED` | 錯誤日志開關 | `false` | -| `NODE_NAME` | 節點名稱,多機部署時便于日志定位 | - | +| `NODE_NAME` | 節點名稱,多機部署時用于日志定位和異步任務結算歸屬 | - | | `PYROSCOPE_URL` | Pyroscope 服務地址 | - | | `PYROSCOPE_APP_NAME` | Pyroscope 應用名 | `max-api` | | `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope Basic Auth 使用者名 | - | @@ -553,7 +556,7 @@ docker build -t cscitechtop/max-api:latest . > [!WARNING] > - 必須設置相同的 `SESSION_SECRET`,否則不同節點之間登錄狀態不一致。 > - 使用共享 Redis 時必須設置相同的 `CRYPTO_SECRET`,否則加密資料無法解密。 -> - 多節點建議設置 `NODE_NAME`,便于在日志和審計信息中定位來源節點。 +> - 多節點建議設置穩定的 `NODE_NAME`,便于在日志、審計信息和異步任務結算中定位來源節點。 > - 生產環境應使用外部資料庫、外部 Redis、HTTPS 反向代理和可靠的備份策略。 --- diff --git a/controller/relay.go b/controller/relay.go index 409e5b5..ba467b3 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -587,6 +587,7 @@ func RelayTask(c *gin.Context) { task.PrivateData.BillingSource = relayInfo.BillingSource task.PrivateData.SubscriptionId = relayInfo.SubscriptionId task.PrivateData.TokenId = relayInfo.TokenId + task.PrivateData.NodeName = common.NodeName task.PrivateData.BillingContext = &model.TaskBillingContext{ ModelPrice: relayInfo.PriceData.ModelPrice, GroupRatio: relayInfo.PriceData.GroupRatioInfo.GroupRatio, @@ -630,6 +631,9 @@ func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *dto.TaskError, if _, ok := c.Get("specific_channel_id"); ok { return false } + if taskErr.LocalError { + return false + } if taskErr.StatusCode == http.StatusTooManyRequests { return true } @@ -650,9 +654,6 @@ func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *dto.TaskError, // azure处理超时不重试 return false } - if taskErr.LocalError { - return false - } if taskErr.StatusCode/100 == 2 { return false } diff --git a/controller/relay_retry_test.go b/controller/relay_retry_test.go index a5c98e3..5e0eb90 100644 --- a/controller/relay_retry_test.go +++ b/controller/relay_retry_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/dto" "github.com/MAX-API-Next/MAX-API/setting/operation_setting" "github.com/MAX-API-Next/MAX-API/types" @@ -85,3 +86,13 @@ func TestShouldRetryGenericEmptyResponseDoesNotRequireEmptyCompletionFlag(t *tes require.True(t, shouldRetry(c, err, 1)) } + +func TestShouldRetryTaskRelaySkipsLocalServerError(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + + require.False(t, shouldRetryTaskRelay(c, 1, &dto.TaskError{ + StatusCode: http.StatusInternalServerError, + LocalError: true, + }, 1)) +} diff --git a/dto/channel_settings.go b/dto/channel_settings.go index 024a3b8..e549d7a 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -77,6 +77,7 @@ const ( AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions = "anthropic_messages_to_openai_chat_completions" AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages = "openai_chat_completions_to_anthropic_messages" AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses = "openai_chat_completions_to_openai_responses" + AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions = "openai_responses_to_openai_chat_completions" AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions = "gemini_generate_content_to_openai_chat_completions" AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent = "openai_chat_completions_to_gemini_generate_content" ) @@ -161,6 +162,7 @@ func IsAdvancedCustomConverterAllowed(converter string) bool { AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions, AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages, AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses, + AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions, AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions, AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent: return true @@ -254,6 +256,10 @@ func validateAdvancedCustomConverterPath(index int, incomingPath string, convert if incomingPath == "/v1/chat/completions" { return nil } + case AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions: + if incomingPath == "/v1/responses" { + return nil + } case AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions: if strings.Contains(incomingPath, ":generateContent") || strings.Contains(incomingPath, ":streamGenerateContent") { return nil diff --git a/dto/openai_response.go b/dto/openai_response.go index b424a8d..00069d1 100644 --- a/dto/openai_response.go +++ b/dto/openai_response.go @@ -334,7 +334,7 @@ func (o *OpenAIResponsesResponse) GetSize() string { } type IncompleteDetails struct { - Reasoning string `json:"reasoning"` + Reason string `json:"reason"` } type ResponsesOutput struct { diff --git a/model/log.go b/model/log.go index 3db1ee1..36ec113 100644 --- a/model/log.go +++ b/model/log.go @@ -54,14 +54,11 @@ type Log struct { UpstreamRequestId string `json:"upstream_request_id,omitempty" gorm:"type:varchar(128);index:idx_logs_upstream_request_id;default:''"` Other string `json:"other"` IsRetry bool `json:"is_retry" gorm:"default:false;index"` + IsErrorRetry bool `json:"is_error_retry" gorm:"default:false;index"` + IsEmptyRetry bool `json:"is_empty_retry" gorm:"default:false;index"` LogId int `json:"log_id,omitempty" gorm:"-"` } -func (log *Log) BeforeCreate(*gorm.DB) error { - log.syncRetryMarker() - return nil -} - func (log *Log) BeforeSave(*gorm.DB) error { log.syncRetryMarker() return nil @@ -71,7 +68,10 @@ func (log *Log) syncRetryMarker() { if log == nil { return } - log.IsRetry = logOtherHasRetryMarker(log.Other) + errorRetry, emptyRetry := logOtherRetryMarkers(log.Other) + log.IsErrorRetry = errorRetry + log.IsEmptyRetry = emptyRetry + log.IsRetry = errorRetry || emptyRetry } // don't use iota, avoid change log type value @@ -86,7 +86,11 @@ const ( LogTypeLogin = 7 ) -const LogFilterRetry = "retry" +const ( + LogFilterRetry = "retry" + LogFilterErrorRetry = "error_retry" + LogFilterEmptyRetry = "empty_retry" +) func applyLogTypeFilter(tx *gorm.DB, logType int) *gorm.DB { if logType == LogTypeUnknown { @@ -104,6 +108,20 @@ func applyRetryLogFilter(tx *gorm.DB) (*gorm.DB, error) { return tx.Where("logs.is_retry = ?", true), nil } +func applyErrorRetryLogFilter(tx *gorm.DB) (*gorm.DB, error) { + if err := ensureLogRetryMarkerBackfillCompletedForRead(); err != nil { + return nil, err + } + return tx.Where("logs.is_error_retry = ?", true), nil +} + +func applyEmptyRetryLogFilter(tx *gorm.DB) (*gorm.DB, error) { + if err := ensureLogRetryMarkerBackfillCompletedForRead(); err != nil { + return nil, err + } + return tx.Where("logs.is_empty_retry = ?", true), nil +} + func ensureLogRetryMarkerBackfillCompletedForReadDefault() error { completed, err := isLogRetryMarkerBackfillCompleted() if err != nil { @@ -122,11 +140,24 @@ func applyLogFilter(tx *gorm.DB, filter string) (*gorm.DB, error) { switch filter { case LogFilterRetry: return applyRetryLogFilter(tx) + case LogFilterErrorRetry: + return applyErrorRetryLogFilter(tx) + case LogFilterEmptyRetry: + return applyEmptyRetryLogFilter(tx) default: return tx, nil } } +func isRetryLogFilter(filter string) bool { + switch filter { + case LogFilterRetry, LogFilterErrorRetry, LogFilterEmptyRetry: + return true + default: + return false + } +} + func userVisibleAuditInfo(adminInfoValue interface{}) map[string]interface{} { if !common.LogRequestContentEnabled && !common.LogResponseContentEnabled { return nil @@ -198,20 +229,25 @@ func stripLogsAuditContent(logs []*Log) { } func logOtherHasRetryMarker(other string) bool { + errorRetry, emptyRetry := logOtherRetryMarkers(other) + return errorRetry || emptyRetry +} + +func logOtherRetryMarkers(other string) (errorRetry bool, emptyRetry bool) { if strings.TrimSpace(other) == "" { - return false + return false, false } otherMap, err := common.StrToMap(other) if err != nil || otherMap == nil { - return false + return false, false } - if retryLog, ok := otherMap["retry_log"].(bool); ok && retryLog { - return true + if marker, ok := otherMap["empty_retry"].(bool); ok && marker { + emptyRetry = true } - if emptyRetry, ok := otherMap["empty_retry"].(bool); ok && emptyRetry { - return true + if retryLog, ok := otherMap["retry_log"].(bool); ok && retryLog { + errorRetry = true } - return false + return errorRetry, emptyRetry } func formatUserLog(log *Log) { @@ -491,8 +527,20 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) logger.LogError(c, "failed to record log: "+err.Error()) } if common.DataExportEnabled { + createdAt := common.GetTimestamp() gopool.Go(func() { - LogQuotaData(userId, username, params.ModelName, params.Quota, common.GetTimestamp(), params.PromptTokens+params.CompletionTokens) + LogQuotaData(QuotaDataLogParams{ + UserID: userId, + Username: username, + ModelName: params.ModelName, + Quota: params.Quota, + CreatedAt: createdAt, + TokenUsed: params.PromptTokens + params.CompletionTokens, + UseGroup: params.Group, + TokenID: params.TokenId, + ChannelID: params.ChannelId, + NodeName: common.NodeName, + }) }) } } @@ -507,6 +555,7 @@ type RecordTaskBillingLogParams struct { TokenId int Group string Other map[string]interface{} + NodeName string // 任务发起节点;为空时回退当前节点 } func RecordTaskBillingLog(params RecordTaskBillingLogParams) { @@ -520,10 +569,11 @@ func RecordTaskBillingLog(params RecordTaskBillingLogParams) { tokenName = token.Name } } + createdAt := common.GetTimestamp() log := &Log{ UserId: params.UserId, Username: username, - CreatedAt: common.GetTimestamp(), + CreatedAt: createdAt, Type: params.LogType, Content: params.Content, TokenName: tokenName, @@ -538,6 +588,25 @@ func RecordTaskBillingLog(params RecordTaskBillingLogParams) { if err != nil { common.SysLog("failed to record task billing log: " + err.Error()) } + if params.LogType == LogTypeConsume && common.DataExportEnabled { + nodeName := params.NodeName + if nodeName == "" { + nodeName = common.NodeName + } + gopool.Go(func() { + LogQuotaData(QuotaDataLogParams{ + UserID: params.UserId, + Username: username, + ModelName: params.ModelName, + Quota: params.Quota, + CreatedAt: createdAt, + UseGroup: params.Group, + TokenID: params.TokenId, + ChannelID: params.ChannelId, + NodeName: nodeName, + }) + }) + } } func GetAllLogs(logType int, logFilter string, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int, group string, requestId string, upstreamRequestId string) (logs []*Log, total int64, err error) { @@ -734,11 +803,9 @@ func SumUsedQuota(logType int, logFilter string, startTimestamp int64, endTimest } if startTimestamp != 0 { tx = tx.Where("created_at >= ?", startTimestamp) - rpmTpmQuery = rpmTpmQuery.Where("created_at >= ?", startTimestamp) } if endTimestamp != 0 { tx = tx.Where("created_at <= ?", endTimestamp) - rpmTpmQuery = rpmTpmQuery.Where("created_at <= ?", endTimestamp) } if tx, err = applyExplicitLogTextFilter(tx, "model_name", modelName); err != nil { return stat, err @@ -758,7 +825,7 @@ func SumUsedQuota(logType int, logFilter string, startTimestamp int64, endTimest if logType != LogTypeUnknown { tx = tx.Where("logs.type = ?", logType) rpmTpmQuery = rpmTpmQuery.Where("logs.type = ?", logType) - } else if logFilter != LogFilterRetry { + } else if !isRetryLogFilter(logFilter) { tx = tx.Where("logs.type = ?", LogTypeConsume) rpmTpmQuery = rpmTpmQuery.Where("logs.type = ?", LogTypeConsume) } diff --git a/model/log_test.go b/model/log_test.go index 114df37..70efa4a 100644 --- a/model/log_test.go +++ b/model/log_test.go @@ -34,9 +34,9 @@ func TestGetAllLogsRetryFilter(t *testing.T) { got, total, err := GetAllLogs(LogTypeUnknown, LogFilterRetry, 0, 0, "", "", "", 0, 10, 0, "", "", "") require.NoError(t, err) - require.EqualValues(t, 4, total) - require.Len(t, got, 4) - require.ElementsMatch(t, []int{logs[0].Id, logs[1].Id, logs[4].Id, logs[5].Id}, []int{got[0].Id, got[1].Id, got[2].Id, got[3].Id}) + require.EqualValues(t, 5, total) + require.Len(t, got, 5) + require.ElementsMatch(t, []int{logs[0].Id, logs[1].Id, logs[4].Id, logs[5].Id, logs[6].Id}, []int{got[0].Id, got[1].Id, got[2].Id, got[3].Id, got[4].Id}) } func TestGetUserLogsRetryFilter(t *testing.T) { @@ -49,10 +49,10 @@ func TestGetUserLogsRetryFilter(t *testing.T) { got, total, err := GetUserLogs(1, LogTypeUnknown, LogFilterRetry, 0, 0, "", "", 0, 10, "", "", "") require.NoError(t, err) - require.EqualValues(t, 3, total) - require.Len(t, got, 3) - gotLogIds := []int{got[0].LogId, got[1].LogId, got[2].LogId} - require.ElementsMatch(t, []int{logs[0].Id, logs[1].Id, logs[5].Id}, gotLogIds) + require.EqualValues(t, 4, total) + require.Len(t, got, 4) + gotLogIds := []int{got[0].LogId, got[1].LogId, got[2].LogId, got[3].LogId} + require.ElementsMatch(t, []int{logs[0].Id, logs[1].Id, logs[5].Id, logs[6].Id}, gotLogIds) require.NotContains(t, gotLogIds, logs[4].Id) for _, log := range got { require.Equal(t, 1, log.UserId) @@ -69,9 +69,51 @@ func TestSumUsedQuotaRetryFilter(t *testing.T) { stat, err := SumUsedQuota(LogTypeUnknown, LogFilterRetry, 0, 0, "", "", "", 0, "") require.NoError(t, err) - require.Equal(t, 1200, stat.Quota) - require.Equal(t, 4, stat.Rpm) - require.Equal(t, 71, stat.Tpm) + require.Equal(t, 1550, stat.Quota) + require.Equal(t, 5, stat.Rpm) + require.Equal(t, 96, stat.Tpm) +} + +func TestGetAllLogsRetrySubtypeFilters(t *testing.T) { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + t.Cleanup(func() { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + }) + + logs := createRetryFilterLogs(t) + + errorLogs, total, err := GetAllLogs(LogTypeUnknown, LogFilterErrorRetry, 0, 0, "", "", "", 0, 10, 0, "", "", "") + require.NoError(t, err) + require.EqualValues(t, 4, total) + require.Len(t, errorLogs, 4) + require.ElementsMatch(t, []int{logs[0].Id, logs[4].Id, logs[5].Id, logs[6].Id}, []int{errorLogs[0].Id, errorLogs[1].Id, errorLogs[2].Id, errorLogs[3].Id}) + + emptyLogs, total, err := GetAllLogs(LogTypeUnknown, LogFilterEmptyRetry, 0, 0, "", "", "", 0, 10, 0, "", "", "") + require.NoError(t, err) + require.EqualValues(t, 2, total) + require.Len(t, emptyLogs, 2) + require.ElementsMatch(t, []int{logs[1].Id, logs[6].Id}, []int{emptyLogs[0].Id, emptyLogs[1].Id}) +} + +func TestSumUsedQuotaRetrySubtypeFilters(t *testing.T) { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + t.Cleanup(func() { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + }) + + createRetryFilterLogs(t) + + errorStat, err := SumUsedQuota(LogTypeUnknown, LogFilterErrorRetry, 0, 0, "", "", "", 0, "") + require.NoError(t, err) + require.Equal(t, 1350, errorStat.Quota) + require.Equal(t, 4, errorStat.Rpm) + require.Equal(t, 81, errorStat.Tpm) + + emptyStat, err := SumUsedQuota(LogTypeUnknown, LogFilterEmptyRetry, 0, 0, "", "", "", 0, "") + require.NoError(t, err) + require.Equal(t, 550, emptyStat.Quota) + require.Equal(t, 2, emptyStat.Rpm) + require.Equal(t, 40, emptyStat.Tpm) } func TestSumUsedQuotaAppliesExplicitLogType(t *testing.T) { @@ -89,6 +131,41 @@ func TestSumUsedQuotaAppliesExplicitLogType(t *testing.T) { require.Equal(t, 0, stat.Tpm) } +func TestSumUsedQuotaKeepsRpmTpmLiveForHistoricalWindow(t *testing.T) { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + t.Cleanup(func() { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + }) + + now := time.Now().Unix() + logs := []Log{ + { + UserId: 1, + CreatedAt: now - 10, + Type: LogTypeConsume, + Quota: 100, + PromptTokens: 3, + CompletionTokens: 7, + }, + { + UserId: 1, + CreatedAt: now - 86400, + Type: LogTypeConsume, + Quota: 200, + PromptTokens: 11, + CompletionTokens: 13, + }, + } + require.NoError(t, LOG_DB.Create(&logs).Error) + + stat, err := SumUsedQuota(LogTypeUnknown, "", now-90000, now-80000, "", "", "", 0, "") + + require.NoError(t, err) + require.Equal(t, 200, stat.Quota) + require.Equal(t, 1, stat.Rpm) + require.Equal(t, 10, stat.Tpm) +} + func TestRetryFilterIgnoresNestedRetryMarker(t *testing.T) { require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) t.Cleanup(func() { @@ -166,9 +243,7 @@ func TestRetryFilterBackfillsLegacyMarkersBeforeCompletion(t *testing.T) { PromptTokens: 7, CompletionTokens: 11, Other: common.MapToJsonStr(map[string]interface{}{ - "admin_info": map[string]interface{}{ - "retry_log": true, - }, + "empty_retry": true, }), }, } @@ -177,15 +252,19 @@ func TestRetryFilterBackfillsLegacyMarkersBeforeCompletion(t *testing.T) { got, total, err := GetAllLogs(LogTypeUnknown, LogFilterRetry, 0, 0, "", "", "", 0, 10, 0, "", "", "") require.NoError(t, err) - require.EqualValues(t, 1, total) - require.Len(t, got, 1) - require.Equal(t, logs[0].Id, got[0].Id) + require.EqualValues(t, 2, total) + require.Len(t, got, 2) + require.ElementsMatch(t, []int{logs[0].Id, logs[1].Id}, []int{got[0].Id, got[1].Id}) var reloaded []Log require.NoError(t, LOG_DB.Order("id asc").Find(&reloaded).Error) require.Len(t, reloaded, 2) require.True(t, reloaded[0].IsRetry) - require.False(t, reloaded[1].IsRetry) + require.True(t, reloaded[0].IsErrorRetry) + require.False(t, reloaded[0].IsEmptyRetry) + require.True(t, reloaded[1].IsRetry) + require.False(t, reloaded[1].IsErrorRetry) + require.True(t, reloaded[1].IsEmptyRetry) } func TestRetryFilterUsesIsRetryAfterBackfillCompletion(t *testing.T) { @@ -281,7 +360,11 @@ func TestBackfillLogRetryMarkerUsesTopLevelMarkersOnly(t *testing.T) { require.NoError(t, LOG_DB.Order("id asc").Find(&reloaded).Error) require.Len(t, reloaded, 2) require.True(t, reloaded[0].IsRetry) + require.True(t, reloaded[0].IsErrorRetry) + require.False(t, reloaded[0].IsEmptyRetry) require.False(t, reloaded[1].IsRetry) + require.False(t, reloaded[1].IsErrorRetry) + require.False(t, reloaded[1].IsEmptyRetry) } func TestBackfillLogRetryMarkerSkipsAfterCompletionMarker(t *testing.T) { @@ -606,6 +689,21 @@ func createRetryFilterLogs(t *testing.T) []Log { }, }), }, + { + UserId: 1, + CreatedAt: time.Now().Unix() - 60, + Type: LogTypeConsume, + Quota: 350, + PromptTokens: 12, + CompletionTokens: 13, + Other: common.MapToJsonStr(map[string]interface{}{ + "retry_log": true, + "empty_retry": true, + "admin_info": map[string]interface{}{ + "use_channel": []string{"9", "10"}, + }, + }), + }, } require.NoError(t, LOG_DB.Create(&logs).Error) diff --git a/model/main.go b/model/main.go index b432ff6..b986b9b 100644 --- a/model/main.go +++ b/model/main.go @@ -27,7 +27,7 @@ var commonFalseVal string var logKeyCol string var logGroupCol string -const logRetryMarkerBackfillOptionKey = "LogRetryMarkerBackfillCompleted" +const logRetryMarkerBackfillOptionKey = "LogRetryMarkerBackfillCompletedV2" var logRetryMarkerBackfillMu sync.Mutex @@ -446,8 +446,8 @@ func backfillLogRetryMarker() error { for { var logs []Log if err := LOG_DB. - Select("id", "other", "is_retry"). - Where("id > ? AND is_retry = ?", lastId, false). + Select("id", "other", "is_retry", "is_error_retry", "is_empty_retry"). + Where("id > ? AND (is_retry = ? OR is_error_retry = ? OR is_empty_retry = ?)", lastId, false, false, false). Order("id asc"). Limit(batchSize). Find(&logs).Error; err != nil { @@ -457,20 +457,21 @@ func backfillLogRetryMarker() error { return markLogRetryMarkerBackfillCompleted() } - retryIds := make([]int, 0, len(logs)) for _, log := range logs { lastId = log.Id - if log.IsRetry { + errorRetry, emptyRetry := logOtherRetryMarkers(log.Other) + if log.IsRetry == (errorRetry || emptyRetry) && + log.IsErrorRetry == errorRetry && + log.IsEmptyRetry == emptyRetry { continue } - if logOtherHasRetryMarker(log.Other) { - retryIds = append(retryIds, log.Id) - } - } - if len(retryIds) > 0 { if err := LOG_DB.Model(&Log{}). - Where("id IN ?", retryIds). - Update("is_retry", true).Error; err != nil { + Where("id = ?", log.Id). + Updates(map[string]interface{}{ + "is_retry": errorRetry || emptyRetry, + "is_error_retry": errorRetry, + "is_empty_retry": emptyRetry, + }).Error; err != nil { return err } } diff --git a/model/task.go b/model/task.go index 7eec69c..9c1929f 100644 --- a/model/task.go +++ b/model/task.go @@ -105,6 +105,7 @@ type TaskPrivateData struct { BillingSource string `json:"billing_source,omitempty"` // "wallet" 或 "subscription" SubscriptionId int `json:"subscription_id,omitempty"` // 订阅 ID,用于订阅退款 TokenId int `json:"token_id,omitempty"` // 令牌 ID,用于令牌额度退款 + NodeName string `json:"node_name,omitempty"` // 发起任务的节点名,轮询结算阶段据此归属日志而非最后查询节点 BillingContext *TaskBillingContext `json:"billing_context,omitempty"` // 计费参数快照(用于轮询阶段重新计算) } @@ -293,8 +294,7 @@ func TaskGetAllTasks(startIdx int, num int, queryParams SyncTaskQueryParams) []* func GetTimedOutUnfinishedTasks(cutoffUnix int64, limit int) []*Task { var tasks []*Task - err := DB.Where("progress != ?", "100%"). - Where("status NOT IN ?", []string{TaskStatusFailure, TaskStatusSuccess}). + err := DB.Where("status NOT IN ?", []string{TaskStatusFailure, TaskStatusSuccess}). Where("submit_time < ?", cutoffUnix). Order("submit_time"). Limit(limit). @@ -308,8 +308,7 @@ func GetTimedOutUnfinishedTasks(cutoffUnix int64, limit int) []*Task { func GetAllUnFinishSyncTasks(limit int) []*Task { var tasks []*Task var err error - // get all tasks progress is not 100% - err = DB.Where("progress != ?", "100%").Where("status != ?", TaskStatusFailure).Where("status != ?", TaskStatusSuccess).Limit(limit).Order("id").Find(&tasks).Error + err = DB.Where("status NOT IN ?", []string{TaskStatusFailure, TaskStatusSuccess}).Limit(limit).Order("id").Find(&tasks).Error if err != nil { return nil } diff --git a/model/task_cas_test.go b/model/task_cas_test.go index cb3686b..b7cbe4a 100644 --- a/model/task_cas_test.go +++ b/model/task_cas_test.go @@ -177,6 +177,53 @@ func TestUpdateWithStatus_Win(t *testing.T) { assert.Equal(t, "100%", reloaded.Progress) } +func TestGetAllUnFinishSyncTasksIncludesNonTerminalHundredProgress(t *testing.T) { + truncateTables(t) + + insertTask(t, &Task{ + TaskID: "task_stuck_progress", + Status: TaskStatusInProgress, + Progress: "100%", + Data: json.RawMessage(`{}`), + }) + insertTask(t, &Task{ + TaskID: "task_done", + Status: TaskStatusSuccess, + Progress: "100%", + Data: json.RawMessage(`{}`), + }) + + tasks := GetAllUnFinishSyncTasks(10) + + require.Len(t, tasks, 1) + assert.Equal(t, "task_stuck_progress", tasks[0].TaskID) +} + +func TestGetTimedOutUnfinishedTasksIncludesNonTerminalHundredProgress(t *testing.T) { + truncateTables(t) + + now := time.Now().Unix() + insertTask(t, &Task{ + TaskID: "task_timeout_stuck_progress", + Status: TaskStatusInProgress, + Progress: "100%", + SubmitTime: now - 60, + Data: json.RawMessage(`{}`), + }) + insertTask(t, &Task{ + TaskID: "task_timeout_done", + Status: TaskStatusSuccess, + Progress: "100%", + SubmitTime: now - 60, + Data: json.RawMessage(`{}`), + }) + + tasks := GetTimedOutUnfinishedTasks(now, 10) + + require.Len(t, tasks, 1) + assert.Equal(t, "task_timeout_stuck_progress", tasks[0].TaskID) +} + func TestUpdateWithStatus_Lose(t *testing.T) { truncateTables(t) diff --git a/model/usedata.go b/model/usedata.go index a47fbeb..7b534aa 100644 --- a/model/usedata.go +++ b/model/usedata.go @@ -16,11 +16,28 @@ type QuotaData struct { Username string `json:"username" gorm:"index:idx_qdt_model_user_name,priority:2;size:64;default:''"` ModelName string `json:"model_name" gorm:"index:idx_qdt_model_user_name,priority:1;size:64;default:''"` CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_qdt_created_at,priority:2"` + UseGroup string `json:"use_group" gorm:"index;size:64;default:''"` + TokenID int `json:"token_id" gorm:"index;default:0"` + ChannelID int `json:"channel_id" gorm:"index;default:0"` + NodeName string `json:"node_name" gorm:"index;size:64;default:''"` TokenUsed int `json:"token_used" gorm:"default:0"` Count int `json:"count" gorm:"default:0"` Quota int `json:"quota" gorm:"default:0"` } +type QuotaDataLogParams struct { + UserID int + Username string + ModelName string + Quota int + CreatedAt int64 + TokenUsed int + UseGroup string + TokenID int + ChannelID int + NodeName string +} + func UpdateQuotaData() { for { if common.DataExportEnabled { @@ -34,34 +51,50 @@ func UpdateQuotaData() { var CacheQuotaData = make(map[string]*QuotaData) var CacheQuotaDataLock = sync.Mutex{} -func logQuotaDataCache(userId int, username string, modelName string, quota int, createdAt int64, tokenUsed int) { - key := fmt.Sprintf("%d-%s-%s-%d", userId, username, modelName, createdAt) - quotaData, ok := CacheQuotaData[key] +func logQuotaDataCache(quotaData *QuotaData) { + key := fmt.Sprintf("%d\x00%s\x00%s\x00%d\x00%s\x00%d\x00%d\x00%s", + quotaData.UserID, + quotaData.Username, + quotaData.ModelName, + quotaData.CreatedAt, + quotaData.UseGroup, + quotaData.TokenID, + quotaData.ChannelID, + quotaData.NodeName, + ) + count := quotaData.Count + quota := quotaData.Quota + tokenUsed := quotaData.TokenUsed + cachedQuotaData, ok := CacheQuotaData[key] if ok { - quotaData.Count += 1 - quotaData.Quota += quota - quotaData.TokenUsed += tokenUsed - } else { - quotaData = &QuotaData{ - UserID: userId, - Username: username, - ModelName: modelName, - CreatedAt: createdAt, - Count: 1, - Quota: quota, - TokenUsed: tokenUsed, - } + cachedQuotaData.Count += count + cachedQuotaData.Quota += quota + cachedQuotaData.TokenUsed += tokenUsed + quotaData = cachedQuotaData } CacheQuotaData[key] = quotaData } -func LogQuotaData(userId int, username string, modelName string, quota int, createdAt int64, tokenUsed int) { +func LogQuotaData(params QuotaDataLogParams) { // 只精确到小时 - createdAt = createdAt - (createdAt % 3600) + createdAt := params.CreatedAt - (params.CreatedAt % 3600) + quotaData := &QuotaData{ + UserID: params.UserID, + Username: params.Username, + ModelName: params.ModelName, + CreatedAt: createdAt, + UseGroup: params.UseGroup, + TokenID: params.TokenID, + ChannelID: params.ChannelID, + NodeName: params.NodeName, + Count: 1, + Quota: params.Quota, + TokenUsed: params.TokenUsed, + } CacheQuotaDataLock.Lock() defer CacheQuotaDataLock.Unlock() - logQuotaDataCache(userId, username, modelName, quota, createdAt, tokenUsed) + logQuotaDataCache(quotaData) } func SaveQuotaDataCache() { @@ -74,13 +107,15 @@ func SaveQuotaDataCache() { // 3. 如果没有数据,就插入数据 for _, quotaData := range CacheQuotaData { quotaDataDB := &QuotaData{} - DB.Table("quota_data").Where("user_id = ? and username = ? and model_name = ? and created_at = ?", - quotaData.UserID, quotaData.Username, quotaData.ModelName, quotaData.CreatedAt).First(quotaDataDB) + DB.Table("quota_data"). + Where("user_id = ? and username = ? and model_name = ? and created_at = ? and use_group = ? and token_id = ? and channel_id = ? and node_name = ?", + quotaData.UserID, quotaData.Username, quotaData.ModelName, quotaData.CreatedAt, quotaData.UseGroup, quotaData.TokenID, quotaData.ChannelID, quotaData.NodeName). + First(quotaDataDB) if quotaDataDB.Id > 0 { //quotaDataDB.Count += quotaData.Count //quotaDataDB.Quota += quotaData.Quota //DB.Table("quota_data").Save(quotaDataDB) - increaseQuotaData(quotaData.UserID, quotaData.Username, quotaData.ModelName, quotaData.Count, quotaData.Quota, quotaData.CreatedAt, quotaData.TokenUsed) + increaseQuotaData(quotaData) } else { DB.Table("quota_data").Create(quotaData) } @@ -89,13 +124,15 @@ func SaveQuotaDataCache() { common.SysLog(fmt.Sprintf("保存数据看板数据成功,共保存%d条数据", size)) } -func increaseQuotaData(userId int, username string, modelName string, count int, quota int, createdAt int64, tokenUsed int) { - err := DB.Table("quota_data").Where("user_id = ? and username = ? and model_name = ? and created_at = ?", - userId, username, modelName, createdAt).Updates(map[string]interface{}{ - "count": gorm.Expr("count + ?", count), - "quota": gorm.Expr("quota + ?", quota), - "token_used": gorm.Expr("token_used + ?", tokenUsed), - }).Error +func increaseQuotaData(quotaData *QuotaData) { + err := DB.Table("quota_data"). + Where("user_id = ? and username = ? and model_name = ? and created_at = ? and use_group = ? and token_id = ? and channel_id = ? and node_name = ?", + quotaData.UserID, quotaData.Username, quotaData.ModelName, quotaData.CreatedAt, quotaData.UseGroup, quotaData.TokenID, quotaData.ChannelID, quotaData.NodeName). + Updates(map[string]interface{}{ + "count": gorm.Expr("count + ?", quotaData.Count), + "quota": gorm.Expr("quota + ?", quotaData.Quota), + "token_used": gorm.Expr("token_used + ?", quotaData.TokenUsed), + }).Error if err != nil { common.SysLog(fmt.Sprintf("increaseQuotaData error: %s", err)) } @@ -104,14 +141,22 @@ func increaseQuotaData(userId int, username string, modelName string, count int, func GetQuotaDataByUsername(username string, startTime int64, endTime int64) (quotaData []*QuotaData, err error) { var quotaDatas []*QuotaData // 从quota_data表中查询数据 - err = DB.Table("quota_data").Where("username = ? and created_at >= ? and created_at <= ?", username, startTime, endTime).Find("aDatas).Error + err = DB.Table("quota_data"). + Select("user_id, username, model_name, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used"). + Where("username = ? and created_at >= ? and created_at <= ?", username, startTime, endTime). + Group("user_id, username, model_name, created_at"). + Find("aDatas).Error return quotaDatas, err } func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData []*QuotaData, err error) { var quotaDatas []*QuotaData // 从quota_data表中查询数据 - err = DB.Table("quota_data").Where("user_id = ? and created_at >= ? and created_at <= ?", userId, startTime, endTime).Find("aDatas).Error + err = DB.Table("quota_data"). + Select("user_id, username, model_name, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used"). + Where("user_id = ? and created_at >= ? and created_at <= ?", userId, startTime, endTime). + Group("user_id, username, model_name, created_at"). + Find("aDatas).Error return quotaDatas, err } diff --git a/relay/channel/advancedcustom/adaptor.go b/relay/channel/advancedcustom/adaptor.go index 1198137..9ac043c 100644 --- a/relay/channel/advancedcustom/adaptor.go +++ b/relay/channel/advancedcustom/adaptor.go @@ -103,10 +103,18 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo if err != nil { return nil, err } - if converter != dto.AdvancedCustomConverterNone { + switch converter { + case dto.AdvancedCustomConverterNone: + return a.convertOpenAICompatibleResponsesRequest(c, info, request) + case dto.AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions: + chatReq, err := service.ResponsesRequestToChatCompletionsRequest(&request) + if err != nil { + return nil, err + } + return a.convertOpenAICompatibleRequest(c, info, chatReq) + default: return nil, fmt.Errorf("converter %q does not support OpenAI Responses requests", converter) } - return a.convertOpenAICompatibleResponsesRequest(c, info, request) } func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { @@ -220,6 +228,11 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom return openai.OaiResponsesToChatStreamHandler(c, info, resp) } return openai.OaiResponsesToChatHandler(c, info, resp) + case dto.AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions: + if info.IsStream { + return openai.OaiChatToResponsesStreamHandler(c, info, resp) + } + return openai.OaiChatToResponsesHandler(c, info, resp) default: return nil, types.NewOpenAIError(fmt.Errorf("unsupported advanced custom converter: %s", converter), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) } diff --git a/relay/channel/advancedcustom/adaptor_test.go b/relay/channel/advancedcustom/adaptor_test.go index 4f609e9..06251bb 100644 --- a/relay/channel/advancedcustom/adaptor_test.go +++ b/relay/channel/advancedcustom/adaptor_test.go @@ -6,6 +6,7 @@ import ( "net/url" "testing" + "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/constant" "github.com/MAX-API-Next/MAX-API/dto" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" @@ -368,6 +369,44 @@ func TestAdaptorMatchesGeminiIncomingPathTemplate(t *testing.T) { } } +func TestAdaptorConvertsResponsesRequestToOpenAIChatUpstream(t *testing.T) { + adaptor := &Adaptor{} + info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{ + Routes: []dto.AdvancedCustomRoute{ + { + IncomingPath: "/v1/responses", + UpstreamPath: "/v1/chat/completions", + Converter: dto.AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions, + }, + }, + }) + info.RelayMode = relayconstant.RelayModeResponses + info.RequestURLPath = "/v1/responses" + c := advancedCustomGinContext("/v1/responses") + + converted, err := adaptor.ConvertOpenAIResponsesRequest(c, info, dto.OpenAIResponsesRequest{ + Model: "gpt-test", + Instructions: mustAdvancedCustomRawMessage(t, "system rules"), + Input: mustAdvancedCustomRawMessage(t, "hello"), + }) + require.NoError(t, err) + + chatReq, ok := converted.(*dto.GeneralOpenAIRequest) + require.True(t, ok) + assert.Equal(t, "gpt-test", chatReq.Model) + require.Len(t, chatReq.Messages, 2) + assert.Equal(t, "system", chatReq.Messages[0].Role) + assert.Equal(t, "system rules", chatReq.Messages[0].StringContent()) + assert.Equal(t, "user", chatReq.Messages[1].Role) + assert.Equal(t, "hello", chatReq.Messages[1].StringContent()) + + requestURL, err := adaptor.GetRequestURL(info) + require.NoError(t, err) + parsedURL, err := url.Parse(requestURL) + require.NoError(t, err) + assert.Equal(t, "/v1/chat/completions", parsedURL.Path) +} + func advancedCustomRelayInfo(config *dto.AdvancedCustomConfig) *relaycommon.RelayInfo { return &relaycommon.RelayInfo{ RelayFormat: types.RelayFormatOpenAI, @@ -391,3 +430,10 @@ func advancedCustomGinContext(path string) *gin.Context { c.Request.Header.Set("Content-Type", "application/json") return c } + +func mustAdvancedCustomRawMessage(t *testing.T, value any) []byte { + t.Helper() + raw, err := common.Marshal(value) + require.NoError(t, err) + return raw +} diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go index 93c8856..0ee2a73 100644 --- a/relay/channel/api_request.go +++ b/relay/channel/api_request.go @@ -194,6 +194,10 @@ func processHeaderOverride(info *common.RelayInfo, c *gin.Context) (map[string]s } headerOverrideSource := common.GetEffectiveHeaderOverride(info) + apiKey := "" + if info.ChannelMeta != nil { + apiKey = info.ApiKey + } passAll := false var passthroughRegex []*regexp.Regexp @@ -274,7 +278,7 @@ func processHeaderOverride(info *common.RelayInfo, c *gin.Context) (map[string]s continue } - value, include, err := applyHeaderOverridePlaceholders(str, c, info.ApiKey) + value, include, err := applyHeaderOverridePlaceholders(str, c, apiKey) if err != nil { return nil, types.NewError(err, types.ErrorCodeChannelHeaderOverrideInvalid) } @@ -532,24 +536,68 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http return resp, nil } +func newTaskHTTPRequest(method string, fullRequestURL string, requestBody io.Reader, info *common.RelayInfo) (*http.Request, error) { + req, err := http.NewRequest(method, fullRequestURL, requestBody) + if err != nil { + return nil, err + } + applyUpstreamContentLength(req, info) + if req.GetBody == nil { + attachSeekableGetBody(req, requestBody) + } + return req, nil +} + +func attachSeekableGetBody(req *http.Request, reader io.Reader) { + if req == nil { + return + } + seeker, ok := reader.(io.ReadSeeker) + if !ok { + return + } + start, err := seeker.Seek(0, io.SeekCurrent) + if err != nil { + return + } + end, err := seeker.Seek(0, io.SeekEnd) + if err != nil { + _, _ = seeker.Seek(start, io.SeekStart) + return + } + if _, err := seeker.Seek(start, io.SeekStart); err != nil { + return + } + if req.ContentLength <= 0 && end >= start { + req.ContentLength = end - start + } + req.GetBody = func() (io.ReadCloser, error) { + if _, err := seeker.Seek(start, io.SeekStart); err != nil { + return nil, err + } + return io.NopCloser(seeker), nil + } +} + func DoTaskApiRequest(a TaskAdaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { fullRequestURL, err := a.BuildRequestURL(info) if err != nil { return nil, err } - req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody) + req, err := newTaskHTTPRequest(c.Request.Method, fullRequestURL, requestBody, info) if err != nil { return nil, fmt.Errorf("new request failed: %w", err) } - applyUpstreamContentLength(req, info) - req.GetBody = func() (io.ReadCloser, error) { - return io.NopCloser(requestBody), nil - } err = a.BuildRequestHeader(c, req, info) if err != nil { return nil, fmt.Errorf("setup request header failed: %w", err) } + headerOverride, err := processHeaderOverride(info, c) + if err != nil { + return nil, err + } + applyHeaderOverrideToRequest(req, headerOverride) resp, err := doRequest(c, req, info) if err != nil { return nil, fmt.Errorf("do request failed: %w", err) diff --git a/relay/channel/api_request_test.go b/relay/channel/api_request_test.go index 37cf56f..b226b0b 100644 --- a/relay/channel/api_request_test.go +++ b/relay/channel/api_request_test.go @@ -1,15 +1,133 @@ package channel import ( + "io" "net/http" "net/http/httptest" + "strings" "testing" + "github.com/MAX-API-Next/MAX-API/dto" + "github.com/MAX-API-Next/MAX-API/model" + "github.com/MAX-API-Next/MAX-API/relay/channel/task/taskcommon" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/MAX-API-Next/MAX-API/service" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) +type taskHeaderAdaptor struct { + taskcommon.BaseBilling + url string +} + +func (a taskHeaderAdaptor) Init(*relaycommon.RelayInfo) {} + +func (a taskHeaderAdaptor) ValidateRequestAndSetAction(*gin.Context, *relaycommon.RelayInfo) *dto.TaskError { + return nil +} + +func (a taskHeaderAdaptor) BuildRequestURL(*relaycommon.RelayInfo) (string, error) { + return a.url, nil +} + +func (a taskHeaderAdaptor) BuildRequestHeader(_ *gin.Context, req *http.Request, _ *relaycommon.RelayInfo) error { + req.Header.Set("X-Default", "default") + return nil +} + +func (a taskHeaderAdaptor) BuildRequestBody(*gin.Context, *relaycommon.RelayInfo) (io.Reader, error) { + return nil, nil +} + +func (a taskHeaderAdaptor) DoRequest(*gin.Context, *relaycommon.RelayInfo, io.Reader) (*http.Response, error) { + return nil, nil +} + +func (a taskHeaderAdaptor) DoResponse(*gin.Context, *http.Response, *relaycommon.RelayInfo) (string, []byte, *dto.TaskError) { + return "", nil, nil +} + +func (a taskHeaderAdaptor) GetModelList() []string { + return nil +} + +func (a taskHeaderAdaptor) GetChannelName() string { + return "test" +} + +func (a taskHeaderAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) { + return nil, nil +} + +func (a taskHeaderAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) { + return nil, nil +} + +func (a taskHeaderAdaptor) AdjustBillingOnComplete(*model.Task, *relaycommon.TaskInfo) int { + return 0 +} + +type countingReader struct { + reads int +} + +func (r *countingReader) Read([]byte) (int, error) { + r.reads++ + return 0, io.EOF +} + +type customReadSeeker struct { + reader *strings.Reader +} + +func (r *customReadSeeker) Read(p []byte) (int, error) { + return r.reader.Read(p) +} + +func (r *customReadSeeker) Seek(offset int64, whence int) (int64, error) { + return r.reader.Seek(offset, whence) +} + +func TestNewTaskHTTPRequestDoesNotPreReadRequestBody(t *testing.T) { + t.Parallel() + + body := &countingReader{} + info := &relaycommon.RelayInfo{ + UpstreamRequestBodySize: 512, + } + + req, err := newTaskHTTPRequest(http.MethodPost, "https://example.com/tasks", body, info) + + require.NoError(t, err) + require.Equal(t, 0, body.reads) + require.Equal(t, int64(512), req.ContentLength) + require.Nil(t, req.GetBody) + + _, _ = req.Body.Read(make([]byte, 1)) + require.Equal(t, 1, body.reads) +} + +func TestNewTaskHTTPRequestUsesSeekableGetBodyWithoutBuffering(t *testing.T) { + t.Parallel() + + body := &customReadSeeker{reader: strings.NewReader("abcdef")} + _, err := body.Seek(2, io.SeekStart) + require.NoError(t, err) + + req, err := newTaskHTTPRequest(http.MethodPost, "https://example.com/tasks", body, &relaycommon.RelayInfo{}) + + require.NoError(t, err) + require.Equal(t, int64(4), req.ContentLength) + require.NotNil(t, req.GetBody) + + replay, err := req.GetBody() + require.NoError(t, err) + replayed, err := io.ReadAll(replay) + require.NoError(t, err) + require.Equal(t, "cdef", string(replayed)) +} + func TestProcessHeaderOverride_ChannelTestSkipsPassthroughRules(t *testing.T) { t.Parallel() @@ -191,3 +309,40 @@ func TestProcessHeaderOverride_PassHeadersTemplateSetsRuntimeHeaders(t *testing. require.Equal(t, "sess-123", upstreamReq.Header.Get("Session_id")) require.Empty(t, upstreamReq.Header.Get("X-Codex-Beta-Features")) } + +func TestDoTaskApiRequestAppliesRuntimeHeaderOverride(t *testing.T) { + t.Parallel() + + gin.SetMode(gin.TestMode) + service.InitHttpClient() + var gotDefault string + var gotRuntime string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotDefault = r.Header.Get("X-Default") + gotRuntime = r.Header.Get("X-Task-Runtime") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":"task_123"}`)) + })) + t.Cleanup(server.Close) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", strings.NewReader(`{"prompt":"test"}`)) + + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{}, + UseRuntimeHeadersOverride: true, + RuntimeHeadersOverride: map[string]interface{}{ + "X-Default": "overridden", + "X-Task-Runtime": "enabled", + }, + } + + resp, err := DoTaskApiRequest(taskHeaderAdaptor{url: server.URL}, ctx, info, strings.NewReader(`{"prompt":"test"}`)) + + require.NoError(t, err) + require.NotNil(t, resp) + require.NoError(t, resp.Body.Close()) + require.Equal(t, "overridden", gotDefault) + require.Equal(t, "enabled", gotRuntime) +} diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index 22a4ae8..1a544fc 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -464,9 +464,10 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe if message.ToolCalls != nil { for _, toolCall := range message.ParseToolCalls() { inputObj := make(map[string]any) - if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &inputObj); err != nil { - common.SysLog("tool call function arguments is not a map[string]any: " + fmt.Sprintf("%v", toolCall.Function.Arguments)) - continue + if args := toolCall.Function.Arguments; args != "" { + if err := common.Unmarshal([]byte(args), &inputObj); err != nil { + return nil, fmt.Errorf("tool call function arguments is not valid JSON object: %w", err) + } } claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{ Type: "tool_use", diff --git a/relay/channel/claude/relay_claude_test.go b/relay/channel/claude/relay_claude_test.go index 761fcfd..c4a15c8 100644 --- a/relay/channel/claude/relay_claude_test.go +++ b/relay/channel/claude/relay_claude_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/dto" "github.com/stretchr/testify/require" ) @@ -304,9 +305,11 @@ func TestRequestOpenAI2ClaudeMessage_IgnoresUnsupportedFileContent(t *testing.T) claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request) require.NoError(t, err) - require.Len(t, claudeRequest.Messages, 1) + require.NotEmpty(t, claudeRequest.Messages) - content, ok := claudeRequest.Messages[0].Content.([]dto.ClaudeMediaMessage) + claudeMessage := claudeRequest.Messages[len(claudeRequest.Messages)-1] + require.Equal(t, "user", claudeMessage.Role) + content, ok := claudeMessage.Content.([]dto.ClaudeMediaMessage) require.True(t, ok) require.Len(t, content, 1) require.Equal(t, "text", content[0].Type) @@ -314,6 +317,79 @@ func TestRequestOpenAI2ClaudeMessage_IgnoresUnsupportedFileContent(t *testing.T) require.Equal(t, "see attachment", *content[0].Text) } +func TestRequestOpenAI2ClaudeMessage_PreservesToolUseWithEmptyArguments(t *testing.T) { + rawToolCalls, err := common.Marshal([]dto.ToolCallRequest{ + { + ID: "call_1", + Type: "function", + Function: dto.FunctionRequest{ + Name: "lookup", + Arguments: "", + }, + }, + }) + require.NoError(t, err) + + request := dto.GeneralOpenAIRequest{ + Model: "claude-3-5-sonnet", + Messages: []dto.Message{ + { + Role: "assistant", + ToolCalls: rawToolCalls, + }, + }, + } + + claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request) + require.NoError(t, err) + require.NotEmpty(t, claudeRequest.Messages) + + assistantMessage := claudeRequest.Messages[len(claudeRequest.Messages)-1] + require.Equal(t, "assistant", assistantMessage.Role) + content, ok := assistantMessage.Content.([]dto.ClaudeMediaMessage) + require.True(t, ok) + var toolUse *dto.ClaudeMediaMessage + for i := range content { + if content[i].Type == "tool_use" { + toolUse = &content[i] + break + } + } + require.NotNil(t, toolUse) + require.Equal(t, "call_1", toolUse.Id) + require.Equal(t, "lookup", toolUse.Name) + require.Empty(t, toolUse.Input) +} + +func TestRequestOpenAI2ClaudeMessage_ReturnsErrorForMalformedToolArguments(t *testing.T) { + rawToolCalls, err := common.Marshal([]dto.ToolCallRequest{ + { + ID: "call_1", + Type: "function", + Function: dto.FunctionRequest{ + Name: "lookup", + Arguments: "{", + }, + }, + }) + require.NoError(t, err) + + request := dto.GeneralOpenAIRequest{ + Model: "claude-3-5-sonnet", + Messages: []dto.Message{ + { + Role: "assistant", + ToolCalls: rawToolCalls, + }, + }, + } + + claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request) + require.Error(t, err) + require.Nil(t, claudeRequest) + require.Contains(t, err.Error(), "tool call function arguments is not valid JSON object") +} + func TestRequestOpenAI2ClaudeMessage_ClaudeOpus48HighUsesAdaptiveThinking(t *testing.T) { request := dto.GeneralOpenAIRequest{ Model: "claude-opus-4-8-high", diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go index e59df2c..6f758cb 100644 --- a/relay/channel/gemini/adaptor.go +++ b/relay/channel/gemini/adaptor.go @@ -12,6 +12,7 @@ import ( "github.com/MAX-API-Next/MAX-API/relay/channel/openai" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" "github.com/MAX-API-Next/MAX-API/relay/constant" + "github.com/MAX-API-Next/MAX-API/service" "github.com/MAX-API-Next/MAX-API/setting/model_setting" "github.com/MAX-API-Next/MAX-API/setting/reasoning" "github.com/MAX-API-Next/MAX-API/types" @@ -238,8 +239,15 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela } func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { - // TODO implement me - return nil, errors.New("not implemented") + request, err := preprocessGeminiOpenAIResponsesRequest(request) + if err != nil { + return nil, err + } + chatRequest, err := service.ResponsesRequestToChatCompletionsRequest(&request) + if err != nil { + return nil, err + } + return a.ConvertOpenAIRequest(c, info, chatRequest) } func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { @@ -247,6 +255,13 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request } func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.MaxAPIError) { + if info.RelayMode == constant.RelayModeResponses { + if info.IsStream { + return GeminiResponsesStreamHandler(c, info, resp) + } + return GeminiResponsesHandler(c, info, resp) + } + if info.RelayMode == constant.RelayModeGemini { if strings.Contains(info.RequestURLPath, ":embedContent") || strings.Contains(info.RequestURLPath, ":batchEmbedContents") { diff --git a/relay/channel/gemini/adaptor_responses.go b/relay/channel/gemini/adaptor_responses.go new file mode 100644 index 0000000..87c3cb5 --- /dev/null +++ b/relay/channel/gemini/adaptor_responses.go @@ -0,0 +1,99 @@ +package gemini + +import ( + "fmt" + "strings" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/dto" +) + +const ( + geminiResponsesInputTypeCustomToolCall = "custom_tool_call" + geminiResponsesInputTypeCustomToolCallOutput = "custom_tool_call_output" + geminiResponsesInputTypeFunctionCallOutput = "function_call_output" +) + +func preprocessGeminiOpenAIResponsesRequest(request dto.OpenAIResponsesRequest) (dto.OpenAIResponsesRequest, error) { + tools, err := filterGeminiResponsesTools(request.Tools) + if err != nil { + return request, err + } + request.Tools = tools + + input, err := filterGeminiResponsesInput(request.Input) + if err != nil { + return request, err + } + request.Input = input + + return request, nil +} + +func filterGeminiResponsesTools(raw []byte) ([]byte, error) { + if !geminiRawJSONPresent(raw) || common.GetJsonType(raw) != "array" { + return raw, nil + } + + var tools []map[string]any + if err := common.Unmarshal(raw, &tools); err != nil { + return nil, err + } + + filtered := make([]map[string]any, 0, len(tools)) + for _, tool := range tools { + toolType := strings.TrimSpace(common.Interface2String(tool["type"])) + if toolType != "function" { + return nil, fmt.Errorf("gemini responses conversion does not support tool type %q", toolType) + } + filtered = append(filtered, tool) + } + if len(filtered) == 0 { + return nil, nil + } + return common.Marshal(filtered) +} + +func filterGeminiResponsesInput(raw []byte) ([]byte, error) { + if !geminiRawJSONPresent(raw) || common.GetJsonType(raw) != "array" { + return raw, nil + } + + var items []map[string]any + if err := common.Unmarshal(raw, &items); err != nil { + return nil, err + } + + skippedCustomCallIDs := make(map[string]struct{}) + for _, item := range items { + if strings.TrimSpace(common.Interface2String(item["type"])) != geminiResponsesInputTypeCustomToolCall { + continue + } + if callID := strings.TrimSpace(common.Interface2String(item["call_id"])); callID != "" { + skippedCustomCallIDs[callID] = struct{}{} + } + } + + filtered := make([]map[string]any, 0, len(items)) + for _, item := range items { + itemType := strings.TrimSpace(common.Interface2String(item["type"])) + switch itemType { + case geminiResponsesInputTypeCustomToolCall, geminiResponsesInputTypeCustomToolCallOutput: + continue + case geminiResponsesInputTypeFunctionCallOutput: + if _, ok := skippedCustomCallIDs[strings.TrimSpace(common.Interface2String(item["call_id"]))]; ok { + continue + } + } + filtered = append(filtered, item) + } + + return common.Marshal(filtered) +} + +func geminiRawJSONPresent(raw []byte) bool { + if len(raw) == 0 { + return false + } + return common.GetJsonType(raw) != "null" +} diff --git a/relay/channel/gemini/adaptor_responses_test.go b/relay/channel/gemini/adaptor_responses_test.go new file mode 100644 index 0000000..73dd264 --- /dev/null +++ b/relay/channel/gemini/adaptor_responses_test.go @@ -0,0 +1,130 @@ +package gemini + +import ( + "fmt" + "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/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConvertOpenAIResponsesRequestToGeminiInstructionsAndInput(t *testing.T) { + got := mustConvertResponsesToGemini(t, dto.OpenAIResponsesRequest{ + Model: "gemini-test", + Instructions: mustGeminiRawMessage(t, "system rules"), + Input: mustGeminiRawMessage(t, "hello"), + }) + + require.NotNil(t, got.SystemInstructions) + require.Len(t, got.SystemInstructions.Parts, 1) + assert.Equal(t, "system rules", got.SystemInstructions.Parts[0].Text) + require.Len(t, got.Contents, 1) + assert.Equal(t, "user", got.Contents[0].Role) + require.Len(t, got.Contents[0].Parts, 1) + assert.Equal(t, "hello", got.Contents[0].Parts[0].Text) +} + +func TestConvertOpenAIResponsesRequestToGeminiSkipsCustomToolCalls(t *testing.T) { + got := mustConvertResponsesToGemini(t, dto.OpenAIResponsesRequest{ + Model: "gemini-test", + Input: mustGeminiRawMessage(t, []map[string]any{ + { + "role": "assistant", + "content": []map[string]any{ + {"type": "output_text", "text": "before custom"}, + }, + }, + { + "type": "custom_tool_call", + "call_id": "call_custom", + "name": "apply_patch", + "input": "patch body", + }, + { + "type": "custom_tool_call_output", + "call_id": "call_custom", + "output": "ok", + }, + { + "type": "function_call_output", + "call_id": "call_custom", + "output": "legacy custom output", + }, + { + "role": "user", + "content": "next turn", + }, + }), + Tools: mustGeminiRawMessage(t, []map[string]any{ + { + "type": "function", + "name": "lookup", + "description": "Lookup data", + "parameters": map[string]any{ + "type": "object", + }, + }, + }), + }) + + require.Len(t, got.GetTools(), 1) + require.Len(t, got.Contents, 2) + assert.Equal(t, "model", got.Contents[0].Role) + require.Len(t, got.Contents[0].Parts, 1) + assert.Equal(t, "before custom", got.Contents[0].Parts[0].Text) + assert.Nil(t, got.Contents[0].Parts[0].FunctionCall) + + assert.Equal(t, "user", got.Contents[1].Role) + require.Len(t, got.Contents[1].Parts, 1) + assert.Equal(t, "next turn", got.Contents[1].Parts[0].Text) + assert.Nil(t, got.Contents[1].Parts[0].FunctionResponse) +} + +func TestConvertOpenAIResponsesRequestToGeminiRejectsUnsupportedTools(t *testing.T) { + got, err := convertResponsesToGemini(dto.OpenAIResponsesRequest{ + Model: "gemini-test", + Input: mustGeminiRawMessage(t, "hello"), + Tools: mustGeminiRawMessage(t, []map[string]any{ + {"type": "web_search_preview"}, + }), + }) + + require.Error(t, err) + require.Nil(t, got) + assert.Contains(t, err.Error(), `tool type "web_search_preview"`) +} + +func mustConvertResponsesToGemini(t *testing.T, req dto.OpenAIResponsesRequest) *dto.GeminiChatRequest { + t.Helper() + got, err := convertResponsesToGemini(req) + require.NoError(t, err) + return got +} + +func convertResponsesToGemini(req dto.OpenAIResponsesRequest) (*dto.GeminiChatRequest, error) { + info := &relaycommon.RelayInfo{ + OriginModelName: req.Model, + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: req.Model, + }, + } + got, err := (&Adaptor{}).ConvertOpenAIResponsesRequest(nil, info, req) + if err != nil { + return nil, err + } + geminiReq, ok := got.(*dto.GeminiChatRequest) + if !ok { + return nil, fmt.Errorf("unexpected converted request type %T", got) + } + return geminiReq, nil +} + +func mustGeminiRawMessage(t *testing.T, value any) []byte { + t.Helper() + raw, err := common.Marshal(value) + require.NoError(t, err) + return raw +} diff --git a/relay/channel/gemini/relay_gemini_usage_test.go b/relay/channel/gemini/relay_gemini_usage_test.go index 2c95a3d..91b2f3b 100644 --- a/relay/channel/gemini/relay_gemini_usage_test.go +++ b/relay/channel/gemini/relay_gemini_usage_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" "github.com/MAX-API-Next/MAX-API/common" @@ -269,6 +270,63 @@ func TestGeminiStreamHandlerUsesEstimatedPromptTokensWhenUsagePromptMissing(t *t require.Equal(t, 110, usage.TotalTokens) } +func TestGeminiResponsesStreamHandlerEstimatesUsageAndAuditWhenUpstreamUsageMissing(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + originalLogResponseContentEnabled := common.LogResponseContentEnabled + common.LogResponseContentEnabled = true + t.Cleanup(func() { + common.LogResponseContentEnabled = originalLogResponseContentEnabled + }) + + info := &relaycommon.RelayInfo{ + OriginModelName: "gemini-3-flash-preview", + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "gemini-3-flash-preview", + }, + } + info.SetEstimatePromptTokens(20) + + chunk := dto.GeminiChatResponse{ + Candidates: []dto.GeminiChatCandidate{ + { + Content: dto.GeminiChatContent{ + Role: "model", + Parts: []dto.GeminiPart{ + {Text: "partial"}, + }, + }, + }, + }, + } + chunkData, err := common.Marshal(chunk) + require.NoError(t, err) + + streamBody := []byte("data: " + string(chunkData) + "\n" + "data: [DONE]\n") + resp := &http.Response{ + Body: io.NopCloser(bytes.NewReader(streamBody)), + } + + usage, maxAPIError := GeminiResponsesStreamHandler(c, info, resp) + require.Nil(t, maxAPIError) + require.NotNil(t, usage) + require.Equal(t, 20, usage.PromptTokens) + require.Positive(t, usage.CompletionTokens) + require.Positive(t, usage.TotalTokens) + require.Equal(t, "partial", info.AuditResponseContent) + + var completed dto.ResponsesStreamResponse + require.NoError(t, common.UnmarshalJsonStr(sseDataForEvent(t, recorder.Body.String(), "response.completed"), &completed)) + require.NotNil(t, completed.Response) + require.NotNil(t, completed.Response.Usage) + require.Equal(t, 20, completed.Response.Usage.InputTokens) + require.Positive(t, completed.Response.Usage.OutputTokens) + require.Positive(t, completed.Response.Usage.TotalTokens) +} + func TestGeminiTextGenerationHandlerUsesEstimatedPromptTokensWhenUsagePromptMissing(t *testing.T) { t.Parallel() @@ -318,3 +376,19 @@ func TestGeminiTextGenerationHandlerUsesEstimatedPromptTokensWhenUsagePromptMiss require.Equal(t, 100, usage.CompletionTokens) require.Equal(t, 110, usage.TotalTokens) } + +func sseDataForEvent(t *testing.T, body string, eventType string) string { + t.Helper() + for _, event := range strings.Split(body, "\n\n") { + if !strings.Contains(event, "event: "+eventType) { + continue + } + for _, line := range strings.Split(event, "\n") { + if strings.HasPrefix(line, "data: ") { + return strings.TrimSpace(strings.TrimPrefix(line, "data: ")) + } + } + } + t.Fatalf("event %q not found in body:\n%s", eventType, body) + return "" +} diff --git a/relay/channel/gemini/relay_responses.go b/relay/channel/gemini/relay_responses.go new file mode 100644 index 0000000..b0535a7 --- /dev/null +++ b/relay/channel/gemini/relay_responses.go @@ -0,0 +1,175 @@ +package gemini + +import ( + "errors" + "fmt" + "io" + "net/http" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/dto" + "github.com/MAX-API-Next/MAX-API/logger" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/MAX-API-Next/MAX-API/relay/helper" + "github.com/MAX-API-Next/MAX-API/service" + "github.com/MAX-API-Next/MAX-API/service/openaicompat" + "github.com/MAX-API-Next/MAX-API/types" + "github.com/gin-gonic/gin" +) + +func GeminiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.MaxAPIError) { + defer service.CloseResponseBodyGracefully(resp) + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + logger.LogDebug(c, "Gemini responses response body: %s", responseBody) + + var geminiResponse dto.GeminiChatResponse + if err := common.Unmarshal(responseBody, &geminiResponse); err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if len(geminiResponse.Candidates) == 0 { + usage := buildUsageFromGeminiMetadata(geminiResponse.UsageMetadata, info.GetEstimatePromptTokens()) + if geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil { + common.SetContextKey(c, constant.ContextKeyAdminRejectReason, fmt.Sprintf("gemini_block_reason=%s", *geminiResponse.PromptFeedback.BlockReason)) + return &usage, types.NewOpenAIError( + errors.New("request blocked by Gemini API: "+*geminiResponse.PromptFeedback.BlockReason), + types.ErrorCodePromptBlocked, + http.StatusBadRequest, + ) + } + common.SetContextKey(c, constant.ContextKeyAdminRejectReason, "gemini_empty_candidates") + return &usage, types.NewOpenAIError( + errors.New("empty response from Gemini API"), + types.ErrorCodeEmptyResponse, + http.StatusInternalServerError, + ) + } + + chatResp := responseGeminiChat2OpenAI(c, &geminiResponse) + chatResp.Model = info.UpstreamModelName + usage := buildUsageFromGeminiMetadata(geminiResponse.UsageMetadata, info.GetEstimatePromptTokens()) + chatResp.Usage = usage + + responsesResp, responsesUsage, err := service.ChatCompletionsResponseToResponsesResponse(chatResp, helper.GetResponseID(c)) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if responsesUsage == nil || responsesUsage.TotalTokens == 0 { + responsesResp.Usage = openaicompat.UsageFromChatUsage(&usage) + } + if service.ResponseAuditEnabled() { + service.SetRelayResponseAuditContent(info, service.BuildGeminiResponseAuditContent(&geminiResponse)) + } + + responseBody, err = common.Marshal(responsesResp) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError) + } + service.IOCopyBytesGracefully(c, resp, responseBody) + return &usage, nil +} + +func GeminiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.MaxAPIError) { + responseID := helper.GetResponseID(c) + created := common.GetTimestamp() + state := openaicompat.NewChatToResponsesStreamState(responseID, info.UpstreamModelName) + state.Created = created + finishReason := constant.FinishReasonStop + toolCallIndexByChoice := make(map[int]map[string]int) + nextToolCallIndexByChoice := make(map[int]int) + var streamErr *types.MaxAPIError + + sendEvent := func(event openaicompat.ChatToResponsesStreamEvent) bool { + data, err := common.Marshal(event.Payload) + if err != nil { + streamErr = types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError) + return false + } + helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: event.Type}, string(data)) + return true + } + sendChunk := func(chunk *dto.ChatCompletionsStreamResponse) bool { + events, err := openaicompat.ChatCompletionsStreamChunkToResponsesEvents(chunk, state) + if err != nil { + streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError) + return false + } + for _, event := range events { + if !sendEvent(event) { + return false + } + } + return true + } + + usage, err := geminiStreamHandler(c, info, resp, func(data string, geminiResponse *dto.GeminiChatResponse) bool { + response, isStop := streamResponseGeminiChat2OpenAI(geminiResponse) + response.Id = responseID + response.Created = created + response.Model = info.UpstreamModelName + + if response.IsToolCall() { + finishReason = constant.FinishReasonToolCalls + } + for choiceIdx := range response.Choices { + choiceKey := response.Choices[choiceIdx].Index + for toolIdx := range response.Choices[choiceIdx].Delta.ToolCalls { + tool := &response.Choices[choiceIdx].Delta.ToolCalls[toolIdx] + if tool.ID == "" { + continue + } + indexByID := toolCallIndexByChoice[choiceKey] + if indexByID == nil { + indexByID = make(map[string]int) + toolCallIndexByChoice[choiceKey] = indexByID + } + if idx, ok := indexByID[tool.ID]; ok { + tool.SetIndex(idx) + continue + } + idx := nextToolCallIndexByChoice[choiceKey] + nextToolCallIndexByChoice[choiceKey] = idx + 1 + indexByID[tool.ID] = idx + tool.SetIndex(idx) + } + } + + if !sendChunk(response) { + return false + } + if isStop { + return sendChunk(helper.GenerateStopResponse(responseID, created, info.UpstreamModelName, finishReason)) + } + return true + }) + if err != nil { + return usage, err + } + if streamErr != nil { + return nil, streamErr + } + + if usage != nil { + state.Usage = openaicompat.UsageFromChatUsage(usage) + } + if state.Usage == nil || state.Usage.TotalTokens == 0 { + estimated := service.ResponseText2Usage(c, state.UsageText(), info.UpstreamModelName, info.GetEstimatePromptTokens()) + state.Usage = openaicompat.UsageFromChatUsage(estimated) + if usage == nil || usage.TotalTokens == 0 { + usage = estimated + } + } + for _, event := range openaicompat.FinalizeChatCompletionsStreamToResponses(state) { + if !sendEvent(event) { + return nil, streamErr + } + } + if service.ResponseAuditEnabled() { + service.SetRelayResponseAuditContent(info, state.UsageText()) + } + return usage, nil +} diff --git a/relay/channel/openai/chat_via_responses.go b/relay/channel/openai/chat_via_responses.go index 3500624..2967f3a 100644 --- a/relay/channel/openai/chat_via_responses.go +++ b/relay/channel/openai/chat_via_responses.go @@ -13,6 +13,7 @@ import ( relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" "github.com/MAX-API-Next/MAX-API/relay/helper" "github.com/MAX-API-Next/MAX-API/service" + "github.com/MAX-API-Next/MAX-API/service/openaicompat" "github.com/MAX-API-Next/MAX-API/types" "github.com/gin-gonic/gin" @@ -481,27 +482,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo createAt = int64(streamResp.Response.CreatedAt) } if streamResp.Response.Usage != nil { - if streamResp.Response.Usage.InputTokens != 0 { - usage.PromptTokens = streamResp.Response.Usage.InputTokens - usage.InputTokens = streamResp.Response.Usage.InputTokens - } - if streamResp.Response.Usage.OutputTokens != 0 { - usage.CompletionTokens = streamResp.Response.Usage.OutputTokens - usage.OutputTokens = streamResp.Response.Usage.OutputTokens - } - if streamResp.Response.Usage.TotalTokens != 0 { - usage.TotalTokens = streamResp.Response.Usage.TotalTokens - } else { - usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens - } - if streamResp.Response.Usage.InputTokensDetails != nil { - usage.PromptTokensDetails.CachedTokens = streamResp.Response.Usage.InputTokensDetails.CachedTokens - usage.PromptTokensDetails.ImageTokens = streamResp.Response.Usage.InputTokensDetails.ImageTokens - usage.PromptTokensDetails.AudioTokens = streamResp.Response.Usage.InputTokensDetails.AudioTokens - } - if streamResp.Response.Usage.CompletionTokenDetails.ReasoningTokens != 0 { - usage.CompletionTokenDetails.ReasoningTokens = streamResp.Response.Usage.CompletionTokenDetails.ReasoningTokens - } + usage = openaicompat.UsageFromResponsesUsage(streamResp.Response.Usage) } if outputText.Len() == 0 { completedText := service.ExtractOutputTextFromResponses(streamResp.Response) diff --git a/relay/channel/openai/empty_completion_test.go b/relay/channel/openai/empty_completion_test.go index 5baf78b..c780b62 100644 --- a/relay/channel/openai/empty_completion_test.go +++ b/relay/channel/openai/empty_completion_test.go @@ -354,3 +354,47 @@ func TestOaiResponsesToChatStreamHandlerRetriesEmptyOnEOFBeforeWriting(t *testin t.Fatalf("response body = %q, want empty before retry", body) } } + +func TestOaiResponsesToChatStreamHandlerCopiesDetailedUsage(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(strings.Join([]string{ + `data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-test","output":[]}}`, + `data: {"type":"response.completed","response":{"id":"resp_1","model":"gpt-test","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}],"usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15,"input_tokens_details":{"cached_tokens":2,"cached_creation_tokens":3,"text_tokens":4,"image_tokens":1,"audio_tokens":0},"completion_tokens_details":{"text_tokens":5,"reasoning_tokens":6}}}}`, + `data: [DONE]`, + ``, + }, "\n"))), + } + info := &relaycommon.RelayInfo{ + DisablePing: true, + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "gpt-test", + }, + } + + usage, err := OaiResponsesToChatStreamHandler(c, info, resp) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if usage == nil { + t.Fatal("usage is nil") + } + if usage.PromptTokens != 10 || usage.CompletionTokens != 5 || usage.TotalTokens != 15 { + t.Fatalf("usage tokens = %#v", usage) + } + if usage.PromptTokensDetails.CachedTokens != 2 || + usage.PromptTokensDetails.CachedCreationTokens != 3 || + usage.PromptTokensDetails.TextTokens != 4 || + usage.PromptTokensDetails.ImageTokens != 1 { + t.Fatalf("prompt details = %#v", usage.PromptTokensDetails) + } + if usage.CompletionTokenDetails.TextTokens != 5 || + usage.CompletionTokenDetails.ReasoningTokens != 6 { + t.Fatalf("completion details = %#v", usage.CompletionTokenDetails) + } +} diff --git a/relay/channel/openai/responses_via_chat.go b/relay/channel/openai/responses_via_chat.go new file mode 100644 index 0000000..214b4fe --- /dev/null +++ b/relay/channel/openai/responses_via_chat.go @@ -0,0 +1,138 @@ +package openai + +import ( + "fmt" + "io" + "net/http" + "strings" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/dto" + "github.com/MAX-API-Next/MAX-API/logger" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/MAX-API-Next/MAX-API/relay/helper" + "github.com/MAX-API-Next/MAX-API/service" + "github.com/MAX-API-Next/MAX-API/service/openaicompat" + "github.com/MAX-API-Next/MAX-API/types" + "github.com/gin-gonic/gin" +) + +func OaiChatToResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.MaxAPIError) { + if resp == nil || resp.Body == nil { + return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError) + } + defer service.CloseResponseBodyGracefully(resp) + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + + var chatResp dto.OpenAITextResponse + if err := common.Unmarshal(body, &chatResp); err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if oaiError := chatResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { + return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) + } + + responseID := helper.GetResponseID(c) + responsesResp, usage, err := service.ChatCompletionsResponseToResponsesResponse(&chatResp, responseID) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if usage == nil || usage.TotalTokens == 0 { + text := service.ExtractOutputTextFromResponses(responsesResp) + usage = service.ResponseText2Usage(c, text, info.UpstreamModelName, info.GetEstimatePromptTokens()) + responsesResp.Usage = openaicompat.UsageFromChatUsage(usage) + } + if service.ResponseAuditEnabled() { + service.SetRelayResponseAuditContent(info, service.ExtractOutputTextFromResponses(responsesResp)) + } + + responseBody, err := common.Marshal(responsesResp) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError) + } + service.IOCopyBytesGracefully(c, resp, responseBody) + return usage, nil +} + +func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.MaxAPIError) { + if resp == nil || resp.Body == nil { + return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError) + } + defer service.CloseResponseBodyGracefully(resp) + + responseID := helper.GetResponseID(c) + state := openaicompat.NewChatToResponsesStreamState(responseID, info.UpstreamModelName) + var streamErr *types.MaxAPIError + + sendEvent := func(event openaicompat.ChatToResponsesStreamEvent) bool { + data, err := common.Marshal(event.Payload) + if err != nil { + streamErr = types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError) + return false + } + helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: event.Type}, string(data)) + return true + } + + helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) { + if streamErr != nil { + sr.Stop(streamErr) + return + } + + if strings.Contains(data, `"error"`) { + var errorResp dto.OpenAITextResponse + if err := common.UnmarshalJsonStr(data, &errorResp); err == nil { + if oaiError := errorResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { + streamErr = types.WithOpenAIError(*oaiError, resp.StatusCode) + sr.Stop(streamErr) + return + } + } + } + + var chunk dto.ChatCompletionsStreamResponse + if err := common.UnmarshalJsonStr(data, &chunk); err != nil { + logger.LogError(c, "failed to unmarshal chat stream response: "+err.Error()) + sr.Error(err) + return + } + + events, err := openaicompat.ChatCompletionsStreamChunkToResponsesEvents(&chunk, state) + if err != nil { + streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError) + sr.Stop(streamErr) + return + } + for _, event := range events { + if !sendEvent(event) { + sr.Stop(streamErr) + return + } + } + }) + + if streamErr != nil { + return nil, streamErr + } + + usage := state.Usage + if usage == nil || usage.TotalTokens == 0 { + usage = service.ResponseText2Usage(c, state.UsageText(), info.UpstreamModelName, info.GetEstimatePromptTokens()) + state.Usage = openaicompat.UsageFromChatUsage(usage) + } + + for _, event := range openaicompat.FinalizeChatCompletionsStreamToResponses(state) { + if !sendEvent(event) { + return nil, streamErr + } + } + if service.ResponseAuditEnabled() { + service.SetRelayResponseAuditContent(info, state.UsageText()) + } + return usage, nil +} diff --git a/relay/channel/task/ali/adaptor.go b/relay/channel/task/ali/adaptor.go index 87c0806..4a03ff5 100644 --- a/relay/channel/task/ali/adaptor.go +++ b/relay/channel/task/ali/adaptor.go @@ -658,7 +658,7 @@ func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInf return nil } - aliReq, err := a.convertToAliRequest(info, taskReq) + aliReq, err := a.resolveBillingAliRequest(c, info, taskReq) if err != nil { return nil } @@ -682,7 +682,7 @@ func (a *TaskAdaptor) EstimateTaskBilling(c *gin.Context, info *relaycommon.Rela return nil, err } - aliReq, err := a.convertToAliRequest(info, taskReq) + aliReq, err := a.resolveBillingAliRequest(c, info, taskReq) if err != nil { return nil, err } @@ -737,6 +737,23 @@ func (a *TaskAdaptor) EstimateTaskBilling(c *gin.Context, info *relaycommon.Rela return task_billing_setting.Calculate(input, info.PriceData.GroupRatioInfo.GroupRatio) } +func (a *TaskAdaptor) resolveBillingAliRequest(c *gin.Context, info *relaycommon.RelayInfo, taskReq relaycommon.TaskSubmitReq) (*AliVideoRequest, error) { + if finalBody, ok := relaycommon.GetTaskSubmitRequestBody(c); ok { + var aliReq AliVideoRequest + if err := common.Unmarshal(finalBody, &aliReq); err == nil && looksLikeAliVideoRequest(&aliReq) { + if aliReq.Parameters == nil { + aliReq.Parameters = &AliVideoParameters{} + } + return &aliReq, nil + } + } + return a.convertToAliRequest(info, taskReq) +} + +func looksLikeAliVideoRequest(req *AliVideoRequest) bool { + return req != nil && (strings.TrimSpace(req.Model) != "" || strings.TrimSpace(req.Input.Prompt) != "" || req.Parameters != nil) +} + func countAliBillingMedia(aliReq *AliVideoRequest, metadata map[string]interface{}) (int, int) { imageCount := 0 videoCount := 0 diff --git a/relay/channel/task/doubao/adaptor.go b/relay/channel/task/doubao/adaptor.go index 08e0ae3..0aa9185 100644 --- a/relay/channel/task/doubao/adaptor.go +++ b/relay/channel/task/doubao/adaptor.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "strconv" + "strings" "time" "github.com/MAX-API-Next/MAX-API/common" @@ -21,6 +22,7 @@ import ( "github.com/gin-gonic/gin" "github.com/pkg/errors" "github.com/samber/lo" + "github.com/tidwall/gjson" ) // ============================ @@ -52,13 +54,15 @@ type requestPayload struct { Tools []struct { Type string `json:"type,omitempty"` } `json:"tools,omitempty"` - Resolution string `json:"resolution,omitempty"` - Ratio string `json:"ratio,omitempty"` - Duration *dto.IntValue `json:"duration,omitempty"` - Frames *dto.IntValue `json:"frames,omitempty"` - Seed *dto.IntValue `json:"seed,omitempty"` - CameraFixed *dto.BoolValue `json:"camera_fixed,omitempty"` - Watermark *dto.BoolValue `json:"watermark,omitempty"` + SafetyIdentifier *string `json:"safety_identifier,omitempty"` + Priority *dto.IntValue `json:"priority,omitempty"` + Resolution *string `json:"resolution,omitempty"` + Ratio *string `json:"ratio,omitempty"` + Duration *dto.IntValue `json:"duration,omitempty"` + Frames *dto.IntValue `json:"frames,omitempty"` + Seed *dto.IntValue `json:"seed,omitempty"` + CameraFixed *dto.BoolValue `json:"camera_fixed,omitempty"` + Watermark *dto.BoolValue `json:"watermark,omitempty"` } type responsePayload struct { @@ -102,26 +106,38 @@ type responseTask struct { type TaskAdaptor struct { taskcommon.BaseBilling - ChannelType int - apiKey string - baseURL string - taskProtocol string - taskProtocolConfig dto.TaskProtocolConfig + ChannelType int + apiKey string + baseURL string } func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { a.ChannelType = info.ChannelType a.baseURL = info.ChannelBaseUrl a.apiKey = info.ApiKey - a.taskProtocol = info.ChannelOtherSettings.TaskProtocol - a.taskProtocolConfig = normalizeSeedanceMediaProtocolConfig(info.ChannelOtherSettings.TaskProtocolConfig) } // ValidateRequestAndSetAction parses body, validates fields and sets default action. func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) { - if a.useSeedanceMediaProtocol() { - return a.validateSeedanceMediaTaskRequest(c, info) + if info != nil && info.ChannelMeta != nil && taskcommon.UseConfiguredTaskProtocol(info.ChannelMeta.ChannelOtherSettings) && + info.ChannelMeta.ChannelSetting.PassThroughBodyEnabled { + var req relaycommon.TaskSubmitReq + if err := common.UnmarshalBodyReusable(c, &req); err != nil { + return service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest) + } + if strings.TrimSpace(req.Model) == "" { + return service.TaskErrorWrapperLocal(fmt.Errorf("model field is required"), "missing_model", http.StatusBadRequest) + } + if len(req.Images) == 0 && strings.TrimSpace(req.Image) != "" { + req.Images = []string{req.Image} + } + if len(req.Images) == 0 && len(req.ReferenceImages) > 0 { + req.Images = append([]string{}, req.ReferenceImages...) + } + relaycommon.StoreTaskRequest(c, info, constant.TaskActionGenerate, req) + return nil } + // Accept only POST /v1/video/generations as "generate" action. taskErr = relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate) if taskErr != nil { @@ -132,9 +148,6 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom // BuildRequestURL constructs the upstream URL. func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { - if a.useSeedanceMediaProtocol() { - return buildSeedanceMediaURL(a.baseURL, a.taskProtocolConfig.SubmitPath), nil - } fallback := fmt.Sprintf("%s/api/v3/contents/generations/tasks", a.baseURL) return taskcommon.BuildTaskSubmitURL(info, fallback), nil } @@ -147,18 +160,54 @@ func (a *TaskAdaptor) BuildRequestHeader(_ *gin.Context, req *http.Request, _ *r return nil } -// EstimateBilling 检测请求 metadata 中是否包含视频输入,返回视频折扣 OtherRatio。 +// EstimateBilling 根据请求 metadata 中的输出分辨率与是否包含视频输入,返回相对基准价的计费 OtherRatio。 func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 { req, err := relaycommon.GetTaskRequest(c) if err != nil { return nil } - if hasVideoInMetadata(req.Metadata) { - if ratio, ok := GetVideoInputRatio(info.OriginModelName); ok { - return map[string]float64{"video_input": ratio} + resolution, hasVideo := a.resolveBillingInputs(c, &req) + ratio, ok := GetVideoInputRatio(info.OriginModelName, resolution, hasVideo) + if !ok || ratio == 1.0 { + return nil + } + return map[string]float64{"video_input": ratio} +} + +func (a *TaskAdaptor) resolveBillingInputs(c *gin.Context, req *relaycommon.TaskSubmitReq) (string, bool) { + if req == nil { + return "", false + } + raw := map[string]any{} + if c != nil && c.Request != nil && c.Request.Body != nil { + _ = common.UnmarshalBodyReusable(c, &raw) + } + if finalBody, ok := relaycommon.GetTaskSubmitRequestBody(c); ok { + _ = common.Unmarshal(finalBody, &raw) + } + hasVideo := hasVideoInMetadata(req.Metadata) + hasVideo = hasVideo || hasVideoInMetadata(raw) + resolution := req.Resolution + if rawResolution := strings.TrimSpace(common.Interface2String(raw["resolution"])); rawResolution != "" { + resolution = rawResolution + } + payload, err := a.convertToRequestPayload(req) + if err == nil && payload != nil { + if payload.Resolution != nil && *payload.Resolution != "" { + resolution = *payload.Resolution } + hasVideo = hasVideo || hasVideoInContent(payload.Content) } - return nil + return resolution, hasVideo +} + +func hasVideoInContent(content []ContentItem) bool { + for _, item := range content { + if item.VideoURL != nil && hasUsableVideoInput(item.VideoURL.URL) { + return true + } + } + return false } // hasVideoInMetadata 直接检查 metadata 的 content 数组是否包含 video_url 条目, @@ -167,6 +216,12 @@ func hasVideoInMetadata(metadata map[string]interface{}) bool { if metadata == nil { return false } + if videoURL, has := metadata["video_url"]; has && hasUsableVideoInput(videoURL) { + return true + } + if video, has := metadata["video"]; has && hasUsableVideoInput(video) { + return true + } contentRaw, ok := metadata["content"] if !ok { return false @@ -180,16 +235,39 @@ func hasVideoInMetadata(metadata map[string]interface{}) bool { if !ok { continue } - if itemMap["type"] == "video_url" { + if itemMap["type"] == "video_url" && hasUsableVideoInput(itemMap["video_url"]) { return true } - if _, has := itemMap["video_url"]; has { + if videoURL, has := itemMap["video_url"]; has && hasUsableVideoInput(videoURL) { return true } } return false } +func hasUsableVideoInput(value interface{}) bool { + switch v := value.(type) { + case nil: + return false + case string: + return strings.TrimSpace(v) != "" + case bool: + return v + case map[string]interface{}: + if url, has := v["url"]; has { + return hasUsableVideoInput(url) + } + return len(v) > 0 + case map[string]string: + if url, has := v["url"]; has { + return strings.TrimSpace(url) != "" + } + return len(v) > 0 + default: + return true + } +} + // BuildRequestBody converts request into Doubao specific format. func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) { req, err := relaycommon.GetTaskRequest(c) @@ -197,23 +275,6 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayIn return nil, err } - if a.useSeedanceMediaProtocol() { - body, err := a.convertToSeedanceMediaRequest(c, &req) - if err != nil { - return nil, errors.Wrap(err, "convert seedance media request failed") - } - if info.IsModelMapped { - body.Model = info.UpstreamModelName - } else { - info.UpstreamModelName = body.Model - } - data, err := common.Marshal(body.ToMap()) - if err != nil { - return nil, err - } - return bytes.NewReader(data), nil - } - body, err := a.convertToRequestPayload(&req) if err != nil { return nil, errors.Wrap(err, "convert request payload failed") @@ -248,10 +309,6 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela return taskID, responseBody, configuredErr } - if a.useSeedanceMediaProtocol() { - return a.doSeedanceMediaResponse(c, responseBody, info) - } - // Parse Doubao response var dResp responsePayload if err := common.Unmarshal(responseBody, &dResp); err != nil { @@ -282,9 +339,6 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy } uri := fmt.Sprintf("%s/api/v3/contents/generations/tasks/%s", baseUrl, taskID) - if a.useSeedanceMediaProtocol() { - uri = a.seedanceMediaQueryURL(baseUrl, taskID) - } uri = taskcommon.BuildTaskQueryURL(baseUrl, body, uri) req, err := http.NewRequest(http.MethodGet, uri, nil) @@ -348,8 +402,14 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (* } func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { - if a.useSeedanceMediaProtocol() || looksLikeSeedanceMediaTask(respBody) { - return a.parseSeedanceMediaTaskResult(respBody) + if looksLikeSeedanceMediaTask(respBody) { + if taskResult, ok, err := taskcommon.ParseConfiguredTaskResult(respBody, dto.ChannelOtherSettings{ + TaskProtocol: taskcommon.TaskProtocolGenericVideo, + }); err != nil { + return nil, err + } else if ok { + return taskResult, nil + } } resTask := responseTask{} @@ -390,8 +450,36 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e } func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) { - if a.useSeedanceMediaProtocol() || looksLikeSeedanceMediaTask(originTask.Data) { - return a.convertSeedanceMediaToOpenAIVideo(originTask) + if body, ok, err := taskcommon.ConvertConfiguredTaskToOpenAIVideo(originTask); ok || err != nil { + return body, err + } + if originTask != nil && looksLikeSeedanceMediaTask(originTask.Data) { + cfg := taskcommon.NormalizeTaskProtocolConfig(nil) + openAIVideo := dto.NewOpenAIVideo() + openAIVideo.ID = originTask.TaskID + openAIVideo.TaskID = originTask.TaskID + openAIVideo.Status = originTask.Status.ToVideoStatus() + openAIVideo.SetProgressStr(originTask.Progress) + urlValue := originTask.GetResultURL() + if urlValue == "" { + urlValue = taskcommon.ExtractConfiguredResultURL(originTask.Data, cfg.ResultURLPaths) + } + openAIVideo.SetMetadata("url", urlValue) + openAIVideo.CreatedAt = originTask.CreatedAt + openAIVideo.CompletedAt = originTask.UpdatedAt + openAIVideo.Model = originTask.Properties.OriginModelName + + if originTask.Status == model.TaskStatusFailure { + message := originTask.FailReason + if message == "" { + message = taskcommon.StringFromGJSONPath(originTask.Data, cfg.ErrorMessagePath) + } + openAIVideo.Error = &dto.OpenAIVideoError{ + Message: message, + Code: "upstream_error", + } + } + return common.Marshal(openAIVideo) } var dResp responseTask @@ -418,3 +506,11 @@ func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, erro return common.Marshal(openAIVideo) } + +func looksLikeSeedanceMediaTask(data []byte) bool { + if len(data) == 0 { + return false + } + return gjson.GetBytes(data, "task_id").Exists() && + (gjson.GetBytes(data, "status").Exists() || gjson.GetBytes(data, "object").String() == "media.task") +} diff --git a/relay/channel/task/doubao/adaptor_billing_test.go b/relay/channel/task/doubao/adaptor_billing_test.go new file mode 100644 index 0000000..4c5c1ef --- /dev/null +++ b/relay/channel/task/doubao/adaptor_billing_test.go @@ -0,0 +1,319 @@ +package doubao + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/dto" + "github.com/MAX-API-Next/MAX-API/model" + "github.com/MAX-API-Next/MAX-API/relay/channel/task/taskcommon" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/gin-gonic/gin" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetVideoInputRatioUsesResolutionAndVideoInput(t *testing.T) { + ratio, ok := GetVideoInputRatio("doubao-seedance-2-0-260128", "", false) + require.True(t, ok) + assert.InDelta(t, 1.0, ratio, 1e-9) + + ratio, ok = GetVideoInputRatio("doubao-seedance-2-0-260128", "1080p", false) + require.True(t, ok) + assert.InDelta(t, 51.0/46.0, ratio, 1e-9) + + ratio, ok = GetVideoInputRatio("doubao-seedance-2-0-260128", "4k", true) + require.True(t, ok) + assert.InDelta(t, 16.0/46.0, ratio, 1e-9) + + ratio, ok = GetVideoInputRatio("doubao-seedance-2-0-fast-260128", "4k", false) + require.True(t, ok) + assert.InDelta(t, 1.0, ratio, 1e-9) + + ratio, ok = GetVideoInputRatio("doubao-seedance-2-0-fast-260128", "4k", true) + require.True(t, ok) + assert.InDelta(t, 22.0/37.0, ratio, 1e-9) +} + +func TestEstimateBillingUsesResolutionAndVideoInput(t *testing.T) { + c, _ := gin.CreateTestContext(nil) + info := &relaycommon.RelayInfo{ + OriginModelName: "doubao-seedance-2-0-260128", + } + c.Set("task_request", relaycommon.TaskSubmitReq{ + Model: "doubao-seedance-2-0-260128", + Metadata: map[string]any{ + "resolution": "1080p", + "content": []any{ + map[string]any{ + "type": "video_url", + "video_url": map[string]any{ + "url": "https://example.com/input.mp4", + }, + }, + }, + }, + }) + + ratios := (&TaskAdaptor{}).EstimateBilling(c, info) + require.NotNil(t, ratios) + assert.InDelta(t, 31.0/46.0, ratios["video_input"], 1e-9) +} + +func TestEstimateBillingIgnoresUnusableVideoInput(t *testing.T) { + cases := []struct { + name string + metadata map[string]any + want float64 + }{ + { + name: "empty top-level video_url", + metadata: map[string]any{ + "resolution": "1080p", + "video_url": " ", + }, + want: 51.0 / 46.0, + }, + { + name: "nil top-level video", + metadata: map[string]any{ + "resolution": "1080p", + "video": nil, + }, + want: 51.0 / 46.0, + }, + { + name: "false top-level video", + metadata: map[string]any{ + "resolution": "1080p", + "video": false, + }, + want: 51.0 / 46.0, + }, + { + name: "empty content video_url object", + metadata: map[string]any{ + "resolution": "1080p", + "content": []any{ + map[string]any{ + "type": "video_url", + "video_url": map[string]any{"url": ""}, + }, + }, + }, + want: 51.0 / 46.0, + }, + { + name: "usable content video_url object", + metadata: map[string]any{ + "resolution": "1080p", + "content": []any{ + map[string]any{ + "type": "video_url", + "video_url": map[string]any{"url": "https://example.com/input.mp4"}, + }, + }, + }, + want: 31.0 / 46.0, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c, _ := gin.CreateTestContext(nil) + info := &relaycommon.RelayInfo{ + OriginModelName: "doubao-seedance-2-0-260128", + } + c.Set("task_request", relaycommon.TaskSubmitReq{ + Model: "doubao-seedance-2-0-260128", + Metadata: tc.metadata, + }) + + ratios := (&TaskAdaptor{}).EstimateBilling(c, info) + require.NotNil(t, ratios) + assert.InDelta(t, tc.want, ratios["video_input"], 1e-9) + }) + } +} + +func TestEstimateBillingUsesTopLevelVideoResolution(t *testing.T) { + c, _ := gin.CreateTestContext(nil) + info := &relaycommon.RelayInfo{ + OriginModelName: "doubao-seedance-2-0-260128", + } + c.Set("task_request", relaycommon.TaskSubmitReq{ + Model: "doubao-seedance-2-0-260128", + Prompt: "test", + Resolution: "1080p", + }) + + ratios := (&TaskAdaptor{}).EstimateBilling(c, info) + require.NotNil(t, ratios) + assert.InDelta(t, 51.0/46.0, ratios["video_input"], 1e-9) +} + +func TestEstimateBillingUsesTopLevelRawVideoInput(t *testing.T) { + c, _ := gin.CreateTestContext(nil) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", strings.NewReader(`{ + "model": "doubao-seedance-2-0-260128", + "prompt": "test", + "resolution": "1080p", + "video_url": "https://example.com/input.mp4" + }`)) + c.Request.Header.Set("Content-Type", "application/json") + info := &relaycommon.RelayInfo{ + OriginModelName: "doubao-seedance-2-0-260128", + } + c.Set("task_request", relaycommon.TaskSubmitReq{ + Model: "doubao-seedance-2-0-260128", + Prompt: "test", + }) + + ratios := (&TaskAdaptor{}).EstimateBilling(c, info) + require.NotNil(t, ratios) + assert.InDelta(t, 31.0/46.0, ratios["video_input"], 1e-9) +} + +func TestEstimateBillingUsesLegacyRequestPayloadResolution(t *testing.T) { + c, _ := gin.CreateTestContext(nil) + info := &relaycommon.RelayInfo{ + OriginModelName: "doubao-seedance-2-0-260128", + } + c.Set("task_request", relaycommon.TaskSubmitReq{ + Model: "doubao-seedance-2-0-260128", + Prompt: "test", + Resolution: "1080p", + Metadata: map[string]any{ + "resolution": "720p", + }, + }) + + ratios := (&TaskAdaptor{}).EstimateBilling(c, info) + require.Nil(t, ratios) +} + +func TestConvertToRequestPayloadPreservesDoubaoVideoFields(t *testing.T) { + var req relaycommon.TaskSubmitReq + err := common.Unmarshal([]byte(`{ + "model": "doubao-seedance-2-0-260128", + "prompt": "test", + "metadata": { + "safety_identifier": "safety-123", + "priority": 0, + "resolution": "4k", + "content": [ + { + "type": "video_url", + "video_url": { "url": "https://example.com/input.mp4" } + } + ] + } + }`), &req) + require.NoError(t, err) + + payload, err := (&TaskAdaptor{}).convertToRequestPayload(&req) + require.NoError(t, err) + require.NotNil(t, payload) + require.NotNil(t, payload.SafetyIdentifier) + assert.Equal(t, "safety-123", *payload.SafetyIdentifier) + require.NotNil(t, payload.Priority) + assert.Equal(t, 0, int(*payload.Priority)) + require.NotNil(t, payload.Resolution) + assert.Equal(t, "4k", *payload.Resolution) +} + +func TestConvertToRequestPayloadPreservesExplicitEmptyOptionalStrings(t *testing.T) { + var req relaycommon.TaskSubmitReq + err := common.Unmarshal([]byte(`{ + "model": "doubao-seedance-2-0-260128", + "prompt": "test", + "metadata": { + "resolution": "", + "ratio": "" + } + }`), &req) + require.NoError(t, err) + + payload, err := (&TaskAdaptor{}).convertToRequestPayload(&req) + require.NoError(t, err) + require.NotNil(t, payload) + require.NotNil(t, payload.Resolution) + assert.Equal(t, "", *payload.Resolution) + require.NotNil(t, payload.Ratio) + assert.Equal(t, "", *payload.Ratio) + + data, err := common.Marshal(payload) + require.NoError(t, err) + assert.Contains(t, string(data), `"resolution":""`) + assert.Contains(t, string(data), `"ratio":""`) +} + +func TestValidateConfiguredTaskProtocolAllowsPromptlessMediaRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", strings.NewReader(`{ + "model": "doubao-seedance-2-0-260128", + "image": "https://example.com/frame.png" + }`)) + c.Request.Header.Set("Content-Type", "application/json") + info := &relaycommon.RelayInfo{ + TaskRelayInfo: &relaycommon.TaskRelayInfo{}, + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelSetting: dto.ChannelSettings{PassThroughBodyEnabled: true}, + ChannelOtherSettings: dto.ChannelOtherSettings{ + TaskProtocol: taskcommon.TaskProtocolGenericVideo, + }, + }, + } + + taskErr := (&TaskAdaptor{}).ValidateRequestAndSetAction(c, info) + + require.Nil(t, taskErr) + require.Equal(t, "generate", info.Action) + req, err := relaycommon.GetTaskRequest(c) + require.NoError(t, err) + require.Equal(t, "doubao-seedance-2-0-260128", req.Model) + require.Equal(t, []string{"https://example.com/frame.png"}, req.Images) +} + +func TestParseSeedanceMediaTaskResultByShape(t *testing.T) { + taskInfo, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{ + "object": "media.task", + "task_id": "task_123", + "status": "succeeded", + "progress": 100, + "result": { + "url": "https://example.com/result.mp4" + } + }`)) + + require.NoError(t, err) + require.NotNil(t, taskInfo) + assert.Equal(t, "task_123", taskInfo.TaskID) + assert.Equal(t, model.TaskStatusSuccess, taskInfo.Status) + assert.Equal(t, "100%", taskInfo.Progress) + assert.Equal(t, "https://example.com/result.mp4", taskInfo.Url) +} + +func TestConvertSeedanceMediaTaskToOpenAIVideoByShape(t *testing.T) { + body, err := (&TaskAdaptor{}).ConvertToOpenAIVideo(&model.Task{ + TaskID: "task_123", + Status: model.TaskStatusSuccess, + Progress: "100%", + Data: []byte(`{"object":"media.task","task_id":"task_123","status":"succeeded","result":{"url":"https://example.com/result.mp4"}}`), + CreatedAt: 1710000000, + UpdatedAt: 1710000100, + Properties: model.Properties{ + OriginModelName: "doubao-seedance-2-0-260128", + }, + }) + + require.NoError(t, err) + assert.Contains(t, string(body), `"url":"https://example.com/result.mp4"`) + assert.Contains(t, string(body), `"task_id":"task_123"`) +} diff --git a/relay/channel/task/doubao/adaptor_media_protocol_test.go b/relay/channel/task/doubao/adaptor_media_protocol_test.go deleted file mode 100644 index e1c5e46..0000000 --- a/relay/channel/task/doubao/adaptor_media_protocol_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package doubao - -import ( - "testing" - - "github.com/MAX-API-Next/MAX-API/common" - "github.com/MAX-API-Next/MAX-API/model" - relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestConvertToGenericMediaRequestFromTopLevelFields(t *testing.T) { - var req relaycommon.TaskSubmitReq - err := common.Unmarshal([]byte(`{ - "aspect_ratio": "16:9", - "duration_seconds": 5, - "end_image": "https://example.com/last-frame.png", - "image": "https://example.com/first-frame.png", - "input_mode": "single_image", - "model": "doubao-seedance-2-0-260128", - "prompt": "让人物自然转身并看向镜头", - "resolution": "720p", - "with_audio": true - }`), &req) - require.NoError(t, err) - - payload, err := (&TaskAdaptor{}).convertToGenericMediaRequest(&req) - require.NoError(t, err) - - assert.Equal(t, "doubao-seedance-2-0-260128", payload.Model) - assert.Equal(t, "让人物自然转身并看向镜头", payload.Prompt) - assert.Equal(t, "16:9", payload.AspectRatio) - assert.Equal(t, "video_generation", payload.Capability) - assert.Equal(t, "end_frame", payload.ControlMode) - assert.Equal(t, "single_image", payload.InputMode) - assert.Equal(t, "https://example.com/first-frame.png", payload.Image) - assert.Equal(t, "https://example.com/last-frame.png", payload.EndImage) - assert.Equal(t, "720p", payload.Resolution) - require.NotNil(t, payload.DurationSeconds) - assert.Equal(t, 5, *payload.DurationSeconds) - require.NotNil(t, payload.WithAudio) - assert.True(t, *payload.WithAudio) -} - -func TestConvertToGenericMediaRequestReferenceImages(t *testing.T) { - var req relaycommon.TaskSubmitReq - err := common.Unmarshal([]byte(`{ - "model": "doubao-seedance-2-0-260128", - "prompt": "参考图片中的人物在海边回眸", - "reference_images": ["asset://asset-a", "asset://asset-b"], - "with_audio": false - }`), &req) - require.NoError(t, err) - - payload, err := (&TaskAdaptor{}).convertToGenericMediaRequest(&req) - require.NoError(t, err) - - assert.Equal(t, "multi_image", payload.InputMode) - assert.Equal(t, "reference", payload.ControlMode) - assert.Equal(t, []string{"asset://asset-a", "asset://asset-b"}, payload.ReferenceImages) - require.NotNil(t, payload.WithAudio) - assert.False(t, *payload.WithAudio) -} - -func TestParseSeedanceMediaTaskResult(t *testing.T) { - taskInfo, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{ - "object": "media.task", - "task_id": "6c1a4eeaaee14736a78170ddbfff8361", - "status": "succeeded", - "progress": 100, - "result": { - "url": "https://example.com/result.mp4", - "duration_seconds": 5 - } - }`)) - require.NoError(t, err) - - assert.Equal(t, model.TaskStatusSuccess, taskInfo.Status) - assert.Equal(t, "100%", taskInfo.Progress) - assert.Equal(t, "https://example.com/result.mp4", taskInfo.Url) - assert.Equal(t, "6c1a4eeaaee14736a78170ddbfff8361", taskInfo.TaskID) -} diff --git a/relay/channel/task/doubao/constants.go b/relay/channel/task/doubao/constants.go index d65773d..f243755 100644 --- a/relay/channel/task/doubao/constants.go +++ b/relay/channel/task/doubao/constants.go @@ -1,5 +1,7 @@ package doubao +import "strings" + var ModelList = []string{ "doubao-seedance-1-0-pro-250528", "doubao-seedance-1-0-lite-t2v", @@ -11,15 +13,48 @@ var ModelList = []string{ var ChannelName = "doubao-video" -// videoInputRatioMap 视频输入折扣比率(含视频单价 / 不含视频单价)。 -// 管理员应将 ModelRatio 设置为"不含视频"的较高费率, -// 系统在检测到视频输入时自动乘以此折扣。 -var videoInputRatioMap = map[string]float64{ - "doubao-seedance-2-0-260128": 28.0 / 46.0, // ~0.6087 - "doubao-seedance-2-0-fast-260128": 22.0 / 37.0, // ~0.5946 +// videoPriceKey 价格表的键:输出分辨率档(is1080p/is4k 均为 false 即 480p/720p 基准档)、输入是否含视频。 +type videoPriceKey struct { + is1080p bool + is4k bool + hasVideo bool +} + +// videoPriceTable 各模型在不同 (输出分辨率档, 是否含视频输入) 下的单价(元/百万 token)。 +// 其中零值键 {480p/720p, 不含视频} 为基准价,等于管理员应配置的 ModelRatio; +// 计费时取 实际单价/基准价 作为 OtherRatio。 +var videoPriceTable = map[string]map[videoPriceKey]float64{ + "doubao-seedance-2-0-260128": { + {hasVideo: false}: 46.0, + {hasVideo: true}: 28.0, + {is1080p: true, hasVideo: false}: 51.0, + {is1080p: true, hasVideo: true}: 31.0, + {is4k: true, hasVideo: false}: 26.0, + {is4k: true, hasVideo: true}: 16.0, + }, + "doubao-seedance-2-0-fast-260128": { + {hasVideo: false}: 37.0, + {hasVideo: true}: 22.0, + {is1080p: true, hasVideo: false}: 37.0, + {is1080p: true, hasVideo: true}: 22.0, + {is4k: true, hasVideo: false}: 37.0, + {is4k: true, hasVideo: true}: 22.0, + }, } -func GetVideoInputRatio(modelName string) (float64, bool) { - r, ok := videoInputRatioMap[modelName] - return r, ok +// GetVideoInputRatio 返回指定模型在给定输出分辨率/是否含视频输入下,相对基准价的计费倍率。 +// 第二个返回值表示该模型是否配置了价格表;倍率为 1.0 时调用方可忽略该 OtherRatio。 +func GetVideoInputRatio(modelName, resolution string, hasVideo bool) (float64, bool) { + prices, ok := videoPriceTable[modelName] + base := prices[videoPriceKey{}] + if !ok || base <= 0 { + return 0, false + } + res := strings.ToLower(strings.TrimSpace(resolution)) + price, ok := prices[videoPriceKey{is1080p: res == "1080p", is4k: res == "4k", hasVideo: hasVideo}] + if !ok { + // 未配置的组合(如 fast 无 1080p/4k,上游会自行报错)按基准价计费即可。 + return 1.0, true + } + return price / base, true } diff --git a/relay/channel/task/doubao/media_protocol.go b/relay/channel/task/doubao/media_protocol.go deleted file mode 100644 index 75423a6..0000000 --- a/relay/channel/task/doubao/media_protocol.go +++ /dev/null @@ -1,591 +0,0 @@ -package doubao - -import ( - "fmt" - "net/http" - "net/url" - "strconv" - "strings" - - "github.com/MAX-API-Next/MAX-API/common" - "github.com/MAX-API-Next/MAX-API/constant" - "github.com/MAX-API-Next/MAX-API/dto" - "github.com/MAX-API-Next/MAX-API/model" - "github.com/MAX-API-Next/MAX-API/relay/channel/task/taskcommon" - relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" - "github.com/MAX-API-Next/MAX-API/service" - - "github.com/gin-gonic/gin" - "github.com/tidwall/gjson" -) - -const ( - taskProtocolSeedanceOfficialMedia = "seedance_official_media" - seedanceMediaRawRequestKey = "doubao_seedance_media_raw_request" -) - -type seedanceMediaRequest struct { - Model string - Prompt string - AspectRatio string - Capability string - ControlMode string - DurationSeconds *int - EndImage string - Image string - InputMode string - ReferenceImages []string - Resolution string - WithAudio *bool - Extra map[string]interface{} -} - -func (r *seedanceMediaRequest) ToMap() map[string]interface{} { - out := map[string]interface{}{} - for k, v := range r.Extra { - out[k] = v - } - setString(out, "model", r.Model) - setString(out, "prompt", r.Prompt) - setString(out, "aspect_ratio", r.AspectRatio) - setString(out, "capability", r.Capability) - setString(out, "control_mode", r.ControlMode) - setString(out, "end_image", r.EndImage) - setString(out, "image", r.Image) - setString(out, "input_mode", r.InputMode) - setString(out, "resolution", r.Resolution) - if r.DurationSeconds != nil { - out["duration_seconds"] = *r.DurationSeconds - } - if len(r.ReferenceImages) > 0 { - out["reference_images"] = r.ReferenceImages - } - if r.WithAudio != nil { - out["with_audio"] = *r.WithAudio - } - return out -} - -func normalizeSeedanceMediaProtocolConfig(input *dto.TaskProtocolConfig) dto.TaskProtocolConfig { - return taskcommon.NormalizeTaskProtocolConfig(input) -} - -func (a *TaskAdaptor) useSeedanceMediaProtocol() bool { - return strings.EqualFold(strings.TrimSpace(a.taskProtocol), taskProtocolSeedanceOfficialMedia) -} - -func (a *TaskAdaptor) seedanceMediaConfig() dto.TaskProtocolConfig { - return normalizeSeedanceMediaProtocolConfig(&a.taskProtocolConfig) -} - -func (a *TaskAdaptor) captureSeedanceMediaRequest(c *gin.Context) error { - var raw map[string]interface{} - if err := common.UnmarshalBodyReusable(c, &raw); err != nil { - return err - } - c.Set(seedanceMediaRawRequestKey, raw) - return nil -} - -func (a *TaskAdaptor) validateSeedanceMediaTaskRequest(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError { - var req relaycommon.TaskSubmitReq - if err := common.UnmarshalBodyReusable(c, &req); err != nil { - return service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest) - } - if strings.TrimSpace(req.Model) == "" { - return service.TaskErrorWrapperLocal(fmt.Errorf("model field is required"), "missing_model", http.StatusBadRequest) - } - if len(req.Images) == 0 && strings.TrimSpace(req.Image) != "" { - req.Images = []string{req.Image} - } - if len(req.Images) == 0 && len(req.ReferenceImages) > 0 { - req.Images = append([]string{}, req.ReferenceImages...) - } - if err := a.captureSeedanceMediaRequest(c); err != nil { - return service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest) - } - relaycommon.StoreTaskRequest(c, info, constant.TaskActionGenerate, req) - return nil -} - -func (a *TaskAdaptor) convertToSeedanceMediaRequest(c *gin.Context, req *relaycommon.TaskSubmitReq) (*seedanceMediaRequest, error) { - payload, err := a.convertToGenericMediaRequest(req) - if err != nil { - return nil, err - } - raw := getSeedanceMediaRawRequest(c) - applySeedanceMediaRawFields(payload, raw) - inferSeedanceMediaModes(payload) - return payload, nil -} - -func (a *TaskAdaptor) convertToGenericMediaRequest(req *relaycommon.TaskSubmitReq) (*seedanceMediaRequest, error) { - if req == nil { - return nil, fmt.Errorf("request is nil") - } - payload := &seedanceMediaRequest{ - Model: req.Model, - Prompt: req.Prompt, - AspectRatio: req.AspectRatio, - Capability: firstNonEmpty(req.Capability, "video_generation"), - ControlMode: req.ControlMode, - DurationSeconds: req.DurationSeconds, - EndImage: req.EndImage, - Image: firstNonEmpty(req.Image, firstString(req.Images)), - InputMode: req.InputMode, - ReferenceImages: append([]string{}, req.ReferenceImages...), - Resolution: req.Resolution, - WithAudio: req.WithAudio, - Extra: map[string]interface{}{}, - } - if payload.DurationSeconds == nil { - if sec, ok := parsePositiveOrAutoSeconds(req.Seconds); ok { - payload.DurationSeconds = &sec - } else if req.Duration != 0 { - sec := req.Duration - payload.DurationSeconds = &sec - } - } - applySeedanceMediaMetadata(payload, req.Metadata) - inferSeedanceMediaModes(payload) - return payload, nil -} - -func applySeedanceMediaRawFields(payload *seedanceMediaRequest, raw map[string]interface{}) { - if payload == nil || raw == nil { - return - } - setPayloadString(raw, "model", &payload.Model) - setPayloadString(raw, "prompt", &payload.Prompt) - setPayloadString(raw, "aspect_ratio", &payload.AspectRatio) - setPayloadString(raw, "capability", &payload.Capability) - setPayloadString(raw, "control_mode", &payload.ControlMode) - setPayloadString(raw, "end_image", &payload.EndImage) - setPayloadString(raw, "image", &payload.Image) - setPayloadString(raw, "input_mode", &payload.InputMode) - setPayloadString(raw, "resolution", &payload.Resolution) - if v, ok := raw["duration_seconds"]; ok { - if sec, exists := intFromAny(v); exists { - payload.DurationSeconds = &sec - } - } - if v, ok := raw["with_audio"]; ok { - if b, exists := boolFromAny(v); exists { - payload.WithAudio = &b - } - } - if refs, exists := stringSliceFromAny(raw["reference_images"]); exists { - payload.ReferenceImages = refs - } -} - -func applySeedanceMediaMetadata(payload *seedanceMediaRequest, metadata map[string]interface{}) { - if payload == nil || metadata == nil { - return - } - for k, v := range metadata { - switch k { - case "model": - setStringValue(v, &payload.Model) - case "prompt": - setStringValue(v, &payload.Prompt) - case "aspect_ratio", "ratio": - setStringValue(v, &payload.AspectRatio) - case "capability": - setStringValue(v, &payload.Capability) - case "control_mode": - setStringValue(v, &payload.ControlMode) - case "duration_seconds": - if sec, exists := intFromAny(v); exists { - payload.DurationSeconds = &sec - } - case "end_image", "image_tail", "tail_image": - setStringValue(v, &payload.EndImage) - case "image": - setStringValue(v, &payload.Image) - case "input_mode": - setStringValue(v, &payload.InputMode) - case "reference_images": - if refs, exists := stringSliceFromAny(v); exists { - payload.ReferenceImages = refs - } - case "resolution": - setStringValue(v, &payload.Resolution) - case "with_audio", "generate_audio": - if b, exists := boolFromAny(v); exists { - payload.WithAudio = &b - } - default: - payload.Extra[k] = v - } - } -} - -func inferSeedanceMediaModes(payload *seedanceMediaRequest) { - if payload == nil { - return - } - if payload.Capability == "" { - payload.Capability = "video_generation" - } - if payload.InputMode == "" { - switch { - case len(payload.ReferenceImages) > 0: - payload.InputMode = "multi_image" - case payload.Image != "": - payload.InputMode = "single_image" - default: - payload.InputMode = "text" - } - } - if payload.ControlMode == "" { - switch { - case payload.InputMode == "multi_image": - payload.ControlMode = "reference" - case payload.EndImage != "": - payload.ControlMode = "end_frame" - default: - payload.ControlMode = "none" - } - } -} - -func (a *TaskAdaptor) doSeedanceMediaResponse(c *gin.Context, responseBody []byte, info *relaycommon.RelayInfo) (string, []byte, *dto.TaskError) { - if taskID, handled, taskErr := taskcommon.TryHandleConfiguredSubmitResponse(c, responseBody, info); handled || taskErr != nil { - return taskID, responseBody, taskErr - } - return "", nil, taskcommon.MissingConfiguredTaskIDError() -} - -func (a *TaskAdaptor) seedanceMediaQueryURL(baseURL, taskID string) string { - cfg := a.seedanceMediaConfig() - path := strings.ReplaceAll(cfg.QueryPath, "{task_id}", url.PathEscape(taskID)) - return buildSeedanceMediaURL(baseURL, path) -} - -func (a *TaskAdaptor) parseSeedanceMediaTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { - cfg := a.seedanceMediaConfig() - result, ok, err := taskcommon.ParseConfiguredTaskResult(respBody, dto.ChannelOtherSettings{ - TaskProtocol: taskcommon.TaskProtocolLegacySeedanceMedia, - TaskProtocolConfig: &cfg, - }) - if err != nil { - return nil, err - } - if ok { - return result, nil - } - return &relaycommon.TaskInfo{ - Code: 0, - Status: string(model.TaskStatusInProgress), - Progress: taskcommon.ProgressInProgress, - }, nil -} - -func (a *TaskAdaptor) convertSeedanceMediaToOpenAIVideo(originTask *model.Task) ([]byte, error) { - if body, ok, err := taskcommon.ConvertConfiguredTaskToOpenAIVideo(originTask); ok || err != nil { - return body, err - } - cfg := a.seedanceMediaConfig() - if !a.useSeedanceMediaProtocol() { - cfg = seedanceMediaConfigFromTask(originTask) - } - openAIVideo := dto.NewOpenAIVideo() - openAIVideo.ID = originTask.TaskID - openAIVideo.TaskID = originTask.TaskID - openAIVideo.Status = originTask.Status.ToVideoStatus() - openAIVideo.SetProgressStr(originTask.Progress) - urlValue := originTask.GetResultURL() - if urlValue == "" { - urlValue = extractSeedanceMediaResultURL(originTask.Data, cfg.ResultURLPaths) - } - openAIVideo.SetMetadata("url", urlValue) - openAIVideo.CreatedAt = firstTimestamp(originTask.CreatedAt, timestampFromGJSONPath(originTask.Data, cfg.CreatedAtPath)) - openAIVideo.CompletedAt = firstTimestamp(originTask.UpdatedAt, timestampFromGJSONPath(originTask.Data, cfg.UpdatedAtPath)) - openAIVideo.Model = originTask.Properties.OriginModelName - - if originTask.Status == model.TaskStatusFailure { - openAIVideo.Error = &dto.OpenAIVideoError{ - Message: firstNonEmpty(originTask.FailReason, stringFromGJSONPath(originTask.Data, cfg.ErrorMessagePath)), - Code: "upstream_error", - } - } - return common.Marshal(openAIVideo) -} - -func seedanceMediaConfigFromTask(task *model.Task) dto.TaskProtocolConfig { - if cfg, ok := taskcommon.TaskProtocolConfigFromTask(task); ok { - return cfg - } - cfg := normalizeSeedanceMediaProtocolConfig(nil) - if task == nil || task.ChannelId == 0 { - return cfg - } - ch, err := model.GetChannelById(task.ChannelId, true) - if err != nil || ch == nil { - return cfg - } - settings := ch.GetOtherSettings() - if strings.EqualFold(settings.TaskProtocol, taskProtocolSeedanceOfficialMedia) { - return normalizeSeedanceMediaProtocolConfig(settings.TaskProtocolConfig) - } - return cfg -} - -func mapSeedanceMediaStatus(status string, cfg dto.TaskProtocolConfig) string { - normalized := strings.ToLower(strings.TrimSpace(status)) - if mapped, ok := cfg.StatusMap[normalized]; ok { - return normalizeInternalTaskStatus(mapped) - } - return normalizeInternalTaskStatus(normalized) -} - -func normalizeInternalTaskStatus(status string) string { - switch strings.ToLower(strings.TrimSpace(status)) { - case "submitted": - return string(model.TaskStatusSubmitted) - case "queued", "pending": - return string(model.TaskStatusQueued) - case "in_progress", "running", "processing": - return string(model.TaskStatusInProgress) - case "success", "succeeded", "completed": - return string(model.TaskStatusSuccess) - case "failure", "failed": - return string(model.TaskStatusFailure) - default: - return string(model.TaskStatusInProgress) - } -} - -func looksLikeSeedanceMediaTask(data []byte) bool { - if len(data) == 0 { - return false - } - return gjson.GetBytes(data, "task_id").Exists() && - (gjson.GetBytes(data, "status").Exists() || gjson.GetBytes(data, "object").String() == "media.task") -} - -func buildSeedanceMediaURL(baseURL, path string) string { - if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { - return path - } - baseURL = strings.TrimRight(baseURL, "/") - path = strings.TrimLeft(path, "/") - if path == "" { - return baseURL - } - if parsed, err := url.Parse(baseURL); err == nil && parsed.Path != "" { - basePath := strings.Trim(parsed.Path, "/") - if path == basePath { - path = "" - } else if strings.HasPrefix(path, basePath+"/") { - path = strings.TrimPrefix(path, basePath+"/") - } - } - if path == "" { - return baseURL - } - return baseURL + "/" + path -} - -func getSeedanceMediaRawRequest(c *gin.Context) map[string]interface{} { - if c == nil { - return nil - } - v, ok := c.Get(seedanceMediaRawRequestKey) - if !ok { - return nil - } - raw, _ := v.(map[string]interface{}) - return raw -} - -func stringFromGJSONPath(data []byte, path string) string { - if path == "" { - return "" - } - result := gjson.GetBytes(data, path) - if !result.Exists() { - return "" - } - if result.IsObject() || result.IsArray() { - return result.Raw - } - return strings.TrimSpace(result.String()) -} - -func extractSeedanceMediaResultURL(data []byte, paths []string) string { - for _, path := range paths { - result := gjson.GetBytes(data, path) - if !result.Exists() { - continue - } - if result.IsObject() { - if urlValue := firstGJSONString(result.Raw, "url", "video_url", "output_url"); urlValue != "" { - return urlValue - } - continue - } - value := strings.TrimSpace(result.String()) - if value == "" { - continue - } - if common.IsJsonObject(value) { - if urlValue := firstGJSONString(value, "url", "video_url", "output_url"); urlValue != "" { - return urlValue - } - continue - } - if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") || strings.HasPrefix(value, "data:") { - return value - } - } - return "" -} - -func firstGJSONString(jsonText string, paths ...string) string { - for _, path := range paths { - value := strings.TrimSpace(gjson.Get(jsonText, path).String()) - if value != "" { - return value - } - } - return "" -} - -func normalizeMediaProgress(progress string, status string) string { - progress = strings.TrimSpace(progress) - if progress == "" { - switch status { - case string(model.TaskStatusSuccess), string(model.TaskStatusFailure): - return "100%" - case string(model.TaskStatusQueued), string(model.TaskStatusSubmitted): - return "10%" - default: - return "50%" - } - } - if strings.HasSuffix(progress, "%") { - return progress - } - return progress + "%" -} - -func timestampFromGJSONPath(data []byte, path string) int64 { - value := stringFromGJSONPath(data, path) - if value == "" { - return 0 - } - if n, err := strconv.ParseInt(value, 10, 64); err == nil { - return n - } - return 0 -} - -func firstTimestamp(values ...int64) int64 { - for _, value := range values { - if value > 0 { - return value - } - } - return 0 -} - -func parsePositiveOrAutoSeconds(value string) (int, bool) { - if strings.TrimSpace(value) == "" { - return 0, false - } - sec, err := strconv.Atoi(value) - if err != nil { - return 0, false - } - return sec, sec > 0 || sec == -1 -} - -func intFromAny(value interface{}) (int, bool) { - switch v := value.(type) { - case int: - return v, true - case int64: - return int(v), true - case float64: - return int(v), true - case string: - n, err := strconv.Atoi(strings.TrimSpace(v)) - return n, err == nil - default: - return 0, false - } -} - -func boolFromAny(value interface{}) (bool, bool) { - switch v := value.(type) { - case bool: - return v, true - case string: - b, err := strconv.ParseBool(strings.TrimSpace(v)) - return b, err == nil - default: - return false, false - } -} - -func stringSliceFromAny(value interface{}) ([]string, bool) { - switch v := value.(type) { - case []string: - return append([]string{}, v...), true - case []interface{}: - out := make([]string, 0, len(v)) - for _, item := range v { - s := strings.TrimSpace(common.Interface2String(item)) - if s != "" { - out = append(out, s) - } - } - return out, len(out) > 0 - default: - return nil, false - } -} - -func setPayloadString(raw map[string]interface{}, key string, target *string) { - if raw == nil || target == nil { - return - } - if value, ok := raw[key]; ok { - setStringValue(value, target) - } -} - -func setStringValue(value interface{}, target *string) { - if target == nil { - return - } - if s, ok := value.(string); ok { - *target = strings.TrimSpace(s) - } -} - -func setString(out map[string]interface{}, key string, value string) { - if strings.TrimSpace(value) != "" { - out[key] = value - } -} - -func firstString(values []string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return value - } - } - return "" -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return value - } - } - return "" -} diff --git a/relay/channel/task/gemini/adaptor.go b/relay/channel/task/gemini/adaptor.go index 38ef72b..9d1b7f4 100644 --- a/relay/channel/task/gemini/adaptor.go +++ b/relay/channel/task/gemini/adaptor.go @@ -173,8 +173,7 @@ func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInf return nil } - seconds := ResolveVeoDuration(req.Metadata, req.Duration, req.Seconds) - resolution := ResolveVeoResolution(req.Metadata, req.Size) + seconds, resolution := ResolveVeoBillingInputs(c, req) resRatio := VeoResolutionRatio(info.UpstreamModelName, resolution) return map[string]float64{ diff --git a/relay/channel/task/gemini/billing.go b/relay/channel/task/gemini/billing.go index b081eb2..e82a503 100644 --- a/relay/channel/task/gemini/billing.go +++ b/relay/channel/task/gemini/billing.go @@ -3,6 +3,10 @@ package gemini import ( "strconv" "strings" + + "github.com/MAX-API-Next/MAX-API/common" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/gin-gonic/gin" ) // ParseVeoDurationSeconds extracts durationSeconds from metadata. @@ -79,6 +83,23 @@ func ResolveVeoResolution(metadata map[string]any, stdSize string) string { return "720p" } +func ResolveVeoBillingInputs(c *gin.Context, req relaycommon.TaskSubmitReq) (int, string) { + seconds := ResolveVeoDuration(req.Metadata, req.Duration, req.Seconds) + resolution := ResolveVeoResolution(req.Metadata, req.Size) + if finalBody, ok := relaycommon.GetTaskSubmitRequestBody(c); ok { + var body VeoRequestPayload + if err := common.Unmarshal(finalBody, &body); err == nil && body.Parameters != nil { + if body.Parameters.DurationSeconds > 0 { + seconds = body.Parameters.DurationSeconds + } + if body.Parameters.Resolution != "" { + resolution = strings.ToLower(body.Parameters.Resolution) + } + } + } + return seconds, resolution +} + // SizeToVeoResolution converts a "WxH" size string to a Veo resolution label. func SizeToVeoResolution(size string) string { parts := strings.SplitN(strings.ToLower(size), "x", 2) diff --git a/relay/channel/task/kling/adaptor.go b/relay/channel/task/kling/adaptor.go index e2e890d..817f3be 100644 --- a/relay/channel/task/kling/adaptor.go +++ b/relay/channel/task/kling/adaptor.go @@ -319,7 +319,7 @@ func (a *TaskAdaptor) EstimateTaskBilling(c *gin.Context, info *relaycommon.Rela if err != nil { return nil, err } - body, err := a.convertToRequestPayload(&req, info) + body, err := a.resolveBillingRequestPayload(c, &req, info) if err != nil { return nil, err } @@ -401,6 +401,25 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq, in return &r, nil } +func (a *TaskAdaptor) resolveBillingRequestPayload(c *gin.Context, req *relaycommon.TaskSubmitReq, info *relaycommon.RelayInfo) (*requestPayload, error) { + if finalBody, ok := relaycommon.GetTaskSubmitRequestBody(c); ok { + var body requestPayload + if err := common.Unmarshal(finalBody, &body); err == nil && looksLikeKlingRequestPayload(&body) { + return &body, nil + } + } + return a.convertToRequestPayload(req, info) +} + +func looksLikeKlingRequestPayload(body *requestPayload) bool { + return body != nil && (strings.TrimSpace(body.ModelName) != "" || + strings.TrimSpace(body.Model) != "" || + strings.TrimSpace(body.Prompt) != "" || + strings.TrimSpace(body.Duration) != "" || + len(body.ImageList) > 0 || + len(body.VideoList) > 0) +} + func (a *TaskAdaptor) getAspectRatio(size string) string { switch size { case "1024x1024", "512x512": diff --git a/relay/channel/task/taskcommon/path_config.go b/relay/channel/task/taskcommon/path_config.go index 592abcd..941cc3c 100644 --- a/relay/channel/task/taskcommon/path_config.go +++ b/relay/channel/task/taskcommon/path_config.go @@ -24,6 +24,9 @@ func ValidateTaskProtocolSettings(otherSettings string) error { if err := common.UnmarshalJsonStr(otherSettings, &settings); err != nil { return nil } + if strings.TrimSpace(settings.TaskProtocol) != "" && !UseConfiguredTaskProtocol(settings) { + return fmt.Errorf("task_protocol must be %s", TaskProtocolGenericVideo) + } cfg := settings.TaskProtocolConfig if cfg == nil { return nil @@ -45,7 +48,7 @@ func WithTaskProtocolConfig(body map[string]any, settings dto.ChannelOtherSettin body = map[string]any{} } if UseConfiguredTaskProtocol(settings) { - body[taskProtocolConfigBodyKey] = NormalizeTaskProtocolConfig(settings.TaskProtocolConfig) + body[taskProtocolConfigBodyKey] = EffectiveTaskProtocolConfig(settings) } else if settings.TaskProtocolConfig != nil { body[taskProtocolConfigBodyKey] = settings.TaskProtocolConfig } @@ -72,7 +75,7 @@ func BuildTaskSubmitURL(info *relaycommon.RelayInfo, fallback string) string { } path := "" if UseConfiguredTaskProtocol(info.ChannelOtherSettings) { - cfg := NormalizeTaskProtocolConfig(info.ChannelOtherSettings.TaskProtocolConfig) + cfg := EffectiveTaskProtocolConfig(info.ChannelOtherSettings) path = cfg.SubmitPath } else if info.ChannelOtherSettings.TaskProtocolConfig != nil { path = info.ChannelOtherSettings.TaskProtocolConfig.SubmitPath @@ -109,9 +112,32 @@ func BuildConfiguredTaskURL(baseURL string, path string, values map[string]strin if strings.HasPrefix(path, "?") { return baseURL + path } + path = trimDuplicatedBasePath(baseURL, path) + if path == "" { + return baseURL + } return baseURL + "/" + strings.TrimLeft(path, "/") } +func trimDuplicatedBasePath(baseURL string, path string) string { + parsed, err := url.Parse(baseURL) + if err != nil { + return strings.TrimLeft(path, "/") + } + basePath := strings.Trim(parsed.EscapedPath(), "/") + if basePath == "" { + return strings.TrimLeft(path, "/") + } + path = strings.TrimLeft(path, "/") + if path == basePath { + return "" + } + if strings.HasPrefix(path, basePath+"/") { + return strings.TrimPrefix(path, basePath+"/") + } + return path +} + func taskProtocolConfigFromBody(body map[string]any) *dto.TaskProtocolConfig { if body == nil { return nil diff --git a/relay/channel/task/taskcommon/path_config_test.go b/relay/channel/task/taskcommon/path_config_test.go index ad38f27..8f3a149 100644 --- a/relay/channel/task/taskcommon/path_config_test.go +++ b/relay/channel/task/taskcommon/path_config_test.go @@ -1,9 +1,12 @@ package taskcommon import ( + "bytes" + "io" "net/http/httptest" "testing" + "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/dto" "github.com/MAX-API-Next/MAX-API/model" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" @@ -61,6 +64,12 @@ func TestBuildConfiguredTaskURLUsesQueryEscaping(t *testing.T) { assert.Equal(t, "https://upstream.example.com/v1/videos?operation=models%2Fveo-3%2Foperations%2Fa%26b&model=video+model&id=task%2Fabc%26x%3D1", got) } +func TestBuildConfiguredTaskURLDeduplicatesBasePath(t *testing.T) { + got := BuildConfiguredTaskURL("https://upstream.example.com/v1", "/v1/videos/create", nil) + + assert.Equal(t, "https://upstream.example.com/v1/videos/create", got) +} + func TestBuildTaskQueryURLKeepsFallbackWithoutConfig(t *testing.T) { got := BuildTaskQueryURL("https://upstream.example.com", map[string]any{ "task_id": "task_abc", @@ -69,6 +78,98 @@ func TestBuildTaskQueryURLKeepsFallbackWithoutConfig(t *testing.T) { assert.Equal(t, "https://fallback.example.com/task_abc", got) } +func TestBuildConfiguredTaskPassThroughBodyUsesChannelPassThrough(t *testing.T) { + c := newJSONTaskContext(`{ + "model": "local-model", + "prompt": "test", + "duration_seconds": 0, + "with_audio": false + }`) + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "upstream-model", + IsModelMapped: true, + ChannelSetting: dto.ChannelSettings{PassThroughBodyEnabled: true}, + ChannelOtherSettings: dto.ChannelOtherSettings{ + TaskProtocol: TaskProtocolGenericVideo, + }, + }, + } + + reader, handled, err := BuildConfiguredTaskPassThroughBody(c, info) + + require.NoError(t, err) + require.True(t, handled) + body := readJSONBody(t, reader) + assert.Equal(t, "upstream-model", gjson.GetBytes(body, "model").String()) + assert.Equal(t, "test", gjson.GetBytes(body, "prompt").String()) + assert.Equal(t, int64(0), gjson.GetBytes(body, "duration_seconds").Int()) + assert.False(t, gjson.GetBytes(body, "with_audio").Bool()) +} + +func TestBuildConfiguredTaskPassThroughBodyFallsBackWithoutChannelPassThrough(t *testing.T) { + c := newJSONTaskContext(`{"model":"video-model","prompt":"test"}`) + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelOtherSettings: dto.ChannelOtherSettings{ + TaskProtocol: TaskProtocolGenericVideo, + }, + }, + } + + reader, handled, err := BuildConfiguredTaskPassThroughBody(c, info) + + require.NoError(t, err) + assert.False(t, handled) + assert.Nil(t, reader) +} + +func TestApplyTaskParamOverrideRewritesJSONBody(t *testing.T) { + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + ParamOverride: map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "prompt", + "mode": "set", + "value": "rewritten", + }, + }, + }, + }, + } + + reader, err := ApplyTaskParamOverride(bytes.NewBufferString(`{"model":"video-model","prompt":"original"}`), info) + + require.NoError(t, err) + body := readJSONBody(t, reader) + assert.Equal(t, "rewritten", gjson.GetBytes(body, "prompt").String()) + assert.Equal(t, "video-model", gjson.GetBytes(body, "model").String()) +} + +func TestApplyTaskParamOverrideSkipsNonJSONObjectBody(t *testing.T) { + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ + ParamOverride: map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "prompt", + "mode": "set", + "value": "rewritten", + }, + }, + }, + }, + } + + reader, err := ApplyTaskParamOverride(bytes.NewBufferString(`not-json`), info) + + require.NoError(t, err) + body, err := io.ReadAll(reader) + require.NoError(t, err) + assert.Equal(t, `not-json`, string(body)) +} + func TestStripTaskProtocolConfig(t *testing.T) { body := WithTaskProtocolConfig(map[string]any{ "task_id": "task_abc", @@ -120,6 +221,27 @@ func TestParseConfiguredTaskResultUsesGenericProtocol(t *testing.T) { assert.Equal(t, "https://example.com/video.mp4", result.Url) } +func TestParseConfiguredTaskResultPromotesResultURLToSuccess(t *testing.T) { + settings := dto.ChannelOtherSettings{ + TaskProtocol: TaskProtocolGenericVideo, + } + body := []byte(`{ + "task_id": "upstream_task_1", + "status": "in_progress", + "progress": 100, + "result": {"video_url": "https://example.com/video.mp4"} + }`) + + result, parsed, err := ParseConfiguredTaskResult(body, settings) + + require.NoError(t, err) + require.True(t, parsed) + require.NotNil(t, result) + assert.Equal(t, string(model.TaskStatusSuccess), result.Status) + assert.Equal(t, "100%", result.Progress) + assert.Equal(t, "https://example.com/video.mp4", result.Url) +} + func TestParseConfiguredTaskResultRequiresProtocol(t *testing.T) { settings := dto.ChannelOtherSettings{ TaskProtocolConfig: &dto.TaskProtocolConfig{ @@ -145,6 +267,8 @@ func TestValidateTaskProtocolSettings(t *testing.T) { {name: "empty settings", settings: "", wantErr: false}, {name: "no task protocol config", settings: `{"azure_responses_version":"v1"}`, wantErr: false}, {name: "empty query path uses default", settings: `{"task_protocol_config":{"submit_path":"/v1/videos/create"}}`, wantErr: false}, + {name: "stale request body config ignored", settings: `{"task_protocol":"generic_video_task","task_protocol_config":{"request_body_mode":"media_generation","request_body_mapping":{"model":"model"},"query_path":"/v1/videos/{task_id}"}}`, wantErr: false}, + {name: "unknown task protocol rejected", settings: `{"task_protocol":"unsupported_video_task","task_protocol_config":{"query_path":"/v1/videos/{task_id}"}}`, wantErr: true}, {name: "task_id placeholder", settings: `{"task_protocol_config":{"query_path":"/v1/videos/{task_id}"}}`, wantErr: false}, {name: "operation_name placeholder", settings: `{"task_protocol_config":{"query_path":"/v1beta/{operation_name}"}}`, wantErr: false}, {name: "upstream_task_id placeholder", settings: `{"task_protocol_config":{"query_path":"/v1/tasks?id={upstream_task_id}"}}`, wantErr: false}, @@ -163,6 +287,23 @@ func TestValidateTaskProtocolSettings(t *testing.T) { } } +func newJSONTaskContext(body string) *gin.Context { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest("POST", "/v1/videos", bytes.NewBufferString(body)) + c.Request.Header.Set("Content-Type", "application/json") + return c +} + +func readJSONBody(t *testing.T, reader io.Reader) []byte { + t.Helper() + body, err := io.ReadAll(reader) + require.NoError(t, err) + require.True(t, common.IsJsonObject(string(body))) + return body +} + func TestTryHandleConfiguredSubmitResponseKlingRouteUsesKlingFormat(t *testing.T) { gin.SetMode(gin.TestMode) recorder := httptest.NewRecorder() diff --git a/relay/channel/task/taskcommon/protocol_config.go b/relay/channel/task/taskcommon/protocol_config.go index b4a786e..138cc40 100644 --- a/relay/channel/task/taskcommon/protocol_config.go +++ b/relay/channel/task/taskcommon/protocol_config.go @@ -17,8 +17,7 @@ import ( ) const ( - TaskProtocolGenericVideo = "generic_video_task" - TaskProtocolLegacySeedanceMedia = "seedance_official_media" + TaskProtocolGenericVideo = "generic_video_task" ) var videoTaskChannelTypes = map[int]struct{}{ @@ -41,7 +40,7 @@ func HasTaskProtocolConfig(settings dto.ChannelOtherSettings) bool { func UseConfiguredTaskProtocol(settings dto.ChannelOtherSettings) bool { protocol := strings.ToLower(strings.TrimSpace(settings.TaskProtocol)) - return protocol == TaskProtocolGenericVideo || protocol == TaskProtocolLegacySeedanceMedia + return protocol == TaskProtocolGenericVideo } func IsVideoTaskChannelType(channelType int) bool { @@ -51,15 +50,14 @@ func IsVideoTaskChannelType(channelType int) bool { func NormalizeTaskProtocolConfig(input *dto.TaskProtocolConfig) dto.TaskProtocolConfig { cfg := dto.TaskProtocolConfig{ - SubmitPath: "/v1/videos/create", - QueryPath: "/v1/videos/{task_id}", - TaskIDPath: "task_id", - StatusPath: "status", - ProgressPath: "progress", - ResultURLPaths: []string{"result.primary_url", "result.urls.0", "result.url", "result.video_url", "result.output_url", "data.result.primary_url", "data.result.urls.0", "data.result.url", "data.result.video_url", "data.result.output_url", "url", "video_url", "output_url", "file_url", "download_url", "result"}, - ErrorMessagePath: "error_message", - CreatedAtPath: "created_at", - UpdatedAtPath: "updated_at", + SubmitPath: "/v1/videos/create", + QueryPath: "/v1/videos/{task_id}", + TaskIDPath: "task_id", + StatusPath: "status", + ProgressPath: "progress", + ResultURLPaths: []string{"result.primary_url", "result.urls.0", "result.url", "result.video_url", "result.output_url", "data.result.primary_url", "data.result.urls.0", "data.result.url", "data.result.video_url", "data.result.output_url", "url", "video_url", "output_url", "file_url", "download_url", "result"}, + CreatedAtPath: "created_at", + UpdatedAtPath: "updated_at", StatusMap: map[string]string{ "queued": string(model.TaskStatusQueued), "pending": string(model.TaskStatusQueued), @@ -76,6 +74,7 @@ func NormalizeTaskProtocolConfig(input *dto.TaskProtocolConfig) dto.TaskProtocol "failure": string(model.TaskStatusFailure), "error": string(model.TaskStatusFailure), }, + ErrorMessagePath: "error_message", } if input == nil { return cfg @@ -113,11 +112,15 @@ func NormalizeTaskProtocolConfig(input *dto.TaskProtocolConfig) dto.TaskProtocol return cfg } +func EffectiveTaskProtocolConfig(settings dto.ChannelOtherSettings) dto.TaskProtocolConfig { + return NormalizeTaskProtocolConfig(settings.TaskProtocolConfig) +} + func TryHandleConfiguredSubmitResponse(c *gin.Context, responseBody []byte, info *relaycommon.RelayInfo) (string, bool, *dto.TaskError) { if info == nil || info.ChannelMeta == nil || !UseConfiguredTaskProtocol(info.ChannelOtherSettings) { return "", false, nil } - cfg := NormalizeTaskProtocolConfig(info.ChannelOtherSettings.TaskProtocolConfig) + cfg := EffectiveTaskProtocolConfig(info.ChannelOtherSettings) taskID := StringFromGJSONPath(responseBody, cfg.TaskIDPath) if taskID == "" { taskID = StringFromGJSONPath(responseBody, "id") @@ -158,7 +161,7 @@ func ParseConfiguredTaskResult(respBody []byte, settings dto.ChannelOtherSetting if !UseConfiguredTaskProtocol(settings) { return nil, false, nil } - cfg := NormalizeTaskProtocolConfig(settings.TaskProtocolConfig) + cfg := EffectiveTaskProtocolConfig(settings) taskID := StringFromGJSONPath(respBody, cfg.TaskIDPath) statusRaw := StringFromGJSONPath(respBody, cfg.StatusPath) progressRaw := StringFromGJSONPath(respBody, cfg.ProgressPath) @@ -173,6 +176,9 @@ func ParseConfiguredTaskResult(respBody []byte, settings dto.ChannelOtherSetting if statusRaw == "" { status = inferConfiguredTaskStatus(resultURL, reason) } + if resultURL != "" && status != string(model.TaskStatusFailure) { + status = string(model.TaskStatusSuccess) + } progress := NormalizeConfiguredProgress(progressRaw, status) return &relaycommon.TaskInfo{ @@ -226,7 +232,7 @@ func TaskProtocolConfigFromTask(task *model.Task) (dto.TaskProtocolConfig, bool) if !UseConfiguredTaskProtocol(settings) { return dto.TaskProtocolConfig{}, false } - return NormalizeTaskProtocolConfig(settings.TaskProtocolConfig), true + return EffectiveTaskProtocolConfig(settings), true } func StringFromGJSONPath(data []byte, path string) string { diff --git a/relay/channel/task/taskcommon/request_body.go b/relay/channel/task/taskcommon/request_body.go new file mode 100644 index 0000000..380e245 --- /dev/null +++ b/relay/channel/task/taskcommon/request_body.go @@ -0,0 +1,286 @@ +package taskcommon + +import ( + "bytes" + "errors" + "fmt" + "io" + "strconv" + "strings" + + "github.com/MAX-API-Next/MAX-API/common" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + + "github.com/gin-gonic/gin" +) + +var ErrTaskParamOverride = errors.New("task param override failed") + +func IsTaskParamOverrideError(err error) bool { + return errors.Is(err, ErrTaskParamOverride) +} + +func BuildConfiguredTaskPassThroughBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, bool, error) { + if info == nil || info.ChannelMeta == nil || !UseConfiguredTaskProtocol(info.ChannelOtherSettings) || + !info.ChannelSetting.PassThroughBodyEnabled || !isJSONRequest(c) { + return nil, false, nil + } + + var raw map[string]any + if err := common.UnmarshalBodyReusable(c, &raw); err != nil { + return nil, true, err + } + applyConfiguredModel(raw, info) + data, err := common.Marshal(raw) + if err != nil { + return nil, true, err + } + return bytes.NewReader(data), true, nil +} + +func ApplyTaskParamOverride(requestBody io.Reader, info *relaycommon.RelayInfo) (io.Reader, error) { + if requestBody == nil || info == nil || len(info.ParamOverride) == 0 { + return requestBody, nil + } + data, err := ApplyTaskParamOverrideBytes(requestBody, info) + if err != nil { + return nil, err + } + if data == nil { + return requestBody, nil + } + return bytes.NewReader(data), nil +} + +func ApplyTaskParamOverrideBytes(requestBody io.Reader, info *relaycommon.RelayInfo) ([]byte, error) { + if requestBody == nil || info == nil || len(info.ParamOverride) == 0 { + return nil, nil + } + data, err := io.ReadAll(requestBody) + if err != nil { + return nil, err + } + if len(bytes.TrimSpace(data)) == 0 || !common.IsJsonObject(string(data)) { + return data, nil + } + data, err = applyTaskParamOverride(data, info) + if err != nil { + return nil, err + } + return data, nil +} + +func SyncTaskRequestContext(c *gin.Context, data []byte) error { + relaycommon.StoreTaskSubmitRequestBody(c, data) + if c == nil || c.Request == nil || len(bytes.TrimSpace(data)) == 0 || !common.IsJsonObject(string(data)) { + return nil + } + var raw map[string]any + if err := common.Unmarshal(data, &raw); err != nil { + return err + } + req := relaycommon.TaskSubmitReq{} + if existing, err := relaycommon.GetTaskRequest(c); err == nil { + req = existing + } + mergeTaskSubmitReq(&req, raw) + c.Set("task_request", req) + return nil +} + +func mergeTaskSubmitReq(req *relaycommon.TaskSubmitReq, raw map[string]any) { + if req == nil || raw == nil { + return + } + if req.Metadata == nil { + req.Metadata = map[string]interface{}{} + } + for key, value := range raw { + req.Metadata[key] = value + } + + copyString(raw, "model", &req.Model) + copyString(raw, "model_name", &req.Model) + copyString(raw, "prompt", &req.Prompt) + copyString(raw, "mode", &req.Mode) + copyString(raw, "image", &req.Image) + copyString(raw, "image_tail", &req.EndImage) + copyString(raw, "size", &req.Size) + copyString(raw, "aspect_ratio", &req.Size) + copyString(raw, "seconds", &req.Seconds) + copyString(raw, "input_reference", &req.InputReference) + copyString(raw, "capability", &req.Capability) + copyString(raw, "control_mode", &req.ControlMode) + copyString(raw, "input_mode", &req.InputMode) + copyString(raw, "resolution", &req.Resolution) + copyInt(raw, "duration", &req.Duration) + copyIntPtr(raw, "duration_seconds", &req.DurationSeconds) + copyBoolPtr(raw, "with_audio", &req.WithAudio) + copyBoolPtr(raw, "generate_audio", &req.WithAudio) + if images, ok := stringSliceValue(raw["images"]); ok { + req.Images = images + } + if refs, ok := stringSliceValue(raw["reference_images"]); ok { + req.ReferenceImages = refs + } + + if input, ok := mapValue(raw["input"]); ok { + copyString(input, "prompt", &req.Prompt) + copyString(input, "img_url", &req.InputReference) + if req.InputReference == "" { + copyString(input, "first_frame_url", &req.InputReference) + } + copyString(input, "last_frame_url", &req.EndImage) + } + if params, ok := mapValue(raw["parameters"]); ok { + for key, value := range params { + req.Metadata[key] = value + } + copyString(params, "mode", &req.Mode) + if req.Size == "" { + if !copyString(params, "aspect_ratio", &req.Size) { + copyString(params, "aspectRatio", &req.Size) + } + if req.Size == "" { + copyString(params, "size", &req.Size) + } + } + copyString(params, "resolution", &req.Resolution) + copyInt(params, "duration", &req.Duration) + copyInt(params, "durationSeconds", &req.Duration) + copyBoolPtr(params, "audio", &req.WithAudio) + copyBoolPtr(params, "generateAudio", &req.WithAudio) + } + if duration, ok := intValue(raw["duration"]); ok && duration > 0 { + req.Seconds = strconv.Itoa(duration) + } +} + +func applyTaskParamOverride(data []byte, info *relaycommon.RelayInfo) ([]byte, error) { + out, err := relaycommon.ApplyParamOverrideWithRelayInfo(data, info) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrTaskParamOverride, err) + } + return out, nil +} + +func applyConfiguredModel(raw map[string]any, info *relaycommon.RelayInfo) { + if raw == nil || info == nil { + return + } + if info.IsModelMapped { + raw["model"] = info.UpstreamModelName + return + } + if model, ok := raw["model"].(string); ok && strings.TrimSpace(model) != "" { + info.UpstreamModelName = strings.TrimSpace(model) + } +} + +func isJSONRequest(c *gin.Context) bool { + if c == nil || c.Request == nil { + return false + } + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type"))), "application/json") +} + +func copyString(raw map[string]any, key string, target *string) bool { + value, ok := stringValue(raw[key]) + if !ok { + return false + } + *target = value + return true +} + +func copyInt(raw map[string]any, key string, target *int) bool { + value, ok := intValue(raw[key]) + if !ok { + return false + } + *target = value + return true +} + +func copyIntPtr(raw map[string]any, key string, target **int) bool { + value, ok := intValue(raw[key]) + if !ok { + return false + } + *target = &value + return true +} + +func copyBoolPtr(raw map[string]any, key string, target **bool) bool { + value, ok := boolValue(raw[key]) + if !ok { + return false + } + *target = &value + return true +} + +func stringValue(value any) (string, bool) { + switch v := value.(type) { + case string: + return v, true + default: + return "", false + } +} + +func intValue(value any) (int, bool) { + switch v := value.(type) { + case int: + return v, true + case int64: + return int(v), true + case float64: + if v == float64(int(v)) { + return int(v), true + } + case string: + if parsed, err := strconv.Atoi(strings.TrimSpace(v)); err == nil { + return parsed, true + } + } + return 0, false +} + +func boolValue(value any) (bool, bool) { + switch v := value.(type) { + case bool: + return v, true + case string: + switch strings.ToLower(strings.TrimSpace(v)) { + case "true", "1", "yes", "on", "enable", "enabled": + return true, true + case "false", "0", "no", "off", "disable", "disabled": + return false, true + } + } + return false, false +} + +func stringSliceValue(value any) ([]string, bool) { + items, ok := value.([]any) + if !ok { + return nil, false + } + result := make([]string, 0, len(items)) + for _, item := range items { + if s, ok := stringValue(item); ok { + result = append(result, s) + } + } + return result, true +} + +func mapValue(value any) (map[string]any, bool) { + switch v := value.(type) { + case map[string]any: + return v, true + default: + return nil, false + } +} diff --git a/relay/channel/task/vertex/adaptor.go b/relay/channel/task/vertex/adaptor.go index 79c5e97..d977df1 100644 --- a/relay/channel/task/vertex/adaptor.go +++ b/relay/channel/task/vertex/adaptor.go @@ -130,8 +130,7 @@ func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInf } req := v.(relaycommon.TaskSubmitReq) - seconds := geminitask.ResolveVeoDuration(req.Metadata, req.Duration, req.Seconds) - resolution := geminitask.ResolveVeoResolution(req.Metadata, req.Size) + seconds, resolution := geminitask.ResolveVeoBillingInputs(c, req) resRatio := geminitask.VeoResolutionRatio(info.UpstreamModelName, resolution) return map[string]float64{ diff --git a/relay/common/relay_utils.go b/relay/common/relay_utils.go index 8e4d2d4..b2547a1 100644 --- a/relay/common/relay_utils.go +++ b/relay/common/relay_utils.go @@ -64,6 +64,35 @@ func StoreTaskRequest(c *gin.Context, info *RelayInfo, action string, requestObj storeTaskRequest(c, info, action, requestObj) } +func StoreTaskSubmitRequestBody(c *gin.Context, body []byte) { + if c == nil { + return + } + c.Set("task_submit_request_body", append([]byte(nil), body...)) +} + +func GetTaskSubmitRequestBody(c *gin.Context) ([]byte, bool) { + if c == nil { + return nil, false + } + v, exists := c.Get("task_submit_request_body") + if !exists { + return nil, false + } + body, ok := v.([]byte) + if !ok { + return nil, false + } + return body, true +} + +func ClearTaskSubmitRequestBody(c *gin.Context) { + if c == nil || c.Keys == nil { + return + } + delete(c.Keys, "task_submit_request_body") +} + func GetTaskRequest(c *gin.Context) (TaskSubmitReq, error) { v, exists := c.Get("task_request") if !exists { diff --git a/relay/relay_task.go b/relay/relay_task.go index 1eec2ea..633fe9d 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -181,7 +181,13 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe info.PublicTaskID = model.GenerateTaskID() } - // 4. 价格计算:基础模型价格 + // 4. 构建最终请求体(含 Param Override),并暴露给任务计费估算。 + requestBody, taskErr := prepareTaskSubmitRequestBody(c, info, adaptor) + if taskErr != nil { + return nil, taskErr + } + + // 5. 价格计算:基础模型价格 info.OriginModelName = modelName priceData, err := helper.ModelPriceHelperPerCall(c, info) if err != nil { @@ -189,7 +195,7 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe } info.PriceData = priceData - // 5. Prefer a parameterized rate card when the adaptor can normalize the + // 6. Prefer a parameterized rate card when the adaptor can normalize the // request. Legacy task models continue to use OtherRatios. taskBillingOverride := false if estimator, ok := adaptor.(taskBillingEstimator); ok { @@ -228,15 +234,12 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe } } - // 8. 构建请求体 - requestBody, err := adaptor.BuildRequestBody(c, info) - if err != nil { - return nil, service.TaskErrorWrapper(err, "build_request_failed", http.StatusInternalServerError) - } - // 9. 发送请求 resp, err := adaptor.DoRequest(c, info, requestBody) if err != nil { + if taskErr := taskErrorFromLocalRelayError(err); taskErr != nil { + return nil, taskErr + } return nil, service.TaskErrorWrapper(err, "do_request_failed", http.StatusInternalServerError) } if resp != nil && resp.StatusCode != http.StatusOK { @@ -278,6 +281,59 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe }, nil } +func prepareTaskSubmitRequestBody(c *gin.Context, info *relaycommon.RelayInfo, adaptor channel.TaskAdaptor) (io.Reader, *dto.TaskError) { + relaycommon.ClearTaskSubmitRequestBody(c) + requestBody, err := buildTaskSubmitRequestBody(c, info, adaptor) + if err != nil { + return nil, taskErrorFromBuildRequestError(err) + } + if info == nil || len(info.ParamOverride) == 0 { + return requestBody, nil + } + bodyBytes, err := io.ReadAll(requestBody) + if err != nil { + return nil, service.TaskErrorWrapper(err, "build_request_failed", http.StatusInternalServerError) + } + if err := taskcommon.SyncTaskRequestContext(c, bodyBytes); err != nil { + return nil, service.TaskErrorWrapper(err, "build_request_failed", http.StatusInternalServerError) + } + return bytes.NewReader(bodyBytes), nil +} + +func taskErrorFromBuildRequestError(err error) *dto.TaskError { + if taskcommon.IsTaskParamOverrideError(err) { + return service.TaskErrorLocalFromAPIError(maxAPIErrorFromParamOverride(err)) + } + return service.TaskErrorWrapper(err, "build_request_failed", http.StatusInternalServerError) +} + +func taskErrorFromLocalRelayError(err error) *dto.TaskError { + var maxAPIError *types.MaxAPIError + if !errors.As(err, &maxAPIError) { + return nil + } + switch maxAPIError.GetErrorCode() { + case types.ErrorCodeChannelParamOverrideInvalid, types.ErrorCodeChannelHeaderOverrideInvalid: + return service.TaskErrorLocalFromAPIError(maxAPIError) + default: + return nil + } +} + +func buildTaskSubmitRequestBody(c *gin.Context, info *relaycommon.RelayInfo, adaptor channel.TaskAdaptor) (io.Reader, error) { + requestBody, handled, err := taskcommon.BuildConfiguredTaskPassThroughBody(c, info) + if err != nil { + return nil, err + } + if !handled { + requestBody, err = adaptor.BuildRequestBody(c, info) + if err != nil { + return nil, err + } + } + return taskcommon.ApplyTaskParamOverride(requestBody, info) +} + // recalcQuotaFromRatios 根据 adjustedRatios 重新计算 quota。 // 公式: baseQuota × ∏(ratio) — 其中 baseQuota 是不含 OtherRatios 的基础额度。 func recalcQuotaFromRatios(info *relaycommon.RelayInfo, ratios map[string]float64) int { diff --git a/relay/relay_task_test.go b/relay/relay_task_test.go new file mode 100644 index 0000000..9327738 --- /dev/null +++ b/relay/relay_task_test.go @@ -0,0 +1,103 @@ +package relay + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/relay/channel/task/doubao" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPrepareTaskSubmitRequestBodyMakesParamOverrideVisibleToBilling(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/video/generations", strings.NewReader(`{ + "model":"doubao-seedance-2-0-260128", + "prompt":"test", + "metadata":{"resolution":"720p"} + }`)) + c.Request.Header.Set("Content-Type", "application/json") + + info := &relaycommon.RelayInfo{ + OriginModelName: "doubao-seedance-2-0-260128", + ChannelMeta: &relaycommon.ChannelMeta{ + ParamOverride: map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "path": "resolution", + "mode": "set", + "value": "1080p", + }, + }, + }, + }, + TaskRelayInfo: &relaycommon.TaskRelayInfo{Action: constant.TaskActionGenerate}, + } + relaycommon.StoreTaskRequest(c, info, constant.TaskActionGenerate, relaycommon.TaskSubmitReq{ + Model: "doubao-seedance-2-0-260128", + Prompt: "test", + Metadata: map[string]interface{}{ + "resolution": "720p", + }, + }) + + requestBody, taskErr := prepareTaskSubmitRequestBody(c, info, &doubao.TaskAdaptor{}) + + require.Nil(t, taskErr) + body, err := io.ReadAll(requestBody) + require.NoError(t, err) + assert.Contains(t, string(body), `"resolution":"1080p"`) + + ratios := (&doubao.TaskAdaptor{}).EstimateBilling(c, info) + require.NotNil(t, ratios) + assert.InDelta(t, 51.0/46.0, ratios["video_input"], 1e-9) +} + +func TestPrepareTaskSubmitRequestBodyParamOverrideReturnErrorIsLocal(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/video/generations", strings.NewReader(`{ + "model":"doubao-seedance-2-0-260128", + "prompt":"test" + }`)) + c.Request.Header.Set("Content-Type", "application/json") + + info := &relaycommon.RelayInfo{ + OriginModelName: "doubao-seedance-2-0-260128", + ChannelMeta: &relaycommon.ChannelMeta{ + ParamOverride: map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{ + "mode": "return_error", + "value": map[string]interface{}{ + "message": "forced bad request by param override", + "status_code": 422, + "code": "forced_bad_request", + "skip_retry": true, + }, + }, + }, + }, + }, + TaskRelayInfo: &relaycommon.TaskRelayInfo{Action: constant.TaskActionGenerate}, + } + relaycommon.StoreTaskRequest(c, info, constant.TaskActionGenerate, relaycommon.TaskSubmitReq{ + Model: "doubao-seedance-2-0-260128", + Prompt: "test", + }) + + _, taskErr := prepareTaskSubmitRequestBody(c, info, &doubao.TaskAdaptor{}) + + require.NotNil(t, taskErr) + assert.True(t, taskErr.LocalError) + assert.Equal(t, http.StatusUnprocessableEntity, taskErr.StatusCode) + assert.Equal(t, "forced_bad_request", taskErr.Code) + assert.Equal(t, "forced bad request by param override", taskErr.Message) +} diff --git a/service/convert.go b/service/convert.go index 467ff15..d6d23e1 100644 --- a/service/convert.go +++ b/service/convert.go @@ -615,24 +615,18 @@ func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info *relayco } for _, choice := range openAIResponse.Choices { stopReason = stopReasonOpenAI2Claude(choice.FinishReason) - if choice.FinishReason == "tool_calls" { - for _, toolUse := range choice.Message.ParseToolCalls() { - claudeContent := dto.ClaudeMediaMessage{} - claudeContent.Type = "tool_use" - claudeContent.Id = toolUse.ID - claudeContent.Name = toolUse.Function.Name - var mapParams map[string]interface{} - if err := common.Unmarshal([]byte(toolUse.Function.Arguments), &mapParams); err == nil { - claudeContent.Input = mapParams - } else { - claudeContent.Input = toolUse.Function.Arguments - } - contents = append(contents, claudeContent) - } - } else { + if text := choice.Message.StringContent(); text != "" { claudeContent := dto.ClaudeMediaMessage{} claudeContent.Type = "text" - claudeContent.SetText(choice.Message.StringContent()) + claudeContent.SetText(text) + contents = append(contents, claudeContent) + } + for _, toolUse := range choice.Message.ParseToolCalls() { + claudeContent := dto.ClaudeMediaMessage{} + claudeContent.Type = "tool_use" + claudeContent.Id = toolUse.ID + claudeContent.Name = toolUse.Function.Name + claudeContent.Input = parseToolCallArguments(toolUse.Function.Arguments) contents = append(contents, claudeContent) } } @@ -643,6 +637,17 @@ func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info *relayco return claudeResponse } +func parseToolCallArguments(rawArgs string) map[string]interface{} { + if rawArgs == "" { + return make(map[string]interface{}) + } + var args map[string]interface{} + if err := common.Unmarshal([]byte(rawArgs), &args); err != nil { + return map[string]interface{}{"arguments": rawArgs} + } + return args +} + func stopReasonOpenAI2Claude(reason string) string { return reasonmap.OpenAIFinishReasonToClaudeStopReason(reason) } @@ -863,37 +868,25 @@ func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info *relayco Parts: make([]dto.GeminiPart, 0), } + // 处理文本内容 + textContent := choice.Message.StringContent() + if textContent != "" { + content.Parts = append(content.Parts, dto.GeminiPart{Text: textContent}) + } + // 处理工具调用 toolCalls := choice.Message.ParseToolCalls() if len(toolCalls) > 0 { for _, toolCall := range toolCalls { // 解析参数 - var args map[string]interface{} - if toolCall.Function.Arguments != "" { - if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil { - args = map[string]interface{}{"arguments": toolCall.Function.Arguments} - } - } else { - args = make(map[string]interface{}) - } - part := dto.GeminiPart{ FunctionCall: &dto.FunctionCall{ FunctionName: toolCall.Function.Name, - Arguments: args, + Arguments: parseToolCallArguments(toolCall.Function.Arguments), }, } content.Parts = append(content.Parts, part) } - } else { - // 处理文本内容 - textContent := choice.Message.StringContent() - if textContent != "" { - part := dto.GeminiPart{ - Text: textContent, - } - content.Parts = append(content.Parts, part) - } } candidate.Content = content @@ -971,19 +964,10 @@ func StreamResponseOpenAI2Gemini(openAIResponse *dto.ChatCompletionsStreamRespon if choice.Delta.ToolCalls != nil { for _, toolCall := range choice.Delta.ToolCalls { // 解析参数 - var args map[string]interface{} - if toolCall.Function.Arguments != "" { - if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil { - args = map[string]interface{}{"arguments": toolCall.Function.Arguments} - } - } else { - args = make(map[string]interface{}) - } - part := dto.GeminiPart{ FunctionCall: &dto.FunctionCall{ FunctionName: toolCall.Function.Name, - Arguments: args, + Arguments: parseToolCallArguments(toolCall.Function.Arguments), }, } content.Parts = append(content.Parts, part) diff --git a/service/convert_test.go b/service/convert_test.go new file mode 100644 index 0000000..a8c7db3 --- /dev/null +++ b/service/convert_test.go @@ -0,0 +1,99 @@ +package service + +import ( + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/dto" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResponseOpenAI2ClaudePreservesTextWithToolCalls(t *testing.T) { + resp := openAIResponseWithTextAndToolCall(t) + got := ResponseOpenAI2Claude(resp, &relaycommon.RelayInfo{}) + + require.NotNil(t, got) + require.Len(t, got.Content, 2) + assert.Equal(t, "text", got.Content[0].Type) + require.NotNil(t, got.Content[0].Text) + assert.Equal(t, "I will look that up.", *got.Content[0].Text) + assert.Equal(t, "tool_use", got.Content[1].Type) + assert.Equal(t, "call_1", got.Content[1].Id) + assert.Equal(t, "lookup", got.Content[1].Name) + assert.Equal(t, map[string]interface{}{"q": "max-api"}, got.Content[1].Input) +} + +func TestResponseOpenAI2ClaudeWrapsMalformedToolArguments(t *testing.T) { + resp := openAIResponseWithToolCallArguments(t, "{") + got := ResponseOpenAI2Claude(resp, &relaycommon.RelayInfo{}) + + require.NotNil(t, got) + require.Len(t, got.Content, 2) + assert.Equal(t, "tool_use", got.Content[1].Type) + assert.Equal(t, map[string]interface{}{"arguments": "{"}, got.Content[1].Input) +} + +func TestResponseOpenAI2GeminiPreservesTextWithToolCalls(t *testing.T) { + resp := openAIResponseWithTextAndToolCall(t) + got := ResponseOpenAI2Gemini(resp, &relaycommon.RelayInfo{}) + + require.NotNil(t, got) + require.Len(t, got.Candidates, 1) + parts := got.Candidates[0].Content.Parts + require.Len(t, parts, 2) + assert.Equal(t, "I will look that up.", parts[0].Text) + require.NotNil(t, parts[1].FunctionCall) + assert.Equal(t, "lookup", parts[1].FunctionCall.FunctionName) + assert.Equal(t, map[string]interface{}{"q": "max-api"}, parts[1].FunctionCall.Arguments) +} + +func TestResponseOpenAI2GeminiWrapsMalformedToolArguments(t *testing.T) { + resp := openAIResponseWithToolCallArguments(t, "{") + resp.Choices[0].Message.Content = "" + got := ResponseOpenAI2Gemini(resp, &relaycommon.RelayInfo{}) + + require.NotNil(t, got) + require.Len(t, got.Candidates, 1) + parts := got.Candidates[0].Content.Parts + require.Len(t, parts, 1) + require.NotNil(t, parts[0].FunctionCall) + assert.Equal(t, map[string]interface{}{"arguments": "{"}, parts[0].FunctionCall.Arguments) +} + +func openAIResponseWithTextAndToolCall(t *testing.T) *dto.OpenAITextResponse { + return openAIResponseWithToolCallArguments(t, `{"q":"max-api"}`) +} + +func openAIResponseWithToolCallArguments(t *testing.T, arguments string) *dto.OpenAITextResponse { + t.Helper() + rawToolCalls, err := common.Marshal([]dto.ToolCallRequest{ + { + ID: "call_1", + Type: "function", + Function: dto.FunctionRequest{ + Name: "lookup", + Arguments: arguments, + }, + }, + }) + require.NoError(t, err) + + return &dto.OpenAITextResponse{ + Id: "chatcmpl_1", + Model: "gpt-test", + Choices: []dto.OpenAITextResponseChoice{ + { + Index: 0, + Message: dto.Message{ + Role: "assistant", + Content: "I will look that up.", + ToolCalls: rawToolCalls, + }, + FinishReason: constant.FinishReasonToolCalls, + }, + }, + } +} diff --git a/service/error.go b/service/error.go index b624022..bc63507 100644 --- a/service/error.go +++ b/service/error.go @@ -214,10 +214,19 @@ func TaskErrorFromAPIError(apiErr *types.MaxAPIError) *dto.TaskError { if apiErr == nil { return nil } + message := apiErr.Error() return &dto.TaskError{ Code: string(apiErr.GetErrorCode()), - Message: apiErr.Err.Error(), + Message: message, StatusCode: apiErr.StatusCode, Error: apiErr.Err, } } + +func TaskErrorLocalFromAPIError(apiErr *types.MaxAPIError) *dto.TaskError { + taskErr := TaskErrorFromAPIError(apiErr) + if taskErr != nil { + taskErr.LocalError = true + } + return taskErr +} diff --git a/service/openai_chat_responses_compat.go b/service/openai_chat_responses_compat.go index 87de761..dda5db5 100644 --- a/service/openai_chat_responses_compat.go +++ b/service/openai_chat_responses_compat.go @@ -9,10 +9,26 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d return openaicompat.ChatCompletionsRequestToResponsesRequest(req) } +func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (*dto.GeneralOpenAIRequest, error) { + return openaicompat.ResponsesRequestToChatCompletionsRequest(req) +} + +func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id string) (*dto.OpenAIResponsesResponse, *dto.Usage, error) { + return openaicompat.ChatCompletionsResponseToResponsesResponse(resp, id) +} + func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesResponse, id string) (*dto.OpenAITextResponse, *dto.Usage, error) { return openaicompat.ResponsesResponseToChatCompletionsResponse(resp, id) } +func ResponsesFinishReasonFromStatus(resp *dto.OpenAIResponsesResponse) (string, bool) { + return openaicompat.ResponsesFinishReasonFromStatus(resp) +} + func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string { return openaicompat.ExtractOutputTextFromResponses(resp) } + +func ExtractReasoningTextFromResponses(resp *dto.OpenAIResponsesResponse) string { + return openaicompat.ExtractReasoningTextFromResponses(resp) +} diff --git a/service/openaicompat/chat_to_responses_response.go b/service/openaicompat/chat_to_responses_response.go new file mode 100644 index 0000000..b461c25 --- /dev/null +++ b/service/openaicompat/chat_to_responses_response.go @@ -0,0 +1,591 @@ +package openaicompat + +import ( + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/dto" +) + +const ( + chatFinishReasonLength = "length" + chatFinishReasonContentFilter = "content_filter" +) + +type ChatToResponsesStreamEvent struct { + Type string + Payload dto.ResponsesStreamResponse +} + +type ChatToResponsesStreamState struct { + ID string + Model string + Created int64 + Usage *dto.Usage + + status string + incompleteDetails *dto.IncompleteDetails + sentCreated bool + textOutputIndex int + textStarted bool + textDone bool + reasoningIndex int + reasoningStarted bool + reasoningDone bool + finalized bool + nextOutputIndex int + toolsByIndex map[int]*chatToResponsesStreamTool + outputOrder []chatToResponsesOutputRef + text strings.Builder + reasoning strings.Builder +} + +type chatToResponsesStreamTool struct { + ChatIndex int + OutputIndex int + ID string + Name string + Arguments strings.Builder + Done bool +} + +type chatToResponsesOutputRef struct { + Kind string + ToolIndex int +} + +func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id string) (*dto.OpenAIResponsesResponse, *dto.Usage, error) { + if resp == nil { + return nil, nil, errors.New("response is nil") + } + + usage := UsageFromChatUsage(&resp.Usage) + out := &dto.OpenAIResponsesResponse{ + ID: id, + Object: "response", + CreatedAt: chatCreatedAt(resp.Created), + Status: []byte(`"completed"`), + Model: resp.Model, + Output: make([]dto.ResponsesOutput, 0), + Usage: usage, + } + if len(resp.Choices) == 0 { + return out, usage, nil + } + + choice := resp.Choices[0] + if status, details := ResponsesStatusFromChatFinishReason(choice.FinishReason); status != "" { + out.Status = []byte(fmt.Sprintf("%q", status)) + out.IncompleteDetails = details + } + status := responseOutputStatus(out) + + if text := choice.Message.StringContent(); text != "" { + out.Output = append(out.Output, dto.ResponsesOutput{ + Type: responsesOutputTypeMessage, + ID: fmt.Sprintf("%s_msg_0", id), + Status: status, + Role: "assistant", + Content: []dto.ResponsesOutputContent{{ + Type: "output_text", + Text: text, + Annotations: []interface{}{}, + }}, + }) + } + if reasoning := choice.Message.GetReasoningContent(); reasoning != "" { + out.Output = append(out.Output, dto.ResponsesOutput{ + Type: responsesOutputTypeReasoning, + ID: fmt.Sprintf("%s_reasoning_0", id), + Status: status, + Content: []dto.ResponsesOutputContent{{ + Type: "summary_text", + Text: reasoning, + }}, + }) + } + for i, toolCall := range choice.Message.ParseToolCalls() { + out.Output = append(out.Output, chatToolCallToResponsesOutput(toolCall, id, i, status)) + } + + return out, usage, nil +} + +func ResponsesStatusFromChatFinishReason(finishReason string) (string, *dto.IncompleteDetails) { + switch strings.TrimSpace(finishReason) { + case chatFinishReasonLength: + return "incomplete", &dto.IncompleteDetails{Reason: responsesIncompleteReasonMaxTokens} + case chatFinishReasonContentFilter: + return "incomplete", &dto.IncompleteDetails{Reason: responsesIncompleteReasonContentFilter} + default: + return "completed", nil + } +} + +func UsageFromChatUsage(src *dto.Usage) *dto.Usage { + usage := &dto.Usage{} + if src == nil { + return usage + } + if src.PromptTokens != 0 { + usage.PromptTokens = src.PromptTokens + usage.InputTokens = src.PromptTokens + } + if src.CompletionTokens != 0 { + usage.CompletionTokens = src.CompletionTokens + usage.OutputTokens = src.CompletionTokens + } + if src.TotalTokens != 0 { + usage.TotalTokens = src.TotalTokens + } else { + usage.TotalTokens = usage.InputTokens + usage.OutputTokens + } + if src.PromptTokensDetails.CachedTokens != 0 || + src.PromptTokensDetails.ImageTokens != 0 || + src.PromptTokensDetails.AudioTokens != 0 || + src.PromptTokensDetails.CachedCreationTokens != 0 || + src.PromptTokensDetails.TextTokens != 0 { + details := src.PromptTokensDetails + usage.InputTokensDetails = &details + } + if src.CompletionTokenDetails.ReasoningTokens != 0 || + src.CompletionTokenDetails.TextTokens != 0 || + src.CompletionTokenDetails.AudioTokens != 0 || + src.CompletionTokenDetails.ImageTokens != 0 { + usage.CompletionTokenDetails = src.CompletionTokenDetails + } + return usage +} + +func NewChatToResponsesStreamState(id string, model string) *ChatToResponsesStreamState { + return &ChatToResponsesStreamState{ + ID: id, + Model: model, + Created: time.Now().Unix(), + Usage: &dto.Usage{}, + status: "completed", + textOutputIndex: -1, + reasoningIndex: -1, + toolsByIndex: make(map[int]*chatToResponsesStreamTool), + } +} + +func ChatCompletionsStreamChunkToResponsesEvents(chunk *dto.ChatCompletionsStreamResponse, state *ChatToResponsesStreamState) ([]ChatToResponsesStreamEvent, error) { + if chunk == nil || state == nil { + return nil, nil + } + if state.ID == "" { + state.ID = chunk.Id + } + if state.Model == "" { + state.Model = chunk.Model + } + if state.Created == 0 { + state.Created = chunk.Created + } + if chunk.Usage != nil { + state.Usage = UsageFromChatUsage(chunk.Usage) + } + + events := make([]ChatToResponsesStreamEvent, 0) + if !state.sentCreated { + state.sentCreated = true + events = append(events, responsesStreamEvent(responsesEventCreated, dto.ResponsesStreamResponse{ + Type: responsesEventCreated, + Response: state.createdResponse(), + })) + } + for _, choice := range chunk.Choices { + if choice.Delta.GetReasoningContent() != "" { + events = append(events, state.appendReasoningDelta(choice.Delta.GetReasoningContent())...) + } + if choice.Delta.GetContentString() != "" { + events = append(events, state.appendTextDelta(choice.Delta.GetContentString())...) + } + for _, toolCall := range choice.Delta.ToolCalls { + toolEvents, err := state.appendToolCallDelta(toolCall) + if err != nil { + return nil, err + } + events = append(events, toolEvents...) + } + if choice.FinishReason != nil && strings.TrimSpace(*choice.FinishReason) != "" { + state.applyFinishReason(*choice.FinishReason) + events = append(events, state.doneDeltaEvents()...) + } + } + return events, nil +} + +func FinalizeChatCompletionsStreamToResponses(state *ChatToResponsesStreamState) []ChatToResponsesStreamEvent { + if state == nil || state.finalized { + return nil + } + events := state.doneDeltaEvents() + state.finalized = true + eventType := responsesEventCompleted + if state.status == "incomplete" { + eventType = responsesEventIncomplete + } + events = append(events, responsesStreamEvent(eventType, dto.ResponsesStreamResponse{ + Type: eventType, + Response: state.finalResponse(), + })) + return events +} + +func (s *ChatToResponsesStreamState) UsageText() string { + if s == nil { + return "" + } + return s.text.String() +} + +func (s *ChatToResponsesStreamState) appendTextDelta(delta string) []ChatToResponsesStreamEvent { + events := make([]ChatToResponsesStreamEvent, 0, 2) + if !s.textStarted { + s.textStarted = true + s.textOutputIndex = s.nextIndex("message", -1) + events = append(events, responsesStreamEvent(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{ + Type: responsesEventOutputItemAdded, + OutputIndex: intPtr(s.textOutputIndex), + Item: &dto.ResponsesOutput{ + Type: responsesOutputTypeMessage, + ID: s.messageID(), + Status: "in_progress", + Role: "assistant", + Content: []dto.ResponsesOutputContent{}, + }, + })) + } + s.text.WriteString(delta) + events = append(events, responsesStreamEvent(responsesEventOutputTextDelta, dto.ResponsesStreamResponse{ + Type: responsesEventOutputTextDelta, + OutputIndex: intPtr(s.textOutputIndex), + ContentIndex: intPtr(0), + Delta: delta, + ItemID: s.messageID(), + })) + return events +} + +func (s *ChatToResponsesStreamState) appendReasoningDelta(delta string) []ChatToResponsesStreamEvent { + events := make([]ChatToResponsesStreamEvent, 0, 2) + if !s.reasoningStarted { + s.reasoningStarted = true + s.reasoningIndex = s.nextIndex("reasoning", -1) + events = append(events, responsesStreamEvent(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{ + Type: responsesEventOutputItemAdded, + OutputIndex: intPtr(s.reasoningIndex), + Item: &dto.ResponsesOutput{ + Type: responsesOutputTypeReasoning, + ID: s.reasoningID(), + Status: "in_progress", + Content: []dto.ResponsesOutputContent{}, + }, + })) + } + s.reasoning.WriteString(delta) + events = append(events, responsesStreamEvent(responsesEventReasoningSummaryDelta, dto.ResponsesStreamResponse{ + Type: responsesEventReasoningSummaryDelta, + OutputIndex: intPtr(s.reasoningIndex), + SummaryIndex: intPtr(0), + Delta: delta, + ItemID: s.reasoningID(), + })) + return events +} + +func (s *ChatToResponsesStreamState) appendToolCallDelta(toolCall dto.ToolCallResponse) ([]ChatToResponsesStreamEvent, error) { + chatIndex := 0 + if toolCall.Index != nil { + chatIndex = *toolCall.Index + } + tool := s.toolsByIndex[chatIndex] + events := make([]ChatToResponsesStreamEvent, 0, 2) + if tool == nil { + tool = &chatToResponsesStreamTool{ + ChatIndex: chatIndex, + OutputIndex: s.nextIndex("tool", chatIndex), + ID: strings.TrimSpace(toolCall.ID), + Name: strings.TrimSpace(toolCall.Function.Name), + } + if tool.ID == "" { + tool.ID = fmt.Sprintf("%s_call_%d", s.ID, chatIndex) + } + s.toolsByIndex[chatIndex] = tool + events = append(events, responsesStreamEvent(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{ + Type: responsesEventOutputItemAdded, + OutputIndex: intPtr(tool.OutputIndex), + ItemID: tool.ID, + Item: &dto.ResponsesOutput{ + Type: responsesOutputTypeFunctionCall, + ID: tool.ID, + Status: "in_progress", + CallId: tool.ID, + Name: tool.Name, + Arguments: []byte(`""`), + }, + })) + } + if strings.TrimSpace(toolCall.ID) != "" { + tool.ID = strings.TrimSpace(toolCall.ID) + } + if strings.TrimSpace(toolCall.Function.Name) != "" { + tool.Name = strings.TrimSpace(toolCall.Function.Name) + } + if toolCall.Function.Arguments != "" { + tool.Arguments.WriteString(toolCall.Function.Arguments) + events = append(events, responsesStreamEvent(responsesEventFunctionArgsDelta, dto.ResponsesStreamResponse{ + Type: responsesEventFunctionArgsDelta, + OutputIndex: intPtr(tool.OutputIndex), + ItemID: tool.ID, + Delta: toolCall.Function.Arguments, + })) + } + return events, nil +} + +func (s *ChatToResponsesStreamState) doneDeltaEvents() []ChatToResponsesStreamEvent { + events := make([]ChatToResponsesStreamEvent, 0) + status := s.outputStatus() + if s.textStarted && !s.textDone { + s.textDone = true + events = append(events, responsesStreamEvent("response.output_text.done", dto.ResponsesStreamResponse{ + Type: "response.output_text.done", + OutputIndex: intPtr(s.textOutputIndex), + ContentIndex: intPtr(0), + ItemID: s.messageID(), + })) + events = append(events, responsesStreamEvent(responsesEventOutputItemDone, dto.ResponsesStreamResponse{ + Type: responsesEventOutputItemDone, + OutputIndex: intPtr(s.textOutputIndex), + Item: s.messageOutput(status), + })) + } + if s.reasoningStarted && !s.reasoningDone { + s.reasoningDone = true + events = append(events, responsesStreamEvent(responsesEventReasoningSummaryDone, dto.ResponsesStreamResponse{ + Type: responsesEventReasoningSummaryDone, + OutputIndex: intPtr(s.reasoningIndex), + SummaryIndex: intPtr(0), + ItemID: s.reasoningID(), + Part: &dto.ResponsesReasoningSummaryPart{ + Type: "summary_text", + Text: s.reasoning.String(), + }, + })) + events = append(events, responsesStreamEvent(responsesEventOutputItemDone, dto.ResponsesStreamResponse{ + Type: responsesEventOutputItemDone, + OutputIndex: intPtr(s.reasoningIndex), + Item: s.reasoningOutput(status), + })) + } + for _, tool := range s.sortedTools() { + if tool.Done { + continue + } + tool.Done = true + events = append(events, responsesStreamEvent(responsesEventFunctionArgsDone, dto.ResponsesStreamResponse{ + Type: responsesEventFunctionArgsDone, + OutputIndex: intPtr(tool.OutputIndex), + ItemID: tool.ID, + })) + events = append(events, responsesStreamEvent(responsesEventOutputItemDone, dto.ResponsesStreamResponse{ + Type: responsesEventOutputItemDone, + OutputIndex: intPtr(tool.OutputIndex), + Item: s.toolOutput(tool, status), + })) + } + return events +} + +func (s *ChatToResponsesStreamState) applyFinishReason(finishReason string) { + if status, details := ResponsesStatusFromChatFinishReason(finishReason); status != "" { + s.status = status + s.incompleteDetails = details + } +} + +func (s *ChatToResponsesStreamState) finalResponse() *dto.OpenAIResponsesResponse { + output := make([]dto.ResponsesOutput, 0, len(s.outputOrder)) + status := s.outputStatus() + for _, ref := range s.outputOrder { + switch ref.Kind { + case "message": + output = append(output, *s.messageOutput(status)) + case "reasoning": + output = append(output, *s.reasoningOutput(status)) + case "tool": + if tool := s.toolsByIndex[ref.ToolIndex]; tool != nil { + output = append(output, *s.toolOutput(tool, status)) + } + } + } + return &dto.OpenAIResponsesResponse{ + ID: s.ID, + Object: "response", + CreatedAt: int(s.Created), + Status: []byte(fmt.Sprintf("%q", s.status)), + IncompleteDetails: s.incompleteDetails, + Model: s.Model, + Output: output, + Usage: s.Usage, + } +} + +func (s *ChatToResponsesStreamState) createdResponse() *dto.OpenAIResponsesResponse { + return &dto.OpenAIResponsesResponse{ + ID: s.ID, + Object: "response", + CreatedAt: int(s.Created), + Status: []byte(`"in_progress"`), + Model: s.Model, + Output: []dto.ResponsesOutput{}, + } +} + +func (s *ChatToResponsesStreamState) nextIndex(kind string, toolIndex int) int { + index := s.nextOutputIndex + s.nextOutputIndex++ + s.outputOrder = append(s.outputOrder, chatToResponsesOutputRef{Kind: kind, ToolIndex: toolIndex}) + return index +} + +func (s *ChatToResponsesStreamState) sortedTools() []*chatToResponsesStreamTool { + indexes := make([]int, 0, len(s.toolsByIndex)) + for index := range s.toolsByIndex { + indexes = append(indexes, index) + } + sort.Ints(indexes) + tools := make([]*chatToResponsesStreamTool, 0, len(indexes)) + for _, index := range indexes { + tools = append(tools, s.toolsByIndex[index]) + } + return tools +} + +func (s *ChatToResponsesStreamState) outputStatus() string { + if s.status == "incomplete" { + return "incomplete" + } + return "completed" +} + +func (s *ChatToResponsesStreamState) messageID() string { + return fmt.Sprintf("%s_msg_0", s.ID) +} + +func (s *ChatToResponsesStreamState) reasoningID() string { + return fmt.Sprintf("%s_reasoning_0", s.ID) +} + +func (s *ChatToResponsesStreamState) messageOutput(status string) *dto.ResponsesOutput { + return &dto.ResponsesOutput{ + Type: responsesOutputTypeMessage, + ID: s.messageID(), + Status: status, + Role: "assistant", + Content: []dto.ResponsesOutputContent{{ + Type: "output_text", + Text: s.text.String(), + Annotations: []interface{}{}, + }}, + } +} + +func (s *ChatToResponsesStreamState) reasoningOutput(status string) *dto.ResponsesOutput { + return &dto.ResponsesOutput{ + Type: responsesOutputTypeReasoning, + ID: s.reasoningID(), + Status: status, + Content: []dto.ResponsesOutputContent{{ + Type: "summary_text", + Text: s.reasoning.String(), + }}, + } +} + +func (s *ChatToResponsesStreamState) toolOutput(tool *chatToResponsesStreamTool, status string) *dto.ResponsesOutput { + return &dto.ResponsesOutput{ + Type: responsesOutputTypeFunctionCall, + ID: tool.ID, + Status: status, + CallId: tool.ID, + Name: tool.Name, + Arguments: chatArgumentsRawMessage(tool.Arguments.String()), + } +} + +func responseOutputStatus(resp *dto.OpenAIResponsesResponse) string { + if resp == nil || responseStatusString(resp) != "incomplete" { + return "completed" + } + return "incomplete" +} + +func chatToolCallToResponsesOutput(toolCall dto.ToolCallRequest, responseID string, index int, status string) dto.ResponsesOutput { + callID := strings.TrimSpace(toolCall.ID) + if callID == "" { + callID = fmt.Sprintf("%s_call_%d", responseID, index) + } + if toolCall.Type == "" || toolCall.Type == "function" { + return dto.ResponsesOutput{ + Type: responsesOutputTypeFunctionCall, + ID: callID, + Status: status, + CallId: callID, + Name: toolCall.Function.Name, + Arguments: chatArgumentsRawMessage(toolCall.Function.Arguments), + } + } + return dto.ResponsesOutput{ + Type: toolCall.Type, + ID: callID, + Status: status, + CallId: callID, + Arguments: toolCall.Custom, + } +} + +func chatArgumentsRawMessage(arguments string) []byte { + raw, err := common.Marshal(arguments) + if err != nil { + return []byte(`""`) + } + return raw +} + +func chatCreatedAt(created any) int { + switch v := created.(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + case float32: + return int(v) + case string: + if parsed := common.String2Int(v); parsed != 0 { + return parsed + } + } + return int(time.Now().Unix()) +} + +func responsesStreamEvent(eventType string, payload dto.ResponsesStreamResponse) ChatToResponsesStreamEvent { + payload.Type = eventType + return ChatToResponsesStreamEvent{ + Type: eventType, + Payload: payload, + } +} + +func intPtr(v int) *int { + return &v +} diff --git a/service/openaicompat/responses_compat_test.go b/service/openaicompat/responses_compat_test.go new file mode 100644 index 0000000..92043a5 --- /dev/null +++ b/service/openaicompat/responses_compat_test.go @@ -0,0 +1,416 @@ +package openaicompat + +import ( + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/dto" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestResponsesRequestToChatCompletionsRequestInstructionsScalarInputAndZeroTemperature(t *testing.T) { + stream := true + temperature := 0.0 + maxOutputTokens := uint(128) + parallelToolCalls := false + + got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{ + Model: "gpt-test", + Instructions: mustCompatRawMessage(t, "system rules"), + Input: mustCompatRawMessage(t, "hello"), + Stream: &stream, + MaxOutputTokens: &maxOutputTokens, + Temperature: &temperature, + ParallelToolCalls: mustCompatRawMessage(t, parallelToolCalls), + Reasoning: &dto.Reasoning{Effort: "medium"}, + }) + require.NoError(t, err) + + assert.Equal(t, "gpt-test", got.Model) + require.Len(t, got.Messages, 2) + assert.Equal(t, "system", got.Messages[0].Role) + assert.Equal(t, "system rules", got.Messages[0].StringContent()) + assert.Equal(t, "user", got.Messages[1].Role) + assert.Equal(t, "hello", got.Messages[1].StringContent()) + assert.Same(t, &stream, got.Stream) + assert.Equal(t, maxOutputTokens, lo.FromPtr(got.MaxCompletionTokens)) + require.NotNil(t, got.Temperature) + assert.Equal(t, 0.0, *got.Temperature) + require.NotNil(t, got.ParallelTooCalls) + assert.False(t, *got.ParallelTooCalls) + assert.Equal(t, "medium", got.ReasoningEffort) +} + +func TestResponsesRequestToChatCompletionsRequestFunctionCallConversation(t *testing.T) { + got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{ + Model: "gpt-test", + Input: mustCompatRawMessage(t, []map[string]any{ + { + "role": "assistant", + "content": []map[string]any{ + {"type": "output_text", "text": "I will call."}, + }, + }, + { + "type": "function_call", + "call_id": "call_1", + "name": "lookup", + "arguments": map[string]any{"q": "x"}, + }, + { + "type": "function_call_output", + "call_id": "call_1", + "output": map[string]any{"ok": true}, + }, + }), + }) + require.NoError(t, err) + + require.Len(t, got.Messages, 2) + assert.Equal(t, "assistant", got.Messages[0].Role) + assert.Equal(t, "I will call.", got.Messages[0].StringContent()) + toolCalls := got.Messages[0].ParseToolCalls() + require.Len(t, toolCalls, 1) + assert.Equal(t, "call_1", toolCalls[0].ID) + assert.Equal(t, "lookup", toolCalls[0].Function.Name) + assert.JSONEq(t, `{"q":"x"}`, toolCalls[0].Function.Arguments) + assert.Equal(t, "tool", got.Messages[1].Role) + assert.Equal(t, "call_1", got.Messages[1].ToolCallId) + assert.JSONEq(t, `{"ok":true}`, got.Messages[1].StringContent()) +} + +func TestResponsesRequestToChatCompletionsRequestToolsToolChoiceAndTextFormat(t *testing.T) { + got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{ + Model: "gpt-test", + Input: mustCompatRawMessage(t, "hello"), + Tools: mustCompatRawMessage(t, []map[string]any{ + { + "type": "function", + "name": "lookup", + "description": "Lookup data", + "parameters": map[string]any{ + "type": "object", + "properties": map[string]any{ + "q": map[string]any{"type": "string"}, + }, + }, + }, + }), + ToolChoice: mustCompatRawMessage(t, map[string]any{ + "type": "function", + "name": "lookup", + }), + Text: mustCompatRawMessage(t, map[string]any{ + "format": map[string]any{ + "type": "json_schema", + "name": "answer", + "schema": map[string]any{"type": "object"}, + "strict": true, + }, + }), + }) + require.NoError(t, err) + + require.Len(t, got.Tools, 1) + assert.Equal(t, "function", got.Tools[0].Type) + assert.Equal(t, "lookup", got.Tools[0].Function.Name) + assert.Equal(t, "Lookup data", got.Tools[0].Function.Description) + assert.Equal(t, "object", got.Tools[0].Function.Parameters.(map[string]any)["type"]) + assert.Equal(t, map[string]any{ + "type": "function", + "function": map[string]any{ + "name": "lookup", + }, + }, got.ToolChoice) + require.NotNil(t, got.ResponseFormat) + assert.Equal(t, "json_schema", got.ResponseFormat.Type) + assert.False(t, gjson.GetBytes(got.ResponseFormat.JsonSchema, "type").Exists()) + assert.Equal(t, "answer", gjson.GetBytes(got.ResponseFormat.JsonSchema, "name").String()) + assert.True(t, gjson.GetBytes(got.ResponseFormat.JsonSchema, "strict").Bool()) + assert.Equal(t, "object", gjson.GetBytes(got.ResponseFormat.JsonSchema, "schema.type").String()) +} + +func TestResponsesRequestToChatCompletionsRequestSkipsNonMessageOutputItems(t *testing.T) { + got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{ + Model: "gpt-test", + Input: mustCompatRawMessage(t, []map[string]any{ + { + "type": "message", + "role": "assistant", + "content": []map[string]any{ + {"type": "output_text", "text": "answer"}, + }, + }, + { + "type": "reasoning", + "content": []map[string]any{ + {"type": "summary_text", "text": "thinking"}, + }, + }, + {"type": "web_search_call", "id": "ws_1", "status": "completed"}, + {"type": "file_search_call", "id": "fs_1", "status": "completed"}, + {"type": "computer_call", "id": "cc_1", "status": "completed"}, + {"type": "item_reference", "id": "item_1"}, + {"type": "unknown", "content": "do not replay"}, + { + "role": "user", + "content": "continue", + }, + }), + }) + require.NoError(t, err) + + require.Len(t, got.Messages, 2) + assert.Equal(t, "assistant", got.Messages[0].Role) + assert.Equal(t, "answer", got.Messages[0].StringContent()) + assert.Equal(t, "user", got.Messages[1].Role) + assert.Equal(t, "continue", got.Messages[1].StringContent()) +} + +func TestResponsesRequestToChatCompletionsRequestRejectsCustomToolCallItems(t *testing.T) { + got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{ + Model: "gpt-test", + Input: mustCompatRawMessage(t, []map[string]any{ + { + "type": "custom_tool_call", + "call_id": "call_custom", + "name": "apply_patch", + "input": "patch body", + }, + }), + }) + + require.Error(t, err) + require.Nil(t, got) + assert.Contains(t, err.Error(), "custom_tool_call") +} + +func TestResponsesRequestToChatCompletionsRequestRejectsMissingToolCallIDs(t *testing.T) { + cases := []struct { + name string + item map[string]any + want string + }{ + { + name: "function call", + item: map[string]any{ + "type": "function_call", + "name": "lookup", + "arguments": map[string]any{"q": "x"}, + }, + want: "function_call item is missing call_id", + }, + { + name: "function call output", + item: map[string]any{ + "type": "function_call_output", + "output": map[string]any{"ok": true}, + }, + want: "function_call_output item is missing call_id", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{ + Model: "gpt-test", + Input: mustCompatRawMessage(t, []map[string]any{ + tc.item, + }), + }) + + require.Error(t, err) + require.Nil(t, got) + assert.Contains(t, err.Error(), tc.want) + }) + } +} + +func TestResponsesRequestToChatCompletionsRequestRejectsUnsupportedToolTypes(t *testing.T) { + got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{ + Model: "gpt-test", + Input: mustCompatRawMessage(t, "hello"), + Tools: mustCompatRawMessage(t, []map[string]any{ + {"type": "custom", "name": "apply_patch"}, + }), + }) + + require.Error(t, err) + require.Nil(t, got) + assert.Contains(t, err.Error(), `tool type "custom"`) +} + +func TestChatCompletionsResponseToResponsesResponsePreservesToolCallsAndIncompleteReason(t *testing.T) { + chat := &dto.OpenAITextResponse{ + Id: "chatcmpl_1", + Model: "gpt-test", + Created: 456, + Choices: []dto.OpenAITextResponseChoice{ + { + Message: compatAssistantMessageWithTool("partial", "call_1", "lookup", `{"q":"x"}`), + FinishReason: "length", + }, + }, + Usage: dto.Usage{PromptTokens: 3, CompletionTokens: 5, TotalTokens: 8}, + } + + resp, usage, err := ChatCompletionsResponseToResponsesResponse(chat, "resp_1") + require.NoError(t, err) + require.NotNil(t, usage) + + assert.Equal(t, "resp_1", resp.ID) + assert.Equal(t, `"incomplete"`, string(resp.Status)) + require.NotNil(t, resp.IncompleteDetails) + assert.Equal(t, responsesIncompleteReasonMaxTokens, resp.IncompleteDetails.Reason) + assert.Equal(t, 3, resp.Usage.InputTokens) + assert.Equal(t, 5, resp.Usage.OutputTokens) + require.Len(t, resp.Output, 2) + assert.Equal(t, responsesOutputTypeMessage, resp.Output[0].Type) + assert.Equal(t, "partial", resp.Output[0].Content[0].Text) + assert.Equal(t, responsesOutputTypeFunctionCall, resp.Output[1].Type) + assert.Equal(t, "call_1", resp.Output[1].CallId) + assert.Equal(t, "lookup", resp.Output[1].Name) + assert.Equal(t, `"{\"q\":\"x\"}"`, string(resp.Output[1].Arguments)) +} + +func TestResponsesResponseToChatCompletionsResponsePreservesUsageDetails(t *testing.T) { + resp, usage, err := ResponsesResponseToChatCompletionsResponse(&dto.OpenAIResponsesResponse{ + ID: "resp_1", + Model: "gpt-test", + CreatedAt: 456, + Status: []byte(`"completed"`), + Usage: &dto.Usage{ + InputTokens: 7, + OutputTokens: 11, + TotalTokens: 18, + InputTokensDetails: &dto.InputTokenDetails{ + CachedTokens: 1, + CachedCreationTokens: 2, + TextTokens: 3, + AudioTokens: 4, + ImageTokens: 5, + }, + CompletionTokenDetails: dto.OutputTokenDetails{ + ReasoningTokens: 6, + TextTokens: 7, + AudioTokens: 8, + ImageTokens: 9, + }, + }, + Output: []dto.ResponsesOutput{{ + Type: responsesOutputTypeMessage, + Role: "assistant", + Content: []dto.ResponsesOutputContent{{ + Type: "output_text", + Text: "done", + }}, + }}, + }, "chatcmpl_1") + require.NoError(t, err) + require.NotNil(t, usage) + + assert.Equal(t, 7, usage.PromptTokens) + assert.Equal(t, 11, usage.CompletionTokens) + assert.Equal(t, 18, usage.TotalTokens) + assert.Equal(t, 1, usage.PromptTokensDetails.CachedTokens) + assert.Equal(t, 2, usage.PromptTokensDetails.CachedCreationTokens) + assert.Equal(t, 3, usage.PromptTokensDetails.TextTokens) + assert.Equal(t, 4, usage.PromptTokensDetails.AudioTokens) + assert.Equal(t, 5, usage.PromptTokensDetails.ImageTokens) + assert.Equal(t, 6, usage.CompletionTokenDetails.ReasoningTokens) + assert.Equal(t, 7, usage.CompletionTokenDetails.TextTokens) + assert.Equal(t, 8, usage.CompletionTokenDetails.AudioTokens) + assert.Equal(t, 9, usage.CompletionTokenDetails.ImageTokens) + assert.Equal(t, usage.PromptTokensDetails, resp.Usage.PromptTokensDetails) + assert.Equal(t, usage.CompletionTokenDetails, resp.Usage.CompletionTokenDetails) +} + +func TestChatCompletionsStreamToResponsesEventsAggregatesUsage(t *testing.T) { + state := NewChatToResponsesStreamState("resp_1", "gpt-test") + state.Created = 123 + toolIndex := 0 + finishReason := "tool_calls" + + var events []ChatToResponsesStreamEvent + events = append(events, mustCompatResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{ + Id: "chatcmpl_1", + Model: "gpt-test", + Created: 123, + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Role: "assistant"}}, + }, + })...) + events = append(events, mustCompatResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{ + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: lo.ToPtr("hello")}}, + }, + })...) + events = append(events, mustCompatResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{ + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{ + {Index: &toolIndex, ID: "call_1", Type: "function", Function: dto.FunctionResponse{Name: "lookup"}}, + }}}, + }, + })...) + events = append(events, mustCompatResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{ + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{ + {Index: &toolIndex, Function: dto.FunctionResponse{Arguments: `{"q":"x"}`}}, + }}}, + }, + })...) + events = append(events, mustCompatResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{ + Choices: []dto.ChatCompletionsStreamResponseChoice{ + {Index: 0, FinishReason: &finishReason}, + }, + })...) + events = append(events, mustCompatResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{ + Usage: &dto.Usage{PromptTokens: 2, CompletionTokens: 4, TotalTokens: 6}, + })...) + events = append(events, FinalizeChatCompletionsStreamToResponses(state)...) + + require.Len(t, events, 10) + assert.Equal(t, responsesEventCreated, events[0].Type) + assert.Equal(t, responsesEventOutputTextDelta, events[2].Type) + assert.Equal(t, "hello", events[2].Payload.Delta) + assert.Equal(t, responsesEventFunctionArgsDelta, events[4].Type) + assert.Equal(t, `{"q":"x"}`, events[4].Payload.Delta) + assert.Equal(t, responsesEventCompleted, events[9].Type) + require.NotNil(t, events[9].Payload.Response) + assert.Equal(t, 6, events[9].Payload.Response.Usage.TotalTokens) + require.Len(t, events[9].Payload.Response.Output, 2) + assert.Equal(t, "hello", events[9].Payload.Response.Output[0].Content[0].Text) + assert.Equal(t, `"{\"q\":\"x\"}"`, string(events[9].Payload.Response.Output[1].Arguments)) +} + +func compatAssistantMessageWithTool(content string, id string, name string, args string) dto.Message { + msg := dto.Message{Role: "assistant", Content: content} + msg.SetToolCalls([]dto.ToolCallRequest{ + { + ID: id, + Type: "function", + Function: dto.FunctionRequest{ + Name: name, + Arguments: args, + }, + }, + }) + return msg +} + +func mustCompatResponsesEventsFromChatChunk(t *testing.T, state *ChatToResponsesStreamState, chunk *dto.ChatCompletionsStreamResponse) []ChatToResponsesStreamEvent { + t.Helper() + events, err := ChatCompletionsStreamChunkToResponsesEvents(chunk, state) + require.NoError(t, err) + return events +} + +func mustCompatRawMessage(t *testing.T, value any) []byte { + t.Helper() + raw, err := common.Marshal(value) + require.NoError(t, err) + return raw +} diff --git a/service/openaicompat/responses_request_to_chat.go b/service/openaicompat/responses_request_to_chat.go new file mode 100644 index 0000000..bb74f88 --- /dev/null +++ b/service/openaicompat/responses_request_to_chat.go @@ -0,0 +1,481 @@ +package openaicompat + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/dto" +) + +const ( + responsesInputTypeMessage = "message" + responsesInputTypeFunctionCall = "function_call" + responsesInputTypeFunctionCallOutput = "function_call_output" + responsesInputTypeCustomToolCall = "custom_tool_call" +) + +func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (*dto.GeneralOpenAIRequest, error) { + if req == nil { + return nil, errors.New("request is nil") + } + if req.Model == "" { + return nil, errors.New("model is required") + } + if err := validateResponsesRequestChatUnsupportedFields(req); err != nil { + return nil, err + } + + messages, err := responsesRequestMessagesToChat(req) + if err != nil { + return nil, err + } + tools, err := responsesRequestToolsToChat(req.Tools) + if err != nil { + return nil, err + } + toolChoice, err := responsesRequestToolChoiceToChat(req.ToolChoice) + if err != nil { + return nil, err + } + responseFormat, err := responsesRequestTextToChatResponseFormat(req.Text) + if err != nil { + return nil, err + } + + out := &dto.GeneralOpenAIRequest{ + Model: req.Model, + Messages: messages, + Stream: req.Stream, + StreamOptions: req.StreamOptions, + MaxCompletionTokens: req.MaxOutputTokens, + Temperature: req.Temperature, + TopP: req.TopP, + TopLogProbs: req.TopLogProbs, + ResponseFormat: responseFormat, + Tools: tools, + ToolChoice: toolChoice, + User: req.User, + Store: req.Store, + Metadata: req.Metadata, + SafetyIdentifier: req.SafetyIdentifier, + PromptCacheRetention: req.PromptCacheRetention, + EnableThinking: req.EnableThinking, + } + if req.Reasoning != nil { + out.ReasoningEffort = req.Reasoning.Effort + } + if req.ServiceTier != "" { + out.ServiceTier, _ = common.Marshal(req.ServiceTier) + } + if len(req.ParallelToolCalls) > 0 && common.GetJsonType(req.ParallelToolCalls) == "boolean" { + var parallelToolCalls bool + if err := common.Unmarshal(req.ParallelToolCalls, ¶llelToolCalls); err == nil { + out.ParallelTooCalls = ¶llelToolCalls + } + } + if len(req.PromptCacheKey) > 0 && common.GetJsonType(req.PromptCacheKey) == "string" { + var promptCacheKey string + if err := common.Unmarshal(req.PromptCacheKey, &promptCacheKey); err == nil { + out.PromptCacheKey = promptCacheKey + } + } + + return out, nil +} + +func validateResponsesRequestChatUnsupportedFields(req *dto.OpenAIResponsesRequest) error { + unsupported := make([]string, 0, 4) + if rawJSONPresent(req.Conversation) { + unsupported = append(unsupported, "conversation") + } + if strings.TrimSpace(req.PreviousResponseID) != "" { + unsupported = append(unsupported, "previous_response_id") + } + if rawJSONPresent(req.Prompt) { + unsupported = append(unsupported, "prompt") + } + if rawJSONPresent(req.ContextManagement) { + unsupported = append(unsupported, "context_management") + } + if len(unsupported) > 0 { + return fmt.Errorf("responses to chat conversion does not support stateful fields: %s", strings.Join(unsupported, ", ")) + } + return nil +} + +func responsesRequestMessagesToChat(req *dto.OpenAIResponsesRequest) ([]dto.Message, error) { + messages := make([]dto.Message, 0) + if rawJSONPresent(req.Instructions) { + instructions, err := responsesJSONString(req.Instructions) + if err != nil { + return nil, fmt.Errorf("invalid instructions: %w", err) + } + if strings.TrimSpace(instructions) != "" { + messages = append(messages, dto.Message{Role: "system", Content: instructions}) + } + } + + if !rawJSONPresent(req.Input) { + return messages, nil + } + switch common.GetJsonType(req.Input) { + case "string": + input, err := responsesJSONString(req.Input) + if err != nil { + return nil, fmt.Errorf("invalid input string: %w", err) + } + return append(messages, dto.Message{Role: "user", Content: input}), nil + case "array": + var items []map[string]any + if err := common.Unmarshal(req.Input, &items); err != nil { + return nil, fmt.Errorf("invalid input array: %w", err) + } + for _, item := range items { + nextMessages, err := responsesInputItemToChatMessages(item, messages) + if err != nil { + return nil, err + } + messages = nextMessages + } + return messages, nil + default: + return nil, fmt.Errorf("unsupported responses input type %q", common.GetJsonType(req.Input)) + } +} + +func responsesInputItemToChatMessages(item map[string]any, messages []dto.Message) ([]dto.Message, error) { + itemType := strings.TrimSpace(common.Interface2String(item["type"])) + switch itemType { + case responsesInputTypeFunctionCall: + toolCall, err := responsesFunctionCallItemToChatToolCall(item) + if err != nil { + return nil, err + } + return appendToolCallToLastAssistant(messages, toolCall), nil + case responsesInputTypeCustomToolCall: + return nil, errors.New("responses to chat conversion does not support custom_tool_call items") + case responsesInputTypeFunctionCallOutput: + callID := strings.TrimSpace(common.Interface2String(item["call_id"])) + if callID == "" { + return nil, errors.New("function_call_output item is missing call_id") + } + content := responseToolOutputToChatContent(item["output"]) + return append(messages, dto.Message{Role: "tool", ToolCallId: callID, Content: content}), nil + } + if !responsesInputItemHasChatMessageShape(itemType, item) { + return messages, nil + } + + role := strings.TrimSpace(common.Interface2String(item["role"])) + if role == "" { + role = "user" + } + content, err := responsesInputContentToChatContent(item["content"]) + if err != nil { + return nil, err + } + return append(messages, dto.Message{Role: role, Content: content}), nil +} + +func responsesInputItemHasChatMessageShape(itemType string, item map[string]any) bool { + if itemType != "" && itemType != responsesInputTypeMessage { + return false + } + if itemType == responsesInputTypeMessage { + return true + } + if strings.TrimSpace(common.Interface2String(item["role"])) != "" { + return true + } + _, hasContent := item["content"] + return hasContent +} + +func responsesInputContentToChatContent(content any) (any, error) { + if content == nil { + return "", nil + } + switch value := content.(type) { + case string: + return value, nil + case []any: + return responsesContentPartsToChatContent(value) + default: + return content, nil + } +} + +func responsesContentPartsToChatContent(parts []any) (any, error) { + chatParts := make([]any, 0, len(parts)) + var textOnly strings.Builder + onlyText := true + + for _, rawPart := range parts { + part, ok := rawPart.(map[string]any) + if !ok { + onlyText = false + chatParts = append(chatParts, rawPart) + continue + } + + switch strings.TrimSpace(common.Interface2String(part["type"])) { + case "input_text", "output_text", "text": + text := common.Interface2String(part["text"]) + textOnly.WriteString(text) + chatParts = append(chatParts, map[string]any{"type": dto.ContentTypeText, "text": text}) + case "input_image": + onlyText = false + chatParts = append(chatParts, map[string]any{"type": dto.ContentTypeImageURL, "image_url": responsesImagePartToChatImageURL(part)}) + case "input_file": + onlyText = false + chatParts = append(chatParts, map[string]any{"type": dto.ContentTypeFile, "file": responsesFilePartToChatFile(part)}) + case "input_audio": + onlyText = false + chatParts = append(chatParts, map[string]any{"type": dto.ContentTypeInputAudio, "input_audio": responsesPartPayload(part, "input_audio")}) + case "input_video": + onlyText = false + chatParts = append(chatParts, map[string]any{"type": dto.ContentTypeVideoUrl, "video_url": responsesVideoPartToChatVideoURL(part)}) + default: + onlyText = false + chatParts = append(chatParts, part) + } + } + if onlyText { + return textOnly.String(), nil + } + return chatParts, nil +} + +func responsesFunctionCallItemToChatToolCall(item map[string]any) (dto.ToolCallRequest, error) { + name := strings.TrimSpace(common.Interface2String(item["name"])) + if name == "" { + return dto.ToolCallRequest{}, errors.New("function_call item is missing name") + } + callID := responsesCallID(item) + if callID == "" { + return dto.ToolCallRequest{}, errors.New("function_call item is missing call_id") + } + return dto.ToolCallRequest{ + ID: callID, + Type: "function", + Function: dto.FunctionRequest{ + Name: name, + Arguments: responsesArgumentsString(item["arguments"]), + }, + }, nil +} + +func appendToolCallToLastAssistant(messages []dto.Message, toolCall dto.ToolCallRequest) []dto.Message { + if len(messages) == 0 || messages[len(messages)-1].Role != "assistant" { + messages = append(messages, dto.Message{Role: "assistant"}) + } + idx := len(messages) - 1 + toolCalls := messages[idx].ParseToolCalls() + toolCalls = append(toolCalls, toolCall) + toolCallsRaw, _ := common.Marshal(toolCalls) + messages[idx].ToolCalls = toolCallsRaw + return messages +} + +func responsesRequestToolsToChat(raw json.RawMessage) ([]dto.ToolCallRequest, error) { + if !rawJSONPresent(raw) { + return nil, nil + } + var tools []map[string]any + if err := common.Unmarshal(raw, &tools); err != nil { + return nil, fmt.Errorf("invalid tools: %w", err) + } + out := make([]dto.ToolCallRequest, 0, len(tools)) + for _, tool := range tools { + toolType := strings.TrimSpace(common.Interface2String(tool["type"])) + if toolType == "function" { + out = append(out, dto.ToolCallRequest{ + Type: "function", + Function: dto.FunctionRequest{ + Name: strings.TrimSpace(common.Interface2String(tool["name"])), + Description: common.Interface2String(tool["description"]), + Parameters: tool["parameters"], + }, + }) + continue + } + return nil, fmt.Errorf("responses to chat conversion does not support tool type %q", toolType) + } + return out, nil +} + +func responsesRequestToolChoiceToChat(raw json.RawMessage) (any, error) { + if !rawJSONPresent(raw) { + return nil, nil + } + if common.GetJsonType(raw) == "string" { + var choice string + if err := common.Unmarshal(raw, &choice); err != nil { + return nil, fmt.Errorf("invalid tool_choice: %w", err) + } + return choice, nil + } + var choice map[string]any + if err := common.Unmarshal(raw, &choice); err != nil { + return nil, fmt.Errorf("invalid tool_choice: %w", err) + } + if common.Interface2String(choice["type"]) == "function" { + if name := strings.TrimSpace(common.Interface2String(choice["name"])); name != "" { + return map[string]any{"type": "function", "function": map[string]any{"name": name}}, nil + } + } + return choice, nil +} + +func responsesRequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat, error) { + if !rawJSONPresent(raw) { + return nil, nil + } + var textConfig map[string]any + if err := common.Unmarshal(raw, &textConfig); err != nil { + return nil, fmt.Errorf("invalid text config: %w", err) + } + format, ok := textConfig["format"].(map[string]any) + if !ok { + return nil, nil + } + formatType := strings.TrimSpace(common.Interface2String(format["type"])) + if formatType == "" { + return nil, nil + } + out := &dto.ResponseFormat{Type: formatType} + if formatType == "json_schema" { + schema := make(map[string]any, len(format)) + for key, value := range format { + if key == "type" { + continue + } + schema[key] = value + } + schemaRaw, err := common.Marshal(schema) + if err != nil { + return nil, err + } + out.JsonSchema = schemaRaw + } + return out, nil +} + +func responsesImagePartToChatImageURL(part map[string]any) any { + if imageURL, ok := part["image_url"]; ok { + return imageURL + } + imageURL := map[string]any{} + for _, key := range []string{"url", "file_id", "detail"} { + if value, ok := part[key]; ok { + imageURL[key] = value + } + } + if len(imageURL) == 0 { + return part + } + return imageURL +} + +func responsesFilePartToChatFile(part map[string]any) any { + if file, ok := part["file"]; ok { + return file + } + file := map[string]any{} + for _, key := range []string{"file_id", "file_data", "filename", "file_url"} { + if value, ok := part[key]; ok { + file[key] = value + } + } + if len(file) == 0 { + return part + } + return file +} + +func responsesVideoPartToChatVideoURL(part map[string]any) any { + if videoURL, ok := part["video_url"]; ok { + if videoURLMap, ok := videoURL.(map[string]any); ok { + if url := common.Interface2String(videoURLMap["url"]); url != "" { + return url + } + } + return videoURL + } + if url := common.Interface2String(part["url"]); url != "" { + return url + } + return responsesPartPayload(part, "video_url") +} + +func responsesPartPayload(part map[string]any, key string) any { + if value, ok := part[key]; ok { + return value + } + payload := make(map[string]any, len(part)) + for k, value := range part { + if k == "type" { + continue + } + payload[k] = value + } + return payload +} + +func responsesCallID(item map[string]any) string { + if callID := strings.TrimSpace(common.Interface2String(item["call_id"])); callID != "" { + return callID + } + return strings.TrimSpace(common.Interface2String(item["id"])) +} + +func responsesArgumentsString(value any) string { + switch v := value.(type) { + case nil: + return "" + case string: + return v + default: + raw, err := common.Marshal(v) + if err != nil { + return common.Interface2String(v) + } + return string(raw) + } +} + +func responseToolOutputToChatContent(value any) any { + switch v := value.(type) { + case nil: + return "" + case string: + return v + default: + raw, err := common.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + return string(raw) + } +} + +func responsesJSONString(raw json.RawMessage) (string, error) { + if common.GetJsonType(raw) != "string" { + return string(raw), nil + } + var value string + if err := common.Unmarshal(raw, &value); err != nil { + return "", err + } + return value, nil +} + +func rawJSONPresent(raw json.RawMessage) bool { + if len(raw) == 0 { + return false + } + return common.GetJsonType(raw) != "null" +} diff --git a/service/openaicompat/responses_to_chat.go b/service/openaicompat/responses_to_chat.go index b1d0def..8d18f66 100644 --- a/service/openaicompat/responses_to_chat.go +++ b/service/openaicompat/responses_to_chat.go @@ -4,47 +4,62 @@ import ( "errors" "strings" + "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/dto" ) +const ( + responsesEventCreated = "response.created" + responsesEventCompleted = "response.completed" + responsesEventDone = "response.done" + responsesEventIncomplete = "response.incomplete" + responsesEventOutputTextDelta = "response.output_text.delta" + responsesEventOutputItemAdded = "response.output_item.added" + responsesEventOutputItemDone = "response.output_item.done" + responsesEventFunctionArgsDelta = "response.function_call_arguments.delta" + responsesEventFunctionArgsDone = "response.function_call_arguments.done" + responsesEventReasoningSummaryDelta = "response.reasoning_summary_text.delta" + responsesEventReasoningSummaryDone = "response.reasoning_summary_text.done" + responsesOutputTypeFunctionCall = "function_call" + responsesOutputTypeCustomToolCall = "custom_tool_call" + responsesOutputTypeMessage = "message" + responsesOutputTypeReasoning = "reasoning" + responsesIncompleteReasonContentFilter = "content_filter" + responsesIncompleteReasonMaxTokens = "max_output_tokens" +) + +func ResponsesFinishReasonFromStatus(resp *dto.OpenAIResponsesResponse) (string, bool) { + if resp == nil { + return "", false + } + if responseStatusString(resp) != "incomplete" { + return "", false + } + reason := "" + if resp.IncompleteDetails != nil { + reason = strings.TrimSpace(resp.IncompleteDetails.Reason) + } + if reason == responsesIncompleteReasonContentFilter { + return "content_filter", true + } + return "length", true +} + func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesResponse, id string) (*dto.OpenAITextResponse, *dto.Usage, error) { if resp == nil { return nil, nil, errors.New("response is nil") } text := ExtractOutputTextFromResponses(resp) + reasoning := ExtractReasoningTextFromResponses(resp) - usage := &dto.Usage{} - if resp.Usage != nil { - if resp.Usage.InputTokens != 0 { - usage.PromptTokens = resp.Usage.InputTokens - usage.InputTokens = resp.Usage.InputTokens - } - if resp.Usage.OutputTokens != 0 { - usage.CompletionTokens = resp.Usage.OutputTokens - usage.OutputTokens = resp.Usage.OutputTokens - } - if resp.Usage.TotalTokens != 0 { - usage.TotalTokens = resp.Usage.TotalTokens - } else { - usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens - } - if resp.Usage.InputTokensDetails != nil { - usage.PromptTokensDetails.CachedTokens = resp.Usage.InputTokensDetails.CachedTokens - usage.PromptTokensDetails.ImageTokens = resp.Usage.InputTokensDetails.ImageTokens - usage.PromptTokensDetails.AudioTokens = resp.Usage.InputTokensDetails.AudioTokens - } - if resp.Usage.CompletionTokenDetails.ReasoningTokens != 0 { - usage.CompletionTokenDetails.ReasoningTokens = resp.Usage.CompletionTokenDetails.ReasoningTokens - } - } - + usage := UsageFromResponsesUsage(resp.Usage) created := resp.CreatedAt var toolCalls []dto.ToolCallResponse - if text == "" && len(resp.Output) > 0 { + if len(resp.Output) > 0 { for _, out := range resp.Output { - if out.Type != "function_call" { + if !isResponsesToolOutputType(out.Type) { continue } name := strings.TrimSpace(out.Name) @@ -67,7 +82,9 @@ func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesRespons } finishReason := "stop" - if len(toolCalls) > 0 { + if mappedReason, ok := ResponsesFinishReasonFromStatus(resp); ok { + finishReason = mappedReason + } else if len(toolCalls) > 0 { finishReason = "tool_calls" } @@ -75,9 +92,11 @@ func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesRespons Role: "assistant", Content: text, } + if reasoning != "" { + msg.ReasoningContent = &reasoning + } if len(toolCalls) > 0 { msg.SetToolCalls(toolCalls) - msg.Content = "" } out := &dto.OpenAITextResponse{ @@ -98,6 +117,35 @@ func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesRespons return out, usage, nil } +func UsageFromResponsesUsage(src *dto.Usage) *dto.Usage { + usage := &dto.Usage{} + if src == nil { + return usage + } + if src.InputTokens != 0 { + usage.PromptTokens = src.InputTokens + usage.InputTokens = src.InputTokens + } + if src.OutputTokens != 0 { + usage.CompletionTokens = src.OutputTokens + usage.OutputTokens = src.OutputTokens + } + if src.TotalTokens != 0 { + usage.TotalTokens = src.TotalTokens + } else { + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + } + if src.InputTokensDetails != nil { + usage.PromptTokensDetails.CachedTokens = src.InputTokensDetails.CachedTokens + usage.PromptTokensDetails.ImageTokens = src.InputTokensDetails.ImageTokens + usage.PromptTokensDetails.AudioTokens = src.InputTokensDetails.AudioTokens + usage.PromptTokensDetails.CachedCreationTokens = src.InputTokensDetails.CachedCreationTokens + usage.PromptTokensDetails.TextTokens = src.InputTokensDetails.TextTokens + } + usage.CompletionTokenDetails = src.CompletionTokenDetails + return usage +} + func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string { if resp == nil || len(resp.Output) == 0 { return "" @@ -135,3 +183,34 @@ func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string { } return sb.String() } + +func ExtractReasoningTextFromResponses(resp *dto.OpenAIResponsesResponse) string { + if resp == nil || len(resp.Output) == 0 { + return "" + } + var sb strings.Builder + for _, out := range resp.Output { + if out.Type != responsesOutputTypeReasoning { + continue + } + for _, c := range out.Content { + if c.Text != "" { + sb.WriteString(c.Text) + } + } + } + return sb.String() +} + +func responseStatusString(resp *dto.OpenAIResponsesResponse) string { + if resp == nil || len(resp.Status) == 0 { + return "" + } + var status string + _ = common.Unmarshal(resp.Status, &status) + return strings.TrimSpace(status) +} + +func isResponsesToolOutputType(outputType string) bool { + return outputType == responsesOutputTypeFunctionCall || outputType == responsesOutputTypeCustomToolCall +} diff --git a/service/task_billing.go b/service/task_billing.go index ab8e32a..c0d977d 100644 --- a/service/task_billing.go +++ b/service/task_billing.go @@ -187,6 +187,7 @@ func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) { TokenId: task.PrivateData.TokenId, Group: task.Group, Other: other, + NodeName: task.PrivateData.NodeName, }) } @@ -250,6 +251,7 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int TokenId: task.PrivateData.TokenId, Group: task.Group, Other: other, + NodeName: task.PrivateData.NodeName, }) } diff --git a/service/task_billing_test.go b/service/task_billing_test.go index 3bedb60..729f2ae 100644 --- a/service/task_billing_test.go +++ b/service/task_billing_test.go @@ -51,6 +51,21 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } +func TestShouldApplyTaskResultProgressSkipsNonTerminalCompleteProgress(t *testing.T) { + assert.False(t, shouldApplyTaskResultProgress(&relaycommon.TaskInfo{ + Status: string(model.TaskStatusInProgress), + Progress: "100%", + })) + assert.True(t, shouldApplyTaskResultProgress(&relaycommon.TaskInfo{ + Status: string(model.TaskStatusInProgress), + Progress: "75%", + })) + assert.True(t, shouldApplyTaskResultProgress(&relaycommon.TaskInfo{ + Status: string(model.TaskStatusSuccess), + Progress: "100%", + })) +} + // --------------------------------------------------------------------------- // Seed helpers // --------------------------------------------------------------------------- @@ -184,6 +199,13 @@ func countLogs(t *testing.T) int64 { return count } +func resetQuotaDataCache(t *testing.T) { + t.Helper() + model.CacheQuotaDataLock.Lock() + defer model.CacheQuotaDataLock.Unlock() + model.CacheQuotaData = make(map[string]*model.QuotaData) +} + // =========================================================================== // RefundTaskQuota tests // =========================================================================== @@ -326,6 +348,52 @@ func TestRecalculate_PositiveDelta(t *testing.T) { assert.Equal(t, actualQuota-preConsumed, log.Quota) } +func TestRecalculate_PositiveDeltaAttributesQuotaDataToSubmitNode(t *testing.T) { + truncate(t) + resetQuotaDataCache(t) + ctx := context.Background() + + originalDataExportEnabled := common.DataExportEnabled + originalNodeName := common.NodeName + common.DataExportEnabled = true + common.NodeName = "polling-node" + t.Cleanup(func() { + common.DataExportEnabled = originalDataExportEnabled + common.NodeName = originalNodeName + resetQuotaDataCache(t) + }) + + const userID, tokenID, channelID = 110, 110, 110 + const initQuota, preConsumed = 10000, 2000 + const actualQuota = 3500 + const tokenRemain = 5000 + + seedUser(t, userID, initQuota) + seedToken(t, tokenID, userID, "sk-recalc-node", tokenRemain) + seedChannel(t, channelID) + + task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0) + task.PrivateData.NodeName = "submit-node" + + RecalculateTaskQuota(ctx, task, actualQuota, "node attribution") + + require.Eventually(t, func() bool { + model.CacheQuotaDataLock.Lock() + defer model.CacheQuotaDataLock.Unlock() + for _, quotaData := range model.CacheQuotaData { + if quotaData.UserID == userID && + quotaData.ModelName == "test-model" && + quotaData.Quota == actualQuota-preConsumed && + quotaData.TokenID == tokenID && + quotaData.ChannelID == channelID && + quotaData.UseGroup == "default" { + return quotaData.NodeName == "submit-node" + } + } + return false + }, time.Second, 10*time.Millisecond) +} + func TestRecalculate_NegativeDelta(t *testing.T) { truncate(t) ctx := context.Background() diff --git a/service/task_polling.go b/service/task_polling.go index a309d12..2745f7b 100644 --- a/service/task_polling.go +++ b/service/task_polling.go @@ -475,7 +475,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * default: return fmt.Errorf("unknown task status %s for task %s", taskResult.Status, task.TaskID) } - if taskResult.Progress != "" { + if shouldApplyTaskResultProgress(taskResult) { task.Progress = taskResult.Progress } @@ -510,6 +510,20 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * return nil } +func shouldApplyTaskResultProgress(taskResult *relaycommon.TaskInfo) bool { + if taskResult == nil || strings.TrimSpace(taskResult.Progress) == "" { + return false + } + switch taskResult.Status { + case model.TaskStatusSuccess, model.TaskStatusFailure: + return true + case model.TaskStatusSubmitted, model.TaskStatusQueued, model.TaskStatusInProgress: + return strings.TrimSpace(taskResult.Progress) != taskcommon.ProgressComplete + default: + return true + } +} + func redactVideoResponseBody(body []byte) []byte { var m map[string]any if err := common.Unmarshal(body, &m); err != nil { diff --git a/tools/jsonwrapcheck/allowlist.txt b/tools/jsonwrapcheck/allowlist.txt index 60e23df..0935833 100644 --- a/tools/jsonwrapcheck/allowlist.txt +++ b/tools/jsonwrapcheck/allowlist.txt @@ -110,7 +110,6 @@ relay/channel/baidu/relay-baidu.go|baiduHandler|Marshal|92981973316a59184c1f9d4f relay/channel/baidu/relay-baidu.go|baiduEmbeddingHandler|Unmarshal|6c88b37cda6b69cb880852f57074d792626b82bd771acbab3a46754f80631684 relay/channel/baidu/relay-baidu.go|baiduEmbeddingHandler|Marshal|92981973316a59184c1f9d4fdca6fe3a1045b5da6a30f89abb52b516f1849571 relay/channel/baidu/relay-baidu.go|getBaiduAccessTokenHelper|NewDecoder|64ee82d1a886a8c84d5771d6a7733571f892d8b735ff5b5ffa0167dc5f1ecb39 -relay/channel/claude/relay-claude.go|RequestOpenAI2ClaudeMessage|Unmarshal|c2fc6982d143a7151516c79cbfae04ae1c89be1758026d0b9461014206dbed15 relay/channel/claude/relay-claude.go|ResponseClaude2OpenAI|Marshal|9f23cc0eecff96adc0410bf3360508a44815f9bc18567a76278a14aaa6e46e0a relay/channel/claude/relay-claude.go|HandleClaudeResponseData|Marshal|0e01a2a781af1a773195f919e8bc2c53345aed7dd99776d98cfe550576ea831a relay/channel/cloudflare/relay_cloudflare.go|cfStreamHandler|Unmarshal|5a9c4fba44410fd4d5ee918ce49cdd49a713d756c491be89ccc2d9f3de44cdc5 @@ -207,8 +206,6 @@ service/codex_oauth.go|decodeJWTClaims|Unmarshal|7ca24118dbcd41c0feb7b0767369b64 service/convert.go|ClaudeToOpenAIRequest|Marshal|ce446f5ba8d179b35eb4bbf8e48f810b753e7a760f3a69cc0f0435092cb0145b service/convert.go|ClaudeToOpenAIRequest|Marshal|3b663c5e2add74ef56c761edc9c50ceb26e287ec2242e79fbf6a9bd35cbc71c6 service/convert.go|toJSONString|Marshal|be383ea6cfaaf292483613ff481847bcf18cbddf173a0870a25e345b29d9f5eb -service/convert.go|ResponseOpenAI2Gemini|Unmarshal|d40a16081c20e0cd2af17a2eac045b9d88afcdcc0fe4179b779269d16e54d731 -service/convert.go|StreamResponseOpenAI2Gemini|Unmarshal|d40a16081c20e0cd2af17a2eac045b9d88afcdcc0fe4179b779269d16e54d731 service/midjourney.go|DoMidjourneyHttpRequest|NewDecoder|dc2d92e0dd4c66591ccceefe086dca58c0af182c9e2c61f92c80cda6833d2f9a service/midjourney.go|DoMidjourneyHttpRequest|Marshal|af957403b76886439ffd61be2eec9cef5f0fd1f3c0b8201b7a5e9565234b44ce service/midjourney.go|DoMidjourneyHttpRequest|Unmarshal|a15ce743bd5d73944713700514fe95dfe90f903ea83e6508c1d05f916dae3d35 diff --git a/web/default/src/features/channels/lib/advanced-custom.ts b/web/default/src/features/channels/lib/advanced-custom.ts index 0fe48c4..fff5dba 100644 --- a/web/default/src/features/channels/lib/advanced-custom.ts +++ b/web/default/src/features/channels/lib/advanced-custom.ts @@ -43,6 +43,10 @@ export const ADVANCED_CUSTOM_CONVERTER_OPTIONS: Array<{ value: 'openai_chat_completions_to_openai_responses', label: 'OpenAI Chat to OpenAI Responses', }, + { + value: 'openai_responses_to_openai_chat_completions', + label: 'OpenAI Responses to OpenAI Chat', + }, { value: 'gemini_generate_content_to_openai_chat_completions', label: 'Gemini Generate Content to OpenAI Chat', @@ -326,6 +330,9 @@ export function getAdvancedCustomUpstreamPathPlaceholder( if (converter === 'openai_chat_completions_to_anthropic_messages') { return '/v1/messages' } + if (converter === 'openai_responses_to_openai_chat_completions') { + return '/v1/chat/completions' + } return '/v1/chat/completions' } @@ -575,6 +582,9 @@ function isConverterPathAllowed( ) { return incomingPath === '/v1/chat/completions' } + if (converter === 'openai_responses_to_openai_chat_completions') { + return incomingPath === '/v1/responses' + } return ( incomingPath.includes(':generateContent') || incomingPath.includes(':streamGenerateContent') diff --git a/web/default/src/features/channels/lib/channel-form.ts b/web/default/src/features/channels/lib/channel-form.ts index 35c831c..e6ecd96 100644 --- a/web/default/src/features/channels/lib/channel-form.ts +++ b/web/default/src/features/channels/lib/channel-form.ts @@ -39,11 +39,7 @@ import { } from './channel-config-rules' const VIDEO_TASK_PROTOCOL = 'generic_video_task' -const LEGACY_SEEDANCE_MEDIA_PROTOCOL = 'seedance_official_media' -const CONFIGURED_VIDEO_TASK_PROTOCOLS = new Set([ - VIDEO_TASK_PROTOCOL, - LEGACY_SEEDANCE_MEDIA_PROTOCOL, -]) +const CONFIGURED_VIDEO_TASK_PROTOCOLS = new Set([VIDEO_TASK_PROTOCOL]) const DEFAULT_VIDEO_TASK_SUBMIT_PATH = '/v1/videos/create' const DEFAULT_VIDEO_TASK_QUERY_PATH = '/v1/videos/{task_id}' const DEFAULT_VIDEO_TASK_TASK_ID_PATH = 'task_id' @@ -378,6 +374,7 @@ export const channelFormSchema = z ) } } + }) export type ChannelFormValues = z.infer @@ -623,7 +620,9 @@ export function transformChannelToFormDefaults( taskProtocolConfig.submit_path || '' ).trim() const parsedQueryPath = String(taskProtocolConfig.query_path || '').trim() - const taskProtocol = String(parsed.task_protocol || '').trim() + const taskProtocol = String(parsed.task_protocol || '') + .trim() + .toLowerCase() const hasConfiguredVideoTaskProtocol = CONFIGURED_VIDEO_TASK_PROTOCOLS.has(taskProtocol) const hasConfiguredVideoTaskPaths = Boolean( @@ -902,11 +901,7 @@ function buildSettingsJSON(formData: ChannelFormValues): string { 'FAILURE' ) - const existingProtocol = String(settingsObj.task_protocol || '').trim() - settingsObj.task_protocol = - existingProtocol === LEGACY_SEEDANCE_MEDIA_PROTOCOL - ? LEGACY_SEEDANCE_MEDIA_PROTOCOL - : VIDEO_TASK_PROTOCOL + settingsObj.task_protocol = VIDEO_TASK_PROTOCOL settingsObj.task_protocol_config = { submit_path: formData.video_task_submit_path?.trim() || @@ -932,13 +927,7 @@ function buildSettingsJSON(formData: ChannelFormValues): string { status_map: statusMap, } } else if (enableVideoTaskPathOverride) { - if ( - CONFIGURED_VIDEO_TASK_PROTOCOLS.has( - String(settingsObj.task_protocol || '').trim() - ) - ) { - delete settingsObj.task_protocol - } + delete settingsObj.task_protocol settingsObj.task_protocol_config = { submit_path: formData.video_task_submit_path?.trim() || @@ -952,12 +941,7 @@ function buildSettingsJSON(formData: ChannelFormValues): string { delete settingsObj.task_protocol_config } - if ( - !enableVideoTaskProtocol && - CONFIGURED_VIDEO_TASK_PROTOCOLS.has( - String(settingsObj.task_protocol || '').trim() - ) - ) { + if (!enableVideoTaskProtocol && 'task_protocol' in settingsObj) { delete settingsObj.task_protocol } diff --git a/web/default/src/features/channels/types.ts b/web/default/src/features/channels/types.ts index cbe416c..80757cf 100644 --- a/web/default/src/features/channels/types.ts +++ b/web/default/src/features/channels/types.ts @@ -146,6 +146,7 @@ export type AdvancedCustomConverter = | 'anthropic_messages_to_openai_chat_completions' | 'openai_chat_completions_to_anthropic_messages' | 'openai_chat_completions_to_openai_responses' + | 'openai_responses_to_openai_chat_completions' | 'gemini_generate_content_to_openai_chat_completions' | 'openai_chat_completions_to_gemini_generate_content' diff --git a/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx b/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx index 7521138..cefaba3 100644 --- a/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx +++ b/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx @@ -61,7 +61,7 @@ const route = getRouteApi('/_authenticated/usage-logs/$section') type LogTypeValue = (typeof LOG_TYPE_FILTER_VALUES)[number] function isLogTypeValue(value: string): value is LogTypeValue { - return LOG_TYPE_FILTER_VALUES.includes(value) + return (LOG_TYPE_FILTER_VALUES as readonly string[]).includes(value) } interface CommonLogsFilterBarProps { diff --git a/web/default/src/features/usage-logs/constants.ts b/web/default/src/features/usage-logs/constants.ts index 8b93121..54a8423 100644 --- a/web/default/src/features/usage-logs/constants.ts +++ b/web/default/src/features/usage-logs/constants.ts @@ -67,6 +67,8 @@ export const LOG_TYPE_ENUM = { */ export const LOG_TYPE_ALL_VALUE = '0' as const export const LOG_TYPE_RETRY_VALUE = 'retry' as const +export const LOG_TYPE_ERROR_RETRY_VALUE = 'error_retry' as const +export const LOG_TYPE_EMPTY_RETRY_VALUE = 'empty_retry' as const // ============================================================================ // Time Range Presets @@ -107,7 +109,8 @@ export const LOG_TYPES = [ */ export const LOG_TYPE_FILTERS = [ { label: 'All Types', value: LOG_TYPE_ALL_VALUE }, - { label: 'Retry', value: LOG_TYPE_RETRY_VALUE }, + { label: 'Empty Completion Retry', value: LOG_TYPE_EMPTY_RETRY_VALUE }, + { label: 'Error Retry', value: LOG_TYPE_ERROR_RETRY_VALUE }, ...LOG_TYPES.filter((type) => type.value !== LOG_TYPE_ENUM.UNKNOWN).map( (type) => ({ label: type.label, @@ -116,9 +119,23 @@ export const LOG_TYPE_FILTERS = [ ), ] as const -export const LOG_TYPE_FILTER_VALUES = LOG_TYPE_FILTERS.map( - (type) => type.value -) as [string, ...string[]] +export const LOG_TYPE_FILTER_VALUES = [ + LOG_TYPE_ALL_VALUE, + LOG_TYPE_EMPTY_RETRY_VALUE, + LOG_TYPE_ERROR_RETRY_VALUE, + '1', + '2', + '3', + '4', + '5', + '6', + '7', +] as const + +export const LOG_TYPE_SEARCH_VALUES = [ + ...LOG_TYPE_FILTER_VALUES, + LOG_TYPE_RETRY_VALUE, +] as [string, ...string[]] // ============================================================================ // Drawing Logs (Midjourney) Constants diff --git a/web/default/src/features/usage-logs/lib/utils.test.ts b/web/default/src/features/usage-logs/lib/utils.test.ts index 75385dc..1cdb813 100644 --- a/web/default/src/features/usage-logs/lib/utils.test.ts +++ b/web/default/src/features/usage-logs/lib/utils.test.ts @@ -33,6 +33,26 @@ describe('buildApiParams', () => { assert.equal(params.type, undefined) }) + test('maps retry subtype filters to log_filter', () => { + const emptyParams = buildApiParams({ + page: 1, + pageSize: 100, + searchParams: { type: ['empty_retry'] }, + isAdmin: true, + }) + assert.equal(emptyParams.log_filter, 'empty_retry') + assert.equal(emptyParams.type, undefined) + + const errorParams = buildApiParams({ + page: 1, + pageSize: 100, + searchParams: { type: ['error_retry'] }, + isAdmin: true, + }) + assert.equal(errorParams.log_filter, 'error_retry') + assert.equal(errorParams.type, undefined) + }) + test('treats mixed retry and numeric column filters as retry filters', () => { const params = buildApiParams({ page: 1, @@ -115,6 +135,71 @@ describe('matchesCommonLogTypeFilter', () => { ) }) + test('matches retry subtype filters against row retry markers', () => { + assert.equal( + matchesCommonLogTypeFilter( + { type: 2, other: '{}', is_error_retry: true }, + ['error_retry'] + ), + true + ) + assert.equal( + matchesCommonLogTypeFilter( + { type: 2, other: JSON.stringify({ retry_log: true }) }, + ['error_retry'] + ), + true + ) + assert.equal( + matchesCommonLogTypeFilter( + { + type: 2, + other: JSON.stringify({ retry_log: true, empty_retry: true }), + }, + ['error_retry'] + ), + false + ) + assert.equal( + matchesCommonLogTypeFilter( + { type: 2, other: JSON.stringify({ empty_retry: true }) }, + ['error_retry'] + ), + false + ) + assert.equal( + matchesCommonLogTypeFilter( + { type: 2, other: '{}', is_empty_retry: true }, + ['empty_retry'] + ), + true + ) + assert.equal( + matchesCommonLogTypeFilter( + { type: 2, other: JSON.stringify({ empty_retry: true }) }, + ['empty_retry'] + ), + true + ) + assert.equal( + matchesCommonLogTypeFilter( + { + type: 2, + other: JSON.stringify({ retry_log: true, empty_retry: true }), + }, + ['empty_retry'] + ), + true + ) + assert.equal( + matchesCommonLogTypeFilter( + { type: 2, other: JSON.stringify({ retry_log: true }) }, + ['empty_retry'] + ), + false + ) + }) + test('gives retry precedence for mixed retry and numeric filters', () => { assert.equal( matchesCommonLogTypeFilter({ type: 1, other: '{}' }, ['retry', '1']), diff --git a/web/default/src/features/usage-logs/lib/utils.ts b/web/default/src/features/usage-logs/lib/utils.ts index a7e91b3..4ea1f41 100644 --- a/web/default/src/features/usage-logs/lib/utils.ts +++ b/web/default/src/features/usage-logs/lib/utils.ts @@ -33,6 +33,8 @@ import { DISPLAYABLE_LOG_TYPES, TIMING_LOG_TYPES, LOG_TYPE_RETRY_VALUE, + LOG_TYPE_ERROR_RETRY_VALUE, + LOG_TYPE_EMPTY_RETRY_VALUE, } from '../constants' import type { GetLogsParams, @@ -43,6 +45,18 @@ import type { } from '../types' import { parseLogOther } from './format' +const RETRY_LOG_FILTER_VALUES = [ + LOG_TYPE_RETRY_VALUE, + LOG_TYPE_ERROR_RETRY_VALUE, + LOG_TYPE_EMPTY_RETRY_VALUE, +] as const + +type RetryLogFilterValue = (typeof RETRY_LOG_FILTER_VALUES)[number] + +function isRetryLogFilterValue(value: string): value is RetryLogFilterValue { + return (RETRY_LOG_FILTER_VALUES as readonly string[]).includes(value) +} + // ============================================================================ // Type Checkers & Utilities // ============================================================================ @@ -96,7 +110,13 @@ function timestampToSeconds(ms: number): number { } export function matchesCommonLogTypeFilter( - log: { type: number; other: string; is_retry?: boolean }, + log: { + type: number + other: string + is_retry?: boolean + is_error_retry?: boolean + is_empty_retry?: boolean + }, value: unknown ): boolean { if (!Array.isArray(value) || value.length === 0) return true @@ -107,7 +127,23 @@ export function matchesCommonLogTypeFilter( if (filterValues.length === 0) return true if (filterValues.includes(LOG_TYPE_ALL_VALUE)) return true - if (filterValues.includes(LOG_TYPE_RETRY_VALUE)) { + if ( + filterValues.includes(LOG_TYPE_RETRY_VALUE) || + filterValues.includes(LOG_TYPE_ERROR_RETRY_VALUE) || + filterValues.includes(LOG_TYPE_EMPTY_RETRY_VALUE) + ) { + if (filterValues.includes(LOG_TYPE_ERROR_RETRY_VALUE)) { + if (log.is_error_retry === true) return true + if (log.is_empty_retry === true) return false + const other = parseLogOther(log.other) + if (other?.empty_retry === true) return false + return other?.retry_log === true + } + if (filterValues.includes(LOG_TYPE_EMPTY_RETRY_VALUE)) { + if (log.is_empty_retry === true) return true + const other = parseLogOther(log.other) + return other?.empty_retry === true + } if (log.is_retry === true) { return true } @@ -222,8 +258,9 @@ export function buildApiParams(config: { return } - if (typeValues.includes(LOG_TYPE_RETRY_VALUE)) { - params.log_filter = LOG_TYPE_RETRY_VALUE + const retryFilter = typeValues.find(isRetryLogFilterValue) + if (retryFilter) { + params.log_filter = retryFilter params.type = undefined return } diff --git a/web/default/src/features/usage-logs/types.ts b/web/default/src/features/usage-logs/types.ts index d3187d1..677cf76 100644 --- a/web/default/src/features/usage-logs/types.ts +++ b/web/default/src/features/usage-logs/types.ts @@ -39,6 +39,8 @@ export interface UsageLog { use_time: number is_stream: boolean is_retry?: boolean + is_error_retry?: boolean + is_empty_retry?: boolean channel: number channel_name?: string token_id?: number diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 7f2d304..1a9b687 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -698,9 +698,9 @@ "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key", "by": "by", "By category": "By category", - "By model author": "By model author", "By clicking sign in, you agree to our": "By clicking sign in, you agree to our", "By creating an account, you agree to our": "By creating an account, you agree to our", + "By model author": "By model author", "Cache": "Cache", "Cache create (1h) price": "Cache create (1h) price", "Cache create price": "Cache create price", @@ -1538,6 +1538,7 @@ "Email, summarisation, knowledge work": "Email, summarisation, knowledge work", "Embeddings": "Embeddings", "Empty": "Empty", + "Empty Completion Retry": "Empty Completion Retry", "Empty value will be saved as {}.": "Empty value will be saved as {}.", "Enable": "Enable", "Enable 2FA": "Enable 2FA", @@ -1689,6 +1690,7 @@ "Error Message": "Error Message", "Error Message (required)": "Error Message (required)", "Error Message Path": "Error Message Path", + "Error Retry": "Error Retry", "Error Type (optional)": "Error Type (optional)", "Estimated cost": "Estimated cost", "Estimated quota cost": "Estimated quota cost", @@ -2214,8 +2216,8 @@ "Human-readable name shown to users during Passkey prompts.": "Human-readable name shown to users during Passkey prompts.", "I confirm enabling high-risk retry": "I Confirm Enabling High-risk Retry", "I have read and agree to the": "I have read and agree to the", - "I have read and agree to the User Agreement.": "I have read and agree to the User Agreement.", "I have read and agree to the User Agreement and Privacy Policy.": "I have read and agree to the User Agreement and Privacy Policy.", + "I have read and agree to the User Agreement.": "I have read and agree to the User Agreement.", "I have read and agree to the Privacy Policy.": "I have read and agree to the Privacy Policy.", "I have read and understood the above compliance reminder": "I have read and understood the above compliance reminder", "I have read and understood the above compliance reminder, acknowledge the related legal risks, and confirm that I bear legal responsibility arising from deployment, operation, and charging behavior.": "I have read and understood the above compliance reminder, acknowledge the related legal risks, and confirm that I bear legal responsibility arising from deployment, operation, and charging behavior.", @@ -2580,6 +2582,7 @@ "maxRequests ≥ 0, maxSuccess ≥ 1, both ≤ 2,147,483,647": "maxRequests ≥ 0, maxSuccess ≥ 1, both ≤ 2,147,483,647", "May be caused by concurrent billing or manual adjustment.": "May be caused by concurrent billing or manual adjustment.", "May be used for training by upstream provider": "May be used for training by upstream provider", + "Media generation fields": "Media generation fields", "Media pricing": "Media pricing", "Median time-to-first-token (TTFT) sampled hourly per group": "Median time-to-first-token (TTFT) sampled hourly per group", "Medical Q&A, mental health support": "Medical Q&A, mental health support", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 7733c1d..27cb5e0 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -698,9 +698,9 @@ "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Intégré : empreinte digitale/visage du téléphone, ou Windows Hello ; Externe : clé de sécurité USB", "by": "par", "By category": "Par catégorie", - "By model author": "Par auteur du modèle", "By clicking sign in, you agree to our": "En cliquant sur Connexion, vous acceptez", "By creating an account, you agree to our": "En créant un compte, vous acceptez", + "By model author": "Par auteur du modèle", "Cache": "Cache", "Cache create (1h) price": "Prix de création du cache (1 h)", "Cache create price": "Prix de création du cache", @@ -1538,6 +1538,7 @@ "Email, summarisation, knowledge work": "Email, résumé, travail intellectuel", "Embeddings": "Embeddings", "Empty": "Vide", + "Empty Completion Retry": "Relance pour complétion vide", "Empty value will be saved as {}.": "Une valeur vide sera enregistrée comme {}.", "Enable": "Activer", "Enable 2FA": "Activer 2FA", @@ -1689,6 +1690,7 @@ "Error Message": "Message d'erreur", "Error Message (required)": "Message d'erreur (requis)", "Error Message Path": "Chemin du message d’erreur", + "Error Retry": "Relance sur erreur", "Error Type (optional)": "Type d'erreur (optionnel)", "Estimated cost": "Coût estimé", "Estimated quota cost": "Coût de quota estimé", @@ -2214,8 +2216,8 @@ "Human-readable name shown to users during Passkey prompts.": "Nom lisible par l'homme affiché aux utilisateurs lors des invites de clé d'accès (Passkey).", "I confirm enabling high-risk retry": "Je confirme l'activation de la relance à haut risque", "I have read and agree to the": "J'ai lu et j'accepte les", - "I have read and agree to the User Agreement.": "J'ai lu et j'accepte l'Accord utilisateur.", "I have read and agree to the User Agreement and Privacy Policy.": "J'ai lu et j'accepte l'Accord utilisateur et la Politique de confidentialité.", + "I have read and agree to the User Agreement.": "J'ai lu et j'accepte l'Accord utilisateur.", "I have read and agree to the Privacy Policy.": "J'ai lu et j'accepte la Politique de confidentialité.", "I have read and understood the above compliance reminder": "J’ai lu et compris le rappel de conformité ci-dessus", "I have read and understood the above compliance reminder, acknowledge the related legal risks, and confirm that I bear legal responsibility arising from deployment, operation, and charging behavior.": "J’ai lu et compris le rappel de conformité ci-dessus, je reconnais les risques juridiques associés et confirme assumer la responsabilité juridique liée au déploiement, à l’exploitation et à la facturation.", @@ -2580,6 +2582,7 @@ "maxRequests ≥ 0, maxSuccess ≥ 1, both ≤ 2,147,483,647": "maxRequests ≥ 0, maxSuccess ≥ 1, les deux ≤ 2 147 483 647", "May be caused by concurrent billing or manual adjustment.": "Peut être causé par une facturation concurrente ou un ajustement manuel.", "May be used for training by upstream provider": "Peut être utilisé pour l'entraînement par le fournisseur amont", + "Media generation fields": "Champs de génération média", "Media pricing": "Tarification multimédia", "Median time-to-first-token (TTFT) sampled hourly per group": "Latence médiane jusqu'au premier jeton (TTFT) échantillonnée par heure et par groupe", "Medical Q&A, mental health support": "Q&R médicales, soutien en santé mentale", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index f1e759a..1efe73b 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -698,9 +698,9 @@ "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "内蔵: 電話の指紋/顔認証、またはWindows Hello。外部: USBセキュリティキー", "by": "によって", "By category": "カテゴリ別", - "By model author": "モデル提供者別", "By clicking sign in, you agree to our": "ログインすると、以下に同意したものとみなされます", "By creating an account, you agree to our": "アカウントを作成すると、以下に同意したものとみなされます", + "By model author": "モデル提供者別", "Cache": "キャッシュ", "Cache create (1h) price": "キャッシュ作成価格 (1時間)", "Cache create price": "キャッシュ作成価格", @@ -1538,6 +1538,7 @@ "Email, summarisation, knowledge work": "メール・要約・ナレッジワーク", "Embeddings": "埋め込み", "Empty": "空", + "Empty Completion Retry": "空補完リトライ", "Empty value will be saved as {}.": "空の値は {} として保存されます。", "Enable": "有効にする", "Enable 2FA": "2FA を有効にする", @@ -1689,6 +1690,7 @@ "Error Message": "エラーメッセージ", "Error Message (required)": "エラーメッセージ(必須)", "Error Message Path": "エラーメッセージパス", + "Error Retry": "エラーリトライ", "Error Type (optional)": "エラータイプ(任意)", "Estimated cost": "推定コスト", "Estimated quota cost": "想定クォートコスト", @@ -2214,8 +2216,8 @@ "Human-readable name shown to users during Passkey prompts.": "パスキーのプロンプト中にユーザーに表示される、人間が読める名前。", "I confirm enabling high-risk retry": "高リスクリトライの有効化を確認します", "I have read and agree to the": "私は以下を読み、同意します", - "I have read and agree to the User Agreement.": "ユーザー利用規約を読み、同意します。", "I have read and agree to the User Agreement and Privacy Policy.": "ユーザー利用規約およびプライバシーポリシーを読み、同意します。", + "I have read and agree to the User Agreement.": "ユーザー利用規約を読み、同意します。", "I have read and agree to the Privacy Policy.": "プライバシーポリシーを読み、同意します。", "I have read and understood the above compliance reminder": "上記のコンプライアンス注意事項を読み、理解しました", "I have read and understood the above compliance reminder, acknowledge the related legal risks, and confirm that I bear legal responsibility arising from deployment, operation, and charging behavior.": "上記のコンプライアンス注意事項を読み理解し、関連する法的リスクを認識したうえで、デプロイ、運用、課金行為に起因する法的責任を負うことを確認します。", @@ -2580,6 +2582,7 @@ "maxRequests ≥ 0, maxSuccess ≥ 1, both ≤ 2,147,483,647": "maxRequests ≥ 0、maxSuccess ≥ 1、両方とも ≤ 2,147,483,647", "May be caused by concurrent billing or manual adjustment.": "同時課金または手動調整によって発生した可能性があります。", "May be used for training by upstream provider": "上流プロバイダーが学習に利用する可能性があります", + "Media generation fields": "メディア生成フィールド", "Media pricing": "メディア料金", "Median time-to-first-token (TTFT) sampled hourly per group": "グループ別に毎時サンプリングした最初のトークンまでの中央値レイテンシ (TTFT)", "Medical Q&A, mental health support": "医療Q&A・メンタルヘルスサポート", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 5135a0e..226a2bc 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -698,9 +698,9 @@ "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Встроенное: отпечаток пальца/лицо телефона или Windows Hello; Внешнее: USB-ключ безопасности", "by": "от", "By category": "По категориям", - "By model author": "По автору модели", "By clicking sign in, you agree to our": "Нажимая «Войти», вы принимаете", "By creating an account, you agree to our": "Создавая аккаунт, вы принимаете", + "By model author": "По автору модели", "Cache": "Кэш", "Cache create (1h) price": "Цена создания кэша (1 ч)", "Cache create price": "Цена создания кэша", @@ -1538,6 +1538,7 @@ "Email, summarisation, knowledge work": "Электронная почта, резюме, knowledge work", "Embeddings": "Встраивания", "Empty": "Пусто", + "Empty Completion Retry": "Повтор при пустом завершении", "Empty value will be saved as {}.": "Пустое значение будет сохранено как {}.", "Enable": "Включить", "Enable 2FA": "Включить 2FA", @@ -1689,6 +1690,7 @@ "Error Message": "Сообщение об ошибке", "Error Message (required)": "Сообщение об ошибке (обязательно)", "Error Message Path": "Путь сообщения об ошибке", + "Error Retry": "Повтор при ошибке", "Error Type (optional)": "Тип ошибки (необязательно)", "Estimated cost": "Примерная стоимость", "Estimated quota cost": "Ориентир стоимости квоты", @@ -2214,8 +2216,8 @@ "Human-readable name shown to users during Passkey prompts.": "Понятное для человека имя, отображаемое пользователям во время запросов Passkey.", "I confirm enabling high-risk retry": "Я подтверждаю включение высокорискового повтора", "I have read and agree to the": "Я прочитал и согласен с", - "I have read and agree to the User Agreement.": "Я прочитал и принимаю Пользовательское соглашение.", "I have read and agree to the User Agreement and Privacy Policy.": "Я прочитал и принимаю Пользовательское соглашение и Политику конфиденциальности.", + "I have read and agree to the User Agreement.": "Я прочитал и принимаю Пользовательское соглашение.", "I have read and agree to the Privacy Policy.": "Я прочитал и принимаю Политику конфиденциальности.", "I have read and understood the above compliance reminder": "Я прочитал и понял приведенное выше напоминание о соответствии", "I have read and understood the above compliance reminder, acknowledge the related legal risks, and confirm that I bear legal responsibility arising from deployment, operation, and charging behavior.": "Я прочитал и понял приведенное выше напоминание о соответствии, признаю связанные правовые риски и подтверждаю, что несу юридическую ответственность за развертывание, эксплуатацию и взимание платы.", @@ -2580,6 +2582,7 @@ "maxRequests ≥ 0, maxSuccess ≥ 1, both ≤ 2,147,483,647": "maxRequests ≥ 0, maxSuccess ≥ 1, оба ≤ 2,147,483,647", "May be caused by concurrent billing or manual adjustment.": "Может быть вызвано параллельным списанием или ручной корректировкой.", "May be used for training by upstream provider": "Может использоваться поставщиком для обучения", + "Media generation fields": "Поля генерации медиа", "Media pricing": "Цены для медиа", "Median time-to-first-token (TTFT) sampled hourly per group": "Медианная задержка первого токена (TTFT), измеряемая ежечасно по группам", "Medical Q&A, mental health support": "Медицинские Q&A, поддержка ментального здоровья", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 31827f0..15e4978 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -698,9 +698,9 @@ "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Tích hợp sẵn: vân tay/khuôn mặt điện thoại, hoặc Windows Hello; Bên ngoài: khóa bảo mật USB", "by": "by", "By category": "Theo danh mục", - "By model author": "Theo nhà phát triển mô hình", "By clicking sign in, you agree to our": "Khi nhấn đăng nhập, bạn đồng ý với", "By creating an account, you agree to our": "Khi tạo tài khoản, bạn đồng ý với", + "By model author": "Theo nhà phát triển mô hình", "Cache": "Bộ nhớ đệm", "Cache create (1h) price": "Giá tạo cache (1 giờ)", "Cache create price": "Giá tạo cache", @@ -1538,6 +1538,7 @@ "Email, summarisation, knowledge work": "Email, tóm tắt, làm việc tri thức", "Embeddings": "Embeddings", "Empty": "Trống", + "Empty Completion Retry": "Thử lại khi hoàn tất rỗng", "Empty value will be saved as {}.": "Giá trị trống sẽ được lưu thành {}.", "Enable": "Bật", "Enable 2FA": "Bật 2FA", @@ -1689,6 +1690,7 @@ "Error Message": "Thông báo lỗi", "Error Message (required)": "Thông báo lỗi (bắt buộc)", "Error Message Path": "Đường dẫn thông báo lỗi", + "Error Retry": "Thử lại do lỗi", "Error Type (optional)": "Loại lỗi (tùy chọn)", "Estimated cost": "Chi phí ước tính", "Estimated quota cost": "Ước tính chi phí hạn mức", @@ -2214,8 +2216,8 @@ "Human-readable name shown to users during Passkey prompts.": "Tên dễ đọc hiển thị cho người dùng trong quá trình nhắc nhở Passkey.", "I confirm enabling high-risk retry": "Tôi xác nhận bật thử lại rủi ro cao", "I have read and agree to the": "Tôi đã đọc và đồng ý với", - "I have read and agree to the User Agreement.": "Tôi đã đọc và đồng ý với Thỏa thuận người dùng.", "I have read and agree to the User Agreement and Privacy Policy.": "Tôi đã đọc và đồng ý với Thỏa thuận người dùngChính sách quyền riêng tư.", + "I have read and agree to the User Agreement.": "Tôi đã đọc và đồng ý với Thỏa thuận người dùng.", "I have read and agree to the Privacy Policy.": "Tôi đã đọc và đồng ý với Chính sách quyền riêng tư.", "I have read and understood the above compliance reminder": "Tôi đã đọc và hiểu nhắc nhở tuân thủ ở trên", "I have read and understood the above compliance reminder, acknowledge the related legal risks, and confirm that I bear legal responsibility arising from deployment, operation, and charging behavior.": "Tôi đã đọc và hiểu nhắc nhở tuân thủ ở trên, thừa nhận các rủi ro pháp lý liên quan và xác nhận rằng tôi chịu trách nhiệm pháp lý phát sinh từ việc triển khai, vận hành và thu phí.", @@ -2580,6 +2582,7 @@ "maxRequests ≥ 0, maxSuccess ≥ 1, both ≤ 2,147,483,647": "maxRequests ≥ 0, maxSuccess ≥ 1, cả hai đều ≤ 2,147,483,647", "May be caused by concurrent billing or manual adjustment.": "Có thể do tính phí đồng thời hoặc điều chỉnh thủ công.", "May be used for training by upstream provider": "Có thể được nhà cung cấp dùng để huấn luyện", + "Media generation fields": "Trường tạo media", "Media pricing": "Giá phương tiện", "Median time-to-first-token (TTFT) sampled hourly per group": "Độ trễ token đầu tiên trung vị (TTFT) lấy mẫu mỗi giờ theo nhóm", "Medical Q&A, mental health support": "Hỏi đáp y tế, hỗ trợ sức khỏe tinh thần", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 7aca7bd..fa33be5 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -698,9 +698,9 @@ "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "内置:手机指纹/面部,或 Windows Hello;外部:USB 安全密钥", "by": "由", "By category": "按行业", - "By model author": "按模型厂商", "By clicking sign in, you agree to our": "点击登录即表示您同意我们的", "By creating an account, you agree to our": "创建账户即表示您同意我们的", + "By model author": "按模型厂商", "Cache": "缓存", "Cache create (1h) price": "1 小时缓存写入价格", "Cache create price": "缓存写入价格", @@ -1538,6 +1538,7 @@ "Email, summarisation, knowledge work": "邮件、摘要与知识工作", "Embeddings": "嵌入", "Empty": "空", + "Empty Completion Retry": "空补全重试", "Empty value will be saved as {}.": "空值将保存为 {}。", "Enable": "启用", "Enable 2FA": "启用 2FA", @@ -1689,6 +1690,7 @@ "Error Message": "错误消息", "Error Message (required)": "错误消息(必填)", "Error Message Path": "错误消息路径", + "Error Retry": "错误重试", "Error Type (optional)": "错误类型(可选)", "Estimated cost": "预计成本", "Estimated quota cost": "估算配额费用", @@ -2214,8 +2216,8 @@ "Human-readable name shown to users during Passkey prompts.": "在 Passkey 提示期间向用户显示的人类可读名称。", "I confirm enabling high-risk retry": "我确认开启高危重试", "I have read and agree to the": "我已阅读并同意", - "I have read and agree to the User Agreement.": "我已阅读并同意用户协议。", "I have read and agree to the User Agreement and Privacy Policy.": "我已阅读并同意用户协议隐私政策。", + "I have read and agree to the User Agreement.": "我已阅读并同意用户协议。", "I have read and agree to the Privacy Policy.": "我已阅读并同意隐私政策。", "I have read and understood the above compliance reminder": "我已阅读并理解上述合规提醒", "I have read and understood the above compliance reminder, acknowledge the related legal risks, and confirm that I bear legal responsibility arising from deployment, operation, and charging behavior.": "我已阅读并理解上述合规提醒,确认相关法律风险,并确认承担因部署、运营和收费行为产生的法律责任。", @@ -2580,6 +2582,7 @@ "maxRequests ≥ 0, maxSuccess ≥ 1, both ≤ 2,147,483,647": "maxRequests ≥ 0, maxSuccess ≥ 1,两者均 ≤ 2,147,483,647", "May be caused by concurrent billing or manual adjustment.": "可能由并发扣费或手动调整导致。", "May be used for training by upstream provider": "可能被上游提供商用于训练", + "Media generation fields": "媒体生成字段", "Media pricing": "媒体定价", "Median time-to-first-token (TTFT) sampled hourly per group": "按小时采样的各分组首 token 延迟(TTFT)中位数", "Medical Q&A, mental health support": "医疗问答与心理健康支持", diff --git a/web/default/src/i18n/static-keys.ts b/web/default/src/i18n/static-keys.ts index 753e6bd..b150714 100644 --- a/web/default/src/i18n/static-keys.ts +++ b/web/default/src/i18n/static-keys.ts @@ -51,6 +51,8 @@ export const STATIC_I18N_KEYS = [ 'Token-based', 'Per Request', 'All Types', + 'Empty Completion Retry', + 'Error Retry', 'Chat', 'Response', 'Anthropic', diff --git a/web/default/src/routes/_authenticated/usage-logs/$section.tsx b/web/default/src/routes/_authenticated/usage-logs/$section.tsx index 1542565..726671c 100644 --- a/web/default/src/routes/_authenticated/usage-logs/$section.tsx +++ b/web/default/src/routes/_authenticated/usage-logs/$section.tsx @@ -19,7 +19,7 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API import z from 'zod' import { createFileRoute, redirect } from '@tanstack/react-router' import { UsageLogs } from '@/features/usage-logs' -import { LOG_TYPE_FILTER_VALUES } from '@/features/usage-logs/constants' +import { LOG_TYPE_SEARCH_VALUES } from '@/features/usage-logs/constants' import { isUsageLogsSectionId, USAGE_LOGS_DEFAULT_SECTION, @@ -31,7 +31,7 @@ const logTypeSearchSchema = z if (value == null || value === '') return undefined return Array.isArray(value) ? value : [value] }, - z.array(z.enum(LOG_TYPE_FILTER_VALUES)).optional() + z.array(z.enum(LOG_TYPE_SEARCH_VALUES)).optional() ) .catch([]) diff --git a/web/default/tests/channel-form.test.ts b/web/default/tests/channel-form.test.ts new file mode 100644 index 0000000..78afd77 --- /dev/null +++ b/web/default/tests/channel-form.test.ts @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict' +import { test } from 'bun:test' + +import { + CHANNEL_FORM_DEFAULT_VALUES, + transformChannelToFormDefaults, + transformFormDataToUpdatePayload, + type ChannelFormValues, +} from '../src/features/channels/lib/channel-form' +import type { Channel } from '../src/features/channels/types' + +const legacyVideoTaskSettings = JSON.stringify({ + task_protocol: 'seedance_official_media', + task_protocol_config: { + submit_path: '/legacy/videos/create', + query_path: '/legacy/videos/{task_id}', + request_body_mode: 'media_generation', + }, + keep_existing: true, +}) + +function buildSettings(overrides: Partial) { + const payload = transformFormDataToUpdatePayload( + { + ...CHANNEL_FORM_DEFAULT_VALUES, + name: 'legacy video task channel', + type: 1, + models: 'video-model', + group: ['default'], + settings: legacyVideoTaskSettings, + ...overrides, + }, + 1 + ) + + return JSON.parse(String(payload.settings || '{}')) as Record +} + +function legacyChannel(): Channel { + return { + id: 1, + type: 1, + key: '', + openai_organization: '', + test_model: '', + status: 1, + name: 'legacy video task channel', + weight: 0, + created_time: 0, + test_time: 0, + response_time: 0, + base_url: '', + other: '', + balance: 0, + balance_updated_time: 0, + models: 'video-model', + group: 'default', + used_quota: 0, + model_mapping: '', + status_code_mapping: '', + priority: 0, + auto_ban: 1, + other_info: '', + tag: '', + setting: '{}', + param_override: '', + header_override: '', + remark: '', + max_input_tokens: 0, + channel_info: { + is_multi_key: false, + multi_key_size: 0, + multi_key_polling_index: 0, + multi_key_mode: 'random', + }, + settings: legacyVideoTaskSettings, + } +} + +function genericVideoTaskChannel(): Channel { + return { + ...legacyChannel(), + settings: JSON.stringify({ + task_protocol: 'generic_video_task', + task_protocol_config: { + submit_path: '/v1/videos', + query_path: '/v1/videos/{task_id}', + request_body_mode: 'media_generation', + request_body_mapping: { + model: 'model', + }, + task_id_path: 'data.id', + status_path: 'data.status', + }, + keep_existing: true, + }), + } +} + +test('loads legacy video task protocol as path override only', () => { + const defaults = transformChannelToFormDefaults(legacyChannel()) + + assert.equal(defaults.video_task_protocol_enabled, false) + assert.equal(defaults.video_task_path_override_enabled, true) + assert.equal(defaults.video_task_submit_path, '/legacy/videos/create') + assert.equal(defaults.video_task_query_path, '/legacy/videos/{task_id}') +}) + +test('clears legacy video task protocol when saving path override', () => { + const defaults = transformChannelToFormDefaults(legacyChannel()) + const savedSettings = buildSettings(defaults) + + assert.equal(savedSettings.keep_existing, true) + assert.equal(savedSettings.task_protocol, undefined) + assert.deepEqual(savedSettings.task_protocol_config, { + submit_path: '/legacy/videos/create', + query_path: '/legacy/videos/{task_id}', + }) +}) + +test('drops stale request body config when saving generic video task channel', () => { + const defaults = transformChannelToFormDefaults(genericVideoTaskChannel()) + const savedSettings = buildSettings(defaults) + const taskProtocolConfig = savedSettings.task_protocol_config as Record< + string, + unknown + > + + assert.equal(defaults.video_task_protocol_enabled, true) + assert.equal(taskProtocolConfig.request_body_mode, undefined) + assert.equal(taskProtocolConfig.request_body_mapping, undefined) + assert.equal(taskProtocolConfig.request_body_defaults, undefined) + assert.equal(taskProtocolConfig.task_id_path, 'data.id') + assert.equal(taskProtocolConfig.status_path, 'data.status') +})