From 34052802242bf1005e8e281c23decec7ded660ef Mon Sep 17 00:00:00 2001 From: qian Date: Sun, 10 May 2026 12:21:27 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E7=AB=AF=E5=8A=9F=E8=83=BD=E9=A6=96?= =?UTF-8?q?=E6=AC=A1=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 43 + CLAUDE.md | 415 +++ Makefile | 41 + README.md | 6 +- api/admin/admin.go | 221 ++ api/admin/v1/admin_user.go | 93 + api/admin/v1/audit.go | 97 + api/admin/v1/auth.go | 98 + api/admin/v1/changelog.go | 73 + api/admin/v1/channel.go | 255 ++ api/admin/v1/channel_oauth.go | 44 + api/admin/v1/channel_testing_api.go | 28 + api/admin/v1/cron_job.go | 49 + api/admin/v1/dashboard.go | 319 +++ api/admin/v1/data_governance.go | 54 + api/admin/v1/error_log.go | 55 + api/admin/v1/feedback.go | 81 + api/admin/v1/help_center.go | 165 ++ api/admin/v1/model.go | 204 ++ api/admin/v1/monitor.go | 184 ++ api/admin/v1/notification.go | 192 ++ api/admin/v1/order_admin.go | 82 + api/admin/v1/payment.go | 123 + api/admin/v1/permission.go | 58 + api/admin/v1/plan.go | 95 + api/admin/v1/plugin.go | 115 + api/admin/v1/promo.go | 89 + api/admin/v1/redemption.go | 98 + api/admin/v1/security.go | 122 + api/admin/v1/settings.go | 49 + api/admin/v1/task_management.go | 66 + api/admin/v1/tenant.go | 170 ++ api/admin/v1/tenant_model.go | 80 + api/admin/v1/ticket.go | 75 + api/admin/v1/usage_log_cleanup.go | 51 + api/captcha/captcha.go | 16 + api/captcha/v1/captcha.go | 26 + api/docs/docs.go | 15 + api/docs/v1/openapi.go | 12 + api/open/open.go | 27 + api/open/v1/open_api.go | 218 ++ api/payment/v1/.gitkeep | 0 api/settings/settings.go | 1 + api/settings/settings.if.go | 16 + api/settings/v1/settings.go | 33 + api/tenant/tenant.go | 164 ++ api/tenant/v1/api_key.go | 64 + api/tenant/v1/audit_tenant.go | 63 + api/tenant/v1/auth.go | 122 + api/tenant/v1/billing_tenant.go | 51 + api/tenant/v1/closure.go | 17 + api/tenant/v1/dashboard.go | 67 + api/tenant/v1/email.go | 33 + api/tenant/v1/feedback.go | 67 + api/tenant/v1/help_center.go | 82 + api/tenant/v1/member.go | 221 ++ api/tenant/v1/member_import_tenant.go | 35 + api/tenant/v1/member_quota_tenant.go | 28 + api/tenant/v1/member_scope_tenant.go | 22 + api/tenant/v1/model_comparison.go | 49 + api/tenant/v1/models.go | 30 + api/tenant/v1/notification_tenant.go | 100 + api/tenant/v1/oauth.go | 59 + api/tenant/v1/open_platform.go | 186 ++ api/tenant/v1/order_tenant.go | 102 + api/tenant/v1/organization.go | 78 + api/tenant/v1/personal_dashboard.go | 127 + api/tenant/v1/plan_tenant.go | 57 + api/tenant/v1/playground.go | 172 ++ api/tenant/v1/plugin.go | 62 + api/tenant/v1/project_tenant.go | 122 + api/tenant/v1/promo_tenant.go | 50 + api/tenant/v1/security.go | 117 + api/tenant/v1/ticket_tenant.go | 89 + api/tenant/v1/wallet_tenant.go | 38 + api/v1/.gitkeep | 0 go.mod | 99 + go.sum | 265 ++ hack/config.example.yaml | 31 + hack/hack-cli.mk | 20 + hack/hack.mk | 75 + internal/cmd/cmd.go | 367 +++ internal/cmd/cmd_reset_pwd.go | 104 + internal/consts/consts.go | 265 ++ internal/controller/admin/admin.go | 5 + internal/controller/admin/admin_new.go | 15 + .../admin/admin_v1_admin_2_fa_confirm.go | 12 + .../admin/admin_v1_admin_2_fa_disable.go | 12 + .../admin/admin_v1_admin_2_fa_enable.go | 12 + ...n_v1_admin_2_fa_regenerate_backup_codes.go | 12 + .../admin/admin_v1_admin_2_fa_setup.go | 12 + .../admin/admin_v1_admin_2_fa_verify.go | 12 + .../admin/admin_v1_admin_all_permissions.go | 12 + .../admin_v1_admin_billing_record_export.go | 12 + .../admin_v1_admin_billing_record_list.go | 12 + .../admin/admin_v1_admin_change_password.go | 12 + .../admin/admin_v1_admin_dashboard.go | 12 + ...admin_v1_admin_dashboard_channel_health.go | 12 + ...n_v1_admin_dashboard_model_distribution.go | 12 + .../admin_v1_admin_dashboard_recent_alerts.go | 12 + .../admin_v1_admin_dashboard_top_tenants.go | 12 + .../admin/admin_v1_admin_dashboard_trends.go | 12 + .../admin/admin_v1_admin_data_scope_update.go | 12 + .../admin/admin_v1_admin_force_logout.go | 12 + .../controller/admin/admin_v1_admin_login.go | 12 + .../admin/admin_v1_admin_login_history.go | 12 + .../controller/admin/admin_v1_admin_logout.go | 12 + .../admin/admin_v1_admin_member_create.go | 12 + .../admin/admin_v1_admin_member_disable.go | 12 + .../admin/admin_v1_admin_member_enable.go | 12 + .../admin/admin_v1_admin_member_export.go | 12 + .../admin/admin_v1_admin_member_list.go | 12 + .../admin_v1_admin_member_reset_password.go | 12 + .../admin/admin_v1_admin_permission_list.go | 12 + .../admin/admin_v1_admin_permission_update.go | 12 + .../admin/admin_v1_admin_refresh.go | 12 + .../admin/admin_v1_admin_revoke_session.go | 12 + .../admin/admin_v1_admin_session_list.go | 12 + .../admin_v1_admin_settings_categories.go | 12 + .../admin/admin_v1_admin_settings_get.go | 12 + .../admin/admin_v1_admin_settings_update.go | 12 + .../admin/admin_v1_admin_usage_log_export.go | 12 + .../admin/admin_v1_admin_usage_log_list.go | 12 + .../admin/admin_v1_admin_user_create.go | 12 + .../admin/admin_v1_admin_user_delete.go | 12 + .../admin/admin_v1_admin_user_export.go | 12 + .../admin/admin_v1_admin_user_list.go | 12 + .../admin_v1_admin_user_reset_password.go | 12 + .../admin/admin_v1_admin_user_update.go | 12 + .../admin_v1_admin_user_update_status.go | 12 + .../admin/admin_v1_admin_wallet_adjust.go | 12 + .../admin/admin_v1_admin_wallet_info.go | 12 + .../admin/admin_v1_admin_wallet_list.go | 12 + ...n_v1_admin_wallet_set_warning_threshold.go | 12 + .../admin_v1_admin_wallet_transaction_list.go | 12 + .../admin/admin_v1_alert_event_acknowledge.go | 12 + .../admin/admin_v1_alert_event_list.go | 12 + .../admin/admin_v1_alert_event_resolve.go | 12 + .../admin/admin_v1_alert_options.go | 12 + .../admin/admin_v1_alert_rule_create.go | 12 + .../admin/admin_v1_alert_rule_delete.go | 12 + .../admin/admin_v1_alert_rule_list.go | 12 + .../admin/admin_v1_alert_rule_toggle.go | 12 + .../admin/admin_v1_alert_rule_update.go | 12 + .../admin/admin_v1_alert_test_endpoint.go | 12 + .../admin/admin_v1_announcement_archive.go | 12 + .../admin/admin_v1_announcement_create.go | 12 + .../admin/admin_v1_announcement_list.go | 12 + .../admin/admin_v1_announcement_publish.go | 12 + .../admin/admin_v1_announcement_update.go | 12 + .../admin/admin_v1_audit_config_get.go | 12 + .../admin/admin_v1_audit_config_update.go | 12 + .../admin/admin_v1_changelog_create.go | 12 + .../admin/admin_v1_changelog_delete.go | 12 + .../admin/admin_v1_changelog_list.go | 12 + .../admin/admin_v1_changelog_publish.go | 12 + .../admin/admin_v1_changelog_update.go | 12 + .../admin/admin_v1_channel_abilities_get.go | 12 + .../admin/admin_v1_channel_ability_batch.go | 12 + .../admin/admin_v1_channel_clone.go | 12 + .../admin/admin_v1_channel_create.go | 12 + .../admin/admin_v1_channel_delete.go | 12 + .../admin/admin_v1_channel_detail.go | 12 + .../admin/admin_v1_channel_export.go | 12 + .../admin/admin_v1_channel_health_trend.go | 12 + .../admin/admin_v1_channel_key_create.go | 12 + .../admin/admin_v1_channel_key_delete.go | 12 + .../admin/admin_v1_channel_key_list.go | 12 + .../controller/admin/admin_v1_channel_list.go | 12 + .../admin/admin_v1_channel_o_auth_auth_url.go | 12 + .../admin/admin_v1_channel_o_auth_exchange.go | 12 + .../admin/admin_v1_channel_o_auth_refresh.go | 12 + .../admin/admin_v1_channel_test_handler.go | 12 + .../admin/admin_v1_channel_update.go | 12 + .../admin/admin_v1_cron_job_executions.go | 12 + .../admin/admin_v1_cron_job_list.go | 12 + .../admin/admin_v1_cron_job_trigger.go | 12 + .../admin/admin_v1_data_governance_cleanup.go | 12 + .../admin_v1_data_governance_deletion.go | 12 + .../admin/admin_v1_data_governance_export.go | 12 + .../admin_v1_data_governance_settings_get.go | 12 + ...dmin_v1_data_governance_settings_update.go | 12 + .../admin/admin_v1_error_log_batch_resolve.go | 12 + .../admin/admin_v1_error_log_detail.go | 12 + .../admin/admin_v1_error_log_list.go | 12 + .../admin/admin_v1_error_log_resolve.go | 12 + .../admin/admin_v1_error_log_stats.go | 12 + .../admin/admin_v1_feedback_list_all.go | 12 + .../admin/admin_v1_feedback_reply.go | 12 + .../admin/admin_v1_feedback_stats.go | 12 + .../admin/admin_v1_feedback_update_status.go | 12 + .../admin/admin_v1_help_article_create.go | 12 + .../admin/admin_v1_help_article_delete.go | 12 + .../admin/admin_v1_help_article_get.go | 12 + .../admin/admin_v1_help_article_list.go | 12 + .../admin/admin_v1_help_article_update.go | 12 + .../admin/admin_v1_help_category_create.go | 12 + .../admin/admin_v1_help_category_delete.go | 12 + .../admin/admin_v1_help_category_list.go | 12 + .../admin/admin_v1_help_category_update.go | 12 + .../admin/admin_v1_message_broadcast.go | 12 + .../controller/admin/admin_v1_message_list.go | 12 + .../controller/admin/admin_v1_message_send.go | 12 + .../controller/admin/admin_v1_model_create.go | 12 + .../controller/admin/admin_v1_model_delete.go | 12 + .../controller/admin/admin_v1_model_export.go | 12 + .../admin_v1_model_fetch_official_info.go | 12 + .../controller/admin/admin_v1_model_list.go | 12 + .../controller/admin/admin_v1_model_update.go | 12 + .../admin/admin_v1_monitor_dashboard.go | 12 + .../admin/admin_v1_monitor_db_pool.go | 12 + .../admin/admin_v1_monitor_latency.go | 12 + .../admin/admin_v1_monitor_realtime.go | 12 + .../admin/admin_v1_monitor_redis_pool.go | 12 + .../admin/admin_v1_monitor_system.go | 12 + .../admin/admin_v1_monitor_traffic.go | 12 + .../admin/admin_v1_operation_log_export.go | 12 + .../admin/admin_v1_operation_log_list.go | 12 + .../admin/admin_v1_order_complete.go | 12 + .../controller/admin/admin_v1_order_detail.go | 12 + .../controller/admin/admin_v1_order_export.go | 12 + .../controller/admin/admin_v1_order_list.go | 12 + .../controller/admin/admin_v1_order_refund.go | 12 + .../admin/admin_v1_payment_channel_create.go | 12 + .../admin/admin_v1_payment_channel_delete.go | 12 + .../admin/admin_v1_payment_channel_detail.go | 12 + .../admin/admin_v1_payment_channel_list.go | 12 + .../admin/admin_v1_payment_channel_toggle.go | 12 + .../admin/admin_v1_payment_channel_update.go | 12 + .../admin/admin_v1_payment_settings_get.go | 12 + .../admin/admin_v1_payment_settings_update.go | 12 + .../controller/admin/admin_v1_plan_archive.go | 12 + .../controller/admin/admin_v1_plan_create.go | 12 + .../controller/admin/admin_v1_plan_detail.go | 12 + .../controller/admin/admin_v1_plan_export.go | 12 + .../controller/admin/admin_v1_plan_list.go | 12 + .../admin/admin_v1_plan_toggle_recommend.go | 12 + .../controller/admin/admin_v1_plan_update.go | 12 + .../admin/admin_v1_plugin_config_schema.go | 12 + .../admin/admin_v1_plugin_config_update.go | 12 + .../admin/admin_v1_plugin_detail.go | 12 + .../admin/admin_v1_plugin_disable.go | 12 + .../admin/admin_v1_plugin_enable.go | 12 + .../admin/admin_v1_plugin_install.go | 12 + .../controller/admin/admin_v1_plugin_list.go | 12 + .../admin/admin_v1_plugin_uninstall.go | 12 + .../admin/admin_v1_plugin_upgrade.go | 12 + .../admin/admin_v1_pricing_fetch_official.go | 12 + .../controller/admin/admin_v1_pricing_get.go | 12 + .../controller/admin/admin_v1_pricing_list.go | 12 + .../controller/admin/admin_v1_pricing_set.go | 12 + .../admin/admin_v1_promo_code_create.go | 12 + .../admin/admin_v1_promo_code_export.go | 12 + .../admin/admin_v1_promo_code_list.go | 12 + .../admin/admin_v1_promo_code_update.go | 12 + .../admin/admin_v1_promo_code_usages.go | 12 + .../admin/admin_v1_provider_default_url.go | 12 + .../admin/admin_v1_redemption_create.go | 12 + .../admin/admin_v1_redemption_disable.go | 12 + .../admin/admin_v1_redemption_export.go | 12 + .../admin/admin_v1_redemption_list.go | 12 + .../admin/admin_v1_redemption_usages.go | 12 + .../admin_v1_request_audit_log_detail.go | 12 + .../admin/admin_v1_request_audit_log_list.go | 12 + .../admin/admin_v1_sensitive_log_list.go | 12 + .../controller/admin/admin_v1_task_cancel.go | 12 + .../controller/admin/admin_v1_task_detail.go | 12 + .../controller/admin/admin_v1_task_list.go | 12 + .../controller/admin/admin_v1_task_retry.go | 12 + .../controller/admin/admin_v1_template_get.go | 12 + .../admin/admin_v1_template_list.go | 12 + .../admin/admin_v1_template_test_endpoint.go | 12 + .../admin/admin_v1_template_update.go | 12 + .../admin_v1_tenant_channel_scope_update.go | 12 + .../admin/admin_v1_tenant_create.go | 12 + .../admin/admin_v1_tenant_export.go | 12 + .../controller/admin/admin_v1_tenant_get.go | 12 + .../controller/admin/admin_v1_tenant_list.go | 12 + .../admin_v1_tenant_model_batch_assign.go | 12 + .../admin/admin_v1_tenant_model_delete.go | 12 + .../admin/admin_v1_tenant_model_list.go | 12 + .../admin/admin_v1_tenant_model_update.go | 12 + .../admin/admin_v1_tenant_select.go | 12 + .../admin/admin_v1_tenant_update.go | 12 + .../admin/admin_v1_tenant_update_status.go | 12 + .../admin/admin_v1_ticket_assign.go | 12 + .../controller/admin/admin_v1_ticket_get.go | 12 + .../controller/admin/admin_v1_ticket_list.go | 12 + .../controller/admin/admin_v1_ticket_reply.go | 12 + .../admin/admin_v1_ticket_status_update.go | 12 + .../admin_v1_usage_log_cleanup_cancel.go | 12 + .../admin_v1_usage_log_cleanup_create.go | 12 + .../admin/admin_v1_usage_log_cleanup_list.go | 12 + internal/controller/captcha/captcha.go | 5 + internal/controller/captcha/captcha_new.go | 15 + .../captcha/captcha_v1_captcha_generate.go | 21 + .../captcha/captcha_v1_captcha_verify.go | 16 + internal/controller/docs/docs_new.go | 11 + .../controller/docs/docs_v1_open_api_spec.go | 21 + internal/controller/open/open.go | 5 + internal/controller/open/open_new.go | 15 + .../open/open_v1_open_billing_query.go | 12 + .../open/open_v1_open_key_create.go | 12 + .../open/open_v1_open_key_delete.go | 12 + .../controller/open/open_v1_open_key_list.go | 12 + .../open/open_v1_open_member_create.go | 12 + .../open/open_v1_open_member_delete.go | 12 + .../open/open_v1_open_member_list.go | 12 + .../open/open_v1_open_member_models.go | 12 + .../open/open_v1_open_member_models_update.go | 12 + .../open/open_v1_open_member_quota.go | 12 + .../open/open_v1_open_member_quota_update.go | 12 + .../open/open_v1_open_member_update.go | 12 + .../open/open_v1_open_usage_query.go | 12 + internal/controller/settings/settings.go | 5 + internal/controller/settings/settings_new.go | 15 + .../settings_v1_public_announcements.go | 12 + .../settings_v1_public_settings_get.go | 12 + internal/controller/tenant/tenant.go | 5 + internal/controller/tenant/tenant_new.go | 15 + .../tenant/tenant_v1_feedback_create.go | 12 + .../tenant/tenant_v1_feedback_get.go | 12 + .../tenant/tenant_v1_feedback_list.go | 12 + .../tenant_v1_help_public_article_get.go | 12 + .../tenant_v1_help_public_article_list.go | 12 + .../tenant_v1_help_public_category_list.go | 12 + .../tenant/tenant_v1_help_public_search.go | 12 + .../tenant/tenant_v1_model_comparison.go | 12 + .../tenant/tenant_v1_o_auth_authorize.go | 12 + .../tenant/tenant_v1_o_auth_callback.go | 12 + .../tenant/tenant_v1_o_auth_link.go | 12 + .../tenant/tenant_v1_o_auth_list_providers.go | 12 + .../tenant/tenant_v1_o_auth_unlink.go | 12 + .../tenant/tenant_v1_open_app_create.go | 12 + .../tenant/tenant_v1_open_app_delete.go | 12 + .../tenant/tenant_v1_open_app_list.go | 12 + .../tenant/tenant_v1_open_app_reset_secret.go | 12 + .../tenant_v1_open_app_toggle_status.go | 12 + .../tenant/tenant_v1_open_app_update.go | 12 + .../tenant_v1_personal_api_key_usage.go | 12 + .../tenant/tenant_v1_personal_dashboard.go | 12 + .../tenant/tenant_v1_personal_model_dist.go | 12 + .../tenant/tenant_v1_personal_token_trends.go | 12 + .../tenant/tenant_v1_playground_audio_tts.go | 12 + .../tenant/tenant_v1_playground_chat.go | 12 + .../tenant/tenant_v1_playground_embedding.go | 12 + .../tenant/tenant_v1_playground_image.go | 12 + .../tenant/tenant_v1_playground_rerank.go | 12 + .../tenant/tenant_v1_sandbox_chat.go | 12 + .../tenant/tenant_v1_sandbox_quota.go | 12 + .../tenant/tenant_v1_tenant_2_fa_confirm.go | 12 + .../tenant/tenant_v1_tenant_2_fa_disable.go | 12 + .../tenant/tenant_v1_tenant_2_fa_enable.go | 12 + ..._v1_tenant_2_fa_regenerate_backup_codes.go | 12 + .../tenant/tenant_v1_tenant_2_fa_setup.go | 12 + .../tenant/tenant_v1_tenant_2_fa_verify.go | 12 + .../tenant/tenant_v1_tenant_announcements.go | 12 + .../tenant/tenant_v1_tenant_api_key_create.go | 12 + .../tenant/tenant_v1_tenant_api_key_delete.go | 12 + .../tenant/tenant_v1_tenant_api_key_export.go | 12 + .../tenant/tenant_v1_tenant_api_key_list.go | 12 + .../tenant_v1_tenant_api_key_update_scopes.go | 12 + .../tenant_v1_tenant_audit_config_get.go | 12 + .../tenant_v1_tenant_audit_config_update.go | 12 + .../tenant/tenant_v1_tenant_audit_logs.go | 12 + .../tenant_v1_tenant_available_models.go | 12 + .../tenant_v1_tenant_balance_prediction.go | 12 + .../tenant_v1_tenant_billing_records.go | 12 + .../tenant/tenant_v1_tenant_budget_alerts.go | 12 + .../tenant/tenant_v1_tenant_cancel_closure.go | 12 + .../tenant/tenant_v1_tenant_change_email.go | 12 + .../tenant_v1_tenant_change_password.go | 12 + .../tenant/tenant_v1_tenant_dashboard.go | 12 + .../tenant_v1_tenant_import_record_get.go | 12 + .../tenant/tenant_v1_tenant_import_records.go | 12 + .../tenant_v1_tenant_invitation_list.go | 12 + .../tenant_v1_tenant_invitation_revoke.go | 12 + .../tenant/tenant_v1_tenant_invite_info.go | 12 + .../tenant_v1_tenant_ip_whitelist_get.go | 12 + .../tenant_v1_tenant_ip_whitelist_update.go | 12 + .../tenant/tenant_v1_tenant_login.go | 12 + .../tenant/tenant_v1_tenant_login_history.go | 12 + .../tenant/tenant_v1_tenant_logout.go | 12 + .../tenant/tenant_v1_tenant_mark_all_read.go | 12 + .../tenant/tenant_v1_tenant_mark_read.go | 12 + .../tenant_v1_tenant_member_api_keys.go | 12 + .../tenant/tenant_v1_tenant_member_create.go | 12 + .../tenant/tenant_v1_tenant_member_export.go | 12 + .../tenant/tenant_v1_tenant_member_get.go | 12 + .../tenant/tenant_v1_tenant_member_import.go | 12 + .../tenant/tenant_v1_tenant_member_invite.go | 12 + .../tenant/tenant_v1_tenant_member_join.go | 12 + .../tenant/tenant_v1_tenant_member_list.go | 12 + .../tenant_v1_tenant_member_model_scopes.go | 12 + ...enant_v1_tenant_member_model_scopes_set.go | 12 + .../tenant/tenant_v1_tenant_member_quota.go | 12 + .../tenant_v1_tenant_member_quota_set.go | 12 + .../tenant/tenant_v1_tenant_member_remove.go | 12 + .../tenant_v1_tenant_member_reset_password.go | 12 + .../tenant_v1_tenant_member_update_role.go | 12 + .../tenant/tenant_v1_tenant_member_usage.go | 12 + .../tenant_v1_tenant_member_usage_ranking.go | 12 + .../tenant_v1_tenant_model_distribution.go | 12 + ..._v1_tenant_notification_preferences_get.go | 12 + ..._tenant_notification_preferences_update.go | 12 + .../tenant/tenant_v1_tenant_notifications.go | 12 + .../tenant_v1_tenant_notifications_export.go | 12 + .../tenant/tenant_v1_tenant_order_cancel.go | 12 + .../tenant/tenant_v1_tenant_order_create.go | 12 + .../tenant/tenant_v1_tenant_order_detail.go | 12 + .../tenant/tenant_v1_tenant_order_export.go | 12 + .../tenant/tenant_v1_tenant_order_list.go | 12 + .../tenant/tenant_v1_tenant_order_pay.go | 12 + .../tenant/tenant_v1_tenant_org_info.go | 12 + .../tenant/tenant_v1_tenant_org_transfer.go | 12 + .../tenant/tenant_v1_tenant_org_update.go | 12 + .../tenant/tenant_v1_tenant_payment_info.go | 12 + ...tenant_v1_tenant_plan_cancel_auto_renew.go | 12 + .../tenant/tenant_v1_tenant_plan_current.go | 12 + .../tenant/tenant_v1_tenant_plan_list.go | 12 + .../tenant_v1_tenant_plugin_config_update.go | 12 + .../tenant/tenant_v1_tenant_plugin_detail.go | 12 + .../tenant/tenant_v1_tenant_plugin_disable.go | 12 + .../tenant/tenant_v1_tenant_plugin_enable.go | 12 + .../tenant/tenant_v1_tenant_plugin_list.go | 12 + .../tenant/tenant_v1_tenant_profile.go | 12 + .../tenant/tenant_v1_tenant_profile_update.go | 12 + ...tenant_v1_tenant_project_api_key_create.go | 12 + ...tenant_v1_tenant_project_api_key_delete.go | 12 + .../tenant_v1_tenant_project_api_key_list.go | 12 + .../tenant_v1_tenant_project_archive.go | 12 + .../tenant/tenant_v1_tenant_project_create.go | 12 + .../tenant/tenant_v1_tenant_project_get.go | 12 + .../tenant/tenant_v1_tenant_project_list.go | 12 + .../tenant_v1_tenant_project_unarchive.go | 12 + .../tenant/tenant_v1_tenant_project_update.go | 12 + .../tenant_v1_tenant_project_usage_logs.go | 12 + .../tenant_v1_tenant_project_usage_stats.go | 12 + .../tenant/tenant_v1_tenant_redeem_code.go | 12 + .../tenant_v1_tenant_redemption_usages.go | 12 + .../tenant/tenant_v1_tenant_refresh.go | 12 + .../tenant/tenant_v1_tenant_register.go | 12 + ...nant_v1_tenant_request_audit_log_detail.go | 12 + .../tenant_v1_tenant_request_audit_logs.go | 12 + .../tenant_v1_tenant_request_closure.go | 12 + .../tenant/tenant_v1_tenant_reset_password.go | 12 + .../tenant/tenant_v1_tenant_revoke_session.go | 12 + .../tenant/tenant_v1_tenant_send_code.go | 12 + .../tenant/tenant_v1_tenant_session_list.go | 12 + .../tenant/tenant_v1_tenant_ticket_close.go | 12 + .../tenant/tenant_v1_tenant_ticket_create.go | 12 + .../tenant/tenant_v1_tenant_ticket_export.go | 12 + .../tenant/tenant_v1_tenant_ticket_get.go | 12 + .../tenant/tenant_v1_tenant_ticket_list.go | 12 + .../tenant/tenant_v1_tenant_ticket_reopen.go | 12 + .../tenant/tenant_v1_tenant_ticket_reply.go | 12 + .../tenant/tenant_v1_tenant_token_trends.go | 12 + .../tenant/tenant_v1_tenant_unread_count.go | 12 + .../tenant/tenant_v1_tenant_usage_logs.go | 12 + .../tenant_v1_tenant_usage_logs_export.go | 12 + .../tenant_v1_tenant_validate_promo_code.go | 12 + .../tenant/tenant_v1_tenant_wallet.go | 12 + .../tenant_v1_tenant_wallet_transactions.go | 12 + ...nt_v1_tenant_wallet_transactions_export.go | 12 + .../tenant/tenant_v1_webhook_config_create.go | 12 + .../tenant/tenant_v1_webhook_config_delete.go | 12 + .../tenant/tenant_v1_webhook_config_list.go | 12 + .../tenant/tenant_v1_webhook_config_update.go | 12 + .../tenant/tenant_v1_webhook_delivery_logs.go | 12 + .../tenant/tenant_v1_webhook_retry.go | 12 + internal/dao/api_key_model_scopes.go | 22 + internal/dao/api_keys.go | 22 + internal/dao/aud_login_history.go | 27 + internal/dao/aud_operation_logs.go | 22 + internal/dao/aud_request_logs.go | 22 + internal/dao/aud_sensitive_access_logs.go | 22 + internal/dao/bil_daily_revenue_summary.go | 22 + internal/dao/bil_daily_usage_summary.go | 22 + internal/dao/bil_monthly_revenue_summary.go | 22 + internal/dao/bil_monthly_usage_summary.go | 22 + internal/dao/bil_records.go | 22 + internal/dao/bil_transactions.go | 22 + internal/dao/bil_usage_logs.go | 22 + internal/dao/bil_wallets.go | 22 + internal/dao/chn_abilities.go | 22 + internal/dao/chn_channel_affinities.go | 22 + internal/dao/chn_channel_keys.go | 22 + internal/dao/chn_channels.go | 22 + internal/dao/chn_health_scores.go | 22 + internal/dao/chn_health_snapshots.go | 22 + internal/dao/clg_changelogs.go | 22 + internal/dao/fil_files.go | 22 + internal/dao/goose_db_version.go | 22 + internal/dao/internal/api_key_model_scopes.go | 87 + internal/dao/internal/api_keys.go | 113 + internal/dao/internal/aud_login_history.go | 103 + internal/dao/internal/aud_operation_logs.go | 101 + internal/dao/internal/aud_request_logs.go | 127 + .../dao/internal/aud_sensitive_access_logs.go | 97 + .../dao/internal/bil_daily_revenue_summary.go | 95 + .../dao/internal/bil_daily_usage_summary.go | 93 + .../internal/bil_monthly_revenue_summary.go | 91 + .../dao/internal/bil_monthly_usage_summary.go | 93 + internal/dao/internal/bil_records.go | 143 + internal/dao/internal/bil_transactions.go | 101 + internal/dao/internal/bil_usage_logs.go | 189 ++ internal/dao/internal/bil_wallets.go | 93 + internal/dao/internal/chn_abilities.go | 91 + .../dao/internal/chn_channel_affinities.go | 95 + internal/dao/internal/chn_channel_keys.go | 99 + internal/dao/internal/chn_channels.go | 115 + internal/dao/internal/chn_health_scores.go | 97 + internal/dao/internal/chn_health_snapshots.go | 93 + internal/dao/internal/clg_changelogs.go | 97 + internal/dao/internal/fil_files.go | 103 + internal/dao/internal/goose_db_version.go | 85 + internal/dao/internal/mdl_models.go | 107 + internal/dao/internal/mdl_pricing.go | 101 + internal/dao/internal/mdl_tenant_models.go | 105 + internal/dao/internal/ntf_announcements.go | 101 + internal/dao/internal/ntf_messages.go | 101 + internal/dao/internal/ntf_preferences.go | 91 + internal/dao/internal/ntf_read_status.go | 85 + internal/dao/internal/ntf_send_log.go | 105 + internal/dao/internal/ntf_templates.go | 95 + internal/dao/internal/opn_apps.go | 107 + internal/dao/internal/opn_webhook_configs.go | 103 + .../dao/internal/opn_webhook_delivery_logs.go | 101 + internal/dao/internal/opn_webhook_events.go | 99 + internal/dao/internal/ops_alert_events.go | 111 + internal/dao/internal/ops_alert_rules.go | 107 + internal/dao/internal/ops_system_metrics.go | 85 + internal/dao/internal/ord_orders.go | 121 + internal/dao/internal/ord_payment_channels.go | 99 + .../dao/internal/ord_promo_code_usages.go | 91 + internal/dao/internal/ord_promo_codes.go | 109 + .../dao/internal/ord_redemption_usages.go | 95 + internal/dao/internal/ord_redemptions.go | 107 + internal/dao/internal/ord_refunds.go | 101 + internal/dao/internal/pln_feature_flags.go | 99 + internal/dao/internal/pln_plans.go | 103 + internal/dao/internal/pln_tenant_plans.go | 103 + internal/dao/internal/spt_articles.go | 105 + internal/dao/internal/spt_attachments.go | 93 + internal/dao/internal/spt_categories.go | 99 + internal/dao/internal/spt_feedbacks.go | 109 + internal/dao/internal/spt_replies.go | 89 + internal/dao/internal/spt_tickets.go | 99 + .../dao/internal/sys_admin_data_scopes.go | 89 + internal/dao/internal/sys_admin_role_perms.go | 87 + internal/dao/internal/sys_admin_users.go | 105 + .../dao/internal/sys_cron_job_executions.go | 97 + .../dao/internal/sys_email_verify_codes.go | 93 + internal/dao/internal/sys_error_logs.go | 103 + .../dao/internal/sys_idempotency_records.go | 93 + internal/dao/internal/sys_options.go | 93 + internal/dao/internal/sys_sessions.go | 97 + internal/dao/internal/tnt_invitations.go | 103 + internal/dao/internal/tnt_member_imports.go | 103 + .../dao/internal/tnt_member_model_scopes.go | 87 + internal/dao/internal/tnt_oauth_identities.go | 105 + internal/dao/internal/tnt_projects.go | 95 + internal/dao/internal/tnt_tenants.go | 111 + internal/dao/internal/tnt_users.go | 121 + internal/dao/internal/tsk_async_tasks.go | 125 + internal/dao/internal/tsk_task_logs.go | 89 + internal/dao/internal/tsk_tasks.go | 105 + internal/dao/mdl_models.go | 22 + internal/dao/mdl_pricing.go | 22 + internal/dao/mdl_tenant_models.go | 22 + internal/dao/ntf_announcements.go | 22 + internal/dao/ntf_messages.go | 22 + internal/dao/ntf_preferences.go | 22 + internal/dao/ntf_read_status.go | 22 + internal/dao/ntf_send_log.go | 22 + internal/dao/ntf_templates.go | 22 + internal/dao/opn_apps.go | 27 + internal/dao/opn_webhook_configs.go | 22 + internal/dao/opn_webhook_delivery_logs.go | 22 + internal/dao/opn_webhook_events.go | 22 + internal/dao/ops_alert_events.go | 22 + internal/dao/ops_alert_rules.go | 22 + internal/dao/ops_system_metrics.go | 22 + internal/dao/ord_orders.go | 22 + internal/dao/ord_payment_channels.go | 22 + internal/dao/ord_promo_code_usages.go | 22 + internal/dao/ord_promo_codes.go | 22 + internal/dao/ord_redemption_usages.go | 22 + internal/dao/ord_redemptions.go | 22 + internal/dao/ord_refunds.go | 22 + internal/dao/pln_feature_flags.go | 22 + internal/dao/pln_plans.go | 22 + internal/dao/pln_tenant_plans.go | 22 + internal/dao/spt_articles.go | 22 + internal/dao/spt_attachments.go | 22 + internal/dao/spt_categories.go | 22 + internal/dao/spt_feedbacks.go | 22 + internal/dao/spt_replies.go | 22 + internal/dao/spt_tickets.go | 22 + internal/dao/sys_admin_data_scopes.go | 22 + internal/dao/sys_admin_role_perms.go | 22 + internal/dao/sys_admin_users.go | 22 + internal/dao/sys_cron_job_executions.go | 22 + internal/dao/sys_email_verify_codes.go | 22 + internal/dao/sys_error_logs.go | 22 + internal/dao/sys_idempotency_records.go | 22 + internal/dao/sys_options.go | 22 + internal/dao/sys_sessions.go | 22 + internal/dao/tnt_invitations.go | 22 + internal/dao/tnt_member_imports.go | 22 + internal/dao/tnt_member_model_scopes.go | 22 + internal/dao/tnt_oauth_identities.go | 22 + internal/dao/tnt_projects.go | 22 + internal/dao/tnt_tenants.go | 22 + internal/dao/tnt_users.go | 22 + internal/dao/tsk_async_tasks.go | 27 + internal/dao/tsk_task_logs.go | 22 + internal/dao/tsk_tasks.go | 22 + internal/handler/public/payment.go | 64 + internal/handler/relay/relay.go | 711 +++++ internal/handler/relay/response_capture.go | 131 + internal/handler/relay/task.go | 215 ++ internal/handler/setup/setup.go | 75 + internal/handler/ws/broker.go | 163 ++ internal/handler/ws/client.go | 156 ++ internal/handler/ws/handler.go | 103 + internal/handler/ws/hub.go | 312 +++ internal/handler/ws/message.go | 89 + internal/logic/admin/admin.go | 32 + internal/logic/admin/admin_user.go | 405 +++ internal/logic/admin/audit.go | 459 ++++ internal/logic/admin/auth.go | 404 +++ internal/logic/admin/changelog.go | 140 + internal/logic/admin/channel.go | 753 ++++++ internal/logic/admin/channel_oauth.go | 325 +++ internal/logic/admin/channel_test_request.go | 183 ++ internal/logic/admin/channel_testing.go | 100 + internal/logic/admin/cron_job.go | 127 + internal/logic/admin/dashboard.go | 822 ++++++ internal/logic/admin/data_governance.go | 222 ++ internal/logic/admin/error_log.go | 171 ++ internal/logic/admin/feedback.go | 228 ++ internal/logic/admin/help_center.go | 373 +++ internal/logic/admin/member.go | 469 ++++ internal/logic/admin/model.go | 690 +++++ internal/logic/admin/notification.go | 398 +++ internal/logic/admin/order.go | 396 +++ internal/logic/admin/permission.go | 307 +++ internal/logic/admin/plan.go | 254 ++ internal/logic/admin/plugin.go | 125 + internal/logic/admin/promo_code.go | 188 ++ internal/logic/admin/redemption.go | 288 ++ internal/logic/admin/security.go | 340 +++ internal/logic/admin/settings.go | 73 + internal/logic/admin/setup.go | 107 + internal/logic/admin/task_management.go | 213 ++ internal/logic/admin/tenant.go | 537 ++++ internal/logic/admin/tenant_model.go | 187 ++ internal/logic/admin/ticket.go | 301 +++ internal/logic/admin/usage_log_cleanup.go | 138 + internal/logic/billing/alert.go | 63 + internal/logic/billing/currency.go | 42 + internal/logic/billing/member_quota.go | 206 ++ internal/logic/billing/pricing.go | 507 ++++ internal/logic/billing/pricing_test.go | 80 + internal/logic/billing/provider.go | 117 + internal/logic/billing/ratelimit.go | 368 +++ internal/logic/billing/ratelimit_test.go | 124 + internal/logic/billing/reconciliation.go | 99 + internal/logic/billing/scope.go | 99 + internal/logic/billing/scope_test.go | 92 + internal/logic/billing/settlement.go | 396 +++ internal/logic/billing/snapshot.go | 284 ++ internal/logic/billing/summary.go | 107 + internal/logic/billing/task_billing.go | 104 + internal/logic/billing/wallet.go | 392 +++ internal/logic/common/audit.go | 319 +++ internal/logic/common/batch_writer.go | 161 ++ internal/logic/common/cache.go | 164 ++ internal/logic/common/captcha.go | 213 ++ internal/logic/common/config.go | 459 ++++ internal/logic/common/content_filter.go | 209 ++ internal/logic/common/cron.go | 240 ++ internal/logic/common/email.go | 225 ++ internal/logic/common/errors.go | 45 + internal/logic/common/events.go | 84 + internal/logic/common/feature_flag.go | 62 + internal/logic/common/help_center.go | 203 ++ internal/logic/common/jwt.go | 165 ++ internal/logic/common/notification.go | 479 ++++ internal/logic/common/oauth/claude.go | 401 +++ internal/logic/common/oauth/client.go | 45 + internal/logic/common/oauth/gemini.go | 359 +++ internal/logic/common/oauth/openai.go | 269 ++ internal/logic/common/oauth/pkce.go | 55 + internal/logic/common/oauth/session.go | 93 + internal/logic/common/oauth/token_types.go | 57 + internal/logic/common/pagination.go | 12 + internal/logic/common/partition.go | 76 + internal/logic/common/pricing_source.go | 291 +++ internal/logic/common/security.go | 457 ++++ internal/logic/common/session.go | 234 ++ internal/logic/common/settings_registry.go | 283 ++ internal/logic/common/storage.go | 149 ++ internal/logic/common/storage_cos.go | 111 + internal/logic/common/storage_factory.go | 69 + internal/logic/common/storage_oss.go | 99 + internal/logic/common/storage_s3.go | 130 + internal/logic/common/usage_log_writer.go | 232 ++ internal/logic/common/validation.go | 45 + internal/logic/common/verify_code.go | 174 ++ internal/logic/docs/openapi.go | 297 +++ internal/logic/logic.go | 19 + internal/logic/monitor/alert_engine.go | 211 ++ internal/logic/monitor/alert_event.go | 229 ++ internal/logic/monitor/alert_notify.go | 215 ++ internal/logic/monitor/alert_rule.go | 354 +++ internal/logic/monitor/api_metrics.go | 293 +++ internal/logic/monitor/collector.go | 451 ++++ internal/logic/monitor/db_monitor.go | 79 + internal/logic/monitor/metrics_api.go | 93 + internal/logic/monitor/monitor.go | 257 ++ internal/logic/monitor/redis_monitor.go | 110 + internal/logic/monitor/request_tracker.go | 261 ++ internal/logic/monitor/ws_push.go | 61 + internal/logic/open/open_api.go | 572 ++++ internal/logic/payment/callback.go | 132 + internal/logic/payment/config.go | 68 + internal/logic/payment/epay.go | 150 ++ internal/logic/payment/fulfill.go | 154 ++ internal/logic/payment/mock.go | 73 + internal/logic/payment/provider.go | 84 + internal/logic/payment/settings.go | 63 + internal/logic/payment/stripe.go | 233 ++ internal/logic/relay/affinity.go | 55 + internal/logic/relay/apikey.go | 189 ++ internal/logic/relay/health.go | 254 ++ internal/logic/relay/provider.go | 960 +++++++ internal/logic/settings/settings.go | 88 + internal/logic/task/active_counter.go | 42 + internal/logic/task/async_polling.go | 243 ++ internal/logic/task/async_provider.go | 362 +++ internal/logic/task/auto_renew.go | 135 + internal/logic/task/channel_auto_test_cron.go | 130 + internal/logic/task/data_tasks.go | 162 ++ internal/logic/task/health_snapshot.go | 71 + internal/logic/task/lifecycle_cron.go | 117 + internal/logic/task/model_sunset.go | 80 + internal/logic/task/oauth_token_refresh.go | 140 + internal/logic/task/summary.go | 164 ++ internal/logic/task/task.go | 290 +++ internal/logic/task/usage_cleanup.go | 275 ++ internal/logic/tenant/api_key.go | 514 ++++ internal/logic/tenant/audit_config.go | 264 ++ internal/logic/tenant/auth.go | 530 ++++ internal/logic/tenant/billing.go | 538 ++++ internal/logic/tenant/dashboard.go | 349 +++ internal/logic/tenant/email.go | 176 ++ internal/logic/tenant/feedback.go | 97 + internal/logic/tenant/help_center.go | 122 + internal/logic/tenant/invitation.go | 188 ++ internal/logic/tenant/lifecycle.go | 89 + internal/logic/tenant/member.go | 955 +++++++ internal/logic/tenant/member_import.go | 447 ++++ internal/logic/tenant/member_model_scope.go | 115 + internal/logic/tenant/member_quota.go | 129 + internal/logic/tenant/model.go | 70 + internal/logic/tenant/model_comparison.go | 250 ++ internal/logic/tenant/notification.go | 590 +++++ internal/logic/tenant/oauth.go | 521 ++++ internal/logic/tenant/open_platform.go | 508 ++++ internal/logic/tenant/order.go | 393 +++ internal/logic/tenant/organization.go | 213 ++ internal/logic/tenant/personal_dashboard.go | 331 +++ internal/logic/tenant/plan.go | 92 + internal/logic/tenant/playground.go | 500 ++++ internal/logic/tenant/plugin.go | 100 + internal/logic/tenant/project.go | 732 ++++++ internal/logic/tenant/promo_code.go | 207 ++ internal/logic/tenant/redemption.go | 218 ++ internal/logic/tenant/security.go | 378 +++ internal/logic/tenant/tenant.go | 67 + internal/logic/tenant/ticket.go | 307 +++ internal/logic/tenant/webhook_dispatcher.go | 115 + internal/logic/tenant/webhook_worker.go | 205 ++ internal/middleware/admin_auth.go | 189 ++ internal/middleware/api_key_auth.go | 101 + internal/middleware/content_filter.go | 136 + internal/middleware/demo.go | 59 + internal/middleware/error_handler.go | 30 + internal/middleware/handler_response.go | 81 + internal/middleware/idempotency.go | 99 + internal/middleware/maintenance.go | 90 + internal/middleware/open_auth.go | 149 ++ internal/middleware/operation_log.go | 268 ++ internal/middleware/rbac.go | 46 + internal/middleware/recovery.go | 41 + internal/middleware/request_id.go | 20 + internal/middleware/tenant_auth.go | 78 + internal/model/do/api_key_model_scopes.go | 20 + internal/model/do/api_keys.go | 33 + internal/model/do/aud_login_history.go | 28 + internal/model/do/aud_operation_logs.go | 27 + internal/model/do/aud_request_logs.go | 40 + .../model/do/aud_sensitive_access_logs.go | 25 + .../model/do/bil_daily_revenue_summary.go | 24 + internal/model/do/bil_daily_usage_summary.go | 23 + .../model/do/bil_monthly_revenue_summary.go | 22 + .../model/do/bil_monthly_usage_summary.go | 23 + internal/model/do/bil_records.go | 48 + internal/model/do/bil_transactions.go | 27 + internal/model/do/bil_usage_logs.go | 71 + internal/model/do/bil_wallets.go | 23 + internal/model/do/chn_abilities.go | 22 + internal/model/do/chn_channel_affinities.go | 24 + internal/model/do/chn_channel_keys.go | 26 + internal/model/do/chn_channels.go | 34 + internal/model/do/chn_health_scores.go | 25 + internal/model/do/chn_health_snapshots.go | 23 + internal/model/do/clg_changelogs.go | 25 + internal/model/do/fil_files.go | 28 + internal/model/do/goose_db_version.go | 19 + internal/model/do/mdl_models.go | 30 + internal/model/do/mdl_pricing.go | 27 + internal/model/do/mdl_tenant_models.go | 29 + internal/model/do/ntf_announcements.go | 27 + internal/model/do/ntf_messages.go | 27 + internal/model/do/ntf_preferences.go | 22 + internal/model/do/ntf_read_status.go | 19 + internal/model/do/ntf_send_log.go | 29 + internal/model/do/ntf_templates.go | 24 + internal/model/do/opn_apps.go | 30 + internal/model/do/opn_webhook_configs.go | 28 + .../model/do/opn_webhook_delivery_logs.go | 27 + internal/model/do/opn_webhook_events.go | 26 + internal/model/do/ops_alert_events.go | 32 + internal/model/do/ops_alert_rules.go | 30 + internal/model/do/ops_system_metrics.go | 19 + internal/model/do/ord_orders.go | 37 + internal/model/do/ord_payment_channels.go | 26 + internal/model/do/ord_promo_code_usages.go | 22 + internal/model/do/ord_promo_codes.go | 31 + internal/model/do/ord_redemption_usages.go | 24 + internal/model/do/ord_redemptions.go | 30 + internal/model/do/ord_refunds.go | 27 + internal/model/do/pln_feature_flags.go | 26 + internal/model/do/pln_plans.go | 28 + internal/model/do/pln_tenant_plans.go | 28 + internal/model/do/spt_articles.go | 29 + internal/model/do/spt_attachments.go | 23 + internal/model/do/spt_categories.go | 26 + internal/model/do/spt_feedbacks.go | 31 + internal/model/do/spt_replies.go | 21 + internal/model/do/spt_tickets.go | 26 + internal/model/do/sys_admin_data_scopes.go | 21 + internal/model/do/sys_admin_role_perms.go | 20 + internal/model/do/sys_admin_users.go | 29 + internal/model/do/sys_cron_job_executions.go | 25 + internal/model/do/sys_email_verify_codes.go | 23 + internal/model/do/sys_error_logs.go | 28 + internal/model/do/sys_idempotency_records.go | 23 + internal/model/do/sys_options.go | 23 + internal/model/do/sys_sessions.go | 25 + internal/model/do/tnt_invitations.go | 28 + internal/model/do/tnt_member_imports.go | 28 + internal/model/do/tnt_member_model_scopes.go | 20 + internal/model/do/tnt_oauth_identities.go | 29 + internal/model/do/tnt_projects.go | 24 + internal/model/do/tnt_tenants.go | 32 + internal/model/do/tnt_users.go | 37 + internal/model/do/tsk_async_tasks.go | 39 + internal/model/do/tsk_task_logs.go | 21 + internal/model/do/tsk_tasks.go | 29 + internal/model/entity/api_key_model_scopes.go | 18 + internal/model/entity/api_keys.go | 31 + internal/model/entity/aud_login_history.go | 26 + internal/model/entity/aud_operation_logs.go | 25 + internal/model/entity/aud_request_logs.go | 38 + .../model/entity/aud_sensitive_access_logs.go | 23 + .../model/entity/bil_daily_revenue_summary.go | 22 + .../model/entity/bil_daily_usage_summary.go | 21 + .../entity/bil_monthly_revenue_summary.go | 20 + .../model/entity/bil_monthly_usage_summary.go | 21 + internal/model/entity/bil_records.go | 46 + internal/model/entity/bil_transactions.go | 25 + internal/model/entity/bil_usage_logs.go | 69 + internal/model/entity/bil_wallets.go | 21 + internal/model/entity/chn_abilities.go | 20 + .../model/entity/chn_channel_affinities.go | 22 + internal/model/entity/chn_channel_keys.go | 24 + internal/model/entity/chn_channels.go | 32 + internal/model/entity/chn_health_scores.go | 23 + internal/model/entity/chn_health_snapshots.go | 21 + internal/model/entity/clg_changelogs.go | 23 + internal/model/entity/fil_files.go | 26 + internal/model/entity/goose_db_version.go | 17 + internal/model/entity/mdl_models.go | 28 + internal/model/entity/mdl_pricing.go | 25 + internal/model/entity/mdl_tenant_models.go | 27 + internal/model/entity/ntf_announcements.go | 25 + internal/model/entity/ntf_messages.go | 25 + internal/model/entity/ntf_preferences.go | 20 + internal/model/entity/ntf_read_status.go | 17 + internal/model/entity/ntf_send_log.go | 27 + internal/model/entity/ntf_templates.go | 22 + internal/model/entity/opn_apps.go | 28 + internal/model/entity/opn_webhook_configs.go | 26 + .../model/entity/opn_webhook_delivery_logs.go | 25 + internal/model/entity/opn_webhook_events.go | 24 + internal/model/entity/ops_alert_events.go | 30 + internal/model/entity/ops_alert_rules.go | 28 + internal/model/entity/ops_system_metrics.go | 17 + internal/model/entity/ord_orders.go | 35 + internal/model/entity/ord_payment_channels.go | 24 + .../model/entity/ord_promo_code_usages.go | 20 + internal/model/entity/ord_promo_codes.go | 29 + .../model/entity/ord_redemption_usages.go | 22 + internal/model/entity/ord_redemptions.go | 28 + internal/model/entity/ord_refunds.go | 25 + internal/model/entity/pln_feature_flags.go | 24 + internal/model/entity/pln_plans.go | 26 + internal/model/entity/pln_tenant_plans.go | 26 + internal/model/entity/spt_articles.go | 27 + internal/model/entity/spt_attachments.go | 21 + internal/model/entity/spt_categories.go | 24 + internal/model/entity/spt_feedbacks.go | 29 + internal/model/entity/spt_replies.go | 19 + internal/model/entity/spt_tickets.go | 24 + .../model/entity/sys_admin_data_scopes.go | 19 + internal/model/entity/sys_admin_role_perms.go | 18 + internal/model/entity/sys_admin_users.go | 27 + .../model/entity/sys_cron_job_executions.go | 23 + .../model/entity/sys_email_verify_codes.go | 21 + internal/model/entity/sys_error_logs.go | 26 + .../model/entity/sys_idempotency_records.go | 21 + internal/model/entity/sys_options.go | 21 + internal/model/entity/sys_sessions.go | 23 + internal/model/entity/tnt_invitations.go | 26 + internal/model/entity/tnt_member_imports.go | 26 + .../model/entity/tnt_member_model_scopes.go | 18 + internal/model/entity/tnt_oauth_identities.go | 27 + internal/model/entity/tnt_projects.go | 22 + internal/model/entity/tnt_tenants.go | 30 + internal/model/entity/tnt_users.go | 35 + internal/model/entity/tsk_async_tasks.go | 37 + internal/model/entity/tsk_task_logs.go | 19 + internal/model/entity/tsk_tasks.go | 27 + internal/packed/packed.go | 6 + internal/packed/setup.html | 265 ++ internal/plugin/bootstrap.go | 118 + internal/plugin/config.go | 100 + internal/plugin/hooks.go | 138 + internal/plugin/interfaces.go | 114 + internal/plugin/lifecycle.go | 229 ++ internal/plugin/plugin.go | 44 + internal/plugin/registry.go | 77 + internal/response/error_log.go | 134 + internal/response/response.go | 137 + internal/service/.gitkeep | 0 internal/service/admin.go | 399 +++ internal/service/billing.go | 8 + internal/service/common.go | 8 + internal/service/docs.go | 34 + internal/service/monitor.go | 49 + internal/service/open.go | 40 + internal/service/payment.go | 8 + internal/service/relay.go | 8 + internal/service/settings.go | 34 + internal/service/task.go | 8 + internal/service/tenant.go | 312 +++ internal/utility/crypto/bcrypt.go | 18 + internal/utility/crypto/crypto.go | 117 + internal/utility/export/csv.go | 74 + internal/utility/export/excel.go | 77 + internal/utility/export/export.go | 24 + internal/utility/export/helper.go | 59 + internal/utility/totp/totp.go | 55 + internal/utility/turnstile/turnstile.go | 63 + main.go | 19 + manifest/config/config.example.yaml | 56 + .../deploy/kustomize/base/deployment.yaml | 21 + .../deploy/kustomize/base/kustomization.yaml | 8 + manifest/deploy/kustomize/base/service.yaml | 12 + .../kustomize/overlays/develop/configmap.yaml | 14 + .../overlays/develop/deployment.yaml | 10 + .../overlays/develop/kustomization.yaml | 14 + manifest/docker/Dockerfile | 16 + manifest/docker/docker-compose.yaml | 56 + manifest/docker/docker.sh | 8 + manifest/i18n/.gitkeep | 0 manifest/protobuf/.keep-if-necessary | 0 migrations/000001_init_schema.sql | 2319 +++++++++++++++++ plugins/example/hooks/relay_hooks.go | 22 + plugins/example/plugin.go | 161 ++ plugins/registry.go | 7 + relay/channel/ali/adaptor.go | 167 ++ relay/channel/ali/constants.go | 4 + relay/channel/ali/converter.go | 35 + relay/channel/aws/adaptor.go | 132 + relay/channel/aws/constants.go | 4 + relay/channel/baidu_v2/adaptor.go | 147 ++ relay/channel/baidu_v2/constants.go | 3 + relay/channel/claude/adaptor.go | 256 ++ relay/channel/claude/converter.go | 446 ++++ relay/channel/claude/converter_test.go | 189 ++ relay/channel/claude/response.go | 626 +++++ relay/channel/cloudflare/adaptor.go | 147 ++ relay/channel/cloudflare/constants.go | 3 + relay/channel/codex/adaptor.go | 127 + relay/channel/codex/constants.go | 3 + relay/channel/coze/adaptor.go | 367 +++ relay/channel/coze/constants.go | 4 + relay/channel/coze/converter.go | 77 + relay/channel/coze/dto.go | 45 + relay/channel/deepseek/adaptor.go | 241 ++ relay/channel/deepseek/constants.go | 4 + relay/channel/dify/adaptor.go | 315 +++ relay/channel/dify/constants.go | 4 + relay/channel/dify/converter.go | 129 + relay/channel/gemini/adaptor.go | 531 ++++ relay/channel/gemini/converter.go | 686 +++++ relay/channel/gemini/converter_test.go | 807 ++++++ relay/channel/gemini/image.go | 240 ++ relay/channel/gemini/response.go | 690 +++++ relay/channel/jimeng/adaptor.go | 139 + relay/channel/jimeng/constants.go | 4 + relay/channel/minimax/adaptor.go | 157 ++ relay/channel/minimax/constants.go | 4 + relay/channel/minimax/image.go | 181 ++ relay/channel/mistral/adaptor.go | 118 + relay/channel/mistral/constants.go | 4 + relay/channel/moonshot/adaptor.go | 150 ++ relay/channel/moonshot/constants.go | 4 + relay/channel/ollama/adaptor.go | 493 ++++ relay/channel/ollama/constants.go | 4 + relay/channel/ollama/converter.go | 220 ++ relay/channel/ollama/dto.go | 58 + relay/channel/openai/adaptor.go | 494 ++++ relay/channel/openai/audio.go | 114 + relay/channel/openai/chat_via_responses.go | 32 + relay/channel/openai/claude_response.go | 442 ++++ relay/channel/openai/completion.go | 51 + relay/channel/openai/converter.go | 1442 ++++++++++ relay/channel/openai/embedding.go | 48 + relay/channel/openai/gemini_response.go | 370 +++ relay/channel/openai/image.go | 32 + relay/channel/openai/realtime.go | 240 ++ relay/channel/openai/rerank.go | 63 + relay/channel/openai/responses.go | 611 +++++ relay/channel/openai/stream.go | 133 + relay/channel/registry.go | 94 + relay/channel/siliconflow/adaptor.go | 139 + relay/channel/siliconflow/constants.go | 3 + relay/channel/submodel/adaptor.go | 112 + relay/channel/submodel/constants.go | 3 + relay/channel/tencent/adaptor.go | 190 ++ relay/channel/tencent/constants.go | 4 + relay/channel/tencent/signing.go | 81 + relay/channel/vertex/adaptor.go | 188 ++ relay/channel/vertex/auth.go | 219 ++ relay/channel/vertex/constants.go | 3 + relay/channel/volcengine/adaptor.go | 160 ++ relay/channel/volcengine/constants.go | 3 + relay/channel/xai/adaptor.go | 149 ++ relay/channel/xai/constants.go | 4 + relay/channel/xunfei/adaptor.go | 121 + relay/channel/xunfei/constants.go | 4 + relay/channel/zhipu/adaptor.go | 257 ++ relay/channel/zhipu/constants.go | 4 + relay/channel/zhipu/converter.go | 92 + relay/common/adaptor.go | 32 + relay/common/billing.go | 73 + relay/common/cache_hash.go | 69 + relay/common/forwarding_trace.go | 28 + relay/common/http_client.go | 157 ++ relay/common/provider.go | 234 ++ relay/common/reasonmap.go | 150 ++ relay/common/relay_info.go | 131 + relay/common/stream_status.go | 109 + relay/common/task_adaptor.go | 97 + relay/common/task_billing.go | 22 + relay/common/task_provider.go | 77 + relay/common/usage.go | 69 + relay/constant/errors.go | 118 + relay/constant/provider.go | 114 + relay/constant/relay_format.go | 12 + relay/constant/relay_mode.go | 163 ++ relay/constant/task.go | 101 + relay/dto/audio.go | 53 + relay/dto/claude.go | 143 + relay/dto/gemini.go | 238 ++ relay/dto/openai.go | 210 ++ relay/dto/openai_responses.go | 292 +++ relay/dto/realtime.go | 98 + relay/dto/rerank.go | 45 + relay/dto/task.go | 42 + relay/dto/usage.go | 145 ++ relay/handler/audio_handler.go | 23 + relay/handler/chat_handler.go | 189 ++ relay/handler/claude_handler.go | 65 + relay/handler/gemini_handler.go | 112 + relay/handler/moderation_handler.go | 18 + relay/handler/passthrough.go | 50 + relay/handler/passthrough_test.go | 161 ++ relay/handler/realtime_handler.go | 189 ++ relay/handler/relay_handler.go | 718 +++++ relay/handler/rerank_handler.go | 13 + relay/handler/task_handler.go | 313 +++ relay/helper/sanitize.go | 86 + relay/helper/sanitize_test.go | 92 + relay/helper/status_code.go | 53 + relay/helper/status_code_test.go | 89 + relay/helper/stream.go | 106 + relay/helper/stream_result.go | 51 + relay/helper/stream_scanner.go | 232 ++ relay/helper/system_prompt.go | 227 ++ relay/helper/system_prompt_test.go | 209 ++ relay/helper/thinking.go | 58 + relay/helper/thinking_test.go | 43 + relay/override/header.go | 116 + relay/override/operations.go | 250 ++ relay/override/override.go | 613 +++++ relay/override/override_test.go | 552 ++++ relay/scheduler/affinity.go | 121 + relay/scheduler/affinity_test.go | 171 ++ relay/scheduler/retry.go | 30 + relay/scheduler/scheduler.go | 166 ++ relay/scheduler/scheduler_test.go | 148 ++ relay/taskchannel/kling/adaptor.go | 226 ++ relay/taskchannel/kling/jwt.go | 32 + relay/taskchannel/midjourney/adaptor.go | 231 ++ relay/taskchannel/registry.go | 48 + relay/taskchannel/sora/adaptor.go | 184 ++ relay/taskchannel/suno/adaptor.go | 193 ++ relay/taskchannel/volcengine/adaptor.go | 384 +++ relay/taskchannel/volcengine/constants.go | 24 + 1136 files changed, 96170 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Makefile create mode 100644 api/admin/admin.go create mode 100644 api/admin/v1/admin_user.go create mode 100644 api/admin/v1/audit.go create mode 100644 api/admin/v1/auth.go create mode 100644 api/admin/v1/changelog.go create mode 100644 api/admin/v1/channel.go create mode 100644 api/admin/v1/channel_oauth.go create mode 100644 api/admin/v1/channel_testing_api.go create mode 100644 api/admin/v1/cron_job.go create mode 100644 api/admin/v1/dashboard.go create mode 100644 api/admin/v1/data_governance.go create mode 100644 api/admin/v1/error_log.go create mode 100644 api/admin/v1/feedback.go create mode 100644 api/admin/v1/help_center.go create mode 100644 api/admin/v1/model.go create mode 100644 api/admin/v1/monitor.go create mode 100644 api/admin/v1/notification.go create mode 100644 api/admin/v1/order_admin.go create mode 100644 api/admin/v1/payment.go create mode 100644 api/admin/v1/permission.go create mode 100644 api/admin/v1/plan.go create mode 100644 api/admin/v1/plugin.go create mode 100644 api/admin/v1/promo.go create mode 100644 api/admin/v1/redemption.go create mode 100644 api/admin/v1/security.go create mode 100644 api/admin/v1/settings.go create mode 100644 api/admin/v1/task_management.go create mode 100644 api/admin/v1/tenant.go create mode 100644 api/admin/v1/tenant_model.go create mode 100644 api/admin/v1/ticket.go create mode 100644 api/admin/v1/usage_log_cleanup.go create mode 100644 api/captcha/captcha.go create mode 100644 api/captcha/v1/captcha.go create mode 100644 api/docs/docs.go create mode 100644 api/docs/v1/openapi.go create mode 100644 api/open/open.go create mode 100644 api/open/v1/open_api.go create mode 100644 api/payment/v1/.gitkeep create mode 100644 api/settings/settings.go create mode 100644 api/settings/settings.if.go create mode 100644 api/settings/v1/settings.go create mode 100644 api/tenant/tenant.go create mode 100644 api/tenant/v1/api_key.go create mode 100644 api/tenant/v1/audit_tenant.go create mode 100644 api/tenant/v1/auth.go create mode 100644 api/tenant/v1/billing_tenant.go create mode 100644 api/tenant/v1/closure.go create mode 100644 api/tenant/v1/dashboard.go create mode 100644 api/tenant/v1/email.go create mode 100644 api/tenant/v1/feedback.go create mode 100644 api/tenant/v1/help_center.go create mode 100644 api/tenant/v1/member.go create mode 100644 api/tenant/v1/member_import_tenant.go create mode 100644 api/tenant/v1/member_quota_tenant.go create mode 100644 api/tenant/v1/member_scope_tenant.go create mode 100644 api/tenant/v1/model_comparison.go create mode 100644 api/tenant/v1/models.go create mode 100644 api/tenant/v1/notification_tenant.go create mode 100644 api/tenant/v1/oauth.go create mode 100644 api/tenant/v1/open_platform.go create mode 100644 api/tenant/v1/order_tenant.go create mode 100644 api/tenant/v1/organization.go create mode 100644 api/tenant/v1/personal_dashboard.go create mode 100644 api/tenant/v1/plan_tenant.go create mode 100644 api/tenant/v1/playground.go create mode 100644 api/tenant/v1/plugin.go create mode 100644 api/tenant/v1/project_tenant.go create mode 100644 api/tenant/v1/promo_tenant.go create mode 100644 api/tenant/v1/security.go create mode 100644 api/tenant/v1/ticket_tenant.go create mode 100644 api/tenant/v1/wallet_tenant.go create mode 100644 api/v1/.gitkeep create mode 100644 go.mod create mode 100644 go.sum create mode 100644 hack/config.example.yaml create mode 100644 hack/hack-cli.mk create mode 100644 hack/hack.mk create mode 100644 internal/cmd/cmd.go create mode 100644 internal/cmd/cmd_reset_pwd.go create mode 100644 internal/consts/consts.go create mode 100644 internal/controller/admin/admin.go create mode 100644 internal/controller/admin/admin_new.go create mode 100644 internal/controller/admin/admin_v1_admin_2_fa_confirm.go create mode 100644 internal/controller/admin/admin_v1_admin_2_fa_disable.go create mode 100644 internal/controller/admin/admin_v1_admin_2_fa_enable.go create mode 100644 internal/controller/admin/admin_v1_admin_2_fa_regenerate_backup_codes.go create mode 100644 internal/controller/admin/admin_v1_admin_2_fa_setup.go create mode 100644 internal/controller/admin/admin_v1_admin_2_fa_verify.go create mode 100644 internal/controller/admin/admin_v1_admin_all_permissions.go create mode 100644 internal/controller/admin/admin_v1_admin_billing_record_export.go create mode 100644 internal/controller/admin/admin_v1_admin_billing_record_list.go create mode 100644 internal/controller/admin/admin_v1_admin_change_password.go create mode 100644 internal/controller/admin/admin_v1_admin_dashboard.go create mode 100644 internal/controller/admin/admin_v1_admin_dashboard_channel_health.go create mode 100644 internal/controller/admin/admin_v1_admin_dashboard_model_distribution.go create mode 100644 internal/controller/admin/admin_v1_admin_dashboard_recent_alerts.go create mode 100644 internal/controller/admin/admin_v1_admin_dashboard_top_tenants.go create mode 100644 internal/controller/admin/admin_v1_admin_dashboard_trends.go create mode 100644 internal/controller/admin/admin_v1_admin_data_scope_update.go create mode 100644 internal/controller/admin/admin_v1_admin_force_logout.go create mode 100644 internal/controller/admin/admin_v1_admin_login.go create mode 100644 internal/controller/admin/admin_v1_admin_login_history.go create mode 100644 internal/controller/admin/admin_v1_admin_logout.go create mode 100644 internal/controller/admin/admin_v1_admin_member_create.go create mode 100644 internal/controller/admin/admin_v1_admin_member_disable.go create mode 100644 internal/controller/admin/admin_v1_admin_member_enable.go create mode 100644 internal/controller/admin/admin_v1_admin_member_export.go create mode 100644 internal/controller/admin/admin_v1_admin_member_list.go create mode 100644 internal/controller/admin/admin_v1_admin_member_reset_password.go create mode 100644 internal/controller/admin/admin_v1_admin_permission_list.go create mode 100644 internal/controller/admin/admin_v1_admin_permission_update.go create mode 100644 internal/controller/admin/admin_v1_admin_refresh.go create mode 100644 internal/controller/admin/admin_v1_admin_revoke_session.go create mode 100644 internal/controller/admin/admin_v1_admin_session_list.go create mode 100644 internal/controller/admin/admin_v1_admin_settings_categories.go create mode 100644 internal/controller/admin/admin_v1_admin_settings_get.go create mode 100644 internal/controller/admin/admin_v1_admin_settings_update.go create mode 100644 internal/controller/admin/admin_v1_admin_usage_log_export.go create mode 100644 internal/controller/admin/admin_v1_admin_usage_log_list.go create mode 100644 internal/controller/admin/admin_v1_admin_user_create.go create mode 100644 internal/controller/admin/admin_v1_admin_user_delete.go create mode 100644 internal/controller/admin/admin_v1_admin_user_export.go create mode 100644 internal/controller/admin/admin_v1_admin_user_list.go create mode 100644 internal/controller/admin/admin_v1_admin_user_reset_password.go create mode 100644 internal/controller/admin/admin_v1_admin_user_update.go create mode 100644 internal/controller/admin/admin_v1_admin_user_update_status.go create mode 100644 internal/controller/admin/admin_v1_admin_wallet_adjust.go create mode 100644 internal/controller/admin/admin_v1_admin_wallet_info.go create mode 100644 internal/controller/admin/admin_v1_admin_wallet_list.go create mode 100644 internal/controller/admin/admin_v1_admin_wallet_set_warning_threshold.go create mode 100644 internal/controller/admin/admin_v1_admin_wallet_transaction_list.go create mode 100644 internal/controller/admin/admin_v1_alert_event_acknowledge.go create mode 100644 internal/controller/admin/admin_v1_alert_event_list.go create mode 100644 internal/controller/admin/admin_v1_alert_event_resolve.go create mode 100644 internal/controller/admin/admin_v1_alert_options.go create mode 100644 internal/controller/admin/admin_v1_alert_rule_create.go create mode 100644 internal/controller/admin/admin_v1_alert_rule_delete.go create mode 100644 internal/controller/admin/admin_v1_alert_rule_list.go create mode 100644 internal/controller/admin/admin_v1_alert_rule_toggle.go create mode 100644 internal/controller/admin/admin_v1_alert_rule_update.go create mode 100644 internal/controller/admin/admin_v1_alert_test_endpoint.go create mode 100644 internal/controller/admin/admin_v1_announcement_archive.go create mode 100644 internal/controller/admin/admin_v1_announcement_create.go create mode 100644 internal/controller/admin/admin_v1_announcement_list.go create mode 100644 internal/controller/admin/admin_v1_announcement_publish.go create mode 100644 internal/controller/admin/admin_v1_announcement_update.go create mode 100644 internal/controller/admin/admin_v1_audit_config_get.go create mode 100644 internal/controller/admin/admin_v1_audit_config_update.go create mode 100644 internal/controller/admin/admin_v1_changelog_create.go create mode 100644 internal/controller/admin/admin_v1_changelog_delete.go create mode 100644 internal/controller/admin/admin_v1_changelog_list.go create mode 100644 internal/controller/admin/admin_v1_changelog_publish.go create mode 100644 internal/controller/admin/admin_v1_changelog_update.go create mode 100644 internal/controller/admin/admin_v1_channel_abilities_get.go create mode 100644 internal/controller/admin/admin_v1_channel_ability_batch.go create mode 100644 internal/controller/admin/admin_v1_channel_clone.go create mode 100644 internal/controller/admin/admin_v1_channel_create.go create mode 100644 internal/controller/admin/admin_v1_channel_delete.go create mode 100644 internal/controller/admin/admin_v1_channel_detail.go create mode 100644 internal/controller/admin/admin_v1_channel_export.go create mode 100644 internal/controller/admin/admin_v1_channel_health_trend.go create mode 100644 internal/controller/admin/admin_v1_channel_key_create.go create mode 100644 internal/controller/admin/admin_v1_channel_key_delete.go create mode 100644 internal/controller/admin/admin_v1_channel_key_list.go create mode 100644 internal/controller/admin/admin_v1_channel_list.go create mode 100644 internal/controller/admin/admin_v1_channel_o_auth_auth_url.go create mode 100644 internal/controller/admin/admin_v1_channel_o_auth_exchange.go create mode 100644 internal/controller/admin/admin_v1_channel_o_auth_refresh.go create mode 100644 internal/controller/admin/admin_v1_channel_test_handler.go create mode 100644 internal/controller/admin/admin_v1_channel_update.go create mode 100644 internal/controller/admin/admin_v1_cron_job_executions.go create mode 100644 internal/controller/admin/admin_v1_cron_job_list.go create mode 100644 internal/controller/admin/admin_v1_cron_job_trigger.go create mode 100644 internal/controller/admin/admin_v1_data_governance_cleanup.go create mode 100644 internal/controller/admin/admin_v1_data_governance_deletion.go create mode 100644 internal/controller/admin/admin_v1_data_governance_export.go create mode 100644 internal/controller/admin/admin_v1_data_governance_settings_get.go create mode 100644 internal/controller/admin/admin_v1_data_governance_settings_update.go create mode 100644 internal/controller/admin/admin_v1_error_log_batch_resolve.go create mode 100644 internal/controller/admin/admin_v1_error_log_detail.go create mode 100644 internal/controller/admin/admin_v1_error_log_list.go create mode 100644 internal/controller/admin/admin_v1_error_log_resolve.go create mode 100644 internal/controller/admin/admin_v1_error_log_stats.go create mode 100644 internal/controller/admin/admin_v1_feedback_list_all.go create mode 100644 internal/controller/admin/admin_v1_feedback_reply.go create mode 100644 internal/controller/admin/admin_v1_feedback_stats.go create mode 100644 internal/controller/admin/admin_v1_feedback_update_status.go create mode 100644 internal/controller/admin/admin_v1_help_article_create.go create mode 100644 internal/controller/admin/admin_v1_help_article_delete.go create mode 100644 internal/controller/admin/admin_v1_help_article_get.go create mode 100644 internal/controller/admin/admin_v1_help_article_list.go create mode 100644 internal/controller/admin/admin_v1_help_article_update.go create mode 100644 internal/controller/admin/admin_v1_help_category_create.go create mode 100644 internal/controller/admin/admin_v1_help_category_delete.go create mode 100644 internal/controller/admin/admin_v1_help_category_list.go create mode 100644 internal/controller/admin/admin_v1_help_category_update.go create mode 100644 internal/controller/admin/admin_v1_message_broadcast.go create mode 100644 internal/controller/admin/admin_v1_message_list.go create mode 100644 internal/controller/admin/admin_v1_message_send.go create mode 100644 internal/controller/admin/admin_v1_model_create.go create mode 100644 internal/controller/admin/admin_v1_model_delete.go create mode 100644 internal/controller/admin/admin_v1_model_export.go create mode 100644 internal/controller/admin/admin_v1_model_fetch_official_info.go create mode 100644 internal/controller/admin/admin_v1_model_list.go create mode 100644 internal/controller/admin/admin_v1_model_update.go create mode 100644 internal/controller/admin/admin_v1_monitor_dashboard.go create mode 100644 internal/controller/admin/admin_v1_monitor_db_pool.go create mode 100644 internal/controller/admin/admin_v1_monitor_latency.go create mode 100644 internal/controller/admin/admin_v1_monitor_realtime.go create mode 100644 internal/controller/admin/admin_v1_monitor_redis_pool.go create mode 100644 internal/controller/admin/admin_v1_monitor_system.go create mode 100644 internal/controller/admin/admin_v1_monitor_traffic.go create mode 100644 internal/controller/admin/admin_v1_operation_log_export.go create mode 100644 internal/controller/admin/admin_v1_operation_log_list.go create mode 100644 internal/controller/admin/admin_v1_order_complete.go create mode 100644 internal/controller/admin/admin_v1_order_detail.go create mode 100644 internal/controller/admin/admin_v1_order_export.go create mode 100644 internal/controller/admin/admin_v1_order_list.go create mode 100644 internal/controller/admin/admin_v1_order_refund.go create mode 100644 internal/controller/admin/admin_v1_payment_channel_create.go create mode 100644 internal/controller/admin/admin_v1_payment_channel_delete.go create mode 100644 internal/controller/admin/admin_v1_payment_channel_detail.go create mode 100644 internal/controller/admin/admin_v1_payment_channel_list.go create mode 100644 internal/controller/admin/admin_v1_payment_channel_toggle.go create mode 100644 internal/controller/admin/admin_v1_payment_channel_update.go create mode 100644 internal/controller/admin/admin_v1_payment_settings_get.go create mode 100644 internal/controller/admin/admin_v1_payment_settings_update.go create mode 100644 internal/controller/admin/admin_v1_plan_archive.go create mode 100644 internal/controller/admin/admin_v1_plan_create.go create mode 100644 internal/controller/admin/admin_v1_plan_detail.go create mode 100644 internal/controller/admin/admin_v1_plan_export.go create mode 100644 internal/controller/admin/admin_v1_plan_list.go create mode 100644 internal/controller/admin/admin_v1_plan_toggle_recommend.go create mode 100644 internal/controller/admin/admin_v1_plan_update.go create mode 100644 internal/controller/admin/admin_v1_plugin_config_schema.go create mode 100644 internal/controller/admin/admin_v1_plugin_config_update.go create mode 100644 internal/controller/admin/admin_v1_plugin_detail.go create mode 100644 internal/controller/admin/admin_v1_plugin_disable.go create mode 100644 internal/controller/admin/admin_v1_plugin_enable.go create mode 100644 internal/controller/admin/admin_v1_plugin_install.go create mode 100644 internal/controller/admin/admin_v1_plugin_list.go create mode 100644 internal/controller/admin/admin_v1_plugin_uninstall.go create mode 100644 internal/controller/admin/admin_v1_plugin_upgrade.go create mode 100644 internal/controller/admin/admin_v1_pricing_fetch_official.go create mode 100644 internal/controller/admin/admin_v1_pricing_get.go create mode 100644 internal/controller/admin/admin_v1_pricing_list.go create mode 100644 internal/controller/admin/admin_v1_pricing_set.go create mode 100644 internal/controller/admin/admin_v1_promo_code_create.go create mode 100644 internal/controller/admin/admin_v1_promo_code_export.go create mode 100644 internal/controller/admin/admin_v1_promo_code_list.go create mode 100644 internal/controller/admin/admin_v1_promo_code_update.go create mode 100644 internal/controller/admin/admin_v1_promo_code_usages.go create mode 100644 internal/controller/admin/admin_v1_provider_default_url.go create mode 100644 internal/controller/admin/admin_v1_redemption_create.go create mode 100644 internal/controller/admin/admin_v1_redemption_disable.go create mode 100644 internal/controller/admin/admin_v1_redemption_export.go create mode 100644 internal/controller/admin/admin_v1_redemption_list.go create mode 100644 internal/controller/admin/admin_v1_redemption_usages.go create mode 100644 internal/controller/admin/admin_v1_request_audit_log_detail.go create mode 100644 internal/controller/admin/admin_v1_request_audit_log_list.go create mode 100644 internal/controller/admin/admin_v1_sensitive_log_list.go create mode 100644 internal/controller/admin/admin_v1_task_cancel.go create mode 100644 internal/controller/admin/admin_v1_task_detail.go create mode 100644 internal/controller/admin/admin_v1_task_list.go create mode 100644 internal/controller/admin/admin_v1_task_retry.go create mode 100644 internal/controller/admin/admin_v1_template_get.go create mode 100644 internal/controller/admin/admin_v1_template_list.go create mode 100644 internal/controller/admin/admin_v1_template_test_endpoint.go create mode 100644 internal/controller/admin/admin_v1_template_update.go create mode 100644 internal/controller/admin/admin_v1_tenant_channel_scope_update.go create mode 100644 internal/controller/admin/admin_v1_tenant_create.go create mode 100644 internal/controller/admin/admin_v1_tenant_export.go create mode 100644 internal/controller/admin/admin_v1_tenant_get.go create mode 100644 internal/controller/admin/admin_v1_tenant_list.go create mode 100644 internal/controller/admin/admin_v1_tenant_model_batch_assign.go create mode 100644 internal/controller/admin/admin_v1_tenant_model_delete.go create mode 100644 internal/controller/admin/admin_v1_tenant_model_list.go create mode 100644 internal/controller/admin/admin_v1_tenant_model_update.go create mode 100644 internal/controller/admin/admin_v1_tenant_select.go create mode 100644 internal/controller/admin/admin_v1_tenant_update.go create mode 100644 internal/controller/admin/admin_v1_tenant_update_status.go create mode 100644 internal/controller/admin/admin_v1_ticket_assign.go create mode 100644 internal/controller/admin/admin_v1_ticket_get.go create mode 100644 internal/controller/admin/admin_v1_ticket_list.go create mode 100644 internal/controller/admin/admin_v1_ticket_reply.go create mode 100644 internal/controller/admin/admin_v1_ticket_status_update.go create mode 100644 internal/controller/admin/admin_v1_usage_log_cleanup_cancel.go create mode 100644 internal/controller/admin/admin_v1_usage_log_cleanup_create.go create mode 100644 internal/controller/admin/admin_v1_usage_log_cleanup_list.go create mode 100644 internal/controller/captcha/captcha.go create mode 100644 internal/controller/captcha/captcha_new.go create mode 100644 internal/controller/captcha/captcha_v1_captcha_generate.go create mode 100644 internal/controller/captcha/captcha_v1_captcha_verify.go create mode 100644 internal/controller/docs/docs_new.go create mode 100644 internal/controller/docs/docs_v1_open_api_spec.go create mode 100644 internal/controller/open/open.go create mode 100644 internal/controller/open/open_new.go create mode 100644 internal/controller/open/open_v1_open_billing_query.go create mode 100644 internal/controller/open/open_v1_open_key_create.go create mode 100644 internal/controller/open/open_v1_open_key_delete.go create mode 100644 internal/controller/open/open_v1_open_key_list.go create mode 100644 internal/controller/open/open_v1_open_member_create.go create mode 100644 internal/controller/open/open_v1_open_member_delete.go create mode 100644 internal/controller/open/open_v1_open_member_list.go create mode 100644 internal/controller/open/open_v1_open_member_models.go create mode 100644 internal/controller/open/open_v1_open_member_models_update.go create mode 100644 internal/controller/open/open_v1_open_member_quota.go create mode 100644 internal/controller/open/open_v1_open_member_quota_update.go create mode 100644 internal/controller/open/open_v1_open_member_update.go create mode 100644 internal/controller/open/open_v1_open_usage_query.go create mode 100644 internal/controller/settings/settings.go create mode 100644 internal/controller/settings/settings_new.go create mode 100644 internal/controller/settings/settings_v1_public_announcements.go create mode 100644 internal/controller/settings/settings_v1_public_settings_get.go create mode 100644 internal/controller/tenant/tenant.go create mode 100644 internal/controller/tenant/tenant_new.go create mode 100644 internal/controller/tenant/tenant_v1_feedback_create.go create mode 100644 internal/controller/tenant/tenant_v1_feedback_get.go create mode 100644 internal/controller/tenant/tenant_v1_feedback_list.go create mode 100644 internal/controller/tenant/tenant_v1_help_public_article_get.go create mode 100644 internal/controller/tenant/tenant_v1_help_public_article_list.go create mode 100644 internal/controller/tenant/tenant_v1_help_public_category_list.go create mode 100644 internal/controller/tenant/tenant_v1_help_public_search.go create mode 100644 internal/controller/tenant/tenant_v1_model_comparison.go create mode 100644 internal/controller/tenant/tenant_v1_o_auth_authorize.go create mode 100644 internal/controller/tenant/tenant_v1_o_auth_callback.go create mode 100644 internal/controller/tenant/tenant_v1_o_auth_link.go create mode 100644 internal/controller/tenant/tenant_v1_o_auth_list_providers.go create mode 100644 internal/controller/tenant/tenant_v1_o_auth_unlink.go create mode 100644 internal/controller/tenant/tenant_v1_open_app_create.go create mode 100644 internal/controller/tenant/tenant_v1_open_app_delete.go create mode 100644 internal/controller/tenant/tenant_v1_open_app_list.go create mode 100644 internal/controller/tenant/tenant_v1_open_app_reset_secret.go create mode 100644 internal/controller/tenant/tenant_v1_open_app_toggle_status.go create mode 100644 internal/controller/tenant/tenant_v1_open_app_update.go create mode 100644 internal/controller/tenant/tenant_v1_personal_api_key_usage.go create mode 100644 internal/controller/tenant/tenant_v1_personal_dashboard.go create mode 100644 internal/controller/tenant/tenant_v1_personal_model_dist.go create mode 100644 internal/controller/tenant/tenant_v1_personal_token_trends.go create mode 100644 internal/controller/tenant/tenant_v1_playground_audio_tts.go create mode 100644 internal/controller/tenant/tenant_v1_playground_chat.go create mode 100644 internal/controller/tenant/tenant_v1_playground_embedding.go create mode 100644 internal/controller/tenant/tenant_v1_playground_image.go create mode 100644 internal/controller/tenant/tenant_v1_playground_rerank.go create mode 100644 internal/controller/tenant/tenant_v1_sandbox_chat.go create mode 100644 internal/controller/tenant/tenant_v1_sandbox_quota.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_2_fa_confirm.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_2_fa_disable.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_2_fa_enable.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_2_fa_regenerate_backup_codes.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_2_fa_setup.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_2_fa_verify.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_announcements.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_api_key_create.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_api_key_delete.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_api_key_export.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_api_key_list.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_api_key_update_scopes.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_audit_config_get.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_audit_config_update.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_audit_logs.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_available_models.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_balance_prediction.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_billing_records.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_budget_alerts.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_cancel_closure.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_change_email.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_change_password.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_dashboard.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_import_record_get.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_import_records.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_invitation_list.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_invitation_revoke.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_invite_info.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_ip_whitelist_get.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_ip_whitelist_update.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_login.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_login_history.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_logout.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_mark_all_read.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_mark_read.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_member_api_keys.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_member_create.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_member_export.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_member_get.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_member_import.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_member_invite.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_member_join.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_member_list.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_member_model_scopes.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_member_model_scopes_set.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_member_quota.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_member_quota_set.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_member_remove.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_member_reset_password.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_member_update_role.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_member_usage.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_member_usage_ranking.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_model_distribution.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_notification_preferences_get.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_notification_preferences_update.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_notifications.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_notifications_export.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_order_cancel.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_order_create.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_order_detail.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_order_export.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_order_list.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_order_pay.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_org_info.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_org_transfer.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_org_update.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_payment_info.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_plan_cancel_auto_renew.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_plan_current.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_plan_list.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_plugin_config_update.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_plugin_detail.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_plugin_disable.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_plugin_enable.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_plugin_list.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_profile.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_profile_update.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_project_api_key_create.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_project_api_key_delete.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_project_api_key_list.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_project_archive.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_project_create.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_project_get.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_project_list.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_project_unarchive.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_project_update.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_project_usage_logs.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_project_usage_stats.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_redeem_code.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_redemption_usages.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_refresh.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_register.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_request_audit_log_detail.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_request_audit_logs.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_request_closure.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_reset_password.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_revoke_session.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_send_code.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_session_list.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_ticket_close.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_ticket_create.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_ticket_export.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_ticket_get.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_ticket_list.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_ticket_reopen.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_ticket_reply.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_token_trends.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_unread_count.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_usage_logs.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_usage_logs_export.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_validate_promo_code.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_wallet.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_wallet_transactions.go create mode 100644 internal/controller/tenant/tenant_v1_tenant_wallet_transactions_export.go create mode 100644 internal/controller/tenant/tenant_v1_webhook_config_create.go create mode 100644 internal/controller/tenant/tenant_v1_webhook_config_delete.go create mode 100644 internal/controller/tenant/tenant_v1_webhook_config_list.go create mode 100644 internal/controller/tenant/tenant_v1_webhook_config_update.go create mode 100644 internal/controller/tenant/tenant_v1_webhook_delivery_logs.go create mode 100644 internal/controller/tenant/tenant_v1_webhook_retry.go create mode 100644 internal/dao/api_key_model_scopes.go create mode 100644 internal/dao/api_keys.go create mode 100644 internal/dao/aud_login_history.go create mode 100644 internal/dao/aud_operation_logs.go create mode 100644 internal/dao/aud_request_logs.go create mode 100644 internal/dao/aud_sensitive_access_logs.go create mode 100644 internal/dao/bil_daily_revenue_summary.go create mode 100644 internal/dao/bil_daily_usage_summary.go create mode 100644 internal/dao/bil_monthly_revenue_summary.go create mode 100644 internal/dao/bil_monthly_usage_summary.go create mode 100644 internal/dao/bil_records.go create mode 100644 internal/dao/bil_transactions.go create mode 100644 internal/dao/bil_usage_logs.go create mode 100644 internal/dao/bil_wallets.go create mode 100644 internal/dao/chn_abilities.go create mode 100644 internal/dao/chn_channel_affinities.go create mode 100644 internal/dao/chn_channel_keys.go create mode 100644 internal/dao/chn_channels.go create mode 100644 internal/dao/chn_health_scores.go create mode 100644 internal/dao/chn_health_snapshots.go create mode 100644 internal/dao/clg_changelogs.go create mode 100644 internal/dao/fil_files.go create mode 100644 internal/dao/goose_db_version.go create mode 100644 internal/dao/internal/api_key_model_scopes.go create mode 100644 internal/dao/internal/api_keys.go create mode 100644 internal/dao/internal/aud_login_history.go create mode 100644 internal/dao/internal/aud_operation_logs.go create mode 100644 internal/dao/internal/aud_request_logs.go create mode 100644 internal/dao/internal/aud_sensitive_access_logs.go create mode 100644 internal/dao/internal/bil_daily_revenue_summary.go create mode 100644 internal/dao/internal/bil_daily_usage_summary.go create mode 100644 internal/dao/internal/bil_monthly_revenue_summary.go create mode 100644 internal/dao/internal/bil_monthly_usage_summary.go create mode 100644 internal/dao/internal/bil_records.go create mode 100644 internal/dao/internal/bil_transactions.go create mode 100644 internal/dao/internal/bil_usage_logs.go create mode 100644 internal/dao/internal/bil_wallets.go create mode 100644 internal/dao/internal/chn_abilities.go create mode 100644 internal/dao/internal/chn_channel_affinities.go create mode 100644 internal/dao/internal/chn_channel_keys.go create mode 100644 internal/dao/internal/chn_channels.go create mode 100644 internal/dao/internal/chn_health_scores.go create mode 100644 internal/dao/internal/chn_health_snapshots.go create mode 100644 internal/dao/internal/clg_changelogs.go create mode 100644 internal/dao/internal/fil_files.go create mode 100644 internal/dao/internal/goose_db_version.go create mode 100644 internal/dao/internal/mdl_models.go create mode 100644 internal/dao/internal/mdl_pricing.go create mode 100644 internal/dao/internal/mdl_tenant_models.go create mode 100644 internal/dao/internal/ntf_announcements.go create mode 100644 internal/dao/internal/ntf_messages.go create mode 100644 internal/dao/internal/ntf_preferences.go create mode 100644 internal/dao/internal/ntf_read_status.go create mode 100644 internal/dao/internal/ntf_send_log.go create mode 100644 internal/dao/internal/ntf_templates.go create mode 100644 internal/dao/internal/opn_apps.go create mode 100644 internal/dao/internal/opn_webhook_configs.go create mode 100644 internal/dao/internal/opn_webhook_delivery_logs.go create mode 100644 internal/dao/internal/opn_webhook_events.go create mode 100644 internal/dao/internal/ops_alert_events.go create mode 100644 internal/dao/internal/ops_alert_rules.go create mode 100644 internal/dao/internal/ops_system_metrics.go create mode 100644 internal/dao/internal/ord_orders.go create mode 100644 internal/dao/internal/ord_payment_channels.go create mode 100644 internal/dao/internal/ord_promo_code_usages.go create mode 100644 internal/dao/internal/ord_promo_codes.go create mode 100644 internal/dao/internal/ord_redemption_usages.go create mode 100644 internal/dao/internal/ord_redemptions.go create mode 100644 internal/dao/internal/ord_refunds.go create mode 100644 internal/dao/internal/pln_feature_flags.go create mode 100644 internal/dao/internal/pln_plans.go create mode 100644 internal/dao/internal/pln_tenant_plans.go create mode 100644 internal/dao/internal/spt_articles.go create mode 100644 internal/dao/internal/spt_attachments.go create mode 100644 internal/dao/internal/spt_categories.go create mode 100644 internal/dao/internal/spt_feedbacks.go create mode 100644 internal/dao/internal/spt_replies.go create mode 100644 internal/dao/internal/spt_tickets.go create mode 100644 internal/dao/internal/sys_admin_data_scopes.go create mode 100644 internal/dao/internal/sys_admin_role_perms.go create mode 100644 internal/dao/internal/sys_admin_users.go create mode 100644 internal/dao/internal/sys_cron_job_executions.go create mode 100644 internal/dao/internal/sys_email_verify_codes.go create mode 100644 internal/dao/internal/sys_error_logs.go create mode 100644 internal/dao/internal/sys_idempotency_records.go create mode 100644 internal/dao/internal/sys_options.go create mode 100644 internal/dao/internal/sys_sessions.go create mode 100644 internal/dao/internal/tnt_invitations.go create mode 100644 internal/dao/internal/tnt_member_imports.go create mode 100644 internal/dao/internal/tnt_member_model_scopes.go create mode 100644 internal/dao/internal/tnt_oauth_identities.go create mode 100644 internal/dao/internal/tnt_projects.go create mode 100644 internal/dao/internal/tnt_tenants.go create mode 100644 internal/dao/internal/tnt_users.go create mode 100644 internal/dao/internal/tsk_async_tasks.go create mode 100644 internal/dao/internal/tsk_task_logs.go create mode 100644 internal/dao/internal/tsk_tasks.go create mode 100644 internal/dao/mdl_models.go create mode 100644 internal/dao/mdl_pricing.go create mode 100644 internal/dao/mdl_tenant_models.go create mode 100644 internal/dao/ntf_announcements.go create mode 100644 internal/dao/ntf_messages.go create mode 100644 internal/dao/ntf_preferences.go create mode 100644 internal/dao/ntf_read_status.go create mode 100644 internal/dao/ntf_send_log.go create mode 100644 internal/dao/ntf_templates.go create mode 100644 internal/dao/opn_apps.go create mode 100644 internal/dao/opn_webhook_configs.go create mode 100644 internal/dao/opn_webhook_delivery_logs.go create mode 100644 internal/dao/opn_webhook_events.go create mode 100644 internal/dao/ops_alert_events.go create mode 100644 internal/dao/ops_alert_rules.go create mode 100644 internal/dao/ops_system_metrics.go create mode 100644 internal/dao/ord_orders.go create mode 100644 internal/dao/ord_payment_channels.go create mode 100644 internal/dao/ord_promo_code_usages.go create mode 100644 internal/dao/ord_promo_codes.go create mode 100644 internal/dao/ord_redemption_usages.go create mode 100644 internal/dao/ord_redemptions.go create mode 100644 internal/dao/ord_refunds.go create mode 100644 internal/dao/pln_feature_flags.go create mode 100644 internal/dao/pln_plans.go create mode 100644 internal/dao/pln_tenant_plans.go create mode 100644 internal/dao/spt_articles.go create mode 100644 internal/dao/spt_attachments.go create mode 100644 internal/dao/spt_categories.go create mode 100644 internal/dao/spt_feedbacks.go create mode 100644 internal/dao/spt_replies.go create mode 100644 internal/dao/spt_tickets.go create mode 100644 internal/dao/sys_admin_data_scopes.go create mode 100644 internal/dao/sys_admin_role_perms.go create mode 100644 internal/dao/sys_admin_users.go create mode 100644 internal/dao/sys_cron_job_executions.go create mode 100644 internal/dao/sys_email_verify_codes.go create mode 100644 internal/dao/sys_error_logs.go create mode 100644 internal/dao/sys_idempotency_records.go create mode 100644 internal/dao/sys_options.go create mode 100644 internal/dao/sys_sessions.go create mode 100644 internal/dao/tnt_invitations.go create mode 100644 internal/dao/tnt_member_imports.go create mode 100644 internal/dao/tnt_member_model_scopes.go create mode 100644 internal/dao/tnt_oauth_identities.go create mode 100644 internal/dao/tnt_projects.go create mode 100644 internal/dao/tnt_tenants.go create mode 100644 internal/dao/tnt_users.go create mode 100644 internal/dao/tsk_async_tasks.go create mode 100644 internal/dao/tsk_task_logs.go create mode 100644 internal/dao/tsk_tasks.go create mode 100644 internal/handler/public/payment.go create mode 100644 internal/handler/relay/relay.go create mode 100644 internal/handler/relay/response_capture.go create mode 100644 internal/handler/relay/task.go create mode 100644 internal/handler/setup/setup.go create mode 100644 internal/handler/ws/broker.go create mode 100644 internal/handler/ws/client.go create mode 100644 internal/handler/ws/handler.go create mode 100644 internal/handler/ws/hub.go create mode 100644 internal/handler/ws/message.go create mode 100644 internal/logic/admin/admin.go create mode 100644 internal/logic/admin/admin_user.go create mode 100644 internal/logic/admin/audit.go create mode 100644 internal/logic/admin/auth.go create mode 100644 internal/logic/admin/changelog.go create mode 100644 internal/logic/admin/channel.go create mode 100644 internal/logic/admin/channel_oauth.go create mode 100644 internal/logic/admin/channel_test_request.go create mode 100644 internal/logic/admin/channel_testing.go create mode 100644 internal/logic/admin/cron_job.go create mode 100644 internal/logic/admin/dashboard.go create mode 100644 internal/logic/admin/data_governance.go create mode 100644 internal/logic/admin/error_log.go create mode 100644 internal/logic/admin/feedback.go create mode 100644 internal/logic/admin/help_center.go create mode 100644 internal/logic/admin/member.go create mode 100644 internal/logic/admin/model.go create mode 100644 internal/logic/admin/notification.go create mode 100644 internal/logic/admin/order.go create mode 100644 internal/logic/admin/permission.go create mode 100644 internal/logic/admin/plan.go create mode 100644 internal/logic/admin/plugin.go create mode 100644 internal/logic/admin/promo_code.go create mode 100644 internal/logic/admin/redemption.go create mode 100644 internal/logic/admin/security.go create mode 100644 internal/logic/admin/settings.go create mode 100644 internal/logic/admin/setup.go create mode 100644 internal/logic/admin/task_management.go create mode 100644 internal/logic/admin/tenant.go create mode 100644 internal/logic/admin/tenant_model.go create mode 100644 internal/logic/admin/ticket.go create mode 100644 internal/logic/admin/usage_log_cleanup.go create mode 100644 internal/logic/billing/alert.go create mode 100644 internal/logic/billing/currency.go create mode 100644 internal/logic/billing/member_quota.go create mode 100644 internal/logic/billing/pricing.go create mode 100644 internal/logic/billing/pricing_test.go create mode 100644 internal/logic/billing/provider.go create mode 100644 internal/logic/billing/ratelimit.go create mode 100644 internal/logic/billing/ratelimit_test.go create mode 100644 internal/logic/billing/reconciliation.go create mode 100644 internal/logic/billing/scope.go create mode 100644 internal/logic/billing/scope_test.go create mode 100644 internal/logic/billing/settlement.go create mode 100644 internal/logic/billing/snapshot.go create mode 100644 internal/logic/billing/summary.go create mode 100644 internal/logic/billing/task_billing.go create mode 100644 internal/logic/billing/wallet.go create mode 100644 internal/logic/common/audit.go create mode 100644 internal/logic/common/batch_writer.go create mode 100644 internal/logic/common/cache.go create mode 100644 internal/logic/common/captcha.go create mode 100644 internal/logic/common/config.go create mode 100644 internal/logic/common/content_filter.go create mode 100644 internal/logic/common/cron.go create mode 100644 internal/logic/common/email.go create mode 100644 internal/logic/common/errors.go create mode 100644 internal/logic/common/events.go create mode 100644 internal/logic/common/feature_flag.go create mode 100644 internal/logic/common/help_center.go create mode 100644 internal/logic/common/jwt.go create mode 100644 internal/logic/common/notification.go create mode 100644 internal/logic/common/oauth/claude.go create mode 100644 internal/logic/common/oauth/client.go create mode 100644 internal/logic/common/oauth/gemini.go create mode 100644 internal/logic/common/oauth/openai.go create mode 100644 internal/logic/common/oauth/pkce.go create mode 100644 internal/logic/common/oauth/session.go create mode 100644 internal/logic/common/oauth/token_types.go create mode 100644 internal/logic/common/pagination.go create mode 100644 internal/logic/common/partition.go create mode 100644 internal/logic/common/pricing_source.go create mode 100644 internal/logic/common/security.go create mode 100644 internal/logic/common/session.go create mode 100644 internal/logic/common/settings_registry.go create mode 100644 internal/logic/common/storage.go create mode 100644 internal/logic/common/storage_cos.go create mode 100644 internal/logic/common/storage_factory.go create mode 100644 internal/logic/common/storage_oss.go create mode 100644 internal/logic/common/storage_s3.go create mode 100644 internal/logic/common/usage_log_writer.go create mode 100644 internal/logic/common/validation.go create mode 100644 internal/logic/common/verify_code.go create mode 100644 internal/logic/docs/openapi.go create mode 100644 internal/logic/logic.go create mode 100644 internal/logic/monitor/alert_engine.go create mode 100644 internal/logic/monitor/alert_event.go create mode 100644 internal/logic/monitor/alert_notify.go create mode 100644 internal/logic/monitor/alert_rule.go create mode 100644 internal/logic/monitor/api_metrics.go create mode 100644 internal/logic/monitor/collector.go create mode 100644 internal/logic/monitor/db_monitor.go create mode 100644 internal/logic/monitor/metrics_api.go create mode 100644 internal/logic/monitor/monitor.go create mode 100644 internal/logic/monitor/redis_monitor.go create mode 100644 internal/logic/monitor/request_tracker.go create mode 100644 internal/logic/monitor/ws_push.go create mode 100644 internal/logic/open/open_api.go create mode 100644 internal/logic/payment/callback.go create mode 100644 internal/logic/payment/config.go create mode 100644 internal/logic/payment/epay.go create mode 100644 internal/logic/payment/fulfill.go create mode 100644 internal/logic/payment/mock.go create mode 100644 internal/logic/payment/provider.go create mode 100644 internal/logic/payment/settings.go create mode 100644 internal/logic/payment/stripe.go create mode 100644 internal/logic/relay/affinity.go create mode 100644 internal/logic/relay/apikey.go create mode 100644 internal/logic/relay/health.go create mode 100644 internal/logic/relay/provider.go create mode 100644 internal/logic/settings/settings.go create mode 100644 internal/logic/task/active_counter.go create mode 100644 internal/logic/task/async_polling.go create mode 100644 internal/logic/task/async_provider.go create mode 100644 internal/logic/task/auto_renew.go create mode 100644 internal/logic/task/channel_auto_test_cron.go create mode 100644 internal/logic/task/data_tasks.go create mode 100644 internal/logic/task/health_snapshot.go create mode 100644 internal/logic/task/lifecycle_cron.go create mode 100644 internal/logic/task/model_sunset.go create mode 100644 internal/logic/task/oauth_token_refresh.go create mode 100644 internal/logic/task/summary.go create mode 100644 internal/logic/task/task.go create mode 100644 internal/logic/task/usage_cleanup.go create mode 100644 internal/logic/tenant/api_key.go create mode 100644 internal/logic/tenant/audit_config.go create mode 100644 internal/logic/tenant/auth.go create mode 100644 internal/logic/tenant/billing.go create mode 100644 internal/logic/tenant/dashboard.go create mode 100644 internal/logic/tenant/email.go create mode 100644 internal/logic/tenant/feedback.go create mode 100644 internal/logic/tenant/help_center.go create mode 100644 internal/logic/tenant/invitation.go create mode 100644 internal/logic/tenant/lifecycle.go create mode 100644 internal/logic/tenant/member.go create mode 100644 internal/logic/tenant/member_import.go create mode 100644 internal/logic/tenant/member_model_scope.go create mode 100644 internal/logic/tenant/member_quota.go create mode 100644 internal/logic/tenant/model.go create mode 100644 internal/logic/tenant/model_comparison.go create mode 100644 internal/logic/tenant/notification.go create mode 100644 internal/logic/tenant/oauth.go create mode 100644 internal/logic/tenant/open_platform.go create mode 100644 internal/logic/tenant/order.go create mode 100644 internal/logic/tenant/organization.go create mode 100644 internal/logic/tenant/personal_dashboard.go create mode 100644 internal/logic/tenant/plan.go create mode 100644 internal/logic/tenant/playground.go create mode 100644 internal/logic/tenant/plugin.go create mode 100644 internal/logic/tenant/project.go create mode 100644 internal/logic/tenant/promo_code.go create mode 100644 internal/logic/tenant/redemption.go create mode 100644 internal/logic/tenant/security.go create mode 100644 internal/logic/tenant/tenant.go create mode 100644 internal/logic/tenant/ticket.go create mode 100644 internal/logic/tenant/webhook_dispatcher.go create mode 100644 internal/logic/tenant/webhook_worker.go create mode 100644 internal/middleware/admin_auth.go create mode 100644 internal/middleware/api_key_auth.go create mode 100644 internal/middleware/content_filter.go create mode 100644 internal/middleware/demo.go create mode 100644 internal/middleware/error_handler.go create mode 100644 internal/middleware/handler_response.go create mode 100644 internal/middleware/idempotency.go create mode 100644 internal/middleware/maintenance.go create mode 100644 internal/middleware/open_auth.go create mode 100644 internal/middleware/operation_log.go create mode 100644 internal/middleware/rbac.go create mode 100644 internal/middleware/recovery.go create mode 100644 internal/middleware/request_id.go create mode 100644 internal/middleware/tenant_auth.go create mode 100644 internal/model/do/api_key_model_scopes.go create mode 100644 internal/model/do/api_keys.go create mode 100644 internal/model/do/aud_login_history.go create mode 100644 internal/model/do/aud_operation_logs.go create mode 100644 internal/model/do/aud_request_logs.go create mode 100644 internal/model/do/aud_sensitive_access_logs.go create mode 100644 internal/model/do/bil_daily_revenue_summary.go create mode 100644 internal/model/do/bil_daily_usage_summary.go create mode 100644 internal/model/do/bil_monthly_revenue_summary.go create mode 100644 internal/model/do/bil_monthly_usage_summary.go create mode 100644 internal/model/do/bil_records.go create mode 100644 internal/model/do/bil_transactions.go create mode 100644 internal/model/do/bil_usage_logs.go create mode 100644 internal/model/do/bil_wallets.go create mode 100644 internal/model/do/chn_abilities.go create mode 100644 internal/model/do/chn_channel_affinities.go create mode 100644 internal/model/do/chn_channel_keys.go create mode 100644 internal/model/do/chn_channels.go create mode 100644 internal/model/do/chn_health_scores.go create mode 100644 internal/model/do/chn_health_snapshots.go create mode 100644 internal/model/do/clg_changelogs.go create mode 100644 internal/model/do/fil_files.go create mode 100644 internal/model/do/goose_db_version.go create mode 100644 internal/model/do/mdl_models.go create mode 100644 internal/model/do/mdl_pricing.go create mode 100644 internal/model/do/mdl_tenant_models.go create mode 100644 internal/model/do/ntf_announcements.go create mode 100644 internal/model/do/ntf_messages.go create mode 100644 internal/model/do/ntf_preferences.go create mode 100644 internal/model/do/ntf_read_status.go create mode 100644 internal/model/do/ntf_send_log.go create mode 100644 internal/model/do/ntf_templates.go create mode 100644 internal/model/do/opn_apps.go create mode 100644 internal/model/do/opn_webhook_configs.go create mode 100644 internal/model/do/opn_webhook_delivery_logs.go create mode 100644 internal/model/do/opn_webhook_events.go create mode 100644 internal/model/do/ops_alert_events.go create mode 100644 internal/model/do/ops_alert_rules.go create mode 100644 internal/model/do/ops_system_metrics.go create mode 100644 internal/model/do/ord_orders.go create mode 100644 internal/model/do/ord_payment_channels.go create mode 100644 internal/model/do/ord_promo_code_usages.go create mode 100644 internal/model/do/ord_promo_codes.go create mode 100644 internal/model/do/ord_redemption_usages.go create mode 100644 internal/model/do/ord_redemptions.go create mode 100644 internal/model/do/ord_refunds.go create mode 100644 internal/model/do/pln_feature_flags.go create mode 100644 internal/model/do/pln_plans.go create mode 100644 internal/model/do/pln_tenant_plans.go create mode 100644 internal/model/do/spt_articles.go create mode 100644 internal/model/do/spt_attachments.go create mode 100644 internal/model/do/spt_categories.go create mode 100644 internal/model/do/spt_feedbacks.go create mode 100644 internal/model/do/spt_replies.go create mode 100644 internal/model/do/spt_tickets.go create mode 100644 internal/model/do/sys_admin_data_scopes.go create mode 100644 internal/model/do/sys_admin_role_perms.go create mode 100644 internal/model/do/sys_admin_users.go create mode 100644 internal/model/do/sys_cron_job_executions.go create mode 100644 internal/model/do/sys_email_verify_codes.go create mode 100644 internal/model/do/sys_error_logs.go create mode 100644 internal/model/do/sys_idempotency_records.go create mode 100644 internal/model/do/sys_options.go create mode 100644 internal/model/do/sys_sessions.go create mode 100644 internal/model/do/tnt_invitations.go create mode 100644 internal/model/do/tnt_member_imports.go create mode 100644 internal/model/do/tnt_member_model_scopes.go create mode 100644 internal/model/do/tnt_oauth_identities.go create mode 100644 internal/model/do/tnt_projects.go create mode 100644 internal/model/do/tnt_tenants.go create mode 100644 internal/model/do/tnt_users.go create mode 100644 internal/model/do/tsk_async_tasks.go create mode 100644 internal/model/do/tsk_task_logs.go create mode 100644 internal/model/do/tsk_tasks.go create mode 100644 internal/model/entity/api_key_model_scopes.go create mode 100644 internal/model/entity/api_keys.go create mode 100644 internal/model/entity/aud_login_history.go create mode 100644 internal/model/entity/aud_operation_logs.go create mode 100644 internal/model/entity/aud_request_logs.go create mode 100644 internal/model/entity/aud_sensitive_access_logs.go create mode 100644 internal/model/entity/bil_daily_revenue_summary.go create mode 100644 internal/model/entity/bil_daily_usage_summary.go create mode 100644 internal/model/entity/bil_monthly_revenue_summary.go create mode 100644 internal/model/entity/bil_monthly_usage_summary.go create mode 100644 internal/model/entity/bil_records.go create mode 100644 internal/model/entity/bil_transactions.go create mode 100644 internal/model/entity/bil_usage_logs.go create mode 100644 internal/model/entity/bil_wallets.go create mode 100644 internal/model/entity/chn_abilities.go create mode 100644 internal/model/entity/chn_channel_affinities.go create mode 100644 internal/model/entity/chn_channel_keys.go create mode 100644 internal/model/entity/chn_channels.go create mode 100644 internal/model/entity/chn_health_scores.go create mode 100644 internal/model/entity/chn_health_snapshots.go create mode 100644 internal/model/entity/clg_changelogs.go create mode 100644 internal/model/entity/fil_files.go create mode 100644 internal/model/entity/goose_db_version.go create mode 100644 internal/model/entity/mdl_models.go create mode 100644 internal/model/entity/mdl_pricing.go create mode 100644 internal/model/entity/mdl_tenant_models.go create mode 100644 internal/model/entity/ntf_announcements.go create mode 100644 internal/model/entity/ntf_messages.go create mode 100644 internal/model/entity/ntf_preferences.go create mode 100644 internal/model/entity/ntf_read_status.go create mode 100644 internal/model/entity/ntf_send_log.go create mode 100644 internal/model/entity/ntf_templates.go create mode 100644 internal/model/entity/opn_apps.go create mode 100644 internal/model/entity/opn_webhook_configs.go create mode 100644 internal/model/entity/opn_webhook_delivery_logs.go create mode 100644 internal/model/entity/opn_webhook_events.go create mode 100644 internal/model/entity/ops_alert_events.go create mode 100644 internal/model/entity/ops_alert_rules.go create mode 100644 internal/model/entity/ops_system_metrics.go create mode 100644 internal/model/entity/ord_orders.go create mode 100644 internal/model/entity/ord_payment_channels.go create mode 100644 internal/model/entity/ord_promo_code_usages.go create mode 100644 internal/model/entity/ord_promo_codes.go create mode 100644 internal/model/entity/ord_redemption_usages.go create mode 100644 internal/model/entity/ord_redemptions.go create mode 100644 internal/model/entity/ord_refunds.go create mode 100644 internal/model/entity/pln_feature_flags.go create mode 100644 internal/model/entity/pln_plans.go create mode 100644 internal/model/entity/pln_tenant_plans.go create mode 100644 internal/model/entity/spt_articles.go create mode 100644 internal/model/entity/spt_attachments.go create mode 100644 internal/model/entity/spt_categories.go create mode 100644 internal/model/entity/spt_feedbacks.go create mode 100644 internal/model/entity/spt_replies.go create mode 100644 internal/model/entity/spt_tickets.go create mode 100644 internal/model/entity/sys_admin_data_scopes.go create mode 100644 internal/model/entity/sys_admin_role_perms.go create mode 100644 internal/model/entity/sys_admin_users.go create mode 100644 internal/model/entity/sys_cron_job_executions.go create mode 100644 internal/model/entity/sys_email_verify_codes.go create mode 100644 internal/model/entity/sys_error_logs.go create mode 100644 internal/model/entity/sys_idempotency_records.go create mode 100644 internal/model/entity/sys_options.go create mode 100644 internal/model/entity/sys_sessions.go create mode 100644 internal/model/entity/tnt_invitations.go create mode 100644 internal/model/entity/tnt_member_imports.go create mode 100644 internal/model/entity/tnt_member_model_scopes.go create mode 100644 internal/model/entity/tnt_oauth_identities.go create mode 100644 internal/model/entity/tnt_projects.go create mode 100644 internal/model/entity/tnt_tenants.go create mode 100644 internal/model/entity/tnt_users.go create mode 100644 internal/model/entity/tsk_async_tasks.go create mode 100644 internal/model/entity/tsk_task_logs.go create mode 100644 internal/model/entity/tsk_tasks.go create mode 100644 internal/packed/packed.go create mode 100644 internal/packed/setup.html create mode 100644 internal/plugin/bootstrap.go create mode 100644 internal/plugin/config.go create mode 100644 internal/plugin/hooks.go create mode 100644 internal/plugin/interfaces.go create mode 100644 internal/plugin/lifecycle.go create mode 100644 internal/plugin/plugin.go create mode 100644 internal/plugin/registry.go create mode 100644 internal/response/error_log.go create mode 100644 internal/response/response.go create mode 100644 internal/service/.gitkeep create mode 100644 internal/service/admin.go create mode 100644 internal/service/billing.go create mode 100644 internal/service/common.go create mode 100644 internal/service/docs.go create mode 100644 internal/service/monitor.go create mode 100644 internal/service/open.go create mode 100644 internal/service/payment.go create mode 100644 internal/service/relay.go create mode 100644 internal/service/settings.go create mode 100644 internal/service/task.go create mode 100644 internal/service/tenant.go create mode 100644 internal/utility/crypto/bcrypt.go create mode 100644 internal/utility/crypto/crypto.go create mode 100644 internal/utility/export/csv.go create mode 100644 internal/utility/export/excel.go create mode 100644 internal/utility/export/export.go create mode 100644 internal/utility/export/helper.go create mode 100644 internal/utility/totp/totp.go create mode 100644 internal/utility/turnstile/turnstile.go create mode 100644 main.go create mode 100644 manifest/config/config.example.yaml create mode 100644 manifest/deploy/kustomize/base/deployment.yaml create mode 100644 manifest/deploy/kustomize/base/kustomization.yaml create mode 100644 manifest/deploy/kustomize/base/service.yaml create mode 100644 manifest/deploy/kustomize/overlays/develop/configmap.yaml create mode 100644 manifest/deploy/kustomize/overlays/develop/deployment.yaml create mode 100644 manifest/deploy/kustomize/overlays/develop/kustomization.yaml create mode 100644 manifest/docker/Dockerfile create mode 100644 manifest/docker/docker-compose.yaml create mode 100644 manifest/docker/docker.sh create mode 100644 manifest/i18n/.gitkeep create mode 100644 manifest/protobuf/.keep-if-necessary create mode 100644 migrations/000001_init_schema.sql create mode 100644 plugins/example/hooks/relay_hooks.go create mode 100644 plugins/example/plugin.go create mode 100644 plugins/registry.go create mode 100644 relay/channel/ali/adaptor.go create mode 100644 relay/channel/ali/constants.go create mode 100644 relay/channel/ali/converter.go create mode 100644 relay/channel/aws/adaptor.go create mode 100644 relay/channel/aws/constants.go create mode 100644 relay/channel/baidu_v2/adaptor.go create mode 100644 relay/channel/baidu_v2/constants.go create mode 100644 relay/channel/claude/adaptor.go create mode 100644 relay/channel/claude/converter.go create mode 100644 relay/channel/claude/converter_test.go create mode 100644 relay/channel/claude/response.go create mode 100644 relay/channel/cloudflare/adaptor.go create mode 100644 relay/channel/cloudflare/constants.go create mode 100644 relay/channel/codex/adaptor.go create mode 100644 relay/channel/codex/constants.go create mode 100644 relay/channel/coze/adaptor.go create mode 100644 relay/channel/coze/constants.go create mode 100644 relay/channel/coze/converter.go create mode 100644 relay/channel/coze/dto.go create mode 100644 relay/channel/deepseek/adaptor.go create mode 100644 relay/channel/deepseek/constants.go create mode 100644 relay/channel/dify/adaptor.go create mode 100644 relay/channel/dify/constants.go create mode 100644 relay/channel/dify/converter.go create mode 100644 relay/channel/gemini/adaptor.go create mode 100644 relay/channel/gemini/converter.go create mode 100644 relay/channel/gemini/converter_test.go create mode 100644 relay/channel/gemini/image.go create mode 100644 relay/channel/gemini/response.go create mode 100644 relay/channel/jimeng/adaptor.go create mode 100644 relay/channel/jimeng/constants.go create mode 100644 relay/channel/minimax/adaptor.go create mode 100644 relay/channel/minimax/constants.go create mode 100644 relay/channel/minimax/image.go create mode 100644 relay/channel/mistral/adaptor.go create mode 100644 relay/channel/mistral/constants.go create mode 100644 relay/channel/moonshot/adaptor.go create mode 100644 relay/channel/moonshot/constants.go create mode 100644 relay/channel/ollama/adaptor.go create mode 100644 relay/channel/ollama/constants.go create mode 100644 relay/channel/ollama/converter.go create mode 100644 relay/channel/ollama/dto.go create mode 100644 relay/channel/openai/adaptor.go create mode 100644 relay/channel/openai/audio.go create mode 100644 relay/channel/openai/chat_via_responses.go create mode 100644 relay/channel/openai/claude_response.go create mode 100644 relay/channel/openai/completion.go create mode 100644 relay/channel/openai/converter.go create mode 100644 relay/channel/openai/embedding.go create mode 100644 relay/channel/openai/gemini_response.go create mode 100644 relay/channel/openai/image.go create mode 100644 relay/channel/openai/realtime.go create mode 100644 relay/channel/openai/rerank.go create mode 100644 relay/channel/openai/responses.go create mode 100644 relay/channel/openai/stream.go create mode 100644 relay/channel/registry.go create mode 100644 relay/channel/siliconflow/adaptor.go create mode 100644 relay/channel/siliconflow/constants.go create mode 100644 relay/channel/submodel/adaptor.go create mode 100644 relay/channel/submodel/constants.go create mode 100644 relay/channel/tencent/adaptor.go create mode 100644 relay/channel/tencent/constants.go create mode 100644 relay/channel/tencent/signing.go create mode 100644 relay/channel/vertex/adaptor.go create mode 100644 relay/channel/vertex/auth.go create mode 100644 relay/channel/vertex/constants.go create mode 100644 relay/channel/volcengine/adaptor.go create mode 100644 relay/channel/volcengine/constants.go create mode 100644 relay/channel/xai/adaptor.go create mode 100644 relay/channel/xai/constants.go create mode 100644 relay/channel/xunfei/adaptor.go create mode 100644 relay/channel/xunfei/constants.go create mode 100644 relay/channel/zhipu/adaptor.go create mode 100644 relay/channel/zhipu/constants.go create mode 100644 relay/channel/zhipu/converter.go create mode 100644 relay/common/adaptor.go create mode 100644 relay/common/billing.go create mode 100644 relay/common/cache_hash.go create mode 100644 relay/common/forwarding_trace.go create mode 100644 relay/common/http_client.go create mode 100644 relay/common/provider.go create mode 100644 relay/common/reasonmap.go create mode 100644 relay/common/relay_info.go create mode 100644 relay/common/stream_status.go create mode 100644 relay/common/task_adaptor.go create mode 100644 relay/common/task_billing.go create mode 100644 relay/common/task_provider.go create mode 100644 relay/common/usage.go create mode 100644 relay/constant/errors.go create mode 100644 relay/constant/provider.go create mode 100644 relay/constant/relay_format.go create mode 100644 relay/constant/relay_mode.go create mode 100644 relay/constant/task.go create mode 100644 relay/dto/audio.go create mode 100644 relay/dto/claude.go create mode 100644 relay/dto/gemini.go create mode 100644 relay/dto/openai.go create mode 100644 relay/dto/openai_responses.go create mode 100644 relay/dto/realtime.go create mode 100644 relay/dto/rerank.go create mode 100644 relay/dto/task.go create mode 100644 relay/dto/usage.go create mode 100644 relay/handler/audio_handler.go create mode 100644 relay/handler/chat_handler.go create mode 100644 relay/handler/claude_handler.go create mode 100644 relay/handler/gemini_handler.go create mode 100644 relay/handler/moderation_handler.go create mode 100644 relay/handler/passthrough.go create mode 100644 relay/handler/passthrough_test.go create mode 100644 relay/handler/realtime_handler.go create mode 100644 relay/handler/relay_handler.go create mode 100644 relay/handler/rerank_handler.go create mode 100644 relay/handler/task_handler.go create mode 100644 relay/helper/sanitize.go create mode 100644 relay/helper/sanitize_test.go create mode 100644 relay/helper/status_code.go create mode 100644 relay/helper/status_code_test.go create mode 100644 relay/helper/stream.go create mode 100644 relay/helper/stream_result.go create mode 100644 relay/helper/stream_scanner.go create mode 100644 relay/helper/system_prompt.go create mode 100644 relay/helper/system_prompt_test.go create mode 100644 relay/helper/thinking.go create mode 100644 relay/helper/thinking_test.go create mode 100644 relay/override/header.go create mode 100644 relay/override/operations.go create mode 100644 relay/override/override.go create mode 100644 relay/override/override_test.go create mode 100644 relay/scheduler/affinity.go create mode 100644 relay/scheduler/affinity_test.go create mode 100644 relay/scheduler/retry.go create mode 100644 relay/scheduler/scheduler.go create mode 100644 relay/scheduler/scheduler_test.go create mode 100644 relay/taskchannel/kling/adaptor.go create mode 100644 relay/taskchannel/kling/jwt.go create mode 100644 relay/taskchannel/midjourney/adaptor.go create mode 100644 relay/taskchannel/registry.go create mode 100644 relay/taskchannel/sora/adaptor.go create mode 100644 relay/taskchannel/suno/adaptor.go create mode 100644 relay/taskchannel/volcengine/adaptor.go create mode 100644 relay/taskchannel/volcengine/constants.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90f944b --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Go +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +go.work + +# Node.js +node_modules/ +dist/ +build/ + +# IDE +.idea/ + +# Environment files with sensitive data +.env +.env.local +.env.*.local + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Temporary files +*.tmp +*.temp + +# 外部文件 +new-api/ +sub2api/ + +# 配置文件(含敏感信息,使用 .example 模板代替) +hack/config.yaml +manifest/config/config.yaml + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..77035a4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,415 @@ +# Team-API — 项目开发规范 + +## 项目概述 + +多租户 AI API 网关 SaaS 平台。租户注册后获取 API Key,通过统一接口调用 OpenAI/Claude/Gemini 等大模型,平台负责计费、限流、监控。 + +双控制台架构:管理后台(平台运营)+ 租户控制台(组织使用),两套完全独立的用户体系。 + +## 技术栈 + +- **后端**:Go + GoFrame (v2) + goose(数据库迁移) +- **数据库**:PostgreSQL(唯一,不用 MySQL/SQLite) +- **缓存**:Redis + 内存缓存(双层缓存) +- **前端**:Vue 3 + Vite + TailwindCSS + Naive UI(管理后台) +- **包管理**:pnpm +- **对象存储**:S3 / OSS / COS / MinIO(禁止纯本地磁盘作为生产存储) + +## 项目结构 + +项目基于 GoFrame v2 标准项目结构(`gf init` 生成),在此基础上扩展业务模块。 + +``` +team-api/ +├── api/ # API 定义(请求/响应结构体 + 路由注解) +│ ├── admin/v1/ # 管理后台 API(gf gen ctrl 源) +│ ├── tenant/v1/ # 租户控制台 API(gf gen ctrl 源) +│ ├── captcha/v1/ # 验证码 API(管理后台 + 租户控制台共用) +│ ├── docs/v1/ # OpenAPI 文档 API +│ ├── open/v1/ # 开放平台 API +│ ├── payment/v1/ # 支付回调 API +├── hack/ +│ └── config.yaml # gf CLI 脚手架配置(gen dao 等工具的数据库连接和生成规则) +├── internal/ +│ ├── cmd/ # 入口命令、服务启动、路由注册 +│ │ ├── cmd.go # main 包注册,路由分组定义 +│ │ └── cmd_reset_pwd.go # 密码重置命令 +│ ├── consts/ # 常量定义(业务状态码、枚举等) +│ ├── controller/ # 控制器(由 gf gen ctrl 从 api/ 自动生成,禁止手动修改) +│ │ ├── admin/ # 管理后台控制器 +│ │ ├── tenant/ # 租户控制台控制器 +│ │ ├── captcha/ # 验证码控制器 +│ │ ├── docs/ # 文档控制器 +│ │ ├── open/ # 开放平台控制器 +│ ├── handler/ # 特殊端点处理器(不走 Controller 链路,见下方说明) +│ │ ├── relay/ # AI 代理端点处理器 +│ │ ├── public/ # 支付回调端点处理器 +│ │ └── setup/ # 系统初始化向导处理器 +│ ├── logic/ # 业务逻辑实现(核心代码所在) +│ │ ├── admin/ # 管理后台业务逻辑 +│ │ ├── tenant/ # 租户控制台业务逻辑 +│ │ ├── billing/ # 计费引擎 +│ │ ├── common/ # 公共逻辑(缓存、配置、安全、邮件、验证码、JWT 等) +│ │ ├── docs/ # OpenAPI 文档生成 +│ │ ├── monitor/ # 监控告警引擎 +│ │ ├── notification/ # 通知服务 +│ │ ├── open/ # 开放平台业务逻辑 +│ │ ├── payment/ # 支付逻辑(回调处理、订单履约) +│ │ ├── relay/ # Relay 业务逻辑(亲和性、健康检查、缓存) +│ │ └── task/ # 异步任务框架 +│ ├── model/ # 数据模型(由 gf gen dao 自动生成,禁止手动修改) +│ │ ├── do/ # Domain Objects(查询条件、输入结构体) +│ │ └── entity/ # Entity(与数据库表 1:1 映射的数据实体) +│ ├── packed/ # 打包资源(内嵌静态文件、编译时生成) +│ ├── dao/ # 数据访问对象(由 gf gen dao 自动生成,禁止手动修改) +│ ├── response/ # 统一响应工具包(Success/Error 封装) +│ ├── service/ # 业务接口定义(由 gf gen service 从 logic/ 自动生成,禁止手动修改) +│ ├── middleware/ # 中间件 +│ └── utility/ # 工具函数 +│ ├── crypto/ # 加密工具 +│ ├── export/ # 数据导出 +│ ├── totp/ # TOTP 两步验证 +│ └── turnstile/ # Cloudflare Turnstile 验证 +├── relay/ # AI 模型代理层(顶级模块,独立于 GoFrame 脚手架生成目录) +│ ├── channel/ # 供应商适配器(registry.go + 24 个子目录,每个供应商一个) +│ │ └── openai/ claude/ gemini/ ... +│ ├── common/ # Relay 层共享类型和工具 +│ ├── constant/ # Relay 层常量(供应商类型、模式、错误码) +│ ├── dto/ # 数据传输对象(OpenAI/Claude/Gemini/Realtime/Task 等格式定义) +│ ├── handler/ # Relay 请求处理器(chat/claude/gemini/audio/rerank/task/realtime 等) +│ ├── helper/ # Relay 辅助函数(流式处理、状态码映射、系统提示词、thinking 处理) +│ ├── override/ # 请求/响应覆盖(Header 改写、参数覆盖) +│ ├── scheduler/ # 渠道调度器(调度、亲和性、重试) +│ └── taskchannel/ # 异步任务渠道适配器 +│ ├── registry.go # 任务渠道注册表 +│ ├── kling/ # 可灵视频生成 +│ ├── midjourney/ # Midjourney 图像生成 +│ ├── sora/ # Sora 视频生成 +│ └── suno/ # Suno 音乐生成 +├── manifest/ +│ ├── config/ # 应用运行时配置(GoFrame 标准路径) +│ ├── deploy/ # 部署配置(K8s/Compose 等) +│ ├── docker/ # Docker 构建配置 +│ ├── i18n/ # 国际化资源文件 +│ └── protobuf/ # Protobuf 定义文件 +├── migrations/ # goose 数据库迁移脚本(六位序号编号:000001_xxx.sql) +├── web/ # 前端 +│ ├── admin/ # 管理后台 +│ └── tenant/ # 租户控制台 +├── docs/ # 项目文档 +│ ├── 开发计划-v2/ # 分周期开发计划(周期一~六) +│ └── 协议文档/ # 协议相关文档 +├── new-api/ # 参考项目(只读,不修改) +├── sub2api/ # 参考项目(只读,不修改) +├── main.go # 应用入口 +├── go.mod +└── Makefile +``` + +### Relay 层设计说明 + +Relay 层作为顶级 `relay/` 模块独立于 GoFrame 脚手架生成目录(`internal/` 下的 `controller/service/dao/model`),原因: +- **文件量大**:24 个供应商适配器 + 4 个异步任务适配器 + 共享模块,放在 `internal/logic/` 下层级过深 +- **职责独立**:Relay 是纯粹的代理转发层,不依赖 GoFrame ORM/DAO,与 `internal/` 下的业务逻辑解耦 +- **参考对齐**:目录结构与 new-api 的 `relay/` 保持一致,降低移植和理解成本 + +`internal/logic/` 中的业务逻辑(如计费、渠道调度)通过 Go import 调用 `relay/` 包,而非将 relay 嵌入 logic 内部。 + +### GoFrame 代码生成与手动编写分界 + +| 目录 | 生成方式 | 能否手动修改 | +|------|---------|------------| +| `api/` | 手动编写 | 是(结构体定义和路由注解) | +| `hack/config.yaml` | 手动编写 | 是(CLI 工具配置) | +| `internal/controller/` | `gf gen ctrl` | 否(自动生成) | +| `internal/service/` | `gf gen service` | 否(自动生成接口) | +| `internal/model/do/` | `gf gen dao` | 否(自动生成) | +| `internal/model/entity/` | `gf gen dao` | 否(自动生成) | +| `internal/dao/` | `gf gen dao` | 否(自动生成) | +| `internal/logic/` | 手动编写 | 是(核心业务逻辑) | +| `internal/cmd/` | 手动编写 | 是(路由注册、服务启动) | +| `internal/consts/` | 手动编写 | 是(常量定义) | +| `internal/handler/` | 手动编写 | 是(特殊端点处理器,见下方说明) | +| `internal/response/` | 手动编写 | 是(统一响应工具包) | +| `internal/middleware/` | 手动编写 | 是(中间件) | +| `internal/utility/` | 手动编写 | 是(工具函数:加密、导出、TOTP、Turnstile) | +| `manifest/config/` | 手动编写 | 是(运行时配置) | +| `relay/` | 手动编写 | 是(AI 模型代理层,独立于 GoFrame 脚手架) | + +### internal/handler/ 目录说明 + +`internal/handler/` 目录包含**特殊端点的处理器**,这些端点不适合走 GoFrame 的标准 Controller 链路: + +| 子目录 | 用途 | +|--------|------| +| `handler/relay/` | `/v1/*` AI 代理端点(OpenAI/Claude/Gemini 兼容格式)- 需要直接操作 `http.ResponseWriter` 进行流式转发 | +| `handler/public/` | `/api/payment/*` 支付回调端点 - 需要返回非 JSON 格式的响应(如支付宝的表单重定向) | +| `handler/setup/` | `/api/setup/*` 系统初始化向导端点 - 首次部署时的系统配置页面和初始化接口 | + +**为什么不用 Controller**: +- **流式响应**:AI 代理端点需要实时转发上游的 SSE 流,Controller 的响应包装会破坏流式传输 +- **原生格式透传**:必须保持与 OpenAI/Claude/Gemini API 完全兼容的请求/响应格式 +- **自定义响应**:支付回调需要返回 HTML 表单或文本响应,而非统一的 JSON 格式 + +**与 Controller 的区别**: +- Controller:走 GoFrame 标准链路(`api → controller → service → logic → dao`),返回统一 JSON 响应 +- Handler:直接处理 HTTP 请求/响应,不走 GoFrame 的 `ghttp.Request` 包装,用于特殊场景 + +### GoFrame 脚手架生成顺序(重要) + +项目 `hack/config.yaml` 中 `gfcli.gen.ctrl` 已配置 `withService: true`,意味着 `gf gen ctrl` 会尝试自动将控制器连接到 service 方法。但前提是 **service 接口中已存在对应方法**,否则生成 `CodeNotImplemented` 桩代码。 + +**新增接口时必须按以下顺序执行,不可颠倒:** + +1. **编写 API 定义**(`api/xxx/v1/xxx.go`)— 添加 Req/Res 结构体 +2. **编写 Logic 实现**(`internal/logic/xxx/xxx.go`)— 实现业务逻辑 +3. **`gf gen service`** — 从 Logic 方法签名自动生成 Service 接口 +4. **`gf gen ctrl`** — 从 API 定义生成 Controller,此时 `withService: true` 会自动生成 `return service.Xxx().Method(ctx, req)` 调用 + +如果先执行 `gf gen ctrl` 再执行 `gf gen service`,控制器会是桩代码(返回 501 Not Implemented),需要手动修改。始终遵循 "logic → gen service → gen ctrl" 的顺序可避免此问题。 + +### GoFrame 请求处理链路 + +``` +cmd(路由注册) + → api(请求/响应结构体定义) + → controller(解析请求、校验参数、调用 service) + → service(接口定义,自动生成) + → logic(业务逻辑实现,使用 DO 构造查询) + → dao(ORM 操作,返回 Entity) + ← 返回 Entity + ← controller 格式化响应(Res 结构体) +``` + +## 参考项目(只读,禁止修改) + +项目根目录下有 `new-api/` 和 `sub2api/` 两个参考项目,它们是大模型网关领域的成熟实现。 +大多数情况下不需要查看这2个目录下的代码,除非我主动提及或者涉及到大模型相关的实现逻辑。 + +### new-api 重点参考 + +- **Relay 代理层架构**:`controller/relay/`,适配器模式、请求转发、响应解析 +- **供应商适配器**:`relay/channel/` 下 40+ 供应商的适配器实现,优先复用 +- **协议转换**:OpenAI ↔ Claude ↔ Gemini 消息格式互转 +- **渠道调度**:优先级/权重选择、自动重试、Key 轮询 +- **计费模型**:预扣/结算/退款、梯度定价、Token 计量 + +### sub2api 重点参考 + +- **渠道亲和性**:用户+模型→渠道映射缓存策略 +- **运维监控**:仪表盘设计、健康监控页面 +- **用户体系**:前端布局和组件设计 + +### 参考原则 + +参考不等于照搬。new-api 使用 Gin + GORM,我们需要适配到 GoFrame 框架。接口设计和核心算法可以参考,但代码需要重新组织以符合 GoFrame 标准结构(`api → controller → service → logic → dao` 分层)和项目的多租户架构。注意 GoFrame 的 `model/do/`、`model/entity/`、`dao/`、`service/`、`controller/` 均由脚手架自动生成,业务代码只写在 `api/`、`logic/`、`cmd/`、`handler/`、`middleware/`、`response/`、`utility/`、`consts/` 和顶级 `relay/` 中。 + +## 核心架构决策 + +### 多租户隔离 + +行级隔离:所有业务表包含 `tenant_id` 字段,GoFrame ORM 中间件全局注入 `WHERE tenant_id = ?`。请求链路通过 Context 传递 tenant_id。 + +### 路由前缀 + +``` +/api/admin/ 管理后台(JWT 认证,admin_users 表) +/api/tenant/ 租户控制台(JWT 认证,tenant_users 表,RAM 格式 用户名@租户代码) +/api/payment/ 支付回调(签名验证,无用户认证) +/api/captcha/ 验证码服务(无需认证,管理后台 + 租户控制台共用) +/api/docs/ OpenAPI 文档(无需认证) +/api/open/ 开放平台 API(HMAC-SHA256 认证) +/api/setup/ 系统初始化向导(首次部署,完成后禁用) +/v1/ AI 代理端点 — OpenAI 兼容格式(API Key 认证,Bearer Token) +/v1beta/ AI 代理端点 — Gemini 兼容格式(API Key 认证,Bearer Token) +/suno/ Suno 音乐生成端点(API Key 认证) +``` + +### RBAC 权限模型 + +三层:角色 + 权限点 + 数据范围。公式:`is_allowed = has_permission_point AND within_data_scope AND resource_state_allows`。 + +管理后台角色:`super_admin`(全权限)、`admin`(可配置权限集)。 +租户控制台角色:`owner`(全权限)、`admin`(管理权限)、`member`(个人数据)。 + +### 五层额度模型 + +``` +租户钱包(货币池)→ 套餐额度(资源池)→ 成员额度(控制线)→ 项目预算(控制线)→ Key 额度(控制线) +``` + +扣费优先级:先扣套餐资源量,不足部分扣钱包余额。免费版额度耗尽停止服务,钱包余额不兜底。 + +### 计费流程 + +``` +预扣(Redis 原子操作)→ 转发请求 → 结算(实际用量)→ 退款/补扣差额 +``` + +价格查询优先级:租户独立价 > 套餐价 > 模型基础价 > 硬编码默认。 + +最终价格公式:`最终价格 = 基础价格 × 模型乘数 × 租户乘数`。模型乘数根据模型稀有度/成本动态调整;租户乘数根据租户等级/用量等级设置(VIP 折扣等)。双层乘数替代单纯的"租户独立定价",提供更灵活的定价能力。 + +### 钱包冻结余额 + +wallets 表包含 `frozen_balance` 字段,用于支付中/退款中的金额冻结,防止并发超扣。可用余额 = `balance - frozen_balance`。预扣和支付操作需检查可用余额而非总余额。 + +### 系统货币规则 + +平台采用**内部统一 USD + 充值入口 CNY**策略,系统内除充值/支付外的所有金额均为 USD: + +| 业务场景 | 货币 | 说明 | +|----------|------|------| +| 大模型价格展示和计费 | **USD(美元)** | 模型定价、Token 单价、计费扣费统一使用美元,与上游供应商(OpenAI/Anthropic/Google)报价币种一致 | +| 用户钱包余额 | **USD(美元)** | 钱包存储 USD 余额,充值时 CNY 按汇率转换为 USD 入账 | +| 计费记录 / 交易流水 | **USD(美元)** | 所有 bil_records、bil_transactions 金额均为 USD | +| 套餐价格 | **CNY(人民币)** | 管理后台创建套餐时设置的价格为人民币,用户购买套餐使用人民币支付 | +| 充值/支付 | **CNY(人民币)** | 用户通过支付渠道(支付宝/微信/Stripe)充值为人民币,履约时转换为 USD 入账钱包 | + +**换算规则**: +- 管理后台支付设置中配置 CNY → USD 兑换比例(`payment_exchange_rate_cny_to_usd`,默认 0.14) +- 充值履约时调用 `billing.ConvertCNYToUSD(ctx, cnyAmount)` 将 CNY 金额转换为 USD 入账钱包 +- 计费引擎全程使用 USD,无需换算 +- 前端展示规则:钱包余额/交易记录/计费用 `$`(USD),套餐价格/充值金额/订单金额用 `¥`(CNY) + +## 数据库表前缀定义 + +按功能域划分,新建表时必须归入对应前缀,除非加入了新的功能模型,否则不得自行新增前缀。 + +| 前缀 | 功能域 | 周期 | 涉及表(示例) | +|------|--------|------|--------------| +| `sys_` | 平台系统 + 系统配置 | 一 | `sys_admin_users` `sys_admin_role_perms` `sys_admin_data_scopes` `sys_sessions` `sys_email_verify_codes` `sys_idempotency_records` `sys_options` | +| `tnt_` | 租户核心 + 项目管理 | 一 | `tnt_tenants` `tnt_users` `tnt_invitations` `tnt_member_imports` `tnt_member_model_scopes` `tnt_projects` | +| `mdl_` | AI 模型 | 一 | `mdl_models` `mdl_pricing_tiers` `mdl_tenant_models` | +| `chn_` | 渠道管理 | 一 | `chn_channels` `chn_abilities` `chn_health_scores` `chn_channel_keys` `chn_channel_affinities` | +| `api_` | API 密钥 | 一 | `api_keys` `api_key_model_scopes` | +| `bil_` | 计费账单 | 一 | `bil_records` `bil_wallets` `bil_transactions` `bil_usage_logs` `bil_daily_usage_summary` `bil_daily_revenue_summary` `bil_monthly_usage_summary` `bil_monthly_revenue_summary` | +| `ord_` | 订单支付 + 优惠券 | 二 | `ord_orders` `ord_refunds` `ord_payment_channels` `ord_promo_codes` `ord_promo_code_usages` `ord_redemptions` `ord_redemption_usages` | +| `pln_` | 套餐订阅 | 二 | `pln_plans` `pln_tenant_plans` `pln_feature_flags` | +| `ntf_` | 通知消息 | 三 | `ntf_templates` `ntf_send_log` `ntf_messages` `ntf_read_status` `ntf_preferences` `ntf_announcements` | +| `aud_` | 审计日志 | 三 | `aud_request_logs` `aud_operation_logs` `aud_sensitive_access_logs` `aud_login_history` | +| `spt_` | 客户支持(工单+帮助+反馈) | 三/六 | `spt_tickets` `spt_replies` `spt_attachments` `spt_categories` `spt_articles` `spt_feedbacks` | +| `ops_` | 运维监控 | 四 | `ops_alert_rules` `ops_alert_events` `ops_system_metrics` | +| `tsk_` | 异步任务 | 四 | `tsk_tasks` `tsk_task_logs` `tsk_async_tasks` | +| `fil_` | 文件存储 | 四 | `fil_files` | +| `opn_` | 开放平台 + Webhook | 五 | `opn_apps` `opn_webhook_configs` `opn_webhook_events` `opn_webhook_delivery_logs` | +| `clg_` | 更新日志 | 六 | `clg_changelogs` | + +### 前缀使用规则 + +- 新建表必须从上表选择前缀,不允许自行新增前缀 +- 如果一个表明显属于两个域,选择核心归属域的前缀 +- 通用/跨域表(如 `sys_options` 系统配置)放在最相关的域下 +- 迁移脚本文件名按现有文件顺序递增编号,格式:`{序号}_{变更内容描述}.sql`,例如 `000002_alter_mdl_pricing_cache_fields.sql` + +## 开发规范 + +### Go 后端 + +- **缩进统一使用 tab**:所有 Go 代码的缩进必须使用 tab,与 `go fmt` 自动格式化保持一致,禁止混用空格和 tab 缩进 +- GoFrame 标准项目结构,不要偏离。框架使用规范(时间处理、错误处理、ORM 模式、日志等)详见 [`docs/reference/goframe-conventions.md`](docs/reference/goframe-conventions.md),遇到不确定的 GoFrame 用法时善用 `/goframe-v2` skill 查询框架规范 +- **修复 GoFrame 框架使用 bug 时**,必须在 `docs/reference/goframe-conventions.md` 末尾的「已修复的框架使用错误记录」章节追加记录(问题描述、原因、修复方式),防止同类问题重现 +- 数据库迁移用 goose,脚本按六位序号递增编号(`migrations/000001_xxx.sql`) +- 所有业务表必须有 `id`, `created_at`, `updated_at` 字段 +- 审计相关表按月分区(audit_logs) +- 使用量大的写入操作(usage_logs)异步批量写入 +- 错误响应统一格式:`{"error": {"type": "...", "message": "...", "code": "...", "request_id": "..."}}` +- 每个 API 请求生成唯一 Request ID,贯穿全链路 +- 敏感字段(密码、API Key 原值)AES-256 加密存储 +- 缓存策略:API Key 60s、用户信息 300s、模型定价 600s、渠道信息 300s + +### 前端 + +- Vue 3 Composition API + ` + + + + + + + + +
+
+ + +
+
+ + + +
+

Team-API

+

系统初始化设置

+
+ + +

+ 首次使用,请创建管理员账号以开始配置系统 +

+ + +
+
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+
+ + + + +
+
+ + +
+ +
+
+
+ +

+ 初始化完成后将自动跳转至登录页面 +

+
+ + + + diff --git a/internal/plugin/bootstrap.go b/internal/plugin/bootstrap.go new file mode 100644 index 0000000..fdd685d --- /dev/null +++ b/internal/plugin/bootstrap.go @@ -0,0 +1,118 @@ +package plugin + +import ( + "context" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/os/glog" + + "github.com/qianfree/team-api/internal/logic/common" +) + +// Bootstrap 启动插件系统(在 cmd.go 的服务启动流程中调用)。 +func Bootstrap(ctx context.Context, appInstance *App) error { + app = appInstance + + entries := AllPlugins() + glog.Infof(ctx, "plugin system bootstrapping, %d plugin(s) registered", len(entries)) + + for _, entry := range entries { + // 从数据库读取安装状态 + dbStatus, err := getPluginStatusFromDB(ctx, entry.Info.Name) + if err != nil { + glog.Warningf(ctx, "plugin %s: failed to read db status: %v", entry.Info.Name, err) + continue + } + + // 数据库中没有记录,说明是新注册但未安装的插件 + if dbStatus == "" { + entry.Status = StatusRegistered + continue + } + + entry.Status = dbStatus + + // 只初始化和启动已启用的插件 + if entry.Status != StatusEnabled { + continue + } + + if err := startPlugin(ctx, entry); err != nil { + entry.Status = StatusError + glog.Errorf(ctx, "plugin %s: start failed: %v", entry.Info.Name, err) + } + } + + return nil +} + +// startPlugin 启动单个插件(初始化 + 注册 Hook/定时任务)。 +func startPlugin(ctx context.Context, entry *PluginEntry) error { + // 1. 调用插件初始化 + if err := entry.Plugin.Init(ctx, app); err != nil { + return err + } + + // 2. 注册 Hook(如果实现了 Hookable) + if h, ok := entry.Plugin.(Hookable); ok { + for _, binding := range h.Hooks() { + app.Hook.RegisterHook(entry.Info.Name, binding.Event, binding.Priority, binding.Handler) + } + } + + // 3. 注册定时任务(如果实现了 Cronable) + if c, ok := entry.Plugin.(Cronable); ok { + cs := common.GetCronScheduler() + for _, job := range c.CronJobs() { + jobFunc := job.Handler + jobName := "plugin_" + job.Name + cs.Register(jobName, job.CronExpr, func(ctx context.Context) error { + jobFunc(ctx) + return nil + }) + } + } + + entry.Status = StatusEnabled + glog.Infof(ctx, "plugin %s: started successfully", entry.Info.Name) + return nil +} + +// RegisterAllRoutes 注册所有已启用插件的路由。 +func RegisterAllRoutes(ctx context.Context, server *ghttp.Server) { + for _, entry := range AllPlugins() { + if entry.Status != StatusEnabled { + continue + } + if r, ok := entry.Plugin.(Routable); ok { + if err := r.Routes(ctx, server); err != nil { + glog.Errorf(ctx, "plugin %s: route registration failed: %v", entry.Info.Name, err) + } + } + } +} + +// Shutdown 关闭所有插件(在服务关闭时调用)。 +func Shutdown(ctx context.Context) { + for _, entry := range AllPlugins() { + if entry.Status == StatusEnabled { + if err := entry.Plugin.Destroy(ctx); err != nil { + glog.Warningf(ctx, "plugin %s: destroy failed: %v", entry.Info.Name, err) + } + } + } + glog.Info(ctx, "plugin system shutdown complete") +} + +// getPluginStatusFromDB 从数据库读取插件状态。 +func getPluginStatusFromDB(ctx context.Context, name string) (PluginStatus, error) { + record, err := g.DB().Model("sys_plugins").Ctx(ctx).Where("name", name).One() + if err != nil { + return "", err + } + if record == nil { + return "", nil + } + return PluginStatus(record["status"].String()), nil +} diff --git a/internal/plugin/config.go b/internal/plugin/config.go new file mode 100644 index 0000000..ccc6cff --- /dev/null +++ b/internal/plugin/config.go @@ -0,0 +1,100 @@ +package plugin + +import ( + "context" + "encoding/json" + + "github.com/gogf/gf/v2/frame/g" +) + +// GetPluginConfig 获取插件全局配置。 +func GetPluginConfig(ctx context.Context, pluginName string) (g.Map, error) { + record, err := g.DB().Model("sys_plugins").Ctx(ctx). + Where("name", pluginName). + Fields("config"). + One() + if err != nil { + return nil, err + } + if record == nil { + return g.Map{}, nil + } + + var config g.Map + configStr := record["config"].String() + if configStr == "" || configStr == "{}" { + return g.Map{}, nil + } + if err := json.Unmarshal([]byte(configStr), &config); err != nil { + return nil, err + } + return config, nil +} + +// GetPluginConfigForTenant 获取租户级插件配置(优先使用租户覆盖,否则使用全局配置)。 +func GetPluginConfigForTenant(ctx context.Context, pluginName string, tenantID int64) (g.Map, error) { + // 先查租户级配置 + record, err := g.DB().Model("tnt_tenant_plugins").Ctx(ctx). + Where("tenant_id = ? AND plugin_name = ?", tenantID, pluginName). + Fields("config"). + One() + if err != nil { + return nil, err + } + + if record != nil { + configStr := record["config"].String() + if configStr != "" && configStr != "{}" { + var config g.Map + if err := json.Unmarshal([]byte(configStr), &config); err != nil { + return nil, err + } + return config, nil + } + } + + // 回退到全局配置 + return GetPluginConfig(ctx, pluginName) +} + +// UpdatePluginConfig 更新插件全局配置。 +func UpdatePluginConfig(ctx context.Context, pluginName string, config g.Map) error { + configJSON, err := json.Marshal(config) + if err != nil { + return err + } + _, err = g.DB().Model("sys_plugins").Ctx(ctx). + Where("name", pluginName). + Data(g.Map{"config": string(configJSON)}). + Update() + return err +} + +// UpdatePluginConfigForTenant 更新租户级插件配置。 +func UpdatePluginConfigForTenant(ctx context.Context, pluginName string, tenantID int64, config g.Map) error { + configJSON, err := json.Marshal(config) + if err != nil { + return err + } + _, err = g.DB().Model("tnt_tenant_plugins").Ctx(ctx). + Data(g.Map{ + "tenant_id": tenantID, + "plugin_name": pluginName, + "config": string(configJSON), + }). + OnConflict("tenant_id,plugin_name"). + Save() + return err +} + +// GetConfigSchema 获取插件配置 schema(如果插件实现了 Configurable)。 +func GetConfigSchema(pluginName string) []ConfigFieldDef { + entry := GetPlugin(pluginName) + if entry == nil { + return nil + } + if c, ok := entry.Plugin.(Configurable); ok { + return c.ConfigSchema() + } + return nil +} diff --git a/internal/plugin/hooks.go b/internal/plugin/hooks.go new file mode 100644 index 0000000..61693f3 --- /dev/null +++ b/internal/plugin/hooks.go @@ -0,0 +1,138 @@ +package plugin + +import ( + "context" + "sort" + "sync" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/glog" +) + +// --------------------------------------------------------------------------- +// 内置事件常量 +// --------------------------------------------------------------------------- + +const ( + // Relay 相关(AI 代理核心流程) + HookRelayBeforeRequest = "relay.before_request" // 请求转发前(可修改请求、中断转发) + HookRelayAfterResponse = "relay.after_response" // 响应返回后(可修改响应、记录日志) + HookRelayOnError = "relay.on_error" // 转发失败(告警、重试决策) + + // 计费相关 + HookBillingBeforeDeduct = "billing.before_deduct" // 预扣前(可修改价格、跳过计费) + HookBillingAfterSettle = "billing.after_settle" // 结算后(用量分析、额度告警) + HookBillingOnRefund = "billing.on_refund" // 退款时(退款通知) + + // 渠道相关 + HookChannelBeforeSelect = "channel.before_select" // 渠道选择前(影响调度决策) + HookChannelOnHealthCheck = "channel.on_health_check" // 健康检查(自定义检查逻辑) + + // 租户相关 + HookTenantOnCreate = "tenant.on_create" // 租户创建后(初始化租户数据) + HookTenantOnSuspend = "tenant.on_suspend" // 租户暂停(清理资源) + + // API Key 相关 + HookApiKeyOnCreate = "apikey.on_create" // Key 创建后(通知、初始化) + HookApiKeyOnVerify = "apikey.on_verify" // Key 验证时(自定义验证逻辑) +) + +// --------------------------------------------------------------------------- +// HookEmitter 事件发射器 +// --------------------------------------------------------------------------- + +// HookEmitter 全局事件发射器。 +type HookEmitter struct { + handlers map[string][]hookEntry + mu sync.RWMutex +} + +type hookEntry struct { + PluginName string + Priority int + Handler HookHandler +} + +var globalEmitter = &HookEmitter{ + handlers: make(map[string][]hookEntry), +} + +// GlobalEmitter 获取全局事件发射器。 +func GlobalEmitter() *HookEmitter { + return globalEmitter +} + +// RegisterHook 注册事件处理器(由插件系统在启动时调用)。 +func (e *HookEmitter) RegisterHook(pluginName, event string, priority int, handler HookHandler) { + e.mu.Lock() + defer e.mu.Unlock() + + e.handlers[event] = append(e.handlers[event], hookEntry{ + PluginName: pluginName, + Priority: priority, + Handler: handler, + }) +} + +// Emit 同步发射事件(按优先级顺序执行,可被中断)。 +func (e *HookEmitter) Emit(ctx context.Context, event string, data g.Map) (HookResult, error) { + e.mu.RLock() + handlers := make([]hookEntry, len(e.handlers[event])) + copy(handlers, e.handlers[event]) + e.mu.RUnlock() + + // 零开销快速路径:无订阅者直接返回 + if len(handlers) == 0 { + return HookResult{Data: data}, nil + } + + // 按优先级排序(数字越小越先执行) + sort.Slice(handlers, func(i, j int) bool { + return handlers[i].Priority < handlers[j].Priority + }) + + result := HookResult{Data: data} + for _, entry := range handlers { + if result.Aborted { + break + } + payload := HookPayload{Event: event, Data: result.Data} + hr, err := entry.Handler(ctx, payload) + if err != nil { + glog.Warningf(ctx, "plugin hook error: plugin=%s event=%s err=%v", + entry.PluginName, event, err) + continue + } + if hr.Aborted { + result.Aborted = true + } + if hr.Data != nil { + for k, v := range hr.Data { + result.Data[k] = v + } + } + } + return result, nil +} + +// EmitAsync 异步发射事件(不阻塞主流程,适用于日志、通知等场景)。 +func (e *HookEmitter) EmitAsync(ctx context.Context, event string, data g.Map) { + go func() { + _, _ = e.Emit(ctx, event, data) + }() +} + +// RemoveHooks 移除指定插件的所有 Hook 注册。 +func (e *HookEmitter) RemoveHooks(pluginName string) { + e.mu.Lock() + defer e.mu.Unlock() + for event, entries := range e.handlers { + filtered := make([]hookEntry, 0, len(entries)) + for _, entry := range entries { + if entry.PluginName != pluginName { + filtered = append(filtered, entry) + } + } + e.handlers[event] = filtered + } +} diff --git a/internal/plugin/interfaces.go b/internal/plugin/interfaces.go new file mode 100644 index 0000000..503bb91 --- /dev/null +++ b/internal/plugin/interfaces.go @@ -0,0 +1,114 @@ +package plugin + +import ( + "context" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" +) + +// --------------------------------------------------------------------------- +// 可选扩展接口(插件按需实现,利用 Go 接口隐式实现特性) +// --------------------------------------------------------------------------- + +// Routable 可注册 HTTP 路由的插件实现此接口。 +type Routable interface { + Routes(ctx context.Context, server *ghttp.Server) error +} + +// Hookable 订阅系统事件的插件实现此接口。 +type Hookable interface { + Hooks() []HookBinding +} + +// Cronable 注册定时任务的插件实现此接口。 +type Cronable interface { + CronJobs() []CronJobDef +} + +// Configurable 声明配置项的插件实现此接口。 +type Configurable interface { + ConfigSchema() []ConfigFieldDef +} + +// TenantAware 租户级控制的插件实现此接口。 +type TenantAware interface { + OnTenantEnable(ctx context.Context, tenantID int64) error + OnTenantDisable(ctx context.Context, tenantID int64) error +} + +// RelayInterceptor 拦截 AI 代理请求的插件实现此接口。 +type RelayInterceptor interface { + BeforeRelay(ctx context.Context, req *RelayRequest) (*RelayRequest, error) + AfterRelay(ctx context.Context, resp *RelayResponse) (*RelayResponse, error) +} + +// --------------------------------------------------------------------------- +// 支持类型 +// --------------------------------------------------------------------------- + +// HookBinding 事件绑定。 +type HookBinding struct { + Event string // 事件名,如 "relay.before_request" + Priority int // 优先级,数字越小越先执行 + Handler HookHandler // 处理函数 +} + +// HookHandler 事件处理函数。 +type HookHandler func(ctx context.Context, payload HookPayload) (HookResult, error) + +// HookPayload 事件载荷。 +type HookPayload struct { + Event string // 事件名 + Data g.Map // 事件数据,不同事件结构不同 +} + +// HookResult 事件处理结果。 +type HookResult struct { + Data g.Map // 可修改的数据(会合并回主流程) + Aborted bool // 是否中断后续处理(仅同步事件有效) +} + +// CronJobDef 定时任务定义。 +type CronJobDef struct { + Name string // 任务名(全局唯一) + CronExpr string // cron 表达式 + Handler func(ctx context.Context) // 任务处理函数 + Singleton bool // 是否单例执行 +} + +// ConfigFieldDef 配置字段定义。 +type ConfigFieldDef struct { + Key string // 配置键,如 "smtp_host" + Label string // 显示名 + Type string // 类型:string/int/bool/select/json + Default interface{} // 默认值 + Options []string // select 类型的选项 + Required bool // 是否必填 + Description string // 说明 +} + +// RelayRequest Relay 请求信息。 +type RelayRequest struct { + Model string `json:"model"` // 请求模型 + Messages string `json:"messages"` // 消息内容(JSON) + ChannelID int64 `json:"channel_id"` // 渠道 ID + TenantID int64 `json:"tenant_id"` // 租户 ID + Headers map[string]string `json:"headers"` // 请求头 + Body string `json:"body"` // 原始请求体 +} + +// RelayResponse Relay 响应信息。 +type RelayResponse struct { + StatusCode int `json:"status_code"` // HTTP 状态码 + Headers map[string]string `json:"headers"` // 响应头 + Body string `json:"body"` // 原始响应体 + Usage *TokenUsage `json:"usage"` // Token 用量 +} + +// TokenUsage Token 用量。 +type TokenUsage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} diff --git a/internal/plugin/lifecycle.go b/internal/plugin/lifecycle.go new file mode 100644 index 0000000..d6831af --- /dev/null +++ b/internal/plugin/lifecycle.go @@ -0,0 +1,229 @@ +package plugin + +import ( + "context" + "fmt" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/glog" +) + +// Install 安装插件(管理后台 API 调用触发)。 +func Install(ctx context.Context, name string) error { + entry := GetPlugin(name) + if entry == nil { + return gerror.New("插件不存在") + } + if entry.Status == StatusInstalled || entry.Status == StatusEnabled || entry.Status == StatusDisabled { + return gerror.New("插件已安装") + } + + if err := checkDependencies(entry); err != nil { + return err + } + + if err := entry.Plugin.Install(ctx); err != nil { + return gerror.Wrap(err, "插件安装失败") + } + + if c, ok := entry.Plugin.(Configurable); ok { + if err := registerConfigSchema(ctx, name, c.ConfigSchema()); err != nil { + return gerror.Wrap(err, "插件配置注册失败") + } + } + + _, err := g.DB().Model("sys_plugins").Ctx(ctx).Insert(g.Map{ + "name": name, + "label": entry.Info.Label, + "version": entry.Info.Version, + "status": StatusInstalled, + "category": entry.Info.Category, + "config": "{}", + }) + if err != nil { + return gerror.Wrap(err, "写入安装记录失败") + } + + entry.Status = StatusInstalled + glog.Infof(ctx, "plugin %s: installed successfully", name) + return nil +} + +// Enable 启用插件。 +func Enable(ctx context.Context, name string) error { + entry := GetPlugin(name) + if entry == nil { + return gerror.New("插件不存在") + } + if entry.Status != StatusInstalled && entry.Status != StatusDisabled { + return gerror.New("插件未安装,无法启用") + } + + if err := startPlugin(ctx, entry); err != nil { + _, _ = g.DB().Model("sys_plugins").Ctx(ctx). + Where("name", name). + Data(g.Map{"status": StatusError, "error_msg": err.Error()}). + Update() + return err + } + + _, err := g.DB().Model("sys_plugins").Ctx(ctx). + Where("name", name). + Data(g.Map{"status": StatusEnabled, "error_msg": ""}). + Update() + return err +} + +// Disable 禁用插件(不卸载,保留数据)。 +func Disable(ctx context.Context, name string) error { + entry := GetPlugin(name) + if entry == nil { + return gerror.New("插件不存在") + } + if entry.Status != StatusEnabled { + return gerror.New("插件未启用") + } + + if err := entry.Plugin.Destroy(ctx); err != nil { + glog.Warningf(ctx, "plugin %s: destroy error during disable: %v", name, err) + } + + app.Hook.RemoveHooks(name) + + entry.Status = StatusDisabled + _, err := g.DB().Model("sys_plugins").Ctx(ctx). + Where("name", name). + Data(g.Map{"status": StatusDisabled}). + Update() + return err +} + +// Uninstall 卸载插件。 +func Uninstall(ctx context.Context, name string, keepData bool) error { + entry := GetPlugin(name) + if entry == nil { + return gerror.New("插件不存在") + } + if entry.Status == StatusEnabled { + return gerror.New("请先禁用插件再卸载") + } + + if !keepData { + if err := entry.Plugin.Uninstall(ctx); err != nil { + glog.Warningf(ctx, "plugin %s: uninstall cleanup failed: %v", name, err) + } + } + + _, err := g.DB().Model("sys_plugins").Ctx(ctx).Where("name", name).Delete() + if err != nil { + return err + } + + _, err = g.DB().Model("tnt_tenant_plugins").Ctx(ctx).Where("plugin_name", name).Delete() + if err != nil { + return err + } + + entry.Status = StatusRegistered + glog.Infof(ctx, "plugin %s: uninstalled (keepData=%v)", name, keepData) + return nil +} + +// Upgrade 升级插件。 +func Upgrade(ctx context.Context, name string) error { + entry := GetPlugin(name) + if entry == nil { + return gerror.New("插件不存在") + } + + if err := entry.Plugin.Upgrade(ctx); err != nil { + return gerror.Wrap(err, "插件升级失败") + } + + _, err := g.DB().Model("sys_plugins").Ctx(ctx). + Where("name", name). + Data(g.Map{"version": entry.Info.Version}). + Update() + return err +} + +// EnableForTenant 为指定租户启用插件。 +func EnableForTenant(ctx context.Context, pluginName string, tenantID int64) error { + entry := GetPlugin(pluginName) + if entry == nil { + return gerror.New("插件不存在") + } + if entry.Status != StatusEnabled { + return gerror.New("插件未全局启用") + } + + if ta, ok := entry.Plugin.(TenantAware); ok { + if err := ta.OnTenantEnable(ctx, tenantID); err != nil { + return gerror.Wrap(err, "租户级插件启用失败") + } + } + + _, err := g.DB().Model("tnt_tenant_plugins").Ctx(ctx). + Data(g.Map{ + "tenant_id": tenantID, + "plugin_name": pluginName, + "enabled": true, + }). + OnConflict("tenant_id,plugin_name"). + Save() + return err +} + +// DisableForTenant 为指定租户禁用插件。 +func DisableForTenant(ctx context.Context, pluginName string, tenantID int64) error { + entry := GetPlugin(pluginName) + if entry == nil { + return gerror.New("插件不存在") + } + + if ta, ok := entry.Plugin.(TenantAware); ok { + if err := ta.OnTenantDisable(ctx, tenantID); err != nil { + glog.Warningf(ctx, "plugin %s: OnTenantDisable failed for tenant %d: %v", pluginName, tenantID, err) + } + } + + _, err := g.DB().Model("tnt_tenant_plugins").Ctx(ctx). + Data(g.Map{"enabled": false}). + Where("tenant_id = ? AND plugin_name = ?", tenantID, pluginName). + Update() + return err +} + +// --- 内部辅助函数 --- + +func checkDependencies(entry *PluginEntry) error { + for _, dep := range entry.Info.Dependencies { + depEntry := GetPlugin(dep) + if depEntry == nil { + return gerror.Newf("依赖插件 %s 不存在", dep) + } + if depEntry.Status != StatusEnabled && depEntry.Status != StatusInstalled { + return gerror.Newf("依赖插件 %s 未安装", dep) + } + } + return nil +} + +func registerConfigSchema(ctx context.Context, pluginName string, fields []ConfigFieldDef) error { + for _, field := range fields { + key := fmt.Sprintf("plugin.%s.%s", pluginName, field.Key) + _, err := g.DB().Model("sys_options").Ctx(ctx). + Data(g.Map{ + "key": key, + "value": fmt.Sprintf("%v", field.Default), + "description": field.Description, + }). + OnConflict("key"). + Save() + if err != nil { + return err + } + } + return nil +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go new file mode 100644 index 0000000..8018556 --- /dev/null +++ b/internal/plugin/plugin.go @@ -0,0 +1,44 @@ +package plugin + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/database/gredis" + "github.com/gogf/gf/v2/net/ghttp" +) + +// Plugin 插件核心接口,所有插件必须实现。 +type Plugin interface { + // Info 返回插件元信息(名称、版本、描述等)。 + Info() PluginInfo + // Init 初始化插件,app 提供框架服务访问入口(服务启动时调用)。 + Init(ctx context.Context, app *App) error + // Destroy 销毁插件,释放资源(服务关闭或插件禁用时调用)。 + Destroy(ctx context.Context) error + // Install 安装插件(管理后台点击"安装"时调用,建表、初始化数据)。 + Install(ctx context.Context) error + // Upgrade 升级插件(插件版本变更时调用,执行增量迁移)。 + Upgrade(ctx context.Context) error + // Uninstall 卸载插件(管理后台点击"卸载"时调用,清理数据)。 + Uninstall(ctx context.Context) error +} + +// PluginInfo 插件元信息。 +type PluginInfo struct { + Name string `json:"name"` // 唯一标识,如 "email-report" + Label string `json:"label"` // 显示名称,如 "邮件报表" + Description string `json:"description"` // 功能描述 + Author string `json:"author"` // 作者 + Version string `json:"version"` // 语义化版本,如 "1.0.0" + Category string `json:"category"` // 分类:relay/middleware/billing/notification/extension + Dependencies []string `json:"dependencies"` // 依赖的其他插件 +} + +// App 插件访问框架服务的推荐入口。 +type App struct { + Server *ghttp.Server // HTTP 服务器 + DB gdb.DB // 数据库 + Redis *gredis.Redis // Redis + Hook *HookEmitter // 事件发射器 +} diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go new file mode 100644 index 0000000..af17b92 --- /dev/null +++ b/internal/plugin/registry.go @@ -0,0 +1,77 @@ +package plugin + +import ( + "context" + "sync" + + "github.com/gogf/gf/v2/os/glog" +) + +var ( + plugins = make(map[string]*PluginEntry) + mu sync.RWMutex + app *App +) + +// PluginEntry 插件注册条目。 +type PluginEntry struct { + Plugin Plugin + Info PluginInfo + Status PluginStatus +} + +// PluginStatus 插件状态类型。 +type PluginStatus string + +const ( + StatusRegistered PluginStatus = "registered" // 已注册(代码加载但未安装) + StatusInstalled PluginStatus = "installed" // 已安装(数据库初始化完成) + StatusEnabled PluginStatus = "enabled" // 已启用(正在运行) + StatusDisabled PluginStatus = "disabled" // 已禁用(已安装但未运行) + StatusError PluginStatus = "error" // 异常 +) + +// Register 注册插件(在插件的 init() 函数中调用)。 +func Register(p Plugin) { + mu.Lock() + defer mu.Unlock() + + info := p.Info() + if info.Name == "" { + panic("plugin: name is required") + } + if _, exists := plugins[info.Name]; exists { + panic("plugin: duplicate registration: " + info.Name) + } + + plugins[info.Name] = &PluginEntry{ + Plugin: p, + Info: info, + Status: StatusRegistered, + } + + glog.Infof(context.Background(), "plugin registered: %s v%s", info.Name, info.Version) +} + +// GetPlugin 获取已注册的插件。 +func GetPlugin(name string) *PluginEntry { + mu.RLock() + defer mu.RUnlock() + return plugins[name] +} + +// AllPlugins 获取所有已注册插件。 +func AllPlugins() []*PluginEntry { + mu.RLock() + defer mu.RUnlock() + result := make([]*PluginEntry, 0, len(plugins)) + for _, entry := range plugins { + result = append(result, entry) + } + return result +} + +// GetApp 获取框架服务实例。 +func GetApp() *App { + return app +} diff --git a/internal/response/error_log.go b/internal/response/error_log.go new file mode 100644 index 0000000..ecc37e7 --- /dev/null +++ b/internal/response/error_log.go @@ -0,0 +1,134 @@ +package response + +import ( + "context" + "errors" + "fmt" + "runtime" + "strings" + "time" + + "github.com/gogf/gf/v2/errors/gerror" + + "github.com/qianfree/team-api/internal/logic/common" +) + +// ErrorLogRecord is a lightweight struct for async error log persistence. +type ErrorLogRecord struct { + RequestId string + ErrorCode int + ErrorMessage string + StackTrace string + HttpMethod string + RequestPath string + RequestBody string + Source string +} + +// errorLogWriter is the global async writer for sys_error_logs. +var errorLogWriter *common.UsageLogWriter + +// InitErrorLogWriter initializes the global error log writer. +func InitErrorLogWriter() { + errorLogWriter = common.NewUsageLogWriter(common.UsageLogWriterConfig{ + Table: "sys_error_logs", + QueueSize: 2048, + BatchSize: 16, + FlushInterval: 2 * time.Second, + Workers: 2, + Overflow: common.OverflowDrop, + }) + errorLogWriter.Start() +} + +// CloseErrorLogWriter flushes and closes the global error log writer. +func CloseErrorLogWriter() { + if errorLogWriter != nil { + errorLogWriter.Close() + } +} + +// captureStackTrace captures the current goroutine's stack trace. +func captureStackTrace(skip int) string { + pcs := make([]uintptr, 32) + n := runtime.Callers(skip+2, pcs) + if n == 0 { + return "" + } + frames := runtime.CallersFrames(pcs[:n]) + var b strings.Builder + for { + frame, more := frames.Next() + fmt.Fprintf(&b, "%s\n\t%s:%d\n", frame.Function, frame.File, frame.Line) + if !more { + break + } + } + return b.String() +} + +// truncateString truncates a string to maxLen bytes. +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} + +// writeErrorLog submits an error record to the async writer. +func writeErrorLog(r *ErrorLogRecord) { + if errorLogWriter == nil { + return + } + errorLogWriter.Submit(r) +} + +// WriteErrorLogFromCtx writes an error log from non-HTTP context (cron/background). +func WriteErrorLogFromCtx(ctx context.Context, err error, source string) { + if errorLogWriter == nil { + return + } + code := 500 + var gerr *gerror.Error + if errors.As(err, &gerr) { + code = gerr.Code().Code() + if code == 0 { + code = 500 + } + } + errorLogWriter.Submit(&ErrorLogRecord{ + ErrorCode: code, + ErrorMessage: err.Error(), + StackTrace: captureStackTrace(2), + Source: source, + RequestId: getRequestIDFromCtx(ctx), + }) +} + +// WritePanicLog writes a panic record to sys_error_logs (called from Recovery middleware). +func WritePanicLog(requestID, httpMethod, requestPath, errMsg, stack string) { + if errorLogWriter == nil { + return + } + errorLogWriter.Submit(&ErrorLogRecord{ + RequestId: requestID, + ErrorCode: 500, + ErrorMessage: errMsg, + StackTrace: stack, + HttpMethod: httpMethod, + RequestPath: requestPath, + Source: "panic", + }) +} + +// getRequestIDFromCtx extracts request ID from GoFrame context. +func getRequestIDFromCtx(ctx context.Context) string { + if ctx != nil { + if v := ctx.Value("RequestId"); v != nil { + if s, ok := v.(string); ok { + return s + } + } + } + return "" +} diff --git a/internal/response/response.go b/internal/response/response.go new file mode 100644 index 0000000..2f41c23 --- /dev/null +++ b/internal/response/response.go @@ -0,0 +1,137 @@ +package response + +import ( + "errors" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" +) + +// jsonResp is the unified response structure for all API endpoints. +type jsonResp struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data"` + RequestID string `json:"request_id"` +} + +// Success writes a successful response: HTTP 200 + {"code":0, "message":"ok", "data":..., "request_id":...}. +func Success(r *ghttp.Request, data interface{}) { + requestID := getRequestID(r) + r.Response.WriteJson(jsonResp{ + Code: 0, + Message: "ok", + Data: data, + RequestID: requestID, + }) + r.SetError(nil) // 清除请求上的错误,防止 ErrorHandler 中间件重复处理 +} + +// Error writes an error response with automatic sanitization. +// +// Security: Only business errors (code >= 10000) and standard HTTP 4xx errors +// expose their messages to the client. All other errors (database, network, +// internal) are replaced with "服务器内部错误" to prevent information leakage. +// The original error is logged with request_id for debugging. +func Error(r *ghttp.Request, err error) { + code := 500 + msg := "服务器内部错误" + + // Extract code from GoFrame gerror (supports wrapped errors via errors.As) + var gerr *gerror.Error + if errors.As(err, &gerr) { + code = gerr.Code().Code() + + // Business errors (>= 10000) and HTTP 4xx have safe user-facing messages + if code >= 10000 || (code >= 400 && code < 500) { + msg = gerr.Error() + } else { + // Internal error — sanitize message, log real cause + g.Log().Warning(r.Context(), "[Response] Internal error sanitized", + "request_id", getRequestID(r), + "code", code, + "error", err.Error(), + ) + // Persist system error to sys_error_logs + writeErrorLog(&ErrorLogRecord{ + RequestId: getRequestID(r), + ErrorCode: code, + ErrorMessage: err.Error(), + StackTrace: captureStackTrace(3), + HttpMethod: r.Method, + RequestPath: r.URL.Path, + RequestBody: truncateString(r.GetBodyString(), 2000), + Source: "api", + }) + } + } else { + // Non-gerror (raw Go error, e.g. database driver error) — always sanitize + g.Log().Warning(r.Context(), "[Response] Raw error sanitized", + "request_id", getRequestID(r), + "error", err.Error(), + ) + // Persist raw error to sys_error_logs + writeErrorLog(&ErrorLogRecord{ + RequestId: getRequestID(r), + ErrorCode: 500, + ErrorMessage: err.Error(), + StackTrace: captureStackTrace(3), + HttpMethod: r.Method, + RequestPath: r.URL.Path, + RequestBody: truncateString(r.GetBodyString(), 2000), + Source: "api", + }) + } + + if code == 0 { + code = 500 + } + + // Determine HTTP status + httpStatus := code + if code >= 10000 { + httpStatus = 422 + } else if httpStatus < 100 || httpStatus > 599 { + // GoFrame gcode 可能返回非标准错误码(如 68=CodeNotModified), + // 不能直接用作 HTTP 状态码,否则 WriteHeader 会 panic + httpStatus = 500 + } + + requestID := getRequestID(r) + r.Response.WriteHeader(httpStatus) + r.Response.WriteJson(jsonResp{ + Code: code, + Message: msg, + Data: nil, + RequestID: requestID, + }) + r.SetError(nil) // 清除请求上的错误,防止 ErrorHandler 中间件重复处理 +} + +// Use this for 400/401/403/404/409/429/500 errors. +func ErrorMsg(r *ghttp.Request, code int, message string) { + ErrorWithCode(r, code, code, message) +} + +// ErrorWithCode writes an error with separate HTTP status and business code. +// Use this when HTTP status differs from the business code (e.g., HTTP 401 with code 10020). +func ErrorWithCode(r *ghttp.Request, httpStatus int, code int, message string) { + requestID := getRequestID(r) + r.Response.WriteHeader(httpStatus) + r.Response.WriteJson(jsonResp{ + Code: code, + Message: message, + Data: nil, + RequestID: requestID, + }) + r.SetError(nil) // 清除请求上的错误,防止 ErrorHandler 中间件重复处理 +} + +// getRequestID extracts the request ID from context. +func getRequestID(r *ghttp.Request) string { + if v := r.GetCtxVar("RequestId"); v != nil { + return v.String() + } + return "" +} diff --git a/internal/service/.gitkeep b/internal/service/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/service/admin.go b/internal/service/admin.go new file mode 100644 index 0000000..125f7af --- /dev/null +++ b/internal/service/admin.go @@ -0,0 +1,399 @@ +// ================================================================================ +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// You can delete these comments if you wish manually maintain this interface file. +// ================================================================================ + +package service + +import ( + "context" + + v1 "github.com/qianfree/team-api/api/admin/v1" +) + +type ( + IAdmin interface { + // ListUsers returns a paginated list of admin users. + ListUsers(ctx context.Context, req *v1.AdminUserListReq) (*v1.AdminUserListRes, error) + // CreateUser creates a new admin user. + CreateUser(ctx context.Context, req *v1.AdminUserCreateReq) (*v1.AdminUserCreateRes, error) + // UpdateUser updates an admin user. + UpdateUser(ctx context.Context, req *v1.AdminUserUpdateReq) (*v1.AdminUserUpdateRes, error) + // DeleteUser deletes an admin user. + DeleteUser(ctx context.Context, req *v1.AdminUserDeleteReq) (*v1.AdminUserDeleteRes, error) + // UpdateUserStatus enables or disables an admin user. + UpdateUserStatus(ctx context.Context, req *v1.AdminUserUpdateStatusReq) (*v1.AdminUserUpdateStatusRes, error) + // ResetUserPassword resets an admin user's password. + ResetUserPassword(ctx context.Context, req *v1.AdminUserResetPasswordReq) (*v1.AdminUserResetPasswordRes, error) + // ExportUsers exports admin users to CSV or Excel. + ExportUsers(ctx context.Context, req *v1.AdminUserExportReq) (*v1.AdminUserExportRes, error) + // GetAuditConfig retrieves the global audit level from sys_options. + GetAuditConfig(ctx context.Context, _ *v1.AuditConfigGetReq) (*v1.AuditConfigGetRes, error) + // UpdateAuditConfig updates the global audit level. + UpdateAuditConfig(ctx context.Context, req *v1.AuditConfigUpdateReq) (*v1.AuditConfigUpdateRes, error) + // ListOperationLogs retrieves a paginated list of operation logs with optional filters. + ListOperationLogs(ctx context.Context, req *v1.OperationLogListReq) (*v1.OperationLogListRes, error) + // ListSensitiveAccessLogs retrieves a paginated list of sensitive data access logs. + ListSensitiveAccessLogs(ctx context.Context, req *v1.SensitiveLogListReq) (*v1.SensitiveLogListRes, error) + // ListRequestAuditLogs 分页查询请求审计日志(不返回 request_body/response_body 以优化性能) + ListRequestAuditLogs(ctx context.Context, req *v1.RequestAuditLogListReq) (*v1.RequestAuditLogListRes, error) + // GetRequestAuditLogDetail 查询单条请求审计日志详情(含完整 request_body 和 response_body) + GetRequestAuditLogDetail(ctx context.Context, req *v1.RequestAuditLogDetailReq) (*v1.RequestAuditLogDetailRes, error) + // ExportOperationLogs exports operation logs to CSV or Excel. + ExportOperationLogs(ctx context.Context, req *v1.OperationLogExportReq) (*v1.OperationLogExportRes, error) + // Login handles admin login. + Login(ctx context.Context, req *v1.AdminLoginReq) (*v1.AdminLoginRes, error) + // Logout handles admin logout. + Logout(ctx context.Context, _ *v1.AdminLogoutReq) (*v1.AdminLogoutRes, error) + // Refresh handles token refresh. + Refresh(ctx context.Context, req *v1.AdminRefreshReq) (*v1.AdminRefreshRes, error) + // ListSessions returns active sessions for the current admin user. + ListSessions(ctx context.Context, req *v1.AdminSessionListReq) (*v1.AdminSessionListRes, error) + // RevokeSession revokes a specific session. + RevokeSession(ctx context.Context, req *v1.AdminRevokeSessionReq) (*v1.AdminRevokeSessionRes, error) + // ForceLogout revokes all sessions for a specific user. + ForceLogout(ctx context.Context, req *v1.AdminForceLogoutReq) (*v1.AdminForceLogoutRes, error) + // ChangePassword handles admin password change. + ChangePassword(ctx context.Context, req *v1.AdminChangePasswordReq) (*v1.AdminChangePasswordRes, error) + // CreateChangelog 创建更新日志 + CreateChangelog(ctx context.Context, req *v1.ChangelogCreateReq) (*v1.ChangelogCreateRes, error) + // ListChangelogs 更新日志列表(管理后台,含草稿) + ListChangelogs(ctx context.Context, req *v1.ChangelogListReq) (*v1.ChangelogListRes, error) + // UpdateChangelog 更新更新日志 + UpdateChangelog(ctx context.Context, req *v1.ChangelogUpdateReq) (*v1.ChangelogUpdateRes, error) + // DeleteChangelog 删除更新日志 + DeleteChangelog(ctx context.Context, req *v1.ChangelogDeleteReq) (*v1.ChangelogDeleteRes, error) + // PublishChangelog 发布更新日志 + PublishChangelog(ctx context.Context, req *v1.ChangelogPublishReq) (*v1.ChangelogPublishRes, error) + // ListChannels 获取渠道列表 + ListChannels(ctx context.Context, req *v1.ChannelListReq) (*v1.ChannelListRes, error) + // CloneChannel 克隆渠道 + CloneChannel(ctx context.Context, req *v1.ChannelCloneReq) (*v1.ChannelCloneRes, error) + // CreateChannel 创建渠道 + CreateChannel(ctx context.Context, req *v1.ChannelCreateReq) (*v1.ChannelCreateRes, error) + // UpdateChannel 更新渠道 + UpdateChannel(ctx context.Context, req *v1.ChannelUpdateReq) (*v1.ChannelUpdateRes, error) + // DeleteChannel 删除渠道 + DeleteChannel(ctx context.Context, req *v1.ChannelDeleteReq) (*v1.ChannelDeleteRes, error) + // GetChannelDetail 获取渠道详情 + GetChannelDetail(ctx context.Context, req *v1.ChannelDetailReq) (*v1.ChannelDetailRes, error) + // AddChannelKey 添加渠道 API Key(已废弃:每渠道仅支持一个 Key) + AddChannelKey(ctx context.Context, req *v1.ChannelKeyCreateReq) (*v1.ChannelKeyCreateRes, error) + // DeleteChannelKey 删除渠道 API Key + DeleteChannelKey(ctx context.Context, req *v1.ChannelKeyDeleteReq) (*v1.ChannelKeyDeleteRes, error) + // SetChannelAbilities 设置渠道模型能力 + SetChannelAbilities(ctx context.Context, req *v1.ChannelAbilityBatchReq) (*v1.ChannelAbilityBatchRes, error) + // GetChannelKeys 获取渠道 Key 列表 + GetChannelKeys(ctx context.Context, req *v1.ChannelKeyListReq) (*v1.ChannelKeyListRes, error) + // GetChannelAbilities 获取渠道模型能力列表 + GetChannelAbilities(ctx context.Context, req *v1.ChannelAbilitiesGetReq) (*v1.ChannelAbilitiesGetRes, error) + // GetProviderDefaultURLs 获取供应商默认 API 地址 + GetProviderDefaultURLs(ctx context.Context, _ *v1.ProviderDefaultURLReq) (*v1.ProviderDefaultURLRes, error) + // GetChannelHealthTrend 获取渠道健康趋势数据 + GetChannelHealthTrend(ctx context.Context, req *v1.ChannelHealthTrendReq) (*v1.ChannelHealthTrendRes, error) + // ExportChannels exports channel list to CSV or Excel. + ExportChannels(ctx context.Context, req *v1.ChannelExportReq) (*v1.ChannelExportRes, error) + // ChannelOAuthAuthURL 生成 OAuth 授权链接 + ChannelOAuthAuthURL(ctx context.Context, req *v1.ChannelOAuthAuthURLReq) (*v1.ChannelOAuthAuthURLRes, error) + // ChannelOAuthExchange OAuth 授权码换取令牌 + ChannelOAuthExchange(ctx context.Context, req *v1.ChannelOAuthExchangeReq) (*v1.ChannelOAuthExchangeRes, error) + // ChannelOAuthRefresh 手动刷新 OAuth 令牌 + ChannelOAuthRefresh(ctx context.Context, req *v1.ChannelOAuthRefreshReq) (*v1.ChannelOAuthRefreshRes, error) + // TestChannel 测试渠道可用性(发送最小请求验证) + TestChannel(ctx context.Context, req *v1.ChannelTestReq) (*v1.ChannelTestRes, error) + // CronJobList returns all registered cron jobs with their last execution status. + CronJobList(ctx context.Context, _ *v1.CronJobListReq) (*v1.CronJobListRes, error) + // CronJobExecutions returns paginated execution history for a specific job. + CronJobExecutions(ctx context.Context, req *v1.CronJobExecutionsReq) (*v1.CronJobExecutionsRes, error) + // CronJobTrigger manually triggers a cron job. + CronJobTrigger(ctx context.Context, req *v1.CronJobTriggerReq) (*v1.CronJobTriggerRes, error) + // GetDashboardStats 获取管理后台仪表盘统计 + GetDashboardStats(ctx context.Context, req *v1.AdminDashboardReq) (*v1.AdminDashboardRes, error) + // GetDashboardTrends returns daily revenue and request trends for the past N days. + GetDashboardTrends(ctx context.Context, req *v1.AdminDashboardTrendsReq) (*v1.AdminDashboardTrendsRes, error) + // GetTopTenants returns the top 10 tenants by revenue. + GetTopTenants(ctx context.Context, req *v1.AdminDashboardTopTenantsReq) (*v1.AdminDashboardTopTenantsRes, error) + // GetModelDistribution returns the model usage distribution. + GetModelDistribution(ctx context.Context, req *v1.AdminDashboardModelDistributionReq) (*v1.AdminDashboardModelDistributionRes, error) + // GetAllUsageLogs 获取所有租户的用量日志(管理后台) + GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListReq) (*v1.AdminUsageLogListRes, error) + // GetAllBillingRecords 获取所有计费记录(管理后台) + GetAllBillingRecords(ctx context.Context, req *v1.AdminBillingRecordListReq) (*v1.AdminBillingRecordListRes, error) + // GetTenantWallets 获取所有租户钱包(管理后台) + GetTenantWallets(ctx context.Context, req *v1.AdminWalletListReq) (*v1.AdminWalletListRes, error) + // AdjustBalance 调整租户余额(管理后台) + AdjustBalance(ctx context.Context, req *v1.AdminWalletAdjustReq) (*v1.AdminWalletAdjustRes, error) + // GetWalletInfo 获取租户钱包信息(管理后台) + GetWalletInfo(ctx context.Context, req *v1.AdminWalletInfoReq) (*v1.AdminWalletInfoRes, error) + // GetWalletTransactions 获取租户钱包交易流水(管理后台) + GetWalletTransactions(ctx context.Context, req *v1.AdminWalletTransactionListReq) (*v1.AdminWalletTransactionListRes, error) + // SetWarningThreshold 设置租户钱包预警阈值(管理后台) + SetWarningThreshold(ctx context.Context, req *v1.AdminWalletSetWarningThresholdReq) (*v1.AdminWalletSetWarningThresholdRes, error) + // GetDashboardChannelHealth 获取渠道健康概览(最不健康的5个活跃渠道) + GetDashboardChannelHealth(ctx context.Context, req *v1.AdminDashboardChannelHealthReq) (*v1.AdminDashboardChannelHealthRes, error) + // GetDashboardRecentAlerts 获取最近5条告警 + GetDashboardRecentAlerts(ctx context.Context, req *v1.AdminDashboardRecentAlertsReq) (*v1.AdminDashboardRecentAlertsRes, error) + // ExportUsageLogs exports usage logs to CSV or Excel. + ExportUsageLogs(ctx context.Context, req *v1.AdminUsageLogExportReq) (*v1.AdminUsageLogExportRes, error) + // ExportBillingRecords exports billing records to CSV or Excel. + ExportBillingRecords(ctx context.Context, req *v1.AdminBillingRecordExportReq) (*v1.AdminBillingRecordExportRes, error) + DataGovernanceSettingsGet(ctx context.Context, _ *v1.DataGovernanceSettingsGetReq) (*v1.DataGovernanceSettingsGetRes, error) + // UpdateDataGovernanceSettings 更新数据治理设置 + DataGovernanceSettingsUpdate(ctx context.Context, req *v1.DataGovernanceSettingsUpdateReq) (*v1.DataGovernanceSettingsUpdateRes, error) + // RequestDataExport 请求数据导出 + DataGovernanceExport(ctx context.Context, req *v1.DataGovernanceExportReq) (*v1.DataGovernanceExportRes, error) + // RequestDataDeletion 请求数据删除 + DataGovernanceDeletion(ctx context.Context, req *v1.DataGovernanceDeletionReq) (*v1.DataGovernanceDeletionRes, error) + // TriggerDataCleanup 手动触发数据清理 + DataGovernanceCleanup(ctx context.Context, _ *v1.DataGovernanceCleanupReq) (*v1.DataGovernanceCleanupRes, error) + // ErrorLogList returns a paginated list of system error logs. + ErrorLogList(ctx context.Context, req *v1.ErrorLogListReq) (*v1.ErrorLogListRes, error) + // ErrorLogDetail returns the detail of a single error log. + ErrorLogDetail(ctx context.Context, req *v1.ErrorLogDetailReq) (*v1.ErrorLogDetailRes, error) + // ErrorLogResolve marks an error log as resolved. + ErrorLogResolve(ctx context.Context, req *v1.ErrorLogResolveReq) (*v1.ErrorLogResolveRes, error) + // ErrorLogBatchResolve marks multiple error logs as resolved. + ErrorLogBatchResolve(ctx context.Context, req *v1.ErrorLogBatchResolveReq) (*v1.ErrorLogBatchResolveRes, error) + // ErrorLogStats returns error log statistics. + ErrorLogStats(ctx context.Context, _ *v1.ErrorLogStatsReq) (*v1.ErrorLogStatsRes, error) + // ListAllFeedbacks 管理后台反馈列表 + ListAllFeedbacks(ctx context.Context, req *v1.FeedbackListAllReq) (*v1.FeedbackListAllRes, error) + // ReplyToFeedback 管理员回复反馈 + ReplyToFeedback(ctx context.Context, req *v1.FeedbackReplyReq) (*v1.FeedbackReplyRes, error) + // UpdateFeedbackStatus 更新反馈状态 + UpdateFeedbackStatus(ctx context.Context, req *v1.FeedbackUpdateStatusReq) (*v1.FeedbackUpdateStatusRes, error) + // GetFeedbackStats 反馈统计 + GetFeedbackStats(ctx context.Context, req *v1.FeedbackStatsReq) (*v1.FeedbackStatsRes, error) + // CreateHelpCategory 创建帮助分类 + CreateHelpCategory(ctx context.Context, req *v1.HelpCategoryCreateReq) (*v1.HelpCategoryCreateRes, error) + // UpdateHelpCategory 更新帮助分类 + UpdateHelpCategory(ctx context.Context, req *v1.HelpCategoryUpdateReq) (*v1.HelpCategoryUpdateRes, error) + // DeleteHelpCategory 删除帮助分类 + DeleteHelpCategory(ctx context.Context, req *v1.HelpCategoryDeleteReq) (*v1.HelpCategoryDeleteRes, error) + // ListHelpCategories 帮助分类列表(管理后台) + ListHelpCategories(ctx context.Context, req *v1.HelpCategoryListReq) (*v1.HelpCategoryListRes, error) + // CreateHelpArticle 创建帮助文章 + CreateHelpArticle(ctx context.Context, req *v1.HelpArticleCreateReq) (*v1.HelpArticleCreateRes, error) + // UpdateHelpArticle 更新帮助文章 + UpdateHelpArticle(ctx context.Context, req *v1.HelpArticleUpdateReq) (*v1.HelpArticleUpdateRes, error) + // DeleteHelpArticle 删除帮助文章 + DeleteHelpArticle(ctx context.Context, req *v1.HelpArticleDeleteReq) (*v1.HelpArticleDeleteRes, error) + // ListHelpArticles 帮助文章列表(管理后台) + ListHelpArticles(ctx context.Context, req *v1.HelpArticleListReq) (*v1.HelpArticleListRes, error) + // GetHelpArticle 帮助文章详情(管理后台) + GetHelpArticle(ctx context.Context, req *v1.HelpArticleGetReq) (*v1.HelpArticleGetRes, error) + // CreateMember adds a new member to a specified tenant. + CreateMember(ctx context.Context, req *v1.AdminMemberCreateReq) (*v1.AdminMemberCreateRes, error) + // ListAllMembers returns a paginated list of all tenant members across all tenants. + ListAllMembers(ctx context.Context, req *v1.AdminMemberListReq) (*v1.AdminMemberListRes, error) + // DisableMember disables a tenant member by admin. + DisableMember(ctx context.Context, req *v1.AdminMemberDisableReq) (*v1.AdminMemberDisableRes, error) + // EnableMember re-enables a tenant member by admin. + EnableMember(ctx context.Context, req *v1.AdminMemberEnableReq) (*v1.AdminMemberEnableRes, error) + // ResetMemberPassword resets a member's password by admin, returns the new random password. + ResetMemberPassword(ctx context.Context, req *v1.AdminMemberResetPasswordReq) (*v1.AdminMemberResetPasswordRes, error) + // ExportMembers exports member list to CSV or Excel. + ExportMembers(ctx context.Context, req *v1.AdminMemberExportReq) (*v1.AdminMemberExportRes, error) + // ListModels 获取模型列表 + ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.ModelListRes, error) + // CreateModel 创建模型(自动创建默认 token 定价记录) + CreateModel(ctx context.Context, req *v1.ModelCreateReq) (*v1.ModelCreateRes, error) + // UpdateModel 更新模型(含弃用状态管理) + UpdateModel(ctx context.Context, req *v1.ModelUpdateReq) (*v1.ModelUpdateRes, error) + // DeleteModel 删除模型(同时删除定价记录) + DeleteModel(ctx context.Context, req *v1.ModelDeleteReq) (*v1.ModelDeleteRes, error) + // ListModelPricing 模型定价列表(模型定价页面专用) + ListModelPricing(ctx context.Context, req *v1.PricingListReq) (*v1.PricingListRes, error) + // GetModelPricing 获取模型定价 + GetModelPricing(ctx context.Context, req *v1.PricingGetReq) (*v1.PricingGetRes, error) + // SetModelPricing 设置模型定价(全量替换) + SetModelPricing(ctx context.Context, req *v1.PricingSetReq) (*v1.PricingSetRes, error) + // FetchOfficialPricing 拉取模型官方定价(来自 LiteLLM + models.dev 双数据源) + FetchOfficialPricing(ctx context.Context, req *v1.PricingFetchOfficialReq) (*v1.PricingFetchOfficialRes, error) + // FetchOfficialModelInfo 按模型名称拉取官方模型信息(上下文长度+能力特性) + FetchOfficialModelInfo(ctx context.Context, req *v1.ModelFetchOfficialInfoReq) (*v1.ModelFetchOfficialInfoRes, error) + // ExportModels exports model list to CSV or Excel. + ExportModels(ctx context.Context, req *v1.ModelExportReq) (*v1.ModelExportRes, error) + // ListTemplates 获取通知模板列表(分页) + ListTemplates(ctx context.Context, req *v1.TemplateListReq) (*v1.TemplateListRes, error) + // GetTemplate 获取单个通知模板 + GetTemplate(ctx context.Context, req *v1.TemplateGetReq) (*v1.TemplateGetRes, error) + // UpdateTemplate 更新通知模板 + UpdateTemplate(ctx context.Context, req *v1.TemplateUpdateReq) (*v1.TemplateUpdateRes, error) + // TestTemplate 用测试变量渲染模板,返回渲染结果(不发送) + TestTemplate(ctx context.Context, req *v1.TemplateTestReq) (*v1.TemplateTestRes, error) + // SendMessage 创建手动站内消息并推送 WebSocket 通知 + SendMessage(ctx context.Context, req *v1.MessageSendReq) (*v1.MessageSendRes, error) + // SendBroadcast 创建广播消息并推送 WebSocket 通知 + SendBroadcast(ctx context.Context, req *v1.MessageBroadcastReq) (*v1.MessageBroadcastRes, error) + // ListMessages 获取所有消息列表(管理后台,支持过滤) + ListMessages(ctx context.Context, req *v1.MessageListReq) (*v1.MessageListRes, error) + // CreateAnnouncement 创建公告 + CreateAnnouncement(ctx context.Context, req *v1.AnnouncementCreateReq) (*v1.AnnouncementCreateRes, error) + // UpdateAnnouncement 更新公告 + UpdateAnnouncement(ctx context.Context, req *v1.AnnouncementUpdateReq) (*v1.AnnouncementUpdateRes, error) + // ListAnnouncements 获取公告列表(分页) + ListAnnouncements(ctx context.Context, req *v1.AnnouncementListReq) (*v1.AnnouncementListRes, error) + // PublishAnnouncement 发布公告 + PublishAnnouncement(ctx context.Context, req *v1.AnnouncementPublishReq) (*v1.AnnouncementPublishRes, error) + // ArchiveAnnouncement 归档公告 + ArchiveAnnouncement(ctx context.Context, req *v1.AnnouncementArchiveReq) (*v1.AnnouncementArchiveRes, error) + // ListOrders 获取全部订单列表 + ListOrders(ctx context.Context, req *v1.OrderListReq) (*v1.OrderListRes, error) + // GetOrder 获取订单详情 + GetOrder(ctx context.Context, req *v1.OrderDetailReq) (*v1.OrderDetailRes, error) + // RefundOrder 发起退款 + RefundOrder(ctx context.Context, req *v1.OrderRefundReq) (*v1.OrderRefundRes, error) + // OrderComplete 手动完成订单 + OrderComplete(ctx context.Context, req *v1.OrderCompleteReq) (*v1.OrderCompleteRes, error) + // GetPaymentChannels 获取支付渠道配置 + GetPaymentChannels(ctx context.Context, _ *v1.PaymentChannelListReq) (*v1.PaymentChannelListRes, error) + // CreatePaymentChannel 创建支付渠道(默认禁用)。 + CreatePaymentChannel(ctx context.Context, req *v1.PaymentChannelCreateReq) (*v1.PaymentChannelCreateRes, error) + // GetPaymentChannel 获取单个支付渠道详情。 + GetPaymentChannel(ctx context.Context, req *v1.PaymentChannelDetailReq) (*v1.PaymentChannelDetailRes, error) + // UpdatePaymentChannel 更新支付渠道配置 + UpdatePaymentChannel(ctx context.Context, req *v1.PaymentChannelUpdateReq) (*v1.PaymentChannelUpdateRes, error) + // DeletePaymentChannel 删除支付渠道。 + DeletePaymentChannel(ctx context.Context, req *v1.PaymentChannelDeleteReq) (*v1.PaymentChannelDeleteRes, error) + // TogglePaymentChannel 切换支付渠道启用/禁用状态。 + TogglePaymentChannel(ctx context.Context, req *v1.PaymentChannelToggleReq) (*v1.PaymentChannelToggleRes, error) + // GetPaymentSettings 获取全局支付设置。 + GetPaymentSettings(ctx context.Context, _ *v1.PaymentSettingsGetReq) (*v1.PaymentSettingsGetRes, error) + // UpdatePaymentSettings 更新全局支付设置。 + UpdatePaymentSettings(ctx context.Context, req *v1.PaymentSettingsUpdateReq) (*v1.PaymentSettingsUpdateRes, error) + // ExportOrders exports order list to CSV or Excel. + ExportOrders(ctx context.Context, req *v1.OrderExportReq) (*v1.OrderExportRes, error) + // GetUserPermissions returns permission points and data scopes for an admin user. + GetUserPermissions(ctx context.Context, req *v1.AdminPermissionListReq) (*v1.AdminPermissionListRes, error) + // UpdateUserPermissions updates permission points for an admin user. + UpdateUserPermissions(ctx context.Context, req *v1.AdminPermissionUpdateReq) (*v1.AdminPermissionUpdateRes, error) + // UpdateUserDataScopes updates data scopes for an admin user. + UpdateUserDataScopes(ctx context.Context, req *v1.AdminDataScopeUpdateReq) (*v1.AdminDataScopeUpdateRes, error) + // GetAllPermissions returns all predefined permission groups. + GetAllPermissions(ctx context.Context, _ *v1.AdminAllPermissionsReq) (*v1.AdminAllPermissionsRes, error) + // ListPlans 获取套餐列表 + ListPlans(ctx context.Context, req *v1.PlanListReq) (*v1.PlanListRes, error) + // GetPlan 获取套餐详情 + GetPlan(ctx context.Context, req *v1.PlanDetailReq) (*v1.PlanDetailRes, error) + // CreatePlan 创建套餐 + CreatePlan(ctx context.Context, req *v1.PlanCreateReq) (*v1.PlanCreateRes, error) + // UpdatePlan 更新套餐 + UpdatePlan(ctx context.Context, req *v1.PlanUpdateReq) (*v1.PlanUpdateRes, error) + // ArchivePlan 下架套餐(软删除) + ArchivePlan(ctx context.Context, req *v1.PlanArchiveReq) (*v1.PlanArchiveRes, error) + // ToggleRecommend 切换推荐标记 + ToggleRecommend(ctx context.Context, req *v1.PlanToggleRecommendReq) (*v1.PlanToggleRecommendRes, error) + // ExportPlans exports plan list to CSV or Excel. + ExportPlans(ctx context.Context, req *v1.PlanExportReq) (*v1.PlanExportRes, error) + PluginList(ctx context.Context, req *v1.PluginListReq) (*v1.PluginListRes, error) + PluginDetail(ctx context.Context, req *v1.PluginDetailReq) (*v1.PluginDetailRes, error) + PluginInstall(ctx context.Context, req *v1.PluginInstallReq) (*v1.PluginInstallRes, error) + PluginEnable(ctx context.Context, req *v1.PluginEnableReq) (*v1.PluginEnableRes, error) + PluginDisable(ctx context.Context, req *v1.PluginDisableReq) (*v1.PluginDisableRes, error) + PluginUninstall(ctx context.Context, req *v1.PluginUninstallReq) (*v1.PluginUninstallRes, error) + PluginUpgrade(ctx context.Context, req *v1.PluginUpgradeReq) (*v1.PluginUpgradeRes, error) + PluginConfigUpdate(ctx context.Context, req *v1.PluginConfigUpdateReq) (*v1.PluginConfigUpdateRes, error) + PluginConfigSchema(ctx context.Context, req *v1.PluginConfigSchemaReq) (*v1.PluginConfigSchemaRes, error) + // ListPromoCodes 获取优惠码列表 + ListPromoCodes(ctx context.Context, req *v1.PromoCodeListReq) (*v1.PromoCodeListRes, error) + // CreatePromoCode 创建优惠码 + CreatePromoCode(ctx context.Context, req *v1.PromoCodeCreateReq) (*v1.PromoCodeCreateRes, error) + // UpdatePromoCode 更新优惠码 + UpdatePromoCode(ctx context.Context, req *v1.PromoCodeUpdateReq) (*v1.PromoCodeUpdateRes, error) + // GetPromoCodeUsages 获取优惠码使用记录 + GetPromoCodeUsages(ctx context.Context, req *v1.PromoCodeUsagesReq) (*v1.PromoCodeUsagesRes, error) + // ExportPromoCodes exports promo code list to CSV or Excel. + ExportPromoCodes(ctx context.Context, req *v1.PromoCodeExportReq) (*v1.PromoCodeExportRes, error) + // ListRedemptions 获取兑换码列表 + ListRedemptions(ctx context.Context, req *v1.RedemptionListReq) (*v1.RedemptionListRes, error) + // BatchCreateRedemptions 批量生成兑换码 + BatchCreateRedemptions(ctx context.Context, req *v1.RedemptionCreateReq) (*v1.RedemptionCreateRes, error) + // DisableRedemption 禁用兑换码 + DisableRedemption(ctx context.Context, req *v1.RedemptionDisableReq) (*v1.RedemptionDisableRes, error) + // ListRedemptionUsages 获取兑换码使用记录 + ListRedemptionUsages(ctx context.Context, req *v1.RedemptionUsagesReq) (*v1.RedemptionUsagesRes, error) + // ExportRedemptions exports redemption list to CSV or Excel. + ExportRedemptions(ctx context.Context, req *v1.RedemptionExportReq) (*v1.RedemptionExportRes, error) + // Verify2FA handles the 2FA verification step during admin login. + Verify2FA(ctx context.Context, req *v1.Admin2FAVerifyReq) (*v1.Admin2FAVerifyRes, error) + // Setup2FA starts the 2FA setup process for the current admin user. + Setup2FA(ctx context.Context, _ *v1.Admin2FASetupReq) (*v1.Admin2FASetupRes, error) + // Enable2FA confirms and enables 2FA after verifying the code. + Enable2FA(ctx context.Context, req *v1.Admin2FAEnableReq) (*v1.Admin2FAEnableRes, error) + // Disable2FA disables 2FA for the current admin user. + Disable2FA(ctx context.Context, req *v1.Admin2FADisableReq) (*v1.Admin2FADisableRes, error) + // RegenerateBackupCodes generates new backup codes. + RegenerateBackupCodes(ctx context.Context, req *v1.Admin2FARegenerateBackupCodesReq) (*v1.Admin2FARegenerateBackupCodesRes, error) + // ConfirmHighRisk generates a confirm token for high-risk operations. + ConfirmHighRisk(ctx context.Context, req *v1.Admin2FAConfirmReq) (*v1.Admin2FAConfirmRes, error) + // LoginHistory returns the login history for all admin users with search filters. + LoginHistory(ctx context.Context, req *v1.AdminLoginHistoryReq) (*v1.AdminLoginHistoryRes, error) + // GetSettingsCategories returns all available setting categories. + GetSettingsCategories(ctx context.Context, _ *v1.AdminSettingsCategoriesReq) (*v1.AdminSettingsCategoriesRes, error) + // GetSettings retrieves settings with schema for a given category. + GetSettings(ctx context.Context, req *v1.AdminSettingsGetReq) (*v1.AdminSettingsGetRes, error) + // UpdateSettings batch-updates settings for a given category. + UpdateSettings(ctx context.Context, req *v1.AdminSettingsUpdateReq) (*v1.AdminSettingsUpdateRes, error) + // TaskList 任务列表 + TaskList(ctx context.Context, req *v1.TaskListReq) (*v1.TaskListRes, error) + // TaskDetail 任务详情 + TaskDetail(ctx context.Context, req *v1.TaskDetailReq) (*v1.TaskDetailRes, error) + // TaskCancel 取消任务 + TaskCancel(ctx context.Context, req *v1.TaskCancelReq) (*v1.TaskCancelRes, error) + // TaskRetry 重试失败任务 + TaskRetry(ctx context.Context, req *v1.TaskRetryReq) (*v1.TaskRetryRes, error) + // TenantSelect returns a lightweight paginated tenant list for dropdown selectors. + TenantSelect(ctx context.Context, req *v1.TenantSelectReq) (*v1.TenantSelectRes, error) + // CreateTenant creates a new tenant with its owner user and wallet. + CreateTenant(ctx context.Context, req *v1.TenantCreateReq) (*v1.TenantCreateRes, error) + // ListTenants returns a paginated list of tenants. + ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.TenantListRes, error) + // GetTenant returns detail of a single tenant. + GetTenant(ctx context.Context, req *v1.TenantGetReq) (*v1.TenantGetRes, error) + // UpdateTenantStatus updates a tenant's status. + UpdateTenantStatus(ctx context.Context, req *v1.TenantUpdateStatusReq) (*v1.TenantUpdateStatusRes, error) + // UpdateTenant updates tenant information. + UpdateTenant(ctx context.Context, req *v1.TenantUpdateReq) (*v1.TenantUpdateRes, error) + // UpdateTenantChannelScope 更新租户默认渠道范围 + UpdateTenantChannelScope(ctx context.Context, req *v1.TenantChannelScopeUpdateReq) (*v1.TenantChannelScopeUpdateRes, error) + // ExportTenants exports tenant list to CSV or Excel. + ExportTenants(ctx context.Context, req *v1.TenantExportReq) (*v1.TenantExportRes, error) + // ListTenantModels 列出租户已分配的模型 + ListTenantModels(ctx context.Context, req *v1.TenantModelListReq) (*v1.TenantModelListRes, error) + // BatchAssignModels 批量分配模型给租户 + BatchAssignModels(ctx context.Context, req *v1.TenantModelBatchAssignReq) (*v1.TenantModelBatchAssignRes, error) + // UpdateTenantModel 更新租户模型配置 + UpdateTenantModel(ctx context.Context, req *v1.TenantModelUpdateReq) (*v1.TenantModelUpdateRes, error) + // DeleteTenantModel 移除租户模型分配 + DeleteTenantModel(ctx context.Context, req *v1.TenantModelDeleteReq) (*v1.TenantModelDeleteRes, error) + // ListAllTickets 获取全部工单列表(管理后台) + ListAllTickets(ctx context.Context, req *v1.TicketListReq) (*v1.TicketListRes, error) + // GetTicketAdmin 获取工单详情(管理后台,含回复) + GetTicketAdmin(ctx context.Context, req *v1.TicketGetReq) (*v1.TicketGetRes, error) + // AssignTicket 分配工单给管理员 + AssignTicket(ctx context.Context, req *v1.TicketAssignReq) (*v1.TicketAssignRes, error) + // ReplyToTicketAdmin 管理员回复工单 + ReplyToTicketAdmin(ctx context.Context, req *v1.TicketReplyReq) (*v1.TicketReplyRes, error) + // UpdateTicketStatus 更新工单状态 + UpdateTicketStatus(ctx context.Context, req *v1.TicketStatusUpdateReq) (*v1.TicketStatusUpdateRes, error) + UsageLogCleanupCreate(ctx context.Context, req *v1.UsageLogCleanupCreateReq) (*v1.UsageLogCleanupCreateRes, error) + UsageLogCleanupList(ctx context.Context, req *v1.UsageLogCleanupListReq) (*v1.UsageLogCleanupListRes, error) + UsageLogCleanupCancel(ctx context.Context, req *v1.UsageLogCleanupCancelReq) (*v1.UsageLogCleanupCancelRes, error) + } +) + +var ( + localAdmin IAdmin +) + +func Admin() IAdmin { + if localAdmin == nil { + panic("implement not found for interface IAdmin, forgot register?") + } + return localAdmin +} + +func RegisterAdmin(i IAdmin) { + localAdmin = i +} diff --git a/internal/service/billing.go b/internal/service/billing.go new file mode 100644 index 0000000..3d70438 --- /dev/null +++ b/internal/service/billing.go @@ -0,0 +1,8 @@ +// ================================================================================ +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// You can delete these comments if you wish manually maintain this interface file. +// ================================================================================ + +package service + +type () diff --git a/internal/service/common.go b/internal/service/common.go new file mode 100644 index 0000000..3d70438 --- /dev/null +++ b/internal/service/common.go @@ -0,0 +1,8 @@ +// ================================================================================ +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// You can delete these comments if you wish manually maintain this interface file. +// ================================================================================ + +package service + +type () diff --git a/internal/service/docs.go b/internal/service/docs.go new file mode 100644 index 0000000..898a43f --- /dev/null +++ b/internal/service/docs.go @@ -0,0 +1,34 @@ +// ================================================================================ +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// You can delete these comments if you wish manually maintain this interface file. +// ================================================================================ + +package service + +import ( + "context" + "encoding/json" + + v1 "github.com/qianfree/team-api/api/docs/v1" +) + +type ( + IDocs interface { + OpenAPISpec(ctx context.Context, _ *v1.OpenAPISpecReq) (json.RawMessage, error) + } +) + +var ( + localDocs IDocs +) + +func Docs() IDocs { + if localDocs == nil { + panic("implement not found for interface IDocs, forgot register?") + } + return localDocs +} + +func RegisterDocs(i IDocs) { + localDocs = i +} diff --git a/internal/service/monitor.go b/internal/service/monitor.go new file mode 100644 index 0000000..b97a22b --- /dev/null +++ b/internal/service/monitor.go @@ -0,0 +1,49 @@ +// ================================================================================ +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// You can delete these comments if you wish manually maintain this interface file. +// ================================================================================ + +package service + +import ( + "context" + + v1 "github.com/qianfree/team-api/api/admin/v1" +) + +type ( + IMonitor interface { + Dashboard(ctx context.Context, req *v1.MonitorDashboardReq) (*v1.MonitorDashboardRes, error) + Traffic(ctx context.Context, req *v1.MonitorTrafficReq) (*v1.MonitorTrafficRes, error) + Latency(ctx context.Context, req *v1.MonitorLatencyReq) (*v1.MonitorLatencyRes, error) + System(ctx context.Context, req *v1.MonitorSystemReq) (*v1.MonitorSystemRes, error) + DBPool(ctx context.Context, _ *v1.MonitorDBPoolReq) (*v1.MonitorDBPoolRes, error) + RedisPool(ctx context.Context, _ *v1.MonitorRedisPoolReq) (*v1.MonitorRedisPoolRes, error) + Realtime(ctx context.Context, _ *v1.MonitorRealtimeReq) (*v1.MonitorRealtimeRes, error) + AlertRuleList(ctx context.Context, req *v1.AlertRuleListReq) (*v1.AlertRuleListRes, error) + AlertOptions(ctx context.Context, _ *v1.AlertOptionsReq) (*v1.AlertOptionsRes, error) + CreateAlertRule(ctx context.Context, req *v1.AlertRuleCreateReq) (*v1.AlertRuleCreateRes, error) + UpdateAlertRule(ctx context.Context, req *v1.AlertRuleUpdateReq) (*v1.AlertRuleUpdateRes, error) + DeleteAlertRule(ctx context.Context, req *v1.AlertRuleDeleteReq) (*v1.AlertRuleDeleteRes, error) + ToggleAlertRule(ctx context.Context, req *v1.AlertRuleToggleReq) (*v1.AlertRuleToggleRes, error) + AlertTest(ctx context.Context, req *v1.AlertTestReq) (*v1.AlertTestRes, error) + AlertEventList(ctx context.Context, req *v1.AlertEventListReq) (*v1.AlertEventListRes, error) + AcknowledgeAlert(ctx context.Context, req *v1.AlertEventAcknowledgeReq) (*v1.AlertEventAcknowledgeRes, error) + ResolveAlert(ctx context.Context, req *v1.AlertEventResolveReq) (*v1.AlertEventResolveRes, error) + } +) + +var ( + localMonitor IMonitor +) + +func Monitor() IMonitor { + if localMonitor == nil { + panic("implement not found for interface IMonitor, forgot register?") + } + return localMonitor +} + +func RegisterMonitor(i IMonitor) { + localMonitor = i +} diff --git a/internal/service/open.go b/internal/service/open.go new file mode 100644 index 0000000..f60afe6 --- /dev/null +++ b/internal/service/open.go @@ -0,0 +1,40 @@ +package service + +import ( + "context" + + v1 "github.com/qianfree/team-api/api/open/v1" +) + +type ( + IOpen interface { + OpenMemberList(ctx context.Context, req *v1.OpenMemberListReq) (*v1.OpenMemberListRes, error) + OpenMemberCreate(ctx context.Context, req *v1.OpenMemberCreateReq) (*v1.OpenMemberCreateRes, error) + OpenMemberUpdate(ctx context.Context, req *v1.OpenMemberUpdateReq) (*v1.OpenMemberUpdateRes, error) + OpenMemberDelete(ctx context.Context, req *v1.OpenMemberDeleteReq) (*v1.OpenMemberDeleteRes, error) + OpenMemberQuota(ctx context.Context, req *v1.OpenMemberQuotaReq) (*v1.OpenMemberQuotaRes, error) + OpenMemberQuotaUpdate(ctx context.Context, req *v1.OpenMemberQuotaUpdateReq) (*v1.OpenMemberQuotaUpdateRes, error) + OpenMemberModels(ctx context.Context, req *v1.OpenMemberModelsReq) (*v1.OpenMemberModelsRes, error) + OpenMemberModelsUpdate(ctx context.Context, req *v1.OpenMemberModelsUpdateReq) (*v1.OpenMemberModelsUpdateRes, error) + OpenKeyList(ctx context.Context, req *v1.OpenKeyListReq) (*v1.OpenKeyListRes, error) + OpenKeyCreate(ctx context.Context, req *v1.OpenKeyCreateReq) (*v1.OpenKeyCreateRes, error) + OpenKeyDelete(ctx context.Context, req *v1.OpenKeyDeleteReq) (*v1.OpenKeyDeleteRes, error) + OpenUsageQuery(ctx context.Context, req *v1.OpenUsageQueryReq) (*v1.OpenUsageQueryRes, error) + OpenBillingQuery(ctx context.Context, req *v1.OpenBillingQueryReq) (*v1.OpenBillingQueryRes, error) + } +) + +var ( + localOpen IOpen +) + +func Open() IOpen { + if localOpen == nil { + panic("implement not found for interface IOpen, forgot register?") + } + return localOpen +} + +func RegisterOpen(i IOpen) { + localOpen = i +} diff --git a/internal/service/payment.go b/internal/service/payment.go new file mode 100644 index 0000000..3d70438 --- /dev/null +++ b/internal/service/payment.go @@ -0,0 +1,8 @@ +// ================================================================================ +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// You can delete these comments if you wish manually maintain this interface file. +// ================================================================================ + +package service + +type () diff --git a/internal/service/relay.go b/internal/service/relay.go new file mode 100644 index 0000000..3d70438 --- /dev/null +++ b/internal/service/relay.go @@ -0,0 +1,8 @@ +// ================================================================================ +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// You can delete these comments if you wish manually maintain this interface file. +// ================================================================================ + +package service + +type () diff --git a/internal/service/settings.go b/internal/service/settings.go new file mode 100644 index 0000000..370f84d --- /dev/null +++ b/internal/service/settings.go @@ -0,0 +1,34 @@ +// ================================================================================ +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// You can delete these comments if you wish manually maintain this interface file. +// ================================================================================ + +package service + +import ( + "context" + + v1 "github.com/qianfree/team-api/api/settings/v1" +) + +type ( + ISettings interface { + PublicSettingsGet(ctx context.Context, _ *v1.PublicSettingsGetReq) (*v1.PublicSettingsGetRes, error) + PublicAnnouncements(ctx context.Context, req *v1.PublicAnnouncementsReq) (*v1.PublicAnnouncementsRes, error) + } +) + +var ( + localSettings ISettings +) + +func Settings() ISettings { + if localSettings == nil { + panic("implement not found for interface ISettings, forgot register?") + } + return localSettings +} + +func RegisterSettings(i ISettings) { + localSettings = i +} diff --git a/internal/service/task.go b/internal/service/task.go new file mode 100644 index 0000000..3d70438 --- /dev/null +++ b/internal/service/task.go @@ -0,0 +1,8 @@ +// ================================================================================ +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// You can delete these comments if you wish manually maintain this interface file. +// ================================================================================ + +package service + +type () diff --git a/internal/service/tenant.go b/internal/service/tenant.go new file mode 100644 index 0000000..d85f7c0 --- /dev/null +++ b/internal/service/tenant.go @@ -0,0 +1,312 @@ +// ================================================================================ +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// You can delete these comments if you wish manually maintain this interface file. +// ================================================================================ + +package service + +import ( + "context" + + v1 "github.com/qianfree/team-api/api/tenant/v1" +) + +type ( + ITenant interface { + // ApiKeyList 列出 API Keys,支持按类型过滤 + ApiKeyList(ctx context.Context, req *v1.TenantApiKeyListReq) (*v1.TenantApiKeyListRes, error) + // ApiKeyCreate 创建新的 API Key + ApiKeyCreate(ctx context.Context, req *v1.TenantApiKeyCreateReq) (*v1.TenantApiKeyCreateRes, error) + // ApiKeyDelete 禁用 API Key + ApiKeyDelete(ctx context.Context, req *v1.TenantApiKeyDeleteReq) (*v1.TenantApiKeyDeleteRes, error) + // ApiKeyUpdateScopes 更新 API Key 的模型 scope + ApiKeyUpdateScopes(ctx context.Context, req *v1.TenantApiKeyUpdateScopesReq) (*v1.TenantApiKeyUpdateScopesRes, error) + // ExportApiKeys exports the tenant API key list as CSV or Excel. + ExportApiKeys(ctx context.Context, req *v1.TenantApiKeyExportReq) (*v1.TenantApiKeyExportRes, error) + // AuditConfigGet returns the tenant's own audit level. + // 租户审计级别与全局级别完全独立,未设置时默认 masked。 + AuditConfigGet(ctx context.Context, req *v1.TenantAuditConfigGetReq) (*v1.TenantAuditConfigGetRes, error) + // AuditConfigUpdate sets the audit level for a specific tenant. + // 租户可独立设置自己的审计级别,不受全局级别约束(双级别存储,各管各的)。 + AuditConfigUpdate(ctx context.Context, req *v1.TenantAuditConfigUpdateReq) (*v1.TenantAuditConfigUpdateRes, error) + // AuditLogs returns a paginated list of audit logs for the tenant. + AuditLogs(ctx context.Context, req *v1.TenantAuditLogsReq) (*v1.TenantAuditLogsRes, error) + // TenantRequestAuditLogs 分页查询租户的请求审计日志(不含 body,性能优先) + TenantRequestAuditLogs(ctx context.Context, req *v1.TenantRequestAuditLogsReq) (*v1.TenantRequestAuditLogsRes, error) + // TenantRequestAuditLogDetail 查询单条请求审计日志详情(含 request_body 和 response_body) + TenantRequestAuditLogDetail(ctx context.Context, req *v1.TenantRequestAuditLogDetailReq) (*v1.TenantRequestAuditLogDetailRes, error) + // Register handles tenant registration. + Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.TenantRegisterRes, error) + // Login handles tenant user login. + Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.TenantLoginRes, error) + // Logout handles tenant user logout. + Logout(ctx context.Context, req *v1.TenantLogoutReq) (*v1.TenantLogoutRes, error) + // Refresh handles token refresh for tenant users. + Refresh(ctx context.Context, req *v1.TenantRefreshReq) (*v1.TenantRefreshRes, error) + // ChangePassword handles tenant user password change. + ChangePassword(ctx context.Context, req *v1.TenantChangePasswordReq) (*v1.TenantChangePasswordRes, error) + // ListSessions returns active sessions for the current tenant user. + ListSessions(ctx context.Context, req *v1.TenantSessionListReq) (*v1.TenantSessionListRes, error) + // RevokeSession revokes a specific session. + RevokeSession(ctx context.Context, req *v1.TenantRevokeSessionReq) (*v1.TenantRevokeSessionRes, error) + // Wallet 获取租户钱包余额 + Wallet(ctx context.Context, req *v1.TenantWalletReq) (*v1.TenantWalletRes, error) + // WalletTransactions 获取租户钱包流水 + WalletTransactions(ctx context.Context, req *v1.TenantWalletTransactionsReq) (*v1.TenantWalletTransactionsRes, error) + // BillingRecords 获取租户计费记录 + BillingRecords(ctx context.Context, req *v1.TenantBillingRecordsReq) (*v1.TenantBillingRecordsRes, error) + // UsageLogs 获取租户用量日志 + UsageLogs(ctx context.Context, req *v1.TenantUsageLogsReq) (*v1.TenantUsageLogsRes, error) + // ExportUsageLogs exports the tenant usage logs as CSV or Excel. + ExportUsageLogs(ctx context.Context, req *v1.TenantUsageLogsExportReq) (*v1.TenantUsageLogsExportRes, error) + // ExportWalletTransactions exports the tenant wallet transactions as CSV or Excel. + ExportWalletTransactions(ctx context.Context, req *v1.TenantWalletTransactionsExportReq) (*v1.TenantWalletTransactionsExportRes, error) + // Dashboard returns the tenant dashboard statistics. + Dashboard(ctx context.Context, req *v1.TenantDashboardReq) (*v1.TenantDashboardRes, error) + // TokenTrends returns daily token usage for the past N days. + TokenTrends(ctx context.Context, req *v1.TenantTokenTrendsReq) (*v1.TenantTokenTrendsRes, error) + // ModelDistribution returns the distribution of model usage. + ModelDistribution(ctx context.Context, req *v1.TenantModelDistributionReq) (*v1.TenantModelDistributionRes, error) + // BalancePrediction predicts when the balance will be exhausted. + BalancePrediction(ctx context.Context, req *v1.TenantBalancePredictionReq) (*v1.TenantBalancePredictionRes, error) + // BudgetAlerts checks member and project budget usage and returns those above 80%. + BudgetAlerts(ctx context.Context, req *v1.TenantBudgetAlertsReq) (*v1.TenantBudgetAlertsRes, error) + // GetMemberUsageRanking returns top members by usage cost in a given date range. + GetMemberUsageRanking(ctx context.Context, req *v1.TenantMemberUsageRankingReq) (*v1.TenantMemberUsageRankingRes, error) + // SendCode sends a verification code email. + SendCode(ctx context.Context, req *v1.TenantSendCodeReq) (*v1.TenantSendCodeRes, error) + // ResetPassword handles password reset. + ResetPassword(ctx context.Context, req *v1.TenantResetPasswordReq) (*v1.TenantResetPasswordRes, error) + // ChangeEmail handles email change for a tenant user. + ChangeEmail(ctx context.Context, req *v1.TenantChangeEmailReq) (*v1.TenantChangeEmailRes, error) + // CreateFeedback 提交反馈 + CreateFeedback(ctx context.Context, req *v1.FeedbackCreateReq) (*v1.FeedbackCreateRes, error) + // ListFeedbacks 我的反馈列表 + ListFeedbacks(ctx context.Context, req *v1.FeedbackListReq) (*v1.FeedbackListRes, error) + // GetFeedback 反馈详情 + GetFeedback(ctx context.Context, req *v1.FeedbackGetReq) (*v1.FeedbackGetRes, error) + // ListHelpPublicCategories 公开帮助分类列表(树结构) + ListHelpPublicCategories(ctx context.Context, _ *v1.HelpPublicCategoryListReq) (*v1.HelpPublicCategoryListRes, error) + // ListHelpPublicArticles 分类下的文章列表 + ListHelpPublicArticles(ctx context.Context, req *v1.HelpPublicArticleListReq) (*v1.HelpPublicArticleListRes, error) + // GetHelpPublicArticle 文章详情 + GetHelpPublicArticle(ctx context.Context, req *v1.HelpPublicArticleGetReq) (*v1.HelpPublicArticleGetRes, error) + // SearchHelpPublicArticles 搜索文章 + SearchHelpPublicArticles(ctx context.Context, req *v1.HelpPublicSearchReq) (*v1.HelpPublicSearchRes, error) + // InvitationList returns a paginated list of invitation records for the tenant. + InvitationList(ctx context.Context, req *v1.TenantInvitationListReq) (*v1.TenantInvitationListRes, error) + // RevokeInvitation revokes a pending invitation by setting used_by_user_id = -1. + RevokeInvitation(ctx context.Context, req *v1.TenantInvitationRevokeReq) (*v1.TenantInvitationRevokeRes, error) + // InviteInfo returns public information about an invitation (no auth required). + InviteInfo(ctx context.Context, req *v1.TenantInviteInfoReq) (*v1.TenantInviteInfoRes, error) + // RequestClosure 申请关户 + RequestClosure(ctx context.Context, req *v1.TenantRequestClosureReq) (*v1.TenantRequestClosureRes, error) + // CancelClosure 取消关户 + CancelClosure(ctx context.Context, req *v1.TenantCancelClosureReq) (*v1.TenantCancelClosureRes, error) + // ListMembers returns a paginated list of tenant members. + ListMembers(ctx context.Context, req *v1.TenantMemberListReq) (*v1.TenantMemberListRes, error) + // InviteMember generates an invitation link. + InviteMember(ctx context.Context, req *v1.TenantMemberInviteReq) (*v1.TenantMemberInviteRes, error) + // JoinByInvite handles a user joining a tenant via invitation link. + JoinByInvite(ctx context.Context, req *v1.TenantMemberJoinReq) (*v1.TenantMemberJoinRes, error) + // CreateMember directly creates a member account within the tenant. + CreateMember(ctx context.Context, req *v1.TenantMemberCreateReq) (*v1.TenantMemberCreateRes, error) + // RemoveMember removes a member from the tenant. + // Revokes all API keys, anonymizes personal data, releases member model scopes. + RemoveMember(ctx context.Context, req *v1.TenantMemberRemoveReq) (*v1.TenantMemberRemoveRes, error) + // DisableMember disables a member and revokes all their API keys. + DisableMember(ctx context.Context, tenantID int64, userID int64) error + // EnableMember re-enables a member and restores their API keys. + EnableMember(ctx context.Context, tenantID int64, userID int64) error + // UpdateMemberRole updates a member's role. + UpdateMemberRole(ctx context.Context, req *v1.TenantMemberUpdateRoleReq) (*v1.TenantMemberUpdateRoleRes, error) + // ResetMemberPassword resets a member's password. Only admins can reset other members' passwords. + ResetMemberPassword(ctx context.Context, req *v1.TenantMemberResetPasswordReq) (*v1.TenantMemberResetPasswordRes, error) + // GetMember returns a single member's detail. + GetMember(ctx context.Context, req *v1.TenantMemberGetReq) (*v1.TenantMemberGetRes, error) + // GetMemberUsage returns usage statistics for a single member. + GetMemberUsage(ctx context.Context, req *v1.TenantMemberUsageReq) (*v1.TenantMemberUsageRes, error) + // ListMemberApiKeys returns a paginated list of API keys belonging to a specific member. + ListMemberApiKeys(ctx context.Context, req *v1.TenantMemberApiKeysReq) (*v1.TenantMemberApiKeysRes, error) + // ExportMembers exports the tenant member list as CSV or Excel. + ExportMembers(ctx context.Context, req *v1.TenantMemberExportReq) (*v1.TenantMemberExportRes, error) + // MemberImport parses CSV content, validates, creates an import record. + MemberImport(ctx context.Context, req *v1.TenantMemberImportReq) (*v1.TenantMemberImportRes, error) + // ImportRecords returns a paginated list of import records. + ImportRecords(ctx context.Context, req *v1.TenantImportRecordsReq) (*v1.TenantImportRecordsRes, error) + // ImportRecordGet returns the status of a member import. + ImportRecordGet(ctx context.Context, req *v1.TenantImportRecordGetReq) (*v1.TenantImportRecordGetRes, error) + // MemberModelScopes returns the model IDs available for a member. + MemberModelScopes(ctx context.Context, req *v1.TenantMemberModelScopesReq) (*v1.TenantMemberModelScopesRes, error) + // MemberModelScopesSet sets the available models for a member (full replace). + MemberModelScopesSet(ctx context.Context, req *v1.TenantMemberModelScopesSetReq) (*v1.TenantMemberModelScopesSetRes, error) + MemberQuota(ctx context.Context, req *v1.TenantMemberQuotaReq) (*v1.TenantMemberQuotaRes, error) + MemberQuotaSet(ctx context.Context, req *v1.TenantMemberQuotaSetReq) (*v1.TenantMemberQuotaSetRes, error) + // ListAvailableModels 获取租户可用的模型列表 + ListAvailableModels(ctx context.Context, req *v1.TenantAvailableModelsReq) (*v1.TenantAvailableModelsRes, error) + ModelComparison(ctx context.Context, req *v1.ModelComparisonReq) (*v1.ModelComparisonRes, error) + // Notifications 获取用户的通知列表(个人消息 + 广播消息) + Notifications(ctx context.Context, req *v1.TenantNotificationsReq) (*v1.TenantNotificationsRes, error) + // UnreadCount 获取未读消息数量 + UnreadCount(ctx context.Context, req *v1.TenantUnreadCountReq) (*v1.TenantUnreadCountRes, error) + // MarkRead 标记消息为已读 + MarkRead(ctx context.Context, req *v1.TenantMarkReadReq) (*v1.TenantMarkReadRes, error) + // MarkAllRead 标记所有未读消息为已读 + MarkAllRead(ctx context.Context, req *v1.TenantMarkAllReadReq) (*v1.TenantMarkAllReadRes, error) + // NotificationPreferencesGet 获取合并后的通知偏好(组织 + 用户) + NotificationPreferencesGet(ctx context.Context, req *v1.TenantNotificationPreferencesGetReq) (*v1.TenantNotificationPreferencesGetRes, error) + // NotificationPreferencesUpdate 更新通知偏好 + NotificationPreferencesUpdate(ctx context.Context, req *v1.TenantNotificationPreferencesUpdateReq) (*v1.TenantNotificationPreferencesUpdateRes, error) + // Announcements 获取已发布的公告(未过期) + Announcements(ctx context.Context, req *v1.TenantAnnouncementsReq) (*v1.TenantAnnouncementsRes, error) + // ExportNotifications exports the tenant notification list as CSV or Excel. + ExportNotifications(ctx context.Context, req *v1.TenantNotificationsExportReq) (*v1.TenantNotificationsExportRes, error) + // GetOAuthAuthorizeURL 获取 OAuth 授权跳转 URL + GetOAuthAuthorizeURL(ctx context.Context, req *v1.OAuthAuthorizeReq) (*v1.OAuthAuthorizeRes, error) + // OAuthCallback 处理 OAuth 回调 + OAuthCallback(ctx context.Context, req *v1.OAuthCallbackReq) (*v1.OAuthCallbackRes, error) + // LinkOAuth 绑定 OAuth 账号 + LinkOAuth(ctx context.Context, req *v1.OAuthLinkReq) (*v1.OAuthLinkRes, error) + // UnlinkOAuth 解绑 OAuth 账号 + UnlinkOAuth(ctx context.Context, req *v1.OAuthUnlinkReq) (*v1.OAuthUnlinkRes, error) + // ListOAuthProviders 获取已绑定的 OAuth 供应商列表 + ListOAuthProviders(ctx context.Context, req *v1.OAuthListProvidersReq) (*v1.OAuthListProvidersRes, error) + OpenAppList(ctx context.Context, req *v1.OpenAppListReq) (*v1.OpenAppListRes, error) + OpenAppCreate(ctx context.Context, req *v1.OpenAppCreateReq) (*v1.OpenAppCreateRes, error) + OpenAppUpdate(ctx context.Context, req *v1.OpenAppUpdateReq) (*v1.OpenAppUpdateRes, error) + OpenAppDelete(ctx context.Context, req *v1.OpenAppDeleteReq) (*v1.OpenAppDeleteRes, error) + OpenAppResetSecret(ctx context.Context, req *v1.OpenAppResetSecretReq) (*v1.OpenAppResetSecretRes, error) + OpenAppToggleStatus(ctx context.Context, req *v1.OpenAppToggleStatusReq) (*v1.OpenAppToggleStatusRes, error) + WebhookConfigList(ctx context.Context, _ *v1.WebhookConfigListReq) (*v1.WebhookConfigListRes, error) + WebhookConfigCreate(ctx context.Context, req *v1.WebhookConfigCreateReq) (*v1.WebhookConfigCreateRes, error) + WebhookConfigUpdate(ctx context.Context, req *v1.WebhookConfigUpdateReq) (*v1.WebhookConfigUpdateRes, error) + WebhookConfigDelete(ctx context.Context, req *v1.WebhookConfigDeleteReq) (*v1.WebhookConfigDeleteRes, error) + WebhookDeliveryLogs(ctx context.Context, req *v1.WebhookDeliveryLogsReq) (*v1.WebhookDeliveryLogsRes, error) + WebhookRetry(ctx context.Context, req *v1.WebhookRetryReq) (*v1.WebhookRetryRes, error) + // OrderList 获取租户订单列表 + OrderList(ctx context.Context, req *v1.TenantOrderListReq) (*v1.TenantOrderListRes, error) + // OrderDetail 获取订单详情 + OrderDetail(ctx context.Context, req *v1.TenantOrderDetailReq) (*v1.TenantOrderDetailRes, error) + // OrderCreate 创建订单 + OrderCreate(ctx context.Context, req *v1.TenantOrderCreateReq) (*v1.TenantOrderCreateRes, error) + // OrderCancel 取消订单 + OrderCancel(ctx context.Context, req *v1.TenantOrderCancelReq) (*v1.TenantOrderCancelRes, error) + // OrderPay 支付订单 + OrderPay(ctx context.Context, req *v1.TenantOrderPayReq) (*v1.TenantOrderPayRes, error) + // PaymentInfo 获取租户可用的支付信息(渠道列表、金额选项、折扣) + PaymentInfo(ctx context.Context, req *v1.TenantPaymentInfoReq) (*v1.TenantPaymentInfoRes, error) + // ExportOrders exports the tenant order list as CSV or Excel. + ExportOrders(ctx context.Context, req *v1.TenantOrderExportReq) (*v1.TenantOrderExportRes, error) + // GetOrgInfo returns tenant organization info. + GetOrgInfo(ctx context.Context, req *v1.TenantOrgInfoReq) (*v1.TenantOrgInfoRes, error) + // UpdateOrgInfo updates tenant organization info. + UpdateOrgInfo(ctx context.Context, req *v1.TenantOrgUpdateReq) (*v1.TenantOrgUpdateRes, error) + // TransferOwnership transfers tenant ownership to another member. + TransferOwnership(ctx context.Context, req *v1.TenantOrgTransferReq) (*v1.TenantOrgTransferRes, error) + // GetProfile returns current user's profile. + GetProfile(ctx context.Context, req *v1.TenantProfileReq) (*v1.TenantProfileRes, error) + // UpdateProfile updates current user's profile. + UpdateProfile(ctx context.Context, req *v1.TenantProfileUpdateReq) (*v1.TenantProfileUpdateRes, error) + // PersonalDashboard returns the personal dashboard overview for the current user. + PersonalDashboard(ctx context.Context, req *v1.PersonalDashboardReq) (*v1.PersonalDashboardRes, error) + // PersonalTokenTrends returns daily token usage trends for the current user. + PersonalTokenTrends(ctx context.Context, req *v1.PersonalTokenTrendsReq) (*v1.PersonalTokenTrendsRes, error) + // PersonalModelDistribution returns model usage distribution for the current user. + PersonalModelDistribution(ctx context.Context, req *v1.PersonalModelDistReq) (*v1.PersonalModelDistRes, error) + // PersonalApiKeyUsage returns per-API-key usage breakdown for the current user. + PersonalApiKeyUsage(ctx context.Context, req *v1.PersonalApiKeyUsageReq) (*v1.PersonalApiKeyUsageRes, error) + // PlanList 获取可购买的套餐列表(仅 active) + PlanList(ctx context.Context, req *v1.TenantPlanListReq) (*v1.TenantPlanListRes, error) + // PlanCurrent 获取租户当前套餐 + PlanCurrent(ctx context.Context, req *v1.TenantPlanCurrentReq) (*v1.TenantPlanCurrentRes, error) + // PlanCancelAutoRenew 取消自动续费 + PlanCancelAutoRenew(ctx context.Context, req *v1.TenantPlanCancelAutoRenewReq) (*v1.TenantPlanCancelAutoRenewRes, error) + PlaygroundChat(ctx context.Context, req *v1.PlaygroundChatReq) (*v1.PlaygroundChatRes, error) + PlaygroundImage(ctx context.Context, req *v1.PlaygroundImageReq) (*v1.PlaygroundImageRes, error) + PlaygroundAudioTTS(ctx context.Context, req *v1.PlaygroundAudioTTSReq) (*v1.PlaygroundAudioTTSRes, error) + PlaygroundEmbedding(ctx context.Context, req *v1.PlaygroundEmbeddingReq) (*v1.PlaygroundEmbeddingRes, error) + PlaygroundRerank(ctx context.Context, req *v1.PlaygroundRerankReq) (*v1.PlaygroundRerankRes, error) + SandboxChat(ctx context.Context, req *v1.SandboxChatReq) (*v1.SandboxChatRes, error) + SandboxQuota(ctx context.Context, req *v1.SandboxQuotaReq) (*v1.SandboxQuotaRes, error) + TenantPluginList(ctx context.Context, req *v1.TenantPluginListReq) (*v1.TenantPluginListRes, error) + TenantPluginDetail(ctx context.Context, req *v1.TenantPluginDetailReq) (*v1.TenantPluginDetailRes, error) + TenantPluginConfigUpdate(ctx context.Context, req *v1.TenantPluginConfigUpdateReq) (*v1.TenantPluginConfigUpdateRes, error) + TenantPluginEnable(ctx context.Context, req *v1.TenantPluginEnableReq) (*v1.TenantPluginEnableRes, error) + TenantPluginDisable(ctx context.Context, req *v1.TenantPluginDisableReq) (*v1.TenantPluginDisableRes, error) + // ProjectList returns a paginated list of projects for a tenant. + ProjectList(ctx context.Context, req *v1.TenantProjectListReq) (*v1.TenantProjectListRes, error) + // ProjectCreate creates a new project for a tenant. + ProjectCreate(ctx context.Context, req *v1.TenantProjectCreateReq) (*v1.TenantProjectCreateRes, error) + // ProjectUpdate updates a project. + ProjectUpdate(ctx context.Context, req *v1.TenantProjectUpdateReq) (*v1.TenantProjectUpdateRes, error) + // ProjectArchive archives a project and revokes all its keys. + ProjectArchive(ctx context.Context, req *v1.TenantProjectArchiveReq) (*v1.TenantProjectArchiveRes, error) + // ProjectUnarchive restores an archived project. Keys are NOT auto-restored. + ProjectUnarchive(ctx context.Context, req *v1.TenantProjectUnarchiveReq) (*v1.TenantProjectUnarchiveRes, error) + // ProjectGet 根据 ID 获取单个项目详情(含统计摘要) + ProjectGet(ctx context.Context, req *v1.TenantProjectGetReq) (*v1.TenantProjectGetRes, error) + // ProjectApiKeyList 获取项目密钥列表(owner/admin 权限) + ProjectApiKeyList(ctx context.Context, req *v1.TenantProjectApiKeyListReq) (*v1.TenantProjectApiKeyListRes, error) + // ProjectApiKeyCreate 创建项目密钥(owner/admin 权限) + ProjectApiKeyCreate(ctx context.Context, req *v1.TenantProjectApiKeyCreateReq) (*v1.TenantProjectApiKeyCreateRes, error) + // ProjectApiKeyDelete 删除项目密钥(owner/admin 权限) + ProjectApiKeyDelete(ctx context.Context, req *v1.TenantProjectApiKeyDeleteReq) (*v1.TenantProjectApiKeyDeleteRes, error) + // ProjectUsageStats 获取项目用量统计(按日汇总,近30天)(owner/admin 权限) + ProjectUsageStats(ctx context.Context, req *v1.TenantProjectUsageStatsReq) (*v1.TenantProjectUsageStatsRes, error) + // ProjectUsageLogs 获取项目用量日志(分页)(owner/admin 权限) + ProjectUsageLogs(ctx context.Context, req *v1.TenantProjectUsageLogsReq) (*v1.TenantProjectUsageLogsRes, error) + // ValidatePromoCode 校验优惠码并返回折扣金额 + ValidatePromoCode(ctx context.Context, req *v1.TenantValidatePromoCodeReq) (*v1.TenantValidatePromoCodeRes, error) + // RedeemCode 租户兑换码 + RedeemCode(ctx context.Context, req *v1.TenantRedeemCodeReq) (*v1.TenantRedeemCodeRes, error) + // ListRedemptionUsages 获取当前租户的兑换历史 + ListRedemptionUsages(ctx context.Context, req *v1.TenantRedemptionUsagesReq) (*v1.TenantRedemptionUsagesRes, error) + // Verify2FA handles the 2FA verification step during tenant login. + Verify2FA(ctx context.Context, req *v1.Tenant2FAVerifyReq) (*v1.Tenant2FAVerifyRes, error) + // Setup2FA starts the 2FA setup process for the current tenant user. + Setup2FA(ctx context.Context, _ *v1.Tenant2FASetupReq) (*v1.Tenant2FASetupRes, error) + // Enable2FA confirms and enables 2FA. + Enable2FA(ctx context.Context, req *v1.Tenant2FAEnableReq) (*v1.Tenant2FAEnableRes, error) + // Disable2FA disables 2FA for the current tenant user. + Disable2FA(ctx context.Context, req *v1.Tenant2FADisableReq) (*v1.Tenant2FADisableRes, error) + // RegenerateBackupCodes generates new backup codes. + RegenerateBackupCodes(ctx context.Context, req *v1.Tenant2FARegenerateBackupCodesReq) (*v1.Tenant2FARegenerateBackupCodesRes, error) + // ConfirmHighRisk generates a confirm token for high-risk operations. + ConfirmHighRisk(ctx context.Context, req *v1.Tenant2FAConfirmReq) (*v1.Tenant2FAConfirmRes, error) + // LoginHistory returns the login history for the current tenant user. + LoginHistory(ctx context.Context, req *v1.TenantLoginHistoryReq) (*v1.TenantLoginHistoryRes, error) + // GetIPWhitelist returns the tenant's IP whitelist configuration. + GetIPWhitelist(ctx context.Context, _ *v1.TenantIPWhitelistGetReq) (*v1.TenantIPWhitelistGetRes, error) + // UpdateIPWhitelist updates the tenant's IP whitelist configuration. + UpdateIPWhitelist(ctx context.Context, req *v1.TenantIPWhitelistUpdateReq) (*v1.TenantIPWhitelistUpdateRes, error) + // TicketCreate 创建工单 + TicketCreate(ctx context.Context, req *v1.TenantTicketCreateReq) (*v1.TenantTicketCreateRes, error) + // TicketList 获取租户工单列表 + TicketList(ctx context.Context, req *v1.TenantTicketListReq) (*v1.TenantTicketListRes, error) + // TicketGet 获取工单详情(含回复) + TicketGet(ctx context.Context, req *v1.TenantTicketGetReq) (*v1.TenantTicketGetRes, error) + // TicketReply 租户用户回复工单 + TicketReply(ctx context.Context, req *v1.TenantTicketReplyReq) (*v1.TenantTicketReplyRes, error) + // TicketClose 租户用户关闭工单 + TicketClose(ctx context.Context, req *v1.TenantTicketCloseReq) (*v1.TenantTicketCloseRes, error) + // TicketReopen 租户用户重新打开工单 + TicketReopen(ctx context.Context, req *v1.TenantTicketReopenReq) (*v1.TenantTicketReopenRes, error) + // ExportTickets exports the tenant ticket list as CSV or Excel. + ExportTickets(ctx context.Context, req *v1.TenantTicketExportReq) (*v1.TenantTicketExportRes, error) + } +) + +var ( + localTenant ITenant +) + +func Tenant() ITenant { + if localTenant == nil { + panic("implement not found for interface ITenant, forgot register?") + } + return localTenant +} + +func RegisterTenant(i ITenant) { + localTenant = i +} diff --git a/internal/utility/crypto/bcrypt.go b/internal/utility/crypto/bcrypt.go new file mode 100644 index 0000000..302aa9a --- /dev/null +++ b/internal/utility/crypto/bcrypt.go @@ -0,0 +1,18 @@ +package crypto + +import "golang.org/x/crypto/bcrypt" + +const bcryptCost = 12 + +func hashPasswordBcrypt(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) + if err != nil { + return "", err + } + return string(bytes), nil +} + +func verifyPasswordBcrypt(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} diff --git a/internal/utility/crypto/crypto.go b/internal/utility/crypto/crypto.go new file mode 100644 index 0000000..d5965df --- /dev/null +++ b/internal/utility/crypto/crypto.go @@ -0,0 +1,117 @@ +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "io" +) + +var ( + ErrInvalidCiphertext = errors.New("invalid ciphertext") + ErrInvalidKeyLength = errors.New("key must be 32 bytes for AES-256") +) + +// Encrypt encrypts plaintext using AES-256-GCM. +// Returns base64-encoded ciphertext (nonce prepended). +func Encrypt(key, plaintext []byte) (string, error) { + if len(key) != 32 { + return "", ErrInvalidKeyLength + } + + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("create gcm: %w", err) + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("generate nonce: %w", err) + } + + ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// Decrypt decrypts base64-encoded AES-256-GCM ciphertext. +func Decrypt(key []byte, encoded string) ([]byte, error) { + if len(key) != 32 { + return nil, ErrInvalidKeyLength + } + + ciphertext, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("decode base64: %w", err) + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("create gcm: %w", err) + } + + nonceSize := gcm.NonceSize() + if len(ciphertext) < nonceSize { + return nil, ErrInvalidCiphertext + } + + nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, fmt.Errorf("decrypt: %w", err) + } + + return plaintext, nil +} + +// EncryptString is a convenience wrapper for string input/output. +func EncryptString(key []byte, plaintext string) (string, error) { + return Encrypt(key, []byte(plaintext)) +} + +// DecryptString is a convenience wrapper for string input/output. +func DecryptString(key []byte, encoded string) (string, error) { + plain, err := Decrypt(key, encoded) + if err != nil { + return "", err + } + return string(plain), nil +} + +// MustGetEncryptionKey returns the encryption key from a hex-encoded string. +// Panics if the key is invalid. +func MustGetEncryptionKey(hexKey string) []byte { + key, err := hex.DecodeString(hexKey) + if err != nil { + panic(fmt.Sprintf("invalid encryption key hex: %v", err)) + } + if len(key) != 32 { + panic(fmt.Sprintf("encryption key must be 32 bytes, got %d", len(key))) + } + return key +} + +// HashPassword hashes a password using bcrypt. +// This is a convenience wrapper to keep crypto utilities together. +func HashPassword(password string) (string, error) { + // Use Go's standard library bcrypt + return hashPasswordBcrypt(password) +} + +// VerifyPassword verifies a password against a bcrypt hash. +func VerifyPassword(password, hash string) bool { + return verifyPasswordBcrypt(password, hash) +} diff --git a/internal/utility/export/csv.go b/internal/utility/export/csv.go new file mode 100644 index 0000000..29c4438 --- /dev/null +++ b/internal/utility/export/csv.go @@ -0,0 +1,74 @@ +package export + +import ( + "encoding/csv" + "strconv" + + "github.com/gogf/gf/v2/net/ghttp" +) + +// StreamCSV writes a CSV file as a streaming HTTP response. +// queryFn receives a yield callback; call yield(row) for each data row. +// Data is fetched and written in batches to avoid loading everything into memory. +func StreamCSV(r *ghttp.Request, config Config, queryFn func(yield func(map[string]any) bool)) error { + filename := config.Filename + if filename == "" { + filename = defaultFilename("export") + } + setDownloadHeaders(r, filename, "csv") + + w := r.Response.RawWriter() + + // UTF-8 BOM for Excel compatibility + w.Write([]byte{0xEF, 0xBB, 0xBF}) + + writer := csv.NewWriter(w) + writer.UseCRLF = true + + // Write header row + headers := make([]string, len(config.Columns)) + for i, col := range config.Columns { + headers[i] = col.Header + } + if err := writer.Write(headers); err != nil { + return err + } + + // Write data rows via the query callback + rowCount := 0 + queryFn(func(row map[string]any) bool { + record := make([]string, len(config.Columns)) + for i, col := range config.Columns { + record[i] = GetCellValue(row, col) + } + _ = writer.Write(record) + rowCount++ + + // Flush every 500 rows to keep memory low + if rowCount%500 == 0 { + writer.Flush() + } + return true + }) + + writer.Flush() + return writer.Error() +} + +// WriteCSVAll writes a CSV file from a complete data slice. +// For large datasets, prefer StreamCSV. +func WriteCSVAll(r *ghttp.Request, config Config, data []map[string]any) error { + return StreamCSV(r, config, func(yield func(map[string]any) bool) { + for _, row := range data { + if !yield(row) { + break + } + } + }) +} + +// parseCSVInt safely parses an int from a CSV cell value. +func parseCSVInt(s string) int { + n, _ := strconv.Atoi(s) + return n +} diff --git a/internal/utility/export/excel.go b/internal/utility/export/excel.go new file mode 100644 index 0000000..d0c6f8a --- /dev/null +++ b/internal/utility/export/excel.go @@ -0,0 +1,77 @@ +package export + +import ( + "fmt" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/xuri/excelize/v2" +) + +// WriteExcel generates an Excel file and writes it as an HTTP response. +// The entire dataset must fit in memory; use CSV for very large exports. +func WriteExcel(r *ghttp.Request, config Config, data []map[string]any) error { + if len(data) > config.GetMaxRows() { + return gerror.Newf("数据量超过 %d 行,请改用 CSV 格式导出", config.GetMaxRows()) + } + + f := excelize.NewFile() + defer f.Close() + + sheet := "Sheet1" + + // Create header style + style, _ := f.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"#F3F4F6"}}, + Alignment: &excelize.Alignment{Horizontal: "center"}, + Border: []excelize.Border{ + {Type: "left", Color: "D1D5DB", Style: 1}, + {Type: "top", Color: "D1D5DB", Style: 1}, + {Type: "bottom", Color: "D1D5DB", Style: 1}, + {Type: "right", Color: "D1D5DB", Style: 1}, + }, + }) + + // Write headers + for i, col := range config.Columns { + cell, _ := excelize.CoordinatesToCellName(i+1, 1) + _ = f.SetCellValue(sheet, cell, col.Header) + _ = f.SetCellStyle(sheet, cell, cell, style) + } + + // Write data rows + for rowIdx, row := range data { + rowNum := rowIdx + 2 // 1-indexed, row 1 is header + for colIdx, col := range config.Columns { + cell, _ := excelize.CoordinatesToCellName(colIdx+1, rowNum) + val := GetCellValue(row, col) + _ = f.SetCellValue(sheet, cell, val) + } + } + + // Auto-fit column widths based on header length + for i, col := range config.Columns { + colName, _ := excelize.ColumnNumberToName(i + 1) + width := float64(len([]rune(col.Header)))*1.5 + 4 + if width < 12 { + width = 12 + } + _ = f.SetColWidth(sheet, colName, colName, width) + } + + // Write to response + filename := config.Filename + if filename == "" { + filename = defaultFilename("export") + } + setDownloadHeaders(r, filename, "xlsx") + + buf, err := f.WriteToBuffer() + if err != nil { + return fmt.Errorf("生成 Excel 失败: %w", err) + } + + _, err = r.Response.RawWriter().Write(buf.Bytes()) + return err +} diff --git a/internal/utility/export/export.go b/internal/utility/export/export.go new file mode 100644 index 0000000..4f191ea --- /dev/null +++ b/internal/utility/export/export.go @@ -0,0 +1,24 @@ +package export + +// Column defines one column in the exported file. +type Column struct { + Field string // Key in the data map + Header string // Display header (Chinese) + Transform func(any) string // Optional value transformer +} + +// Config holds the full export configuration. +type Config struct { + Format string // "csv" or "xlsx" + Filename string // Without extension + Columns []Column // Ordered column definitions + MaxRows int // Safety cap for Excel (default 100000) +} + +// GetMaxRows returns the configured max rows or the default. +func (c Config) GetMaxRows() int { + if c.MaxRows <= 0 { + return 100000 + } + return c.MaxRows +} diff --git a/internal/utility/export/helper.go b/internal/utility/export/helper.go new file mode 100644 index 0000000..a159527 --- /dev/null +++ b/internal/utility/export/helper.go @@ -0,0 +1,59 @@ +package export + +import ( + "fmt" + "net/url" + "strings" + "time" + + "github.com/gogf/gf/v2/net/ghttp" +) + +var ( + csvContentType = "text/csv; charset=utf-8" + xlsxContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +) + +// setDownloadHeaders sets HTTP headers for file download. +func setDownloadHeaders(r *ghttp.Request, filename, format string) { + contentType := csvContentType + ext := ".csv" + if format == "xlsx" { + contentType = xlsxContentType + ext = ".xlsx" + } + + encodedName := url.PathEscape(filename + ext) + r.Response.Header().Set("Content-Type", contentType) + r.Response.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`, filename+ext, encodedName)) + r.Response.Header().Set("X-Content-Type-Options", "nosniff") +} + +// defaultFilename generates a default filename with timestamp. +func defaultFilename(prefix string) string { + return fmt.Sprintf("%s_%s", prefix, time.Now().Format("20060102_150405")) +} + +// detectFormat validates and normalizes the format string. +func detectFormat(format string) string { + f := strings.ToLower(strings.TrimSpace(format)) + if f == "xlsx" || f == "excel" { + return "xlsx" + } + return "csv" +} + +// GetCellValue extracts a value from a row map, applying the column's Transform if set. +func GetCellValue(row map[string]any, col Column) string { + val, ok := row[col.Field] + if !ok { + return "" + } + if col.Transform != nil { + return col.Transform(val) + } + if val == nil { + return "" + } + return fmt.Sprintf("%v", val) +} diff --git a/internal/utility/totp/totp.go b/internal/utility/totp/totp.go new file mode 100644 index 0000000..ca2387f --- /dev/null +++ b/internal/utility/totp/totp.go @@ -0,0 +1,55 @@ +package totp + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" +) + +const ( + issuerName = "Team-API" + accountPrefix = "team-api" +) + +// GenerateSecret generates a new TOTP secret for a user. +func GenerateSecret(accountName string) (secret string, err error) { + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: issuerName, + AccountName: accountName, + Period: 30, + Digits: otp.DigitsSix, + Algorithm: otp.AlgorithmSHA1, + }) + if err != nil { + return "", fmt.Errorf("generate totp key: %w", err) + } + return key.Secret(), nil +} + +// ValidateCode validates a TOTP code against a secret. +func ValidateCode(code, secret string) bool { + return totp.Validate(code, secret) +} + +// GenerateURI generates the otpauth:// URI for QR code scanning. +func GenerateURI(accountName, secret string) string { + return fmt.Sprintf("otpauth://totp/%s:%s?issuer=%s&secret=%s&algorithm=SHA1&digits=6&period=30", + issuerName, accountName, issuerName, secret) +} + +// GenerateBackupCodes generates a set of one-time backup recovery codes. +// Returns plain text codes (to show to user once) and their SHA256 hashes (to store). +func GenerateBackupCodes(count int) (plainCodes []string, err error) { + plainCodes = make([]string, count) + for i := 0; i < count; i++ { + bytes := make([]byte, 4) + if _, err = rand.Read(bytes); err != nil { + return nil, fmt.Errorf("generate backup code: %w", err) + } + plainCodes[i] = hex.EncodeToString(bytes) + } + return plainCodes, nil +} diff --git a/internal/utility/turnstile/turnstile.go b/internal/utility/turnstile/turnstile.go new file mode 100644 index 0000000..05e6c4b --- /dev/null +++ b/internal/utility/turnstile/turnstile.go @@ -0,0 +1,63 @@ +package turnstile + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +const verifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify" + +var httpClient = &http.Client{Timeout: 10 * time.Second} + +// VerifyResult represents the Cloudflare Turnstile verification response. +type VerifyResult struct { + Success bool `json:"success"` + ErrorCodes []string `json:"error-codes"` + ChallengeTS string `json:"challenge_ts"` + Hostname string `json:"hostname"` + Action string `json:"action"` + CData string `json:"cdata"` +} + +// Verify verifies a Turnstile token with Cloudflare's siteverify API. +func Verify(ctx context.Context, secretKey, token, clientIP string) (*VerifyResult, error) { + if token == "" { + return nil, fmt.Errorf("turnstile token is empty") + } + + data := url.Values{} + data.Set("secret", secretKey) + data.Set("response", token) + if clientIP != "" { + data.Set("remoteip", clientIP) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, verifyURL, nil) + if err != nil { + return nil, fmt.Errorf("create verify request: %w", err) + } + req.URL.RawQuery = data.Encode() + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("turnstile verify request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read verify response: %w", err) + } + + var result VerifyResult + if err = json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parse verify response: %w", err) + } + + return &result, nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..671deae --- /dev/null +++ b/main.go @@ -0,0 +1,19 @@ +package main + +import ( + _ "github.com/qianfree/team-api/internal/packed" + + _ "github.com/gogf/gf/contrib/drivers/pgsql/v2" + _ "github.com/gogf/gf/contrib/nosql/redis/v2" + + _ "github.com/qianfree/team-api/internal/logic" + _ "github.com/qianfree/team-api/plugins" + + "github.com/gogf/gf/v2/os/gctx" + + "github.com/qianfree/team-api/internal/cmd" +) + +func main() { + cmd.Main.Run(gctx.GetInitCtx()) +} diff --git a/manifest/config/config.example.yaml b/manifest/config/config.example.yaml new file mode 100644 index 0000000..cbafbda --- /dev/null +++ b/manifest/config/config.example.yaml @@ -0,0 +1,56 @@ +# Team-API Runtime Configuration + +# Server +server: + address: ":18888" + serverAgent: "team-api" + dumpRouterMap: false + +# Logger +logger: + level: "all" + stdout: true + ctxKeys: ["RequestId"] + +# Database (PostgreSQL) +database: + default: + type: "pgsql" + link: "pgsql::@tcp(:)/?sslmode=disable" + debug: false + dryRun: false + maxIdle: 10 + maxOpen: 100 + maxLifetime: "30s" + +# Redis +redis: + default: + address: ":" + db: 0 + +# Cache (GoFrame built-in, uses Redis as adapter) +cache: + default: + adapter: "redis" + +# JWT Authentication +jwt: + secret: "" + accessTokenExpire: "12h" + refreshTokenExpire: "7d" + issuer: "team-api" + adminMaxSessions: 5 + tenantMaxSessions: 10 + +# Email SMTP (fallback defaults, override via opt_options or env vars) +email: + smtp: + host: "" + port: 587 + username: "" + password: "" + from: "" + +# Channel proxy (for accessing Google/Anthropic etc. from China) +channel_proxy_url: "" diff --git a/manifest/deploy/kustomize/base/deployment.yaml b/manifest/deploy/kustomize/base/deployment.yaml new file mode 100644 index 0000000..28f1d69 --- /dev/null +++ b/manifest/deploy/kustomize/base/deployment.yaml @@ -0,0 +1,21 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: template-single + labels: + app: template-single +spec: + replicas: 1 + selector: + matchLabels: + app: template-single + template: + metadata: + labels: + app: template-single + spec: + containers: + - name : main + image: template-single + imagePullPolicy: Always + diff --git a/manifest/deploy/kustomize/base/kustomization.yaml b/manifest/deploy/kustomize/base/kustomization.yaml new file mode 100644 index 0000000..302d92d --- /dev/null +++ b/manifest/deploy/kustomize/base/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- deployment.yaml +- service.yaml + + + diff --git a/manifest/deploy/kustomize/base/service.yaml b/manifest/deploy/kustomize/base/service.yaml new file mode 100644 index 0000000..608771c --- /dev/null +++ b/manifest/deploy/kustomize/base/service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: template-single +spec: + ports: + - port: 80 + protocol: TCP + targetPort: 8000 + selector: + app: template-single + diff --git a/manifest/deploy/kustomize/overlays/develop/configmap.yaml b/manifest/deploy/kustomize/overlays/develop/configmap.yaml new file mode 100644 index 0000000..3b1d0af --- /dev/null +++ b/manifest/deploy/kustomize/overlays/develop/configmap.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: template-single-configmap +data: + config.yaml: | + server: + address: ":8000" + openapiPath: "/api.json" + swaggerPath: "/swagger" + + logger: + level : "all" + stdout: true diff --git a/manifest/deploy/kustomize/overlays/develop/deployment.yaml b/manifest/deploy/kustomize/overlays/develop/deployment.yaml new file mode 100644 index 0000000..04e4851 --- /dev/null +++ b/manifest/deploy/kustomize/overlays/develop/deployment.yaml @@ -0,0 +1,10 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: template-single +spec: + template: + spec: + containers: + - name : main + image: template-single:develop \ No newline at end of file diff --git a/manifest/deploy/kustomize/overlays/develop/kustomization.yaml b/manifest/deploy/kustomize/overlays/develop/kustomization.yaml new file mode 100644 index 0000000..4731c47 --- /dev/null +++ b/manifest/deploy/kustomize/overlays/develop/kustomization.yaml @@ -0,0 +1,14 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: +- ../../base +- configmap.yaml + +patchesStrategicMerge: +- deployment.yaml + +namespace: default + + + diff --git a/manifest/docker/Dockerfile b/manifest/docker/Dockerfile new file mode 100644 index 0000000..d3abe8f --- /dev/null +++ b/manifest/docker/Dockerfile @@ -0,0 +1,16 @@ +FROM loads/alpine:3.8 + +############################################################################### +# INSTALLATION +############################################################################### + +ENV WORKDIR /app +ADD resource $WORKDIR/ +ADD ./temp/linux_amd64/main $WORKDIR/main +RUN chmod +x $WORKDIR/main + +############################################################################### +# START +############################################################################### +WORKDIR $WORKDIR +CMD ./main diff --git a/manifest/docker/docker-compose.yaml b/manifest/docker/docker-compose.yaml new file mode 100644 index 0000000..7354735 --- /dev/null +++ b/manifest/docker/docker-compose.yaml @@ -0,0 +1,56 @@ +services: + postgres: + image: postgres:15-alpine + container_name: team-api-postgres + restart: unless-stopped + ports: + - "5432:5432" + environment: + POSTGRES_USER: team_api + POSTGRES_PASSWORD: team_api_secret + POSTGRES_DB: team_api + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U team_api -d team_api"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: team-api-redis + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + minio: + image: minio/minio:latest + container_name: team-api-minio + restart: unless-stopped + ports: + - "9000:9000" + - "9001:9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin_secret + volumes: + - minio_data:/data + command: server /data --console-address ":9001" + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + postgres_data: + redis_data: + minio_data: diff --git a/manifest/docker/docker.sh b/manifest/docker/docker.sh new file mode 100644 index 0000000..ff393f9 --- /dev/null +++ b/manifest/docker/docker.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +# This shell is executed before docker build. + + + + + diff --git a/manifest/i18n/.gitkeep b/manifest/i18n/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/manifest/protobuf/.keep-if-necessary b/manifest/protobuf/.keep-if-necessary new file mode 100644 index 0000000..e69de29 diff --git a/migrations/000001_init_schema.sql b/migrations/000001_init_schema.sql new file mode 100644 index 0000000..083dc45 --- /dev/null +++ b/migrations/000001_init_schema.sql @@ -0,0 +1,2319 @@ +-- +goose Up +-- Consolidated initial schema for open-source release. +-- Generated on 2026-05-10. + +-- ============================================================ +-- Functions +-- ============================================================ + +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION cleanup_channel_references() + RETURNS trigger + LANGUAGE plpgsql +AS $$ +DECLARE + channel_id_int INTEGER := OLD.id; +BEGIN + -- 清理租户默认渠道范围 + UPDATE tnt_tenants + SET default_channel_scope = ( + SELECT jsonb_agg(elem::int) + FROM jsonb_array_elements_text(default_channel_scope) elem + WHERE elem::int != channel_id_int + ) + WHERE default_channel_scope IS NOT NULL + AND jsonb_array_length(default_channel_scope) > 0 + AND default_channel_scope::text LIKE '%' || channel_id_int::text || '%'; + + -- 清理租户-模型渠道范围 + UPDATE mdl_tenant_models + SET channel_scope = ( + SELECT jsonb_agg(elem::int) + FROM jsonb_array_elements_text(channel_scope) elem + WHERE elem::int != channel_id_int + ) + WHERE channel_scope IS NOT NULL + AND jsonb_array_length(channel_scope) > 0 + AND channel_scope::text LIKE '%' || channel_id_int::text || '%'; + + RETURN OLD; +END; +$$ +; +-- +goose StatementEnd + +-- ============================================================ +-- Tables +-- ============================================================ + +CREATE TABLE api_key_model_scopes ( + id BIGSERIAL PRIMARY KEY, + api_key_id BIGINT NOT NULL, + model_name VARCHAR(100) NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT uk_api_key_model_scopes UNIQUE (api_key_id, model_name) +); + +CREATE TABLE api_keys ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + name VARCHAR(100) NOT NULL, + encrypted_key TEXT NOT NULL, + key_prefix VARCHAR(12) NOT NULL, + scope VARCHAR(30) DEFAULT 'full' NOT NULL, + status VARCHAR(20) DEFAULT 'active' NOT NULL, + expires_at TIMESTAMPTZ, + rate_limit_qps INTEGER, + rate_limit_concurrency INTEGER, + ip_whitelist TEXT[], + total_quota NUMERIC(20,10), + used_quota NUMERIC(20,10) DEFAULT 0 NOT NULL, + project_id BIGINT, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + key_type VARCHAR(20) DEFAULT 'personal' NOT NULL +); + +CREATE TABLE aud_login_history ( + id BIGSERIAL PRIMARY KEY, + user_type VARCHAR(20) NOT NULL, + user_id BIGINT NOT NULL, + tenant_id BIGINT, + login_method VARCHAR(30) DEFAULT 'password' NOT NULL, + ip_address VARCHAR(45) NOT NULL, + user_agent TEXT, + device_fingerprint VARCHAR(128), + location VARCHAR(200), + is_new_device BOOLEAN DEFAULT false NOT NULL, + success BOOLEAN DEFAULT true NOT NULL, + fail_reason VARCHAR(200), + created_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE aud_operation_logs ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT, + user_id BIGINT NOT NULL, + user_type VARCHAR(20) NOT NULL, + action VARCHAR(100) NOT NULL, + resource_type VARCHAR(50), + resource_id BIGINT, + detail JSONB, + ip_address VARCHAR(45), + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + changes_json JSONB +); + +CREATE TABLE aud_request_logs ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + api_key_id BIGINT, + request_id VARCHAR(64) NOT NULL, + method VARCHAR(10) NOT NULL, + path VARCHAR(500) NOT NULL, + query_params TEXT, + status_code INTEGER, + client_ip VARCHAR(45), + user_agent TEXT, + request_body TEXT, + response_body TEXT, + latency_ms INTEGER, + audit_level VARCHAR(20) DEFAULT 'full' NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + tenant_request_body TEXT, + tenant_response_body TEXT, + tenant_audit_level VARCHAR(20) DEFAULT 'full' NOT NULL, + project_id BIGINT, + first_token_ms INTEGER, + request_headers JSONB, + response_headers JSONB, + forwarding_trace JSONB +); + +CREATE TABLE aud_sensitive_access_logs ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + user_type VARCHAR(20) NOT NULL, + resource_type VARCHAR(50) NOT NULL, + resource_id BIGINT, + action VARCHAR(50) NOT NULL, + reason TEXT, + ip_address VARCHAR(45), + user_agent TEXT, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE bil_daily_revenue_summary ( + id BIGSERIAL PRIMARY KEY, + date DATE NOT NULL, + total_recharge NUMERIC(20,10) DEFAULT 0 NOT NULL, + total_consumption NUMERIC(20,10) DEFAULT 0 NOT NULL, + net_revenue NUMERIC(20,10) DEFAULT 0 NOT NULL, + new_orders INTEGER DEFAULT 0 NOT NULL, + paid_orders INTEGER DEFAULT 0 NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE bil_daily_usage_summary ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + date DATE NOT NULL, + total_requests BIGINT DEFAULT 0 NOT NULL, + total_tokens BIGINT DEFAULT 0 NOT NULL, + total_cost NUMERIC(20,10) DEFAULT 0 NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE bil_monthly_revenue_summary ( + id BIGSERIAL PRIMARY KEY, + month DATE NOT NULL, + total_recharge NUMERIC(20,10) DEFAULT 0 NOT NULL, + total_consumption NUMERIC(20,10) DEFAULT 0 NOT NULL, + net_revenue NUMERIC(20,10) DEFAULT 0 NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE bil_monthly_usage_summary ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + month DATE NOT NULL, + total_requests BIGINT DEFAULT 0 NOT NULL, + total_tokens BIGINT DEFAULT 0 NOT NULL, + total_cost NUMERIC(20,10) DEFAULT 0 NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE bil_records ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + api_key_id BIGINT, + channel_id BIGINT, + model_name VARCHAR(100) NOT NULL, + request_id VARCHAR(64) NOT NULL, + relay_mode VARCHAR(30), + input_tokens INTEGER DEFAULT 0 NOT NULL, + output_tokens INTEGER DEFAULT 0 NOT NULL, + input_price NUMERIC(20,10), + output_price NUMERIC(20,10), + total_cost NUMERIC(20,10) NOT NULL, + currency VARCHAR(3) DEFAULT 'USD' NOT NULL, + status VARCHAR(20) NOT NULL, + settled_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + billing_mode VARCHAR(20) DEFAULT 'token' NOT NULL, + effective_input_price NUMERIC(20,10), + effective_output_price NUMERIC(20,10), + discount_ratio NUMERIC(5,4), + billing_input_multiplier NUMERIC(10,4), + billing_output_multiplier NUMERIC(10,4), + cache_creation_tokens INTEGER DEFAULT 0 NOT NULL, + cache_read_tokens INTEGER DEFAULT 0 NOT NULL, + cache_creation_cost NUMERIC(20,10) DEFAULT 0 NOT NULL, + cache_read_cost NUMERIC(20,10) DEFAULT 0 NOT NULL, + model_multiplier NUMERIC(10,4) DEFAULT 1.0000 NOT NULL, + tenant_multiplier NUMERIC(10,4) DEFAULT 1.0000 NOT NULL, + base_input_price NUMERIC(20,10) DEFAULT 0 NOT NULL, + base_output_price NUMERIC(20,10) DEFAULT 0 NOT NULL, + billing_snapshot JSONB +); + + +CREATE TABLE bil_transactions ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + wallet_id BIGINT NOT NULL, + type VARCHAR(30) NOT NULL, + amount NUMERIC(20,10) NOT NULL, + balance_after NUMERIC(20,10) NOT NULL, + frozen_after NUMERIC(20,10) DEFAULT 0 NOT NULL, + related_id BIGINT, + related_type VARCHAR(30), + description TEXT, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE bil_wallets ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + balance NUMERIC(20,10) DEFAULT 0 NOT NULL, + frozen_balance NUMERIC(20,10) DEFAULT 0 NOT NULL, + warning_threshold NUMERIC(20,10), + currency VARCHAR(3) DEFAULT 'USD' NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT uk_bil_wallets_tenant UNIQUE (tenant_id) +); + +CREATE TABLE chn_abilities ( + id BIGSERIAL PRIMARY KEY, + channel_id BIGINT NOT NULL, + model_name VARCHAR(100) NOT NULL, + upstream_model VARCHAR(100), + enabled BOOLEAN DEFAULT true NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT uk_chn_abilities UNIQUE (channel_id, model_name) +); + +CREATE TABLE chn_channel_affinities ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + model_name VARCHAR(100) NOT NULL, + channel_id BIGINT NOT NULL, + hit_count INTEGER DEFAULT 1 NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT uk_chn_affinities UNIQUE (tenant_id, user_id, model_name) +); + +CREATE TABLE chn_channel_keys ( + id BIGSERIAL PRIMARY KEY, + channel_id BIGINT NOT NULL, + name VARCHAR(100), + encrypted_key TEXT NOT NULL, + status VARCHAR(20) DEFAULT 'active' NOT NULL, + last_used_at TIMESTAMPTZ, + last_error TEXT, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + key_type VARCHAR(20) NOT NULL DEFAULT 'apikey', + token_expires_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE chn_channels ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + type INTEGER NOT NULL, + base_url VARCHAR(500) NOT NULL, + status VARCHAR(20) DEFAULT 'active' NOT NULL, + priority INTEGER DEFAULT 0 NOT NULL, + weight INTEGER DEFAULT 100 NOT NULL, + max_concurrency INTEGER DEFAULT 100 NOT NULL, + settings JSONB DEFAULT '{}'::jsonb, + test_model VARCHAR(100), + remark TEXT, + created_by BIGINT, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + is_vip BOOLEAN DEFAULT false NOT NULL, + sharing_threshold NUMERIC(5,2) DEFAULT 0.6, + preemption_threshold NUMERIC(5,2) DEFAULT 0.8, + borrowing_cooldown_seconds INTEGER DEFAULT 30, + auto_disabled SMALLINT DEFAULT 0 NOT NULL +); + +CREATE TABLE chn_health_scores ( + id BIGSERIAL PRIMARY KEY, + channel_id BIGINT NOT NULL, + success_rate NUMERIC(5,2), + latency_ms NUMERIC(10,2), + stability_score NUMERIC(5,2), + consecutive_failures INTEGER DEFAULT 0 NOT NULL, + health_score NUMERIC(5,2), + calculated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT uk_chn_health_scores UNIQUE (channel_id) +); + +CREATE TABLE chn_health_snapshots ( + id BIGSERIAL PRIMARY KEY, + channel_id BIGINT NOT NULL, + health_score NUMERIC(6,2) NOT NULL, + success_rate NUMERIC(6,2) NOT NULL, + latency_ms NUMERIC(10,2) NOT NULL, + stability_score NUMERIC(6,2) NOT NULL, + consecutive_failures INTEGER DEFAULT 0 NOT NULL, + snapshot_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE clg_changelogs ( + id BIGSERIAL PRIMARY KEY, + version VARCHAR(50) NOT NULL, + title VARCHAR(200) NOT NULL, + content TEXT NOT NULL, + type VARCHAR(20) DEFAULT 'feature' NOT NULL, + status VARCHAR(20) DEFAULT 'draft' NOT NULL, + published_at TIMESTAMPTZ, + created_by BIGINT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + + +CREATE TABLE ord_promo_code_usages ( + id BIGSERIAL PRIMARY KEY, + promo_code_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + order_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + discount_amount NUMERIC(20,10) NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE ord_promo_codes ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(50) NOT NULL, + name VARCHAR(100) NOT NULL, + type VARCHAR(20) NOT NULL, + discount_value NUMERIC(20,10) NOT NULL, + min_amount NUMERIC(20,10) DEFAULT 0 NOT NULL, + max_discount NUMERIC(20,10) DEFAULT 0 NOT NULL, + total_count INTEGER DEFAULT 0 NOT NULL, + used_count INTEGER DEFAULT 0 NOT NULL, + per_user_limit INTEGER DEFAULT 1 NOT NULL, + valid_from TIMESTAMPTZ DEFAULT now() NOT NULL, + valid_to TIMESTAMPTZ DEFAULT (now() + '1 year'::interval) NOT NULL, + plan_ids BIGINT[], + status VARCHAR(20) DEFAULT 'active' NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT uk_ord_promo_codes_code UNIQUE (code) +); + +CREATE TABLE ord_redemptions ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(50) NOT NULL, + type VARCHAR(20) NOT NULL, + value NUMERIC(20,10) DEFAULT 0 NOT NULL, + plan_id BIGINT, + duration_days INTEGER DEFAULT 0 NOT NULL, + max_uses INTEGER DEFAULT 1 NOT NULL, + used_count INTEGER DEFAULT 0 NOT NULL, + redeemed_by BIGINT, + redeemed_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + status VARCHAR(20) DEFAULT 'active' NOT NULL, + batch_no VARCHAR(50), + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT uk_ord_redemptions_code UNIQUE (code) +); + +CREATE TABLE spt_feedbacks ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + category VARCHAR(30) DEFAULT 'suggestion' NOT NULL, + title VARCHAR(200) NOT NULL, + description TEXT NOT NULL, + status VARCHAR(20) DEFAULT 'pending' NOT NULL, + priority VARCHAR(10) DEFAULT 'normal' NOT NULL, + admin_reply TEXT, + admin_reply_by BIGINT, + admin_reply_at TIMESTAMPTZ, + resolution VARCHAR(200), + tags JSONB DEFAULT '[]'::jsonb, + metadata JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE fil_files ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT, + user_id BIGINT NOT NULL, + filename VARCHAR(255) NOT NULL, + original_name VARCHAR(255), + mime_type VARCHAR(100), + size BIGINT NOT NULL, + storage_provider VARCHAR(20) NOT NULL, + storage_path VARCHAR(500) NOT NULL, + virus_scan_status VARCHAR(20) DEFAULT 'pending' NOT NULL, + checksum VARCHAR(64), + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE spt_articles ( + id BIGSERIAL PRIMARY KEY, + category_id BIGINT NOT NULL, + title VARCHAR(200) NOT NULL, + slug VARCHAR(200) NOT NULL, + content TEXT NOT NULL, + summary VARCHAR(500), + status VARCHAR(20) DEFAULT 'draft' NOT NULL, + author_id BIGINT NOT NULL, + view_count INTEGER DEFAULT 0 NOT NULL, + sort_order INTEGER DEFAULT 0 NOT NULL, + keywords JSONB DEFAULT '[]'::jsonb, + published_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT uk_spt_articles_slug UNIQUE (slug) +); + +CREATE TABLE spt_categories ( + id BIGSERIAL PRIMARY KEY, + parent_id BIGINT DEFAULT 0 NOT NULL, + name VARCHAR(100) NOT NULL, + slug VARCHAR(100) NOT NULL, + description VARCHAR(500), + sort_order INTEGER DEFAULT 0 NOT NULL, + icon VARCHAR(50), + is_visible BOOLEAN DEFAULT true NOT NULL, + article_count INTEGER DEFAULT 0 NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT uk_spt_categories_slug UNIQUE (slug) +); + + +CREATE TABLE mdl_models ( + id BIGSERIAL PRIMARY KEY, + model_id VARCHAR(100) NOT NULL, + model_name VARCHAR(200), + category VARCHAR(30) NOT NULL, + status VARCHAR(20) DEFAULT 'active' NOT NULL, + max_context_tokens INTEGER, + max_output_tokens INTEGER, + description TEXT, + tags TEXT[], + capabilities JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + deprecated_at TIMESTAMPTZ, + sunset_date DATE, + replacement_model VARCHAR(100), + CONSTRAINT uk_mdl_models_model_id UNIQUE (model_id) +); + +CREATE TABLE mdl_pricing ( + id BIGSERIAL PRIMARY KEY, + model_id BIGINT NOT NULL, + billing_mode VARCHAR(20) DEFAULT 'token' NOT NULL, + min_tokens BIGINT DEFAULT 0 NOT NULL, + max_tokens BIGINT, + input_price NUMERIC(20,10) DEFAULT 0 NOT NULL, + output_price NUMERIC(20,10) DEFAULT 0 NOT NULL, + per_request_price NUMERIC(20,10), + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + cache_read_price NUMERIC(20,10) DEFAULT 0 NOT NULL, + cache_creation_price NUMERIC(20,10) DEFAULT 0 NOT NULL +); + +CREATE TABLE mdl_tenant_models ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + model_id BIGINT NOT NULL, + enabled BOOLEAN DEFAULT true NOT NULL, + custom_input_price NUMERIC(20,10), + custom_output_price NUMERIC(20,10), + multiplier NUMERIC(10,4) DEFAULT 1.0 NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + billing_mode VARCHAR(20) DEFAULT NULL, + per_request_price NUMERIC(20,10) DEFAULT NULL::numeric, + discount_ratio NUMERIC(5,4) DEFAULT NULL::numeric, + max_concurrency INTEGER, + channel_scope JSONB, + CONSTRAINT chk_discount_ratio_range CHECK (((discount_ratio IS NULL) OR ((discount_ratio > (0)::numeric) AND (discount_ratio <= 1.0)))), + CONSTRAINT uk_mdl_tenant_models UNIQUE (tenant_id, model_id) +); + +CREATE TABLE ntf_announcements ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + type VARCHAR(50) DEFAULT 'info' NOT NULL, + content TEXT NOT NULL, + status VARCHAR(20) DEFAULT 'draft' NOT NULL, + is_pinned SMALLINT DEFAULT 0 NOT NULL, + display_position VARCHAR(50) DEFAULT 'console' NOT NULL, + effective_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + created_by BIGINT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE ntf_messages ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + user_id BIGINT, + type VARCHAR(50) NOT NULL, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + channel VARCHAR(20) DEFAULT 'in_app' NOT NULL, + is_read SMALLINT DEFAULT 0 NOT NULL, + is_broadcast SMALLINT DEFAULT 0 NOT NULL, + metadata JSONB, + target_roles VARCHAR(255) DEFAULT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE ntf_preferences ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT, + user_id BIGINT, + scope VARCHAR(20) DEFAULT 'user' NOT NULL, + preferences JSONB DEFAULT '{}'::jsonb NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT uk_ntf_preferences UNIQUE (tenant_id, user_id, scope) +); + +CREATE TABLE ntf_read_status ( + id BIGSERIAL PRIMARY KEY, + message_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + read_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT uk_ntf_read_status UNIQUE (message_id, user_id) +); + +CREATE TABLE ntf_send_log ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT, + user_id BIGINT, + template_code VARCHAR(50) NOT NULL, + channel VARCHAR(20) NOT NULL, + recipient VARCHAR(200) NOT NULL, + subject VARCHAR(200), + body TEXT, + status VARCHAR(20) NOT NULL, + error_message TEXT, + sent_at TIMESTAMPTZ, + retry_count INTEGER DEFAULT 0 NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE ntf_templates ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(50) NOT NULL, + channel VARCHAR(20) NOT NULL, + subject VARCHAR(200), + body_template TEXT NOT NULL, + variables JSONB DEFAULT '[]'::jsonb, + status VARCHAR(20) DEFAULT 'active' NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT uk_ntf_templates_code UNIQUE (code) +); + +CREATE TABLE opn_apps ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + name VARCHAR(100) NOT NULL, + description VARCHAR(500), + app_id VARCHAR(32) NOT NULL, + app_secret_hash VARCHAR(255) NOT NULL, + permissions JSONB DEFAULT '[]'::jsonb NOT NULL, + ip_whitelist JSONB DEFAULT '[]'::jsonb, + callback_url VARCHAR(500), + is_sandbox BOOLEAN DEFAULT false NOT NULL, + status VARCHAR(20) DEFAULT 'active' NOT NULL, + rate_limit INTEGER DEFAULT 60 NOT NULL, + last_used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT uk_opn_apps_app_id UNIQUE (app_id) +); + + +CREATE TABLE ops_alert_events ( + id BIGSERIAL PRIMARY KEY, + rule_id BIGINT NOT NULL, + rule_name VARCHAR(100) NOT NULL, + metric_type VARCHAR(50) NOT NULL, + level VARCHAR(20) NOT NULL, + status VARCHAR(20) DEFAULT 'firing' NOT NULL, + trigger_value NUMERIC(20,10), + threshold_value NUMERIC(20,10), + trigger_message TEXT, + acknowledged_by BIGINT, + acknowledged_at TIMESTAMPTZ, + resolve_notes TEXT, + resolved_by BIGINT, + resolved_at TIMESTAMPTZ, + notified_methods TEXT[], + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE ops_alert_rules ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + metric_type VARCHAR(50) NOT NULL, + condition VARCHAR(20) NOT NULL, + threshold NUMERIC(20,10) NOT NULL, + duration_seconds INTEGER DEFAULT 0 NOT NULL, + notification_methods TEXT[] DEFAULT ARRAY['email','in_app'] NOT NULL, + webhook_url VARCHAR(500), + level VARCHAR(20) DEFAULT 'warning' NOT NULL, + is_enabled BOOLEAN DEFAULT true NOT NULL, + cooldown_seconds INTEGER DEFAULT 300 NOT NULL, + last_triggered_at TIMESTAMPTZ, + notify_user_ids BIGINT[], + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE sys_options ( + id BIGSERIAL PRIMARY KEY, + key VARCHAR(100) NOT NULL, + value TEXT, + description TEXT, + category VARCHAR(50), + is_public BOOLEAN DEFAULT false NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT uk_sys_options_key UNIQUE (key) +); + +CREATE TABLE ord_orders ( + id BIGSERIAL PRIMARY KEY, + order_no VARCHAR(64) NOT NULL, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + order_type VARCHAR(20) NOT NULL, + plan_id BIGINT, + amount NUMERIC(20,10) NOT NULL, + discount_amount NUMERIC(20,10) DEFAULT 0 NOT NULL, + final_amount NUMERIC(20,10) NOT NULL, + currency VARCHAR(3) DEFAULT 'USD' NOT NULL, + payment_channel VARCHAR(20) DEFAULT 'mock' NOT NULL, + payment_method VARCHAR(100), + payment_no VARCHAR(200), + status VARCHAR(20) DEFAULT 'pending' NOT NULL, + paid_at TIMESTAMPTZ, + fulfilled_at TIMESTAMPTZ, + expired_at TIMESTAMPTZ, + cancelled_at TIMESTAMPTZ, + related_order_id BIGINT, + description TEXT, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT uk_ord_orders_order_no UNIQUE (order_no) +); + +CREATE TABLE ord_payment_channels ( + id BIGSERIAL PRIMARY KEY, + channel VARCHAR(20) NOT NULL, + name VARCHAR(100) NOT NULL, + config JSONB DEFAULT '{}'::jsonb NOT NULL, + is_enabled BOOLEAN DEFAULT false NOT NULL, + sort_order INTEGER DEFAULT 0 NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + payment_type VARCHAR(20) DEFAULT '' NOT NULL, + callback_url VARCHAR(500) DEFAULT '' NOT NULL, + return_url VARCHAR(500) DEFAULT '' NOT NULL +); + +CREATE TABLE ord_refunds ( + id BIGSERIAL PRIMARY KEY, + order_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + amount NUMERIC(20,10) NOT NULL, + reason TEXT, + status VARCHAR(20) DEFAULT 'pending' NOT NULL, + payment_channel VARCHAR(20) NOT NULL, + payment_refund_id VARCHAR(200), + approved_by BIGINT, + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE pln_feature_flags ( + id BIGSERIAL PRIMARY KEY, + feature_key VARCHAR(100) NOT NULL, + description TEXT, + default_enabled BOOLEAN DEFAULT false NOT NULL, + enabled BOOLEAN DEFAULT false NOT NULL, + source VARCHAR(20) DEFAULT 'manual' NOT NULL, + source_id BIGINT, + tenant_id BIGINT, + plan_id BIGINT, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE pln_plans ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + identifier VARCHAR(50) NOT NULL, + description TEXT, + monthly_price NUMERIC(20,10) DEFAULT 0 NOT NULL, + yearly_price NUMERIC(20,10) DEFAULT 0 NOT NULL, + status VARCHAR(20) DEFAULT 'active' NOT NULL, + monthly_quota_tokens BIGINT DEFAULT 0 NOT NULL, + allowed_models TEXT[], + is_recommended BOOLEAN DEFAULT false NOT NULL, + sort_order INTEGER DEFAULT 0 NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT uk_pln_plans_identifier UNIQUE (identifier) +); + +CREATE TABLE pln_tenant_plans ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + plan_id BIGINT NOT NULL, + status VARCHAR(20) DEFAULT 'active' NOT NULL, + start_at TIMESTAMPTZ NOT NULL, + end_at TIMESTAMPTZ NOT NULL, + auto_renew BOOLEAN DEFAULT false NOT NULL, + monthly_quota_tokens BIGINT DEFAULT 0 NOT NULL, + used_tokens BIGINT DEFAULT 0 NOT NULL, + last_reset_at TIMESTAMPTZ, + cancelled_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE tnt_projects ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + name VARCHAR(100) NOT NULL, + description TEXT, + status VARCHAR(20) DEFAULT 'active' NOT NULL, + budget NUMERIC(20,10), + created_by BIGINT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + + +CREATE TABLE sys_admin_data_scopes ( + id BIGSERIAL PRIMARY KEY, + admin_user_id BIGINT NOT NULL, + scope_type VARCHAR(20) NOT NULL, + scope_value TEXT, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE sys_admin_role_perms ( + id BIGSERIAL PRIMARY KEY, + admin_user_id BIGINT NOT NULL, + permission_point VARCHAR(100) NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT uk_sys_admin_role_perms UNIQUE (admin_user_id, permission_point) +); + +CREATE TABLE sys_admin_users ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(50) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + email VARCHAR(100), + display_name VARCHAR(100), + role VARCHAR(20) DEFAULT 'admin' NOT NULL, + status VARCHAR(20) DEFAULT 'active' NOT NULL, + last_login_at TIMESTAMPTZ, + last_login_ip VARCHAR(45), + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + totp_secret VARCHAR(64), + totp_enabled BOOLEAN DEFAULT false NOT NULL, + backup_codes JSONB, + CONSTRAINT uk_sys_admin_users_email UNIQUE (email), + CONSTRAINT uk_sys_admin_users_username UNIQUE (username) +); + +CREATE TABLE sys_email_verify_codes ( + id BIGSERIAL PRIMARY KEY, + email VARCHAR(100) NOT NULL, + code VARCHAR(10) NOT NULL, + purpose VARCHAR(20) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE sys_idempotency_records ( + id BIGSERIAL PRIMARY KEY, + idempotency_key VARCHAR(255) NOT NULL, + request_hash VARCHAR(64), + response_body TEXT, + status VARCHAR(20) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT uk_sys_idempotency_key UNIQUE (idempotency_key) +); + +CREATE TABLE sys_sessions ( + id BIGSERIAL PRIMARY KEY, + user_type VARCHAR(20) NOT NULL, + user_id BIGINT NOT NULL, + tenant_id BIGINT, + refresh_token_hash VARCHAR(255) NOT NULL, + device_info JSONB, + ip_address VARCHAR(45), + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT uk_sys_sessions_refresh_token UNIQUE (refresh_token_hash) +); + +CREATE TABLE spt_attachments ( + id BIGSERIAL PRIMARY KEY, + ticket_id BIGINT NOT NULL, + reply_id BIGINT, + file_name VARCHAR(255) NOT NULL, + file_url VARCHAR(500) NOT NULL, + file_size INTEGER DEFAULT 0 NOT NULL, + content_type VARCHAR(100), + created_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE spt_replies ( + id BIGSERIAL PRIMARY KEY, + ticket_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + user_type VARCHAR(20) NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE spt_tickets ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + category VARCHAR(50) NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + urgency VARCHAR(20) DEFAULT 'normal' NOT NULL, + status VARCHAR(20) DEFAULT 'pending' NOT NULL, + assigned_admin_id BIGINT, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE tnt_invitations ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + code VARCHAR(64) NOT NULL, + invited_email VARCHAR(100), + role VARCHAR(20) DEFAULT 'member' NOT NULL, + expires_at TIMESTAMPTZ, + used_by_user_id BIGINT, + used_at TIMESTAMPTZ, + created_by BIGINT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + max_uses INT DEFAULT 0 NOT NULL, + use_count INT DEFAULT 0 NOT NULL, + CONSTRAINT uk_tnt_invitations_code UNIQUE (code) +); + +CREATE TABLE tnt_member_imports ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + filename VARCHAR(255) NOT NULL, + total_count INTEGER DEFAULT 0 NOT NULL, + success_count INTEGER DEFAULT 0 NOT NULL, + fail_count INTEGER DEFAULT 0 NOT NULL, + skip_count INTEGER DEFAULT 0 NOT NULL, + status VARCHAR(20) DEFAULT 'pending' NOT NULL, + error_message TEXT, + result_json JSONB, + created_by BIGINT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE tnt_member_model_scopes ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + model_id BIGINT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT uk_tnt_member_model_scopes UNIQUE (tenant_id, user_id, model_id) +); + +CREATE TABLE tnt_oauth_identities ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + provider VARCHAR(20) NOT NULL, + provider_user_id VARCHAR(128) NOT NULL, + provider_username VARCHAR(100), + email VARCHAR(255), + avatar_url VARCHAR(500), + access_token TEXT, + refresh_token TEXT, + token_expires_at TIMESTAMPTZ, + raw_data JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT uk_tnt_oauth_identity UNIQUE (provider, provider_user_id) +); + +CREATE TABLE tnt_tenants ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + code VARCHAR(30) NOT NULL, + logo_url VARCHAR(500), + owner_user_id BIGINT, + status VARCHAR(20) DEFAULT 'active' NOT NULL, + settings JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + trial_ends_at TIMESTAMPTZ, + grace_period_ends_at TIMESTAMPTZ, + frozen_at TIMESTAMPTZ, + closing_requested_at TIMESTAMPTZ, + data_removal_at TIMESTAMPTZ, + max_concurrency INTEGER DEFAULT 0 NOT NULL, + default_channel_scope JSONB, + CONSTRAINT uk_tnt_tenants_code UNIQUE (code) +); + +CREATE TABLE tnt_users ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + username VARCHAR(50) NOT NULL, + email VARCHAR(100) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + display_name VARCHAR(100), + role VARCHAR(20) DEFAULT 'member' NOT NULL, + status VARCHAR(20) DEFAULT 'active' NOT NULL, + last_login_at TIMESTAMPTZ, + last_login_ip VARCHAR(45), + failed_attempts INTEGER DEFAULT 0 NOT NULL, + locked_until TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + totp_secret VARCHAR(64), + totp_enabled BOOLEAN DEFAULT false NOT NULL, + backup_codes JSONB, + quota_type VARCHAR(10) DEFAULT 'none' NOT NULL, + quota_limit NUMERIC(20,10) DEFAULT 0 NOT NULL, + quota_used NUMERIC(20,10) DEFAULT 0 NOT NULL, + quota_period VARCHAR(10), + quota_reset_at TIMESTAMPTZ, + CONSTRAINT uk_tnt_users_tenant_email UNIQUE (tenant_id, email), + CONSTRAINT uk_tnt_users_tenant_username UNIQUE (tenant_id, username) +); + +CREATE TABLE tsk_async_tasks ( + id BIGSERIAL PRIMARY KEY, + public_task_id VARCHAR(64) NOT NULL, + platform VARCHAR(30) NOT NULL, + action VARCHAR(40) NOT NULL, + status VARCHAR(20) DEFAULT 'NOT_START' NOT NULL, + progress VARCHAR(20) DEFAULT '0%' NOT NULL, + fail_reason TEXT, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + api_key_id BIGINT NOT NULL, + channel_id BIGINT NOT NULL, + model_name VARCHAR(100) NOT NULL, + upstream_model VARCHAR(100), + pre_deduct_amount NUMERIC(16,6) DEFAULT 0 NOT NULL, + actual_cost NUMERIC(16,6) DEFAULT 0 NOT NULL, + billing_settled BOOLEAN DEFAULT false NOT NULL, + result_url TEXT, + data JSONB, + private_data JSONB, + submit_time TIMESTAMPTZ, + start_time TIMESTAMPTZ, + finish_time TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT uk_tsk_async_tasks_public_id UNIQUE (public_task_id) +); + + +CREATE TABLE tsk_task_logs ( + id BIGSERIAL PRIMARY KEY, + task_id BIGINT NOT NULL, + level VARCHAR(10) NOT NULL, + message TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE tsk_tasks ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + handler VARCHAR(200) NOT NULL, + status VARCHAR(20) DEFAULT 'pending' NOT NULL, + payload JSONB, + result JSONB, + max_retries INTEGER DEFAULT 3 NOT NULL, + retry_count INTEGER DEFAULT 0 NOT NULL, + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + scheduled_at TIMESTAMPTZ, + error_message TEXT, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE opn_webhook_configs ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + name VARCHAR(100) NOT NULL, + url VARCHAR(500) NOT NULL, + secret_key VARCHAR(64) NOT NULL, + events JSONB DEFAULT '[]'::jsonb NOT NULL, + is_active BOOLEAN DEFAULT true NOT NULL, + retry_policy JSONB DEFAULT '{"intervals": [60, 300, 900, 3600, 21600], "max_attempts": 5}'::jsonb, + consecutive_failures INTEGER DEFAULT 0 NOT NULL, + max_consecutive_failures INTEGER DEFAULT 10 NOT NULL, + last_delivery_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT chk_opn_webhook_configs_url CHECK (((url)::text ~~ 'https://%'::text)) +); + +CREATE TABLE opn_webhook_delivery_logs ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + webhook_config_id BIGINT NOT NULL, + event_id BIGINT NOT NULL, + attempt INTEGER DEFAULT 1 NOT NULL, + request_url VARCHAR(500) NOT NULL, + request_headers JSONB, + response_status INTEGER, + response_body TEXT, + response_time_ms INTEGER, + error_message VARCHAR(500), + created_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE opn_webhook_events ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + webhook_config_id BIGINT NOT NULL, + event_id VARCHAR(64) NOT NULL, + event_type VARCHAR(100) NOT NULL, + payload JSONB DEFAULT '{}'::jsonb NOT NULL, + status VARCHAR(20) DEFAULT 'pending' NOT NULL, + attempts INTEGER DEFAULT 0 NOT NULL, + next_retry_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE bil_usage_logs ( + id BIGSERIAL, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + api_key_id BIGINT, + channel_id BIGINT, + model_name VARCHAR(100) NOT NULL, + request_id VARCHAR(64) NOT NULL, + relay_mode VARCHAR(30), + input_tokens INTEGER, + output_tokens INTEGER, + total_cost NUMERIC(20,10), + currency VARCHAR(3) DEFAULT 'USD' NOT NULL, + latency_ms INTEGER, + status VARCHAR(20) NOT NULL, + error_message TEXT, + client_ip VARCHAR(45), + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + cache_creation_tokens INTEGER DEFAULT 0 NOT NULL, + cache_read_tokens INTEGER DEFAULT 0 NOT NULL, + input_cost NUMERIC(20,10) DEFAULT 0 NOT NULL, + output_cost NUMERIC(20,10) DEFAULT 0 NOT NULL, + cache_creation_cost NUMERIC(20,10) DEFAULT 0 NOT NULL, + cache_read_cost NUMERIC(20,10) DEFAULT 0 NOT NULL, + actual_cost NUMERIC(20,10) DEFAULT 0 NOT NULL, + requested_model VARCHAR(100), + upstream_model VARCHAR(100), + request_type SMALLINT DEFAULT 1 NOT NULL, + user_agent VARCHAR(512), + first_token_ms INTEGER, + service_tier VARCHAR(16), + reasoning_effort VARCHAR(20), + channel_name VARCHAR(100), + channel_type INTEGER, + billing_mode VARCHAR(20), + billing_source VARCHAR(20), + rate_multiplier NUMERIC(10,4) DEFAULT 1.0000 NOT NULL, + pre_deduct_amount NUMERIC(20,10) DEFAULT 0 NOT NULL, + refund_amount NUMERIC(20,10) DEFAULT 0 NOT NULL, + supplement_amount NUMERIC(20,10) DEFAULT 0 NOT NULL, + image_count INTEGER DEFAULT 0 NOT NULL, + image_size VARCHAR(10), + stream_end_reason VARCHAR(20), + retry_index INTEGER DEFAULT 0 NOT NULL, + billing_summary TEXT, + cache_creation_5m_tokens INTEGER DEFAULT 0 NOT NULL, + cache_creation_1h_tokens INTEGER DEFAULT 0 NOT NULL, + audio_input_tokens INTEGER DEFAULT 0 NOT NULL, + audio_output_tokens INTEGER DEFAULT 0 NOT NULL, + image_output_tokens INTEGER DEFAULT 0 NOT NULL, + reasoning_tokens INTEGER DEFAULT 0 NOT NULL, + account_cost NUMERIC(20,10) DEFAULT 0 NOT NULL, + inbound_endpoint VARCHAR(128), + upstream_endpoint VARCHAR(128), + billing_snapshot JSONB, + project_id BIGINT, + PRIMARY KEY (id, created_at) +) +PARTITION BY RANGE (created_at); + +CREATE TABLE ops_system_metrics ( + id BIGSERIAL, + metric_type VARCHAR(30) NOT NULL, + metric_data JSONB DEFAULT '{}'::jsonb NOT NULL, + collected_at TIMESTAMPTZ DEFAULT now() NOT NULL, + PRIMARY KEY (id, collected_at) +) +PARTITION BY RANGE (collected_at); + + +-- ============================================================ +-- Additional Tables +-- ============================================================ + +CREATE TABLE ord_redemption_usages ( + id BIGSERIAL PRIMARY KEY, + redemption_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + type VARCHAR(20) NOT NULL, + value NUMERIC(20,10) DEFAULT 0 NOT NULL, + transaction_id BIGINT, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE sys_error_logs ( + id BIGSERIAL PRIMARY KEY, + request_id VARCHAR(64), + error_code INTEGER NOT NULL, + error_message TEXT NOT NULL, + stack_trace TEXT, + http_method VARCHAR(10), + request_path VARCHAR(500), + request_body TEXT, + source VARCHAR(50) NOT NULL DEFAULT 'api', + resolved BOOLEAN NOT NULL DEFAULT FALSE, + resolved_by BIGINT, + resolved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +CREATE TABLE sys_cron_job_executions ( + id BIGSERIAL PRIMARY KEY, + job_name VARCHAR(100) NOT NULL, + status VARCHAR(20) NOT NULL, + started_at TIMESTAMPTZ NOT NULL, + finished_at TIMESTAMPTZ NOT NULL, + duration_ms INTEGER NOT NULL, + error_message TEXT, + triggered_by VARCHAR(20) NOT NULL DEFAULT 'auto', + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL +); + +-- ============================================================ +-- Indexes +-- ============================================================ + +CREATE INDEX idx_api_keys_prefix ON api_keys USING btree (key_prefix); +CREATE INDEX idx_api_keys_project ON api_keys USING btree (project_id) WHERE (project_id IS NOT NULL); +CREATE INDEX idx_api_keys_tenant ON api_keys USING btree (tenant_id); +CREATE INDEX idx_api_keys_tenant_type ON api_keys USING btree (tenant_id, key_type); +CREATE INDEX idx_aud_login_history_tenant ON aud_login_history USING btree (tenant_id, created_at DESC) WHERE (tenant_id IS NOT NULL); +CREATE INDEX idx_aud_login_history_user ON aud_login_history USING btree (user_type, user_id, created_at DESC); +CREATE INDEX idx_aud_operation_logs_resource ON aud_operation_logs USING btree (resource_type, resource_id); +CREATE INDEX idx_aud_operation_logs_user ON aud_operation_logs USING btree (user_type, user_id, created_at); +CREATE INDEX idx_aud_request_logs_created_brin ON aud_request_logs USING brin (created_at); +CREATE INDEX idx_aud_request_logs_tenant ON aud_request_logs USING btree (tenant_id, created_at); +CREATE INDEX idx_aud_sensitive_access_logs_created ON aud_sensitive_access_logs USING btree (created_at); +CREATE INDEX idx_aud_sensitive_access_logs_resource ON aud_sensitive_access_logs USING btree (resource_type, resource_id); +CREATE INDEX idx_aud_sensitive_access_logs_user ON aud_sensitive_access_logs USING btree (user_id, user_type); +CREATE UNIQUE INDEX uk_bil_daily_revenue_date ON bil_daily_revenue_summary USING btree (date); +CREATE UNIQUE INDEX uk_bil_daily_usage_tenant_date ON bil_daily_usage_summary USING btree (tenant_id, date); +CREATE UNIQUE INDEX uk_bil_monthly_revenue_month ON bil_monthly_revenue_summary USING btree (month); +CREATE UNIQUE INDEX uk_bil_monthly_usage_tenant_month ON bil_monthly_usage_summary USING btree (tenant_id, month); +CREATE INDEX idx_bil_records_request ON bil_records USING btree (request_id); +CREATE INDEX idx_bil_records_tenant ON bil_records USING btree (tenant_id, created_at); +CREATE INDEX idx_bil_records_tenant_model ON bil_records USING btree (tenant_id, model_name, created_at); +CREATE INDEX idx_bil_records_user ON bil_records USING btree (user_id, created_at); + +CREATE INDEX idx_bil_transactions_tenant ON bil_transactions USING btree (tenant_id, created_at); +CREATE INDEX idx_bil_transactions_wallet ON bil_transactions USING btree (wallet_id, created_at); +CREATE INDEX idx_bil_usage_logs_channel_created ON ONLY bil_usage_logs USING btree (channel_id, created_at); +CREATE INDEX idx_bil_usage_logs_created_brin ON ONLY bil_usage_logs USING brin (created_at); +CREATE INDEX idx_bil_usage_logs_model_created ON ONLY bil_usage_logs USING btree (model_name, created_at); +CREATE INDEX idx_bil_usage_logs_request ON ONLY bil_usage_logs USING btree (request_id); +CREATE INDEX idx_bil_usage_logs_status_created ON ONLY bil_usage_logs USING btree (status, created_at); +CREATE INDEX idx_bil_usage_logs_tenant ON ONLY bil_usage_logs USING btree (tenant_id, created_at); +CREATE INDEX idx_chn_abilities_model ON chn_abilities USING btree (model_name); +CREATE INDEX idx_chn_affinities_expires ON chn_channel_affinities USING btree (expires_at); +CREATE INDEX idx_chn_channel_keys_channel ON chn_channel_keys USING btree (channel_id); +CREATE INDEX idx_chn_health_snapshots_brin ON chn_health_snapshots USING brin (snapshot_at); +CREATE INDEX idx_chn_health_snapshots_channel_time ON chn_health_snapshots USING btree (channel_id, snapshot_at); +CREATE INDEX idx_clg_changelogs_status ON clg_changelogs USING btree (status, created_at DESC); + + +CREATE INDEX idx_ord_promo_code_usages_promo ON ord_promo_code_usages USING btree (promo_code_id); +CREATE INDEX idx_ord_promo_code_usages_tenant ON ord_promo_code_usages USING btree (tenant_id); +CREATE INDEX idx_ord_promo_codes_status ON ord_promo_codes USING btree (status); +CREATE INDEX idx_ord_redemptions_batch ON ord_redemptions USING btree (batch_no); +CREATE INDEX idx_ord_redemptions_status ON ord_redemptions USING btree (status); +CREATE INDEX idx_spt_feedbacks_category ON spt_feedbacks USING btree (category); +CREATE INDEX idx_spt_feedbacks_status ON spt_feedbacks USING btree (status); +CREATE INDEX idx_spt_feedbacks_tenant ON spt_feedbacks USING btree (tenant_id, created_at DESC); +CREATE INDEX idx_fil_files_tenant ON fil_files USING btree (tenant_id, created_at); +CREATE INDEX idx_spt_articles_category ON spt_articles USING btree (category_id); +CREATE INDEX idx_spt_articles_search ON spt_articles USING gin (to_tsvector('simple'::regconfig, (((COALESCE(title, ''::character varying))::text || ' '::text) || COALESCE(content, ''::text)))); +CREATE INDEX idx_spt_articles_status ON spt_articles USING btree (status, published_at DESC); +CREATE INDEX idx_spt_categories_parent_sort ON spt_categories USING btree (parent_id, sort_order); + + +CREATE INDEX idx_mdl_pricing_model_billing ON mdl_pricing USING btree (model_id, billing_mode); +CREATE INDEX idx_mdl_pricing_model_id ON mdl_pricing USING btree (model_id); +CREATE INDEX idx_mdl_tenant_models_tenant ON mdl_tenant_models USING btree (tenant_id); +CREATE INDEX idx_ntf_announcements_effective ON ntf_announcements USING btree (effective_at, expires_at); +CREATE INDEX idx_ntf_announcements_status ON ntf_announcements USING btree (status); +CREATE INDEX idx_ntf_messages_created ON ntf_messages USING btree (created_at); +CREATE INDEX idx_ntf_messages_read ON ntf_messages USING btree (tenant_id, user_id, is_read); +CREATE INDEX idx_ntf_messages_tenant_user ON ntf_messages USING btree (tenant_id, user_id); +CREATE INDEX idx_ntf_read_status_message ON ntf_read_status USING btree (message_id); +CREATE INDEX idx_ntf_read_status_user ON ntf_read_status USING btree (user_id); +CREATE INDEX idx_ntf_send_log_status ON ntf_send_log USING btree (status, created_at); +CREATE INDEX idx_opn_apps_tenant ON opn_apps USING btree (tenant_id); + +CREATE INDEX idx_ops_alert_events_created_brin ON ops_alert_events USING brin (created_at); +CREATE INDEX idx_ops_alert_events_level ON ops_alert_events USING btree (level); +CREATE INDEX idx_ops_alert_events_rule ON ops_alert_events USING btree (rule_id); +CREATE INDEX idx_ops_alert_events_status ON ops_alert_events USING btree (status); +CREATE INDEX idx_ops_alert_rules_enabled ON ops_alert_rules USING btree (is_enabled); +CREATE INDEX idx_ops_alert_rules_metric ON ops_alert_rules USING btree (metric_type); +CREATE INDEX idx_ops_system_metrics_type_brin ON ONLY ops_system_metrics USING brin (metric_type, collected_at); +CREATE INDEX idx_ord_orders_no ON ord_orders USING btree (order_no); +CREATE INDEX idx_ord_orders_status ON ord_orders USING btree (status, created_at); +CREATE INDEX idx_ord_orders_tenant ON ord_orders USING btree (tenant_id, created_at); +CREATE INDEX idx_ord_payment_channels_channel ON ord_payment_channels USING btree (channel); +CREATE INDEX idx_ord_refunds_order ON ord_refunds USING btree (order_id); +CREATE INDEX idx_ord_refunds_tenant ON ord_refunds USING btree (tenant_id, created_at); +CREATE INDEX idx_pln_feature_flags_key ON pln_feature_flags USING btree (feature_key); +CREATE INDEX idx_pln_feature_flags_tenant ON pln_feature_flags USING btree (tenant_id, feature_key); +CREATE INDEX idx_pln_tenant_plans_status ON pln_tenant_plans USING btree (status, end_at); +CREATE INDEX idx_pln_tenant_plans_tenant ON pln_tenant_plans USING btree (tenant_id); +CREATE INDEX idx_tnt_projects_tenant ON tnt_projects USING btree (tenant_id); + + +CREATE INDEX idx_sys_email_verify_codes_lookup ON sys_email_verify_codes USING btree (email, purpose, expires_at); +CREATE INDEX idx_sys_sessions_user ON sys_sessions USING btree (user_type, user_id, expires_at); +CREATE INDEX idx_spt_attachments_ticket ON spt_attachments USING btree (ticket_id); +CREATE INDEX idx_spt_replies_created ON spt_replies USING btree (created_at); +CREATE INDEX idx_spt_replies_ticket ON spt_replies USING btree (ticket_id); +CREATE INDEX idx_spt_tickets_assigned ON spt_tickets USING btree (assigned_admin_id); +CREATE INDEX idx_spt_tickets_created ON spt_tickets USING btree (created_at); +CREATE INDEX idx_spt_tickets_status ON spt_tickets USING btree (status); +CREATE INDEX idx_spt_tickets_tenant ON spt_tickets USING btree (tenant_id, user_id); +CREATE INDEX idx_tnt_invitations_tenant ON tnt_invitations USING btree (tenant_id); +CREATE INDEX idx_tnt_member_imports_status ON tnt_member_imports USING btree (status); +CREATE INDEX idx_tnt_member_imports_tenant ON tnt_member_imports USING btree (tenant_id); +CREATE INDEX idx_tnt_member_model_scopes_user ON tnt_member_model_scopes USING btree (tenant_id, user_id); +CREATE INDEX idx_tnt_oauth_identities_provider ON tnt_oauth_identities USING btree (provider, provider_user_id); +CREATE INDEX idx_tnt_oauth_identities_user ON tnt_oauth_identities USING btree (tenant_id, user_id); +CREATE INDEX idx_tnt_users_tenant ON tnt_users USING btree (tenant_id); +CREATE INDEX idx_tsk_async_tasks_active ON tsk_async_tasks USING btree (status, platform) WHERE ((status)::text <> ALL ((ARRAY['SUCCESS'::character varying, 'FAILURE'::character varying])::text[])); +CREATE INDEX idx_tsk_async_tasks_status ON tsk_async_tasks USING btree (status); +CREATE INDEX idx_tsk_async_tasks_submit_time ON tsk_async_tasks USING btree (submit_time); +CREATE INDEX idx_tsk_async_tasks_user ON tsk_async_tasks USING btree (tenant_id, user_id); + +CREATE INDEX idx_tsk_task_logs_task ON tsk_task_logs USING btree (task_id, created_at); +CREATE INDEX idx_tsk_tasks_scheduled ON tsk_tasks USING btree (scheduled_at) WHERE ((status)::text = 'pending'::text); +CREATE INDEX idx_tsk_tasks_status ON tsk_tasks USING btree (status, created_at); +CREATE INDEX idx_opn_webhook_configs_tenant ON opn_webhook_configs USING btree (tenant_id); +CREATE INDEX idx_opn_webhook_delivery_logs_event ON opn_webhook_delivery_logs USING btree (event_id); +CREATE INDEX idx_opn_webhook_delivery_logs_tenant ON opn_webhook_delivery_logs USING btree (tenant_id, created_at DESC); +CREATE INDEX idx_opn_webhook_events_status ON opn_webhook_events USING btree (status, next_retry_at) WHERE ((status)::text = ANY ((ARRAY['pending'::character varying, 'failed'::character varying])::text[])); +CREATE INDEX idx_opn_webhook_events_tenant ON opn_webhook_events USING btree (tenant_id, created_at DESC); + + +CREATE INDEX idx_bil_usage_logs_project ON ONLY bil_usage_logs USING btree (project_id) WHERE project_id IS NOT NULL; +CREATE INDEX idx_aud_request_logs_project ON aud_request_logs USING btree (project_id) WHERE project_id IS NOT NULL; +CREATE UNIQUE INDEX uk_chn_channel_keys_channel_id ON chn_channel_keys USING btree (channel_id); +CREATE INDEX idx_chn_channel_keys_oauth_expiring ON chn_channel_keys (token_expires_at) WHERE key_type = 'oauth' AND status = 'active'; + + +CREATE INDEX idx_ord_redemption_usages_redemption ON ord_redemption_usages USING btree (redemption_id); +CREATE INDEX idx_ord_redemption_usages_tenant ON ord_redemption_usages USING btree (tenant_id, created_at DESC); +CREATE INDEX idx_sys_error_logs_resolved ON sys_error_logs (resolved) WHERE resolved = FALSE; +CREATE INDEX idx_sys_error_logs_source ON sys_error_logs (source); +CREATE INDEX idx_sys_error_logs_error_code ON sys_error_logs (error_code); +CREATE INDEX idx_sys_error_logs_created_at ON sys_error_logs USING BRIN (created_at); +CREATE INDEX idx_sys_cron_job_exec_name_time ON sys_cron_job_executions (job_name, created_at DESC); +CREATE INDEX idx_sys_cron_job_exec_created_brin ON sys_cron_job_executions USING BRIN (created_at); + +-- ============================================================ +-- Triggers +-- ============================================================ + +CREATE TRIGGER tr_cleanup_channel_references BEFORE DELETE ON chn_channels FOR EACH ROW EXECUTE FUNCTION cleanup_channel_references(); + +-- ============================================================ +-- Comments +-- ============================================================ + +COMMENT ON TABLE api_key_model_scopes IS 'API Key 可用模型范围'; +COMMENT ON COLUMN api_key_model_scopes.id IS '主键ID'; +COMMENT ON COLUMN api_key_model_scopes.api_key_id IS '关联 API Key ID'; +COMMENT ON COLUMN api_key_model_scopes.model_name IS '允许调用的模型名'; +COMMENT ON COLUMN api_key_model_scopes.created_at IS '创建时间'; +COMMENT ON COLUMN api_key_model_scopes.updated_at IS '更新时间'; +COMMENT ON TABLE api_keys IS 'API Key(租户调用 AI 接口的凭证)'; +COMMENT ON COLUMN api_keys.id IS '主键ID'; +COMMENT ON COLUMN api_keys.tenant_id IS '所属租户ID'; +COMMENT ON COLUMN api_keys.user_id IS '创建者用户ID'; +COMMENT ON COLUMN api_keys.name IS 'Key 名称(如 "生产环境"、"测试用")'; +COMMENT ON COLUMN api_keys.encrypted_key IS '加密存储的完整 API Key(AES-256)'; +COMMENT ON COLUMN api_keys.key_prefix IS 'Key 前缀(用于快速查找,明文存储,如 sk-a1b2c3d4)'; +COMMENT ON COLUMN api_keys.scope IS '权限范围:full(全部)/ chat_only(仅对话)/ embeddings_only(仅嵌入)/ images_only(仅图像)/ read_only(只读)/ custom(自定义)'; +COMMENT ON COLUMN api_keys.status IS '状态:active(正常)/ disabled(禁用)/ expired(已过期)'; +COMMENT ON COLUMN api_keys.expires_at IS '过期时间(NULL 表示永不过期)'; +COMMENT ON COLUMN api_keys.rate_limit_qps IS 'QPS 限流阈值(NULL 表示使用默认值)'; +COMMENT ON COLUMN api_keys.rate_limit_concurrency IS '并发限制阈值(NULL 表示使用默认值)'; +COMMENT ON COLUMN api_keys.ip_whitelist IS 'IP 白名单数组(NULL 或空数组表示不限制)'; +COMMENT ON COLUMN api_keys.total_quota IS '额度上限(NULL 表示不限制)'; +COMMENT ON COLUMN api_keys.used_quota IS '已使用额度'; +COMMENT ON COLUMN api_keys.project_id IS '关联项目ID(NULL 表示不属于任何项目)'; +COMMENT ON COLUMN api_keys.created_at IS '创建时间'; +COMMENT ON COLUMN api_keys.updated_at IS '更新时间'; +COMMENT ON COLUMN api_keys.key_type IS '密钥类型:personal(个人密钥)/ project(项目密钥)'; +COMMENT ON TABLE aud_login_history IS '登录历史记录(管理后台 + 租户控制台共用)'; +COMMENT ON COLUMN aud_login_history.id IS '主键ID'; +COMMENT ON COLUMN aud_login_history.user_type IS '用户类型:admin(管理后台)/ tenant(租户控制台)'; +COMMENT ON COLUMN aud_login_history.user_id IS '用户ID'; +COMMENT ON COLUMN aud_login_history.tenant_id IS '租户ID(仅 tenant 类型用户有值)'; +COMMENT ON COLUMN aud_login_history.login_method IS '登录方式:password(密码)/ totp(双因素)/ sso(单点登录)/ backup_code(恢复码)'; +COMMENT ON COLUMN aud_login_history.ip_address IS '登录IP地址'; +COMMENT ON COLUMN aud_login_history.user_agent IS '浏览器 User-Agent'; +COMMENT ON COLUMN aud_login_history.device_fingerprint IS '设备指纹(用于检测新设备登录)'; +COMMENT ON COLUMN aud_login_history.location IS 'IP 地理位置'; +COMMENT ON COLUMN aud_login_history.is_new_device IS '是否为新设备'; +COMMENT ON COLUMN aud_login_history.success IS '登录是否成功'; +COMMENT ON COLUMN aud_login_history.fail_reason IS '登录失败原因'; +COMMENT ON COLUMN aud_login_history.created_at IS '登录时间'; +COMMENT ON TABLE aud_operation_logs IS '操作日志(记录管理后台和租户控制台的所有写操作)'; +COMMENT ON COLUMN aud_operation_logs.id IS '主键ID'; +COMMENT ON COLUMN aud_operation_logs.tenant_id IS '租户ID(管理后台操作时为 NULL)'; +COMMENT ON COLUMN aud_operation_logs.user_id IS '操作者用户ID'; +COMMENT ON COLUMN aud_operation_logs.user_type IS '操作者类型:admin(管理后台)/ tenant(租户控制台)'; +COMMENT ON COLUMN aud_operation_logs.action IS '操作动作(如 create_tenant、update_channel、delete_model)'; +COMMENT ON COLUMN aud_operation_logs.resource_type IS '操作资源类型:tenant / channel / model / user / api_key 等'; +COMMENT ON COLUMN aud_operation_logs.resource_id IS '操作资源ID'; +COMMENT ON COLUMN aud_operation_logs.detail IS '操作详情(JSONB:变更前后的字段差异等)'; +COMMENT ON COLUMN aud_operation_logs.ip_address IS '操作者 IP 地址'; +COMMENT ON COLUMN aud_operation_logs.created_at IS '创建时间'; +COMMENT ON COLUMN aud_operation_logs.updated_at IS '更新时间'; +COMMENT ON COLUMN aud_operation_logs.changes_json IS '变更前后数据对比(JSON diff)'; +COMMENT ON TABLE aud_request_logs IS '请求审计日志(记录所有 API 代理请求)'; +COMMENT ON COLUMN aud_request_logs.id IS '主键ID'; +COMMENT ON COLUMN aud_request_logs.tenant_id IS '租户ID'; +COMMENT ON COLUMN aud_request_logs.user_id IS '用户ID'; +COMMENT ON COLUMN aud_request_logs.api_key_id IS '使用的 API Key ID'; +COMMENT ON COLUMN aud_request_logs.request_id IS '请求唯一ID(关联全链路追踪)'; +COMMENT ON COLUMN aud_request_logs.method IS 'HTTP 方法(GET/POST/PUT/DELETE)'; +COMMENT ON COLUMN aud_request_logs.path IS '请求路径'; +COMMENT ON COLUMN aud_request_logs.query_params IS '查询参数(URL Query String)'; +COMMENT ON COLUMN aud_request_logs.status_code IS 'HTTP 响应状态码'; +COMMENT ON COLUMN aud_request_logs.client_ip IS '客户端 IP'; +COMMENT ON COLUMN aud_request_logs.user_agent IS '客户端 User-Agent'; +COMMENT ON COLUMN aud_request_logs.request_body IS '请求体(敏感字段脱敏后存储)'; +COMMENT ON COLUMN aud_request_logs.response_body IS '响应体(截断后存储)'; +COMMENT ON COLUMN aud_request_logs.latency_ms IS '请求延迟(毫秒)'; +COMMENT ON COLUMN aud_request_logs.audit_level IS '审计级别:full(完整记录)/ masked(脱敏记录)/ question_only(仅记录提问)/ none(不记录)'; +COMMENT ON COLUMN aud_request_logs.created_at IS '创建时间'; +COMMENT ON COLUMN aud_request_logs.updated_at IS '更新时间'; +COMMENT ON COLUMN aud_request_logs.tenant_request_body IS '租户级请求体(按租户审计级别处理)'; +COMMENT ON COLUMN aud_request_logs.tenant_response_body IS '租户级响应体(按租户审计级别处理)'; +COMMENT ON COLUMN aud_request_logs.tenant_audit_level IS '租户审计级别:full/full_text/masked/question_only/none'; +COMMENT ON TABLE aud_sensitive_access_logs IS '敏感数据访问日志'; +COMMENT ON COLUMN aud_sensitive_access_logs.id IS '主键ID'; +COMMENT ON COLUMN aud_sensitive_access_logs.user_id IS '访问者用户ID'; +COMMENT ON COLUMN aud_sensitive_access_logs.user_type IS '访问者类型:admin(管理后台)/ tenant(租户控制台)'; +COMMENT ON COLUMN aud_sensitive_access_logs.resource_type IS '资源类型(如 api_key、channel、wallet 等)'; +COMMENT ON COLUMN aud_sensitive_access_logs.resource_id IS '资源ID'; +COMMENT ON COLUMN aud_sensitive_access_logs.action IS '访问动作(如 view、export、download)'; +COMMENT ON COLUMN aud_sensitive_access_logs.reason IS '访问原因(查看敏感数据时需填写)'; +COMMENT ON COLUMN aud_sensitive_access_logs.ip_address IS '访问者 IP 地址'; +COMMENT ON COLUMN aud_sensitive_access_logs.user_agent IS '访问者 User-Agent'; +COMMENT ON COLUMN aud_sensitive_access_logs.created_at IS '创建时间'; +COMMENT ON TABLE bil_daily_revenue_summary IS '日收入汇总'; +COMMENT ON TABLE bil_daily_usage_summary IS '日用量汇总'; +COMMENT ON TABLE bil_monthly_revenue_summary IS '月收入汇总'; +COMMENT ON TABLE bil_monthly_usage_summary IS '月用量汇总'; +COMMENT ON TABLE bil_records IS '计费记录(预扣→结算→退差额全流程)'; +COMMENT ON COLUMN bil_records.id IS '主键ID'; +COMMENT ON COLUMN bil_records.tenant_id IS '租户ID'; +COMMENT ON COLUMN bil_records.user_id IS '用户ID'; +COMMENT ON COLUMN bil_records.api_key_id IS '使用的 API Key ID'; +COMMENT ON COLUMN bil_records.channel_id IS '使用的渠道ID'; +COMMENT ON COLUMN bil_records.model_name IS '调用的模型名'; +COMMENT ON COLUMN bil_records.request_id IS '请求唯一ID(关联全链路追踪)'; +COMMENT ON COLUMN bil_records.relay_mode IS '代理模式:chat_completions / embeddings / images_generations 等'; +COMMENT ON COLUMN bil_records.input_tokens IS '输入 token 数'; +COMMENT ON COLUMN bil_records.output_tokens IS '输出 token 数'; +COMMENT ON COLUMN bil_records.input_price IS '计费时输入单价(快照,防止价格变更影响历史记录)'; +COMMENT ON COLUMN bil_records.output_price IS '计费时输出单价(快照)'; +COMMENT ON COLUMN bil_records.total_cost IS '最终费用 = 基础价格 × 模型倍率 × 租户倍率'; +COMMENT ON COLUMN bil_records.currency IS '货币(USD)'; +COMMENT ON COLUMN bil_records.status IS '状态:pre_deducted(已预扣)/ settled(已结算)/ refunded(已退款)'; +COMMENT ON COLUMN bil_records.settled_at IS '结算时间'; +COMMENT ON COLUMN bil_records.created_at IS '创建时间'; +COMMENT ON COLUMN bil_records.updated_at IS '更新时间'; +COMMENT ON COLUMN bil_records.billing_mode IS '实际计费模式'; +COMMENT ON COLUMN bil_records.effective_input_price IS '实际生效的输入单价(快照)'; +COMMENT ON COLUMN bil_records.effective_output_price IS '实际生效的输出单价(快照)'; +COMMENT ON COLUMN bil_records.discount_ratio IS '实际折扣比例(快照)'; +COMMENT ON COLUMN bil_records.billing_input_multiplier IS '梯度定价输入乘数(快照)'; +COMMENT ON COLUMN bil_records.billing_output_multiplier IS '梯度定价输出乘数(快照)'; +COMMENT ON COLUMN bil_records.cache_creation_tokens IS '缓存创建 token 数'; +COMMENT ON COLUMN bil_records.cache_read_tokens IS '缓存读取 token 数'; +COMMENT ON COLUMN bil_records.cache_creation_cost IS '缓存创建费用'; +COMMENT ON COLUMN bil_records.cache_read_cost IS '缓存读取费用'; +COMMENT ON COLUMN bil_records.model_multiplier IS '模型倍率(快照)'; +COMMENT ON COLUMN bil_records.tenant_multiplier IS '租户倍率(快照)'; +COMMENT ON COLUMN bil_records.base_input_price IS '基础模型输入单价(快照,应用倍率前)'; +COMMENT ON COLUMN bil_records.base_output_price IS '基础模型输出单价(快照,应用倍率前)'; +COMMENT ON COLUMN bil_records.billing_snapshot IS '完整计费计算过程快照(JSONB)'; +COMMENT ON TABLE bil_transactions IS '交易流水(钱包所有资金变动记录)'; +COMMENT ON COLUMN bil_transactions.id IS '主键ID'; +COMMENT ON COLUMN bil_transactions.tenant_id IS '租户ID'; +COMMENT ON COLUMN bil_transactions.wallet_id IS '关联钱包ID'; +COMMENT ON COLUMN bil_transactions.type IS '类型:recharge(充值)/ pre_deduct(预扣)/ settle(结算)/ refund(退款)/ adjust(调整)/ freeze(冻结)/ unfreeze(解冻)'; +COMMENT ON COLUMN bil_transactions.amount IS '变动金额(正数=收入,负数=支出)'; +COMMENT ON COLUMN bil_transactions.balance_after IS '变动后总余额'; +COMMENT ON COLUMN bil_transactions.frozen_after IS '变动后冻结余额'; +COMMENT ON COLUMN bil_transactions.related_id IS '关联业务ID(如计费记录ID、订单ID等)'; +COMMENT ON COLUMN bil_transactions.related_type IS '关联业务类型:billing_record / order / refund / adjustment / redemption'; +COMMENT ON COLUMN bil_transactions.description IS '交易描述'; +COMMENT ON COLUMN bil_transactions.created_at IS '创建时间'; +COMMENT ON COLUMN bil_transactions.updated_at IS '更新时间'; +COMMENT ON TABLE bil_wallets IS '租户钱包'; +COMMENT ON COLUMN bil_wallets.id IS '主键ID'; +COMMENT ON COLUMN bil_wallets.tenant_id IS '租户ID(每个租户一个钱包)'; +COMMENT ON COLUMN bil_wallets.balance IS '总余额'; +COMMENT ON COLUMN bil_wallets.frozen_balance IS '冻结余额(支付中/退款中,可用余额 = balance - frozen_balance)'; +COMMENT ON COLUMN bil_wallets.warning_threshold IS '余额预警线(低于此值触发通知)'; +COMMENT ON COLUMN bil_wallets.currency IS '货币(USD)'; +COMMENT ON COLUMN bil_wallets.created_at IS '创建时间'; +COMMENT ON COLUMN bil_wallets.updated_at IS '更新时间'; +COMMENT ON TABLE chn_abilities IS '渠道能力索引(渠道支持的模型映射)'; +COMMENT ON COLUMN chn_abilities.id IS '主键ID'; +COMMENT ON COLUMN chn_abilities.channel_id IS '关联渠道ID'; +COMMENT ON COLUMN chn_abilities.model_name IS '平台标准模型名(用户请求使用的模型名)'; +COMMENT ON COLUMN chn_abilities.upstream_model IS '上游实际模型名(与平台标准名不同时需要映射,如平台名 gpt-4 → 上游名 gpt-4-0314)'; +COMMENT ON COLUMN chn_abilities.enabled IS '是否启用该模型能力'; +COMMENT ON COLUMN chn_abilities.created_at IS '创建时间'; +COMMENT ON COLUMN chn_abilities.updated_at IS '更新时间'; +COMMENT ON TABLE chn_channel_affinities IS '渠道亲和性缓存(用户+模型→渠道映射,TTL 1800s)'; +COMMENT ON COLUMN chn_channel_affinities.id IS '主键ID'; +COMMENT ON COLUMN chn_channel_affinities.tenant_id IS '租户ID'; +COMMENT ON COLUMN chn_channel_affinities.user_id IS '用户ID'; +COMMENT ON COLUMN chn_channel_affinities.model_name IS '模型名'; +COMMENT ON COLUMN chn_channel_affinities.channel_id IS '绑定的渠道ID'; +COMMENT ON COLUMN chn_channel_affinities.hit_count IS '命中次数(同一渠道连续成功次数)'; +COMMENT ON COLUMN chn_channel_affinities.expires_at IS '过期时间(默认 1800 秒后过期)'; +COMMENT ON COLUMN chn_channel_affinities.created_at IS '创建时间'; +COMMENT ON COLUMN chn_channel_affinities.updated_at IS '更新时间'; +COMMENT ON TABLE chn_channel_keys IS '渠道 API Key(一个渠道可配多个 Key 轮询)'; +COMMENT ON COLUMN chn_channel_keys.id IS '主键ID'; +COMMENT ON COLUMN chn_channel_keys.channel_id IS '关联渠道ID'; +COMMENT ON COLUMN chn_channel_keys.name IS 'Key 别名(用于管理标识,如"主力Key"、"备用Key")'; +COMMENT ON COLUMN chn_channel_keys.encrypted_key IS '加密存储的 API Key 原值(AES-256)'; +COMMENT ON COLUMN chn_channel_keys.status IS '状态:active(可用)/ disabled(禁用)/ exhausted(额度耗尽)'; +COMMENT ON COLUMN chn_channel_keys.last_used_at IS '最后使用时间'; +COMMENT ON COLUMN chn_channel_keys.last_error IS '最后一次错误信息'; +COMMENT ON COLUMN chn_channel_keys.created_at IS '创建时间'; +COMMENT ON COLUMN chn_channel_keys.updated_at IS '更新时间'; +COMMENT ON TABLE chn_channels IS '渠道配置(AI 供应商接入点)'; +COMMENT ON COLUMN chn_channels.id IS '主键ID'; +COMMENT ON COLUMN chn_channels.name IS '渠道显示名称(如 "OpenAI 主力"、"Claude 备用")'; +COMMENT ON COLUMN chn_channels.type IS '供应商类型:1=OpenAI, 2=Anthropic Claude, 3=Google Gemini, 4=阿里云百炼, 5=百度文心, 6=腾讯混元, 7=智谱AI, 8=DeepSeek, 9=Moonshot, 10=火山引擎, 11=AWS Bedrock, 12=Azure OpenAI, 13=Google Vertex AI, 14=Cohere, 15=Mistral, 16=xAI'; +COMMENT ON COLUMN chn_channels.base_url IS 'API 基础地址'; +COMMENT ON COLUMN chn_channels.status IS '状态:active(启用)/ disabled(禁用)/ testing(测试中)'; +COMMENT ON COLUMN chn_channels.priority IS '优先级(数字越大越优先,调度时优先选择高优先级渠道)'; +COMMENT ON COLUMN chn_channels.weight IS '权重(同优先级下按权重随机选择,范围 1-100)'; +COMMENT ON COLUMN chn_channels.max_concurrency IS '最大并发请求数'; +COMMENT ON COLUMN chn_channels.settings IS '渠道配置(JSONB):超时时间、重试次数等'; +COMMENT ON COLUMN chn_channels.test_model IS '测试使用的模型名'; +COMMENT ON COLUMN chn_channels.remark IS '备注'; +COMMENT ON COLUMN chn_channels.created_by IS '创建者管理员ID'; +COMMENT ON COLUMN chn_channels.created_at IS '创建时间'; +COMMENT ON COLUMN chn_channels.updated_at IS '更新时间'; +COMMENT ON COLUMN chn_channels.is_vip IS '是否VIP专属渠道'; +COMMENT ON COLUMN chn_channels.sharing_threshold IS '允许普通租户借用的利用率阈值(如0.6表示利用率<60%时可借用)'; +COMMENT ON COLUMN chn_channels.preemption_threshold IS '触发VIP抢占的利用率阈值(如0.8表示利用率>=80%时VIP可抢占)'; +COMMENT ON COLUMN chn_channels.borrowing_cooldown_seconds IS '普通租户被抢占后的冷却时间(秒)'; +COMMENT ON COLUMN chn_channels.auto_disabled IS '是否被自动禁用:0=否, 1=是(由连续失败触发)'; +COMMENT ON TABLE chn_health_scores IS '渠道健康度评分'; +COMMENT ON COLUMN chn_health_scores.id IS '主键ID'; +COMMENT ON COLUMN chn_health_scores.channel_id IS '关联渠道ID'; +COMMENT ON COLUMN chn_health_scores.success_rate IS '请求成功率(0-100)'; +COMMENT ON COLUMN chn_health_scores.latency_ms IS '平均延迟(毫秒)'; +COMMENT ON COLUMN chn_health_scores.stability_score IS '稳定性评分(0-100,基于延迟波动计算)'; +COMMENT ON COLUMN chn_health_scores.consecutive_failures IS '连续失败次数(成功后归零)'; +COMMENT ON COLUMN chn_health_scores.health_score IS '综合健康度(0-100)= 成功率×0.40 + 延迟分×0.25 + 稳定性×0.20 + 连续失败分×0.15'; +COMMENT ON COLUMN chn_health_scores.calculated_at IS '最近一次计算时间'; +COMMENT ON COLUMN chn_health_scores.created_at IS '创建时间'; +COMMENT ON COLUMN chn_health_scores.updated_at IS '更新时间'; +COMMENT ON TABLE chn_health_snapshots IS '渠道健康度定时快照(每5分钟采集,用于趋势图表)'; +COMMENT ON COLUMN chn_health_snapshots.id IS '主键ID'; +COMMENT ON COLUMN chn_health_snapshots.channel_id IS '关联渠道ID'; +COMMENT ON COLUMN chn_health_snapshots.health_score IS '综合健康度(0-100)'; +COMMENT ON COLUMN chn_health_snapshots.success_rate IS '请求成功率(0-100)'; +COMMENT ON COLUMN chn_health_snapshots.latency_ms IS '平均延迟(毫秒)'; +COMMENT ON COLUMN chn_health_snapshots.stability_score IS '稳定性评分(0-100)'; +COMMENT ON COLUMN chn_health_snapshots.consecutive_failures IS '连续失败次数'; +COMMENT ON COLUMN chn_health_snapshots.snapshot_at IS '快照时间'; +COMMENT ON TABLE clg_changelogs IS '更新日志'; +COMMENT ON COLUMN clg_changelogs.id IS '主键ID'; +COMMENT ON COLUMN clg_changelogs.version IS '版本号'; +COMMENT ON COLUMN clg_changelogs.title IS '标题'; +COMMENT ON COLUMN clg_changelogs.content IS 'Markdown 内容'; +COMMENT ON COLUMN clg_changelogs.type IS '类型:feature / fix / improvement / breaking'; +COMMENT ON COLUMN clg_changelogs.status IS '状态:draft / published'; +COMMENT ON COLUMN clg_changelogs.published_at IS '发布时间'; +COMMENT ON COLUMN clg_changelogs.created_by IS '创建的管理员 ID'; +COMMENT ON TABLE ord_promo_code_usages IS '优惠码使用记录'; +COMMENT ON COLUMN ord_promo_code_usages.discount_amount IS '实际折扣金额'; +COMMENT ON TABLE ord_promo_codes IS '优惠码'; +COMMENT ON COLUMN ord_promo_codes.code IS '优惠码文本(唯一)'; +COMMENT ON COLUMN ord_promo_codes.type IS '类型:percentage(折扣百分比)/ fixed(立减固定金额)'; +COMMENT ON COLUMN ord_promo_codes.discount_value IS '折扣值(百分比 0-100,立减为金额)'; +COMMENT ON COLUMN ord_promo_codes.min_amount IS '最低订单金额'; +COMMENT ON COLUMN ord_promo_codes.max_discount IS '最大折扣金额(0=不限)'; +COMMENT ON COLUMN ord_promo_codes.plan_ids IS '适用套餐ID数组(NULL=全部)'; +COMMENT ON TABLE ord_redemptions IS '兑换码'; +COMMENT ON COLUMN ord_redemptions.type IS '类型:quota(额度)/ plan(套餐时长)/ duration(时长天数)'; +COMMENT ON COLUMN ord_redemptions.batch_no IS '批次号(批量生成时,便于管理)'; +COMMENT ON TABLE spt_feedbacks IS '用户反馈'; +COMMENT ON COLUMN spt_feedbacks.id IS '主键ID'; +COMMENT ON COLUMN spt_feedbacks.tenant_id IS '所属租户ID'; +COMMENT ON COLUMN spt_feedbacks.user_id IS '提交用户ID'; +COMMENT ON COLUMN spt_feedbacks.category IS '反馈类型:bug_report / feature_request / suggestion / complaint'; +COMMENT ON COLUMN spt_feedbacks.title IS '反馈标题'; +COMMENT ON COLUMN spt_feedbacks.description IS '反馈详细描述'; +COMMENT ON COLUMN spt_feedbacks.status IS '状态:pending / acknowledged / in_progress / resolved / closed'; +COMMENT ON COLUMN spt_feedbacks.priority IS '优先级:low / normal / high / critical'; +COMMENT ON COLUMN spt_feedbacks.admin_reply IS '管理员回复'; +COMMENT ON COLUMN spt_feedbacks.admin_reply_by IS '回复管理员ID'; +COMMENT ON COLUMN spt_feedbacks.admin_reply_at IS '回复时间'; +COMMENT ON COLUMN spt_feedbacks.resolution IS '解决方案摘要'; +COMMENT ON COLUMN spt_feedbacks.tags IS '自定义标签(JSON 数组)'; +COMMENT ON COLUMN spt_feedbacks.metadata IS '元数据(环境信息、截图链接等)'; +COMMENT ON TABLE fil_files IS '文件元数据'; +COMMENT ON COLUMN fil_files.id IS '主键ID'; +COMMENT ON COLUMN fil_files.tenant_id IS '所属租户ID(系统文件为 NULL)'; +COMMENT ON COLUMN fil_files.user_id IS '上传者用户ID'; +COMMENT ON COLUMN fil_files.filename IS '存储文件名(UUID 或哈希值命名)'; +COMMENT ON COLUMN fil_files.original_name IS '用户上传的原始文件名'; +COMMENT ON COLUMN fil_files.mime_type IS 'MIME 类型(如 image/png、application/pdf)'; +COMMENT ON COLUMN fil_files.size IS '文件大小(字节)'; +COMMENT ON COLUMN fil_files.storage_provider IS '存储供应商:s3 / minio / oss / cos'; +COMMENT ON COLUMN fil_files.storage_path IS '存储桶中的完整路径'; +COMMENT ON COLUMN fil_files.virus_scan_status IS '病毒扫描状态:pending(待扫描)/ scanning(扫描中)/ clean(安全)/ infected(感染)'; +COMMENT ON COLUMN fil_files.checksum IS '文件 SHA-256 校验和'; +COMMENT ON COLUMN fil_files.created_at IS '创建时间'; +COMMENT ON COLUMN fil_files.updated_at IS '更新时间'; +COMMENT ON TABLE spt_articles IS '帮助中心-文章'; +COMMENT ON COLUMN spt_articles.id IS '主键ID'; +COMMENT ON COLUMN spt_articles.category_id IS '所属分类ID'; +COMMENT ON COLUMN spt_articles.title IS '文章标题'; +COMMENT ON COLUMN spt_articles.slug IS 'URL 友好标识,唯一'; +COMMENT ON COLUMN spt_articles.content IS '文章内容(Markdown)'; +COMMENT ON COLUMN spt_articles.summary IS '文章摘要'; +COMMENT ON COLUMN spt_articles.status IS '状态:draft / published'; +COMMENT ON COLUMN spt_articles.author_id IS '作者(管理员)ID'; +COMMENT ON COLUMN spt_articles.view_count IS '浏览次数'; +COMMENT ON COLUMN spt_articles.sort_order IS '排序序号,越小越靠前'; +COMMENT ON COLUMN spt_articles.keywords IS '关键词(JSON 数组)'; +COMMENT ON COLUMN spt_articles.published_at IS '发布时间'; +COMMENT ON TABLE spt_categories IS '帮助中心-分类'; +COMMENT ON COLUMN spt_categories.id IS '主键ID'; +COMMENT ON COLUMN spt_categories.parent_id IS '父分类ID,0表示顶级分类'; +COMMENT ON COLUMN spt_categories.name IS '分类名称'; +COMMENT ON COLUMN spt_categories.slug IS 'URL 友好标识,唯一'; +COMMENT ON COLUMN spt_categories.description IS '分类描述'; +COMMENT ON COLUMN spt_categories.sort_order IS '排序序号,越小越靠前'; +COMMENT ON COLUMN spt_categories.icon IS '图标名称'; +COMMENT ON COLUMN spt_categories.is_visible IS '是否对外可见'; +COMMENT ON COLUMN spt_categories.article_count IS '分类下文章数量(冗余计数)'; +COMMENT ON TABLE mdl_models IS 'AI 模型定义'; +COMMENT ON COLUMN mdl_models.id IS '主键ID'; +COMMENT ON COLUMN mdl_models.model_id IS '模型唯一标识(如 gpt-4o、claude-3-5-sonnet)'; +COMMENT ON COLUMN mdl_models.model_name IS '模型显示名称(如 GPT-4o、Claude 3.5 Sonnet)'; +COMMENT ON COLUMN mdl_models.category IS '模型分类:chat(对话)/ embedding(嵌入)/ image(图像)/ audio(音频)/ rerank(重排序)'; +COMMENT ON COLUMN mdl_models.status IS '状态:active(可用)/ deprecated(已废弃)/ offline(已下线)'; +COMMENT ON COLUMN mdl_models.max_context_tokens IS '最大上下文 token 数'; +COMMENT ON COLUMN mdl_models.max_output_tokens IS '最大输出 token 数'; +COMMENT ON COLUMN mdl_models.description IS '模型描述'; +COMMENT ON COLUMN mdl_models.tags IS '标签(如 reasoning、vision、function_calling)'; +COMMENT ON COLUMN mdl_models.capabilities IS '模型能力特性(如 vision、function_calling、reasoning 等)'; +COMMENT ON COLUMN mdl_models.created_at IS '创建时间'; +COMMENT ON COLUMN mdl_models.updated_at IS '更新时间'; +COMMENT ON COLUMN mdl_models.deprecated_at IS '标记弃用的时间(NULL表示未弃用)'; +COMMENT ON COLUMN mdl_models.sunset_date IS '计划下线日期(到达后返回410 Gone,NULL表示未设置)'; +COMMENT ON COLUMN mdl_models.replacement_model IS '推荐替代模型名(NULL表示无替代)'; +COMMENT ON TABLE mdl_pricing IS '模型统一定价表(按次/按量/阶梯三种计费模式)'; +COMMENT ON COLUMN mdl_pricing.model_id IS '关联模型ID'; +COMMENT ON COLUMN mdl_pricing.billing_mode IS '计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)'; +COMMENT ON COLUMN mdl_pricing.min_tokens IS '阶梯起始 token 数(仅 tiered 模式,其他模式为 0)'; +COMMENT ON COLUMN mdl_pricing.max_tokens IS '阶梯结束 token 数(NULL=无上限,仅 tiered 模式)'; +COMMENT ON COLUMN mdl_pricing.input_price IS '每 1M input token 价格(token/tiered 模式)'; +COMMENT ON COLUMN mdl_pricing.output_price IS '每 1M output token 价格(token/tiered 模式)'; +COMMENT ON COLUMN mdl_pricing.per_request_price IS '按次计费单价(仅 per_request 模式)'; +COMMENT ON COLUMN mdl_pricing.cache_read_price IS '缓存读取每 1M token 价格(直接定价)'; +COMMENT ON COLUMN mdl_pricing.cache_creation_price IS '缓存创建每 1M token 价格(直接定价)'; +COMMENT ON TABLE mdl_tenant_models IS '租户可用模型配置'; +COMMENT ON COLUMN mdl_tenant_models.id IS '主键ID'; +COMMENT ON COLUMN mdl_tenant_models.tenant_id IS '租户ID'; +COMMENT ON COLUMN mdl_tenant_models.model_id IS '模型ID'; +COMMENT ON COLUMN mdl_tenant_models.enabled IS '是否启用(禁用后该租户无法调用此模型)'; +COMMENT ON COLUMN mdl_tenant_models.custom_input_price IS '租户自定义输入价格(NULL 表示使用默认定价)'; +COMMENT ON COLUMN mdl_tenant_models.custom_output_price IS '租户自定义输出价格(NULL 表示使用默认定价)'; +COMMENT ON COLUMN mdl_tenant_models.multiplier IS '租户价格倍率(VIP 折扣等,最终价格 = 基础价格 × 模型倍率 × 租户倍率)'; +COMMENT ON COLUMN mdl_tenant_models.created_at IS '创建时间'; +COMMENT ON COLUMN mdl_tenant_models.updated_at IS '更新时间'; +COMMENT ON COLUMN mdl_tenant_models.billing_mode IS '覆盖模型计费方式(NULL表示跟随模型默认)'; +COMMENT ON COLUMN mdl_tenant_models.per_request_price IS '按次计费单价(覆盖模型默认,仅 billing_mode = per_request 时有效)'; +COMMENT ON COLUMN mdl_tenant_models.discount_ratio IS '折扣比例(如0.8表示八折,NULL表示不打折,优先于 multiplier 使用)'; +COMMENT ON COLUMN mdl_tenant_models.max_concurrency IS '单模型并发上限(NULL表示不限制)'; +COMMENT ON COLUMN mdl_tenant_models.channel_scope IS '渠道范围覆盖(NULL跟随租户默认,[]表示全部,数组表示指定渠道ID)'; +COMMENT ON TABLE ntf_announcements IS '系统公告'; +COMMENT ON COLUMN ntf_announcements.id IS '主键ID'; +COMMENT ON COLUMN ntf_announcements.title IS '公告标题'; +COMMENT ON COLUMN ntf_announcements.type IS '公告类型:info(通知)/ warning(警告)/ important(重要)'; +COMMENT ON COLUMN ntf_announcements.content IS '公告内容'; +COMMENT ON COLUMN ntf_announcements.status IS '状态:draft(草稿)/ published(已发布)/ archived(已归档)'; +COMMENT ON COLUMN ntf_announcements.is_pinned IS '是否置顶:0=否, 1=是'; +COMMENT ON COLUMN ntf_announcements.display_position IS '展示位置:login(登录页)/ console(控制台)/ both(双位置)'; +COMMENT ON COLUMN ntf_announcements.effective_at IS '生效时间(NULL=立即生效)'; +COMMENT ON COLUMN ntf_announcements.expires_at IS '过期时间(NULL=永不过期)'; +COMMENT ON COLUMN ntf_announcements.created_by IS '创建者(管理员ID)'; +COMMENT ON COLUMN ntf_announcements.created_at IS '创建时间'; +COMMENT ON COLUMN ntf_announcements.updated_at IS '更新时间'; +COMMENT ON TABLE ntf_messages IS '站内消息'; +COMMENT ON COLUMN ntf_messages.id IS '主键ID'; +COMMENT ON COLUMN ntf_messages.tenant_id IS '所属租户ID'; +COMMENT ON COLUMN ntf_messages.user_id IS '接收用户ID(广播消息时为 NULL)'; +COMMENT ON COLUMN ntf_messages.type IS '消息类型:billing(计费)/ system(系统)/ security(安全)/ invitation(邀请)/ announcement(公告)'; +COMMENT ON COLUMN ntf_messages.title IS '消息标题'; +COMMENT ON COLUMN ntf_messages.content IS '消息内容'; +COMMENT ON COLUMN ntf_messages.channel IS '发送渠道:in_app(站内)/ email(邮件)/ both(双渠道)'; +COMMENT ON COLUMN ntf_messages.is_read IS '是否已读:0=未读, 1=已读'; +COMMENT ON COLUMN ntf_messages.is_broadcast IS '是否广播消息:0=个人消息, 1=广播消息'; +COMMENT ON COLUMN ntf_messages.metadata IS '附加元数据(JSONB,如关联资源ID、跳转链接等)'; +COMMENT ON COLUMN ntf_messages.created_at IS '创建时间'; +COMMENT ON TABLE ntf_preferences IS '通知偏好设置'; +COMMENT ON COLUMN ntf_preferences.id IS '主键ID'; +COMMENT ON COLUMN ntf_preferences.tenant_id IS '所属租户ID'; +COMMENT ON COLUMN ntf_preferences.user_id IS '用户ID(组织级偏好时为 NULL)'; +COMMENT ON COLUMN ntf_preferences.scope IS '偏好范围:user(用户级)/ org(组织级)'; +COMMENT ON COLUMN ntf_preferences.preferences IS '偏好配置(JSONB,如 {"billing":{"email":true,"in_app":true},"security":{"email":true,"in_app":true}})'; +COMMENT ON COLUMN ntf_preferences.created_at IS '创建时间'; +COMMENT ON COLUMN ntf_preferences.updated_at IS '更新时间'; +COMMENT ON TABLE ntf_read_status IS '广播消息已读状态'; +COMMENT ON COLUMN ntf_read_status.id IS '主键ID'; +COMMENT ON COLUMN ntf_read_status.message_id IS '广播消息ID'; +COMMENT ON COLUMN ntf_read_status.user_id IS '已读用户ID'; +COMMENT ON COLUMN ntf_read_status.read_at IS '已读时间'; +COMMENT ON TABLE ntf_send_log IS '通知发送记录'; +COMMENT ON COLUMN ntf_send_log.id IS '主键ID'; +COMMENT ON COLUMN ntf_send_log.tenant_id IS '租户ID(系统级通知为 NULL)'; +COMMENT ON COLUMN ntf_send_log.user_id IS '目标用户ID'; +COMMENT ON COLUMN ntf_send_log.template_code IS '使用的通知模板编码'; +COMMENT ON COLUMN ntf_send_log.channel IS '发送渠道:email / sms / webhook'; +COMMENT ON COLUMN ntf_send_log.recipient IS '接收方(邮箱地址/手机号/Webhook URL)'; +COMMENT ON COLUMN ntf_send_log.subject IS '发送主题'; +COMMENT ON COLUMN ntf_send_log.body IS '发送内容(渲染后的最终内容)'; +COMMENT ON COLUMN ntf_send_log.status IS '状态:pending(待发送)/ sent(已发送)/ failed(发送失败)'; +COMMENT ON COLUMN ntf_send_log.error_message IS '失败时的错误信息'; +COMMENT ON COLUMN ntf_send_log.sent_at IS '实际发送时间'; +COMMENT ON COLUMN ntf_send_log.retry_count IS '重试次数(最多重试 3 次)'; +COMMENT ON COLUMN ntf_send_log.created_at IS '创建时间'; +COMMENT ON COLUMN ntf_send_log.updated_at IS '更新时间'; +COMMENT ON TABLE ntf_templates IS '通知模板'; +COMMENT ON COLUMN ntf_templates.id IS '主键ID'; +COMMENT ON COLUMN ntf_templates.code IS '模板编码(唯一标识,如 email_verify_code、balance_warning)'; +COMMENT ON COLUMN ntf_templates.channel IS '发送渠道:email(邮件)/ sms(短信)/ webhook(Webhook)'; +COMMENT ON COLUMN ntf_templates.subject IS '邮件/消息主题'; +COMMENT ON COLUMN ntf_templates.body_template IS '消息体模板(支持变量占位符,如 {{.code}})'; +COMMENT ON COLUMN ntf_templates.variables IS '模板变量列表(JSONB 数组,如 ["username", "tenant_name", "code"])'; +COMMENT ON COLUMN ntf_templates.status IS '状态:active(启用)/ disabled(禁用)'; +COMMENT ON COLUMN ntf_templates.created_at IS '创建时间'; +COMMENT ON COLUMN ntf_templates.updated_at IS '更新时间'; +COMMENT ON TABLE opn_apps IS '开放平台应用'; +COMMENT ON COLUMN opn_apps.id IS '主键ID'; +COMMENT ON COLUMN opn_apps.tenant_id IS '所属租户ID'; +COMMENT ON COLUMN opn_apps.name IS '应用名称'; +COMMENT ON COLUMN opn_apps.description IS '应用描述'; +COMMENT ON COLUMN opn_apps.app_id IS '应用标识(opn_xxx 格式)'; +COMMENT ON COLUMN opn_apps.app_secret_hash IS 'App Secret 哈希(bcrypt)'; +COMMENT ON COLUMN opn_apps.permissions IS '权限范围(JSON 数组)'; +COMMENT ON COLUMN opn_apps.ip_whitelist IS 'IP 白名单(JSON 数组,为空则不限制)'; +COMMENT ON COLUMN opn_apps.callback_url IS 'OAuth 回调 URL'; +COMMENT ON COLUMN opn_apps.is_sandbox IS '是否沙箱应用'; +COMMENT ON COLUMN opn_apps.status IS '状态:active(启用)/ disabled(禁用)'; +COMMENT ON COLUMN opn_apps.rate_limit IS '每分钟请求上限'; +COMMENT ON COLUMN opn_apps.last_used_at IS '最后使用时间'; +COMMENT ON COLUMN opn_apps.created_at IS '创建时间'; +COMMENT ON COLUMN opn_apps.updated_at IS '更新时间'; +COMMENT ON TABLE ops_alert_events IS '告警事件'; +COMMENT ON COLUMN ops_alert_events.id IS '主键ID'; +COMMENT ON COLUMN ops_alert_events.rule_id IS '关联规则ID'; +COMMENT ON COLUMN ops_alert_events.rule_name IS '规则名称(冗余存储)'; +COMMENT ON COLUMN ops_alert_events.metric_type IS '指标类型'; +COMMENT ON COLUMN ops_alert_events.level IS '告警级别:info / warning / critical'; +COMMENT ON COLUMN ops_alert_events.status IS '状态:firing(触发中)/ acknowledged(已确认)/ resolved(已恢复)'; +COMMENT ON COLUMN ops_alert_events.trigger_value IS '触发时的实际指标值'; +COMMENT ON COLUMN ops_alert_events.threshold_value IS '规则阈值'; +COMMENT ON COLUMN ops_alert_events.trigger_message IS '触发消息描述'; +COMMENT ON COLUMN ops_alert_events.acknowledged_by IS '确认人管理员ID'; +COMMENT ON COLUMN ops_alert_events.acknowledged_at IS '确认时间'; +COMMENT ON COLUMN ops_alert_events.resolve_notes IS '处理备注'; +COMMENT ON COLUMN ops_alert_events.resolved_by IS '解决人管理员ID'; +COMMENT ON COLUMN ops_alert_events.resolved_at IS '解决时间'; +COMMENT ON COLUMN ops_alert_events.notified_methods IS '已发送的通知方式'; +COMMENT ON COLUMN ops_alert_events.created_at IS '创建时间'; +COMMENT ON COLUMN ops_alert_events.updated_at IS '更新时间'; +COMMENT ON TABLE ops_alert_rules IS '告警规则'; +COMMENT ON COLUMN ops_alert_rules.id IS '主键ID'; +COMMENT ON COLUMN ops_alert_rules.name IS '规则名称'; +COMMENT ON COLUMN ops_alert_rules.metric_type IS '指标类型:api.error_rate / api.p95_latency / api.p99_latency / api.qps / system.cpu_percent / system.memory_percent / system.disk_percent / db.active_connections / redis.used_memory_mb'; +COMMENT ON COLUMN ops_alert_rules.condition IS '比较条件:gt / gte / lt / lte / eq'; +COMMENT ON COLUMN ops_alert_rules.threshold IS '阈值'; +COMMENT ON COLUMN ops_alert_rules.duration_seconds IS '持续时间(秒),0表示立即触发'; +COMMENT ON COLUMN ops_alert_rules.notification_methods IS '通知方式数组:email / webhook / in_app'; +COMMENT ON COLUMN ops_alert_rules.webhook_url IS 'Webhook回调地址'; +COMMENT ON COLUMN ops_alert_rules.level IS '告警级别:info / warning / critical'; +COMMENT ON COLUMN ops_alert_rules.is_enabled IS '是否启用'; +COMMENT ON COLUMN ops_alert_rules.cooldown_seconds IS '冷却时间(秒),同一规则两次告警最小间隔'; +COMMENT ON COLUMN ops_alert_rules.last_triggered_at IS '上次触发时间'; +COMMENT ON COLUMN ops_alert_rules.notify_user_ids IS '通知接收人管理员ID列表'; +COMMENT ON COLUMN ops_alert_rules.created_at IS '创建时间'; +COMMENT ON COLUMN ops_alert_rules.updated_at IS '更新时间'; +COMMENT ON TABLE sys_options IS '系统配置(键值对,支持动态更新)'; +COMMENT ON COLUMN sys_options.id IS '主键ID'; +COMMENT ON COLUMN sys_options.key IS '配置键(唯一标识,如 site_name、register_enabled)'; +COMMENT ON COLUMN sys_options.value IS '配置值'; +COMMENT ON COLUMN sys_options.description IS '配置说明'; +COMMENT ON COLUMN sys_options.category IS '配置分类(如 general、security、email、payment)'; +COMMENT ON COLUMN sys_options.is_public IS '是否公开(前端可直接获取,如站点名称、注册开关)'; +COMMENT ON COLUMN sys_options.created_at IS '创建时间'; +COMMENT ON COLUMN sys_options.updated_at IS '更新时间'; +COMMENT ON TABLE ord_orders IS '订单'; +COMMENT ON COLUMN ord_orders.id IS '主键ID'; +COMMENT ON COLUMN ord_orders.order_no IS '订单号(唯一,格式 ORD + 时间戳 + 随机数)'; +COMMENT ON COLUMN ord_orders.tenant_id IS '租户ID'; +COMMENT ON COLUMN ord_orders.user_id IS '下单用户ID'; +COMMENT ON COLUMN ord_orders.order_type IS '订单类型:new_plan(新购)/ renew(续费)/ upgrade(升级)/ downgrade(降级)/ recharge(充值)'; +COMMENT ON COLUMN ord_orders.plan_id IS '套餐ID(充值订单时为 NULL)'; +COMMENT ON COLUMN ord_orders.amount IS '原始金额'; +COMMENT ON COLUMN ord_orders.discount_amount IS '优惠金额'; +COMMENT ON COLUMN ord_orders.final_amount IS '最终金额'; +COMMENT ON COLUMN ord_orders.currency IS '货币'; +COMMENT ON COLUMN ord_orders.payment_channel IS '支付渠道'; +COMMENT ON COLUMN ord_orders.payment_method IS '支付方式描述'; +COMMENT ON COLUMN ord_orders.payment_no IS '第三方支付流水号'; +COMMENT ON COLUMN ord_orders.status IS '订单状态'; +COMMENT ON COLUMN ord_orders.paid_at IS '支付时间'; +COMMENT ON COLUMN ord_orders.fulfilled_at IS '履约完成时间'; +COMMENT ON COLUMN ord_orders.expired_at IS '过期时间(未支付 30 分钟后自动过期)'; +COMMENT ON COLUMN ord_orders.cancelled_at IS '取消时间'; +COMMENT ON COLUMN ord_orders.related_order_id IS '关联订单ID(退款时指向原始订单)'; +COMMENT ON COLUMN ord_orders.description IS '订单描述'; +COMMENT ON COLUMN ord_orders.created_at IS '创建时间'; +COMMENT ON COLUMN ord_orders.updated_at IS '更新时间'; +COMMENT ON TABLE ord_payment_channels IS '支付渠道配置'; +COMMENT ON COLUMN ord_payment_channels.id IS '主键ID'; +COMMENT ON COLUMN ord_payment_channels.channel IS '渠道标识(alipay/wechat/stripe/mock)'; +COMMENT ON COLUMN ord_payment_channels.name IS '显示名称'; +COMMENT ON COLUMN ord_payment_channels.config IS '渠道配置(JSONB,含 API 密钥等敏感信息)'; +COMMENT ON COLUMN ord_payment_channels.is_enabled IS '是否启用'; +COMMENT ON COLUMN ord_payment_channels.sort_order IS '排序权重'; +COMMENT ON COLUMN ord_payment_channels.created_at IS '创建时间'; +COMMENT ON COLUMN ord_payment_channels.updated_at IS '更新时间'; +COMMENT ON COLUMN ord_payment_channels.payment_type IS '子支付方式(alipay/wxpay 等,空表示该渠道支持所有方式)'; +COMMENT ON COLUMN ord_payment_channels.callback_url IS '支付回调地址覆盖(为空则使用系统默认)'; +COMMENT ON COLUMN ord_payment_channels.return_url IS '支付完成后前端跳转地址覆盖'; +COMMENT ON TABLE ord_refunds IS '退款记录'; +COMMENT ON COLUMN ord_refunds.id IS '主键ID'; +COMMENT ON COLUMN ord_refunds.order_id IS '关联订单ID'; +COMMENT ON COLUMN ord_refunds.tenant_id IS '租户ID'; +COMMENT ON COLUMN ord_refunds.amount IS '退款金额'; +COMMENT ON COLUMN ord_refunds.reason IS '退款原因'; +COMMENT ON COLUMN ord_refunds.status IS '退款状态'; +COMMENT ON COLUMN ord_refunds.payment_channel IS '原支付渠道'; +COMMENT ON COLUMN ord_refunds.payment_refund_id IS '第三方退款流水号'; +COMMENT ON COLUMN ord_refunds.approved_by IS '审批人(管理员ID)'; +COMMENT ON COLUMN ord_refunds.approved_at IS '审批时间'; +COMMENT ON COLUMN ord_refunds.created_at IS '创建时间'; +COMMENT ON COLUMN ord_refunds.updated_at IS '更新时间'; +COMMENT ON TABLE pln_feature_flags IS '功能开关配置'; +COMMENT ON COLUMN pln_feature_flags.id IS '主键ID'; +COMMENT ON COLUMN pln_feature_flags.feature_key IS '功能标识(如 api_docs, export_csv)'; +COMMENT ON COLUMN pln_feature_flags.description IS '功能描述'; +COMMENT ON COLUMN pln_feature_flags.default_enabled IS '默认是否启用'; +COMMENT ON COLUMN pln_feature_flags.enabled IS '当前是否启用(计算后的最终值)'; +COMMENT ON COLUMN pln_feature_flags.source IS '来源:plan(套餐)/ tenant(租户覆盖)/ manual(手动)'; +COMMENT ON COLUMN pln_feature_flags.source_id IS '来源ID(plan_id 或 tenant_id)'; +COMMENT ON COLUMN pln_feature_flags.tenant_id IS '关联租户ID(租户级覆盖时使用)'; +COMMENT ON COLUMN pln_feature_flags.plan_id IS '关联套餐ID(套餐级配置时使用)'; +COMMENT ON COLUMN pln_feature_flags.created_at IS '创建时间'; +COMMENT ON COLUMN pln_feature_flags.updated_at IS '更新时间'; +COMMENT ON TABLE pln_plans IS '套餐定义'; +COMMENT ON COLUMN pln_plans.id IS '主键ID'; +COMMENT ON COLUMN pln_plans.name IS '套餐显示名称'; +COMMENT ON COLUMN pln_plans.identifier IS '套餐唯一标识(free/basic/pro/enterprise)'; +COMMENT ON COLUMN pln_plans.description IS '套餐描述(面向用户的营销文案)'; +COMMENT ON COLUMN pln_plans.monthly_price IS '月度价格(CNY)'; +COMMENT ON COLUMN pln_plans.yearly_price IS '年度价格(CNY,通常为月价×10)'; +COMMENT ON COLUMN pln_plans.status IS '状态:active(上架)/ archived(下架)'; +COMMENT ON COLUMN pln_plans.monthly_quota_tokens IS '每月 Token 配额(0=不限)'; +COMMENT ON COLUMN pln_plans.allowed_models IS '允许使用的模型列表(NULL=全部,空数组=无)'; +COMMENT ON COLUMN pln_plans.is_recommended IS '是否推荐'; +COMMENT ON COLUMN pln_plans.sort_order IS '排序权重(数字越小越靠前)'; +COMMENT ON COLUMN pln_plans.created_at IS '创建时间'; +COMMENT ON COLUMN pln_plans.updated_at IS '更新时间'; +COMMENT ON TABLE pln_tenant_plans IS '租户套餐订阅'; +COMMENT ON COLUMN pln_tenant_plans.id IS '主键ID'; +COMMENT ON COLUMN pln_tenant_plans.tenant_id IS '租户ID'; +COMMENT ON COLUMN pln_tenant_plans.plan_id IS '套餐ID'; +COMMENT ON COLUMN pln_tenant_plans.status IS '状态:pending(待生效)/ active(生效中)/ expired(已过期)/ cancelled(已取消)'; +COMMENT ON COLUMN pln_tenant_plans.start_at IS '生效起始时间'; +COMMENT ON COLUMN pln_tenant_plans.end_at IS '到期时间'; +COMMENT ON COLUMN pln_tenant_plans.auto_renew IS '是否自动续费'; +COMMENT ON COLUMN pln_tenant_plans.monthly_quota_tokens IS '月度 Token 配额快照'; +COMMENT ON COLUMN pln_tenant_plans.used_tokens IS '本月已使用 Token'; +COMMENT ON COLUMN pln_tenant_plans.last_reset_at IS '上次配额重置时间'; +COMMENT ON COLUMN pln_tenant_plans.cancelled_at IS '取消时间'; +COMMENT ON COLUMN pln_tenant_plans.created_at IS '创建时间'; +COMMENT ON COLUMN pln_tenant_plans.updated_at IS '更新时间'; +COMMENT ON TABLE tnt_projects IS '项目(租户下的逻辑分组,用于组织 API Key 和统计用量)'; +COMMENT ON COLUMN tnt_projects.id IS '主键ID'; +COMMENT ON COLUMN tnt_projects.tenant_id IS '所属租户ID'; +COMMENT ON COLUMN tnt_projects.name IS '项目名称'; +COMMENT ON COLUMN tnt_projects.description IS '项目描述'; +COMMENT ON COLUMN tnt_projects.status IS '状态:active(活跃)/ archived(归档)/ budget_exhausted(预算耗尽)'; +COMMENT ON COLUMN tnt_projects.budget IS '项目预算上限(NUMERIC(20,10) 金额,NULL 表示不限制)'; +COMMENT ON COLUMN tnt_projects.created_by IS '创建者用户ID'; +COMMENT ON COLUMN tnt_projects.created_at IS '创建时间'; +COMMENT ON COLUMN tnt_projects.updated_at IS '更新时间'; +COMMENT ON TABLE sys_admin_data_scopes IS '管理员数据范围配置'; +COMMENT ON COLUMN sys_admin_data_scopes.id IS '主键ID'; +COMMENT ON COLUMN sys_admin_data_scopes.admin_user_id IS '关联的管理员用户ID'; +COMMENT ON COLUMN sys_admin_data_scopes.scope_type IS '范围类型:all(全部)/ tenant_group(租户组)/ tenant(指定租户)'; +COMMENT ON COLUMN sys_admin_data_scopes.scope_value IS '范围值(tenant_group时为组名,tenant时为租户ID列表,逗号分隔)'; +COMMENT ON COLUMN sys_admin_data_scopes.created_at IS '创建时间'; +COMMENT ON COLUMN sys_admin_data_scopes.updated_at IS '更新时间'; +COMMENT ON TABLE sys_admin_role_perms IS '管理员角色权限关联'; +COMMENT ON COLUMN sys_admin_role_perms.id IS '主键ID'; +COMMENT ON COLUMN sys_admin_role_perms.admin_user_id IS '关联的管理员用户ID'; +COMMENT ON COLUMN sys_admin_role_perms.permission_point IS '权限点标识(如 tenant:create、channel:edit)'; +COMMENT ON COLUMN sys_admin_role_perms.created_at IS '创建时间'; +COMMENT ON COLUMN sys_admin_role_perms.updated_at IS '更新时间'; +COMMENT ON TABLE sys_admin_users IS '管理后台用户'; +COMMENT ON COLUMN sys_admin_users.id IS '主键ID'; +COMMENT ON COLUMN sys_admin_users.username IS '登录用户名'; +COMMENT ON COLUMN sys_admin_users.password_hash IS '密码哈希(bcrypt)'; +COMMENT ON COLUMN sys_admin_users.email IS '邮箱地址(可选,可为NULL)'; +COMMENT ON COLUMN sys_admin_users.display_name IS '显示名称'; +COMMENT ON COLUMN sys_admin_users.role IS '角色:super_admin(全权限)/ admin(可配置权限)'; +COMMENT ON COLUMN sys_admin_users.status IS '状态:active(启用)/ disabled(禁用)'; +COMMENT ON COLUMN sys_admin_users.last_login_at IS '最后登录时间'; +COMMENT ON COLUMN sys_admin_users.last_login_ip IS '最后登录IP'; +COMMENT ON COLUMN sys_admin_users.created_at IS '创建时间'; +COMMENT ON COLUMN sys_admin_users.updated_at IS '更新时间'; +COMMENT ON COLUMN sys_admin_users.totp_secret IS 'TOTP 密钥(AES-256 加密存储)'; +COMMENT ON COLUMN sys_admin_users.totp_enabled IS '是否启用双因素认证'; +COMMENT ON COLUMN sys_admin_users.backup_codes IS '备用恢复码(bcrypt 哈希存储)'; +COMMENT ON TABLE sys_email_verify_codes IS '邮箱验证码'; +COMMENT ON COLUMN sys_email_verify_codes.id IS '主键ID'; +COMMENT ON COLUMN sys_email_verify_codes.email IS '目标邮箱地址'; +COMMENT ON COLUMN sys_email_verify_codes.code IS '验证码(6位数字)'; +COMMENT ON COLUMN sys_email_verify_codes.purpose IS '用途:register(注册)/ reset_password(重置密码)/ change_email(更换邮箱)'; +COMMENT ON COLUMN sys_email_verify_codes.expires_at IS '过期时间(10分钟有效)'; +COMMENT ON COLUMN sys_email_verify_codes.used_at IS '使用时间(NULL表示未使用)'; +COMMENT ON COLUMN sys_email_verify_codes.created_at IS '创建时间'; +COMMENT ON COLUMN sys_email_verify_codes.updated_at IS '更新时间'; +COMMENT ON TABLE sys_idempotency_records IS '幂等记录(防止重复提交)'; +COMMENT ON COLUMN sys_idempotency_records.id IS '主键ID'; +COMMENT ON COLUMN sys_idempotency_records.idempotency_key IS '幂等键(来自请求头 Idempotency-Key)'; +COMMENT ON COLUMN sys_idempotency_records.request_hash IS '请求体哈希(SHA-256,用于校验请求一致性)'; +COMMENT ON COLUMN sys_idempotency_records.response_body IS '首次处理的响应体(幂等返回时复用)'; +COMMENT ON COLUMN sys_idempotency_records.status IS '状态:processing(处理中)/ completed(已完成)/ failed(失败)'; +COMMENT ON COLUMN sys_idempotency_records.expires_at IS '过期时间(过期后记录可清理)'; +COMMENT ON COLUMN sys_idempotency_records.created_at IS '创建时间'; +COMMENT ON COLUMN sys_idempotency_records.updated_at IS '更新时间'; +COMMENT ON TABLE sys_sessions IS '用户会话(JWT Refresh Token 存储)'; +COMMENT ON COLUMN sys_sessions.id IS '主键ID'; +COMMENT ON COLUMN sys_sessions.user_type IS '用户类型:admin(管理后台)/ tenant(租户控制台)'; +COMMENT ON COLUMN sys_sessions.user_id IS '用户ID'; +COMMENT ON COLUMN sys_sessions.tenant_id IS '租户ID(admin类型时为NULL)'; +COMMENT ON COLUMN sys_sessions.refresh_token_hash IS 'Refresh Token 哈希值'; +COMMENT ON COLUMN sys_sessions.device_info IS '设备信息(JSONB:浏览器、操作系统等)'; +COMMENT ON COLUMN sys_sessions.ip_address IS '登录IP地址'; +COMMENT ON COLUMN sys_sessions.expires_at IS 'Token 过期时间'; +COMMENT ON COLUMN sys_sessions.created_at IS '创建时间'; +COMMENT ON COLUMN sys_sessions.updated_at IS '更新时间'; +COMMENT ON TABLE spt_attachments IS '工单附件'; +COMMENT ON COLUMN spt_attachments.id IS '主键ID'; +COMMENT ON COLUMN spt_attachments.ticket_id IS '工单ID'; +COMMENT ON COLUMN spt_attachments.reply_id IS '回复ID(NULL表示工单创建时的附件)'; +COMMENT ON COLUMN spt_attachments.file_name IS '文件名'; +COMMENT ON COLUMN spt_attachments.file_url IS '文件访问地址'; +COMMENT ON COLUMN spt_attachments.file_size IS '文件大小(字节)'; +COMMENT ON COLUMN spt_attachments.content_type IS '文件MIME类型'; +COMMENT ON COLUMN spt_attachments.created_at IS '上传时间'; +COMMENT ON TABLE spt_replies IS '工单回复'; +COMMENT ON COLUMN spt_replies.id IS '主键ID'; +COMMENT ON COLUMN spt_replies.ticket_id IS '工单ID'; +COMMENT ON COLUMN spt_replies.user_id IS '回复者用户ID'; +COMMENT ON COLUMN spt_replies.user_type IS '回复者类型:admin(管理员)/ tenant(租户用户)'; +COMMENT ON COLUMN spt_replies.content IS '回复内容'; +COMMENT ON COLUMN spt_replies.created_at IS '回复时间'; +COMMENT ON TABLE spt_tickets IS '工单'; +COMMENT ON COLUMN spt_tickets.id IS '主键ID'; +COMMENT ON COLUMN spt_tickets.tenant_id IS '所属租户ID'; +COMMENT ON COLUMN spt_tickets.user_id IS '创建者用户ID'; +COMMENT ON COLUMN spt_tickets.category IS '分类:billing(计费)/ technical(技术)/ feature_request(功能建议)/ other(其他)'; +COMMENT ON COLUMN spt_tickets.title IS '工单标题'; +COMMENT ON COLUMN spt_tickets.description IS '工单描述'; +COMMENT ON COLUMN spt_tickets.urgency IS '紧急程度:low(低)/ normal(普通)/ high(高)/ urgent(紧急)'; +COMMENT ON COLUMN spt_tickets.status IS '状态:pending(待处理)/ processing(处理中)/ replied(已回复)/ closed(已关闭)/ reopened(已重开)'; +COMMENT ON COLUMN spt_tickets.assigned_admin_id IS '处理人管理员ID'; +COMMENT ON COLUMN spt_tickets.created_at IS '创建时间'; +COMMENT ON COLUMN spt_tickets.updated_at IS '更新时间'; +COMMENT ON TABLE tnt_invitations IS '租户邀请链接'; +COMMENT ON COLUMN tnt_invitations.id IS '主键ID'; +COMMENT ON COLUMN tnt_invitations.tenant_id IS '所属租户ID'; +COMMENT ON COLUMN tnt_invitations.code IS '邀请码(唯一标识)'; +COMMENT ON COLUMN tnt_invitations.invited_email IS '被邀请人邮箱(可选,指定后仅该邮箱可使用)'; +COMMENT ON COLUMN tnt_invitations.role IS '邀请后分配的角色:owner / admin / member'; +COMMENT ON COLUMN tnt_invitations.expires_at IS '过期时间:7天 / 30天 / 永久(NULL)'; +COMMENT ON COLUMN tnt_invitations.used_by_user_id IS '使用该邀请注册的用户ID(NULL表示未使用)'; +COMMENT ON COLUMN tnt_invitations.used_at IS '使用时间'; +COMMENT ON COLUMN tnt_invitations.created_by IS '创建者用户ID'; +COMMENT ON COLUMN tnt_invitations.created_at IS '创建时间'; +COMMENT ON COLUMN tnt_invitations.updated_at IS '更新时间'; +COMMENT ON TABLE tnt_member_imports IS '成员批量导入记录'; +COMMENT ON COLUMN tnt_member_imports.id IS '主键ID'; +COMMENT ON COLUMN tnt_member_imports.tenant_id IS '所属租户ID'; +COMMENT ON COLUMN tnt_member_imports.filename IS '上传文件名'; +COMMENT ON COLUMN tnt_member_imports.total_count IS '总行数'; +COMMENT ON COLUMN tnt_member_imports.success_count IS '成功数'; +COMMENT ON COLUMN tnt_member_imports.fail_count IS '失败数'; +COMMENT ON COLUMN tnt_member_imports.skip_count IS '跳过数(重复)'; +COMMENT ON COLUMN tnt_member_imports.status IS '状态:pending/processing/completed/failed'; +COMMENT ON COLUMN tnt_member_imports.error_message IS '整体错误信息'; +COMMENT ON COLUMN tnt_member_imports.result_json IS '逐行结果 [{row,username,status,error}]'; +COMMENT ON COLUMN tnt_member_imports.created_by IS '创建者用户ID'; +COMMENT ON COLUMN tnt_member_imports.created_at IS '创建时间'; +COMMENT ON COLUMN tnt_member_imports.updated_at IS '更新时间'; +COMMENT ON TABLE tnt_member_model_scopes IS '成员模型分配映射'; +COMMENT ON COLUMN tnt_member_model_scopes.id IS '主键ID'; +COMMENT ON COLUMN tnt_member_model_scopes.tenant_id IS '所属租户ID'; +COMMENT ON COLUMN tnt_member_model_scopes.user_id IS '成员用户ID'; +COMMENT ON COLUMN tnt_member_model_scopes.model_id IS '模型ID'; +COMMENT ON COLUMN tnt_member_model_scopes.created_at IS '创建时间'; +COMMENT ON TABLE tnt_oauth_identities IS '租户用户 OAuth 身份绑定'; +COMMENT ON COLUMN tnt_oauth_identities.id IS '主键ID'; +COMMENT ON COLUMN tnt_oauth_identities.tenant_id IS '所属租户ID'; +COMMENT ON COLUMN tnt_oauth_identities.user_id IS '关联的用户ID'; +COMMENT ON COLUMN tnt_oauth_identities.provider IS 'OAuth 供应商:github / google'; +COMMENT ON COLUMN tnt_oauth_identities.provider_user_id IS '供应商用户ID'; +COMMENT ON COLUMN tnt_oauth_identities.provider_username IS '供应商用户名'; +COMMENT ON COLUMN tnt_oauth_identities.email IS '供应商返回的邮箱'; +COMMENT ON COLUMN tnt_oauth_identities.avatar_url IS '供应商返回的头像URL'; +COMMENT ON COLUMN tnt_oauth_identities.access_token IS '加密存储的 access_token'; +COMMENT ON COLUMN tnt_oauth_identities.refresh_token IS '加密存储的 refresh_token'; +COMMENT ON COLUMN tnt_oauth_identities.token_expires_at IS 'Token 过期时间'; +COMMENT ON COLUMN tnt_oauth_identities.raw_data IS '供应商原始返回数据'; +COMMENT ON TABLE tnt_tenants IS '租户/组织'; +COMMENT ON COLUMN tnt_tenants.id IS '主键ID'; +COMMENT ON COLUMN tnt_tenants.name IS '租户显示名称(如公司名)'; +COMMENT ON COLUMN tnt_tenants.code IS '租户代码(唯一标识,用于 RAM 账号格式 username@tenant_code)'; +COMMENT ON COLUMN tnt_tenants.logo_url IS '租户 Logo URL'; +COMMENT ON COLUMN tnt_tenants.owner_user_id IS '所有者用户ID(关联 tnt_users.id)'; +COMMENT ON COLUMN tnt_tenants.status IS '状态:trial(试用)/ active(活跃)/ past_due(逾期)/ frozen(冻结)/ terminated(已终止)/ free(免费版)/ suspended(暂停)/ closed(关闭)'; +COMMENT ON COLUMN tnt_tenants.max_members IS '最大成员数上限'; +COMMENT ON COLUMN tnt_tenants.settings IS '租户配置(JSONB):通知偏好、安全策略、IP 白名单等'; +COMMENT ON COLUMN tnt_tenants.created_at IS '创建时间'; +COMMENT ON COLUMN tnt_tenants.updated_at IS '更新时间'; +COMMENT ON COLUMN tnt_tenants.trial_ends_at IS '试用期结束时间'; +COMMENT ON COLUMN tnt_tenants.grace_period_ends_at IS '宽限期结束时间(套餐到期后 7 天)'; +COMMENT ON COLUMN tnt_tenants.frozen_at IS '冻结时间'; +COMMENT ON COLUMN tnt_tenants.closing_requested_at IS '主动申请注销时间(7 天冷静期)'; +COMMENT ON COLUMN tnt_tenants.data_removal_at IS '数据清除时间(冻结 30 天后)'; +COMMENT ON COLUMN tnt_tenants.max_concurrency IS '租户总并发上限(0表示不限制)'; +COMMENT ON COLUMN tnt_tenants.default_channel_scope IS '默认渠道范围(NULL或[]表示全部可用,否则为channel_id数组)'; +COMMENT ON TABLE tnt_users IS '租户用户(RAM 账号,格式 username@tenant_code)'; +COMMENT ON COLUMN tnt_users.id IS '主键ID'; +COMMENT ON COLUMN tnt_users.tenant_id IS '所属租户ID'; +COMMENT ON COLUMN tnt_users.username IS '用户名(租户内唯一)'; +COMMENT ON COLUMN tnt_users.email IS '邮箱地址(租户内唯一)'; +COMMENT ON COLUMN tnt_users.password_hash IS '密码哈希(bcrypt)'; +COMMENT ON COLUMN tnt_users.display_name IS '显示名称'; +COMMENT ON COLUMN tnt_users.role IS '角色:owner(所有者)/ admin(管理员)/ member(成员)'; +COMMENT ON COLUMN tnt_users.status IS '状态:active(正常)/ disabled(禁用)/ locked(锁定)'; +COMMENT ON COLUMN tnt_users.last_login_at IS '最后登录时间'; +COMMENT ON COLUMN tnt_users.last_login_ip IS '最后登录IP'; +COMMENT ON COLUMN tnt_users.failed_attempts IS '连续登录失败次数(成功登录后归零)'; +COMMENT ON COLUMN tnt_users.locked_until IS '锁定截止时间(连续5次失败后锁定30分钟)'; +COMMENT ON COLUMN tnt_users.created_at IS '创建时间'; +COMMENT ON COLUMN tnt_users.updated_at IS '更新时间'; +COMMENT ON COLUMN tnt_users.totp_secret IS 'TOTP 密钥(AES-256 加密存储)'; +COMMENT ON COLUMN tnt_users.totp_enabled IS '是否启用双因素认证'; +COMMENT ON COLUMN tnt_users.backup_codes IS '备用恢复码(bcrypt 哈希存储)'; +COMMENT ON TABLE tsk_task_logs IS '任务执行日志'; +COMMENT ON COLUMN tsk_task_logs.id IS '主键ID'; +COMMENT ON COLUMN tsk_task_logs.task_id IS '关联任务ID'; +COMMENT ON COLUMN tsk_task_logs.level IS '日志级别:info(信息)/ warn(警告)/ error(错误)'; +COMMENT ON COLUMN tsk_task_logs.message IS '日志内容'; +COMMENT ON COLUMN tsk_task_logs.created_at IS '创建时间'; +COMMENT ON COLUMN tsk_task_logs.updated_at IS '更新时间'; +COMMENT ON TABLE tsk_tasks IS '异步任务(用于邮件发送、数据导出、定时对账等后台任务)'; +COMMENT ON COLUMN tsk_tasks.id IS '主键ID'; +COMMENT ON COLUMN tsk_tasks.name IS '任务名称(如 "发送邮件"、"日对账"、"数据导出")'; +COMMENT ON COLUMN tsk_tasks.handler IS 'Handler 函数路径(用于任务路由)'; +COMMENT ON COLUMN tsk_tasks.status IS '状态:pending(待执行)/ running(执行中)/ succeeded(成功)/ failed(失败)/ cancelled(已取消)'; +COMMENT ON COLUMN tsk_tasks.payload IS '任务输入参数(JSONB)'; +COMMENT ON COLUMN tsk_tasks.result IS '任务执行结果(JSONB)'; +COMMENT ON COLUMN tsk_tasks.max_retries IS '最大重试次数'; +COMMENT ON COLUMN tsk_tasks.retry_count IS '已重试次数'; +COMMENT ON COLUMN tsk_tasks.started_at IS '开始执行时间'; +COMMENT ON COLUMN tsk_tasks.finished_at IS '执行完成时间'; +COMMENT ON COLUMN tsk_tasks.scheduled_at IS '计划执行时间(用于定时任务)'; +COMMENT ON COLUMN tsk_tasks.error_message IS '失败时的错误信息'; +COMMENT ON COLUMN tsk_tasks.created_at IS '创建时间'; +COMMENT ON COLUMN tsk_tasks.updated_at IS '更新时间'; +COMMENT ON TABLE opn_webhook_configs IS 'Webhook 配置'; +COMMENT ON COLUMN opn_webhook_configs.id IS '主键ID'; +COMMENT ON COLUMN opn_webhook_configs.tenant_id IS '所属租户ID'; +COMMENT ON COLUMN opn_webhook_configs.name IS '配置名称'; +COMMENT ON COLUMN opn_webhook_configs.url IS '回调地址(必须 HTTPS)'; +COMMENT ON COLUMN opn_webhook_configs.secret_key IS 'HMAC-SHA256 签名密钥'; +COMMENT ON COLUMN opn_webhook_configs.events IS '订阅的事件类型列表'; +COMMENT ON COLUMN opn_webhook_configs.is_active IS '是否启用'; +COMMENT ON COLUMN opn_webhook_configs.retry_policy IS '重试策略(JSON)'; +COMMENT ON COLUMN opn_webhook_configs.consecutive_failures IS '连续失败次数'; +COMMENT ON COLUMN opn_webhook_configs.max_consecutive_failures IS '最大连续失败次数(超过后自动禁用)'; +COMMENT ON COLUMN opn_webhook_configs.last_delivery_at IS '最后投递时间'; +COMMENT ON COLUMN opn_webhook_configs.created_at IS '创建时间'; +COMMENT ON COLUMN opn_webhook_configs.updated_at IS '更新时间'; +COMMENT ON TABLE opn_webhook_delivery_logs IS 'Webhook 投递日志'; +COMMENT ON COLUMN opn_webhook_delivery_logs.id IS '主键ID'; +COMMENT ON COLUMN opn_webhook_delivery_logs.tenant_id IS '所属租户ID'; +COMMENT ON COLUMN opn_webhook_delivery_logs.webhook_config_id IS 'Webhook 配置ID'; +COMMENT ON COLUMN opn_webhook_delivery_logs.event_id IS '关联的事件ID'; +COMMENT ON COLUMN opn_webhook_delivery_logs.attempt IS '第几次尝试'; +COMMENT ON COLUMN opn_webhook_delivery_logs.request_url IS '请求 URL'; +COMMENT ON COLUMN opn_webhook_delivery_logs.request_headers IS '请求头(JSON)'; +COMMENT ON COLUMN opn_webhook_delivery_logs.response_status IS 'HTTP 响应状态码'; +COMMENT ON COLUMN opn_webhook_delivery_logs.response_body IS '响应体(截断到 2000 字符)'; +COMMENT ON COLUMN opn_webhook_delivery_logs.response_time_ms IS '响应时间(毫秒)'; +COMMENT ON COLUMN opn_webhook_delivery_logs.error_message IS '错误信息'; +COMMENT ON COLUMN opn_webhook_delivery_logs.created_at IS '投递时间'; +COMMENT ON TABLE opn_webhook_events IS 'Webhook 事件'; +COMMENT ON COLUMN opn_webhook_events.id IS '主键ID'; +COMMENT ON COLUMN opn_webhook_events.tenant_id IS '所属租户ID'; +COMMENT ON COLUMN opn_webhook_events.webhook_config_id IS '关联的 Webhook 配置ID'; +COMMENT ON COLUMN opn_webhook_events.event_id IS '事件唯一标识'; +COMMENT ON COLUMN opn_webhook_events.event_type IS '事件类型'; +COMMENT ON COLUMN opn_webhook_events.payload IS '事件载荷(JSON)'; +COMMENT ON COLUMN opn_webhook_events.status IS '状态:pending / delivered / failed'; +COMMENT ON COLUMN opn_webhook_events.attempts IS '已尝试次数'; +COMMENT ON COLUMN opn_webhook_events.next_retry_at IS '下次重试时间'; +COMMENT ON COLUMN opn_webhook_events.created_at IS '创建时间'; +COMMENT ON COLUMN opn_webhook_events.updated_at IS '更新时间'; +COMMENT ON TABLE bil_usage_logs IS 'API 调用日志(按月分区,高吞吐追加写表)'; +COMMENT ON COLUMN bil_usage_logs.id IS '主键ID'; +COMMENT ON COLUMN bil_usage_logs.tenant_id IS '租户ID'; +COMMENT ON COLUMN bil_usage_logs.user_id IS '用户ID'; +COMMENT ON COLUMN bil_usage_logs.api_key_id IS '使用的 API Key ID'; +COMMENT ON COLUMN bil_usage_logs.channel_id IS '使用的渠道ID'; +COMMENT ON COLUMN bil_usage_logs.model_name IS '调用的模型名'; +COMMENT ON COLUMN bil_usage_logs.request_id IS '请求唯一ID'; +COMMENT ON COLUMN bil_usage_logs.relay_mode IS '代理模式:chat_completions / embeddings / images_generations 等'; +COMMENT ON COLUMN bil_usage_logs.input_tokens IS '输入 token 数'; +COMMENT ON COLUMN bil_usage_logs.output_tokens IS '输出 token 数'; +COMMENT ON COLUMN bil_usage_logs.total_cost IS '本次调用费用'; +COMMENT ON COLUMN bil_usage_logs.currency IS '货币(USD)'; +COMMENT ON COLUMN bil_usage_logs.latency_ms IS '请求延迟(毫秒)'; +COMMENT ON COLUMN bil_usage_logs.status IS '状态:success(成功)/ error(错误)/ timeout(超时)/ cancelled(取消)'; +COMMENT ON COLUMN bil_usage_logs.error_message IS '错误信息(成功时为 NULL)'; +COMMENT ON COLUMN bil_usage_logs.client_ip IS '客户端 IP'; +COMMENT ON COLUMN bil_usage_logs.created_at IS '创建时间'; +COMMENT ON COLUMN bil_usage_logs.updated_at IS '更新时间'; +COMMENT ON COLUMN bil_usage_logs.cache_creation_tokens IS '缓存创建 token 数 (Claude)'; +COMMENT ON COLUMN bil_usage_logs.cache_read_tokens IS '缓存读取 token 数 (Claude/OpenAI)'; +COMMENT ON COLUMN bil_usage_logs.input_cost IS '输入 token 费用'; +COMMENT ON COLUMN bil_usage_logs.output_cost IS '输出 token 费用'; +COMMENT ON COLUMN bil_usage_logs.cache_creation_cost IS '缓存创建费用'; +COMMENT ON COLUMN bil_usage_logs.cache_read_cost IS '缓存读取费用'; +COMMENT ON COLUMN bil_usage_logs.actual_cost IS '实际扣除费用(含折扣后)'; +COMMENT ON COLUMN bil_usage_logs.requested_model IS '用户请求的模型名'; +COMMENT ON COLUMN bil_usage_logs.upstream_model IS '上游实际模型名(模型映射后)'; +COMMENT ON COLUMN bil_usage_logs.request_type IS '请求类型: 1=sync, 2=stream, 3=websocket'; +COMMENT ON COLUMN bil_usage_logs.user_agent IS '客户端 User-Agent'; +COMMENT ON COLUMN bil_usage_logs.first_token_ms IS '首 token 延迟(毫秒)'; +COMMENT ON COLUMN bil_usage_logs.service_tier IS '服务等级 (default/flex等)'; +COMMENT ON COLUMN bil_usage_logs.reasoning_effort IS '推理力度 (low/medium/high)'; +COMMENT ON COLUMN bil_usage_logs.channel_name IS '渠道名称'; +COMMENT ON COLUMN bil_usage_logs.channel_type IS '渠道类型 (ProviderType)'; +COMMENT ON COLUMN bil_usage_logs.billing_mode IS '计费模式 (token/per_request/tiered)'; +COMMENT ON COLUMN bil_usage_logs.billing_source IS '定价来源 (base/tenant/custom)'; +COMMENT ON COLUMN bil_usage_logs.rate_multiplier IS '费率倍率/折扣'; +COMMENT ON COLUMN bil_usage_logs.pre_deduct_amount IS '预扣金额'; +COMMENT ON COLUMN bil_usage_logs.refund_amount IS '退款金额'; +COMMENT ON COLUMN bil_usage_logs.supplement_amount IS '补扣金额'; +COMMENT ON COLUMN bil_usage_logs.image_count IS '生成图片数量'; +COMMENT ON COLUMN bil_usage_logs.image_size IS '图片尺寸'; +COMMENT ON COLUMN bil_usage_logs.stream_end_reason IS '流结束原因 (done/timeout/client_gone/error/panic)'; +COMMENT ON COLUMN bil_usage_logs.retry_index IS '重试次数(0=首次成功)'; +COMMENT ON COLUMN bil_usage_logs.billing_summary IS '计费快照文本(人类可读的计费过程描述)'; +COMMENT ON COLUMN bil_usage_logs.cache_creation_5m_tokens IS 'Claude 5分钟缓存创建 token 数'; +COMMENT ON COLUMN bil_usage_logs.cache_creation_1h_tokens IS 'Claude 1小时缓存创建 token 数'; +COMMENT ON COLUMN bil_usage_logs.audio_input_tokens IS '音频输入 token 数'; +COMMENT ON COLUMN bil_usage_logs.audio_output_tokens IS '音频输出 token 数'; +COMMENT ON COLUMN bil_usage_logs.image_output_tokens IS '图像输出 token 数(DALL-E 等)'; +COMMENT ON COLUMN bil_usage_logs.reasoning_tokens IS '推理 token 数(O1/o3 等)'; +COMMENT ON COLUMN bil_usage_logs.account_cost IS '上游账户成本(用于利润分析)'; +COMMENT ON COLUMN bil_usage_logs.inbound_endpoint IS '客户端请求路径(如 /v1/chat/completions)'; +COMMENT ON COLUMN bil_usage_logs.upstream_endpoint IS '上游实际请求路径'; +COMMENT ON COLUMN bil_usage_logs.billing_snapshot IS '完整计费计算过程快照(JSONB)'; +COMMENT ON TABLE ops_system_metrics IS '系统指标时序数据(按月分区)'; +COMMENT ON COLUMN ops_system_metrics.id IS '主键ID'; +COMMENT ON COLUMN ops_system_metrics.metric_type IS '指标类型:cpu/memory/disk/network/runtime/db_pool/redis_pool'; +COMMENT ON COLUMN ops_system_metrics.metric_data IS '指标数据(JSONB,结构因类型而异)'; +COMMENT ON COLUMN ops_system_metrics.collected_at IS '采集时间'; + +COMMENT ON COLUMN aud_request_logs.project_id IS '关联项目ID(通过API Key关联,NULL表示个人密钥无项目)'; +COMMENT ON COLUMN aud_request_logs.first_token_ms IS '首个 Token 出现的用时(毫秒),仅流式请求有值'; +COMMENT ON COLUMN aud_request_logs.request_headers IS '请求头信息(仅审计级别为 all 时记录,管理后台调试用)'; +COMMENT ON COLUMN aud_request_logs.response_headers IS '响应头信息(仅审计级别为 all 时记录,管理后台调试用)'; +COMMENT ON COLUMN aud_request_logs.forwarding_trace IS '请求转发路径追踪(仅管理员可见)'; +COMMENT ON COLUMN bil_usage_logs.project_id IS '关联项目ID(通过API Key关联,NULL表示个人密钥无项目)'; +COMMENT ON COLUMN tnt_users.quota_type IS '额度限制类型:none(不限)/ total(总额)/ periodic(周期性)'; +COMMENT ON COLUMN tnt_users.quota_limit IS '额度上限(USD),quota_type 为 none 时忽略'; +COMMENT ON COLUMN tnt_users.quota_used IS '已使用额度(USD)'; +COMMENT ON COLUMN tnt_users.quota_period IS '周期类型:day / week / month(仅 periodic 时有效)'; +COMMENT ON COLUMN tnt_users.quota_reset_at IS '上次额度重置时间(懒重置用)'; +COMMENT ON COLUMN tnt_invitations.max_uses IS '最大使用次数,0表示不限'; +COMMENT ON COLUMN tnt_invitations.use_count IS '已使用次数'; +COMMENT ON COLUMN ntf_messages.target_roles IS '目标角色(NULL=全部角色,逗号分隔如 owner,admin 表示仅限这些角色)'; +COMMENT ON COLUMN chn_channel_keys.key_type IS 'Key 类型:apikey(传统静态密钥)/ oauth(OAuth 令牌)'; +COMMENT ON COLUMN chn_channel_keys.token_expires_at IS 'OAuth access_token 过期时间(仅 key_type=oauth 时有值)'; + +COMMENT ON TABLE ord_redemption_usages IS '兑换码使用记录'; +COMMENT ON COLUMN ord_redemption_usages.id IS '主键ID'; +COMMENT ON COLUMN ord_redemption_usages.redemption_id IS '关联兑换码ID'; +COMMENT ON COLUMN ord_redemption_usages.tenant_id IS '使用兑换码的租户ID'; +COMMENT ON COLUMN ord_redemption_usages.user_id IS '执行兑换操作的用户ID'; +COMMENT ON COLUMN ord_redemption_usages.type IS '兑换类型:quota / plan / duration'; +COMMENT ON COLUMN ord_redemption_usages.value IS '兑换面值(quota类型为金额,plan/duration为0)'; +COMMENT ON COLUMN ord_redemption_usages.transaction_id IS '关联的交易流水ID(仅quota类型有值)'; +COMMENT ON COLUMN ord_redemption_usages.created_at IS '创建时间'; +COMMENT ON COLUMN ord_redemption_usages.updated_at IS '更新时间'; +COMMENT ON TABLE sys_error_logs IS '系统错误日志'; +COMMENT ON COLUMN sys_error_logs.id IS '主键'; +COMMENT ON COLUMN sys_error_logs.request_id IS '请求ID,用于链路追踪'; +COMMENT ON COLUMN sys_error_logs.error_code IS '错误码(HTTP状态码或GoFrame错误码)'; +COMMENT ON COLUMN sys_error_logs.error_message IS '错误消息'; +COMMENT ON COLUMN sys_error_logs.stack_trace IS '错误堆栈'; +COMMENT ON COLUMN sys_error_logs.http_method IS 'HTTP请求方法'; +COMMENT ON COLUMN sys_error_logs.request_path IS '请求路径'; +COMMENT ON COLUMN sys_error_logs.request_body IS '请求体摘要(截断)'; +COMMENT ON COLUMN sys_error_logs.source IS '错误来源:api/panic/cron/background'; +COMMENT ON COLUMN sys_error_logs.resolved IS '是否已处理'; +COMMENT ON COLUMN sys_error_logs.resolved_by IS '处理人ID'; +COMMENT ON COLUMN sys_error_logs.resolved_at IS '处理时间'; +COMMENT ON COLUMN sys_error_logs.created_at IS '创建时间'; +COMMENT ON TABLE sys_cron_job_executions IS '定时任务执行记录'; +COMMENT ON COLUMN sys_cron_job_executions.id IS '主键ID'; +COMMENT ON COLUMN sys_cron_job_executions.job_name IS '任务名称(代码中定义的唯一标识)'; +COMMENT ON COLUMN sys_cron_job_executions.status IS '执行状态:succeeded/failed'; +COMMENT ON COLUMN sys_cron_job_executions.started_at IS '开始执行时间'; +COMMENT ON COLUMN sys_cron_job_executions.finished_at IS '执行完成时间'; +COMMENT ON COLUMN sys_cron_job_executions.duration_ms IS '执行耗时(毫秒)'; +COMMENT ON COLUMN sys_cron_job_executions.error_message IS '错误消息(仅失败时有值)'; +COMMENT ON COLUMN sys_cron_job_executions.triggered_by IS '触发方式:auto(自动调度)/ manual(手动触发)'; +COMMENT ON COLUMN sys_cron_job_executions.created_at IS '创建时间'; +COMMENT ON COLUMN sys_cron_job_executions.updated_at IS '更新时间'; + +-- ============================================================ +-- Seed Data +-- ============================================================ + +INSERT INTO ntf_templates (code, channel, subject, body_template, variables, status) +VALUES ( + 'balance_warning', + 'in_app', + '余额预警通知', + '

您的账户可用余额为 ${available} USD,已低于预警线 ${threshold} USD。

请及时充值以避免服务中断。

', + '["available", "threshold"]'::jsonb, + 'active' +); + + +-- ============================================================ +-- Plugin System +-- ============================================================ + +CREATE TABLE sys_plugins ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(64) NOT NULL, + label VARCHAR(128) NOT NULL, + version VARCHAR(32) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'registered', + category VARCHAR(32) NOT NULL DEFAULT 'extension', + config JSONB DEFAULT '{}'::jsonb, + error_msg TEXT DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uk_sys_plugins_name UNIQUE (name) +); + +COMMENT ON TABLE sys_plugins IS '系统插件注册表'; +COMMENT ON COLUMN sys_plugins.name IS '插件唯一标识,如 email-report'; +COMMENT ON COLUMN sys_plugins.label IS '显示名称'; +COMMENT ON COLUMN sys_plugins.version IS '当前安装版本'; +COMMENT ON COLUMN sys_plugins.status IS '状态:registered=已注册, installed=已安装, enabled=已启用, disabled=已禁用, error=异常'; +COMMENT ON COLUMN sys_plugins.category IS '分类:relay=代理扩展, middleware=中间件, billing=计费, notification=通知, extension=通用扩展'; +COMMENT ON COLUMN sys_plugins.config IS '插件全局配置(JSON)'; +COMMENT ON COLUMN sys_plugins.error_msg IS '异常信息'; + +CREATE TABLE tnt_tenant_plugins ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + plugin_name VARCHAR(64) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + config JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uk_tnt_tenant_plugins UNIQUE (tenant_id, plugin_name) +); + +COMMENT ON TABLE tnt_tenant_plugins IS '租户级插件启用/配置'; +COMMENT ON COLUMN tnt_tenant_plugins.plugin_name IS '插件标识'; +COMMENT ON COLUMN tnt_tenant_plugins.enabled IS '是否启用'; +COMMENT ON COLUMN tnt_tenant_plugins.config IS '租户级配置覆盖(JSON),优先级高于全局配置'; + +-- +goose Down +DROP SCHEMA public CASCADE; +CREATE SCHEMA public; diff --git a/plugins/example/hooks/relay_hooks.go b/plugins/example/hooks/relay_hooks.go new file mode 100644 index 0000000..a122391 --- /dev/null +++ b/plugins/example/hooks/relay_hooks.go @@ -0,0 +1,22 @@ +package hooks + +import ( + "context" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/glog" + + "github.com/qianfree/team-api/internal/plugin" +) + +// RelayBeforeRequest 记录 relay 请求日志。 +func RelayBeforeRequest(ctx context.Context, payload plugin.HookPayload) (plugin.HookResult, error) { + glog.Infof(ctx, "[example plugin] relay before request: model=%v", payload.Data["model"]) + return plugin.HookResult{Data: g.Map{}}, nil +} + +// RelayAfterResponse 记录 relay 响应日志。 +func RelayAfterResponse(ctx context.Context, payload plugin.HookPayload) (plugin.HookResult, error) { + glog.Info(ctx, "[example plugin] relay after response") + return plugin.HookResult{Data: g.Map{}}, nil +} diff --git a/plugins/example/plugin.go b/plugins/example/plugin.go new file mode 100644 index 0000000..68f7d62 --- /dev/null +++ b/plugins/example/plugin.go @@ -0,0 +1,161 @@ +package example + +import ( + "context" + "fmt" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/os/glog" + + "github.com/qianfree/team-api/internal/plugin" + "github.com/qianfree/team-api/plugins/example/hooks" +) + +type examplePlugin struct{} + +func init() { + plugin.Register(&examplePlugin{}) +} + +// Info 实现 Plugin 基础接口 +func (p *examplePlugin) Info() plugin.PluginInfo { + return plugin.PluginInfo{ + Name: "example", + Label: "功能示例", + Description: "插件系统功能示例,展示所有扩展接口的用法", + Author: "team-api", + Version: "1.0.0", + Category: "extension", + } +} + +// Init 实现 Plugin 基础接口 +func (p *examplePlugin) Init(ctx context.Context, app *plugin.App) error { + glog.Info(ctx, "example plugin initialized") + return nil +} + +// Destroy 实现 Plugin 基础接口 +func (p *examplePlugin) Destroy(ctx context.Context) error { + glog.Info(ctx, "example plugin destroyed") + return nil +} + +// Install 建表 +func (p *examplePlugin) Install(ctx context.Context) error { + _, err := g.DB().Exec(ctx, ` + CREATE TABLE IF NOT EXISTS plg_example_logs ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + action VARCHAR(64) NOT NULL, + detail TEXT DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + COMMENT ON TABLE plg_example_logs IS '示例插件-操作日志'; + CREATE INDEX IF NOT EXISTS idx_plg_example_logs_tenant_id ON plg_example_logs (tenant_id); + CREATE INDEX IF NOT EXISTS idx_plg_example_logs_created_at ON plg_example_logs USING BRIN (created_at); + `) + return err +} + +// Upgrade 增量迁移 +func (p *examplePlugin) Upgrade(ctx context.Context) error { + return nil +} + +// Uninstall 清理数据 +func (p *examplePlugin) Uninstall(ctx context.Context) error { + _, err := g.DB().Exec(ctx, `DROP TABLE IF EXISTS plg_example_logs`) + return err +} + +// --- 可选接口实现 --- + +// Hooks 实现 Hookable 接口 +func (p *examplePlugin) Hooks() []plugin.HookBinding { + return []plugin.HookBinding{ + { + Event: plugin.HookRelayBeforeRequest, + Priority: 100, + Handler: hooks.RelayBeforeRequest, + }, + { + Event: plugin.HookRelayAfterResponse, + Priority: 100, + Handler: hooks.RelayAfterResponse, + }, + } +} + +// Routes 实现 Routable 接口 +func (p *examplePlugin) Routes(ctx context.Context, server *ghttp.Server) error { + // 示例:注册插件自定义路由 + // 实际使用时可以绑定到 admin 或 tenant 路由组 + return nil +} + +// CronJobs 实现 Cronable 接口 +func (p *examplePlugin) CronJobs() []plugin.CronJobDef { + return []plugin.CronJobDef{ + { + Name: "example_daily_cleanup", + CronExpr: "0 3 * * *", + Handler: p.dailyCleanup, + Singleton: true, + }, + } +} + +func (p *examplePlugin) dailyCleanup(ctx context.Context) { + glog.Info(ctx, "[example plugin] running daily cleanup") + _, err := g.DB().Exec(ctx, fmt.Sprintf( + `DELETE FROM plg_example_logs WHERE created_at < NOW() - INTERVAL '%d days'`, 30, + )) + if err != nil { + glog.Warningf(ctx, "[example plugin] cleanup failed: %v", err) + } +} + +// ConfigSchema 实现 Configurable 接口 +func (p *examplePlugin) ConfigSchema() []plugin.ConfigFieldDef { + return []plugin.ConfigFieldDef{ + { + Key: "max_log_retention_days", + Label: "日志保留天数", + Type: "int", + Default: 30, + Required: false, + Description: "操作日志保留天数,超过此天数自动清理", + }, + { + Key: "enable_detailed_logging", + Label: "详细日志", + Type: "bool", + Default: false, + Required: false, + Description: "是否记录详细的请求/响应内容", + }, + { + Key: "log_level", + Label: "日志级别", + Type: "select", + Default: "info", + Options: []string{"debug", "info", "warn", "error"}, + Required: false, + Description: "插件日志级别", + }, + } +} + +// OnTenantEnable 实现 TenantAware 接口 +func (p *examplePlugin) OnTenantEnable(ctx context.Context, tenantID int64) error { + glog.Infof(ctx, "[example plugin] enabled for tenant %d", tenantID) + return nil +} + +// OnTenantDisable 实现 TenantAware 接口 +func (p *examplePlugin) OnTenantDisable(ctx context.Context, tenantID int64) error { + glog.Infof(ctx, "[example plugin] disabled for tenant %d", tenantID) + return nil +} diff --git a/plugins/registry.go b/plugins/registry.go new file mode 100644 index 0000000..eb3ce53 --- /dev/null +++ b/plugins/registry.go @@ -0,0 +1,7 @@ +package plugins + +// 插件导入中心。每新增一个插件,在这里加一行 blank import。 +// +import ( + _ "github.com/qianfree/team-api/plugins/example" +) diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go new file mode 100644 index 0000000..c95c943 --- /dev/null +++ b/relay/channel/ali/adaptor.go @@ -0,0 +1,167 @@ +package ali + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/channel/claude" + "github.com/qianfree/team-api/relay/channel/openai" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/override" +) + +// Adaptor 阿里云 DashScope 供应商适配器 +type Adaptor struct { + info *common.RelayInfo +} + +func (a *Adaptor) Init(info *common.RelayInfo) { + a.info = info +} + +// GetRequestURL 构建上游请求 URL。 +// Claude 协议走 Anthropic 兼容端点,OpenAI 走 compatible-mode 端点,图像生成使用独立端点。 +func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") + + switch constant.RelayMode(info.RelayMode) { + case constant.RelayModeClaudeMessages: + return baseURL + "/apps/anthropic/v1/messages", nil + case constant.RelayModeChatCompletions: + return baseURL + "/compatible-mode/v1/chat/completions", nil + case constant.RelayModeCompletions: + return baseURL + "/compatible-mode/v1/completions", nil + case constant.RelayModeEmbeddings: + return baseURL + "/compatible-mode/v1/embeddings", nil + case constant.RelayModeImagesGenerations: + return baseURL + "/api/v1/services/aigc/text2image/image-synthesis", nil + default: + return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode) + } +} + +// SetupRequestHeader 设置上游请求头。 +// 流式请求需额外添加 X-DashScope-SSE 头。 +func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) error { + header.Set("Authorization", "Bearer "+info.ChannelMeta.ApiKey) + header.Set("Content-Type", "application/json") + header.Set("Accept", "application/json") + + if info.IsStream { + header.Set("X-DashScope-SSE", "enable") + } + + return nil +} + +// ConvertRequest 转换请求体。 +// Claude 入站直接透传到 Anthropic 兼容端点,其他格式先转为 OpenAI 再做 DashScope 特有适配。 +func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { + // Claude 入站:仅做模型映射,不做 DashScope top_p 裁剪(Claude 格式请求体不含 top_p) + if info.InboundFormat == constant.RelayFormatClaude { + return convertClaudeRequest(requestBody, info) + } + + // 非 OpenAI 格式先转换为 OpenAI + if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { + c, err := openai.ConvertToOpenAI(requestBody, info) + if err != nil { + return nil, err + } + requestBody = c + } + + // DashScope 参数适配 + converted, err := convertRequest(requestBody) + if err != nil { + return nil, err + } + + // 模型名映射 + if info.ChannelMeta.IsModelMapped { + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(converted, &rawMap); err != nil { + return bytes.NewReader(converted), nil + } + rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) + mapped, err := json.Marshal(rawMap) + if err != nil { + return nil, fmt.Errorf("marshal mapped request failed: %w", err) + } + return bytes.NewReader(mapped), nil + } + + return bytes.NewReader(converted), nil +} + +func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + reqURL, err := a.GetRequestURL(info) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + if err := a.SetupRequestHeader(httpReq.Header, info); err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + + if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { + override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) + } + + timeout := info.ChannelMeta.Settings.TimeoutSeconds + if timeout <= 0 { + timeout = 60 + } + + client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) + + return client.Do(httpReq) +} + +// DoResponse 处理上游响应。 +// Claude 入站委托 claude.Adaptor 原生直通;其他格式委托 openai.Adaptor。 +func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + if info.GetOriginalClientFormat() == constant.RelayFormatClaude { + delegate := &claude.Adaptor{} + delegate.Init(info) + return delegate.DoResponse(ctx, resp, info, writer) + } + + delegate := &openai.Adaptor{} + delegate.Init(info) + return delegate.DoResponse(ctx, resp, info, writer) +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} + +var _ common.Adaptor = (*Adaptor)(nil) + +// convertClaudeRequest 处理 Claude 入站请求的模型映射。 +func convertClaudeRequest(requestBody []byte, info *common.RelayInfo) (io.Reader, error) { + if !info.ChannelMeta.IsModelMapped { + return bytes.NewReader(requestBody), nil + } + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(requestBody, &rawMap); err != nil { + return bytes.NewReader(requestBody), nil + } + rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) + result, err := json.Marshal(rawMap) + if err != nil { + return bytes.NewReader(requestBody), nil + } + return bytes.NewReader(result), nil +} diff --git a/relay/channel/ali/constants.go b/relay/channel/ali/constants.go new file mode 100644 index 0000000..0622088 --- /dev/null +++ b/relay/channel/ali/constants.go @@ -0,0 +1,4 @@ +package ali + +// ChannelName 渠道名称 +var ChannelName = "Ali" diff --git a/relay/channel/ali/converter.go b/relay/channel/ali/converter.go new file mode 100644 index 0000000..956684a --- /dev/null +++ b/relay/channel/ali/converter.go @@ -0,0 +1,35 @@ +package ali + +import ( + "encoding/json" + "fmt" +) + +// convertRequest 转换 OpenAI 格式请求以适配 DashScope。 +// 主要处理 top_p 参数的合法范围限制:DashScope 要求 top_p 在 (0, 1) 开区间内。 +func convertRequest(requestBody []byte) ([]byte, error) { + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(requestBody, &rawMap); err != nil { + return nil, fmt.Errorf("unmarshal request failed: %w", err) + } + + // 处理 top_p 参数:DashScope 要求 0 < top_p < 1 + if topPRaw, ok := rawMap["top_p"]; ok { + var topP float64 + if err := json.Unmarshal(topPRaw, &topP); err == nil { + if topP >= 1.0 { + topP = 0.999 + } else if topP <= 0 { + topP = 0.001 + } + capped, _ := json.Marshal(topP) + rawMap["top_p"] = capped + } + } + + converted, err := json.Marshal(rawMap) + if err != nil { + return nil, fmt.Errorf("marshal converted request failed: %w", err) + } + return converted, nil +} diff --git a/relay/channel/aws/adaptor.go b/relay/channel/aws/adaptor.go new file mode 100644 index 0000000..af01819 --- /dev/null +++ b/relay/channel/aws/adaptor.go @@ -0,0 +1,132 @@ +package aws + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/channel/openai" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/override" +) + +// Adaptor AWS Bedrock 供应商适配器。 +// 采用网关代理模式:用户在 BaseURL 中配置 Bedrock 兼容网关(如 LiteLLM、Bedrock Access Gateway), +// 适配器以 OpenAI 兼容格式与网关交互。 +// +// ApiKey 格式: +// - "apiKey" — 纯 API Key,BaseURL 须包含完整网关地址 +// - "apiKey|region" — 管道分隔,region 仅用于日志/调试,实际路由由网关决定 +type Adaptor struct { + info *common.RelayInfo + apiKey string + region string +} + +func (a *Adaptor) Init(info *common.RelayInfo) { + a.info = info + + // 解析 ApiKey:支持 "apiKey" 或 "apiKey|region" 格式 + key := info.ChannelMeta.ApiKey + if parts := strings.SplitN(key, "|", 2); len(parts) == 2 { + a.apiKey = strings.TrimSpace(parts[0]) + a.region = strings.TrimSpace(parts[1]) + } else { + a.apiKey = key + } +} + +// GetRequestURL 构建上游请求 URL。 +// 使用 BaseURL 指向的 OpenAI 兼容网关,拼接标准 OpenAI 路径。 +func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") + + switch constant.RelayMode(info.RelayMode) { + case constant.RelayModeChatCompletions, constant.RelayModeClaudeMessages: + return baseURL + "/v1/chat/completions", nil + case constant.RelayModeEmbeddings: + return baseURL + "/v1/embeddings", nil + default: + return "", fmt.Errorf("aws: unsupported relay mode: %d", info.RelayMode) + } +} + +func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) error { + header.Set("Authorization", "Bearer "+a.apiKey) + header.Set("Content-Type", "application/json") + header.Set("Accept", "application/json") + return nil +} + +// ConvertRequest 转换请求体。网关接受 OpenAI 格式,只需做模型名映射。 +func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { + // 非 OpenAI 格式先转换为 OpenAI + if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { + converted, err := openai.ConvertToOpenAI(requestBody, info) + if err != nil { + return nil, err + } + requestBody = converted + } + + if info.ChannelMeta.IsModelMapped { + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(requestBody, &rawMap); err != nil { + return bytes.NewReader(requestBody), nil + } + rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) + converted, err := json.Marshal(rawMap) + if err != nil { + return nil, fmt.Errorf("marshal converted request failed: %w", err) + } + return bytes.NewReader(converted), nil + } + return bytes.NewReader(requestBody), nil +} + +func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + reqURL, err := a.GetRequestURL(info) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + if err := a.SetupRequestHeader(httpReq.Header, info); err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + + if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { + override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) + } + + timeout := info.ChannelMeta.Settings.TimeoutSeconds + if timeout <= 0 { + timeout = 60 + } + + client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) + + return client.Do(httpReq) +} + +// DoResponse 处理上游响应。网关返回 OpenAI 格式,委托 OpenAI 适配器处理。 +func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + delegate := &openai.Adaptor{} + delegate.Init(info) + return delegate.DoResponse(ctx, resp, info, writer) +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} + +var _ common.Adaptor = (*Adaptor)(nil) diff --git a/relay/channel/aws/constants.go b/relay/channel/aws/constants.go new file mode 100644 index 0000000..5ee4d83 --- /dev/null +++ b/relay/channel/aws/constants.go @@ -0,0 +1,4 @@ +package aws + +// ChannelName 渠道名称 +var ChannelName = "AWS Bedrock" diff --git a/relay/channel/baidu_v2/adaptor.go b/relay/channel/baidu_v2/adaptor.go new file mode 100644 index 0000000..27ac935 --- /dev/null +++ b/relay/channel/baidu_v2/adaptor.go @@ -0,0 +1,147 @@ +package baidu_v2 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/channel/openai" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/override" +) + +// Adaptor 百度 V2 供应商适配器。 +// OpenAI 兼容格式,支持特殊的 ApiKey 分割(token|appid)和搜索模式。 +type Adaptor struct { + info *common.RelayInfo + token string + appID string +} + +func (a *Adaptor) Init(info *common.RelayInfo) { + a.info = info + a.parseAPIKey(info.ChannelMeta.ApiKey) +} + +// parseAPIKey 解析 ApiKey。格式为 "token|appid",按 "|" 分割。 +func (a *Adaptor) parseAPIKey(apiKey string) { + parts := strings.SplitN(apiKey, "|", 2) + a.token = parts[0] + if len(parts) > 1 { + a.appID = parts[1] + } +} + +// GetRequestURL 构建上游请求 URL。 +func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") + + switch constant.RelayMode(info.RelayMode) { + case constant.RelayModeChatCompletions, constant.RelayModeClaudeMessages: + return baseURL + "/v2/chat/completions", nil + case constant.RelayModeEmbeddings: + return baseURL + "/v2/embeddings", nil + case constant.RelayModeImagesGenerations: + return baseURL + "/v2/images/generations", nil + default: + return "", fmt.Errorf("baidu_v2: unsupported relay mode: %d", info.RelayMode) + } +} + +// SetupRequestHeader 设置上游请求头。 +// 使用分割后的 token 作为 Bearer 认证,appid 作为额外请求头。 +func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) error { + header.Set("Authorization", "Bearer "+a.token) + header.Set("Content-Type", "application/json") + header.Set("Accept", "application/json") + if a.appID != "" { + header.Set("appid", a.appID) + } + return nil +} + +// ConvertRequest 转换请求体。 +// 如果模型名以 "-search" 结尾,去掉后缀并注入 web_search 配置。 +func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(requestBody, &rawMap); err != nil { + return bytes.NewReader(requestBody), nil + } + + // 确定上游模型名 + modelName := info.OriginModelName + if info.ChannelMeta.IsModelMapped { + modelName = info.ChannelMeta.UpstreamModelName + } + + // 检测 "-search" 后缀,启用搜索模式 + if strings.HasSuffix(modelName, "-search") { + modelName = strings.TrimSuffix(modelName, "-search") + webSearch := map[string]interface{}{ + "enable": true, + "enable_citation": true, + "enable_trace": true, + } + wsJSON, err := json.Marshal(webSearch) + if err != nil { + return nil, fmt.Errorf("marshal web_search failed: %w", err) + } + rawMap["web_search"] = json.RawMessage(wsJSON) + } + + // 设置模型名 + rawMap["model"] = json.RawMessage(`"` + modelName + `"`) + + converted, err := json.Marshal(rawMap) + if err != nil { + return nil, fmt.Errorf("marshal converted request failed: %w", err) + } + return bytes.NewReader(converted), nil +} + +func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + reqURL, err := a.GetRequestURL(info) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + if err := a.SetupRequestHeader(httpReq.Header, info); err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + + if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { + override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) + } + + timeout := info.ChannelMeta.Settings.TimeoutSeconds + if timeout <= 0 { + timeout = 60 + } + + client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) + + return client.Do(httpReq) +} + +// DoResponse 处理上游响应。百度 V2 返回格式与 OpenAI 一致,委托 OpenAI 适配器处理。 +func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + delegate := &openai.Adaptor{} + delegate.Init(info) + return delegate.DoResponse(ctx, resp, info, writer) +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} + +var _ common.Adaptor = (*Adaptor)(nil) diff --git a/relay/channel/baidu_v2/constants.go b/relay/channel/baidu_v2/constants.go new file mode 100644 index 0000000..0dd2415 --- /dev/null +++ b/relay/channel/baidu_v2/constants.go @@ -0,0 +1,3 @@ +package baidu_v2 + +var ChannelName = "Baidu V2" diff --git a/relay/channel/claude/adaptor.go b/relay/channel/claude/adaptor.go new file mode 100644 index 0000000..a245367 --- /dev/null +++ b/relay/channel/claude/adaptor.go @@ -0,0 +1,256 @@ +package claude + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/common" + + loauth "github.com/qianfree/team-api/internal/logic/common/oauth" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/override" +) + +// Adaptor Claude 供应商适配器 +type Adaptor struct { + info *common.RelayInfo +} + +// Init 初始化适配器 +func (a *Adaptor) Init(info *common.RelayInfo) { + a.info = info +} + +// GetRequestURL 构建上游请求 URL +func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") + + switch constant.RelayMode(info.RelayMode) { + case constant.RelayModeChatCompletions, constant.RelayModeClaudeMessages: + return baseURL + "/v1/messages", nil + default: + return "", fmt.Errorf("unsupported relay mode for Claude: %d", info.RelayMode) + } +} + +// SetupRequestHeader 设置上游请求头 +func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) error { + apiKey := info.ChannelMeta.ApiKey + + // OAuth 模式:使用 Bearer token 而非 x-api-key + if loauth.IsOAuthKeyData(apiKey) { + var oauthData loauth.OAuthKeyData + if err := json.Unmarshal([]byte(apiKey), &oauthData); err == nil { + header.Set("Authorization", "Bearer "+oauthData.AccessToken) + header.Set("anthropic-version", "2023-06-01") + header.Set("Content-Type", "application/json") + header.Set("Accept", "application/json") + return nil + } + } + + header.Set("x-api-key", apiKey) + header.Set("anthropic-version", "2023-06-01") + header.Set("Content-Type", "application/json") + header.Set("Accept", "application/json") + + if info.RequestHeaders != nil { + for _, h := range []string{"X-Request-Id", "anthropic-beta"} { + if v := info.RequestHeaders.Get(h); v != "" { + header.Set(h, v) + } + } + } + + return nil +} + +// ConvertRequest 根据入站格式转换请求体为 Claude 格式 +func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { + var converted io.Reader + switch info.InboundFormat { + case constant.RelayFormatClaude: + converted = bytes.NewReader(requestBody) + case constant.RelayFormatOpenAI: + r, err := ConvertOpenAIToClaude(requestBody, info) + if err != nil { + return nil, err + } + converted = r + case constant.RelayFormatGemini: + r, err := ConvertGeminiToClaude(requestBody, info) + if err != nil { + return nil, err + } + converted = r + case constant.RelayFormatResponses: + r, err := ConvertResponsesToClaude(requestBody, info) + if err != nil { + return nil, err + } + converted = r + default: + converted = bytes.NewReader(requestBody) + } + result := replaceModelIfNeeded(converted, info) + + // Thinking 后缀路由 + if info.ThinkingEnabled { + result = injectClaudeThinking(result, info) + } else if info.ReasoningEffort != "" { + result = injectClaudeEffort(result, info) + } + + return result, nil +} + +// DoRequest 发送请求到上游 +func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + reqURL, err := a.GetRequestURL(info) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + if err := a.SetupRequestHeader(httpReq.Header, info); err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + + if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { + override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) + } + + timeout := info.ChannelMeta.Settings.TimeoutSeconds + if timeout <= 0 { + timeout = 60 + } + + client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) + + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("send request failed: %w", err) + } + + return resp, nil +} + +// injectClaudeThinking 注入 Claude thinking 配置(-thinking 后缀) +// 设 thinking.type=enabled, budget_tokens=80%*max_tokens, temperature=1.0 +func injectClaudeThinking(r io.Reader, info *common.RelayInfo) io.Reader { + body, err := io.ReadAll(r) + if err != nil { + return r + } + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(body, &rawMap); err != nil { + return bytes.NewReader(body) + } + + // 获取 max_tokens + var maxTokens int + if mt, ok := rawMap["max_tokens"]; ok { + _ = json.Unmarshal(mt, &maxTokens) + } + if maxTokens < 1280 { + maxTokens = 16384 + } + budgetTokens := maxTokens * 80 / 100 + if budgetTokens < 1280 { + budgetTokens = 1280 + } + + // 设置 thinking + rawMap["thinking"] = json.RawMessage(fmt.Sprintf(`{"type":"enabled","budget_tokens":%d}`, budgetTokens)) + // Claude thinking 要求 temperature=1.0 + rawMap["temperature"] = json.RawMessage(`1.0`) + + result, err := json.Marshal(rawMap) + if err != nil { + return bytes.NewReader(body) + } + return bytes.NewReader(result) +} + +// injectClaudeEffort 注入 Claude effort 级别(-high/-low 等后缀) +// 使用 adaptive thinking 模式 +func injectClaudeEffort(r io.Reader, info *common.RelayInfo) io.Reader { + body, err := io.ReadAll(r) + if err != nil { + return r + } + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(body, &rawMap); err != nil { + return bytes.NewReader(body) + } + + // 仅在客户端未显式设置 thinking 时注入 + if _, exists := rawMap["thinking"]; !exists { + rawMap["thinking"] = json.RawMessage(fmt.Sprintf(`{"type":"adaptive"}`)) + } + + result, err := json.Marshal(rawMap) + if err != nil { + return bytes.NewReader(body) + } + return bytes.NewReader(result) +} + +// DoResponse 处理上游响应,根据客户端格式分发 +func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + clientFormat := info.GetOriginalClientFormat() + + switch clientFormat { + case constant.RelayFormatClaude: + return a.handleClaudeNativeResponse(ctx, resp, info, writer) + case constant.RelayFormatOpenAI: + if info.IsStream { + return a.handleStreamToOpenAI(ctx, resp, info, writer) + } + return a.handleNonStreamToOpenAI(ctx, resp, info, writer) + default: + // 兜底:默认 OpenAI 转换 + if info.IsStream { + return a.handleStreamToOpenAI(ctx, resp, info, writer) + } + return a.handleNonStreamToOpenAI(ctx, resp, info, writer) + } +} + +// GetChannelName 返回渠道名称 +func (a *Adaptor) GetChannelName() string { + return "Claude" +} + +// 确保接口实现 +var _ common.Adaptor = (*Adaptor)(nil) + +// replaceModelIfNeeded 如果渠道有模型映射,替换请求体中的模型名 +func replaceModelIfNeeded(r io.Reader, info *common.RelayInfo) io.Reader { + if !info.ChannelMeta.IsModelMapped { + return r + } + body, err := io.ReadAll(r) + if err != nil { + return r + } + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(body, &rawMap); err != nil { + return bytes.NewReader(body) + } + rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) + result, err := json.Marshal(rawMap) + if err != nil { + return bytes.NewReader(body) + } + return bytes.NewReader(result) +} diff --git a/relay/channel/claude/converter.go b/relay/channel/claude/converter.go new file mode 100644 index 0000000..89029e6 --- /dev/null +++ b/relay/channel/claude/converter.go @@ -0,0 +1,446 @@ +package claude + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/qianfree/team-api/relay/channel/openai" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/dto" +) + +// ConvertOpenAIToClaude 将 OpenAI 格式请求转换为 Claude Messages API 格式。 +func ConvertOpenAIToClaude(requestBody []byte, info *common.RelayInfo) (io.Reader, error) { + var openaiReq dto.GeneralOpenAIRequest + if err := json.Unmarshal(requestBody, &openaiReq); err != nil { + return nil, fmt.Errorf("parse openai request: %w", err) + } + + claudeReq := dto.ClaudeRequest{ + Model: info.ChannelMeta.UpstreamModelName, + Messages: make([]dto.ClaudeMessage, 0, len(openaiReq.Messages)), + } + + if openaiReq.MaxTokens != nil { + maxTokens := uint(*openaiReq.MaxTokens) + claudeReq.MaxTokens = &maxTokens + } else if openaiReq.MaxCompletionTokens != nil { + maxTokens := uint(*openaiReq.MaxCompletionTokens) + claudeReq.MaxTokens = &maxTokens + } else { + defaultMax := uint(4096) + claudeReq.MaxTokens = &defaultMax + } + + if openaiReq.Temperature != nil { + claudeReq.Temperature = openaiReq.Temperature + } + if openaiReq.TopP != nil { + claudeReq.TopP = openaiReq.TopP + } + if openaiReq.TopK != nil { + claudeReq.TopK = openaiReq.TopK + } + if openaiReq.Stop != nil { + if stops, ok := openaiReq.Stop.([]any); ok { + for _, s := range stops { + if str, ok := s.(string); ok { + claudeReq.StopSequences = append(claudeReq.StopSequences, str) + } + } + } else if str, ok := openaiReq.Stop.(string); ok { + claudeReq.StopSequences = append(claudeReq.StopSequences, str) + } + } + if openaiReq.Stream != nil { + claudeReq.Stream = openaiReq.Stream + } + + // reasoning_effort → thinking + if openaiReq.ReasoningEffort != "" { + claudeReq.Thinking = o2cConvertReasoningEffort(openaiReq.ReasoningEffort) + if claudeReq.MaxTokens != nil && *claudeReq.MaxTokens < 16384 { + v := uint(16384) + claudeReq.MaxTokens = &v + } + } + + if len(openaiReq.Tools) > 0 { + claudeReq.Tools = make([]dto.ClaudeTool, 0, len(openaiReq.Tools)) + for _, t := range openaiReq.Tools { + if t.Type == "function" { + claudeReq.Tools = append(claudeReq.Tools, dto.ClaudeTool{ + Name: t.Function.Name, + Description: t.Function.Description, + InputSchema: t.Function.Parameters, + }) + } + } + } + + claudeReq.ToolChoice = o2cConvertToolChoice(openaiReq.ToolChoice, openaiReq.ParallelToolCalls) + + var systemBlocks []dto.ClaudeContentBlock + var lastRole string + + for _, msg := range openaiReq.Messages { + content := msg.Content + if content == nil { + content = "" + } + + switch msg.Role { + case "system", "developer": + text := o2cExtractTextContent(content) + if text != "" { + systemBlocks = append(systemBlocks, dto.ClaudeContentBlock{ + Type: "text", + Text: &text, + }) + } + + case "user": + blocks := o2cConvertUserMessage(content) + if lastRole == "user" && len(claudeReq.Messages) > 0 { + lastMsg := &claudeReq.Messages[len(claudeReq.Messages)-1] + lastMsg.Content = o2cMergeContents(lastMsg.Content, blocks) + } else { + claudeReq.Messages = append(claudeReq.Messages, dto.ClaudeMessage{ + Role: "user", + Content: blocks, + }) + } + lastRole = "user" + + case "assistant": + blocks := o2cConvertAssistantMessage(msg) + if lastRole == "assistant" && len(claudeReq.Messages) > 0 { + lastMsg := &claudeReq.Messages[len(claudeReq.Messages)-1] + lastMsg.Content = o2cMergeContents(lastMsg.Content, blocks) + } else { + claudeReq.Messages = append(claudeReq.Messages, dto.ClaudeMessage{ + Role: "assistant", + Content: blocks, + }) + } + lastRole = "assistant" + + case "tool": + toolResult := dto.ClaudeContentBlock{ + Type: "tool_result", + ToolUseID: msg.ToolCallID, + Content: o2cExtractTextContent(content), + } + if lastRole == "user" && len(claudeReq.Messages) > 0 { + lastMsg := &claudeReq.Messages[len(claudeReq.Messages)-1] + lastMsg.Content = o2cMergeContents(lastMsg.Content, []dto.ClaudeContentBlock{toolResult}) + } else { + claudeReq.Messages = append(claudeReq.Messages, dto.ClaudeMessage{ + Role: "user", + Content: []dto.ClaudeContentBlock{toolResult}, + }) + } + lastRole = "user" + } + } + + if len(claudeReq.Messages) > 0 && claudeReq.Messages[0].Role != "user" { + claudeReq.Messages = append([]dto.ClaudeMessage{ + {Role: "user", Content: " "}, + }, claudeReq.Messages...) + } + + if len(systemBlocks) > 0 { + if len(systemBlocks) == 1 { + claudeReq.System = systemBlocks[0].Text + } else { + claudeReq.System = systemBlocks + } + } + + result, err := json.Marshal(claudeReq) + if err != nil { + return nil, fmt.Errorf("marshal claude request failed: %w", err) + } + + return bytes.NewReader(result), nil +} + +// ConvertGeminiToClaude 将 Gemini 格式请求转换为 Claude Messages API 格式。 +// 通过 OpenAI 作为中间格式:Gemini → OpenAI → Claude +func ConvertGeminiToClaude(requestBody []byte, info *common.RelayInfo) (io.Reader, error) { + openaiBody, err := openai.ConvertGeminiToOpenAI(requestBody, info) + if err != nil { + return nil, fmt.Errorf("gemini to openai conversion failed: %w", err) + } + openaiBytes, err := io.ReadAll(openaiBody) + if err != nil { + return nil, fmt.Errorf("read openai intermediate body failed: %w", err) + } + return ConvertOpenAIToClaude(openaiBytes, info) +} + +// ConvertResponsesToClaude 将 OpenAI Responses API 格式请求转换为 Claude Messages API 格式。 +// 通过 OpenAI 作为中间格式:Responses → OpenAI → Claude +func ConvertResponsesToClaude(requestBody []byte, info *common.RelayInfo) (io.Reader, error) { + openaiBody, err := openai.ConvertResponsesToOpenAI(requestBody, info) + if err != nil { + return nil, fmt.Errorf("responses to openai conversion failed: %w", err) + } + openaiBytes, err := io.ReadAll(openaiBody) + if err != nil { + return nil, fmt.Errorf("read openai intermediate body failed: %w", err) + } + return ConvertOpenAIToClaude(openaiBytes, info) +} + +func o2cConvertToolChoice(toolChoice any, parallelToolCalls *bool) any { + if toolChoice == nil { + if parallelToolCalls != nil && !*parallelToolCalls { + return map[string]any{ + "type": "auto", + "disable_parallel_tool_use": true, + } + } + return nil + } + + switch v := toolChoice.(type) { + case string: + switch v { + case "auto": + return map[string]any{"type": "auto"} + case "none": + return nil + case "required": + return map[string]any{"type": "any"} + } + case map[string]any: + if v["type"] == "function" { + if fn, ok := v["function"].(map[string]any); ok { + if name, ok := fn["name"].(string); ok { + return map[string]any{"type": "tool", "name": name} + } + } + } + } + return toolChoice +} + +func o2cMergeContents(existing any, newContent any) any { + existingBlocks := o2cContentToBlocks(existing) + newBlocks := o2cContentToBlocks(newContent) + merged := append(existingBlocks, newBlocks...) + if len(merged) == 1 && merged[0].Type == "text" { + return merged[0].Text + } + return merged +} + +func o2cContentToBlocks(content any) []dto.ClaudeContentBlock { + switch v := content.(type) { + case string: + if v == "" { + return nil + } + return []dto.ClaudeContentBlock{{Type: "text", Text: &v}} + case []dto.ClaudeContentBlock: + return v + default: + if content == nil { + return nil + } + return []dto.ClaudeContentBlock{{Type: "text", Text: strPtr(fmt.Sprintf("%v", v))}} + } +} + +func o2cExtractTextContent(content any) string { + switch v := content.(type) { + case string: + return v + case []any: + var parts []string + for _, item := range v { + if m, ok := item.(map[string]any); ok { + if m["type"] == "text" { + if text, ok := m["text"].(string); ok { + parts = append(parts, text) + } + } + } + } + if len(parts) == 0 { + return "" + } + return o2cJoinTextParts(parts) + default: + return fmt.Sprintf("%v", v) + } +} + +func o2cConvertUserMessage(content any) any { + switch v := content.(type) { + case string: + if v == "" { + return []dto.ClaudeContentBlock{{Type: "text", Text: strPtr(" ")}} + } + return v + case []any: + blocks := make([]dto.ClaudeContentBlock, 0, len(v)) + for _, item := range v { + m, ok := item.(map[string]any) + if !ok { + continue + } + switch m["type"] { + case "text": + if text, ok := m["text"].(string); ok { + blocks = append(blocks, dto.ClaudeContentBlock{Type: "text", Text: &text}) + } + case "image_url": + if imageURL, ok := m["image_url"].(map[string]any); ok { + if url, ok := imageURL["url"].(string); ok { + if len(url) > 5 && url[:5] == "data:" { + block, ok := o2cParseDataURL(url) + if ok { + blocks = append(blocks, block) + continue + } + } + if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") { + blocks = append(blocks, dto.ClaudeContentBlock{ + Type: "image", + Source: &dto.ClaudeSource{Type: "url", URL: url}, + }) + } else { + blocks = append(blocks, dto.ClaudeContentBlock{Type: "text", Text: &url}) + } + } + } + } + } + if len(blocks) == 0 { + return []dto.ClaudeContentBlock{{Type: "text", Text: strPtr(" ")}} + } + if len(blocks) == 1 && blocks[0].Type == "text" { + return blocks[0].Text + } + return blocks + default: + return fmt.Sprintf("%v", v) + } +} + +func o2cConvertAssistantMessage(msg dto.Message) any { + blocks := make([]dto.ClaudeContentBlock, 0) + + if msg.ReasoningContent != nil && *msg.ReasoningContent != "" { + blocks = append(blocks, dto.ClaudeContentBlock{ + Type: "thinking", + Thinking: msg.ReasoningContent, + // Note: Anthropic officially requires a valid signature for thinking blocks in history. + // Since OpenAI format doesn't have a dedicated signature field, we leave it empty here. + // If API rejects this, it may need to be stored in Message.Annotations or elsewhere in the future. + }) + } + + text := o2cExtractTextContent(msg.Content) + if text != "" { + blocks = append(blocks, dto.ClaudeContentBlock{Type: "text", Text: &text}) + } + + for _, tc := range msg.ToolCalls { + var inputObj any + if err := json.Unmarshal([]byte(tc.Function.Arguments), &inputObj); err != nil { + inputObj = map[string]any{} + } + blocks = append(blocks, dto.ClaudeContentBlock{ + Type: "tool_use", + ID: tc.ID, + Name: tc.Function.Name, + Input: inputObj, + }) + } + + if len(blocks) == 0 { + return []dto.ClaudeContentBlock{{Type: "text", Text: strPtr(" ")}} + } + if len(blocks) == 1 && blocks[0].Type == "text" { + return blocks[0].Text + } + return blocks +} + +func o2cParseDataURL(dataURL string) (dto.ClaudeContentBlock, bool) { + if len(dataURL) < 11 { + return dto.ClaudeContentBlock{}, false + } + rest := dataURL[5:] + semicolonIdx := -1 + for i := 0; i < len(rest); i++ { + if rest[i] == ';' { + semicolonIdx = i + break + } + } + if semicolonIdx == -1 { + return dto.ClaudeContentBlock{}, false + } + + mediaType := rest[:semicolonIdx] + afterSemicolon := rest[semicolonIdx+1:] + if len(afterSemicolon) < 7 || afterSemicolon[:7] != "base64," { + return dto.ClaudeContentBlock{}, false + } + data := afterSemicolon[7:] + + contentType := "image" + if mediaType == "application/pdf" { + contentType = "document" + } + + return dto.ClaudeContentBlock{ + Type: contentType, + Source: &dto.ClaudeSource{ + Type: "base64", + MediaType: mediaType, + Data: data, + }, + }, true +} + +func o2cJoinTextParts(parts []string) string { + result := make([]byte, 0, 64) + for i, p := range parts { + if i > 0 { + result = append(result, '\n') + } + result = append(result, p...) + } + return string(result) +} + +// o2cConvertReasoningEffort 将 OpenAI reasoning_effort 转换为 Claude thinking 配置 +func o2cConvertReasoningEffort(effort string) *dto.ClaudeThinking { + var budget int + switch effort { + case "low": + budget = 1024 + case "medium": + budget = 8192 + case "high": + budget = 32768 + default: + budget = 8192 + } + return &dto.ClaudeThinking{ + Type: "enabled", + BudgetTokens: &budget, + } +} + +// strPtr 返回 string 的指针 +func strPtr(v string) *string { return &v } diff --git a/relay/channel/claude/converter_test.go b/relay/channel/claude/converter_test.go new file mode 100644 index 0000000..ecc2c51 --- /dev/null +++ b/relay/channel/claude/converter_test.go @@ -0,0 +1,189 @@ +package claude + +import ( + "encoding/json" + "io" + "testing" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/dto" +) + +func TestConvertOpenAIToClaude_SimpleChat(t *testing.T) { + info := &common.RelayInfo{ + ChannelMeta: &common.ChannelMeta{ + UpstreamModelName: "claude-3-5-sonnet-20241022", + }, + InboundFormat: "openai", + } + + openaiReq := dto.GeneralOpenAIRequest{ + Model: "claude-3-5-sonnet-20241022", + Messages: []dto.Message{ + {Role: "system", Content: "You are helpful."}, + {Role: "user", Content: "Hello"}, + {Role: "assistant", Content: "Hi there!"}, + {Role: "user", Content: "How are you?"}, + }, + } + + body, _ := json.Marshal(openaiReq) + reader, err := ConvertOpenAIToClaude(body, info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var claudeReq dto.ClaudeRequest + data, _ := io.ReadAll(reader) + if err := json.Unmarshal(data, &claudeReq); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if claudeReq.Model != "claude-3-5-sonnet-20241022" { + t.Errorf("expected model claude-3-5-sonnet-20241022, got %s", claudeReq.Model) + } + if claudeReq.System != "You are helpful." { + t.Errorf("expected system 'You are helpful.', got %v", claudeReq.System) + } + if len(claudeReq.Messages) != 3 { + t.Fatalf("expected 3 messages, got %d", len(claudeReq.Messages)) + } + if claudeReq.Messages[0].Role != "user" { + t.Errorf("expected first message role 'user', got %s", claudeReq.Messages[0].Role) + } + if claudeReq.Messages[1].Role != "assistant" { + t.Errorf("expected second message role 'assistant', got %s", claudeReq.Messages[1].Role) + } + if claudeReq.Messages[2].Role != "user" { + t.Errorf("expected third message role 'user', got %s", claudeReq.Messages[2].Role) + } +} + +func TestConvertOpenAIToClaude_ToolUse(t *testing.T) { + info := &common.RelayInfo{ + ChannelMeta: &common.ChannelMeta{ + UpstreamModelName: "claude-3-5-sonnet-20241022", + }, + InboundFormat: "openai", + } + + openaiReq := dto.GeneralOpenAIRequest{ + Model: "claude-3-5-sonnet-20241022", + Messages: []dto.Message{ + {Role: "user", Content: "What's the weather?"}, + { + Role: "assistant", + ToolCalls: []dto.ToolCall{ + { + ID: "call_123", + Type: "function", + Function: dto.FunctionCall{ + Name: "get_weather", + Arguments: `{"location":"NYC"}`, + }, + }, + }, + }, + {Role: "tool", ToolCallID: "call_123", Content: "Sunny, 72°F"}, + }, + Tools: []dto.Tool{ + { + Type: "function", + Function: dto.FunctionDef{ + Name: "get_weather", + Description: "Get weather info", + Parameters: map[string]any{"type": "object"}, + }, + }, + }, + } + + body, _ := json.Marshal(openaiReq) + reader, err := ConvertOpenAIToClaude(body, info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var claudeReq dto.ClaudeRequest + data, _ := io.ReadAll(reader) + if err := json.Unmarshal(data, &claudeReq); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if len(claudeReq.Messages) != 3 { + t.Fatalf("expected 3 messages, got %d", len(claudeReq.Messages)) + } + assistantMsg := claudeReq.Messages[1] + if assistantMsg.Role != "assistant" { + t.Errorf("expected role 'assistant', got %s", assistantMsg.Role) + } + toolMsg := claudeReq.Messages[2] + if toolMsg.Role != "user" { + t.Errorf("expected role 'user' for tool result, got %s", toolMsg.Role) + } + if len(claudeReq.Tools) != 1 { + t.Fatalf("expected 1 tool, got %d", len(claudeReq.Tools)) + } + if claudeReq.Tools[0].Name != "get_weather" { + t.Errorf("expected tool name 'get_weather', got %s", claudeReq.Tools[0].Name) + } +} + +func TestClaudeStopReasonToOpenAI(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"end_turn", "stop"}, + {"stop_sequence", "stop"}, + {"max_tokens", "length"}, + {"tool_use", "tool_calls"}, + {"pause_turn", "stop"}, + {"refusal", "content_filter"}, + {"unknown", "unknown"}, + } + + for _, tt := range tests { + result := common.ClaudeStopReasonToOpenAI(tt.input) + if result != tt.expected { + t.Errorf("ClaudeStopReasonToOpenAI(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestConvertOpenAIToClaude_WithMaxTokens(t *testing.T) { + info := &common.RelayInfo{ + ChannelMeta: &common.ChannelMeta{ + UpstreamModelName: "claude-3-5-sonnet-20241022", + }, + InboundFormat: "openai", + } + + maxTokens := 2048 + openaiReq := dto.GeneralOpenAIRequest{ + Model: "claude-3-5-sonnet-20241022", + MaxTokens: &maxTokens, + Messages: []dto.Message{ + {Role: "user", Content: "Hello"}, + }, + } + + body, _ := json.Marshal(openaiReq) + reader, err := ConvertOpenAIToClaude(body, info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var claudeReq dto.ClaudeRequest + data, _ := io.ReadAll(reader) + if err := json.Unmarshal(data, &claudeReq); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if claudeReq.MaxTokens == nil { + t.Fatal("expected MaxTokens to be set") + } + if *claudeReq.MaxTokens != 2048 { + t.Errorf("expected MaxTokens 2048, got %d", *claudeReq.MaxTokens) + } +} diff --git a/relay/channel/claude/response.go b/relay/channel/claude/response.go new file mode 100644 index 0000000..42ef088 --- /dev/null +++ b/relay/channel/claude/response.go @@ -0,0 +1,626 @@ +package claude + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/dto" + "github.com/qianfree/team-api/relay/helper" +) + +// handleNonStreamToOpenAI 将 Claude 非流式响应转换为 OpenAI 格式 +func (a *Adaptor) handleNonStreamToOpenAI(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "read response body failed", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + var claudeResp dto.ClaudeResponse + if err := json.Unmarshal(body, &claudeResp); err != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "invalid response body", err) + } + + // 转换为 OpenAI 格式 + openaiResp := claudeToOpenAIResponse(&claudeResp, info) + + respBody, _ := json.Marshal(openaiResp) + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(respBody) + + usage := &common.Usage{} + if claudeResp.Usage != nil { + usage.PromptTokens = claudeResp.Usage.InputTokens + usage.CompletionTokens = claudeResp.Usage.OutputTokens + usage.TotalTokens = claudeResp.Usage.InputTokens + claudeResp.Usage.OutputTokens + usage.CacheCreationTokens = claudeResp.Usage.CacheCreationInputTokens + usage.PromptTokensDetails = claudeUsageToTokenDetails(claudeResp.Usage) + } + return usage, nil +} + +// handleStreamToOpenAI 将 Claude 流式响应转换为 OpenAI SSE 格式 +func (a *Adaptor) handleStreamToOpenAI(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + helper.SetEventStreamHeaders(writer) + defer helper.PingTicker(writer, 15*time.Second)() + + scanner := bufio.NewScanner(resp.Body) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 10*1024*1024) + + responseID := fmt.Sprintf("chatcmpl-%s", info.RequestID) + createdAt := time.Now().Unix() + + var ( + usage dto.ClaudeUsage + modelName string + finishReason string + toolCallIdx int + roleChunkSent bool + responseTextBuf strings.Builder + ) + + newChunk := func(delta dto.Message) *dto.ChatCompletionStreamResponse { + m := modelName + if m == "" { + m = info.OriginModelName + } + return &dto.ChatCompletionStreamResponse{ + ID: responseID, + Object: "chat.completion.chunk", + Created: createdAt, + Model: m, + Choices: []dto.StreamChoice{{ + Index: 0, + Delta: delta, + }}, + } + } + + for scanner.Scan() { + select { + case <-ctx.Done(): + info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, ctx.Err()) + return buildUsageFromClaude(&usage), common.ErrStreamInterrupted + default: + } + + line := scanner.Text() + if line == "" { + continue + } + + if !strings.HasPrefix(line, "data:") { + continue + } + + data, _ := helper.ExtractSSEData(line) + + if data != "" && data != "[DONE]" { + info.SetFirstResponseTime() + } + + var event dto.ClaudeResponse + if err := json.Unmarshal([]byte(data), &event); err != nil { + continue + } + + switch event.Type { + case "message_start": + if event.Message != nil { + modelName = event.Message.Model + if modelName == "" { + modelName = info.OriginModelName + } + if event.Message.Usage != nil { + usage = *event.Message.Usage + } + } + + if !roleChunkSent { + emptyContent := "" + writeStreamChunk(writer, newChunk(dto.Message{ + Role: "assistant", + Content: &emptyContent, + })) + roleChunkSent = true + } + + case "content_block_start": + if event.ContentBlock == nil { + continue + } + switch event.ContentBlock.Type { + case "text": + case "thinking": + case "redacted_thinking": + // 脱敏思考,OpenAI 格式无等价物,忽略 + case "tool_use": + toolCall := dto.ToolCall{ + ID: event.ContentBlock.ID, + Type: "function", + Function: dto.FunctionCall{ + Name: event.ContentBlock.Name, + Arguments: "", + }, + } + writeStreamChunk(writer, newChunk(dto.Message{ + ToolCalls: []dto.ToolCall{toolCall}, + })) + toolCallIdx++ + } + + case "content_block_delta": + if event.Delta == nil { + continue + } + switch event.Delta.Type { + case "text_delta": + if event.Delta.Text != nil && *event.Delta.Text != "" { + responseTextBuf.WriteString(*event.Delta.Text) + writeStreamChunk(writer, newChunk(dto.Message{ + Content: *event.Delta.Text, + })) + } + case "thinking_delta": + if event.Delta.Thinking != nil && *event.Delta.Thinking != "" { + writeStreamChunk(writer, newChunk(dto.Message{ + ReasoningContent: event.Delta.Thinking, + })) + } + case "input_json_delta": + if event.Delta.PartialJSON != nil && *event.Delta.PartialJSON != "" { + writeStreamChunk(writer, newChunk(dto.Message{ + ToolCalls: []dto.ToolCall{{ + Index: toolCallIdx - 1, + Function: dto.FunctionCall{ + Arguments: *event.Delta.PartialJSON, + }, + }}, + })) + } + case "signature_delta": + } + + case "content_block_stop": + + case "error": + errMsg := "claude stream error" + if event.Error != nil { + if b, err := json.Marshal(event.Error); err == nil { + errMsg = fmt.Sprintf("claude stream error: %s", string(b)) + } + } + info.StreamStatus.SetEndReason(common.StreamEndReasonError, fmt.Errorf("%s", errMsg)) + + case "message_delta": + if event.Delta != nil { + if event.Delta.StopReason != nil { + finishReason = common.ClaudeStopReasonToOpenAI(*event.Delta.StopReason) + } + } + if event.Usage != nil { + if event.Usage.InputTokens > 0 { + usage.InputTokens = event.Usage.InputTokens + } + usage.OutputTokens = event.Usage.OutputTokens + if event.Usage.CacheReadInputTokens > 0 { + usage.CacheReadInputTokens = event.Usage.CacheReadInputTokens + } + if event.Usage.CacheCreationInputTokens > 0 { + usage.CacheCreationInputTokens = event.Usage.CacheCreationInputTokens + } + if event.Usage.CacheCreation != nil { + usage.CacheCreation = event.Usage.CacheCreation + } + } + + case "message_stop": + reason := finishReason + if reason == "" { + reason = "stop" + } + usageObj := &dto.UsageWithDetails{ + PromptTokens: usage.InputTokens, + CompletionTokens: usage.OutputTokens, + TotalTokens: usage.InputTokens + usage.OutputTokens, + PromptTokensDetails: common.CommonTokenDetailsToDto(claudeUsageToTokenDetails(&usage)), + } + if usageObj.CompletionTokens == 0 { + estimated := responseTextBuf.Len() / 4 + if estimated > 0 { + usageObj.CompletionTokens = estimated + usageObj.TotalTokens = usageObj.PromptTokens + usageObj.CompletionTokens + } + } + + chunk := &dto.ChatCompletionStreamResponse{ + ID: responseID, + Object: "chat.completion.chunk", + Created: createdAt, + Model: modelName, + Choices: []dto.StreamChoice{{ + Index: 0, + FinishReason: &reason, + }}, + Usage: usageObj, + } + if chunk.Model == "" { + chunk.Model = info.OriginModelName + } + writeStreamChunk(writer, chunk) + helper.WriteSSEData(writer, "[DONE]") + info.StreamStatus.SetEndReason(common.StreamEndReasonDone, nil) + } + } + + if err := scanner.Err(); err != nil && err != io.EOF && ctx.Err() == nil { + info.StreamStatus.SetEndReason(common.StreamEndReasonError, err) + return &common.Usage{}, fmt.Errorf("stream scanner error: %w", err) + } + + if info.StreamStatus.GetEndReason() == "" { + reason := "stop" + if finishReason != "" { + reason = finishReason + } + usageObj := &dto.UsageWithDetails{ + PromptTokens: usage.InputTokens, + CompletionTokens: usage.OutputTokens, + TotalTokens: usage.InputTokens + usage.OutputTokens, + PromptTokensDetails: common.CommonTokenDetailsToDto(claudeUsageToTokenDetails(&usage)), + } + if usageObj.CompletionTokens == 0 { + estimated := responseTextBuf.Len() / 4 + if estimated > 0 { + usageObj.CompletionTokens = estimated + usageObj.TotalTokens = usageObj.PromptTokens + usageObj.CompletionTokens + } + } + chunk := &dto.ChatCompletionStreamResponse{ + ID: responseID, + Object: "chat.completion.chunk", + Created: createdAt, + Model: modelName, + Choices: []dto.StreamChoice{{ + Index: 0, + FinishReason: &reason, + }}, + Usage: usageObj, + } + if chunk.Model == "" { + chunk.Model = info.OriginModelName + } + writeStreamChunk(writer, chunk) + helper.WriteSSEData(writer, "[DONE]") + info.StreamStatus.SetEndReason(common.StreamEndReasonDone, nil) + } + + return &common.Usage{ + PromptTokens: usage.InputTokens, + CompletionTokens: usage.OutputTokens, + TotalTokens: usage.InputTokens + usage.OutputTokens, + CacheCreationTokens: usage.CacheCreationInputTokens, + PromptTokensDetails: claudeUsageToTokenDetails(&usage), + }, nil +} + +// handleClaudeNativeResponse 直通 Claude 原生格式响应 +func (a *Adaptor) handleClaudeNativeResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + if info.IsStream { + return a.handleClaudeNativeStream(ctx, resp, info, writer) + } + return a.handleClaudeNativeNonStream(ctx, resp, info, writer) +} + +// handleClaudeNativeNonStream 直通 Claude 非流式响应 +func (a *Adaptor) handleClaudeNativeNonStream(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "read response body failed", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + if info.ChannelMeta.IsModelMapped { + body = replaceModelName(body, info.OriginModelName) + } + + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(resp.StatusCode) + _, _ = writer.Write(body) + + var claudeResp dto.ClaudeResponse + if err := json.Unmarshal(body, &claudeResp); err == nil && claudeResp.Usage != nil { + return &common.Usage{ + PromptTokens: claudeResp.Usage.InputTokens, + CompletionTokens: claudeResp.Usage.OutputTokens, + TotalTokens: claudeResp.Usage.InputTokens + claudeResp.Usage.OutputTokens, + CacheCreationTokens: claudeResp.Usage.CacheCreationInputTokens, + PromptTokensDetails: claudeUsageToTokenDetails(claudeResp.Usage), + }, nil + } + + return &common.Usage{}, nil +} + +// handleClaudeNativeStream 直通 Claude 流式响应 +func (a *Adaptor) handleClaudeNativeStream(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + helper.SetEventStreamHeaders(writer) + defer helper.PingTicker(writer, 15*time.Second)() + + reader := bufio.NewReaderSize(resp.Body, 64*1024) + var usage dto.ClaudeUsage + + for { + select { + case <-ctx.Done(): + info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, ctx.Err()) + return buildUsageFromClaude(&usage), common.ErrStreamInterrupted + default: + } + + line, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF { + break + } + info.StreamStatus.SetEndReason(common.StreamEndReasonError, err) + return buildUsageFromClaude(&usage), fmt.Errorf("stream read error: %w", err) + } + + if strings.HasPrefix(line, "data:") { + data, _ := helper.ExtractSSEData(line) + + if data != "" && data != "[DONE]" { + info.SetFirstResponseTime() + } + + var event dto.ClaudeResponse + if json.Unmarshal([]byte(data), &event) == nil { + switch event.Type { + case "message_start": + if event.Message != nil && event.Message.Usage != nil { + usage = *event.Message.Usage + } + case "message_delta": + if event.Usage != nil { + if event.Usage.InputTokens > 0 { + usage.InputTokens = event.Usage.InputTokens + } + usage.OutputTokens = event.Usage.OutputTokens + if event.Usage.CacheReadInputTokens > 0 { + usage.CacheReadInputTokens = event.Usage.CacheReadInputTokens + } + if event.Usage.CacheCreationInputTokens > 0 { + usage.CacheCreationInputTokens = event.Usage.CacheCreationInputTokens + } + if event.Usage.CacheCreation != nil { + usage.CacheCreation = event.Usage.CacheCreation + } + } + case "error": + info.StreamStatus.SetEndReason(common.StreamEndReasonError, fmt.Errorf("claude upstream stream error")) + } + } + + if info.ChannelMeta.IsModelMapped { + replaced := string(replaceModelName([]byte(data), info.OriginModelName)) + line = fmt.Sprintf("data: %s\n", replaced) + } + } + + if _, err := writer.Write([]byte(line)); err != nil { + info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, err) + return buildUsageFromClaude(&usage), common.ErrStreamInterrupted + } + + if len(line) == 1 && line[0] == '\n' { + if f, ok := writer.(http.Flusher); ok { + f.Flush() + } + } + } + + info.StreamStatus.SetEndReason(common.StreamEndReasonDone, nil) + + return buildUsageFromClaude(&usage), nil +} + +// buildUsageFromClaude 从已累积的 ClaudeUsage 构建 common.Usage,保留 cache 字段 +func buildUsageFromClaude(u *dto.ClaudeUsage) *common.Usage { + return &common.Usage{ + PromptTokens: u.InputTokens, + CompletionTokens: u.OutputTokens, + TotalTokens: u.InputTokens + u.OutputTokens, + CacheCreationTokens: u.CacheCreationInputTokens, + PromptTokensDetails: claudeUsageToTokenDetails(u), + } +} + +// claudeUsageToTokenDetails 将 ClaudeUsage 转换为 TokenDetails(含 cache token 细分) +func claudeUsageToTokenDetails(u *dto.ClaudeUsage) *common.TokenDetails { + if u == nil { + return nil + } + td := &common.TokenDetails{ + CachedTokens: u.CacheReadInputTokens, + CachedCreationTokens: u.CacheCreationInputTokens, + } + if u.CacheCreation != nil { + td.CachedCreation5mTokens = u.CacheCreation.Ephemeral5mInputTokens + td.CachedCreation1hTokens = u.CacheCreation.Ephemeral1hInputTokens + } + return td +} + +// claudeToOpenAIResponse 将 Claude 非流式响应转换为 OpenAI ChatCompletion 格式 +func claudeToOpenAIResponse(claudeResp *dto.ClaudeResponse, info *common.RelayInfo) dto.ChatCompletionResponse { + resp := dto.ChatCompletionResponse{ + ID: claudeResp.ID, + Object: "chat.completion", + Created: time.Now().Unix(), + Model: claudeResp.Model, + Choices: []dto.Choice{{ + Index: 0, + FinishReason: common.ClaudeStopReasonToOpenAI(claudeResp.StopReason), + }}, + } + + if info.ChannelMeta.IsModelMapped { + resp.Model = info.OriginModelName + } + + var textParts []string + var thinkingParts []string + var toolCalls []dto.ToolCall + + for _, block := range claudeResp.Content { + switch block.Type { + case "text": + if block.Text != nil { + textParts = append(textParts, *block.Text) + } + case "thinking": + if block.Thinking != nil { + thinkingParts = append(thinkingParts, *block.Thinking) + } + case "redacted_thinking": + // 脱敏思考,OpenAI 格式无等价物,忽略 + case "tool_use": + argsJSON, _ := json.Marshal(block.Input) + toolCalls = append(toolCalls, dto.ToolCall{ + ID: block.ID, + Type: "function", + Function: dto.FunctionCall{ + Name: block.Name, + Arguments: string(argsJSON), + }, + }) + } + } + + message := dto.Message{ + Role: "assistant", + Content: joinTextPartsResponse(textParts), + } + if len(thinkingParts) > 0 { + thinking := strings.Join(thinkingParts, "") + message.ReasoningContent = &thinking + } + if len(toolCalls) > 0 { + message.ToolCalls = toolCalls + } + resp.Choices[0].Message = message + + if claudeResp.Usage != nil { + resp.Usage = dto.UsageWithDetails{ + PromptTokens: claudeResp.Usage.InputTokens, + CompletionTokens: claudeResp.Usage.OutputTokens, + TotalTokens: claudeResp.Usage.InputTokens + claudeResp.Usage.OutputTokens, + } + } + + return resp +} + +// writeStreamChunk 写入流式 chunk +func writeStreamChunk(w http.ResponseWriter, chunk *dto.ChatCompletionStreamResponse) { + data, _ := json.Marshal(chunk) + _ = helper.WriteSSEData(w, string(data)) +} + +// replaceModelName 替换 JSON 中 "model" 字段的值 +func replaceModelName(body []byte, modelName string) []byte { + fieldPrefix := []byte(`"model":"`) + replacement := append([]byte(`"model":"`), modelName...) + replacement = append(replacement, '"') + + result := make([]byte, 0, len(body)) + i := 0 + for i < len(body) { + idx := indexOf(body[i:], fieldPrefix) + if idx == -1 { + result = append(result, body[i:]...) + break + } + result = append(result, body[i:i+idx+len(fieldPrefix)]...) + i += idx + len(fieldPrefix) + + endQuote := indexOf(body[i:], []byte{'"'}) + if endQuote == -1 { + result = append(result, body[i:]...) + break + } + i += endQuote + 1 + result = append(result, replacement...) + } + return result +} + +func indexOf(s, sep []byte) int { + for i := 0; i <= len(s)-len(sep); i++ { + if equal(s[i:i+len(sep)], sep) { + return i + } + } + return -1 +} + +func equal(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func joinTextPartsResponse(parts []string) any { + if len(parts) == 0 { + return nil + } + result := make([]byte, 0, 64) + for i, p := range parts { + if i > 0 { + result = append(result, '\n') + } + result = append(result, p...) + } + return string(result) +} diff --git a/relay/channel/cloudflare/adaptor.go b/relay/channel/cloudflare/adaptor.go new file mode 100644 index 0000000..f60c9d5 --- /dev/null +++ b/relay/channel/cloudflare/adaptor.go @@ -0,0 +1,147 @@ +package cloudflare + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/channel/openai" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/override" +) + +// Adaptor Cloudflare Workers AI 供应商适配器 +// ApiKey 格式为 "token|accountid",用 "|" 分隔认证令牌和账户 ID +type Adaptor struct { + info *common.RelayInfo + token string + accountID string +} + +// 确保接口实现 +var _ common.Adaptor = (*Adaptor)(nil) + +// Init 初始化适配器,从 ApiKey 中解析 token 和 accountID +func (a *Adaptor) Init(info *common.RelayInfo) { + a.info = info + a.parseApiKey(info.ChannelMeta.ApiKey) +} + +// parseApiKey 解析 "token|accountid" 格式的 ApiKey +func (a *Adaptor) parseApiKey(apiKey string) { + parts := strings.SplitN(apiKey, "|", 2) + if len(parts) == 2 { + a.token = parts[0] + a.accountID = parts[1] + } else { + a.token = apiKey + a.accountID = "" + } +} + +// GetRequestURL 构建上游请求 URL +// Cloudflare Workers AI URL 格式: {baseURL}/client/v4/accounts/{accountID}/ai/v1/{endpoint} +func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { + if a.accountID == "" { + return "", fmt.Errorf("cloudflare: account ID not found, ApiKey must be in 'token|accountid' format") + } + + baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") + prefix := fmt.Sprintf("%s/client/v4/accounts/%s/ai/v1", baseURL, a.accountID) + + switch constant.RelayMode(info.RelayMode) { + case constant.RelayModeChatCompletions: + return prefix + "/chat/completions", nil + case constant.RelayModeCompletions: + return prefix + "/completions", nil + case constant.RelayModeEmbeddings: + return prefix + "/embeddings", nil + case constant.RelayModeImagesGenerations: + return prefix + "/images/generations", nil + default: + return "", fmt.Errorf("cloudflare: unsupported relay mode: %d", info.RelayMode) + } +} + +// SetupRequestHeader 设置上游请求头 +func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) error { + header.Set("Authorization", "Bearer "+a.token) + header.Set("Content-Type", "application/json") + header.Set("Accept", "application/json") + return nil +} + +// ConvertRequest 转换请求体,执行模型名映射 +func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { + // 非 OpenAI 格式先转换为 OpenAI + if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { + converted, err := openai.ConvertToOpenAI(requestBody, info) + if err != nil { + return nil, err + } + requestBody = converted + } + + if !info.ChannelMeta.IsModelMapped { + return bytes.NewReader(requestBody), nil + } + + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(requestBody, &rawMap); err != nil { + return bytes.NewReader(requestBody), nil + } + + rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) + + converted, err := json.Marshal(rawMap) + if err != nil { + return nil, fmt.Errorf("cloudflare: marshal converted request failed: %w", err) + } + return bytes.NewReader(converted), nil +} + +// DoRequest 发送请求到上游 +func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + reqURL, err := a.GetRequestURL(info) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + if err := a.SetupRequestHeader(httpReq.Header, info); err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + + if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { + override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) + } + + timeout := info.ChannelMeta.Settings.TimeoutSeconds + if timeout <= 0 { + timeout = 60 + } + + client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) + return client.Do(httpReq) +} + +// DoResponse 处理上游响应,委托给 OpenAI 适配器 +func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + delegate := &openai.Adaptor{} + delegate.Init(info) + return delegate.DoResponse(ctx, resp, info, writer) +} + +// GetChannelName 返回渠道名称 +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/cloudflare/constants.go b/relay/channel/cloudflare/constants.go new file mode 100644 index 0000000..f4e9eeb --- /dev/null +++ b/relay/channel/cloudflare/constants.go @@ -0,0 +1,3 @@ +package cloudflare + +var ChannelName = "Cloudflare" diff --git a/relay/channel/codex/adaptor.go b/relay/channel/codex/adaptor.go new file mode 100644 index 0000000..1fc0cf6 --- /dev/null +++ b/relay/channel/codex/adaptor.go @@ -0,0 +1,127 @@ +package codex + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/channel/openai" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/override" +) + +// codexCredentials 解析 ApiKey JSON 中的凭证信息 +type codexCredentials struct { + AccessToken string `json:"access_token"` + AccountID string `json:"account_id"` +} + +// Adaptor Codex 供应商适配器(ChatGPT /v1/responses 端点) +type Adaptor struct { + info *common.RelayInfo + creds codexCredentials +} + +func (a *Adaptor) Init(info *common.RelayInfo) { + a.info = info + // ApiKey 格式为 JSON: {"access_token": "...", "account_id": "..."} + _ = json.Unmarshal([]byte(info.ChannelMeta.ApiKey), &a.creds) +} + +// GetRequestURL 构建上游请求 URL。Codex 仅支持 /v1/responses 端点。 +func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") + + switch constant.RelayMode(info.RelayMode) { + case constant.RelayModeResponses: + return baseURL + "/v1/responses", nil + default: + return "", fmt.Errorf("codex only supports Responses mode, got relay mode: %d", info.RelayMode) + } +} + +func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) error { + header.Set("Authorization", "Bearer "+a.creds.AccessToken) + header.Set("chatgpt-account-id", a.creds.AccountID) + header.Set("Content-Type", "application/json") + return nil +} + +// ConvertRequest 转换请求体。Codex 仅支持 Responses 模式,直通并做模型名映射。 +func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { + // 非 OpenAI 格式先转换为 OpenAI + if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { + converted, err := openai.ConvertToOpenAI(requestBody, info) + if err != nil { + return nil, err + } + requestBody = converted + } + + if constant.RelayMode(info.RelayMode) != constant.RelayModeResponses { + return nil, fmt.Errorf("codex only supports Responses mode, got relay mode: %d", info.RelayMode) + } + + // 模型名映射 + if info.ChannelMeta.IsModelMapped { + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(requestBody, &rawMap); err != nil { + return bytes.NewReader(requestBody), nil + } + rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) + converted, err := json.Marshal(rawMap) + if err != nil { + return nil, fmt.Errorf("marshal converted request failed: %w", err) + } + return bytes.NewReader(converted), nil + } + + return bytes.NewReader(requestBody), nil +} + +func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + reqURL, err := a.GetRequestURL(info) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + if err := a.SetupRequestHeader(httpReq.Header, info); err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + + if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { + override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) + } + + timeout := info.ChannelMeta.Settings.TimeoutSeconds + if timeout <= 0 { + timeout = 60 + } + + client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) + + return client.Do(httpReq) +} + +// DoResponse 处理上游响应。委托 OpenAI 适配器处理。 +func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + delegate := &openai.Adaptor{} + delegate.Init(info) + return delegate.DoResponse(ctx, resp, info, writer) +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} + +var _ common.Adaptor = (*Adaptor)(nil) diff --git a/relay/channel/codex/constants.go b/relay/channel/codex/constants.go new file mode 100644 index 0000000..b5554d4 --- /dev/null +++ b/relay/channel/codex/constants.go @@ -0,0 +1,3 @@ +package codex + +var ChannelName = "Codex" diff --git a/relay/channel/coze/adaptor.go b/relay/channel/coze/adaptor.go new file mode 100644 index 0000000..0efce54 --- /dev/null +++ b/relay/channel/coze/adaptor.go @@ -0,0 +1,367 @@ +package coze + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/qianfree/team-api/relay/channel/openai" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/dto" + "github.com/qianfree/team-api/relay/helper" + "github.com/qianfree/team-api/relay/override" +) + +// Adaptor Coze 供应商适配器。 +// Coze v3 API 支持流式 SSE 响应,本适配器将 Coze SSE 事件转换为 OpenAI 格式的 SSE 流。 +// 非流式请求也通过流式端点收集完整响应后以 OpenAI JSON 格式返回。 +type Adaptor struct { + info *common.RelayInfo +} + +func (a *Adaptor) Init(info *common.RelayInfo) { + a.info = info +} + +// GetRequestURL 构建上游请求 URL。Coze v3 chat 端点: {baseURL}/v3/chat +func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") + return baseURL + "/v3/chat", nil +} + +// SetupRequestHeader 设置上游请求头。ApiKey 为 Coze Personal Access Token。 +func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) error { + header.Set("Authorization", "Bearer "+info.ChannelMeta.ApiKey) + header.Set("Content-Type", "application/json") + header.Set("Accept", "application/json") + return nil +} + +// ConvertRequest 将 OpenAI Chat 请求转换为 Coze v3 请求格式 +func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { + // 非 OpenAI 格式先转换为 OpenAI + if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { + converted, err := openai.ConvertToOpenAI(requestBody, info) + if err != nil { + return nil, err + } + requestBody = converted + } + + // 非流式请求也强制使用流式模式,以便在 DoResponse 中统一处理 + cozeBody, err := convertOpenAIToCoze(requestBody, info) + if err != nil { + return nil, fmt.Errorf("convert to Coze request failed: %w", err) + } + + // 强制开启流式模式:Coze 非流式需要轮询,实现复杂, + // 这里统一走流式,非流式场景在 DoResponse 中收集完整响应后一次性返回 + var cozeReq CozeCreateRequest + if err := json.Unmarshal(cozeBody, &cozeReq); err == nil { + cozeReq.Stream = true + cozeBody, _ = json.Marshal(cozeReq) + } + + return bytes.NewReader(cozeBody), nil +} + +// DoRequest 发送请求到 Coze 上游 +func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + reqURL, err := a.GetRequestURL(info) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + if err := a.SetupRequestHeader(httpReq.Header, info); err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + + if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { + override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) + } + + timeout := info.ChannelMeta.Settings.TimeoutSeconds + if timeout <= 0 { + timeout = 120 // Coze 可能响应较慢,给更长超时 + } + + client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) + + return client.Do(httpReq) +} + +// DoResponse 处理 Coze 上游 SSE 响应,转换为 OpenAI 格式。 +// Coze SSE 事件格式: +// +// event: conversation.message.delta +// data: {"role":"assistant","type":"answer","content":"Hello"} +// +// event: conversation.message.completed +// data: {"role":"assistant","type":"answer","content":"Hello world"} +// +// event: done +// data: {} +func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + if info.IsStream { + return a.handleStreamResponse(ctx, resp, info, writer) + } + return a.handleNonStreamResponse(ctx, resp, info, writer) +} + +// handleStreamResponse 流式模式:逐事件读取 Coze SSE,转换为 OpenAI SSE 格式输出 +func (a *Adaptor) handleStreamResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + helper.SetEventStreamHeaders(writer) + defer helper.PingTicker(writer, 15*time.Second)() + + streamStatus := info.StreamStatus + if streamStatus == nil { + streamStatus = common.NewStreamStatus() + info.StreamStatus = streamStatus + } + + chatID := fmt.Sprintf("chatcmpl-coze-%s", info.RequestID) + createdAt := time.Now().Unix() + modelName := info.OriginModelName + + scanner := bufio.NewScanner(resp.Body) + var currentEvent string + var completionTokens int + + for scanner.Scan() { + select { + case <-ctx.Done(): + streamStatus.SetEndReason(common.StreamEndReasonClientGone, common.ErrStreamInterrupted) + return &common.Usage{CompletionTokens: completionTokens}, nil + default: + } + + line := scanner.Text() + + // 解析 SSE event 行 + if strings.HasPrefix(line, "event:") { + currentEvent = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + continue + } + + // 解析 SSE data 行 + if !strings.HasPrefix(line, "data:") { + continue + } + data, _ := helper.ExtractSSEData(line) + + info.SetFirstResponseTime() + + switch currentEvent { + case "conversation.message.delta": + var msg CozeMessage + if err := json.Unmarshal([]byte(data), &msg); err != nil { + continue + } + // 只转发 answer 类型的消息 + if msg.Type != "answer" { + continue + } + completionTokens += estimateTokens(msg.Content) + + chunk := buildOpenAIStreamChunk(chatID, createdAt, modelName, msg.Content, nil) + chunkJSON, err := json.Marshal(chunk) + if err != nil { + continue + } + if err := helper.WriteSSEData(writer, string(chunkJSON)); err != nil { + streamStatus.SetEndReason(common.StreamEndReasonClientGone, common.ErrStreamInterrupted) + return &common.Usage{CompletionTokens: completionTokens}, nil + } + + case "conversation.message.completed": + // 完成事件,发送带 finish_reason 的最终 chunk + finishReason := "stop" + chunk := buildOpenAIStreamChunk(chatID, createdAt, modelName, "", &finishReason) + chunkJSON, _ := json.Marshal(chunk) + _ = helper.WriteSSEData(writer, string(chunkJSON)) + + case "done": + // 流结束 + _ = helper.WriteSSEData(writer, "[DONE]") + streamStatus.SetEndReason(common.StreamEndReasonDone, nil) + return &common.Usage{ + CompletionTokens: completionTokens, + TotalTokens: completionTokens, + }, nil + + case "error": + // Coze 错误事件 + streamStatus.SetEndReason(common.StreamEndReasonError, fmt.Errorf("coze error: %s", data)) + return &common.Usage{CompletionTokens: completionTokens}, fmt.Errorf("coze stream error: %s", data) + } + + currentEvent = "" + } + + if err := scanner.Err(); err != nil { + streamStatus.SetEndReason(common.StreamEndReasonError, err) + return &common.Usage{CompletionTokens: completionTokens}, fmt.Errorf("read coze stream failed: %w", err) + } + + streamStatus.SetEndReason(common.StreamEndReasonDone, nil) + return &common.Usage{ + CompletionTokens: completionTokens, + TotalTokens: completionTokens, + }, nil +} + +// handleNonStreamResponse 非流式模式:读取 Coze SSE 收集完整内容,转换为 OpenAI JSON 响应 +func (a *Adaptor) handleNonStreamResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + scanner := bufio.NewScanner(resp.Body) + var currentEvent string + var fullContent strings.Builder + + for scanner.Scan() { + select { + case <-ctx.Done(): + return nil, fmt.Errorf("context cancelled while reading coze response") + default: + } + + line := scanner.Text() + + if strings.HasPrefix(line, "event:") { + currentEvent = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + continue + } + + if !strings.HasPrefix(line, "data:") { + continue + } + data, _ := helper.ExtractSSEData(line) + + info.SetFirstResponseTime() + + switch currentEvent { + case "conversation.message.delta": + var msg CozeMessage + if err := json.Unmarshal([]byte(data), &msg); err != nil { + continue + } + if msg.Type == "answer" { + fullContent.WriteString(msg.Content) + } + + case "conversation.message.completed": + // completed 事件包含完整内容,优先使用 + var msg CozeMessage + if err := json.Unmarshal([]byte(data), &msg); err == nil && msg.Type == "answer" { + fullContent.Reset() + fullContent.WriteString(msg.Content) + } + + case "error": + return nil, fmt.Errorf("coze error: %s", data) + } + + currentEvent = "" + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read coze response failed: %w", err) + } + + content := fullContent.String() + completionTokens := estimateTokens(content) + + // 构建 OpenAI 非流式响应 + chatResp := dto.ChatCompletionResponse{ + ID: fmt.Sprintf("chatcmpl-coze-%s", info.RequestID), + Object: "chat.completion", + Created: time.Now().Unix(), + Model: info.OriginModelName, + Choices: []dto.Choice{ + { + Index: 0, + Message: dto.Message{ + Role: "assistant", + Content: content, + }, + FinishReason: "stop", + }, + }, + Usage: dto.UsageWithDetails{ + CompletionTokens: completionTokens, + TotalTokens: completionTokens, + }, + } + + respJSON, err := json.Marshal(chatResp) + if err != nil { + return nil, fmt.Errorf("marshal OpenAI response failed: %w", err) + } + + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(respJSON) + + return &common.Usage{ + CompletionTokens: completionTokens, + TotalTokens: completionTokens, + }, nil +} + +// buildOpenAIStreamChunk 构建 OpenAI 格式的流式响应块 +func buildOpenAIStreamChunk(id string, created int64, model string, content string, finishReason *string) dto.ChatCompletionStreamResponse { + delta := dto.Message{} + if content != "" { + delta.Content = content + } + if finishReason == nil { + delta.Role = "assistant" + } + + return dto.ChatCompletionStreamResponse{ + ID: id, + Object: "chat.completion.chunk", + Created: created, + Model: model, + Choices: []dto.StreamChoice{ + { + Index: 0, + Delta: delta, + FinishReason: finishReason, + }, + }, + } +} + +// estimateTokens 粗略估算 token 数(英文按空格分词,中文按字符计数)。 +// 这是一个简化实现,实际 token 计数应由上游返回或使用 tiktoken 等库计算。 +func estimateTokens(text string) int { + if text == "" { + return 0 + } + // 粗略估算:每 4 个字符约 1 个 token + return (len(text) + 3) / 4 +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} + +var _ common.Adaptor = (*Adaptor)(nil) diff --git a/relay/channel/coze/constants.go b/relay/channel/coze/constants.go new file mode 100644 index 0000000..0ffe42b --- /dev/null +++ b/relay/channel/coze/constants.go @@ -0,0 +1,4 @@ +package coze + +// ChannelName 渠道名称 +var ChannelName = "Coze" diff --git a/relay/channel/coze/converter.go b/relay/channel/coze/converter.go new file mode 100644 index 0000000..9dfd80e --- /dev/null +++ b/relay/channel/coze/converter.go @@ -0,0 +1,77 @@ +package coze + +import ( + "encoding/json" + "fmt" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/dto" +) + +// convertOpenAIToCoze 将 OpenAI Chat Completions 请求转换为 Coze v3 请求。 +// BotID 来自上游模型名(UpstreamModelName),即渠道配置中的模型映射目标; +// query 取最后一条 user 角色消息的文本内容。 +func convertOpenAIToCoze(requestBody []byte, info *common.RelayInfo) ([]byte, error) { + var openaiReq dto.GeneralOpenAIRequest + if err := json.Unmarshal(requestBody, &openaiReq); err != nil { + return nil, fmt.Errorf("parse OpenAI request failed: %w", err) + } + + // 提取最后一条 user 消息作为 query + query := extractLastUserMessage(openaiReq.Messages) + if query == "" { + return nil, fmt.Errorf("no user message found in request") + } + + // BotID:优先使用映射后的上游模型名,否则使用原始模型名 + botID := openaiReq.Model + if info.ChannelMeta.IsModelMapped && info.ChannelMeta.UpstreamModelName != "" { + botID = info.ChannelMeta.UpstreamModelName + } + + // 用户标识 + user := openaiReq.User + if user == "" { + user = fmt.Sprintf("tenant_%d_user_%d", info.TenantID, info.UserID) + } + + // 流式标识 + stream := false + if openaiReq.Stream != nil { + stream = *openaiReq.Stream + } + + cozeReq := CozeCreateRequest{ + BotID: botID, + User: user, + Query: query, + Stream: stream, + } + + return json.Marshal(cozeReq) +} + +// extractLastUserMessage 从消息列表中提取最后一条 user 角色的文本内容 +func extractLastUserMessage(messages []dto.Message) string { + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role != "user" { + continue + } + switch content := messages[i].Content.(type) { + case string: + return content + case []interface{}: + // 多模态消息,提取文本部分 + for _, part := range content { + if m, ok := part.(map[string]interface{}); ok { + if m["type"] == "text" { + if text, ok := m["text"].(string); ok { + return text + } + } + } + } + } + } + return "" +} diff --git a/relay/channel/coze/dto.go b/relay/channel/coze/dto.go new file mode 100644 index 0000000..987e372 --- /dev/null +++ b/relay/channel/coze/dto.go @@ -0,0 +1,45 @@ +package coze + +// CozeCreateRequest Coze v3 创建对话请求 +type CozeCreateRequest struct { + ConversationID string `json:"conversation_id,omitempty"` + BotID string `json:"bot_id"` + User string `json:"user"` + Query string `json:"query"` + Stream bool `json:"stream"` +} + +// CozeCreateResponse Coze 创建对话响应 +type CozeCreateResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + ID string `json:"id"` + ConversationID string `json:"conversation_id"` + Status string `json:"status"` + } `json:"data"` +} + +// CozeRetrieveResponse Coze 查询对话状态响应 +type CozeRetrieveResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Status string `json:"status"` + } `json:"data"` +} + +// CozeMessageListResponse Coze 消息列表响应 +type CozeMessageListResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data []CozeMessage `json:"data"` +} + +// CozeMessage Coze 消息 +type CozeMessage struct { + Role string `json:"role"` + Type string `json:"type"` + Content string `json:"content"` + ContentType string `json:"content_type"` +} diff --git a/relay/channel/deepseek/adaptor.go b/relay/channel/deepseek/adaptor.go new file mode 100644 index 0000000..1036e08 --- /dev/null +++ b/relay/channel/deepseek/adaptor.go @@ -0,0 +1,241 @@ +package deepseek + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/channel/claude" + "github.com/qianfree/team-api/relay/channel/openai" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/override" +) + +// Adaptor DeepSeek 供应商适配器 +type Adaptor struct { + info *common.RelayInfo +} + +func (a *Adaptor) Init(info *common.RelayInfo) { + a.info = info +} + +// GetRequestURL 构建上游请求 URL。 +// Completions(FIM) 模式使用 /beta/completions 端点,其余走标准 OpenAI 路径。 +func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") + + switch constant.RelayMode(info.RelayMode) { + case constant.RelayModeCompletions: + betaURL := baseURL + if !strings.HasSuffix(betaURL, "/beta") { + betaURL += "/beta" + } + return betaURL + "/completions", nil + case constant.RelayModeChatCompletions: + return baseURL + "/v1/chat/completions", nil + case constant.RelayModeClaudeMessages: + return baseURL + "/anthropic/v1/messages", nil + case constant.RelayModeEmbeddings: + return baseURL + "/v1/embeddings", nil + default: + return baseURL + "/v1/chat/completions", nil + } +} + +func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) error { + header.Set("Authorization", "Bearer "+info.ChannelMeta.ApiKey) + header.Set("Content-Type", "application/json") + header.Set("Accept", "application/json") + return nil +} + +// ConvertRequest 转换请求体。 +// Claude 入站直接透传到 Anthropic 兼容端点,其他格式先转为 OpenAI 再做 DeepSeek 特有适配。 +func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { + // Claude 入站:透传请求体,仅做模型映射和思考参数注入 + if info.InboundFormat == constant.RelayFormatClaude { + return convertClaudeRequestForDeepSeek(requestBody, info) + } + + // 非 OpenAI 格式先转换为 OpenAI + if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { + converted, err := openai.ConvertToOpenAI(requestBody, info) + if err != nil { + return nil, err + } + requestBody = converted + } + + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(requestBody, &rawMap); err != nil { + return bytes.NewReader(requestBody), nil + } + + // 模型名映射 + if info.ChannelMeta.IsModelMapped { + rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) + } + + // 注入 stream_options(流式请求需要 usage 信息用于计费) + rawMap = injectStreamOptions(rawMap, info) + + // 注入思考模式参数 + rawMap = injectThinkingParams(rawMap, info) + + result, err := json.Marshal(rawMap) + if err != nil { + return nil, fmt.Errorf("marshal converted request failed: %w", err) + } + return bytes.NewReader(result), nil +} + +// injectStreamOptions 为流式请求注入 stream_options:{include_usage:true} +func injectStreamOptions(rawMap map[string]json.RawMessage, info *common.RelayInfo) map[string]json.RawMessage { + if !info.IsStream { + return rawMap + } + if _, exists := rawMap["stream_options"]; !exists { + rawMap["stream_options"] = json.RawMessage(`{"include_usage":true}`) + } + return rawMap +} + +// injectThinkingParams 根据 RelayInfo 中的思考后缀注入 DeepSeek 思考模式参数。 +// +// DeepSeek 思考模式参数说明: +// - thinking.type: "enabled"(默认) / "disabled" +// - reasoning_effort: "high"(默认) / "max",兼容映射:low/medium→high, xhigh→max +// +// 注入优先级:客户端已显式设置 > 后缀路由注入 > 不干预(保留 DeepSeek 默认行为) +func injectThinkingParams(rawMap map[string]json.RawMessage, info *common.RelayInfo) map[string]json.RawMessage { + // 客户端已显式设置 thinking 参数 → 不干预 + if _, clientSet := rawMap["thinking"]; clientSet { + return rawMap + } + + // -nothinking 后缀:显式关闭思考 + if info.ThinkingDisabled { + rawMap["thinking"] = json.RawMessage(`{"type":"disabled"}`) + // 关闭思考时清理 reasoning_effort + delete(rawMap, "reasoning_effort") + return rawMap + } + + // -thinking 后缀:显式开启思考 + 可选 effort + if info.ThinkingEnabled { + rawMap["thinking"] = json.RawMessage(`{"type":"enabled"}`) + rawMap = injectReasoningEffort(rawMap, info) + return rawMap + } + + // effort 后缀(-high/-low/-medium 等):开启思考 + 设置 effort + if info.ReasoningEffort != "" { + // 仅在客户端未显式设置 thinking 时注入 + rawMap["thinking"] = json.RawMessage(`{"type":"enabled"}`) + rawMap = injectReasoningEffort(rawMap, info) + return rawMap + } + + // 无后缀:不干预,保留 DeepSeek 默认行为(默认 enabled) + return rawMap +} + +// injectReasoningEffort 注入 reasoning_effort 参数,映射到 DeepSeek 支持的值 +func injectReasoningEffort(rawMap map[string]json.RawMessage, info *common.RelayInfo) map[string]json.RawMessage { + if info.ReasoningEffort == "" { + return rawMap + } + // 仅在客户端未显式设置时注入 + if _, clientSet := rawMap["reasoning_effort"]; clientSet { + return rawMap + } + + // DeepSeek 只支持 high 和 max,做兼容映射 + // low/medium → high, xhigh/max → max + effort := info.ReasoningEffort + switch effort { + case "low", "medium", "minimal": + effort = "high" + case "xhigh", "max": + effort = "max" + default: + effort = "high" + } + rawMap["reasoning_effort"], _ = json.Marshal(effort) + return rawMap +} + +func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + reqURL, err := a.GetRequestURL(info) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + if err := a.SetupRequestHeader(httpReq.Header, info); err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + + if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { + override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) + } + + timeout := info.ChannelMeta.Settings.TimeoutSeconds + if timeout <= 0 { + timeout = 300 + } + + client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) + + return client.Do(httpReq) +} + +// DoResponse 处理上游响应。 +// Claude 入站委托 claude.Adaptor 原生直通;其他格式委托 openai.Adaptor。 +func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + if info.GetOriginalClientFormat() == constant.RelayFormatClaude { + delegate := &claude.Adaptor{} + delegate.Init(info) + return delegate.DoResponse(ctx, resp, info, writer) + } + + delegate := &openai.Adaptor{} + delegate.Init(info) + return delegate.DoResponse(ctx, resp, info, writer) +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} + +var _ common.Adaptor = (*Adaptor)(nil) + +// convertClaudeRequestForDeepSeek 处理 Claude 入站请求的 DeepSeek 兼容适配(模型映射 + 思考参数)。 +func convertClaudeRequestForDeepSeek(requestBody []byte, info *common.RelayInfo) (io.Reader, error) { + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(requestBody, &rawMap); err != nil { + return bytes.NewReader(requestBody), nil + } + + if info.ChannelMeta.IsModelMapped { + rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) + } + + rawMap = injectThinkingParams(rawMap, info) + + result, err := json.Marshal(rawMap) + if err != nil { + return bytes.NewReader(requestBody), nil + } + return bytes.NewReader(result), nil +} diff --git a/relay/channel/deepseek/constants.go b/relay/channel/deepseek/constants.go new file mode 100644 index 0000000..806cbdf --- /dev/null +++ b/relay/channel/deepseek/constants.go @@ -0,0 +1,4 @@ +package deepseek + +// ChannelName 渠道名称 +var ChannelName = "DeepSeek" diff --git a/relay/channel/dify/adaptor.go b/relay/channel/dify/adaptor.go new file mode 100644 index 0000000..0178d76 --- /dev/null +++ b/relay/channel/dify/adaptor.go @@ -0,0 +1,315 @@ +package dify + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/qianfree/team-api/relay/channel/openai" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/dto" + "github.com/qianfree/team-api/relay/helper" + "github.com/qianfree/team-api/relay/override" +) + +// Adaptor Dify 供应商适配器。 +// Dify 是开源 LLM 应用开发平台,通过 App API 提供对话能力。 +// 本适配器将 OpenAI Chat Completions 请求转换为 Dify chat-messages 格式, +// 并将 Dify 响应(blocking/streaming)转换回 OpenAI 格式。 +type Adaptor struct { + info *common.RelayInfo +} + +func (a *Adaptor) Init(info *common.RelayInfo) { + a.info = info +} + +// GetRequestURL 构建上游请求 URL。Dify chat-messages 端点: {baseURL}/v1/chat-messages +func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") + + switch constant.RelayMode(info.RelayMode) { + case constant.RelayModeChatCompletions: + return baseURL + "/v1/chat-messages", nil + default: + return "", fmt.Errorf("unsupported relay mode for Dify: %d", info.RelayMode) + } +} + +// SetupRequestHeader 设置上游请求头。ApiKey 为 Dify App API Key。 +func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) error { + header.Set("Authorization", "Bearer "+info.ChannelMeta.ApiKey) + header.Set("Content-Type", "application/json") + header.Set("Accept", "application/json") + return nil +} + +// ConvertRequest 将 OpenAI Chat 请求转换为 Dify 请求格式 +func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { + // 非 OpenAI 格式先转换为 OpenAI + if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { + converted, err := openai.ConvertToOpenAI(requestBody, info) + if err != nil { + return nil, err + } + requestBody = converted + } + + difyBody, err := convertOpenAIToDify(requestBody, info) + if err != nil { + return nil, fmt.Errorf("convert to Dify request failed: %w", err) + } + return bytes.NewReader(difyBody), nil +} + +// DoRequest 发送请求到 Dify 上游 +func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + reqURL, err := a.GetRequestURL(info) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + if err := a.SetupRequestHeader(httpReq.Header, info); err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + + if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { + override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) + } + + timeout := info.ChannelMeta.Settings.TimeoutSeconds + if timeout <= 0 { + timeout = 120 // Dify 应用可能包含复杂工作流,给更长超时 + } + + client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) + + return client.Do(httpReq) +} + +// DoResponse 处理 Dify 上游响应,转换为 OpenAI 格式。 +// +// Dify blocking 模式返回 JSON: +// +// {"answer": "...", "metadata": {"usage": {"total_tokens": N}}} +// +// Dify streaming 模式返回 SSE: +// +// data: {"event": "message", "answer": "chunk text"} +// data: {"event": "message_end", "metadata": {"usage": {...}}} +func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + if info.IsStream { + return a.handleStreamResponse(ctx, resp, info, writer) + } + return a.handleNonStreamResponse(ctx, resp, info, writer) +} + +// handleNonStreamResponse 处理 Dify blocking 模式响应 +func (a *Adaptor) handleNonStreamResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read Dify response body failed: %w", err) + } + + info.SetFirstResponseTime() + + var difyResp DifyBlockingResponse + if err := json.Unmarshal(body, &difyResp); err != nil { + return nil, fmt.Errorf("parse Dify response failed: %w", err) + } + + // 构建 OpenAI 非流式响应 + chatResp := dto.ChatCompletionResponse{ + ID: fmt.Sprintf("chatcmpl-dify-%s", info.RequestID), + Object: "chat.completion", + Created: time.Now().Unix(), + Model: info.OriginModelName, + Choices: []dto.Choice{ + { + Index: 0, + Message: dto.Message{ + Role: "assistant", + Content: difyResp.Answer, + }, + FinishReason: "stop", + }, + }, + Usage: dto.UsageWithDetails{ + PromptTokens: difyResp.Metadata.Usage.PromptTokens, + CompletionTokens: difyResp.Metadata.Usage.CompletionTokens, + TotalTokens: difyResp.Metadata.Usage.TotalTokens, + }, + } + + // 如果 Dify 未返回用量信息,使用粗略估算 + if chatResp.Usage.TotalTokens == 0 { + chatResp.Usage.CompletionTokens = estimateTokens(difyResp.Answer) + chatResp.Usage.TotalTokens = chatResp.Usage.CompletionTokens + } + + respJSON, err := json.Marshal(chatResp) + if err != nil { + return nil, fmt.Errorf("marshal OpenAI response failed: %w", err) + } + + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(respJSON) + + return &common.Usage{ + PromptTokens: chatResp.Usage.PromptTokens, + CompletionTokens: chatResp.Usage.CompletionTokens, + TotalTokens: chatResp.Usage.TotalTokens, + }, nil +} + +// handleStreamResponse 处理 Dify streaming 模式 SSE 响应 +func (a *Adaptor) handleStreamResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + helper.SetEventStreamHeaders(writer) + defer helper.PingTicker(writer, 15*time.Second)() + + streamStatus := info.StreamStatus + if streamStatus == nil { + streamStatus = common.NewStreamStatus() + info.StreamStatus = streamStatus + } + + chatID := fmt.Sprintf("chatcmpl-dify-%s", info.RequestID) + createdAt := time.Now().Unix() + modelName := info.OriginModelName + + scanner := bufio.NewScanner(resp.Body) + var usage common.Usage + + for scanner.Scan() { + select { + case <-ctx.Done(): + streamStatus.SetEndReason(common.StreamEndReasonClientGone, common.ErrStreamInterrupted) + return &usage, nil + default: + } + + line := scanner.Text() + + // Dify SSE 格式: "data: {...}" + if !strings.HasPrefix(line, "data:") { + continue + } + data, _ := helper.ExtractSSEData(line) + if data == "" { + continue + } + + info.SetFirstResponseTime() + + var event DifyStreamEvent + if err := json.Unmarshal([]byte(data), &event); err != nil { + continue + } + + switch event.Event { + case "message": + // 内容增量 + if event.Answer == "" { + continue + } + + chunk := buildOpenAIStreamChunk(chatID, createdAt, modelName, event.Answer, nil) + chunkJSON, err := json.Marshal(chunk) + if err != nil { + continue + } + if err := helper.WriteSSEData(writer, string(chunkJSON)); err != nil { + streamStatus.SetEndReason(common.StreamEndReasonClientGone, common.ErrStreamInterrupted) + return &usage, nil + } + + case "message_end": + // 流结束,包含用量信息 + usage.PromptTokens = event.Metadata.Usage.PromptTokens + usage.CompletionTokens = event.Metadata.Usage.CompletionTokens + usage.TotalTokens = event.Metadata.Usage.TotalTokens + + // 发送带 finish_reason 的最终 chunk + finishReason := "stop" + chunk := buildOpenAIStreamChunk(chatID, createdAt, modelName, "", &finishReason) + chunkJSON, _ := json.Marshal(chunk) + _ = helper.WriteSSEData(writer, string(chunkJSON)) + + // 发送 [DONE] + _ = helper.WriteSSEData(writer, "[DONE]") + streamStatus.SetEndReason(common.StreamEndReasonDone, nil) + return &usage, nil + + case "error": + streamStatus.SetEndReason(common.StreamEndReasonError, fmt.Errorf("dify error: %s", data)) + return &usage, fmt.Errorf("dify stream error: %s", data) + } + } + + if err := scanner.Err(); err != nil { + streamStatus.SetEndReason(common.StreamEndReasonError, err) + return &usage, fmt.Errorf("read dify stream failed: %w", err) + } + + streamStatus.SetEndReason(common.StreamEndReasonDone, nil) + return &usage, nil +} + +// buildOpenAIStreamChunk 构建 OpenAI 格式的流式响应块 +func buildOpenAIStreamChunk(id string, created int64, model string, content string, finishReason *string) dto.ChatCompletionStreamResponse { + delta := dto.Message{} + if content != "" { + delta.Content = content + } + if finishReason == nil { + delta.Role = "assistant" + } + + return dto.ChatCompletionStreamResponse{ + ID: id, + Object: "chat.completion.chunk", + Created: created, + Model: model, + Choices: []dto.StreamChoice{ + { + Index: 0, + Delta: delta, + FinishReason: finishReason, + }, + }, + } +} + +// estimateTokens 粗略估算 token 数(每 4 个字符约 1 个 token) +func estimateTokens(text string) int { + if text == "" { + return 0 + } + return (len(text) + 3) / 4 +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} + +var _ common.Adaptor = (*Adaptor)(nil) diff --git a/relay/channel/dify/constants.go b/relay/channel/dify/constants.go new file mode 100644 index 0000000..4a00490 --- /dev/null +++ b/relay/channel/dify/constants.go @@ -0,0 +1,4 @@ +package dify + +// ChannelName 渠道名称 +var ChannelName = "Dify" diff --git a/relay/channel/dify/converter.go b/relay/channel/dify/converter.go new file mode 100644 index 0000000..2e793a8 --- /dev/null +++ b/relay/channel/dify/converter.go @@ -0,0 +1,129 @@ +package dify + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/dto" +) + +// DifyRequest Dify Chat Messages 请求体 +type DifyRequest struct { + Inputs map[string]interface{} `json:"inputs"` + Query string `json:"query"` + ResponseMode string `json:"response_mode"` + User string `json:"user"` +} + +// DifyBlockingResponse Dify 非流式(blocking)响应 +type DifyBlockingResponse struct { + Answer string `json:"answer"` + Metadata struct { + Usage struct { + TotalTokens int `json:"total_tokens"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + } `json:"usage"` + } `json:"metadata"` + ConversationID string `json:"conversation_id"` + MessageID string `json:"message_id"` +} + +// DifyStreamEvent Dify 流式 SSE 事件数据 +type DifyStreamEvent struct { + Event string `json:"event"` + Answer string `json:"answer"` + ConversationID string `json:"conversation_id"` + MessageID string `json:"message_id"` + Metadata struct { + Usage struct { + TotalTokens int `json:"total_tokens"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + } `json:"usage"` + } `json:"metadata"` +} + +// convertOpenAIToDify 将 OpenAI Chat Completions 请求转换为 Dify 请求格式。 +// 所有消息被拼接为一个 query 字符串,最后一条 user 消息作为主要查询内容。 +func convertOpenAIToDify(requestBody []byte, info *common.RelayInfo) ([]byte, error) { + var openaiReq dto.GeneralOpenAIRequest + if err := json.Unmarshal(requestBody, &openaiReq); err != nil { + return nil, fmt.Errorf("parse OpenAI request failed: %w", err) + } + + // 拼接所有消息为 query 字符串 + query := flattenMessages(openaiReq.Messages) + if query == "" { + return nil, fmt.Errorf("no messages found in request") + } + + // 确定 response_mode + responseMode := "blocking" + if info.IsStream { + responseMode = "streaming" + } + + // 用户标识 + user := openaiReq.User + if user == "" { + user = "relay-user" + } + + difyReq := DifyRequest{ + Inputs: map[string]interface{}{}, + Query: query, + ResponseMode: responseMode, + User: user, + } + + return json.Marshal(difyReq) +} + +// flattenMessages 将 OpenAI 消息列表拼接为带角色前缀的字符串。 +// 格式: "System: ...\nUser: ...\nAssistant: ..." +// 最后一条 user 消息作为主要查询。 +func flattenMessages(messages []dto.Message) string { + if len(messages) == 0 { + return "" + } + + var parts []string + for _, msg := range messages { + text := extractTextContent(msg.Content) + if text == "" { + continue + } + + prefix := strings.Title(msg.Role) + parts = append(parts, prefix+": "+text) + } + + return strings.Join(parts, "\n") +} + +// extractTextContent 从消息内容中提取文本。 +// Content 可以是 string 或 []ContentPart(多模态)。 +func extractTextContent(content interface{}) string { + switch c := content.(type) { + case string: + return c + case []interface{}: + // 多模态消息,提取文本部分 + var texts []string + for _, part := range c { + if m, ok := part.(map[string]interface{}); ok { + if m["type"] == "text" { + if text, ok := m["text"].(string); ok { + texts = append(texts, text) + } + } + } + } + return strings.Join(texts, " ") + default: + return "" + } +} diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go new file mode 100644 index 0000000..8ddd21d --- /dev/null +++ b/relay/channel/gemini/adaptor.go @@ -0,0 +1,531 @@ +package gemini + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/common" + + loauth "github.com/qianfree/team-api/internal/logic/common/oauth" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/dto" + "github.com/qianfree/team-api/relay/helper" + "github.com/qianfree/team-api/relay/override" +) + +const codeAssistBaseURL = "https://cloudcode-pa.googleapis.com" + +const geminiCLIUserAgent = "GeminiCLI/0.1.5 (Windows; AMD64)" + +// Adaptor Gemini 供应商适配器 +type Adaptor struct { + info *common.RelayInfo +} + +// Init 初始化适配器 +func (a *Adaptor) Init(info *common.RelayInfo) { + a.info = info +} + +// isCodeAssistMode 检测是否为 Code Assist 模式(OAuth + project_id) +func (a *Adaptor) isCodeAssistMode() (*loauth.OAuthKeyData, bool) { + apiKey := a.info.ChannelMeta.ApiKey + if !loauth.IsOAuthKeyData(apiKey) { + return nil, false + } + var oauthData loauth.OAuthKeyData + if err := json.Unmarshal([]byte(apiKey), &oauthData); err != nil { + return nil, false + } + if oauthData.ProjectID != "" { + return &oauthData, true + } + return nil, false +} + +// isCodeAssistActive 检测当前是否为 Code Assist 模式 +func (a *Adaptor) isCodeAssistActive() bool { + _, isCA := a.isCodeAssistMode() + return isCA +} + +// isCodeAssistForcedStream 检测 Code Assist 模式下是否需要强制流式(非流式请求被强制转为流式) +func (a *Adaptor) isCodeAssistForcedStream() bool { + _, isCA := a.isCodeAssistMode() + if !isCA || a.info.IsStream { + return false + } + mode := constant.RelayMode(a.info.RelayMode) + return mode == constant.RelayModeChatCompletions || mode == constant.RelayModeGeminiChat +} + +// getRelayAction 获取当前 relay 模式对应的 Gemini action 名称 +func (a *Adaptor) getRelayAction(info *common.RelayInfo) (string, error) { + switch constant.RelayMode(info.RelayMode) { + case constant.RelayModeChatCompletions, constant.RelayModeGeminiChat: + if info.IsStream { + return "streamGenerateContent", nil + } + return "generateContent", nil + case constant.RelayModeEmbeddings: + return "embedContent", nil + case constant.RelayModeImagesGenerations: + if strings.HasPrefix(info.ChannelMeta.UpstreamModelName, "imagen") { + return "predict", nil + } + return "generateContent", nil + default: + return "", fmt.Errorf("unsupported relay mode for Gemini: %d", info.RelayMode) + } +} + +// GetRequestURL 构建上游请求 URL +func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { + // Code Assist 模式:路由到 cloudcode-pa.googleapis.com(图片生成不支持,需 API Key 渠道) + if _, isCA := a.isCodeAssistMode(); isCA { + if constant.RelayMode(info.RelayMode) == constant.RelayModeImagesGenerations { + return "", fmt.Errorf("Code Assist OAuth 不支持图片生成,请使用 API Key 渠道") + } + action, err := a.getRelayAction(info) + if err != nil { + return "", err + } + if action == "generateContent" { + action = "streamGenerateContent" + } + url := fmt.Sprintf("%s/v1internal:%s", codeAssistBaseURL, action) + if action == "streamGenerateContent" { + url += "?alt=sse" + } + return url, nil + } + + // 标准模式(API Key 或 OAuth 无 project_id) + baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") + model := info.ChannelMeta.UpstreamModelName + + switch constant.RelayMode(info.RelayMode) { + case constant.RelayModeChatCompletions, constant.RelayModeGeminiChat: + if info.IsStream { + return fmt.Sprintf("%s/v1beta/models/%s:streamGenerateContent?alt=sse", baseURL, model), nil + } + return fmt.Sprintf("%s/v1beta/models/%s:generateContent", baseURL, model), nil + case constant.RelayModeEmbeddings: + return fmt.Sprintf("%s/v1beta/models/%s:embedContent", baseURL, model), nil + case constant.RelayModeImagesGenerations: + if strings.HasPrefix(model, "imagen") { + return fmt.Sprintf("%s/v1beta/models/%s:predict", baseURL, model), nil + } + return fmt.Sprintf("%s/v1beta/models/%s:generateContent", baseURL, model), nil + default: + return "", fmt.Errorf("unsupported relay mode for Gemini: %d", info.RelayMode) + } +} + +// SetupRequestHeader 设置上游请求头 +func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) error { + apiKey := info.ChannelMeta.ApiKey + + // OAuth 模式:使用 Bearer token 而非 x-goog-api-key + if loauth.IsOAuthKeyData(apiKey) { + var oauthData loauth.OAuthKeyData + if err := json.Unmarshal([]byte(apiKey), &oauthData); err == nil { + header.Set("Authorization", "Bearer "+oauthData.AccessToken) + } + // Code Assist 模式:添加 Gemini CLI User-Agent + if oauthData.ProjectID != "" { + header.Set("User-Agent", geminiCLIUserAgent) + } + } else { + header.Set("x-goog-api-key", apiKey) + } + header.Set("Content-Type", "application/json") + header.Set("Accept", "application/json") + + if info.RequestHeaders != nil { + for _, h := range []string{"X-Request-Id"} { + if v := info.RequestHeaders.Get(h); v != "" { + header.Set(h, v) + } + } + } + + return nil +} + +// ConvertRequest 根据入站格式转换请求体为 Gemini 格式 +func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { + // 图片生成模式:Imagen 走 predict 格式,其他走 generateContent + ResponseModalities + if constant.RelayMode(info.RelayMode) == constant.RelayModeImagesGenerations { + if strings.HasPrefix(info.ChannelMeta.UpstreamModelName, "imagen") { + return convertImageRequest(requestBody, info) + } + return convertImageRequestToChat(requestBody, info) + } + + var converted io.Reader + switch info.InboundFormat { + case constant.RelayFormatGemini: + // Gemini 原生格式通过 URL 路径控制流式,body 中的 "stream" 字段会导致上游报错 + cleaned := helper.StripStreamField(requestBody) + converted = bytes.NewReader(cleaned) + case constant.RelayFormatOpenAI: + r, err := ConvertOpenAIToGemini(requestBody, info) + if err != nil { + return nil, err + } + converted = r + case constant.RelayFormatClaude: + r, err := ConvertClaudeToGemini(requestBody, info) + if err != nil { + return nil, err + } + converted = r + case constant.RelayFormatResponses: + r, err := ConvertResponsesToGemini(requestBody, info) + if err != nil { + return nil, err + } + converted = r + default: + r, err := ConvertOpenAIToGemini(requestBody, info) + if err != nil { + return nil, err + } + converted = r + } + + // Thinking 后缀路由 + if info.ThinkingEnabled || info.ReasoningEffort != "" { + converted = injectGeminiThinking(converted, info) + } + + return converted, nil +} + +// injectGeminiThinking 注入 Gemini thinking 配置 +func injectGeminiThinking(r io.Reader, info *common.RelayInfo) io.Reader { + body, err := io.ReadAll(r) + if err != nil { + return r + } + var req map[string]json.RawMessage + if err := json.Unmarshal(body, &req); err != nil { + return bytes.NewReader(body) + } + + if info.ThinkingEnabled { + // -thinking: 设置 thoughtBudget + var maxTokens int + if mt, ok := req["maxOutputTokens"]; ok { + _ = json.Unmarshal(mt, &maxTokens) + } + if maxTokens < 128 { + maxTokens = 8192 + } + budget := maxTokens * 80 / 100 + if budget < 128 { + budget = 128 + } + req["thinkingConfig"] = json.RawMessage(fmt.Sprintf(`{"thoughtBudget":%d,"includeThoughts":true}`, budget)) + } else if info.ReasoningEffort != "" { + // effort 后缀:设置 thinkingLevel + req["thinkingConfig"] = json.RawMessage(fmt.Sprintf(`{"thinkingLevel":"%s","includeThoughts":true}`, + strings.ToUpper(info.ReasoningEffort))) + } + + result, err := json.Marshal(req) + if err != nil { + return bytes.NewReader(body) + } + return bytes.NewReader(result) +} + +// wrapCodeAssistBody 将 Gemini 请求体包装为 Code Assist 格式 +// Code Assist 格式:{"model":"...", "project":"...", "request":{原始body}} +func (a *Adaptor) wrapCodeAssistBody(body io.Reader, info *common.RelayInfo, oauthData *loauth.OAuthKeyData) (io.Reader, error) { + bodyBytes, err := io.ReadAll(body) + if err != nil { + return nil, fmt.Errorf("read request body: %w", err) + } + + wrapped := map[string]any{ + "model": info.ChannelMeta.UpstreamModelName, + "project": oauthData.ProjectID, + "request": json.RawMessage(bodyBytes), + } + + result, err := json.Marshal(wrapped) + if err != nil { + return nil, fmt.Errorf("marshal wrapped request: %w", err) + } + return bytes.NewReader(result), nil +} + +// DoRequest 发送请求到上游 +func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + reqURL, err := a.GetRequestURL(info) + if err != nil { + return nil, err + } + + // Code Assist 模式:包装请求体(图片生成跳过,已在 GetRequestURL 拦截) + if oauthData, isCA := a.isCodeAssistMode(); isCA { + wrappedBody, err := a.wrapCodeAssistBody(requestBody, info, oauthData) + if err != nil { + return nil, fmt.Errorf("wrap code assist body failed: %w", err) + } + requestBody = wrappedBody + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + if err := a.SetupRequestHeader(httpReq.Header, info); err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + + if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { + override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) + } + + timeout := info.ChannelMeta.Settings.TimeoutSeconds + if timeout <= 0 { + timeout = 60 + } + + client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) + + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("send request failed: %w", err) + } + + return resp, nil +} + +// DoResponse 处理上游响应,根据客户端格式分发 +func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + // 图片生成响应:Imagen 走 predict 响应格式,其他走 generateContent 响应格式 + if constant.RelayMode(info.RelayMode) == constant.RelayModeImagesGenerations { + if strings.HasPrefix(info.ChannelMeta.UpstreamModelName, "imagen") { + return handleImagenResponse(ctx, resp, info, writer) + } + return handleBananaImageResponse(ctx, resp, writer) + } + + // Code Assist 强制流式:聚合 SSE 为非流式响应 + if a.isCodeAssistForcedStream() { + return a.handleCodeAssistAggregatedStream(ctx, resp, info, writer) + } + + clientFormat := info.GetOriginalClientFormat() + + switch clientFormat { + case constant.RelayFormatGemini: + return a.handleGeminiNativeResponse(ctx, resp, info, writer) + case constant.RelayFormatOpenAI: + if info.IsStream { + return a.handleStreamToOpenAI(ctx, resp, info, writer) + } + return a.handleNonStreamToOpenAI(ctx, resp, info, writer) + default: + if info.IsStream { + return a.handleStreamToOpenAI(ctx, resp, info, writer) + } + return a.handleNonStreamToOpenAI(ctx, resp, info, writer) + } +} + +// handleCodeAssistAggregatedStream 聚合 Code Assist 强制流式响应为非流式 +func (a *Adaptor) handleCodeAssistAggregatedStream(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + body = unwrapCodeAssistData(body) + clientFormat := info.GetOriginalClientFormat() + if clientFormat == constant.RelayFormatGemini { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(resp.StatusCode) + _, _ = writer.Write(body) + } else { + writeGeminiErrorAsOpenAI(writer, body, resp.StatusCode) + } + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + // 读取所有 SSE chunks 并聚合 + scanner := bufio.NewScanner(resp.Body) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 10*1024*1024) + + var ( + aggregated dto.GeminiChatResponse + totalUsage dto.GeminiUsageMetadata + modelName string + textParts []string + thoughtText []string + lastFinish string + toolCalls []dto.ToolCall + toolCallIdx int + ) + + for scanner.Scan() { + select { + case <-ctx.Done(): + return &common.Usage{}, common.ErrStreamInterrupted + default: + } + + line := scanner.Text() + if line == "" { + continue + } + if !strings.HasPrefix(line, "data:") { + continue + } + + data, _ := helper.ExtractSSEData(line) + if data == "" || data == "[DONE]" { + if data == "[DONE]" { + break + } + continue + } + + info.SetFirstResponseTime() + + rawData := []byte(data) + rawData = unwrapCodeAssistData(rawData) + + var chunk dto.GeminiChatResponse + if err := json.Unmarshal(rawData, &chunk); err != nil { + continue + } + + // 收集 usage + if chunk.UsageMetadata != nil { + totalUsage = *chunk.UsageMetadata + } + if chunk.ModelName != "" { + modelName = chunk.ModelName + } + + // 检查安全过滤 + if chunk.PromptFeedback != nil && chunk.PromptFeedback.BlockReason != "" { + return nil, constant.NewRequestError( + fmt.Sprintf("request blocked by Gemini safety filter: %s", chunk.PromptFeedback.BlockReason), nil, + ) + } + + // 聚合 candidates + for _, candidate := range chunk.Candidates { + if candidate.FinishReason != "" { + lastFinish = candidate.FinishReason + } + if candidate.Content == nil { + continue + } + for _, part := range candidate.Content.Parts { + isThought := part.Thought != nil && *part.Thought + if part.Text != "" { + if isThought { + thoughtText = append(thoughtText, part.Text) + } else { + textParts = append(textParts, part.Text) + } + } + if part.FunctionCall != nil { + toolCalls = appendToolCall(toolCalls, &toolCallIdx, part) + } + } + } + } + + // 构建 Gemini 响应 + var fullText string + if len(textParts) > 0 { + fullText = strings.Join(textParts, "") + } + + var thoughtFullText string + if len(thoughtText) > 0 { + thoughtFullText = strings.Join(thoughtText, "") + } + + content := &dto.GeminiContent{Role: "model"} + if fullText != "" { + content.Parts = append(content.Parts, dto.GeminiPart{Text: fullText}) + } + if thoughtFullText != "" { + t := true + content.Parts = append(content.Parts, dto.GeminiPart{Text: thoughtFullText, Thought: &t}) + } + for _, tc := range toolCalls { + content.Parts = append(content.Parts, dto.GeminiPart{FunctionCall: &dto.GeminiFunctionCall{ + FunctionName: tc.Function.Name, + Arguments: tc.Function.Arguments, + }}) + } + + aggregated.Candidates = []dto.GeminiCandidate{{ + Content: content, + FinishReason: lastFinish, + }} + aggregated.UsageMetadata = &totalUsage + aggregated.ModelName = modelName + + // 按客户端格式返回 + clientFormat := info.GetOriginalClientFormat() + switch clientFormat { + case constant.RelayFormatGemini: + respBody, _ := json.Marshal(aggregated) + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(respBody) + default: + openaiResp := geminiToOpenAIResponse(&aggregated, info) + respBody, _ := json.Marshal(openaiResp) + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(respBody) + } + + return geminiUsageToCommon(&totalUsage), nil +} + +// appendToolCall 添加工具调用到列表 +func appendToolCall(toolCalls []dto.ToolCall, idx *int, part dto.GeminiPart) []dto.ToolCall { + if part.FunctionCall == nil { + return toolCalls + } + argsJSON, _ := json.Marshal(part.FunctionCall.Arguments) + tc := dto.ToolCall{ + ID: fmt.Sprintf("call_%d", *idx), + Type: "function", + Index: *idx, + Function: dto.FunctionCall{ + Name: part.FunctionCall.FunctionName, + Arguments: string(argsJSON), + }, + } + *idx++ + return append(toolCalls, tc) +} + +// GetChannelName 返回渠道名称 +func (a *Adaptor) GetChannelName() string { + return "Gemini" +} + +// 确保接口实现 +var _ common.Adaptor = (*Adaptor)(nil) diff --git a/relay/channel/gemini/converter.go b/relay/channel/gemini/converter.go new file mode 100644 index 0000000..368e834 --- /dev/null +++ b/relay/channel/gemini/converter.go @@ -0,0 +1,686 @@ +package gemini + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/qianfree/team-api/relay/channel/openai" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/dto" +) + +// ConvertOpenAIToGemini 将 OpenAI 格式请求转换为 Gemini API 格式。 +func ConvertOpenAIToGemini(requestBody []byte, info *common.RelayInfo) (io.Reader, error) { + var openaiReq dto.GeneralOpenAIRequest + if err := json.Unmarshal(requestBody, &openaiReq); err != nil { + return nil, fmt.Errorf("parse openai request: %w", err) + } + + geminiReq := dto.GeminiChatRequest{ + Contents: make([]dto.GeminiContent, 0, len(openaiReq.Messages)), + GenerationConfig: &dto.GeminiGenerationConfig{ + Temperature: openaiReq.Temperature, + TopP: openaiReq.TopP, + }, + } + + // topK + if openaiReq.TopK != nil && *openaiReq.TopK > 0 { + v := float64(*openaiReq.TopK) + geminiReq.GenerationConfig.TopK = &v + } + + // maxOutputTokens + maxTokens := 0 + if openaiReq.MaxTokens != nil { + maxTokens = int(*openaiReq.MaxTokens) + } else if openaiReq.MaxCompletionTokens != nil { + maxTokens = int(*openaiReq.MaxCompletionTokens) + } + if maxTokens > 0 { + geminiReq.GenerationConfig.MaxOutputTokens = o2gUintPtr(uint(maxTokens)) + } + + // stopSequences + if stops := o2gParseStopSequences(openaiReq.Stop); len(stops) > 0 { + if len(stops) > 5 { + stops = stops[:5] + } + geminiReq.GenerationConfig.StopSequences = stops + } + + // seed + if openaiReq.Seed != nil { + geminiReq.GenerationConfig.Seed = o2gInt64Ptr(int64(*openaiReq.Seed)) + } + + // presencePenalty + if openaiReq.PresencePenalty != nil { + geminiReq.GenerationConfig.PresencePenalty = openaiReq.PresencePenalty + } + + // frequencyPenalty + if openaiReq.FrequencyPenalty != nil { + geminiReq.GenerationConfig.FrequencyPenalty = openaiReq.FrequencyPenalty + } + + // n → candidateCount + if openaiReq.N != nil && *openaiReq.N > 0 { + n := *openaiReq.N + geminiReq.GenerationConfig.CandidateCount = &n + } + + // logprobs + if openaiReq.LogProbs != nil && *openaiReq.LogProbs { + geminiReq.GenerationConfig.ResponseLogprobs = openaiReq.LogProbs + } + if openaiReq.TopLogProbs != nil && *openaiReq.TopLogProbs > 0 { + v := *openaiReq.TopLogProbs + geminiReq.GenerationConfig.Logprobs = &v + } + + // serviceTier + if openaiReq.ServiceTier != "" { + geminiReq.ServiceTier = openaiReq.ServiceTier + } + + // reasoning_effort → thinkingConfig + if openaiReq.ReasoningEffort != "" { + geminiReq.GenerationConfig.ThinkingConfig = o2gConvertReasoningEffort(openaiReq.ReasoningEffort) + } + + // response_format → responseMimeType + responseSchema + if openaiReq.ResponseFormat != nil { + if openaiReq.ResponseFormat.Type == "json_schema" || openaiReq.ResponseFormat.Type == "json_object" { + geminiReq.GenerationConfig.ResponseMimeType = "application/json" + if openaiReq.ResponseFormat.JSONSchema != nil { + geminiReq.GenerationConfig.ResponseSchema = o2gConvertResponseSchema(openaiReq.ResponseFormat.JSONSchema) + } + } + } + + // Chat 内生图:为支持图片输出的模型注入 ResponseModalities + if isGeminiImageModel(info.ChannelMeta.UpstreamModelName) { + geminiReq.GenerationConfig.ResponseModalities = []string{"TEXT", "IMAGE"} + // 透传 imageConfig(宽高比、分辨率) + if openaiReq.ImageConfig != nil { + geminiReq.GenerationConfig.ImageConfig = &dto.GeminiImageConfig{ + AspectRatio: openaiReq.ImageConfig.AspectRatio, + ImageSize: openaiReq.ImageConfig.ImageSize, + } + } + } + + // 默认安全设置(较宽松,避免过度过滤) + geminiReq.SafetySettings = []dto.GeminiSafetySetting{ + {Category: "HARM_CATEGORY_HARASSMENT", Threshold: "BLOCK_ONLY_HIGH"}, + {Category: "HARM_CATEGORY_HATE_SPEECH", Threshold: "BLOCK_ONLY_HIGH"}, + {Category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", Threshold: "BLOCK_ONLY_HIGH"}, + {Category: "HARM_CATEGORY_DANGEROUS_CONTENT", Threshold: "BLOCK_ONLY_HIGH"}, + } + + // tools 转换 + if len(openaiReq.Tools) > 0 { + geminiTools, err := o2gConvertTools(openaiReq.Tools) + if err != nil { + return nil, fmt.Errorf("convert tools: %w", err) + } + if len(geminiTools) > 0 { + toolsJSON, err := json.Marshal(geminiTools) + if err != nil { + return nil, fmt.Errorf("marshal gemini tools: %w", err) + } + geminiReq.Tools = toolsJSON + } + + // tool_choice 转换 + if openaiReq.ToolChoice != nil { + geminiReq.ToolConfig = o2gConvertToolChoice(openaiReq.ToolChoice) + } + } + + // messages 转换 + toolCallIDs := make(map[string]string) // toolCallID -> functionName + var systemParts []dto.GeminiPart + + for _, msg := range openaiReq.Messages { + switch msg.Role { + case "system", "developer": + text := o2gExtractText(msg.Content) + if text != "" { + systemParts = append(systemParts, dto.GeminiPart{Text: text}) + } + + case "user": + parts := o2gConvertUserParts(msg.Content) + if len(parts) > 0 { + geminiReq.Contents = append(geminiReq.Contents, dto.GeminiContent{ + Role: "user", + Parts: parts, + }) + } + + case "assistant": + parts := o2gConvertAssistantParts(msg, toolCallIDs) + if len(parts) > 0 { + geminiReq.Contents = append(geminiReq.Contents, dto.GeminiContent{ + Role: "model", + Parts: parts, + }) + } + + case "tool": + // 确保最后一个 content 是 user(Gemini 要求 functionResponse 在 user content 中) + if len(geminiReq.Contents) == 0 || geminiReq.Contents[len(geminiReq.Contents)-1].Role == "model" { + geminiReq.Contents = append(geminiReq.Contents, dto.GeminiContent{Role: "user"}) + } + lastIdx := len(geminiReq.Contents) - 1 + + name := msg.Name + if name == "" { + name = toolCallIDs[msg.ToolCallID] + } + + contentStr := o2gExtractText(msg.Content) + var response any = contentStr + if contentStr != "" { + var parsed any + if json.Unmarshal([]byte(contentStr), &parsed) == nil { + response = parsed + } + } + + geminiReq.Contents[lastIdx].Parts = append(geminiReq.Contents[lastIdx].Parts, dto.GeminiPart{ + FunctionResponse: &dto.GeminiFunctionResponse{ + Name: name, + Response: response, + }, + }) + } + } + + // system instructions + if len(systemParts) > 0 { + geminiReq.SystemInstruction = &dto.GeminiContent{ + Parts: systemParts, + } + } + + result, err := json.Marshal(geminiReq) + if err != nil { + return nil, fmt.Errorf("marshal gemini request: %w", err) + } + return bytes.NewReader(result), nil +} + +// ConvertClaudeToGemini 将 Claude 格式请求转换为 Gemini 格式。 +// 链式转换:Claude → OpenAI → Gemini +func ConvertClaudeToGemini(requestBody []byte, info *common.RelayInfo) (io.Reader, error) { + openaiReader, err := openai.ConvertClaudeToOpenAI(requestBody, info) + if err != nil { + return nil, fmt.Errorf("claude to openai: %w", err) + } + openaiBody, err := io.ReadAll(openaiReader) + if err != nil { + return nil, fmt.Errorf("read openai intermediate: %w", err) + } + return ConvertOpenAIToGemini(openaiBody, info) +} + +// ConvertResponsesToGemini 将 Responses API 格式请求转换为 Gemini 格式。 +// 链式转换:Responses → OpenAI → Gemini +func ConvertResponsesToGemini(requestBody []byte, info *common.RelayInfo) (io.Reader, error) { + openaiReader, err := openai.ConvertResponsesToOpenAI(requestBody, info) + if err != nil { + return nil, fmt.Errorf("responses to openai: %w", err) + } + openaiBody, err := io.ReadAll(openaiReader) + if err != nil { + return nil, fmt.Errorf("read openai intermediate: %w", err) + } + return ConvertOpenAIToGemini(openaiBody, info) +} + +// ===== OpenAI → Gemini 内部工具函数 ===== + +// o2gGeminiTool Gemini 工具定义(内部序列化用) +type o2gGeminiTool struct { + FunctionDeclarations []o2gFunctionDecl `json:"functionDeclarations,omitempty"` +} + +// o2gFunctionDecl Gemini 函数声明 +type o2gFunctionDecl struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters any `json:"parameters,omitempty"` +} + +func o2gConvertTools(tools []dto.Tool) ([]o2gGeminiTool, error) { + var funcDecls []o2gFunctionDecl + for _, t := range tools { + if t.Type != "function" { + continue + } + cleanedParams := o2gCleanParams(t.Function.Parameters) + funcDecls = append(funcDecls, o2gFunctionDecl{ + Name: t.Function.Name, + Description: t.Function.Description, + Parameters: cleanedParams, + }) + } + if len(funcDecls) == 0 { + return nil, nil + } + return []o2gGeminiTool{{FunctionDeclarations: funcDecls}}, nil +} + +// o2gCleanParams 清理函数参数,移除 Gemini 不支持的字段 +func o2gCleanParams(params any) any { + if params == nil { + return nil + } + m, ok := params.(map[string]any) + if !ok { + return params + } + cleaned := make(map[string]any) + for k, v := range m { + switch k { + case "type", "description", "properties", "required", "items", + "anyOf", "default", "enum", "format", "maxLength", "minLength", + "maximum", "minimum", "pattern", "title", "nullable", + "maxItems", "minItems", "maxProperties", "minProperties", "example": + cleaned[k] = v + } + } + return cleaned +} + +func o2gConvertToolChoice(toolChoice any) any { + if toolChoice == nil { + return nil + } + switch v := toolChoice.(type) { + case string: + switch v { + case "auto": + return map[string]any{"functionCallingConfig": map[string]any{"mode": "AUTO"}} + case "none": + return map[string]any{"functionCallingConfig": map[string]any{"mode": "NONE"}} + case "required": + return map[string]any{"functionCallingConfig": map[string]any{"mode": "ANY"}} + default: + return map[string]any{"functionCallingConfig": map[string]any{"mode": "AUTO"}} + } + case map[string]any: + if v["type"] == "function" { + config := map[string]any{ + "functionCallingConfig": map[string]any{"mode": "ANY"}, + } + if fn, ok := v["function"].(map[string]any); ok { + if name, ok := fn["name"].(string); ok && name != "" { + config["functionCallingConfig"].(map[string]any)["allowedFunctionNames"] = []string{name} + } + } + return config + } + } + return nil +} + +func o2gParseStopSequences(stop any) []string { + if stop == nil { + return nil + } + switch v := stop.(type) { + case string: + if v != "" { + return []string{v} + } + case []string: + return v + case []any: + var result []string + for _, item := range v { + if s, ok := item.(string); ok && s != "" { + result = append(result, s) + } + } + return result + } + return nil +} + +func o2gExtractText(content any) string { + switch v := content.(type) { + case string: + return v + case []any: + var parts []string + for _, item := range v { + if m, ok := item.(map[string]any); ok { + if m["type"] == "text" { + if t, ok := m["text"].(string); ok { + parts = append(parts, t) + } + } + } + } + return strings.Join(parts, "\n") + default: + return "" + } +} + +func o2gConvertUserParts(content any) []dto.GeminiPart { + switch v := content.(type) { + case string: + if v == "" { + return nil + } + return o2gParseMarkdownImages(v) + case []any: + var parts []dto.GeminiPart + for _, item := range v { + m, ok := item.(map[string]any) + if !ok { + continue + } + switch m["type"] { + case "text": + if text, ok := m["text"].(string); ok && text != "" { + parts = append(parts, o2gParseMarkdownImages(text)...) + } + case "image_url": + if imageURL, ok := m["image_url"].(map[string]any); ok { + if url, ok := imageURL["url"].(string); ok { + if mimeType, data, ok := o2gParseDataURL(url); ok { + parts = append(parts, dto.GeminiPart{ + InlineData: &dto.GeminiInlineData{ + MimeType: mimeType, + Data: data, + }, + }) + } + } + } + case "input_audio": + if audioData, ok := m["input_audio"].(map[string]any); ok { + if data, ok := audioData["data"].(string); ok { + mimeType := "audio/wav" + if fmt, ok := audioData["format"].(string); ok && fmt != "" { + mimeType = "audio/" + fmt + } + parts = append(parts, dto.GeminiPart{ + InlineData: &dto.GeminiInlineData{ + MimeType: mimeType, + Data: data, + }, + }) + } + } + case "file": + if fileData, ok := m["file"].(map[string]any); ok { + if url, ok := fileData["url"].(string); ok { + mimeType := "application/octet-stream" + if mt, ok := fileData["mime_type"].(string); ok && mt != "" { + mimeType = mt + } + parts = append(parts, dto.GeminiPart{ + FileData: &dto.GeminiFileData{ + MimeType: mimeType, + FileURI: url, + }, + }) + } + } + } + } + return parts + default: + return nil + } +} + +func o2gParseMarkdownImages(text string) []dto.GeminiPart { + var parts []dto.GeminiPart + + for { + startIdx := strings.Index(text, "![") + if startIdx == -1 { + break + } + bracketIdx := strings.Index(text[startIdx:], "](data:") + if bracketIdx == -1 { + break + } + bracketIdx += startIdx + closeIdx := strings.Index(text[bracketIdx+2:], ")") + if closeIdx == -1 { + break + } + closeIdx += bracketIdx + 2 + + if startIdx > 0 { + before := text[:startIdx] + if before != "" { + parts = append(parts, dto.GeminiPart{Text: before}) + } + } + + dataURL := text[bracketIdx+2 : closeIdx] + if mimeType, data, ok := o2gParseDataURL(dataURL); ok { + parts = append(parts, dto.GeminiPart{ + InlineData: &dto.GeminiInlineData{MimeType: mimeType, Data: data}, + }) + } + + text = text[closeIdx+1:] + } + + if len(parts) == 0 { + if text != "" { + return []dto.GeminiPart{{Text: text}} + } + return nil + } + if text != "" { + parts = append(parts, dto.GeminiPart{Text: text}) + } + return parts +} + +func o2gParseDataURL(dataURL string) (mimeType, data string, ok bool) { + if len(dataURL) < 11 || dataURL[:5] != "data:" { + return "", "", false + } + rest := dataURL[5:] + semiIdx := strings.Index(rest, ";") + if semiIdx == -1 { + return "", "", false + } + mimeType = rest[:semiIdx] + afterSemi := rest[semiIdx+1:] + if len(afterSemi) < 7 || afterSemi[:7] != "base64," { + return "", "", false + } + data = afterSemi[7:] + return mimeType, data, true +} + +func o2gConvertAssistantParts(msg dto.Message, toolCallIDs map[string]string) []dto.GeminiPart { + var parts []dto.GeminiPart + + text := o2gExtractText(msg.Content) + if text != "" { + parts = append(parts, dto.GeminiPart{Text: text}) + } + + // reasoning_content → thought (for multi-turn) + if msg.ReasoningContent != nil && *msg.ReasoningContent != "" { + t := true + parts = append(parts, dto.GeminiPart{ + Text: *msg.ReasoningContent, + Thought: &t, + }) + } + + for _, tc := range msg.ToolCalls { + args := map[string]any{} + if tc.Function.Arguments != "" { + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { + args = map[string]any{"raw": tc.Function.Arguments} + } + } + parts = append(parts, dto.GeminiPart{ + FunctionCall: &dto.GeminiFunctionCall{ + FunctionName: tc.Function.Name, + Arguments: args, + }, + }) + toolCallIDs[tc.ID] = tc.Function.Name + } + + return parts +} + +func o2gUintPtr(v uint) *uint { return &v } +func o2gInt64Ptr(v int64) *int64 { return &v } + +// o2gConvertReasoningEffort 将 OpenAI reasoning_effort 转换为 Gemini thinkingConfig +func o2gConvertReasoningEffort(effort string) *dto.GeminiThinkingConfig { + var budget int + var level string + switch effort { + case "low": + budget = 1024 + level = "LOW" + case "medium": + budget = 8192 + level = "MEDIUM" + case "high": + budget = 32768 + level = "HIGH" + default: + budget = 8192 + level = "MEDIUM" + } + return &dto.GeminiThinkingConfig{ + IncludeThoughts: true, + ThoughtBudget: &budget, + ThinkingLevel: level, + } +} + +// o2gConvertResponseSchema 将 OpenAI 的 JSON Schema 转换为 Gemini Schema 格式。 +// OpenAI 使用小写类型名 ("string"),Gemini 使用大写 ("STRING")。 +// 还处理 OpenAI 的 json_schema 包装格式:{"type":"json_schema","json_schema":{"schema":{...}}} +func o2gConvertResponseSchema(schema any) any { + if schema == nil { + return nil + } + + // 处理 json_schema 包装格式 + if m, ok := schema.(map[string]any); ok { + // 检查是否是 {"type":"json_schema","json_schema":{"schema":{...}}} 格式 + if js, ok := m["json_schema"].(map[string]any); ok { + if innerSchema, ok := js["schema"]; ok { + return o2gConvertSchemaMap(innerSchema) + } + } + return o2gConvertSchemaMap(m) + } + + return schema +} + +// o2gConvertSchemaMap 递归转换 JSON Schema 类型名为 Gemini 格式 +func o2gConvertSchemaMap(schema any) any { + m, ok := schema.(map[string]any) + if !ok { + return schema + } + + result := make(map[string]any, len(m)) + for k, v := range m { + switch k { + case "type": + if s, ok := v.(string); ok { + result["type"] = o2gMapSchemaType(s) + } else { + result[k] = v + } + case "properties": + if props, ok := v.(map[string]any); ok { + converted := make(map[string]any, len(props)) + for pk, pv := range props { + converted[pk] = o2gConvertSchemaMap(pv) + } + result["properties"] = converted + } else { + result[k] = v + } + case "items": + result["items"] = o2gConvertSchemaMap(v) + case "anyOf", "oneOf", "allOf": + if arr, ok := v.([]any); ok { + converted := make([]any, len(arr)) + for i, item := range arr { + converted[i] = o2gConvertSchemaMap(item) + } + result[k] = converted + } else { + result[k] = v + } + default: + result[k] = v + } + } + return result +} + +// o2gMapSchemaType 将 JSON Schema 类型名映射为 Gemini Schema 类型名 +func o2gMapSchemaType(t string) string { + switch t { + case "string": + return "STRING" + case "number": + return "NUMBER" + case "integer": + return "INTEGER" + case "boolean": + return "BOOLEAN" + case "object": + return "OBJECT" + case "array": + return "ARRAY" + case "null": + return "NULL" + default: + return t + } +} + +// isGeminiImageModel 检测模型是否支持 Chat 内生图,需要注入 ResponseModalities +func isGeminiImageModel(model string) bool { + imageModels := []string{ + "gemini-2.0-flash-exp-image-generation", + "gemini-2.0-flash-exp", + "gemini-3-pro-image-preview", + "gemini-2.5-flash-image", + "gemini-3.1-flash-image-preview", + // Nano Banana 别名 + "nano-banana", + "nano-banana-2-preview", + "nano-banana-pro-preview", + } + for _, m := range imageModels { + if model == m || strings.HasPrefix(model, m+"-") { + return true + } + } + return false +} diff --git a/relay/channel/gemini/converter_test.go b/relay/channel/gemini/converter_test.go new file mode 100644 index 0000000..28dddf1 --- /dev/null +++ b/relay/channel/gemini/converter_test.go @@ -0,0 +1,807 @@ +package gemini + +import ( + "encoding/json" + "io" + "testing" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/dto" +) + +func makeTestInfo() *common.RelayInfo { + return &common.RelayInfo{ + ChannelMeta: &common.ChannelMeta{ + UpstreamModelName: "gemini-2.5-flash", + }, + InboundFormat: "openai", + } +} + +func TestConvertOpenAIToGemini_SimpleChat(t *testing.T) { + info := makeTestInfo() + + openaiReq := dto.GeneralOpenAIRequest{ + Model: "gemini-2.5-flash", + Messages: []dto.Message{ + {Role: "system", Content: "You are helpful."}, + {Role: "user", Content: "Hello"}, + {Role: "assistant", Content: "Hi there!"}, + {Role: "user", Content: "How are you?"}, + }, + } + + body, _ := json.Marshal(openaiReq) + reader, err := ConvertOpenAIToGemini(body, info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var geminiReq dto.GeminiChatRequest + data, _ := io.ReadAll(reader) + if err := json.Unmarshal(data, &geminiReq); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if geminiReq.SystemInstruction == nil { + t.Fatal("expected SystemInstruction to be set") + } + if len(geminiReq.SystemInstruction.Parts) != 1 || geminiReq.SystemInstruction.Parts[0].Text != "You are helpful." { + t.Errorf("expected system instruction 'You are helpful.', got %v", geminiReq.SystemInstruction) + } + + if len(geminiReq.Contents) != 3 { + t.Fatalf("expected 3 contents, got %d", len(geminiReq.Contents)) + } + if geminiReq.Contents[0].Role != "user" { + t.Errorf("expected first content role 'user', got %s", geminiReq.Contents[0].Role) + } + if geminiReq.Contents[1].Role != "model" { + t.Errorf("expected second content role 'model', got %s", geminiReq.Contents[1].Role) + } + if geminiReq.Contents[2].Role != "user" { + t.Errorf("expected third content role 'user', got %s", geminiReq.Contents[2].Role) + } +} + +func TestConvertOpenAIToGemini_ToolUse(t *testing.T) { + info := makeTestInfo() + + openaiReq := dto.GeneralOpenAIRequest{ + Model: "gemini-2.5-flash", + Messages: []dto.Message{ + {Role: "user", Content: "What's the weather?"}, + { + Role: "assistant", + ToolCalls: []dto.ToolCall{ + { + ID: "call_123", + Type: "function", + Function: dto.FunctionCall{ + Name: "get_weather", + Arguments: `{"location":"NYC"}`, + }, + }, + }, + }, + {Role: "tool", ToolCallID: "call_123", Content: "Sunny, 72°F"}, + }, + } + + body, _ := json.Marshal(openaiReq) + reader, err := ConvertOpenAIToGemini(body, info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var geminiReq dto.GeminiChatRequest + data, _ := io.ReadAll(reader) + if err := json.Unmarshal(data, &geminiReq); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if len(geminiReq.Contents) != 3 { + t.Fatalf("expected 3 contents, got %d", len(geminiReq.Contents)) + } + + assistantContent := geminiReq.Contents[1] + if assistantContent.Role != "model" { + t.Errorf("expected role 'model', got %s", assistantContent.Role) + } + hasFunctionCall := false + for _, part := range assistantContent.Parts { + if part.FunctionCall != nil { + hasFunctionCall = true + if part.FunctionCall.FunctionName != "get_weather" { + t.Errorf("expected function name 'get_weather', got %s", part.FunctionCall.FunctionName) + } + } + } + if !hasFunctionCall { + t.Error("expected function call in assistant content") + } + + toolContent := geminiReq.Contents[2] + if toolContent.Role != "user" { + t.Errorf("expected role 'user' for tool result, got %s", toolContent.Role) + } + hasFunctionResponse := false + for _, part := range toolContent.Parts { + if part.FunctionResponse != nil { + hasFunctionResponse = true + } + } + if !hasFunctionResponse { + t.Error("expected function response in tool content") + } +} + +func TestConvertOpenAIToGemini_GenerationConfig(t *testing.T) { + info := makeTestInfo() + + maxTokens := 1024 + temp := 0.7 + presence := 0.5 + frequency := 0.3 + n := 2 + openaiReq := dto.GeneralOpenAIRequest{ + Model: "gemini-2.5-flash", + MaxTokens: &maxTokens, + Temperature: &temp, + PresencePenalty: &presence, + FrequencyPenalty: &frequency, + N: &n, + Messages: []dto.Message{ + {Role: "user", Content: "Hello"}, + }, + } + + body, _ := json.Marshal(openaiReq) + reader, err := ConvertOpenAIToGemini(body, info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var geminiReq dto.GeminiChatRequest + data, _ := io.ReadAll(reader) + if err := json.Unmarshal(data, &geminiReq); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if geminiReq.GenerationConfig == nil { + t.Fatal("expected GenerationConfig to be set") + } + if geminiReq.GenerationConfig.MaxOutputTokens == nil || *geminiReq.GenerationConfig.MaxOutputTokens != 1024 { + t.Errorf("expected MaxOutputTokens 1024, got %v", geminiReq.GenerationConfig.MaxOutputTokens) + } + if geminiReq.GenerationConfig.Temperature == nil || *geminiReq.GenerationConfig.Temperature != 0.7 { + t.Errorf("expected Temperature 0.7, got %v", geminiReq.GenerationConfig.Temperature) + } + if geminiReq.GenerationConfig.PresencePenalty == nil || *geminiReq.GenerationConfig.PresencePenalty != 0.5 { + t.Errorf("expected PresencePenalty 0.5, got %v", geminiReq.GenerationConfig.PresencePenalty) + } + if geminiReq.GenerationConfig.FrequencyPenalty == nil || *geminiReq.GenerationConfig.FrequencyPenalty != 0.3 { + t.Errorf("expected FrequencyPenalty 0.3, got %v", geminiReq.GenerationConfig.FrequencyPenalty) + } + if geminiReq.GenerationConfig.CandidateCount == nil || *geminiReq.GenerationConfig.CandidateCount != 2 { + t.Errorf("expected CandidateCount 2, got %v", geminiReq.GenerationConfig.CandidateCount) + } +} + +func TestConvertOpenAIToGemini_SafetySettings(t *testing.T) { + info := makeTestInfo() + + openaiReq := dto.GeneralOpenAIRequest{ + Model: "gemini-2.5-flash", + Messages: []dto.Message{ + {Role: "user", Content: "Hello"}, + }, + } + + body, _ := json.Marshal(openaiReq) + reader, err := ConvertOpenAIToGemini(body, info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var geminiReq dto.GeminiChatRequest + data, _ := io.ReadAll(reader) + if err := json.Unmarshal(data, &geminiReq); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if len(geminiReq.SafetySettings) == 0 { + t.Fatal("expected safety settings to be set") + } +} + +func TestConvertOpenAIToGemini_ReasoningEffort(t *testing.T) { + info := makeTestInfo() + + openaiReq := dto.GeneralOpenAIRequest{ + Model: "gemini-2.5-flash", + ReasoningEffort: "high", + Messages: []dto.Message{ + {Role: "user", Content: "Hello"}, + }, + } + + body, _ := json.Marshal(openaiReq) + reader, err := ConvertOpenAIToGemini(body, info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var geminiReq dto.GeminiChatRequest + data, _ := io.ReadAll(reader) + if err := json.Unmarshal(data, &geminiReq); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if geminiReq.GenerationConfig.ThinkingConfig == nil { + t.Fatal("expected ThinkingConfig to be set") + } + if !geminiReq.GenerationConfig.ThinkingConfig.IncludeThoughts { + t.Error("expected IncludeThoughts to be true") + } + if geminiReq.GenerationConfig.ThinkingConfig.ThoughtBudget == nil || *geminiReq.GenerationConfig.ThinkingConfig.ThoughtBudget != 32768 { + t.Errorf("expected ThoughtBudget 32768, got %v", geminiReq.GenerationConfig.ThinkingConfig.ThoughtBudget) + } + if geminiReq.GenerationConfig.ThinkingConfig.ThinkingLevel != "HIGH" { + t.Errorf("expected ThinkingLevel HIGH, got %s", geminiReq.GenerationConfig.ThinkingConfig.ThinkingLevel) + } +} + +func TestConvertOpenAIToGemini_ResponseFormat(t *testing.T) { + info := makeTestInfo() + + openaiReq := dto.GeneralOpenAIRequest{ + Model: "gemini-2.5-flash", + ResponseFormat: &dto.ResponseFormat{ + Type: "json_schema", + JSONSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + "age": map[string]any{"type": "integer"}, + "tags": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + }, + }, + "required": []any{"name"}, + }, + }, + Messages: []dto.Message{ + {Role: "user", Content: "Hello"}, + }, + } + + body, _ := json.Marshal(openaiReq) + reader, err := ConvertOpenAIToGemini(body, info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var geminiReq dto.GeminiChatRequest + data, _ := io.ReadAll(reader) + if err := json.Unmarshal(data, &geminiReq); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if geminiReq.GenerationConfig.ResponseMimeType != "application/json" { + t.Errorf("expected ResponseMimeType 'application/json', got %s", geminiReq.GenerationConfig.ResponseMimeType) + } + + // Verify schema types are converted to uppercase + schemaMap, ok := geminiReq.GenerationConfig.ResponseSchema.(map[string]any) + if !ok { + t.Fatalf("expected ResponseSchema to be a map, got %T", geminiReq.GenerationConfig.ResponseSchema) + } + if schemaMap["type"] != "OBJECT" { + t.Errorf("expected top-level type 'OBJECT', got %v", schemaMap["type"]) + } + + props, ok := schemaMap["properties"].(map[string]any) + if !ok { + t.Fatalf("expected properties to be a map, got %T", schemaMap["properties"]) + } + nameField, ok := props["name"].(map[string]any) + if !ok { + t.Fatalf("expected name field to be a map") + } + if nameField["type"] != "STRING" { + t.Errorf("expected name type 'STRING', got %v", nameField["type"]) + } + + ageField, ok := props["age"].(map[string]any) + if !ok { + t.Fatalf("expected age field to be a map") + } + if ageField["type"] != "INTEGER" { + t.Errorf("expected age type 'INTEGER', got %v", ageField["type"]) + } + + tagsField, ok := props["tags"].(map[string]any) + if !ok { + t.Fatalf("expected tags field to be a map") + } + if tagsField["type"] != "ARRAY" { + t.Errorf("expected tags type 'ARRAY', got %v", tagsField["type"]) + } + itemsField, ok := tagsField["items"].(map[string]any) + if !ok { + t.Fatalf("expected items to be a map") + } + if itemsField["type"] != "STRING" { + t.Errorf("expected items type 'STRING', got %v", itemsField["type"]) + } + + // Verify other fields are preserved + required, ok := schemaMap["required"].([]any) + if !ok || len(required) != 1 || required[0] != "name" { + t.Errorf("expected required to be preserved as ['name'], got %v", schemaMap["required"]) + } +} + +func TestConvertOpenAIToGemini_JsonSchemaWrapper(t *testing.T) { + info := makeTestInfo() + + openaiReq := dto.GeneralOpenAIRequest{ + Model: "gemini-2.5-flash", + ResponseFormat: &dto.ResponseFormat{ + Type: "json_schema", + JSONSchema: map[string]any{ + "type": "json_schema", + "json_schema": map[string]any{ + "name": "person", + "schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + }, + }, + }, + }, + Messages: []dto.Message{ + {Role: "user", Content: "Hello"}, + }, + } + + body, _ := json.Marshal(openaiReq) + reader, err := ConvertOpenAIToGemini(body, info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var geminiReq dto.GeminiChatRequest + data, _ := io.ReadAll(reader) + if err := json.Unmarshal(data, &geminiReq); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + schemaMap, ok := geminiReq.GenerationConfig.ResponseSchema.(map[string]any) + if !ok { + t.Fatalf("expected ResponseSchema to be a map") + } + if schemaMap["type"] != "OBJECT" { + t.Errorf("expected unwrapped schema type 'OBJECT', got %v", schemaMap["type"]) + } +} + +func TestGeminiFinishReasonToOpenAI(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"STOP", "stop"}, + {"MAX_TOKENS", "length"}, + {"SAFETY", "content_filter"}, + {"RECITATION", "content_filter"}, + {"OTHER", "content_filter"}, + {"BLOCKLIST", "content_filter"}, + {"PROHIBITED", "content_filter"}, + {"SPII", "content_filter"}, + {"MALFORMED_FUNCTION_CALL", "tool_calls"}, + {"TOOL_CALLS", "tool_calls"}, + {"LANGUAGE", "content_filter"}, + {"IMAGE_SAFETY", "content_filter"}, + {"IMAGE_PROHIBITED_CONTENT", "content_filter"}, + {"IMAGE_OTHER", "content_filter"}, + {"NO_IMAGE", "content_filter"}, + {"IMAGE_RECITATION", "content_filter"}, + {"UNEXPECTED_TOOL_CALL", "tool_calls"}, + {"TOO_MANY_TOOL_CALLS", "tool_calls"}, + {"MISSING_THOUGHT_SIGNATURE", "stop"}, + {"MALFORMED_RESPONSE", "stop"}, + {"FINISH_REASON_UNSPECIFIED", "stop"}, + {"unknown", "unknown"}, + } + + for _, tt := range tests { + result := common.GeminiFinishReasonToOpenAI(tt.input) + if result != tt.expected { + t.Errorf("GeminiFinishReasonToOpenAI(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestGeminiFinishReasonToClaude(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"STOP", "end_turn"}, + {"MAX_TOKENS", "max_tokens"}, + {"SAFETY", "refusal"}, + {"RECITATION", "refusal"}, + {"LANGUAGE", "refusal"}, + {"IMAGE_SAFETY", "refusal"}, + {"IMAGE_RECITATION", "refusal"}, + {"MALFORMED_FUNCTION_CALL", "tool_use"}, + {"UNEXPECTED_TOOL_CALL", "tool_use"}, + {"TOO_MANY_TOOL_CALLS", "tool_use"}, + {"MISSING_THOUGHT_SIGNATURE", "end_turn"}, + {"MALFORMED_RESPONSE", "end_turn"}, + {"FINISH_REASON_UNSPECIFIED", "end_turn"}, + {"SPII", "refusal"}, + {"unknown", "unknown"}, + } + + for _, tt := range tests { + result := common.GeminiFinishReasonToClaude(tt.input) + if result != tt.expected { + t.Errorf("GeminiFinishReasonToClaude(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestO2gMapSchemaType(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"string", "STRING"}, + {"number", "NUMBER"}, + {"integer", "INTEGER"}, + {"boolean", "BOOLEAN"}, + {"object", "OBJECT"}, + {"array", "ARRAY"}, + {"null", "NULL"}, + {"custom", "custom"}, + } + for _, tt := range tests { + result := o2gMapSchemaType(tt.input) + if result != tt.expected { + t.Errorf("o2gMapSchemaType(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestO2gConvertReasoningEffort(t *testing.T) { + tests := []struct { + effort string + wantBudget int + wantLevel string + }{ + {"low", 1024, "LOW"}, + {"medium", 8192, "MEDIUM"}, + {"high", 32768, "HIGH"}, + {"unknown", 8192, "MEDIUM"}, + } + for _, tt := range tests { + tc := o2gConvertReasoningEffort(tt.effort) + if !tc.IncludeThoughts { + t.Errorf("IncludeThoughts should be true for %q", tt.effort) + } + if tc.ThoughtBudget == nil || *tc.ThoughtBudget != tt.wantBudget { + t.Errorf("effort %q: budget = %v, want %d", tt.effort, tc.ThoughtBudget, tt.wantBudget) + } + if tc.ThinkingLevel != tt.wantLevel { + t.Errorf("effort %q: level = %q, want %q", tt.effort, tc.ThinkingLevel, tt.wantLevel) + } + } +} + +func TestParseGeminiError(t *testing.T) { + tests := []struct { + name string + body string + wantCode int + wantStatus string + wantMessage string + }{ + { + name: "standard RPC error", + body: `{"error":{"code":400,"message":"Invalid argument","status":"INVALID_ARGUMENT"}}`, + wantCode: 400, + wantStatus: "INVALID_ARGUMENT", + wantMessage: "Invalid argument", + }, + { + name: "auth error", + body: `{"error":{"code":401,"message":"API key not valid","status":"UNAUTHENTICATED"}}`, + wantCode: 401, + wantStatus: "UNAUTHENTICATED", + wantMessage: "API key not valid", + }, + { + name: "non-RPC body", + body: `plain text error`, + wantCode: 0, + wantStatus: "", + wantMessage: "plain text error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + code, status, message := parseGeminiError([]byte(tt.body)) + if code != tt.wantCode { + t.Errorf("code = %d, want %d", code, tt.wantCode) + } + if status != tt.wantStatus { + t.Errorf("status = %q, want %q", status, tt.wantStatus) + } + if message != tt.wantMessage { + t.Errorf("message = %q, want %q", message, tt.wantMessage) + } + }) + } +} + +func TestGeminiStatusToOpenAIType(t *testing.T) { + tests := []struct { + status string + expected string + }{ + {"UNAUTHENTICATED", "authentication_error"}, + {"PERMISSION_DENIED", "permission_error"}, + {"INVALID_ARGUMENT", "invalid_request_error"}, + {"NOT_FOUND", "invalid_request_error"}, + {"RESOURCE_EXHAUSTED", "rate_limit_error"}, + {"RATE_LIMIT_EXCEEDED", "rate_limit_error"}, + {"INTERNAL", "internal_error"}, + {"UNAVAILABLE", "server_error"}, + {"DEADLINE_EXCEEDED", "timeout_error"}, + {"UNKNOWN_STATUS", "api_error"}, + } + for _, tt := range tests { + result := geminiStatusToOpenAIType(tt.status) + if result != tt.expected { + t.Errorf("geminiStatusToOpenAIType(%q) = %q, want %q", tt.status, result, tt.expected) + } + } +} + +func TestConvertOpenAIToGemini_Logprobs(t *testing.T) { + info := makeTestInfo() + logprobs := true + topLogprobs := 5 + + openaiReq := dto.GeneralOpenAIRequest{ + Model: "gemini-2.5-flash", + LogProbs: &logprobs, + TopLogProbs: &topLogprobs, + Messages: []dto.Message{ + {Role: "user", Content: "Hello"}, + }, + } + + body, _ := json.Marshal(openaiReq) + reader, err := ConvertOpenAIToGemini(body, info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var geminiReq dto.GeminiChatRequest + data, _ := io.ReadAll(reader) + if err := json.Unmarshal(data, &geminiReq); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if geminiReq.GenerationConfig.ResponseLogprobs == nil || !*geminiReq.GenerationConfig.ResponseLogprobs { + t.Error("expected ResponseLogprobs to be true") + } + if geminiReq.GenerationConfig.Logprobs == nil || *geminiReq.GenerationConfig.Logprobs != 5 { + t.Errorf("expected Logprobs 5, got %v", geminiReq.GenerationConfig.Logprobs) + } +} + +func TestConvertOpenAIToGemini_ReasoningContentPassthrough(t *testing.T) { + info := makeTestInfo() + thinking := "Let me think about this..." + + openaiReq := dto.GeneralOpenAIRequest{ + Model: "gemini-2.5-flash", + Messages: []dto.Message{ + { + Role: "assistant", + ReasoningContent: &thinking, + }, + {Role: "user", Content: "Continue"}, + }, + } + + body, _ := json.Marshal(openaiReq) + reader, err := ConvertOpenAIToGemini(body, info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var geminiReq dto.GeminiChatRequest + data, _ := io.ReadAll(reader) + if err := json.Unmarshal(data, &geminiReq); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + // First content should be model with thought part + if len(geminiReq.Contents) < 1 { + t.Fatal("expected at least 1 content") + } + assistantContent := geminiReq.Contents[0] + if assistantContent.Role != "model" { + t.Errorf("expected role 'model', got %s", assistantContent.Role) + } + + hasThought := false + for _, part := range assistantContent.Parts { + if part.Thought != nil && *part.Thought && part.Text == thinking { + hasThought = true + } + } + if !hasThought { + t.Error("expected thought part with reasoning content") + } +} + +func TestGeminiModalityToTokenDetails(t *testing.T) { + tests := []struct { + modality string + count int + field string + expected int + }{ + {"TEXT", 100, "TextTokens", 100}, + {"IMAGE", 50, "ImageTokens", 50}, + {"AUDIO", 30, "AudioTokens", 30}, + {"UNKNOWN", 10, "", 0}, + } + for _, tt := range tests { + td := &common.TokenDetails{} + geminiModalityToTokenDetails(dto.GeminiModalityTokenCount{Modality: tt.modality, TokenCount: tt.count}, td) + var actual int + switch tt.field { + case "TextTokens": + actual = td.TextTokens + case "ImageTokens": + actual = td.ImageTokens + case "AudioTokens": + actual = td.AudioTokens + } + if actual != tt.expected { + t.Errorf("modality %q: got %d, want %d", tt.modality, actual, tt.expected) + } + } +} + +func TestO2gParseStopSequences(t *testing.T) { + tests := []struct { + input any + expected []string + }{ + {nil, nil}, + {"", nil}, + {"stop", []string{"stop"}}, + {[]string{"a", "b"}, []string{"a", "b"}}, + {[]any{"x", "y"}, []string{"x", "y"}}, + {42, nil}, + } + for _, tt := range tests { + result := o2gParseStopSequences(tt.input) + if len(result) != len(tt.expected) { + t.Errorf("o2gParseStopSequences(%v) = %v, want %v", tt.input, result, tt.expected) + continue + } + for i, v := range result { + if v != tt.expected[i] { + t.Errorf("o2gParseStopSequences(%v)[%d] = %q, want %q", tt.input, i, v, tt.expected[i]) + } + } + } +} + +func TestGeminiDTOJsonMarshal(t *testing.T) { + // Verify new DTO fields serialize correctly + t.Run("GeminiPart with FileData", func(t *testing.T) { + part := dto.GeminiPart{ + FileData: &dto.GeminiFileData{ + MimeType: "video/mp4", + FileURI: "gs://bucket/file.mp4", + }, + } + data, err := json.Marshal(part) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + var parsed dto.GeminiPart + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if parsed.FileData == nil || parsed.FileData.FileURI != "gs://bucket/file.mp4" { + t.Errorf("FileData not preserved: %v", parsed.FileData) + } + }) + + t.Run("GeminiFunctionCall with ID", func(t *testing.T) { + fc := dto.GeminiFunctionCall{ + ID: "call_123", + FunctionName: "get_weather", + Arguments: map[string]any{"city": "NYC"}, + } + data, err := json.Marshal(fc) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + var parsed dto.GeminiFunctionCall + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if parsed.ID != "call_123" || parsed.FunctionName != "get_weather" { + t.Errorf("FunctionCall fields not preserved: %+v", parsed) + } + }) + + t.Run("GeminiGenerationConfig with new fields", func(t *testing.T) { + gc := dto.GeminiGenerationConfig{ + PresencePenalty: float64Ptr(0.5), + FrequencyPenalty: float64Ptr(0.3), + ResponseLogprobs: boolPtr(true), + Logprobs: intPtr(10), + ResponseModalities: []string{"TEXT", "IMAGE"}, + MediaResolution: "MEDIA_RESOLUTION_HIGH", + } + data, err := json.Marshal(gc) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + var parsed dto.GeminiGenerationConfig + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if parsed.PresencePenalty == nil || *parsed.PresencePenalty != 0.5 { + t.Errorf("PresencePenalty not preserved") + } + if len(parsed.ResponseModalities) != 2 || parsed.ResponseModalities[0] != "TEXT" { + t.Errorf("ResponseModalities not preserved: %v", parsed.ResponseModalities) + } + }) + + t.Run("GeminiUsageMetadata with modality details", func(t *testing.T) { + um := dto.GeminiUsageMetadata{ + PromptTokenCount: 100, + CandidatesTokenCount: 200, + TotalTokenCount: 300, + PromptTokensDetails: []dto.GeminiModalityTokenCount{ + {Modality: "TEXT", TokenCount: 80}, + {Modality: "IMAGE", TokenCount: 20}, + }, + } + data, err := json.Marshal(um) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + var parsed dto.GeminiUsageMetadata + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if len(parsed.PromptTokensDetails) != 2 { + t.Errorf("PromptTokensDetails not preserved: %v", parsed.PromptTokensDetails) + } + }) +} + +func float64Ptr(v float64) *float64 { return &v } +func boolPtr(v bool) *bool { return &v } +func intPtr(v int) *int { return &v } diff --git a/relay/channel/gemini/image.go b/relay/channel/gemini/image.go new file mode 100644 index 0000000..384bbd3 --- /dev/null +++ b/relay/channel/gemini/image.go @@ -0,0 +1,240 @@ +package gemini + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/dto" +) + +// convertImageRequestToChat 将 OpenAI ImageRequest 转为 Gemini ChatRequest(Banana 内生图模式) +func convertImageRequestToChat(requestBody []byte, info *common.RelayInfo) (io.Reader, error) { + var imgReq dto.ImageRequest + if err := json.Unmarshal(requestBody, &imgReq); err != nil { + return nil, fmt.Errorf("parse image request: %w", err) + } + + chatReq := dto.GeminiChatRequest{ + Contents: []dto.GeminiContent{ + { + Role: "user", + Parts: []dto.GeminiPart{ + {Text: imgReq.Prompt}, + }, + }, + }, + GenerationConfig: &dto.GeminiGenerationConfig{ + ResponseModalities: []string{"TEXT", "IMAGE"}, + }, + } + + result, err := json.Marshal(chatReq) + if err != nil { + return nil, fmt.Errorf("marshal gemini chat request: %w", err) + } + return strings.NewReader(string(result)), nil +} + +// handleBananaImageResponse 将 Gemini generateContent 响应中的图片转为 OpenAI ImageResponse +func handleBananaImageResponse(_ context.Context, resp *http.Response, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read banana response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(resp.StatusCode) + _, _ = writer.Write(body) + return &common.Usage{}, fmt.Errorf("banana API error: %d, body: %s", resp.StatusCode, string(body)) + } + + var geminiResp dto.GeminiChatResponse + if err := json.Unmarshal(body, &geminiResp); err != nil { + return nil, fmt.Errorf("parse banana response: %w", err) + } + + openaiResp := dto.ImageResponse{ + Created: 0, + } + + for _, candidate := range geminiResp.Candidates { + if candidate.Content == nil { + continue + } + for _, part := range candidate.Content.Parts { + if part.InlineData != nil { + openaiResp.Data = append(openaiResp.Data, dto.ImageData{ + B64JSON: part.InlineData.Data, + ContentType: part.InlineData.MimeType, + }) + } + } + } + + if len(openaiResp.Data) == 0 { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusBadRequest) + errBody, _ := json.Marshal(map[string]any{ + "error": map[string]any{ + "type": "banana_no_image", + "message": "model did not return any image data", + }, + }) + _, _ = writer.Write(errBody) + return &common.Usage{}, fmt.Errorf("banana response contained no image data") + } + + usage := &common.Usage{} + if geminiResp.UsageMetadata != nil { + usage.PromptTokens = geminiResp.UsageMetadata.PromptTokenCount + usage.CompletionTokens = geminiResp.UsageMetadata.CandidatesTokenCount + usage.TotalTokens = geminiResp.UsageMetadata.TotalTokenCount + } + + respBody, err := json.Marshal(openaiResp) + if err != nil { + return usage, fmt.Errorf("marshal openai image response: %w", err) + } + + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(respBody) + + return usage, nil +} + +// convertImageRequest 将 OpenAI 图片生成请求转换为 Gemini Imagen 格式 +func convertImageRequest(requestBody []byte, info *common.RelayInfo) (io.Reader, error) { + var imgReq dto.ImageRequest + if err := json.Unmarshal(requestBody, &imgReq); err != nil { + return nil, fmt.Errorf("parse image request: %w", err) + } + + sampleCount := 1 + if imgReq.N != nil && *imgReq.N > 0 { + sampleCount = *imgReq.N + } + + geminiReq := dto.GeminiImageRequest{ + Instances: []dto.GeminiImageInstance{{Prompt: imgReq.Prompt}}, + Parameters: dto.GeminiImageParameters{ + SampleCount: sampleCount, + AspectRatio: imageSizeToAspectRatio(imgReq.Size), + PersonGeneration: "allow_adult", + ImageSize: qualityToImageSize(imgReq.Quality), + }, + } + + result, err := json.Marshal(geminiReq) + if err != nil { + return nil, fmt.Errorf("marshal gemini image request: %w", err) + } + return strings.NewReader(string(result)), nil +} + +// handleImagenResponse 将 Gemini Imagen 响应转换为 OpenAI 图片格式 +func handleImagenResponse(_ context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read imagen response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(resp.StatusCode) + _, _ = writer.Write(body) + return &common.Usage{}, fmt.Errorf("imagen API error: %d", resp.StatusCode) + } + + var geminiResp dto.GeminiImageResponse + if err := json.Unmarshal(body, &geminiResp); err != nil { + return nil, fmt.Errorf("parse imagen response: %w", err) + } + + openaiResp := dto.ImageResponse{ + Created: info.StartTime.Unix(), + } + + for _, pred := range geminiResp.Predictions { + if pred.RaiFilteredReason != "" { + continue + } + openaiResp.Data = append(openaiResp.Data, dto.ImageData{ + B64JSON: pred.BytesBase64Encoded, + ContentType: pred.MimeType, + }) + } + + if len(openaiResp.Data) == 0 { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusBadRequest) + errBody, _ := json.Marshal(map[string]any{ + "error": map[string]any{ + "type": "imagen_filtered", + "message": "all images were filtered by safety filters", + }, + }) + _, _ = writer.Write(errBody) + return &common.Usage{}, fmt.Errorf("all images filtered by safety") + } + + respBody, err := json.Marshal(openaiResp) + if err != nil { + return nil, fmt.Errorf("marshal openai image response: %w", err) + } + + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(respBody) + + return &common.Usage{}, nil +} + +// imageSizeToAspectRatio 将 OpenAI 图片尺寸映射为 Imagen 宽高比 +func imageSizeToAspectRatio(size string) string { + switch size { + case "1024x1024": + return "1:1" + case "1792x1024", "1280x720": + return "16:9" + case "1024x1792", "720x1280": + return "9:16" + case "1536x1024": + return "3:2" + case "1024x1536": + return "2:3" + case "1152x864": + return "4:3" + case "864x1152": + return "3:4" + case "1344x576": + return "21:9" + } + + // 支持原生比例格式(如 "1:1"、"16:9") + if strings.Contains(size, ":") { + return size + } + + return "1:1" +} + +// qualityToImageSize 将 OpenAI quality 映射为 Imagen imageSize +func qualityToImageSize(quality string) string { + switch strings.ToLower(quality) { + case "hd", "high": + return "2K" + default: + return "1K" + } +} diff --git a/relay/channel/gemini/response.go b/relay/channel/gemini/response.go new file mode 100644 index 0000000..2f6c46e --- /dev/null +++ b/relay/channel/gemini/response.go @@ -0,0 +1,690 @@ +package gemini + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/dto" + "github.com/qianfree/team-api/relay/helper" +) + +// codeAssistWrapper Code Assist 响应包装层 +type codeAssistWrapper struct { + Response json.RawMessage `json:"response"` +} + +// unwrapCodeAssistData 解包 Code Assist 的响应格式 +// Code Assist 格式:{"response": {GeminiChatResponse}, "traceId": "...", ...} +// 标准格式:{GeminiChatResponse} +func unwrapCodeAssistData(data []byte) []byte { + var wrapper codeAssistWrapper + if err := json.Unmarshal(data, &wrapper); err == nil && wrapper.Response != nil { + return wrapper.Response + } + return data +} + +// handleGeminiNativeResponse 处理 Gemini 原生透传响应(Gemini 客户端 → Gemini 上游) +func (a *Adaptor) handleGeminiNativeResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + if info.IsStream { + return a.handleGeminiNativeStream(ctx, resp, info, writer) + } + return a.handleGeminiNativeNonStream(ctx, resp, info, writer) +} + +// handleGeminiNativeNonStream Gemini 原生非流式透传 +func (a *Adaptor) handleGeminiNativeNonStream(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "read response body failed", err) + } + + if resp.StatusCode != http.StatusOK { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(resp.StatusCode) + _, _ = writer.Write(body) + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(body) + + var geminiResp dto.GeminiChatResponse + if err := json.Unmarshal(body, &geminiResp); err == nil && geminiResp.UsageMetadata != nil { + return geminiUsageToCommon(geminiResp.UsageMetadata), nil + } + return &common.Usage{}, nil +} + +// handleGeminiNativeStream Gemini 原生流式透传 +func (a *Adaptor) handleGeminiNativeStream(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(resp.StatusCode) + _, _ = writer.Write(body) + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + helper.SetEventStreamHeaders(writer) + + isCA := a.isCodeAssistActive() + reader := bufio.NewReader(resp.Body) + + var totalUsage dto.GeminiUsageMetadata + + for { + select { + case <-ctx.Done(): + info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, ctx.Err()) + return &common.Usage{}, common.ErrStreamInterrupted + default: + } + + line, err := reader.ReadString('\n') + if len(line) > 0 { + outputLine := line + + // 解析 data: 行以收集 usage 并解包 Code Assist 响应 + trimmed := strings.TrimRight(line, "\r\n") + if strings.HasPrefix(trimmed, "data:") { + data := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) + + if data != "" && data != "[DONE]" { + info.SetFirstResponseTime() + } + + if data == "[DONE]" { + _, _ = io.WriteString(writer, outputLine) + if f, ok := writer.(http.Flusher); ok { + f.Flush() + } + break + } + + if data != "" { + rawData := []byte(data) + // Code Assist 模式:解包 response 字段并替换 SSE data + if isCA { + unwrapped := unwrapCodeAssistData(rawData) + outputLine = "data: " + string(unwrapped) + "\n" + rawData = unwrapped + } + var geminiResp dto.GeminiChatResponse + if jsonErr := json.Unmarshal(rawData, &geminiResp); jsonErr == nil { + if geminiResp.UsageMetadata != nil { + totalUsage = *geminiResp.UsageMetadata + } + } + } + } + + // 输出 SSE 行(解包后的) + _, _ = io.WriteString(writer, outputLine) + if f, ok := writer.(http.Flusher); ok { + f.Flush() + } + } + + if err == io.EOF { + break + } + if err != nil { + info.StreamStatus.SetEndReason(common.StreamEndReasonError, err) + break + } + } + + info.StreamStatus.SetEndReason(common.StreamEndReasonDone, nil) + + return geminiUsageToCommon(&totalUsage), nil +} + +// handleNonStreamToOpenAI 将 Gemini 非流式响应转换为 OpenAI 格式 +func (a *Adaptor) handleNonStreamToOpenAI(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "read response body failed", err) + } + + if resp.StatusCode != http.StatusOK { + writeGeminiErrorAsOpenAI(writer, body, resp.StatusCode) + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + var geminiResp dto.GeminiChatResponse + if err := json.Unmarshal(body, &geminiResp); err != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "invalid response body", err) + } + + // 检查 promptFeedback.blockReason(Gemini 安全过滤) + if geminiResp.PromptFeedback != nil && geminiResp.PromptFeedback.BlockReason != "" { + return nil, constant.NewRequestError( + fmt.Sprintf("request blocked by Gemini safety filter: %s", geminiResp.PromptFeedback.BlockReason), nil, + ) + } + + openaiResp := geminiToOpenAIResponse(&geminiResp, info) + + respBody, _ := json.Marshal(openaiResp) + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(respBody) + + usage := &common.Usage{} + if geminiResp.UsageMetadata != nil { + usage = geminiUsageToCommon(geminiResp.UsageMetadata) + } + return usage, nil +} + +// handleStreamToOpenAI 将 Gemini 流式响应转换为 OpenAI SSE 格式 +func (a *Adaptor) handleStreamToOpenAI(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + writeGeminiErrorAsOpenAI(writer, body, resp.StatusCode) + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + helper.SetEventStreamHeaders(writer) + defer helper.PingTicker(writer, 15*time.Second)() + + isCA := a.isCodeAssistActive() + scanner := bufio.NewScanner(resp.Body) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 10*1024*1024) + + var ( + totalUsage dto.GeminiUsageMetadata + finishReason string + toolCallIdx int + modelName string + ) + + for scanner.Scan() { + select { + case <-ctx.Done(): + info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, ctx.Err()) + return &common.Usage{}, common.ErrStreamInterrupted + default: + } + + line := scanner.Text() + if line == "" { + continue + } + + if !strings.HasPrefix(line, "data:") { + continue + } + + data, _ := helper.ExtractSSEData(line) + + if data != "" && data != "[DONE]" { + info.SetFirstResponseTime() + } + + if data == "[DONE]" { + break + } + + var geminiResp dto.GeminiChatResponse + rawData := []byte(data) + if isCA { + rawData = unwrapCodeAssistData(rawData) + } + if err := json.Unmarshal(rawData, &geminiResp); err != nil { + continue + } + + // 收集用量 + if geminiResp.UsageMetadata != nil { + totalUsage = *geminiResp.UsageMetadata + } + + // 检查 promptFeedback.blockReason(流式安全过滤) + if geminiResp.PromptFeedback != nil && geminiResp.PromptFeedback.BlockReason != "" { + return nil, constant.NewRequestError( + fmt.Sprintf("request blocked by Gemini safety filter: %s", geminiResp.PromptFeedback.BlockReason), nil, + ) + } + + // 收集模型名 + if geminiResp.ModelName != "" { + modelName = geminiResp.ModelName + } + + for _, candidate := range geminiResp.Candidates { + if candidate.FinishReason != "" { + finishReason = common.GeminiFinishReasonToOpenAI(candidate.FinishReason) + } + + if candidate.Content == nil { + continue + } + + for _, part := range candidate.Content.Parts { + isThought := part.Thought != nil && *part.Thought + + // 文本内容 + if part.Text != "" { + if isThought { + // 思考内容 → reasoning_content + text := part.Text + chunk := dto.ChatCompletionStreamResponse{ + ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), + Object: "chat.completion.chunk", + Model: modelName, + Choices: []dto.StreamChoice{{ + Index: 0, + Delta: dto.Message{ + Role: "assistant", + ReasoningContent: &text, + }, + }}, + } + if modelName == "" { + chunk.Model = info.OriginModelName + } + writeStreamChunk(writer, &chunk) + } else { + chunk := dto.ChatCompletionStreamResponse{ + ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), + Object: "chat.completion.chunk", + Model: modelName, + Choices: []dto.StreamChoice{{ + Index: 0, + Delta: dto.Message{ + Role: "assistant", + Content: part.Text, + }, + }}, + } + if modelName == "" { + chunk.Model = info.OriginModelName + } + writeStreamChunk(writer, &chunk) + } + } + + // inline image data + if part.InlineData != nil { + imageText := fmt.Sprintf("![image](data:%s;base64,%s)", part.InlineData.MimeType, part.InlineData.Data) + imageChunk := dto.ChatCompletionStreamResponse{ + ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), + Object: "chat.completion.chunk", + Model: modelName, + Choices: []dto.StreamChoice{{ + Index: 0, + Delta: dto.Message{ + Role: "assistant", + Content: imageText, + }, + }}, + } + if modelName == "" { + imageChunk.Model = info.OriginModelName + } + writeStreamChunk(writer, &imageChunk) + } + + // executable code + if part.ExecutableCode != nil { + codeText := fmt.Sprintf("```%s\n%s\n```", part.ExecutableCode.Language, part.ExecutableCode.Code) + codeChunk := dto.ChatCompletionStreamResponse{ + ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), + Object: "chat.completion.chunk", + Model: modelName, + Choices: []dto.StreamChoice{{ + Index: 0, + Delta: dto.Message{ + Role: "assistant", + Content: codeText, + }, + }}, + } + if modelName == "" { + codeChunk.Model = info.OriginModelName + } + writeStreamChunk(writer, &codeChunk) + } + + // code execution result + if part.CodeExecutionResult != nil { + resultText := fmt.Sprintf("Execution %s:\n%s", part.CodeExecutionResult.Outcome, part.CodeExecutionResult.Output) + resultChunk := dto.ChatCompletionStreamResponse{ + ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), + Object: "chat.completion.chunk", + Model: modelName, + Choices: []dto.StreamChoice{{ + Index: 0, + Delta: dto.Message{ + Role: "assistant", + Content: resultText, + }, + }}, + } + if modelName == "" { + resultChunk.Model = info.OriginModelName + } + writeStreamChunk(writer, &resultChunk) + } + + // file data + if part.FileData != nil { + fileText := fmt.Sprintf("[file](%s)", part.FileData.FileURI) + fileChunk := dto.ChatCompletionStreamResponse{ + ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), + Object: "chat.completion.chunk", + Model: modelName, + Choices: []dto.StreamChoice{{ + Index: 0, + Delta: dto.Message{ + Role: "assistant", + Content: fileText, + }, + }}, + } + if modelName == "" { + fileChunk.Model = info.OriginModelName + } + writeStreamChunk(writer, &fileChunk) + } + + // function call + if part.FunctionCall != nil { + argsJSON, _ := json.Marshal(part.FunctionCall.Arguments) + chunk := dto.ChatCompletionStreamResponse{ + ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), + Object: "chat.completion.chunk", + Model: modelName, + Choices: []dto.StreamChoice{{ + Index: 0, + Delta: dto.Message{ + ToolCalls: []dto.ToolCall{{ + ID: fmt.Sprintf("call_%s_%d", info.RequestID, toolCallIdx), + Type: "function", + Function: dto.FunctionCall{ + Name: part.FunctionCall.FunctionName, + Arguments: string(argsJSON), + }, + }}, + }, + }}, + } + if modelName == "" { + chunk.Model = info.OriginModelName + } + writeStreamChunk(writer, &chunk) + toolCallIdx++ + } + } + } + } + + // 发送结束 chunk + reason := finishReason + if reason == "" { + reason = "stop" + } + endChunk := dto.ChatCompletionStreamResponse{ + ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), + Object: "chat.completion.chunk", + Model: modelName, + Choices: []dto.StreamChoice{{ + Index: 0, + FinishReason: &reason, + }}, + } + if modelName == "" { + endChunk.Model = info.OriginModelName + } + + if totalUsage.PromptTokenCount > 0 || totalUsage.CandidatesTokenCount > 0 { + endChunk.Usage = &dto.UsageWithDetails{ + PromptTokens: totalUsage.PromptTokenCount, + CompletionTokens: totalUsage.CandidatesTokenCount, + TotalTokens: totalUsage.TotalTokenCount, + } + } + writeStreamChunk(writer, &endChunk) + + helper.WriteSSEData(writer, "[DONE]") + info.StreamStatus.SetEndReason(common.StreamEndReasonDone, nil) + + if err := scanner.Err(); err != nil && err != io.EOF && ctx.Err() == nil { + info.StreamStatus.SetEndReason(common.StreamEndReasonError, err) + return &common.Usage{}, fmt.Errorf("stream scanner error: %w", err) + } + + return geminiUsageToCommon(&totalUsage), nil +} + +// geminiToOpenAIResponse 将 Gemini 非流式响应转换为 OpenAI ChatCompletion 格式 +func geminiToOpenAIResponse(geminiResp *dto.GeminiChatResponse, info *common.RelayInfo) dto.ChatCompletionResponse { + resp := dto.ChatCompletionResponse{ + ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), + Object: "chat.completion", + Created: 0, + Model: info.OriginModelName, + Choices: make([]dto.Choice, 0), + } + + if len(geminiResp.Candidates) == 0 { + return resp + } + + candidate := geminiResp.Candidates[0] + choice := dto.Choice{ + Index: candidate.Index, + FinishReason: common.GeminiFinishReasonToOpenAI(candidate.FinishReason), + } + + if candidate.Content != nil { + var textParts []string + var thinkingParts []string + var toolCalls []dto.ToolCall + toolIdx := 0 + + for _, part := range candidate.Content.Parts { + isThought := part.Thought != nil && *part.Thought + + if part.Text != "" { + if isThought { + thinkingParts = append(thinkingParts, part.Text) + } else { + textParts = append(textParts, part.Text) + } + } + // Gemini inline image data + if part.InlineData != nil { + textParts = append(textParts, fmt.Sprintf("![image](data:%s;base64,%s)", part.InlineData.MimeType, part.InlineData.Data)) + } + // file data + if part.FileData != nil { + textParts = append(textParts, fmt.Sprintf("[file](%s)", part.FileData.FileURI)) + } + // executable code + if part.ExecutableCode != nil { + textParts = append(textParts, fmt.Sprintf("```%s\n%s\n```", part.ExecutableCode.Language, part.ExecutableCode.Code)) + } + // code execution result + if part.CodeExecutionResult != nil { + textParts = append(textParts, fmt.Sprintf("Execution %s:\n%s", part.CodeExecutionResult.Outcome, part.CodeExecutionResult.Output)) + } + if part.FunctionCall != nil { + argsJSON, _ := json.Marshal(part.FunctionCall.Arguments) + toolCalls = append(toolCalls, dto.ToolCall{ + ID: fmt.Sprintf("call_%s_%d", info.RequestID, toolIdx), + Type: "function", + Function: dto.FunctionCall{ + Name: part.FunctionCall.FunctionName, + Arguments: string(argsJSON), + }, + }) + toolIdx++ + } + } + + message := dto.Message{ + Role: "assistant", + Content: strings.Join(textParts, ""), + } + if len(thinkingParts) > 0 { + joined := strings.Join(thinkingParts, "\n") + message.ReasoningContent = &joined + } + if len(toolCalls) > 0 { + message.ToolCalls = toolCalls + choice.FinishReason = "tool_calls" + } + choice.Message = message + } + + resp.Choices = append(resp.Choices, choice) + + if geminiResp.UsageMetadata != nil { + resp.Usage = dto.UsageWithDetails{ + PromptTokens: geminiResp.UsageMetadata.PromptTokenCount, + CompletionTokens: geminiResp.UsageMetadata.CandidatesTokenCount, + TotalTokens: geminiResp.UsageMetadata.TotalTokenCount, + } + } + + return resp +} + +// writeStreamChunk 写入流式 chunk +func writeStreamChunk(w http.ResponseWriter, chunk *dto.ChatCompletionStreamResponse) { + data, _ := json.Marshal(chunk) + _ = helper.WriteSSEData(w, string(data)) +} + +// geminiUsageToCommon 将 Gemini UsageMetadata 转换为 common.Usage +func geminiUsageToCommon(um *dto.GeminiUsageMetadata) *common.Usage { + if um == nil { + return &common.Usage{} + } + usage := &common.Usage{ + PromptTokens: um.PromptTokenCount, + CompletionTokens: um.CandidatesTokenCount, + TotalTokens: um.TotalTokenCount, + PromptTokensDetails: &common.TokenDetails{ + CachedTokens: um.CachedContentTokenCount, + }, + CompletionTokenDetails: &common.TokenDetails{ + ReasoningTokens: um.ThoughtsTokenCount, + }, + } + + // 转换模态 Token 明细 + if len(um.PromptTokensDetails) > 0 || len(um.CandidatesTokensDetails) > 0 { + if usage.PromptTokensDetails == nil { + usage.PromptTokensDetails = &common.TokenDetails{} + } + for _, mtc := range um.PromptTokensDetails { + geminiModalityToTokenDetails(mtc, usage.PromptTokensDetails) + } + for _, mtc := range um.CandidatesTokensDetails { + geminiModalityToTokenDetails(mtc, usage.CompletionTokenDetails) + } + } + + return usage +} + +// geminiModalityToTokenDetails 将 Gemini 模态 Token 计数转换为 OpenAI TokenDetails 字段 +func geminiModalityToTokenDetails(mtc dto.GeminiModalityTokenCount, td *common.TokenDetails) { + if td == nil { + return + } + switch mtc.Modality { + case "TEXT": + td.TextTokens += mtc.TokenCount + case "IMAGE": + td.ImageTokens += mtc.TokenCount + case "AUDIO": + td.AudioTokens += mtc.TokenCount + } +} + +// ===== 上游错误格式转换 ===== + +// parseGeminiError 解析 Gemini RPC Status 错误格式 +func parseGeminiError(body []byte) (code int, status string, message string) { + var rpcStatus struct { + Error struct { + Code int `json:"code"` + Message string `json:"message"` + Status string `json:"status"` + } `json:"error"` + } + if err := json.Unmarshal(body, &rpcStatus); err == nil && rpcStatus.Error.Code != 0 { + return rpcStatus.Error.Code, rpcStatus.Error.Status, rpcStatus.Error.Message + } + return 0, "", string(body) +} + +// geminiStatusToOpenAIType 将 Gemini RPC status 映射为 OpenAI error type +func geminiStatusToOpenAIType(status string) string { + switch status { + case "UNAUTHENTICATED": + return "authentication_error" + case "PERMISSION_DENIED": + return "permission_error" + case "INVALID_ARGUMENT": + return "invalid_request_error" + case "NOT_FOUND": + return "invalid_request_error" + case "RESOURCE_EXHAUSTED", "RATE_LIMIT_EXCEEDED": + return "rate_limit_error" + case "INTERNAL": + return "internal_error" + case "UNAVAILABLE": + return "server_error" + case "DEADLINE_EXCEEDED": + return "timeout_error" + default: + return "api_error" + } +} + +// writeGeminiErrorAsOpenAI 将 Gemini 上游错误转换为 OpenAI 格式写入响应 +func writeGeminiErrorAsOpenAI(writer http.ResponseWriter, body []byte, defaultStatusCode int) { + code, status, message := parseGeminiError(body) + if code == 0 { + code = defaultStatusCode + } + if code < 100 || code > 599 { + code = 500 + } + + openaiErr, _ := json.Marshal(map[string]any{ + "error": map[string]any{ + "message": message, + "type": geminiStatusToOpenAIType(status), + "param": nil, + "code": nil, + }, + }) + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(code) + _, _ = writer.Write(openaiErr) +} diff --git a/relay/channel/jimeng/adaptor.go b/relay/channel/jimeng/adaptor.go new file mode 100644 index 0000000..3ab426d --- /dev/null +++ b/relay/channel/jimeng/adaptor.go @@ -0,0 +1,139 @@ +package jimeng + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/channel/openai" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/override" +) + +// Adaptor 即梦(Jimeng)供应商适配器。 +// 即梦是字节跳动的 AI 图像生成平台,仅支持图像生成模式。 +// 完整的即梦原生 HMAC 签名较为复杂,本适配器为简化版本, +// 假设通过一个兼容网关代理,返回 OpenAI 兼容的图像生成响应格式。 +// +// ApiKey 格式: "accessKey|secretKey"(竖线分隔) +type Adaptor struct { + info *common.RelayInfo + accessKey string + secretKey string +} + +func (a *Adaptor) Init(info *common.RelayInfo) { + a.info = info + // 解析 ApiKey: "accessKey|secretKey" + parts := strings.SplitN(info.ChannelMeta.ApiKey, "|", 2) + if len(parts) == 2 { + a.accessKey = parts[0] + a.secretKey = parts[1] + } else { + a.accessKey = info.ChannelMeta.ApiKey + } +} + +// GetRequestURL 构建上游请求 URL。 +// 仅支持图像生成: {baseURL}/v2/images/generations +func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") + + switch constant.RelayMode(info.RelayMode) { + case constant.RelayModeImagesGenerations: + return baseURL + "/v2/images/generations", nil + default: + return "", fmt.Errorf("unsupported relay mode for Jimeng: %d (only image generation is supported)", info.RelayMode) + } +} + +// SetupRequestHeader 设置上游请求头。使用 accessKey 作为 Bearer Token。 +func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) error { + header.Set("Authorization", "Bearer "+a.accessKey) + header.Set("Content-Type", "application/json") + header.Set("Accept", "application/json") + return nil +} + +// ConvertRequest 转换请求体。 +// 仅支持图像生成模式,对非图像模式返回错误。 +// 请求体直通,仅做模型名映射。 +func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { + // 非 OpenAI 格式先转换为 OpenAI + if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { + converted, err := openai.ConvertToOpenAI(requestBody, info) + if err != nil { + return nil, err + } + requestBody = converted + } + + if constant.RelayMode(info.RelayMode) != constant.RelayModeImagesGenerations { + return nil, fmt.Errorf("Jimeng only supports image generation mode") + } + + // 模型名映射 + if info.ChannelMeta.IsModelMapped { + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(requestBody, &rawMap); err != nil { + return bytes.NewReader(requestBody), nil + } + rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) + converted, err := json.Marshal(rawMap) + if err != nil { + return nil, fmt.Errorf("marshal converted request failed: %w", err) + } + return bytes.NewReader(converted), nil + } + + return bytes.NewReader(requestBody), nil +} + +// DoRequest 发送请求到即梦上游 +func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + reqURL, err := a.GetRequestURL(info) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + if err := a.SetupRequestHeader(httpReq.Header, info); err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + + if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { + override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) + } + + timeout := info.ChannelMeta.Settings.TimeoutSeconds + if timeout <= 0 { + timeout = 120 // 图像生成通常较慢 + } + + client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) + + return client.Do(httpReq) +} + +// DoResponse 处理即梦上游响应。 +// 假设网关返回 OpenAI 兼容格式,委托给 openai.Adaptor 处理。 +func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + openaiAdaptor := &openai.Adaptor{} + openaiAdaptor.Init(info) + return openaiAdaptor.DoResponse(ctx, resp, info, writer) +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} + +var _ common.Adaptor = (*Adaptor)(nil) diff --git a/relay/channel/jimeng/constants.go b/relay/channel/jimeng/constants.go new file mode 100644 index 0000000..34f19b0 --- /dev/null +++ b/relay/channel/jimeng/constants.go @@ -0,0 +1,4 @@ +package jimeng + +// ChannelName 渠道名称 +var ChannelName = "Jimeng" diff --git a/relay/channel/minimax/adaptor.go b/relay/channel/minimax/adaptor.go new file mode 100644 index 0000000..83a053d --- /dev/null +++ b/relay/channel/minimax/adaptor.go @@ -0,0 +1,157 @@ +package minimax + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/channel/claude" + "github.com/qianfree/team-api/relay/channel/openai" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/override" +) + +// Adaptor MiniMax 供应商适配器 +type Adaptor struct { + info *common.RelayInfo +} + +func (a *Adaptor) Init(info *common.RelayInfo) { + a.info = info +} + +// GetRequestURL 构建上游请求 URL。 +// Claude 协议走 Anthropic 兼容端点,OpenAI 走 /v1/text/chatcompletion_v2,图片走 /v1/image/generation。 +func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") + + switch constant.RelayMode(info.RelayMode) { + case constant.RelayModeClaudeMessages: + return baseURL + "/anthropic/v1/messages", nil + case constant.RelayModeChatCompletions: + return baseURL + "/v1/text/chatcompletion_v2", nil + case constant.RelayModeImagesGenerations: + return baseURL + "/v1/image/generation", nil + default: + return "", fmt.Errorf("minimax does not support relay mode: %d", info.RelayMode) + } +} + +func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) error { + header.Set("Authorization", "Bearer "+info.ChannelMeta.ApiKey) + header.Set("Content-Type", "application/json") + header.Set("Accept", "application/json") + return nil +} + +// ConvertRequest 转换请求体。 +// Claude 入站直接透传到 Anthropic 兼容端点,图片生成做 MiniMax 格式转换,其他格式先转为 OpenAI 再做模型映射。 +func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { + // 图片生成:OpenAI 格式 → MiniMax 格式 + if constant.RelayMode(info.RelayMode) == constant.RelayModeImagesGenerations { + return convertImageRequest(requestBody, info) + } + + // Claude 入站:仅做模型映射 + if info.InboundFormat == constant.RelayFormatClaude { + return convertClaudeRequest(requestBody, info) + } + + // 非 OpenAI 格式先转换为 OpenAI + if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { + converted, err := openai.ConvertToOpenAI(requestBody, info) + if err != nil { + return nil, err + } + requestBody = converted + } + + if info.ChannelMeta.IsModelMapped { + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(requestBody, &rawMap); err != nil { + return bytes.NewReader(requestBody), nil + } + rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) + converted, err := json.Marshal(rawMap) + if err != nil { + return nil, fmt.Errorf("marshal converted request failed: %w", err) + } + return bytes.NewReader(converted), nil + } + return bytes.NewReader(requestBody), nil +} + +func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + reqURL, err := a.GetRequestURL(info) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + if err := a.SetupRequestHeader(httpReq.Header, info); err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + + if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { + override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) + } + + timeout := info.ChannelMeta.Settings.TimeoutSeconds + if timeout <= 0 { + timeout = 60 + } + + client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) + + return client.Do(httpReq) +} + +// DoResponse 处理上游响应。 +// 图片生成走 MiniMax → OpenAI 转换;Claude 入站委托 claude.Adaptor;其他委托 openai.Adaptor。 +func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + if constant.RelayMode(info.RelayMode) == constant.RelayModeImagesGenerations { + return handleImageResponse(resp, info, writer) + } + + if info.GetOriginalClientFormat() == constant.RelayFormatClaude { + delegate := &claude.Adaptor{} + delegate.Init(info) + return delegate.DoResponse(ctx, resp, info, writer) + } + + delegate := &openai.Adaptor{} + delegate.Init(info) + return delegate.DoResponse(ctx, resp, info, writer) +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} + +var _ common.Adaptor = (*Adaptor)(nil) + +// convertClaudeRequest 处理 Claude 入站请求的模型映射。 +func convertClaudeRequest(requestBody []byte, info *common.RelayInfo) (io.Reader, error) { + if !info.ChannelMeta.IsModelMapped { + return bytes.NewReader(requestBody), nil + } + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(requestBody, &rawMap); err != nil { + return bytes.NewReader(requestBody), nil + } + rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) + result, err := json.Marshal(rawMap) + if err != nil { + return bytes.NewReader(requestBody), nil + } + return bytes.NewReader(result), nil +} diff --git a/relay/channel/minimax/constants.go b/relay/channel/minimax/constants.go new file mode 100644 index 0000000..59f6347 --- /dev/null +++ b/relay/channel/minimax/constants.go @@ -0,0 +1,4 @@ +package minimax + +// ChannelName 渠道名称 +var ChannelName = "MiniMax" diff --git a/relay/channel/minimax/image.go b/relay/channel/minimax/image.go new file mode 100644 index 0000000..fd968f9 --- /dev/null +++ b/relay/channel/minimax/image.go @@ -0,0 +1,181 @@ +package minimax + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/dto" +) + +// MiniMax 图片生成请求/响应结构 + +type miniMaxImageRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + AspectRatio string `json:"aspect_ratio,omitempty"` + ResponseFormat string `json:"response_format,omitempty"` + N int `json:"n,omitempty"` +} + +type miniMaxImageResponse struct { + ID string `json:"id"` + Data struct { + ImageURLs []string `json:"image_urls"` + ImageBase64 []string `json:"image_base64"` + } `json:"data"` + Metadata map[string]any `json:"metadata"` + BaseResp struct { + StatusCode int `json:"status_code"` + StatusMsg string `json:"status_msg"` + } `json:"base_resp"` +} + +// convertImageRequest 将 OpenAI 图片生成请求转换为 MiniMax 格式 +func convertImageRequest(requestBody []byte, info *common.RelayInfo) (io.Reader, error) { + var imgReq dto.ImageRequest + if err := json.Unmarshal(requestBody, &imgReq); err != nil { + return nil, fmt.Errorf("parse image request: %w", err) + } + + minimaxReq := miniMaxImageRequest{ + Model: info.ChannelMeta.UpstreamModelName, + Prompt: imgReq.Prompt, + ResponseFormat: normalizeResponseFormat(imgReq.ResponseFormat), + N: 1, + } + + if minimaxReq.Model == "" { + minimaxReq.Model = "image-01" + } + if imgReq.N != nil && *imgReq.N > 0 { + minimaxReq.N = *imgReq.N + } + if ar := aspectRatioFromSize(imgReq.Size); ar != "" { + minimaxReq.AspectRatio = ar + } + + result, err := json.Marshal(minimaxReq) + if err != nil { + return nil, fmt.Errorf("marshal minimax image request: %w", err) + } + return strings.NewReader(string(result)), nil +} + +// handleImageResponse 将 MiniMax 图片生成响应转换为 OpenAI 格式 +func handleImageResponse(resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read image response body: %w", err) + } + + var mmResp miniMaxImageResponse + if err := json.Unmarshal(body, &mmResp); err != nil { + return nil, fmt.Errorf("parse minimax image response: %w", err) + } + + if mmResp.BaseResp.StatusCode != 0 { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(resp.StatusCode) + errBody, _ := json.Marshal(map[string]any{ + "error": map[string]any{ + "type": "minimax_image_error", + "message": mmResp.BaseResp.StatusMsg, + "code": fmt.Sprintf("%d", mmResp.BaseResp.StatusCode), + }, + }) + _, _ = writer.Write(errBody) + return &common.Usage{}, fmt.Errorf("minimax image error: %s (code %d)", mmResp.BaseResp.StatusMsg, mmResp.BaseResp.StatusCode) + } + + openaiResp := dto.ImageResponse{ + Created: info.StartTime.Unix(), + } + + for _, url := range mmResp.Data.ImageURLs { + openaiResp.Data = append(openaiResp.Data, dto.ImageData{URL: url}) + } + for _, b64 := range mmResp.Data.ImageBase64 { + openaiResp.Data = append(openaiResp.Data, dto.ImageData{B64JSON: b64}) + } + + respBody, err := json.Marshal(openaiResp) + if err != nil { + return nil, fmt.Errorf("marshal openai image response: %w", err) + } + + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(respBody) + + return &common.Usage{}, nil +} + +// normalizeResponseFormat 将 OpenAI 响应格式映射为 MiniMax 格式 +func normalizeResponseFormat(format string) string { + switch strings.ToLower(format) { + case "", "url": + return "url" + case "b64_json", "base64": + return "base64" + default: + return format + } +} + +// aspectRatioFromSize 将 OpenAI 图片尺寸映射为 MiniMax 宽高比 +func aspectRatioFromSize(size string) string { + switch size { + case "1024x1024": + return "1:1" + case "1792x1024": + return "16:9" + case "1024x1792": + return "9:16" + case "1536x1024", "1248x832": + return "3:2" + case "1024x1536", "832x1248": + return "2:3" + case "1152x864": + return "4:3" + case "864x1152": + return "3:4" + case "1344x576": + return "21:9" + } + + parts := strings.Split(size, "x") + if len(parts) != 2 { + return "" + } + w, err1 := strconv.Atoi(parts[0]) + h, err2 := strconv.Atoi(parts[1]) + if err1 != nil || err2 != nil || w <= 0 || h <= 0 { + return "" + } + + d := gcd(w, h) + ratio := fmt.Sprintf("%d:%d", w/d, h/d) + for _, valid := range []string{"1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9"} { + if ratio == valid { + return ratio + } + } + return "" +} + +func gcd(a, b int) int { + for b != 0 { + a, b = b, a%b + } + if a == 0 { + return 1 + } + return a +} diff --git a/relay/channel/mistral/adaptor.go b/relay/channel/mistral/adaptor.go new file mode 100644 index 0000000..2e769e4 --- /dev/null +++ b/relay/channel/mistral/adaptor.go @@ -0,0 +1,118 @@ +package mistral + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/channel/openai" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/override" +) + +// Adaptor Mistral 供应商适配器(OpenAI 兼容) +type Adaptor struct { + info *common.RelayInfo +} + +func (a *Adaptor) Init(info *common.RelayInfo) { + a.info = info +} + +// GetRequestURL 构建上游请求 URL。Mistral 使用标准 OpenAI 路径。 +func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") + + switch constant.RelayMode(info.RelayMode) { + case constant.RelayModeChatCompletions, constant.RelayModeClaudeMessages: + return baseURL + "/v1/chat/completions", nil + case constant.RelayModeCompletions: + return baseURL + "/v1/completions", nil + case constant.RelayModeEmbeddings: + return baseURL + "/v1/embeddings", nil + case constant.RelayModeImagesGenerations: + return baseURL + "/v1/images/generations", nil + default: + return baseURL + "/v1/chat/completions", nil + } +} + +func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) error { + header.Set("Authorization", "Bearer "+info.ChannelMeta.ApiKey) + header.Set("Content-Type", "application/json") + header.Set("Accept", "application/json") + return nil +} + +// ConvertRequest 转换请求体。Mistral 兼容 OpenAI 格式,只需做模型名映射。 +func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { + // 非 OpenAI 格式先转换为 OpenAI + if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { + converted, err := openai.ConvertToOpenAI(requestBody, info) + if err != nil { + return nil, err + } + requestBody = converted + } + + if info.ChannelMeta.IsModelMapped { + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(requestBody, &rawMap); err != nil { + return bytes.NewReader(requestBody), nil + } + rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) + converted, err := json.Marshal(rawMap) + if err != nil { + return nil, fmt.Errorf("marshal converted request failed: %w", err) + } + return bytes.NewReader(converted), nil + } + return bytes.NewReader(requestBody), nil +} + +func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + reqURL, err := a.GetRequestURL(info) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + if err := a.SetupRequestHeader(httpReq.Header, info); err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + + if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { + override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) + } + + timeout := info.ChannelMeta.Settings.TimeoutSeconds + if timeout <= 0 { + timeout = 60 + } + + client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) + + return client.Do(httpReq) +} + +// DoResponse 处理上游响应。Mistral 返回格式与 OpenAI 一致,委托 OpenAI 适配器处理。 +func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + delegate := &openai.Adaptor{} + delegate.Init(info) + return delegate.DoResponse(ctx, resp, info, writer) +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} + +var _ common.Adaptor = (*Adaptor)(nil) diff --git a/relay/channel/mistral/constants.go b/relay/channel/mistral/constants.go new file mode 100644 index 0000000..3df7045 --- /dev/null +++ b/relay/channel/mistral/constants.go @@ -0,0 +1,4 @@ +package mistral + +// ChannelName 渠道名称 +var ChannelName = "Mistral" diff --git a/relay/channel/moonshot/adaptor.go b/relay/channel/moonshot/adaptor.go new file mode 100644 index 0000000..3fb9f69 --- /dev/null +++ b/relay/channel/moonshot/adaptor.go @@ -0,0 +1,150 @@ +package moonshot + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/channel/claude" + "github.com/qianfree/team-api/relay/channel/openai" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/override" +) + +// Adaptor Moonshot 供应商适配器(OpenAI 兼容) +type Adaptor struct { + info *common.RelayInfo +} + +func (a *Adaptor) Init(info *common.RelayInfo) { + a.info = info +} + +// GetRequestURL 构建上游请求 URL。 +// Claude 协议走 Anthropic 兼容端点,其他走标准 OpenAI 路径。 +func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") + + switch constant.RelayMode(info.RelayMode) { + case constant.RelayModeClaudeMessages: + return baseURL + "/anthropic/v1/messages", nil + case constant.RelayModeChatCompletions: + return baseURL + "/v1/chat/completions", nil + case constant.RelayModeCompletions: + return baseURL + "/v1/completions", nil + case constant.RelayModeEmbeddings: + return baseURL + "/v1/embeddings", nil + default: + return baseURL + "/v1/chat/completions", nil + } +} + +func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) error { + header.Set("Authorization", "Bearer "+info.ChannelMeta.ApiKey) + header.Set("Content-Type", "application/json") + header.Set("Accept", "application/json") + return nil +} + +// ConvertRequest 转换请求体。 +// Claude 入站直接透传到 Anthropic 兼容端点,其他格式先转为 OpenAI 再做模型映射。 +func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { + // Claude 入站:仅做模型映射 + if info.InboundFormat == constant.RelayFormatClaude { + return convertClaudeRequest(requestBody, info) + } + + // 非 OpenAI 格式先转换为 OpenAI + if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { + converted, err := openai.ConvertToOpenAI(requestBody, info) + if err != nil { + return nil, err + } + requestBody = converted + } + + if info.ChannelMeta.IsModelMapped { + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(requestBody, &rawMap); err != nil { + return bytes.NewReader(requestBody), nil + } + rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) + converted, err := json.Marshal(rawMap) + if err != nil { + return nil, fmt.Errorf("marshal converted request failed: %w", err) + } + return bytes.NewReader(converted), nil + } + return bytes.NewReader(requestBody), nil +} + +func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + reqURL, err := a.GetRequestURL(info) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + if err := a.SetupRequestHeader(httpReq.Header, info); err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + + if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { + override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) + } + + timeout := info.ChannelMeta.Settings.TimeoutSeconds + if timeout <= 0 { + timeout = 60 + } + + client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) + + return client.Do(httpReq) +} + +// DoResponse 处理上游响应。 +// Claude 入站委托 claude.Adaptor 原生直通;其他格式委托 openai.Adaptor。 +func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + if info.GetOriginalClientFormat() == constant.RelayFormatClaude { + delegate := &claude.Adaptor{} + delegate.Init(info) + return delegate.DoResponse(ctx, resp, info, writer) + } + + delegate := &openai.Adaptor{} + delegate.Init(info) + return delegate.DoResponse(ctx, resp, info, writer) +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} + +var _ common.Adaptor = (*Adaptor)(nil) + +// convertClaudeRequest 处理 Claude 入站请求的模型映射。 +func convertClaudeRequest(requestBody []byte, info *common.RelayInfo) (io.Reader, error) { + if !info.ChannelMeta.IsModelMapped { + return bytes.NewReader(requestBody), nil + } + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(requestBody, &rawMap); err != nil { + return bytes.NewReader(requestBody), nil + } + rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) + result, err := json.Marshal(rawMap) + if err != nil { + return bytes.NewReader(requestBody), nil + } + return bytes.NewReader(result), nil +} diff --git a/relay/channel/moonshot/constants.go b/relay/channel/moonshot/constants.go new file mode 100644 index 0000000..9810989 --- /dev/null +++ b/relay/channel/moonshot/constants.go @@ -0,0 +1,4 @@ +package moonshot + +// ChannelName 渠道名称 +var ChannelName = "Moonshot" diff --git a/relay/channel/ollama/adaptor.go b/relay/channel/ollama/adaptor.go new file mode 100644 index 0000000..70c1993 --- /dev/null +++ b/relay/channel/ollama/adaptor.go @@ -0,0 +1,493 @@ +package ollama + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/qianfree/team-api/relay/channel/openai" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/dto" + "github.com/qianfree/team-api/relay/helper" + "github.com/qianfree/team-api/relay/override" +) + +// Adaptor Ollama 供应商适配器 +type Adaptor struct { + info *common.RelayInfo +} + +// 确保接口实现 +var _ common.Adaptor = (*Adaptor)(nil) + +// Init 初始化适配器 +func (a *Adaptor) Init(info *common.RelayInfo) { + a.info = info +} + +// GetRequestURL 构建上游请求 URL +func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") + + switch constant.RelayMode(info.RelayMode) { + case constant.RelayModeEmbeddings: + return baseURL + "/api/embed", nil + case constant.RelayModeCompletions: + return baseURL + "/api/generate", nil + case constant.RelayModeChatCompletions: + return baseURL + "/api/chat", nil + default: + return baseURL + "/api/chat", nil + } +} + +// SetupRequestHeader 设置上游请求头 +func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) error { + header.Set("Content-Type", "application/json") + // Ollama 认证是可选的 + if info.ChannelMeta.ApiKey != "" { + header.Set("Authorization", "Bearer "+info.ChannelMeta.ApiKey) + } + return nil +} + +// ConvertRequest 将入站请求体转换为 Ollama 原生格式 +func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { + // 非 OpenAI 格式先转换为 OpenAI + if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { + converted, err := openai.ConvertToOpenAI(requestBody, info) + if err != nil { + return nil, err + } + requestBody = converted + } + + var converted []byte + var err error + + switch constant.RelayMode(info.RelayMode) { + case constant.RelayModeChatCompletions: + converted, err = convertChatRequest(requestBody, info) + case constant.RelayModeCompletions: + converted, err = convertCompletionsRequest(requestBody, info) + case constant.RelayModeEmbeddings: + converted, err = convertEmbeddingRequest(requestBody, info) + default: + converted, err = convertChatRequest(requestBody, info) + } + + if err != nil { + return nil, fmt.Errorf("convert request failed: %w", err) + } + return bytes.NewReader(converted), nil +} + +// DoRequest 发送请求到 Ollama 上游 +func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + reqURL, err := a.GetRequestURL(info) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + if err := a.SetupRequestHeader(httpReq.Header, info); err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + + if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { + override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) + } + + // Ollama 默认 300s 超时(大模型推理可能很慢) + timeout := info.ChannelMeta.Settings.TimeoutSeconds + if timeout <= 0 { + timeout = 300 + } + + client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) + + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("send request failed: %w", err) + } + + return resp, nil +} + +// DoResponse 处理 Ollama 上游响应并写回客户端 +func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + switch constant.RelayMode(info.RelayMode) { + case constant.RelayModeEmbeddings: + return a.handleEmbeddingResponse(ctx, resp, info, writer) + case constant.RelayModeCompletions: + if info.IsStream { + return a.handleGenerateStreamResponse(ctx, resp, info, writer) + } + return a.handleGenerateNonStreamResponse(ctx, resp, info, writer) + default: + // Chat Completions + if info.IsStream { + return a.handleChatStreamResponse(ctx, resp, info, writer) + } + return a.handleChatNonStreamResponse(ctx, resp, info, writer) + } +} + +// GetChannelName 返回渠道名称 +func (a *Adaptor) GetChannelName() string { + return ChannelName +} + +// handleChatNonStreamResponse 处理 Ollama Chat 非流式响应,转换为 OpenAI 格式 +func (a *Adaptor) handleChatNonStreamResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "read response body failed", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + var ollamaResp OllamaChatResponse + if err := json.Unmarshal(body, &ollamaResp); err != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "invalid response body", err) + } + + // 转换为 OpenAI ChatCompletion 格式 + finishReason := "stop" + openaiResp := dto.ChatCompletionResponse{ + ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), + Object: "chat.completion", + Created: time.Now().Unix(), + Model: info.OriginModelName, + Choices: []dto.Choice{ + { + Index: 0, + Message: dto.Message{ + Role: ollamaResp.Message.Role, + Content: ollamaResp.Message.Content, + }, + FinishReason: finishReason, + }, + }, + Usage: dto.UsageWithDetails{ + PromptTokens: ollamaResp.PromptEvalCount, + CompletionTokens: ollamaResp.EvalCount, + TotalTokens: ollamaResp.PromptEvalCount + ollamaResp.EvalCount, + }, + } + + respBody, _ := json.Marshal(openaiResp) + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(respBody) + + return &common.Usage{ + PromptTokens: ollamaResp.PromptEvalCount, + CompletionTokens: ollamaResp.EvalCount, + TotalTokens: ollamaResp.PromptEvalCount + ollamaResp.EvalCount, + }, nil +} + +// handleChatStreamResponse 处理 Ollama Chat 流式响应(NDJSON → SSE 转换) +func (a *Adaptor) handleChatStreamResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + helper.SetEventStreamHeaders(writer) + defer helper.PingTicker(writer, 15*time.Second)() + + scanner := bufio.NewScanner(resp.Body) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 10*1024*1024) + + var usage common.Usage + + for scanner.Scan() { + select { + case <-ctx.Done(): + info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, ctx.Err()) + return &usage, common.ErrStreamInterrupted + default: + } + + line := scanner.Text() + if line == "" { + continue + } + + var ollamaResp OllamaChatResponse + if err := json.Unmarshal([]byte(line), &ollamaResp); err != nil { + continue + } + + info.SetFirstResponseTime() + + if ollamaResp.Done { + // 最后一条消息包含用量信息 + usage.PromptTokens = ollamaResp.PromptEvalCount + usage.CompletionTokens = ollamaResp.EvalCount + usage.TotalTokens = ollamaResp.PromptEvalCount + ollamaResp.EvalCount + + // 发送带 finish_reason 的结束 chunk + finishReason := "stop" + endChunk := dto.ChatCompletionStreamResponse{ + ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), + Object: "chat.completion.chunk", + Model: info.OriginModelName, + Choices: []dto.StreamChoice{ + { + Index: 0, + Delta: dto.Message{}, + FinishReason: &finishReason, + }, + }, + Usage: &dto.UsageWithDetails{ + PromptTokens: usage.PromptTokens, + CompletionTokens: usage.CompletionTokens, + TotalTokens: usage.TotalTokens, + }, + } + writeStreamChunk(writer, &endChunk) + break + } + + // 构建 OpenAI 流式 chunk + chunk := dto.ChatCompletionStreamResponse{ + ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), + Object: "chat.completion.chunk", + Model: info.OriginModelName, + Choices: []dto.StreamChoice{ + { + Index: 0, + Delta: dto.Message{ + Role: ollamaResp.Message.Role, + Content: ollamaResp.Message.Content, + }, + }, + }, + } + writeStreamChunk(writer, &chunk) + } + + // 发送 [DONE] + _ = helper.WriteSSEData(writer, "[DONE]") + info.StreamStatus.SetEndReason(common.StreamEndReasonDone, nil) + + if err := scanner.Err(); err != nil && err != io.EOF && ctx.Err() == nil { + info.StreamStatus.SetEndReason(common.StreamEndReasonError, err) + return &usage, fmt.Errorf("stream scanner error: %w", err) + } + + return &usage, nil +} + +// handleGenerateNonStreamResponse 处理 Ollama Generate 非流式响应,转换为 OpenAI Completions 格式 +func (a *Adaptor) handleGenerateNonStreamResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "read response body failed", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + var ollamaResp OllamaGenerateResponse + if err := json.Unmarshal(body, &ollamaResp); err != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "invalid response body", err) + } + + // 转换为 OpenAI Completions 格式 + openaiResp := dto.CompletionsResponse{ + ID: fmt.Sprintf("cmpl-%s", info.RequestID), + Object: "text_completion", + Created: time.Now().Unix(), + Model: info.OriginModelName, + Choices: []dto.CompletionsChoice{ + { + Index: 0, + Text: ollamaResp.Response, + FinishReason: "stop", + }, + }, + Usage: dto.UsageWithDetails{ + PromptTokens: ollamaResp.PromptEvalCount, + CompletionTokens: ollamaResp.EvalCount, + TotalTokens: ollamaResp.PromptEvalCount + ollamaResp.EvalCount, + }, + } + + respBody, _ := json.Marshal(openaiResp) + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(respBody) + + return &common.Usage{ + PromptTokens: ollamaResp.PromptEvalCount, + CompletionTokens: ollamaResp.EvalCount, + TotalTokens: ollamaResp.PromptEvalCount + ollamaResp.EvalCount, + }, nil +} + +// handleGenerateStreamResponse 处理 Ollama Generate 流式响应(NDJSON → SSE 转换) +func (a *Adaptor) handleGenerateStreamResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + helper.SetEventStreamHeaders(writer) + defer helper.PingTicker(writer, 15*time.Second)() + + scanner := bufio.NewScanner(resp.Body) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 10*1024*1024) + + var usage common.Usage + + for scanner.Scan() { + select { + case <-ctx.Done(): + info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, ctx.Err()) + return &usage, common.ErrStreamInterrupted + default: + } + + line := scanner.Text() + if line == "" { + continue + } + + var ollamaResp OllamaGenerateResponse + if err := json.Unmarshal([]byte(line), &ollamaResp); err != nil { + continue + } + + info.SetFirstResponseTime() + + if ollamaResp.Done { + usage.PromptTokens = ollamaResp.PromptEvalCount + usage.CompletionTokens = ollamaResp.EvalCount + usage.TotalTokens = ollamaResp.PromptEvalCount + ollamaResp.EvalCount + + finishReason := "stop" + endChunk := dto.CompletionsStreamResponse{ + ID: fmt.Sprintf("cmpl-%s", info.RequestID), + Object: "text_completion", + Model: info.OriginModelName, + Choices: []dto.CompletionsStreamChoice{ + { + Index: 0, + Text: "", + FinishReason: &finishReason, + }, + }, + Usage: &dto.UsageWithDetails{ + PromptTokens: usage.PromptTokens, + CompletionTokens: usage.CompletionTokens, + TotalTokens: usage.TotalTokens, + }, + } + writeStreamChunk(writer, &endChunk) + break + } + + chunk := dto.CompletionsStreamResponse{ + ID: fmt.Sprintf("cmpl-%s", info.RequestID), + Object: "text_completion", + Model: info.OriginModelName, + Choices: []dto.CompletionsStreamChoice{ + { + Index: 0, + Text: ollamaResp.Response, + }, + }, + } + writeStreamChunk(writer, &chunk) + } + + _ = helper.WriteSSEData(writer, "[DONE]") + info.StreamStatus.SetEndReason(common.StreamEndReasonDone, nil) + + if err := scanner.Err(); err != nil && err != io.EOF && ctx.Err() == nil { + info.StreamStatus.SetEndReason(common.StreamEndReasonError, err) + return &usage, fmt.Errorf("stream scanner error: %w", err) + } + + return &usage, nil +} + +// handleEmbeddingResponse 处理 Ollama Embedding 响应,转换为 OpenAI 格式 +func (a *Adaptor) handleEmbeddingResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "read response body failed", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + var ollamaResp OllamaEmbeddingResponse + if err := json.Unmarshal(body, &ollamaResp); err != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "invalid response body", err) + } + + // 转换为 OpenAI Embedding 格式 + embeddings := make([]dto.Embedding, 0, len(ollamaResp.Embeddings)) + for i, emb := range ollamaResp.Embeddings { + embeddings = append(embeddings, dto.Embedding{ + Object: "embedding", + Index: i, + Embedding: emb, + }) + } + + openaiResp := dto.EmbeddingResponse{ + Object: "list", + Data: embeddings, + Model: info.OriginModelName, + Usage: dto.UsageWithDetails{ + PromptTokens: 0, // Ollama 不返回 embedding 的 token 用量 + TotalTokens: 0, + }, + } + + respBody, _ := json.Marshal(openaiResp) + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(respBody) + + return &common.Usage{}, nil +} + +// writeStreamChunk 将任意结构体序列化为 JSON 并写入 SSE data 行 +func writeStreamChunk(w http.ResponseWriter, chunk any) { + data, _ := json.Marshal(chunk) + _ = helper.WriteSSEData(w, string(data)) +} diff --git a/relay/channel/ollama/constants.go b/relay/channel/ollama/constants.go new file mode 100644 index 0000000..24e01ca --- /dev/null +++ b/relay/channel/ollama/constants.go @@ -0,0 +1,4 @@ +package ollama + +// ChannelName Ollama 渠道名称 +var ChannelName = "Ollama" diff --git a/relay/channel/ollama/converter.go b/relay/channel/ollama/converter.go new file mode 100644 index 0000000..c30ad2a --- /dev/null +++ b/relay/channel/ollama/converter.go @@ -0,0 +1,220 @@ +package ollama + +import ( + "encoding/json" + "fmt" + + "github.com/qianfree/team-api/relay/common" +) + +// convertChatRequest 将 OpenAI Chat Completions 请求转换为 Ollama Chat 格式 +func convertChatRequest(requestBody []byte, info *common.RelayInfo) ([]byte, error) { + var rawReq map[string]json.RawMessage + if err := json.Unmarshal(requestBody, &rawReq); err != nil { + return nil, fmt.Errorf("parse request body failed: %w", err) + } + + // 确定模型名 + model := info.OriginModelName + if info.ChannelMeta.IsModelMapped { + model = info.ChannelMeta.UpstreamModelName + } + + // 解析消息 + var openaiMessages []struct { + Role string `json:"role"` + Content any `json:"content"` + } + if raw, ok := rawReq["messages"]; ok { + if err := json.Unmarshal(raw, &openaiMessages); err != nil { + return nil, fmt.Errorf("parse messages failed: %w", err) + } + } + + ollamaMessages := make([]OllamaMessage, 0, len(openaiMessages)) + for _, msg := range openaiMessages { + ollamaMsg := OllamaMessage{ + Role: msg.Role, + } + // content 可以是 string 或 []ContentPart + switch c := msg.Content.(type) { + case string: + ollamaMsg.Content = c + case []any: + // 多模态内容数组,提取文本部分 + for _, part := range c { + partMap, ok := part.(map[string]any) + if !ok { + continue + } + partType, _ := partMap["type"].(string) + if partType == "text" { + if text, ok := partMap["text"].(string); ok { + ollamaMsg.Content += text + } + } + } + default: + // 尝试 JSON 序列化为字符串 + if c != nil { + b, _ := json.Marshal(c) + ollamaMsg.Content = string(b) + } + } + ollamaMessages = append(ollamaMessages, ollamaMsg) + } + + // 解析 stream + stream := info.IsStream + + // 构建 options + options := buildOptions(rawReq) + + ollamaReq := OllamaChatRequest{ + Model: model, + Messages: ollamaMessages, + Stream: stream, + Options: options, + } + + return json.Marshal(ollamaReq) +} + +// convertCompletionsRequest 将 OpenAI Completions 请求转换为 Ollama Generate 格式 +func convertCompletionsRequest(requestBody []byte, info *common.RelayInfo) ([]byte, error) { + var rawReq map[string]json.RawMessage + if err := json.Unmarshal(requestBody, &rawReq); err != nil { + return nil, fmt.Errorf("parse request body failed: %w", err) + } + + // 确定模型名 + model := info.OriginModelName + if info.ChannelMeta.IsModelMapped { + model = info.ChannelMeta.UpstreamModelName + } + + // 解析 prompt + var prompt string + if raw, ok := rawReq["prompt"]; ok { + // prompt 可以是 string 或 []string + if err := json.Unmarshal(raw, &prompt); err != nil { + // 尝试 []string + var prompts []string + if err2 := json.Unmarshal(raw, &prompts); err2 == nil && len(prompts) > 0 { + prompt = prompts[0] + } + } + } + + // 解析 stream + stream := info.IsStream + + // 构建 options + options := buildOptions(rawReq) + + ollamaReq := OllamaGenerateRequest{ + Model: model, + Prompt: prompt, + Stream: stream, + Options: options, + } + + return json.Marshal(ollamaReq) +} + +// convertEmbeddingRequest 将 OpenAI Embedding 请求转换为 Ollama Embedding 格式 +func convertEmbeddingRequest(requestBody []byte, info *common.RelayInfo) ([]byte, error) { + var rawReq map[string]json.RawMessage + if err := json.Unmarshal(requestBody, &rawReq); err != nil { + return nil, fmt.Errorf("parse request body failed: %w", err) + } + + // 确定模型名 + model := info.OriginModelName + if info.ChannelMeta.IsModelMapped { + model = info.ChannelMeta.UpstreamModelName + } + + // 解析 input(string 或 []string) + var input []string + if raw, ok := rawReq["input"]; ok { + var singleInput string + if err := json.Unmarshal(raw, &singleInput); err == nil { + input = []string{singleInput} + } else { + if err := json.Unmarshal(raw, &input); err != nil { + return nil, fmt.Errorf("parse input failed: %w", err) + } + } + } + + ollamaReq := OllamaEmbeddingRequest{ + Model: model, + Input: input, + } + + return json.Marshal(ollamaReq) +} + +// buildOptions 从 OpenAI 请求参数构建 Ollama options +func buildOptions(rawReq map[string]json.RawMessage) map[string]any { + options := make(map[string]any) + + // temperature → temperature + if raw, ok := rawReq["temperature"]; ok { + var v float64 + if json.Unmarshal(raw, &v) == nil { + options["temperature"] = v + } + } + + // max_tokens / max_completion_tokens → num_predict + if raw, ok := rawReq["max_tokens"]; ok { + var v int + if json.Unmarshal(raw, &v) == nil { + options["num_predict"] = v + } + } else if raw, ok := rawReq["max_completion_tokens"]; ok { + var v int + if json.Unmarshal(raw, &v) == nil { + options["num_predict"] = v + } + } + + // top_p → top_p + if raw, ok := rawReq["top_p"]; ok { + var v float64 + if json.Unmarshal(raw, &v) == nil { + options["top_p"] = v + } + } + + // stop → stop + if raw, ok := rawReq["stop"]; ok { + var v any + if json.Unmarshal(raw, &v) == nil && v != nil { + options["stop"] = v + } + } + + // frequency_penalty → frequency_penalty + if raw, ok := rawReq["frequency_penalty"]; ok { + var v float64 + if json.Unmarshal(raw, &v) == nil { + options["frequency_penalty"] = v + } + } + + // presence_penalty → presence_penalty + if raw, ok := rawReq["presence_penalty"]; ok { + var v float64 + if json.Unmarshal(raw, &v) == nil { + options["presence_penalty"] = v + } + } + + if len(options) == 0 { + return nil + } + return options +} diff --git a/relay/channel/ollama/dto.go b/relay/channel/ollama/dto.go new file mode 100644 index 0000000..fdda756 --- /dev/null +++ b/relay/channel/ollama/dto.go @@ -0,0 +1,58 @@ +package ollama + +// OllamaChatRequest Ollama Chat 请求 +type OllamaChatRequest struct { + Model string `json:"model"` + Messages []OllamaMessage `json:"messages"` + Stream bool `json:"stream"` + Options map[string]any `json:"options,omitempty"` +} + +// OllamaMessage Ollama 消息格式 +type OllamaMessage struct { + Role string `json:"role"` + Content string `json:"content"` + Images []string `json:"images,omitempty"` +} + +// OllamaGenerateRequest Ollama Generate(文本补全)请求 +type OllamaGenerateRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + Stream bool `json:"stream"` + Options map[string]any `json:"options,omitempty"` +} + +// OllamaEmbeddingRequest Ollama Embedding 请求 +type OllamaEmbeddingRequest struct { + Model string `json:"model"` + Input []string `json:"input"` +} + +// OllamaChatResponse Ollama Chat 响应(流式和非流式共用) +type OllamaChatResponse struct { + Model string `json:"model"` + CreatedAt string `json:"created_at"` + Message OllamaMessage `json:"message"` + Done bool `json:"done"` + TotalDuration int64 `json:"total_duration,omitempty"` + PromptEvalCount int `json:"prompt_eval_count,omitempty"` + EvalCount int `json:"eval_count,omitempty"` +} + +// OllamaGenerateResponse Ollama Generate 响应(流式和非流式共用) +type OllamaGenerateResponse struct { + Model string `json:"model"` + CreatedAt string `json:"created_at"` + Response string `json:"response"` + Done bool `json:"done"` + TotalDuration int64 `json:"total_duration,omitempty"` + PromptEvalCount int `json:"prompt_eval_count,omitempty"` + EvalCount int `json:"eval_count,omitempty"` +} + +// OllamaEmbeddingResponse Ollama Embedding 响应 +type OllamaEmbeddingResponse struct { + Model string `json:"model"` + Embeddings [][]float64 `json:"embeddings"` +} diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go new file mode 100644 index 0000000..2474bb8 --- /dev/null +++ b/relay/channel/openai/adaptor.go @@ -0,0 +1,494 @@ +package openai + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/gogf/gf/v2/frame/g" + + loauth "github.com/qianfree/team-api/internal/logic/common/oauth" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/dto" + "github.com/qianfree/team-api/relay/override" +) + +// Adaptor OpenAI 供应商适配器 +type Adaptor struct { + info *common.RelayInfo +} + +// Init 初始化适配器 +func (a *Adaptor) Init(info *common.RelayInfo) { + a.info = info +} + +// GetRequestURL 构建上游请求 URL +func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") + + switch constant.RelayMode(info.RelayMode) { + case constant.RelayModeChatCompletions, constant.RelayModeClaudeMessages: + if info.UseResponsesAPI { + return baseURL + "/v1/responses", nil + } + return baseURL + "/v1/chat/completions", nil + case constant.RelayModeCompletions: + return baseURL + "/v1/completions", nil + case constant.RelayModeEmbeddings: + return baseURL + "/v1/embeddings", nil + case constant.RelayModeImagesGenerations: + return baseURL + "/v1/images/generations", nil + case constant.RelayModeImagesEdits: + return baseURL + "/v1/images/edits", nil + case constant.RelayModeModerations: + return baseURL + "/v1/moderations", nil + case constant.RelayModeAudioSpeech: + return baseURL + "/v1/audio/speech", nil + case constant.RelayModeAudioTranscription: + return baseURL + "/v1/audio/transcriptions", nil + case constant.RelayModeAudioTranslation: + return baseURL + "/v1/audio/translations", nil + case constant.RelayModeRerank: + return baseURL + "/v1/rerank", nil + case constant.RelayModeResponses, constant.RelayModeResponsesCompact: + // Responses API 请求会被转换为 Chat Completions 格式发送 + return baseURL + "/v1/chat/completions", nil + default: + return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode) + } +} + +// SetupRequestHeader 设置上游请求头 +func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) error { + apiKey := info.ChannelMeta.ApiKey + + // OAuth 模式:Bearer token + chatgpt-account-id + if loauth.IsOAuthKeyData(apiKey) { + var oauthData loauth.OAuthKeyData + if err := json.Unmarshal([]byte(apiKey), &oauthData); err == nil { + header.Set("Authorization", "Bearer "+oauthData.AccessToken) + if oauthData.AccountID != "" { + header.Set("chatgpt-account-id", oauthData.AccountID) + } + } + } else { + header.Set("Authorization", "Bearer "+apiKey) + } + + // Audio STT/翻译使用 multipart form,不强制 JSON Content-Type + mode := constant.RelayMode(info.RelayMode) + if mode != constant.RelayModeAudioTranscription && mode != constant.RelayModeAudioTranslation && mode != constant.RelayModeImagesEdits { + // 透传客户端 Content-Type,无则回退 application/json + if ct := info.RequestHeaders.Get("Content-Type"); ct != "" { + header.Set("Content-Type", ct) + } else { + header.Set("Content-Type", "application/json") + } + } + + // 透传客户端 Accept,流式请求回退 text/event-stream,否则 application/json + if accept := info.RequestHeaders.Get("Accept"); accept != "" { + header.Set("Accept", accept) + } else if info.IsStream { + header.Set("Accept", "text/event-stream") + } else { + header.Set("Accept", "application/json") + } + + if info.RequestHeaders != nil { + for _, h := range []string{"X-Request-Id"} { + if v := info.RequestHeaders.Get(h); v != "" { + header.Set(h, v) + } + } + } + + return nil +} + +// ConvertRequest 根据入站格式转换请求体为 OpenAI 格式,然后做 OpenAI 特有后处理。 +func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { + mode := constant.RelayMode(info.RelayMode) + + // Audio/Rerank: OpenAI 原生格式直接透传(不做 replaceModelIfNeeded 和 injectStreamOptions) + switch mode { + case constant.RelayModeAudioSpeech, constant.RelayModeAudioTranscription, + constant.RelayModeAudioTranslation, constant.RelayModeRerank: + return bytes.NewReader(requestBody), nil + } + + var converted io.Reader + switch info.InboundFormat { + case constant.RelayFormatClaude: + r, err := ConvertClaudeToOpenAI(requestBody, info) + if err != nil { + return nil, err + } + converted = r + case constant.RelayFormatGemini: + r, err := ConvertGeminiToOpenAI(requestBody, info) + if err != nil { + return nil, err + } + converted = r + case constant.RelayFormatResponses: + r, err := ConvertResponsesToOpenAI(requestBody, info) + if err != nil { + return nil, err + } + converted = r + default: + converted = bytes.NewReader(requestBody) + } + result := injectStreamOptions(replaceModelIfNeeded(converted, info), info) + + // Thinking 后缀路由:注入 reasoning_effort + if info.ReasoningEffort != "" { + result = injectReasoningEffort(result, info.ReasoningEffort) + } + + return result, nil +} + +// DoRequest 发送请求到上游 +func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + reqURL, err := a.GetRequestURL(info) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + if err := a.SetupRequestHeader(httpReq.Header, info); err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + + // 应用渠道级 Header Override(透传客户端 header 等) + if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { + override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) + } + + mode := constant.RelayMode(info.RelayMode) + if httpReq.Header.Get("Content-Type") == "" && requestBody != nil && + mode != constant.RelayModeAudioTranscription && mode != constant.RelayModeAudioTranslation { + httpReq.Header.Set("Content-Type", "application/json") + } + + // Multipart form: 保留客户端原始 Content-Type(含 boundary) + if (mode == constant.RelayModeAudioTranscription || mode == constant.RelayModeAudioTranslation) && + info.RequestHeaders != nil { + if ct := info.RequestHeaders.Get("Content-Type"); ct != "" { + httpReq.Header.Set("Content-Type", ct) + } + } + + timeout := info.ChannelMeta.Settings.TimeoutSeconds + if timeout <= 0 { + timeout = 60 + } + + client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) + + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("send request failed: %w", err) + } + + // 请求体已完整发送,立即关闭释放资源 + if httpReq.Body != nil { + _ = httpReq.Body.Close() + } + + return resp, nil +} + +// DoResponse 处理上游响应,按客户端格式和 RelayMode 分发 +func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + clientFormat := info.GetOriginalClientFormat() + g.Log().Infof(ctx, "[OpenAI.Adaptor.DoResponse] Entry: clientFormat=%s, relayMode=%d, isStream=%v, statusCode=%d", + clientFormat, info.RelayMode, info.IsStream, resp.StatusCode) + + // 根据客户端格式转换响应 + switch clientFormat { + case constant.RelayFormatClaude: + if info.IsStream { + return handleClaudeInboundStream(ctx, resp, info, writer) + } + return handleClaudeInboundNonStream(ctx, resp, info, writer) + case constant.RelayFormatGemini: + if info.IsStream { + return handleGeminiInboundStream(ctx, resp, info, writer) + } + return handleGeminiInboundNonStream(ctx, resp, info, writer) + case constant.RelayFormatResponses: + if info.IsStream { + return a.handleResponsesInboundStream(ctx, resp, info, writer) + } + return a.handleResponsesInboundNonStream(ctx, resp, info, writer) + } + + // Chat Completions via Responses API 桥接 + if info.UseResponsesAPI { + g.Log().Infof(ctx, "[OpenAI.Adaptor.DoResponse] Chat via Responses bridge (stream=%v)", info.IsStream) + if info.IsStream { + return a.handleChatViaResponsesStream(ctx, resp, info, writer) + } + return a.handleChatViaResponsesNonStream(ctx, resp, info, writer) + } + + switch constant.RelayMode(info.RelayMode) { + case constant.RelayModeChatCompletions: + if info.IsStream { + return a.handleChatStreamResponse(ctx, resp, info, writer) + } + return a.handleChatNonStreamResponse(ctx, resp, info, writer) + + case constant.RelayModeCompletions: + if info.IsStream { + return a.handleCompletionStreamResponse(ctx, resp, info, writer) + } + return a.handleCompletionNonStreamResponse(ctx, resp, info, writer) + + case constant.RelayModeEmbeddings: + return a.handleEmbeddingResponse(ctx, resp, info, writer) + + case constant.RelayModeImagesGenerations, constant.RelayModeImagesEdits: + return a.handleImageResponse(ctx, resp, info, writer) + case constant.RelayModeModerations: + return a.handleModerationResponse(ctx, resp, info, writer) + + case constant.RelayModeAudioSpeech: + return handleAudioSpeechResponse(ctx, resp, info, writer) + case constant.RelayModeAudioTranscription, constant.RelayModeAudioTranslation: + return handleAudioTranscriptionResponse(ctx, resp, info, writer) + case constant.RelayModeRerank: + return handleRerankResponse(ctx, resp, info, writer) + + default: + return a.handleChatNonStreamResponse(ctx, resp, info, writer) + } +} + +// handleChatNonStreamResponse 处理 Chat Completions 非流式响应 +func (a *Adaptor) handleChatNonStreamResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "read response body failed", err) + } + + if resp.StatusCode != http.StatusOK { + // 尝试解析上游错误格式,如果是标准格式则透传原始响应 + if isUpstreamOpenAIError(body) { + writeUpstreamErrorResponse(writer, resp.StatusCode, body) + return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + if info.ChannelMeta.IsModelMapped { + body = replaceModelName(body, info.OriginModelName) + } + + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(body) + + var chatResp dto.ChatCompletionResponse + if err := json.Unmarshal(body, &chatResp); err == nil { + return &common.Usage{ + PromptTokens: chatResp.Usage.PromptTokens, + CompletionTokens: chatResp.Usage.CompletionTokens, + TotalTokens: chatResp.Usage.TotalTokens, + PromptTokensDetails: common.DtoTokenDetailsToCommon(chatResp.Usage.PromptTokensDetails), + CompletionTokenDetails: common.DtoTokenDetailsToCommon(chatResp.Usage.CompletionTokenDetails), + CacheIncludedInPrompt: true, + }, nil + } + + return &common.Usage{}, nil +} + +// handleChatStreamResponse 处理 Chat Completions 流式响应 +func (a *Adaptor) handleChatStreamResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + // 尝试透传上游错误格式 + if isUpstreamOpenAIError(body) { + writeUpstreamErrorResponse(writer, resp.StatusCode, body) + return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + return StreamHandler(ctx, resp, info, writer) +} + +// handleCompletionStreamResponse 处理 Completions 流式响应 +func (a *Adaptor) handleCompletionStreamResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + return StreamHandlerForCompletions(ctx, resp, info, writer) +} + +// replaceModelName 替换响应 JSON 中 "model":"xxx" 的值为指定模型名 +func replaceModelName(body []byte, modelName string) []byte { + target := []byte(`"model":"`) + replacement := append(target, modelName...) + replacement = append(replacement, '"') + return replaceModelNameInJSON(body, target, replacement) +} + +// replaceModelNameInJSON 替换 JSON 中 "model" 字段的值 +func replaceModelNameInJSON(body, fieldPrefix, replacement []byte) []byte { + result := make([]byte, 0, len(body)) + i := 0 + for i < len(body) { + idx := bytes.Index(body[i:], fieldPrefix) + if idx == -1 { + result = append(result, body[i:]...) + break + } + result = append(result, body[i:i+idx+len(fieldPrefix)]...) + i += idx + len(fieldPrefix) + + // 跳到引号结束 + endQuote := bytes.IndexByte(body[i:], '"') + if endQuote == -1 { + result = append(result, body[i:]...) + break + } + i += endQuote + 1 + result = append(result, replacement...) + } + return result +} + +// GetChannelName 返回渠道名称 +func (a *Adaptor) GetChannelName() string { + return "OpenAI" +} + +// 确保接口实现 +var _ common.Adaptor = (*Adaptor)(nil) + +// replaceModelIfNeeded 如果渠道有模型映射,替换请求体中的模型名 +func replaceModelIfNeeded(r io.Reader, info *common.RelayInfo) io.Reader { + if !info.ChannelMeta.IsModelMapped { + return r + } + body, err := io.ReadAll(r) + if err != nil { + return r + } + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(body, &rawMap); err != nil { + return bytes.NewReader(body) + } + rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) + result, err := json.Marshal(rawMap) + if err != nil { + return bytes.NewReader(body) + } + return bytes.NewReader(result) +} + +// injectStreamOptions 为流式请求注入 stream_options:{include_usage:true} +// 确保上游在流式响应的最后一个 chunk 中返回 usage 信息 +func injectStreamOptions(r io.Reader, info *common.RelayInfo) io.Reader { + if !info.IsStream { + return r + } + body, err := io.ReadAll(r) + if err != nil { + return r + } + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(body, &rawMap); err != nil { + return bytes.NewReader(body) + } + // 仅在客户端未显式设置 stream_options 时注入 + if _, exists := rawMap["stream_options"]; !exists { + rawMap["stream_options"] = json.RawMessage(`{"include_usage":true}`) + result, err := json.Marshal(rawMap) + if err != nil { + return bytes.NewReader(body) + } + return bytes.NewReader(result) + } + return bytes.NewReader(body) +} + +// injectReasoningEffort 注入 reasoning_effort 字段到请求体 +func injectReasoningEffort(r io.Reader, effort string) io.Reader { + body, err := io.ReadAll(r) + if err != nil { + return r + } + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(body, &rawMap); err != nil { + return bytes.NewReader(body) + } + // 仅在客户端未显式设置时注入 + if _, exists := rawMap["reasoning_effort"]; !exists { + rawMap["reasoning_effort"], _ = json.Marshal(effort) + result, err := json.Marshal(rawMap) + if err != nil { + return bytes.NewReader(body) + } + return bytes.NewReader(result) + } + return bytes.NewReader(body) +} + +// isUpstreamOpenAIError 检查上游响应是否为标准 OpenAI 错误格式 +// 标准格式: {"error": {"type": "...", "message": "...", ...}} +func isUpstreamOpenAIError(body []byte) bool { + var raw map[string]json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil { + return false + } + _, hasError := raw["error"] + return hasError +} + +// writeUpstreamErrorResponse 将上游错误响应原样写入客户端 +func writeUpstreamErrorResponse(writer http.ResponseWriter, statusCode int, body []byte) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(statusCode) + _, _ = writer.Write(body) +} + +// handleModerationResponse 处理 Moderations 响应(透传) +func (a *Adaptor) handleModerationResponse(_ context.Context, resp *http.Response, _ *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "read response body failed", err) + } + if resp.StatusCode != http.StatusOK { + writeUpstreamErrorResponse(writer, resp.StatusCode, body) + return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(body) + return &common.Usage{}, nil +} diff --git a/relay/channel/openai/audio.go b/relay/channel/openai/audio.go new file mode 100644 index 0000000..2642643 --- /dev/null +++ b/relay/channel/openai/audio.go @@ -0,0 +1,114 @@ +package openai + +import ( + "context" + "encoding/json" + "io" + "net/http" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/dto" + + "github.com/gogf/gf/v2/frame/g" +) + +// handleAudioSpeechResponse 处理 TTS 响应(二进制音频流) +func handleAudioSpeechResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + if isUpstreamOpenAIError(body) { + writeUpstreamErrorResponse(writer, resp.StatusCode, body) + return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + // 透传响应头(Content-Type 可能是 audio/mpeg 等) + for k, vs := range resp.Header { + for _, v := range vs { + writer.Header().Add(k, v) + } + } + writer.WriteHeader(http.StatusOK) + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "read audio response failed", err) + } + + if _, err := writer.Write(body); err != nil { + g.Log().Warningf(ctx, "[OpenAI.handleAudioSpeechResponse] write audio body failed: %v", err) + } + + // TTS 没有标准 usage 返回,按音频大小粗略估算 + // 1 分钟音频 ≈ 1MB mp3 ≈ 1000 tokens + estimatedTokens := len(body) / 1000 + if estimatedTokens == 0 { + estimatedTokens = 1 + } + return &common.Usage{ + CompletionTokens: estimatedTokens, + TotalTokens: estimatedTokens, + }, nil +} + +// handleAudioTranscriptionResponse 处理 STT/翻译响应(JSON) +func handleAudioTranscriptionResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "read transcription response failed", err) + } + + if resp.StatusCode != http.StatusOK { + if isUpstreamOpenAIError(body) { + writeUpstreamErrorResponse(writer, resp.StatusCode, body) + return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(body) + + // 尝试从响应中提取 usage + usage := extractAudioUsage(body) + return usage, nil +} + +// extractAudioUsage 从 STT/翻译响应中提取 token 使用量 +func extractAudioUsage(body []byte) *common.Usage { + // 尝试解析为 verbose_json 格式 + var verboseResp dto.WhisperVerboseJSONResponse + if err := json.Unmarshal(body, &verboseResp); err == nil && verboseResp.Duration > 0 { + // 按音频时长估算:1 分钟 ≈ 150 tokens + estimatedTokens := int(verboseResp.Duration/60.0) * 150 + if estimatedTokens == 0 { + estimatedTokens = 1 + } + return &common.Usage{ + PromptTokens: estimatedTokens, + TotalTokens: estimatedTokens, + } + } + + // 普通 json 格式按文本长度估算 + var simpleResp dto.AudioResponse + if err := json.Unmarshal(body, &simpleResp); err == nil { + estimatedTokens := len(simpleResp.Text) / 4 + if estimatedTokens == 0 { + estimatedTokens = 1 + } + return &common.Usage{ + PromptTokens: estimatedTokens, + TotalTokens: estimatedTokens, + } + } + + return &common.Usage{} +} diff --git a/relay/channel/openai/chat_via_responses.go b/relay/channel/openai/chat_via_responses.go new file mode 100644 index 0000000..1c1db0d --- /dev/null +++ b/relay/channel/openai/chat_via_responses.go @@ -0,0 +1,32 @@ +package openai + +import ( + "context" + "net/http" + + "github.com/gogf/gf/v2/frame/g" + + "github.com/qianfree/team-api/relay/common" +) + +// handleChatViaResponsesNonStream 处理 Chat Completions via Responses API 的非流式响应 +func (a *Adaptor) handleChatViaResponsesNonStream( + ctx context.Context, + resp *http.Response, + info *common.RelayInfo, + writer http.ResponseWriter, +) (*common.Usage, error) { + g.Log().Infof(ctx, "[OpenAI] Chat via Responses bridge: non-stream mode") + return HandleResponsesNonStreamToChat(ctx, resp, info, writer) +} + +// handleChatViaResponsesStream 处理 Chat Completions via Responses API 的流式响应 +func (a *Adaptor) handleChatViaResponsesStream( + ctx context.Context, + resp *http.Response, + info *common.RelayInfo, + writer http.ResponseWriter, +) (*common.Usage, error) { + g.Log().Infof(ctx, "[OpenAI] Chat via Responses bridge: stream mode") + return HandleResponsesStreamToChat(ctx, resp, info, writer) +} diff --git a/relay/channel/openai/claude_response.go b/relay/channel/openai/claude_response.go new file mode 100644 index 0000000..bfc53e4 --- /dev/null +++ b/relay/channel/openai/claude_response.go @@ -0,0 +1,442 @@ +package openai + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/dto" + "github.com/qianfree/team-api/relay/helper" +) + +// handleClaudeInboundNonStream 将 OpenAI 非流式响应转换为 Claude 格式 +func handleClaudeInboundNonStream(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response body failed: %w", err) + } + + if resp.StatusCode != http.StatusOK { + // 将上游错误转换为 Claude 格式透传给客户端 + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(resp.StatusCode) + claudeErr, _ := json.Marshal(map[string]any{ + "type": "error", + "error": map[string]any{"type": "api_error", "message": string(body)}, + }) + _, _ = writer.Write(claudeErr) + return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + var openaiResp dto.ChatCompletionResponse + if err := json.Unmarshal(body, &openaiResp); err != nil { + return nil, fmt.Errorf("invalid response body: %w", err) + } + + // 转换为 Claude 格式 + claudeResp := openAIToClaudeResponse(&openaiResp, info) + + respBody, _ := json.Marshal(claudeResp) + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(respBody) + + usage := &common.Usage{} + if len(openaiResp.Choices) > 0 { + usage.PromptTokens = openaiResp.Usage.PromptTokens + usage.CompletionTokens = openaiResp.Usage.CompletionTokens + usage.TotalTokens = openaiResp.Usage.TotalTokens + usage.PromptTokensDetails = common.DtoTokenDetailsToCommon(openaiResp.Usage.PromptTokensDetails) + usage.CompletionTokenDetails = common.DtoTokenDetailsToCommon(openaiResp.Usage.CompletionTokenDetails) + } + usage.CacheIncludedInPrompt = true + return usage, nil +} + +// handleClaudeInboundStream 将 OpenAI SSE 流转换为 Claude SSE 流 +func handleClaudeInboundStream(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(resp.StatusCode) + claudeErr, _ := json.Marshal(map[string]any{ + "type": "error", + "error": map[string]any{"type": "api_error", "message": string(body)}, + }) + _, _ = writer.Write(claudeErr) + return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + helper.SetEventStreamHeaders(writer) + defer helper.PingTicker(writer, 15*time.Second)() + + scanner := bufio.NewScanner(resp.Body) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 10*1024*1024) + + msgID := fmt.Sprintf("msg_%s", info.RequestID) + modelName := info.OriginModelName + if info.ChannelMeta.IsModelMapped { + modelName = info.ChannelMeta.UpstreamModelName + } + + var ( + usage common.Usage + startSent bool + finishReason string + contentIndex int + inputTokens int + outputTokens int + currentBlockType string // 跟踪当前 block 类型: "text" 或 "tool_use" + ) + + for scanner.Scan() { + select { + case <-ctx.Done(): + info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, ctx.Err()) + return &usage, common.ErrStreamInterrupted + default: + } + + line := scanner.Text() + if line == "" || strings.HasPrefix(line, "event:") { + continue + } + if !strings.HasPrefix(line, "data:") { + continue + } + + data, _ := helper.ExtractSSEData(line) + + if data != "" && data != "[DONE]" { + info.SetFirstResponseTime() + } + + if data == "[DONE]" { + // 关闭当前 content block(如果有的话) + if currentBlockType != "" { + blockStop := dto.ClaudeResponse{ + Type: "content_block_stop", + Index: intPtr(contentIndex), + } + writeClaudeSSE(writer, "content_block_stop", &blockStop) + contentIndex++ + currentBlockType = "" + } + + // 发送 message_delta(stop_reason)+ message_stop + reason := common.OpenAIFinishReasonToClaude(finishReason) + delta := dto.ClaudeResponse{ + Type: "message_delta", + Delta: &dto.ClaudeDelta{ + StopReason: strPtr(reason), + }, + Usage: &dto.ClaudeUsage{ + OutputTokens: outputTokens, + }, + } + writeClaudeSSE(writer, "message_delta", &delta) + + stopEvent := dto.ClaudeResponse{Type: "message_stop"} + writeClaudeSSE(writer, "message_stop", &stopEvent) + + info.StreamStatus.SetEndReason(common.StreamEndReasonDone, nil) + break + } + + var streamResp dto.ChatCompletionStreamResponse + if err := json.Unmarshal([]byte(data), &streamResp); err != nil { + continue + } + + // 提取 usage + if streamResp.Usage != nil { + inputTokens = streamResp.Usage.PromptTokens + outputTokens = streamResp.Usage.CompletionTokens + } + + // 发送 message_start(首块数据时) + if !startSent { + startEvent := dto.ClaudeResponse{ + Type: "message_start", + Message: &dto.ClaudeMessageInfo{ + ID: msgID, + Type: "message", + Role: "assistant", + Content: []dto.ClaudeContentBlock{}, + Model: modelName, + StopReason: nil, + StopSequence: nil, + Usage: &dto.ClaudeUsage{ + InputTokens: inputTokens, + OutputTokens: 0, + }, + }, + } + writeClaudeSSE(writer, "message_start", &startEvent) + startSent = true + } + + // 处理每个 choice delta + for _, choice := range streamResp.Choices { + // 文本内容 + if text, ok := choice.Delta.Content.(string); ok && text != "" { + // 如果当前 block 不是 text,先关闭前一个 block + if currentBlockType != "" && currentBlockType != "text" { + blockStop := dto.ClaudeResponse{ + Type: "content_block_stop", + Index: intPtr(contentIndex), + } + writeClaudeSSE(writer, "content_block_stop", &blockStop) + contentIndex++ + } + + // content_block_start(首次文本输出) + if currentBlockType != "text" { + blockStart := dto.ClaudeResponse{ + Type: "content_block_start", + Index: intPtr(contentIndex), + ContentBlock: &dto.ClaudeContentBlock{ + Type: "text", + Text: strPtr(""), + }, + } + writeClaudeSSE(writer, "content_block_start", &blockStart) + currentBlockType = "text" + } + + delta := dto.ClaudeResponse{ + Type: "content_block_delta", + Index: intPtr(contentIndex), + Delta: &dto.ClaudeDelta{ + Type: "text_delta", + Text: strPtr(text), + }, + } + writeClaudeSSE(writer, "content_block_delta", &delta) + } + + // reasoning content (thinking) + if choice.Delta.ReasoningContent != nil && *choice.Delta.ReasoningContent != "" { + // 如果当前 block 不是 thinking,先关闭前一个 block + if currentBlockType != "" && currentBlockType != "thinking" { + blockStop := dto.ClaudeResponse{ + Type: "content_block_stop", + Index: intPtr(contentIndex), + } + writeClaudeSSE(writer, "content_block_stop", &blockStop) + contentIndex++ + } + + if currentBlockType != "thinking" { + blockStart := dto.ClaudeResponse{ + Type: "content_block_start", + Index: intPtr(contentIndex), + ContentBlock: &dto.ClaudeContentBlock{ + Type: "thinking", + Thinking: strPtr(""), + }, + } + writeClaudeSSE(writer, "content_block_start", &blockStart) + currentBlockType = "thinking" + } + + delta := dto.ClaudeResponse{ + Type: "content_block_delta", + Index: intPtr(contentIndex), + Delta: &dto.ClaudeDelta{ + Type: "thinking_delta", + Thinking: choice.Delta.ReasoningContent, + }, + } + writeClaudeSSE(writer, "content_block_delta", &delta) + } + + // tool calls + for _, tc := range choice.Delta.ToolCalls { + // content_block_start for tool_use(仅在 function name 出现时) + if tc.Function.Name != "" { + // 先关闭前一个 block + if currentBlockType != "" { + blockStop := dto.ClaudeResponse{ + Type: "content_block_stop", + Index: intPtr(contentIndex), + } + writeClaudeSSE(writer, "content_block_stop", &blockStop) + contentIndex++ + } + + blockStart := dto.ClaudeResponse{ + Type: "content_block_start", + Index: intPtr(contentIndex), + ContentBlock: &dto.ClaudeContentBlock{ + Type: "tool_use", + ID: tc.ID, + Name: tc.Function.Name, + Input: map[string]any{}, + }, + } + writeClaudeSSE(writer, "content_block_start", &blockStart) + currentBlockType = "tool_use" + } + + // content_block_delta for tool arguments + if tc.Function.Arguments != "" { + delta := dto.ClaudeResponse{ + Type: "content_block_delta", + Index: intPtr(contentIndex), + Delta: &dto.ClaudeDelta{ + Type: "input_json_delta", + PartialJSON: &tc.Function.Arguments, + }, + } + writeClaudeSSE(writer, "content_block_delta", &delta) + } + } + + // finish_reason + if choice.FinishReason != nil { + finishReason = *choice.FinishReason + } + } + } + + if err := scanner.Err(); err != nil && err != io.EOF && ctx.Err() == nil { + info.StreamStatus.SetEndReason(common.StreamEndReasonError, err) + return &usage, fmt.Errorf("stream scanner error: %w", err) + } + + if info.StreamStatus.GetEndReason() == "" { + // 流意外结束,发送终止事件 + if currentBlockType != "" { + blockStop := dto.ClaudeResponse{ + Type: "content_block_stop", + Index: intPtr(contentIndex), + } + writeClaudeSSE(writer, "content_block_stop", &blockStop) + } + stopEvent := dto.ClaudeResponse{Type: "message_stop"} + writeClaudeSSE(writer, "message_stop", &stopEvent) + info.StreamStatus.SetEndReason(common.StreamEndReasonDone, nil) + } + + usage.PromptTokens = inputTokens + usage.CompletionTokens = outputTokens + usage.TotalTokens = inputTokens + outputTokens + usage.CacheIncludedInPrompt = true + + return &usage, nil +} + +// openAIToClaudeResponse 将 OpenAI ChatCompletion 响应转换为 Claude Messages 响应 +func openAIToClaudeResponse(openaiResp *dto.ChatCompletionResponse, info *common.RelayInfo) dto.ClaudeResponse { + content := make([]dto.ClaudeContentBlock, 0) + var textParts []string + var thinkingParts []string + var toolCalls []dto.ClaudeContentBlock + + if len(openaiResp.Choices) > 0 { + choice := openaiResp.Choices[0] + + // 提取文本 + if text, ok := choice.Message.Content.(string); ok && text != "" { + textParts = append(textParts, text) + } + + // 提取思维内容 + if choice.Message.ReasoningContent != nil && *choice.Message.ReasoningContent != "" { + thinkingParts = append(thinkingParts, *choice.Message.ReasoningContent) + } + + // 提取工具调用 + for _, tc := range choice.Message.ToolCalls { + var inputObj any + if err := json.Unmarshal([]byte(tc.Function.Arguments), &inputObj); err != nil { + inputObj = map[string]any{} + } + toolCalls = append(toolCalls, dto.ClaudeContentBlock{ + Type: "tool_use", + ID: tc.ID, + Name: tc.Function.Name, + Input: inputObj, + }) + } + } + + // 添加思维块(如果有) + for _, thinking := range thinkingParts { + content = append(content, dto.ClaudeContentBlock{ + Type: "thinking", + Thinking: strPtr(thinking), + }) + } + + // 添加文本块 + for _, text := range textParts { + content = append(content, dto.ClaudeContentBlock{ + Type: "text", + Text: strPtr(text), + }) + } + + // 添加工具调用块 + content = append(content, toolCalls...) + + if len(content) == 0 { + content = append(content, dto.ClaudeContentBlock{ + Type: "text", + Text: strPtr(""), + }) + } + + modelName := openaiResp.Model + if info.ChannelMeta.IsModelMapped { + modelName = info.OriginModelName + } + + stopReason := "end_turn" + if len(openaiResp.Choices) > 0 { + stopReason = common.OpenAIFinishReasonToClaude(openaiResp.Choices[0].FinishReason) + } + + return dto.ClaudeResponse{ + ID: fmt.Sprintf("msg_%s", info.RequestID), + Type: "message", + Role: "assistant", + Content: content, + StopReason: stopReason, + StopSequence: nil, + Model: modelName, + Usage: &dto.ClaudeUsage{ + InputTokens: openaiResp.Usage.PromptTokens, + OutputTokens: openaiResp.Usage.CompletionTokens, + CacheReadInputTokens: openaiResp.Usage.PromptTokensDetails.CachedTokens, + }, + } +} + +// writeClaudeSSE 写入 Claude 格式的 SSE 事件 +func writeClaudeSSE(w http.ResponseWriter, eventType string, data any) { + dataJSON, _ := json.Marshal(data) + _ = helper.WriteSSEEvent(w, eventType, string(dataJSON)) +} + +// intPtr 返回 int 的指针 +func intPtr(v int) *int { + return &v +} + +// strPtr 返回 string 的指针 +func strPtr(v string) *string { + return &v +} diff --git a/relay/channel/openai/completion.go b/relay/channel/openai/completion.go new file mode 100644 index 0000000..43cb734 --- /dev/null +++ b/relay/channel/openai/completion.go @@ -0,0 +1,51 @@ +package openai + +import ( + "context" + "encoding/json" + "io" + "net/http" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/dto" +) + +// handleCompletionNonStreamResponse 处理 Completions 非流式响应 +func (a *Adaptor) handleCompletionNonStreamResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "read response body failed", err) + } + + if resp.StatusCode != http.StatusOK { + if isUpstreamOpenAIError(body) { + writeUpstreamErrorResponse(writer, resp.StatusCode, body) + return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + if info.ChannelMeta.IsModelMapped { + body = replaceModelName(body, info.OriginModelName) + } + + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(body) + + var compResp dto.CompletionsResponse + if err := json.Unmarshal(body, &compResp); err == nil { + return &common.Usage{ + PromptTokens: compResp.Usage.PromptTokens, + CompletionTokens: compResp.Usage.CompletionTokens, + TotalTokens: compResp.Usage.TotalTokens, + PromptTokensDetails: common.DtoTokenDetailsToCommon(compResp.Usage.PromptTokensDetails), + CompletionTokenDetails: common.DtoTokenDetailsToCommon(compResp.Usage.CompletionTokenDetails), + }, nil + } + + return &common.Usage{}, nil +} diff --git a/relay/channel/openai/converter.go b/relay/channel/openai/converter.go new file mode 100644 index 0000000..82ac033 --- /dev/null +++ b/relay/channel/openai/converter.go @@ -0,0 +1,1442 @@ +package openai + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/gogf/gf/v2/frame/g" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/dto" + "github.com/qianfree/team-api/relay/helper" +) + +// ConvertToOpenAI 根据入站格式将请求转换为 OpenAI 格式。 +// 如果入站已是 OpenAI 格式(或空),原样返回。 +// 其他供应商适配器可调用此函数统一处理入站格式预转换。 +func ConvertToOpenAI(requestBody []byte, info *common.RelayInfo) ([]byte, error) { + switch info.InboundFormat { + case constant.RelayFormatClaude: + r, err := ConvertClaudeToOpenAI(requestBody, info) + if err != nil { + return nil, err + } + return io.ReadAll(r) + case constant.RelayFormatGemini: + r, err := ConvertGeminiToOpenAI(requestBody, info) + if err != nil { + return nil, err + } + return io.ReadAll(r) + case constant.RelayFormatResponses: + r, err := ConvertResponsesToOpenAI(requestBody, info) + if err != nil { + return nil, err + } + return io.ReadAll(r) + default: + return requestBody, nil + } +} + +// ===== Claude → OpenAI 请求转换 ===== + +// ConvertClaudeToOpenAI 将 Claude 格式请求转换为 OpenAI 格式。 +func ConvertClaudeToOpenAI(requestBody []byte, info *common.RelayInfo) (io.Reader, error) { + var claudeReq dto.ClaudeRequest + if err := json.Unmarshal(requestBody, &claudeReq); err != nil { + return nil, fmt.Errorf("parse claude request: %w", err) + } + + openaiReq := dto.GeneralOpenAIRequest{ + Model: info.ChannelMeta.UpstreamModelName, + Messages: make([]dto.Message, 0), + } + + if claudeReq.MaxTokens != nil { + v := int(*claudeReq.MaxTokens) + openaiReq.MaxTokens = &v + } else { + v := 4096 + openaiReq.MaxTokens = &v + } + + openaiReq.Temperature = claudeReq.Temperature + openaiReq.TopP = claudeReq.TopP + openaiReq.Stream = claudeReq.Stream + + if claudeReq.Thinking != nil && claudeReq.Thinking.Type == "enabled" { + openaiReq.ReasoningEffort = c2oConvertThinkingToReasoningEffort(claudeReq.Thinking) + } + + if len(claudeReq.StopSequences) > 0 { + openaiReq.Stop = claudeReq.StopSequences + } + + if len(claudeReq.Tools) > 0 { + openaiReq.Tools = make([]dto.Tool, 0, len(claudeReq.Tools)) + for _, t := range claudeReq.Tools { + openaiReq.Tools = append(openaiReq.Tools, dto.Tool{ + Type: "function", + Function: dto.FunctionDef{ + Name: t.Name, + Description: t.Description, + Parameters: t.InputSchema, + }, + }) + } + } + + if claudeReq.ToolChoice != nil { + openaiReq.ToolChoice = c2oConvertToolChoice(claudeReq.ToolChoice) + } + + if claudeReq.System != nil { + systemText := extractClaudeSystemText(claudeReq.System) + if systemText != "" { + openaiReq.Messages = append(openaiReq.Messages, dto.Message{ + Role: "system", + Content: systemText, + }) + } + } + + for _, msg := range claudeReq.Messages { + switch msg.Role { + case "user": + openaiMsgs := convertClaudeUserMessage(msg) + openaiReq.Messages = append(openaiReq.Messages, openaiMsgs...) + case "assistant": + openaiMsg := convertClaudeAssistantMessage(msg) + openaiReq.Messages = append(openaiReq.Messages, openaiMsg) + } + } + + result, err := json.Marshal(openaiReq) + if err != nil { + return nil, fmt.Errorf("marshal openai request failed: %w", err) + } + + return bytes.NewReader(result), nil +} + +func extractClaudeSystemText(system any) string { + switch v := system.(type) { + case string: + return v + case []any: + var parts []string + for _, item := range v { + if m, ok := item.(map[string]any); ok { + if m["type"] == "text" { + if text, ok := m["text"].(string); ok { + parts = append(parts, text) + } + } + } + } + return c2oJoinParts(parts) + default: + return "" + } +} + +func convertClaudeUserMessage(msg dto.ClaudeMessage) []dto.Message { + var results []dto.Message + + switch v := msg.Content.(type) { + case string: + results = append(results, dto.Message{Role: "user", Content: v}) + case []any: + var contentParts []dto.ContentPart + for _, item := range v { + m, ok := item.(map[string]any) + if !ok { + continue + } + switch m["type"] { + case "text": + if text, ok := m["text"].(string); ok { + contentParts = append(contentParts, dto.ContentPart{Type: "text", Text: text}) + } + case "tool_result": + toolUseID, _ := m["tool_use_id"].(string) + toolContent := "" + if c, ok := m["content"].(string); ok { + toolContent = c + } else if cMap, ok := m["content"].(map[string]any); ok { + if b, err := json.Marshal(cMap); err == nil { + toolContent = string(b) + } + } + if len(contentParts) > 0 { + if len(contentParts) == 1 && contentParts[0].Type == "text" { + results = append(results, dto.Message{Role: "user", Content: contentParts[0].Text}) + } else { + results = append(results, dto.Message{Role: "user", Content: contentParts}) + } + contentParts = nil + } + results = append(results, dto.Message{Role: "tool", Content: toolContent, ToolCallID: toolUseID}) + case "image": + if source, ok := m["source"].(map[string]any); ok { + mediaType, _ := source["media_type"].(string) + data, _ := source["data"].(string) + url, _ := source["url"].(string) + if data != "" && mediaType != "" { + contentParts = append(contentParts, dto.ContentPart{ + Type: "image_url", + ImageURL: &dto.ImageURL{URL: fmt.Sprintf("data:%s;base64,%s", mediaType, data), Detail: "auto"}, + }) + } else if url != "" { + contentParts = append(contentParts, dto.ContentPart{ + Type: "image_url", + ImageURL: &dto.ImageURL{URL: url, Detail: "auto"}, + }) + } + } + } + } + if len(contentParts) > 0 { + if len(contentParts) == 1 && contentParts[0].Type == "text" { + results = append(results, dto.Message{Role: "user", Content: contentParts[0].Text}) + } else { + results = append(results, dto.Message{Role: "user", Content: contentParts}) + } + } + default: + results = append(results, dto.Message{Role: "user", Content: fmt.Sprintf("%v", v)}) + } + + if len(results) == 0 { + results = append(results, dto.Message{Role: "user", Content: ""}) + } + return results +} + +func convertClaudeAssistantMessage(msg dto.ClaudeMessage) dto.Message { + result := dto.Message{Role: "assistant"} + switch v := msg.Content.(type) { + case string: + result.Content = v + case []any: + var textParts []string + var toolCalls []dto.ToolCall + var reasoningParts []string + for _, item := range v { + m, ok := item.(map[string]any) + if !ok { + continue + } + switch m["type"] { + case "text": + if text, ok := m["text"].(string); ok { + textParts = append(textParts, text) + } + case "thinking": + if thinking, ok := m["thinking"].(string); ok && thinking != "" { + reasoningParts = append(reasoningParts, thinking) + } + case "tool_use": + id, _ := m["id"].(string) + name, _ := m["name"].(string) + argsJSON := "{}" + if input, ok := m["input"]; ok { + if b, err := json.Marshal(input); err == nil { + argsJSON = string(b) + } + } + toolCalls = append(toolCalls, dto.ToolCall{ + ID: id, Type: "function", + Function: dto.FunctionCall{Name: name, Arguments: argsJSON}, + }) + } + } + result.Content = c2oJoinParts(textParts) + if len(reasoningParts) > 0 { + rc := c2oJoinParts(reasoningParts) + result.ReasoningContent = &rc + } + if len(toolCalls) > 0 { + result.ToolCalls = toolCalls + } + default: + result.Content = fmt.Sprintf("%v", v) + } + return result +} + +func c2oJoinParts(parts []string) string { + if len(parts) == 0 { + return "" + } + result := make([]byte, 0, 64) + for i, p := range parts { + if i > 0 { + result = append(result, '\n') + } + result = append(result, p...) + } + return string(result) +} + +func c2oConvertThinkingToReasoningEffort(thinking *dto.ClaudeThinking) string { + if thinking.BudgetTokens == nil { + return "medium" + } + budget := *thinking.BudgetTokens + switch { + case budget <= 2048: + return "low" + case budget <= 16384: + return "medium" + default: + return "high" + } +} + +func c2oConvertToolChoice(toolChoice any) any { + if toolChoice == nil { + return nil + } + switch v := toolChoice.(type) { + case string: + return v + case map[string]any: + tcType, _ := v["type"].(string) + switch tcType { + case "auto": + return "auto" + case "any": + return "required" + case "none": + return "none" + case "tool": + name, _ := v["name"].(string) + if name != "" { + return map[string]any{"type": "function", "function": map[string]any{"name": name}} + } + return "required" + } + } + return toolChoice +} + +// ===== Gemini → OpenAI 请求转换 ===== + +// ConvertGeminiToOpenAI 将 Gemini 格式请求转换为 OpenAI Chat Completions 格式。 +func ConvertGeminiToOpenAI(requestBody []byte, info *common.RelayInfo) (io.Reader, error) { + var geminiReq dto.GeminiChatRequest + if err := json.Unmarshal(requestBody, &geminiReq); err != nil { + return nil, fmt.Errorf("parse gemini request: %w", err) + } + + openaiReq := dto.GeneralOpenAIRequest{ + Model: info.ChannelMeta.UpstreamModelName, + Messages: make([]dto.Message, 0), + } + + if geminiReq.GenerationConfig != nil { + gc := geminiReq.GenerationConfig + openaiReq.Temperature = gc.Temperature + openaiReq.TopP = gc.TopP + if gc.TopK != nil { + v := int(*gc.TopK) + openaiReq.TopK = &v + } + if gc.MaxOutputTokens != nil { + v := int(*gc.MaxOutputTokens) + openaiReq.MaxTokens = &v + } + if len(gc.StopSequences) > 0 { + openaiReq.Stop = gc.StopSequences + } + if gc.Seed != nil { + v := *gc.Seed + openaiReq.Seed = &v + } + if gc.ThinkingConfig != nil { + openaiReq.ReasoningEffort = g2oConvertThinkingConfig(gc.ThinkingConfig) + } + if gc.ResponseMimeType != "" { + openaiReq.ResponseFormat = &dto.ResponseFormat{Type: "json_object"} + if gc.ResponseSchema != nil { + openaiReq.ResponseFormat.Type = "json_schema" + openaiReq.ResponseFormat.JSONSchema = gc.ResponseSchema + } + } + } + + if len(geminiReq.Tools) > 0 { + openaiReq.Tools = g2oConvertTools(geminiReq.Tools) + } + if geminiReq.ToolConfig != nil { + openaiReq.ToolChoice = g2oConvertToolConfig(geminiReq.ToolConfig) + } + + if geminiReq.SystemInstruction != nil && len(geminiReq.SystemInstruction.Parts) > 0 { + var textParts []string + for _, p := range geminiReq.SystemInstruction.Parts { + if p.Text != "" { + textParts = append(textParts, p.Text) + } + } + if len(textParts) > 0 { + openaiReq.Messages = append(openaiReq.Messages, dto.Message{Role: "system", Content: strings.Join(textParts, "\n")}) + } + } + + toolCallIDCounter := 0 + toolCallIDs := make(map[string]string) + var pendingToolResults []dto.Message + + for _, content := range geminiReq.Contents { + msgs := g2oConvertContent(content, &toolCallIDCounter, toolCallIDs) + for i := range msgs { + if msgs[i].Role == "tool" { + pendingToolResults = append(pendingToolResults, msgs[i]) + } else { + if len(pendingToolResults) > 0 { + openaiReq.Messages = append(openaiReq.Messages, pendingToolResults...) + pendingToolResults = nil + } + openaiReq.Messages = append(openaiReq.Messages, msgs[i]) + } + } + } + if len(pendingToolResults) > 0 { + openaiReq.Messages = append(openaiReq.Messages, pendingToolResults...) + } + + result, err := json.Marshal(openaiReq) + if err != nil { + return nil, fmt.Errorf("marshal openai request failed: %w", err) + } + return bytes.NewReader(result), nil +} + +func g2oConvertContent(content dto.GeminiContent, toolCallIDCounter *int, toolCallIDs map[string]string) []dto.Message { + role := g2oMapRole(content.Role) + var results []dto.Message + var textParts []string + var imageParts []dto.ContentPart + var funcCalls []dto.ToolCall + var funcResponses []dto.Message + + for _, part := range content.Parts { + switch { + case part.Text != "": + textParts = append(textParts, part.Text) + case part.InlineData != nil: + imageParts = append(imageParts, g2oConvertInlineData(part.InlineData)) + case part.FunctionCall != nil: + id := fmt.Sprintf("call_%d", *toolCallIDCounter) + *toolCallIDCounter++ + toolCallIDs[part.FunctionCall.FunctionName] = id + argsJSON := "{}" + if part.FunctionCall.Arguments != nil { + if b, err := json.Marshal(part.FunctionCall.Arguments); err == nil { + argsJSON = string(b) + } + } + funcCalls = append(funcCalls, dto.ToolCall{ID: id, Type: "function", Function: dto.FunctionCall{Name: part.FunctionCall.FunctionName, Arguments: argsJSON}}) + case part.FunctionResponse != nil: + name := part.FunctionResponse.Name + callID, ok := toolCallIDs[name] + if !ok { + callID = fmt.Sprintf("call_%d", *toolCallIDCounter) + *toolCallIDCounter++ + toolCallIDs[name] = callID + } + respJSON := "" + if part.FunctionResponse.Response != nil { + if b, err := json.Marshal(part.FunctionResponse.Response); err == nil { + respJSON = string(b) + } + } + funcResponses = append(funcResponses, dto.Message{Role: "tool", Content: respJSON, ToolCallID: callID}) + } + } + + switch role { + case "user": + if len(textParts) == 1 && len(imageParts) == 0 && len(funcResponses) == 0 { + results = append(results, dto.Message{Role: "user", Content: textParts[0]}) + } else if len(imageParts) > 0 { + var parts []dto.ContentPart + for _, t := range textParts { + parts = append(parts, dto.ContentPart{Type: "text", Text: t}) + } + parts = append(parts, imageParts...) + results = append(results, dto.Message{Role: "user", Content: parts}) + } else if len(textParts) > 0 { + results = append(results, dto.Message{Role: "user", Content: strings.Join(textParts, "\n")}) + } + case "assistant": + msg := dto.Message{Role: "assistant"} + if len(textParts) > 0 { + msg.Content = strings.Join(textParts, "\n") + } + if len(imageParts) > 0 { + msg.Content = imageParts + } + if len(funcCalls) > 0 { + msg.ToolCalls = funcCalls + if msg.Content == nil { + msg.Content = "" + } + } + results = append(results, msg) + } + results = append(results, funcResponses...) + return results +} + +func g2oMapRole(role string) string { + switch role { + case "model": + return "assistant" + default: + return role + } +} + +func g2oConvertInlineData(data *dto.GeminiInlineData) dto.ContentPart { + return dto.ContentPart{ + Type: "image_url", + ImageURL: &dto.ImageURL{ + URL: fmt.Sprintf("data:%s;base64,%s", data.MimeType, data.Data), + Detail: "auto", + }, + } +} + +func g2oConvertTools(toolsJSON json.RawMessage) []dto.Tool { + var geminiTools []struct { + FunctionDeclarations []struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters any `json:"parameters"` + } `json:"functionDeclarations"` + } + if err := json.Unmarshal(toolsJSON, &geminiTools); err != nil { + return nil + } + var result []dto.Tool + for _, gt := range geminiTools { + for _, fd := range gt.FunctionDeclarations { + result = append(result, dto.Tool{ + Type: "function", + Function: dto.FunctionDef{ + Name: fd.Name, Description: fd.Description, Parameters: fd.Parameters, + }, + }) + } + } + return result +} + +func g2oConvertToolConfig(toolConfig any) any { + tcMap, ok := toolConfig.(map[string]any) + if !ok { + return nil + } + fcc, ok := tcMap["functionCallingConfig"].(map[string]any) + if !ok { + return nil + } + mode, _ := fcc["mode"].(string) + switch mode { + case "NONE": + return "none" + case "AUTO": + return "auto" + case "ANY": + if names, ok := fcc["allowedFunctionNames"].([]any); ok && len(names) == 1 { + if name, ok := names[0].(string); ok { + return map[string]any{"type": "function", "function": map[string]any{"name": name}} + } + } + return "required" + default: + return "auto" + } +} + +func g2oConvertThinkingConfig(tc *dto.GeminiThinkingConfig) string { + if tc.ThoughtBudget == nil { + return "medium" + } + budget := *tc.ThoughtBudget + switch { + case budget <= 2048: + return "low" + case budget <= 16384: + return "medium" + default: + return "high" + } +} + +// ===== Responses → OpenAI 请求转换 ===== + +// ConvertResponsesToOpenAI 将 Responses API 请求转换为 Chat Completions 格式 +func ConvertResponsesToOpenAI(requestBody []byte, info *common.RelayInfo) (io.Reader, error) { + var req dto.OpenAIResponsesRequest + if err := json.Unmarshal(requestBody, &req); err != nil { + return nil, fmt.Errorf("parse responses request: %w", err) + } + + chatReq := make(map[string]any) + if info.ChannelMeta.IsModelMapped { + chatReq["model"] = info.ChannelMeta.UpstreamModelName + } else { + chatReq["model"] = req.Model + } + + messages := make([]map[string]any, 0) + if len(req.Instructions) > 0 { + var instructions string + if err := json.Unmarshal(req.Instructions, &instructions); err == nil && instructions != "" { + messages = append(messages, map[string]any{"role": "system", "content": instructions}) + } + } + inputMessages, err := r2cConvertInputToMessages(req.Input) + if err != nil { + return nil, fmt.Errorf("convert input to messages: %w", err) + } + messages = append(messages, inputMessages...) + chatReq["messages"] = messages + + if req.Stream != nil { + chatReq["stream"] = *req.Stream + if *req.Stream { + chatReq["stream_options"] = map[string]any{"include_usage": true} + } + } + if req.Temperature != nil { + chatReq["temperature"] = *req.Temperature + } + if req.TopP != nil { + chatReq["top_p"] = *req.TopP + } + if req.MaxOutputTokens != nil { + chatReq["max_tokens"] = *req.MaxOutputTokens + } + if req.Logprobs != nil { + chatReq["logprobs"] = true + chatReq["top_logprobs"] = *req.Logprobs + } else if req.TopLogProbs != nil { + chatReq["top_logprobs"] = *req.TopLogProbs + chatReq["logprobs"] = true + } + if len(req.Tools) > 0 { + chatTools := r2cConvertTools(req.Tools) + if len(chatTools) > 0 { + chatReq["tools"] = chatTools + } + } + if len(req.ToolChoice) > 0 { + chatReq["tool_choice"] = r2cConvertToolChoice(req.ToolChoice) + } + if req.Reasoning != nil && req.Reasoning.Effort != "" { + chatReq["reasoning_effort"] = req.Reasoning.Effort + } + if req.ServiceTier != "" { + chatReq["service_tier"] = req.ServiceTier + } + if req.PromptCacheKey != "" { + chatReq["prompt_cache_key"] = req.PromptCacheKey + } + if len(req.Metadata) > 0 { + chatReq["metadata"] = json.RawMessage(req.Metadata) + } + + result, err := json.Marshal(chatReq) + if err != nil { + return nil, fmt.Errorf("marshal chat completions request failed: %w", err) + } + return bytes.NewReader(result), nil +} + +type r2cInputItem struct { + Type string `json:"type"` + Role string `json:"role"` + Content json.RawMessage `json:"content"` + CallID string `json:"call_id,omitempty"` + Output string `json:"output,omitempty"` + Text string `json:"text,omitempty"` +} + +type r2cContentPart struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ImageURL string `json:"image_url,omitempty"` + URL string `json:"url,omitempty"` + Detail string `json:"detail,omitempty"` +} + +func r2cConvertInputToMessages(input json.RawMessage) ([]map[string]any, error) { + if len(input) == 0 { + return nil, nil + } + var simpleText string + if err := json.Unmarshal(input, &simpleText); err == nil { + return []map[string]any{{"role": "user", "content": simpleText}}, nil + } + var items []json.RawMessage + if err := json.Unmarshal(input, &items); err != nil { + return nil, fmt.Errorf("input must be string or array: %w", err) + } + messages := make([]map[string]any, 0, len(items)) + for _, raw := range items { + var item r2cInputItem + if err := json.Unmarshal(raw, &item); err != nil { + continue + } + switch item.Type { + case "message": + if msg := r2cConvertMessage(item); msg != nil { + messages = append(messages, msg) + } + case "function_call_output": + messages = append(messages, map[string]any{"role": "tool", "tool_call_id": item.CallID, "content": item.Output}) + default: + if item.Role != "" { + if msg := r2cConvertMessage(item); msg != nil { + messages = append(messages, msg) + } + } + } + } + return messages, nil +} + +func r2cConvertMessage(item r2cInputItem) map[string]any { + role := item.Role + if role == "" { + role = "user" + } + if len(item.Content) == 0 { + return nil + } + var textContent string + if err := json.Unmarshal(item.Content, &textContent); err == nil { + return map[string]any{"role": role, "content": textContent} + } + var parts []r2cContentPart + if err := json.Unmarshal(item.Content, &parts); err != nil { + return nil + } + chatParts := make([]map[string]any, 0, len(parts)) + for _, part := range parts { + switch part.Type { + case "input_text": + chatParts = append(chatParts, map[string]any{"type": "text", "text": part.Text}) + case "input_image": + imageURL := part.ImageURL + if imageURL == "" { + imageURL = part.URL + } + if imageURL != "" { + imgPart := map[string]any{"type": "image_url", "image_url": map[string]any{"url": imageURL}} + if part.Detail != "" { + imgPart["image_url"].(map[string]any)["detail"] = part.Detail + } + chatParts = append(chatParts, imgPart) + } + case "output_text": + chatParts = append(chatParts, map[string]any{"type": "text", "text": part.Text}) + } + } + if len(chatParts) == 0 { + return nil + } + if len(chatParts) == 1 && chatParts[0]["type"] == "text" { + return map[string]any{"role": role, "content": chatParts[0]["text"]} + } + return map[string]any{"role": role, "content": chatParts} +} + +func r2cConvertTools(toolsRaw json.RawMessage) []map[string]any { + var tools []map[string]any + if err := json.Unmarshal(toolsRaw, &tools); err != nil { + return nil + } + chatTools := make([]map[string]any, 0, len(tools)) + for _, tool := range tools { + toolType, _ := tool["type"].(string) + if toolType == "function" { + chatTools = append(chatTools, map[string]any{ + "type": "function", + "function": map[string]any{"name": tool["name"], "description": tool["description"], "parameters": tool["parameters"]}, + }) + } + } + return chatTools +} + +func r2cConvertToolChoice(tcRaw json.RawMessage) any { + if len(tcRaw) == 0 { + return "auto" + } + var strVal string + if err := json.Unmarshal(tcRaw, &strVal); err == nil { + return strVal + } + var tc map[string]any + if err := json.Unmarshal(tcRaw, &tc); err != nil { + return "auto" + } + if tc["type"] == "function" { + if fn, ok := tc["function"].(map[string]any); ok { + if name, ok := fn["name"].(string); ok { + return map[string]any{"type": "function", "function": map[string]any{"name": name}} + } + } + } + return tc +} + +// ===== OpenAI → Responses 请求转换(Responses API Bridge) ===== + +// ConvertOpenAIToResponses 将 Chat Completions 请求体转换为 Responses API 请求体 +func ConvertOpenAIToResponses(body []byte, info *common.RelayInfo) ([]byte, error) { + var req dto.GeneralOpenAIRequest + if err := json.Unmarshal(body, &req); err != nil { + return nil, fmt.Errorf("parse chat completions request: %w", err) + } + + respReq := make(map[string]any) + if info.ChannelMeta.IsModelMapped { + respReq["model"] = info.ChannelMeta.UpstreamModelName + } else { + respReq["model"] = req.Model + } + + if instructions := c2rExtractInstructions(req.Messages); instructions != "" { + respReq["instructions"] = instructions + } + respReq["input"] = c2rConvertMessagesToInput(req.Messages) + + if req.Stream != nil { + respReq["stream"] = *req.Stream + } + if req.Temperature != nil { + respReq["temperature"] = *req.Temperature + } + if req.TopP != nil { + respReq["top_p"] = *req.TopP + } + if maxTokens := c2rGetMaxTokens(req); maxTokens > 0 { + respReq["max_output_tokens"] = maxTokens + } + if req.ResponseFormat != nil { + respReq["text"] = map[string]any{"format": req.ResponseFormat} + } + if req.ReasoningEffort != "" { + respReq["reasoning"] = map[string]any{"effort": req.ReasoningEffort, "summary": "detailed"} + } + if len(req.Tools) > 0 { + if respTools := c2rConvertTools(req.Tools); len(respTools) > 0 { + respReq["tools"] = respTools + } + } + if req.ToolChoice != nil { + respReq["tool_choice"] = c2rConvertToolChoice(req.ToolChoice) + } + if req.User != "" { + respReq["user"] = req.User + } + if req.ParallelToolCalls != nil { + respReq["parallel_tool_calls"] = *req.ParallelToolCalls + } + + result, err := json.Marshal(respReq) + if err != nil { + return nil, fmt.Errorf("marshal responses request: %w", err) + } + return result, nil +} + +func c2rExtractInstructions(messages []dto.Message) string { + var parts []string + for _, msg := range messages { + if msg.Role == "system" || msg.Role == "developer" { + if text, ok := msg.Content.(string); ok && text != "" { + parts = append(parts, text) + } + } + } + if len(parts) == 0 { + return "" + } + instructions := parts[0] + for i := 1; i < len(parts); i++ { + instructions += "\n\n" + parts[i] + } + return instructions +} + +func c2rConvertMessagesToInput(messages []dto.Message) []any { + var input []any + for _, msg := range messages { + if msg.Role == "system" || msg.Role == "developer" { + continue + } + switch msg.Role { + case "user": + input = append(input, c2rMakeMessageItem("user", msg.Content)) + case "assistant": + if len(msg.ToolCalls) > 0 { + if msg.Content != nil { + if text, ok := msg.Content.(string); ok && text != "" { + input = append(input, c2rMakeMessageItem("assistant", text)) + } + } + for _, tc := range msg.ToolCalls { + input = append(input, map[string]any{ + "type": "function_call", "call_id": tc.ID, "name": tc.Function.Name, "arguments": tc.Function.Arguments, + }) + } + } else { + input = append(input, c2rMakeMessageItem("assistant", msg.Content)) + } + case "tool": + input = append(input, map[string]any{ + "type": "function_call_output", "call_id": msg.ToolCallID, "output": c2rContentToString(msg.Content), + }) + } + } + if len(input) == 0 { + return []any{} + } + return input +} + +func c2rMakeMessageItem(role string, content any) map[string]any { + if content == nil { + return map[string]any{"type": "message", "role": role, "content": []any{}} + } + switch v := content.(type) { + case string: + textType := "input_text" + if role == "assistant" { + textType = "output_text" + } + return map[string]any{"type": "message", "role": role, "content": []any{map[string]any{"type": textType, "text": v}}} + case []any: + parts := make([]any, 0, len(v)) + for _, item := range v { + part, ok := item.(map[string]any) + if !ok { + continue + } + if converted := c2rConvertContentPart(part, role); converted != nil { + parts = append(parts, converted) + } + } + return map[string]any{"type": "message", "role": role, "content": parts} + default: + b, err := json.Marshal(content) + if err != nil { + return map[string]any{"type": "message", "role": role, "content": []any{}} + } + var parts []any + if err := json.Unmarshal(b, &parts); err == nil { + converted := make([]any, 0, len(parts)) + for _, item := range parts { + part, ok := item.(map[string]any) + if !ok { + continue + } + if c := c2rConvertContentPart(part, role); c != nil { + converted = append(converted, c) + } + } + return map[string]any{"type": "message", "role": role, "content": converted} + } + return map[string]any{"type": "message", "role": role, "content": []any{}} + } +} + +func c2rConvertContentPart(part map[string]any, role string) map[string]any { + partType, _ := part["type"].(string) + switch partType { + case "text": + text, _ := part["text"].(string) + textType := "input_text" + if role == "assistant" { + textType = "output_text" + } + return map[string]any{"type": textType, "text": text} + case "image_url": + imgURL, _ := part["image_url"].(map[string]any) + if imgURL != nil { + result := map[string]any{"type": "input_image"} + if url, ok := imgURL["url"].(string); ok { + result["image_url"] = url + } + if detail, ok := imgURL["detail"].(string); ok { + result["detail"] = detail + } + return result + } + case "input_audio": + data, _ := part["data"].(string) + format, _ := part["format"].(string) + return map[string]any{"type": "input_audio", "data": data, "format": format} + case "file": + result := map[string]any{"type": "input_file"} + if fileData, ok := part["file_data"].(string); ok { + result["file_data"] = fileData + } + if filename, ok := part["filename"].(string); ok { + result["filename"] = filename + } + return result + } + return nil +} + +func c2rConvertTools(tools []dto.Tool) []any { + result := make([]any, 0, len(tools)) + for _, tool := range tools { + if tool.Type == "function" { + result = append(result, map[string]any{ + "type": "function", "name": tool.Function.Name, "description": tool.Function.Description, "parameters": tool.Function.Parameters, + }) + } + } + return result +} + +func c2rConvertToolChoice(toolChoice any) any { + b, err := json.Marshal(toolChoice) + if err != nil { + return toolChoice + } + var tc map[string]any + if err := json.Unmarshal(b, &tc); err != nil { + return toolChoice + } + if tc["type"] == "function" { + if fn, ok := tc["function"].(map[string]any); ok { + if name, ok := fn["name"].(string); ok { + return map[string]any{"type": "function", "name": name} + } + } + } + return tc +} + +func c2rContentToString(content any) string { + if content == nil { + return "" + } + if s, ok := content.(string); ok { + return s + } + b, _ := json.Marshal(content) + return string(b) +} + +func c2rGetMaxTokens(req dto.GeneralOpenAIRequest) int { + max := 0 + if req.MaxTokens != nil && *req.MaxTokens > 0 { + max = *req.MaxTokens + } + if req.MaxCompletionTokens != nil && *req.MaxCompletionTokens > max { + max = *req.MaxCompletionTokens + } + return max +} + +// ===== Responses → Chat 响应转换 ===== + +// ResponsesResponseToChatCompletions 将 Responses API 非流式响应转换为 Chat Completions 响应 +func ResponsesResponseToChatCompletions(resp *dto.OpenAIResponsesResponse, id string, model string) (*dto.ChatCompletionResponse, *common.Usage, error) { + text, toolCalls := extractOutputFromResponses(resp) + + finishReason := "stop" + content := "" + if len(toolCalls) > 0 && text == "" { + finishReason = "tool_calls" + } + if text != "" || len(toolCalls) == 0 { + content = text + } + + usage := &dto.UsageWithDetails{} + if resp.Usage != nil { + usage.PromptTokens = resp.Usage.InputTokens + usage.CompletionTokens = resp.Usage.OutputTokens + usage.TotalTokens = resp.Usage.TotalTokens + if usage.TotalTokens == 0 { + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + } + if resp.Usage.InputTokensDetails != nil { + usage.PromptTokensDetails = &dto.TokenDetails{ + CachedTokens: resp.Usage.InputTokensDetails.CachedTokens, + AudioTokens: resp.Usage.InputTokensDetails.AudioTokens, + TextTokens: resp.Usage.InputTokensDetails.TextTokens, + ImageTokens: resp.Usage.InputTokensDetails.ImageTokens, + } + } + if resp.Usage.OutputTokenDetails != nil { + usage.CompletionTokenDetails = &dto.TokenDetails{ + ReasoningTokens: resp.Usage.OutputTokenDetails.ReasoningTokens, + AudioTokens: resp.Usage.OutputTokenDetails.AudioTokens, + TextTokens: resp.Usage.OutputTokenDetails.TextTokens, + AcceptedPredictionTokens: resp.Usage.OutputTokenDetails.AcceptedPredictionTokens, + RejectedPredictionTokens: resp.Usage.OutputTokenDetails.RejectedPredictionTokens, + } + } + } else if text != "" { + estimated := len(text) / 4 + usage.CompletionTokens = estimated + usage.TotalTokens = usage.PromptTokens + estimated + } + + chatResp := &dto.ChatCompletionResponse{ + ID: id, Object: "chat.completion", Created: time.Now().Unix(), Model: model, + Choices: []dto.Choice{{ + Index: 0, Message: dto.Message{Role: "assistant", Content: content, ToolCalls: toolCalls}, FinishReason: finishReason, + }}, + Usage: *usage, + } + + return chatResp, &common.Usage{ + PromptTokens: usage.PromptTokens, + CompletionTokens: usage.CompletionTokens, + TotalTokens: usage.TotalTokens, + PromptTokensDetails: common.DtoTokenDetailsToCommon(usage.PromptTokensDetails), + CompletionTokenDetails: common.DtoTokenDetailsToCommon(usage.CompletionTokenDetails), + }, nil +} + +func extractOutputFromResponses(resp *dto.OpenAIResponsesResponse) (string, []dto.ToolCall) { + var textParts []string + var toolCalls []dto.ToolCall + for _, output := range resp.Output { + switch output.Type { + case "message": + if output.Role == "assistant" { + for _, c := range output.Content { + if c.Type == "output_text" && c.Text != "" { + textParts = append(textParts, c.Text) + } + } + } + case "function_call": + toolCalls = append(toolCalls, dto.ToolCall{ + ID: output.CallID, Type: "function", + Function: dto.FunctionCall{Name: output.Name, Arguments: output.Arguments}, + }) + } + } + return strings.Join(textParts, ""), toolCalls +} + +// HandleResponsesStreamToChat 将 Responses API 流式响应实时转换为 Chat Completions SSE 流 +func HandleResponsesStreamToChat(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + responseID := fmt.Sprintf("chatcmpl-%s", info.RequestID) + createAt := time.Now().Unix() + model := info.ChannelMeta.UpstreamModelName + + var ( + totalUsage common.Usage + usageText, outputText strings.Builder + sentStart, sentStop bool + sawToolCall bool + ) + + toolCallIndexByID := make(map[string]int) + toolCallNameByID := make(map[string]string) + toolCallArgsByID := make(map[string]string) + toolCallNameSent := make(map[string]bool) + + sendChatChunk := func(chunk *dto.ChatCompletionStreamResponse) bool { + if chunk == nil { + return true + } + data, err := json.Marshal(chunk) + if err != nil { + return false + } + return helper.WriteSSEData(writer, string(data)) == nil + } + + sendStartIfNeeded := func() bool { + if sentStart { + return true + } + chunk := &dto.ChatCompletionStreamResponse{ + ID: responseID, Object: "chat.completion.chunk", Created: createAt, Model: model, + Choices: []dto.StreamChoice{{Index: 0, Delta: dto.Message{Role: "assistant", Content: ""}}}, + } + if !sendChatChunk(chunk) { + return false + } + sentStart = true + return true + } + + helper.StreamScannerHandler(ctx, resp, info, writer, func(data string, sr *helper.StreamResult) { + var streamResp dto.ResponsesStreamResponse + if err := json.Unmarshal([]byte(data), &streamResp); err != nil { + sr.Error(fmt.Errorf("unmarshal responses stream: %w", err)) + return + } + + switch streamResp.Type { + case "response.created": + if streamResp.Response != nil { + if streamResp.Response.Model != "" { + model = streamResp.Response.Model + } + if streamResp.Response.CreatedAt != 0 { + createAt = int64(streamResp.Response.CreatedAt) + } + } + + case "response.reasoning_summary_text.delta": + if streamResp.Delta == "" { + return + } + if !sendStartIfNeeded() { + sr.Stop(fmt.Errorf("send start chunk failed")) + return + } + usageText.WriteString(streamResp.Delta) + delta := streamResp.Delta + chunk := &dto.ChatCompletionStreamResponse{ + ID: responseID, Object: "chat.completion.chunk", Created: createAt, Model: model, + Choices: []dto.StreamChoice{{Index: 0, Delta: dto.Message{ReasoningContent: &delta}}}, + } + if !sendChatChunk(chunk) { + sr.Stop(fmt.Errorf("send reasoning chunk failed")) + return + } + + case "response.output_text.delta": + if !sendStartIfNeeded() { + sr.Stop(fmt.Errorf("send start chunk failed")) + return + } + if streamResp.Delta != "" { + outputText.WriteString(streamResp.Delta) + usageText.WriteString(streamResp.Delta) + delta := streamResp.Delta + chunk := &dto.ChatCompletionStreamResponse{ + ID: responseID, Object: "chat.completion.chunk", Created: createAt, Model: model, + Choices: []dto.StreamChoice{{Index: 0, Delta: dto.Message{Content: delta}}}, + } + if !sendChatChunk(chunk) { + sr.Stop(fmt.Errorf("send text chunk failed")) + return + } + } + + case "response.output_item.added", "response.output_item.done": + if streamResp.Item == nil || streamResp.Item.Type != "function_call" { + return + } + callID := strings.TrimSpace(streamResp.Item.CallID) + if callID == "" { + callID = strings.TrimSpace(streamResp.Item.ID) + } + if callID == "" { + return + } + name := strings.TrimSpace(streamResp.Item.Name) + if name != "" { + toolCallNameByID[callID] = name + } + newArgs := streamResp.Item.Arguments + prevArgs := toolCallArgsByID[callID] + var argsDelta string + if newArgs != "" { + if strings.HasPrefix(newArgs, prevArgs) { + argsDelta = newArgs[len(prevArgs):] + } else { + argsDelta = newArgs + } + toolCallArgsByID[callID] = newArgs + } + if !r2cSendToolCallChunk(responseID, createAt, model, callID, name, argsDelta, toolCallIndexByID, toolCallNameByID, toolCallNameSent, writer, sendChatChunk) { + sr.Stop(fmt.Errorf("send tool call chunk failed")) + return + } + sawToolCall = true + usageText.WriteString(name) + usageText.WriteString(argsDelta) + + case "response.function_call_arguments.delta": + itemID := strings.TrimSpace(streamResp.ItemID) + callID := itemID + if callID == "" { + return + } + toolCallArgsByID[callID] += streamResp.Delta + if !r2cSendToolCallChunk(responseID, createAt, model, callID, "", streamResp.Delta, toolCallIndexByID, toolCallNameByID, toolCallNameSent, writer, sendChatChunk) { + sr.Stop(fmt.Errorf("send tool call args chunk failed")) + return + } + sawToolCall = true + usageText.WriteString(streamResp.Delta) + + case "response.completed": + if streamResp.Response != nil { + if streamResp.Response.Model != "" { + model = streamResp.Response.Model + } + if streamResp.Response.CreatedAt != 0 { + createAt = int64(streamResp.Response.CreatedAt) + } + if streamResp.Response.Usage != nil { + totalUsage.PromptTokens = streamResp.Response.Usage.InputTokens + totalUsage.CompletionTokens = streamResp.Response.Usage.OutputTokens + totalUsage.TotalTokens = streamResp.Response.Usage.TotalTokens + if totalUsage.TotalTokens == 0 { + totalUsage.TotalTokens = totalUsage.PromptTokens + totalUsage.CompletionTokens + } + } + } + if !sendStartIfNeeded() { + sr.Stop(fmt.Errorf("send start chunk failed")) + return + } + if !sentStop { + finishReason := "stop" + if sawToolCall && outputText.Len() == 0 { + finishReason = "tool_calls" + } + fr := finishReason + stopChunk := &dto.ChatCompletionStreamResponse{ + ID: responseID, Object: "chat.completion.chunk", Created: createAt, Model: model, + Choices: []dto.StreamChoice{{Index: 0, FinishReason: &fr}}, + } + if !sendChatChunk(stopChunk) { + sr.Stop(fmt.Errorf("send stop chunk failed")) + return + } + sentStop = true + } + + case "response.error", "response.failed": + errMsg := "responses stream error" + if streamResp.Response != nil && streamResp.Response.Error != nil { + if b, err := json.Marshal(streamResp.Response.Error); err == nil { + errMsg = string(b) + } + } + sr.Stop(fmt.Errorf("%s: %s", streamResp.Type, errMsg)) + return + } + }) + + if totalUsage.TotalTokens == 0 && usageText.Len() > 0 { + estimated := usageText.Len() / 4 + totalUsage.CompletionTokens = estimated + totalUsage.TotalTokens = totalUsage.PromptTokens + estimated + } + if !sentStart { + sendStartIfNeeded() + } + if !sentStop { + fr := "stop" + stopChunk := &dto.ChatCompletionStreamResponse{ + ID: responseID, Object: "chat.completion.chunk", Created: createAt, Model: model, + Choices: []dto.StreamChoice{{Index: 0, FinishReason: &fr}}, + } + sendChatChunk(stopChunk) + } + if totalUsage.TotalTokens > 0 { + usageChunk := &dto.ChatCompletionStreamResponse{ + ID: responseID, Object: "chat.completion.chunk", Created: createAt, Model: model, + Choices: []dto.StreamChoice{}, + Usage: &dto.UsageWithDetails{PromptTokens: totalUsage.PromptTokens, CompletionTokens: totalUsage.CompletionTokens, TotalTokens: totalUsage.TotalTokens, PromptTokensDetails: common.CommonTokenDetailsToDto(totalUsage.PromptTokensDetails), CompletionTokenDetails: common.CommonTokenDetailsToDto(totalUsage.CompletionTokenDetails)}, + } + sendChatChunk(usageChunk) + } + helper.WriteSSEData(writer, "[DONE]") + + g.Log().Infof(ctx, "[HandleResponsesStreamToChat] completed: usage=%+v, endReason=%s", totalUsage, info.StreamStatus.GetEndReason()) + return &totalUsage, nil +} + +func r2cSendToolCallChunk(responseID string, createAt int64, model string, callID string, name string, argsDelta string, indexByID map[string]int, nameByID map[string]string, nameSent map[string]bool, writer http.ResponseWriter, sendChatChunk func(*dto.ChatCompletionStreamResponse) bool) bool { + idx, ok := indexByID[callID] + if !ok { + idx = len(indexByID) + indexByID[callID] = idx + } + if name != "" { + nameByID[callID] = name + } + if nameByID[callID] != "" { + name = nameByID[callID] + } + tool := dto.ToolCall{ID: callID, Type: "function", Index: idx, Function: dto.FunctionCall{Arguments: argsDelta}} + if name != "" && !nameSent[callID] { + tool.Function.Name = name + nameSent[callID] = true + } + chunk := &dto.ChatCompletionStreamResponse{ + ID: responseID, Object: "chat.completion.chunk", Created: createAt, Model: model, + Choices: []dto.StreamChoice{{Index: 0, Delta: dto.Message{ToolCalls: []dto.ToolCall{tool}}}}, + } + return sendChatChunk(chunk) +} + +// HandleResponsesNonStreamToChat 将 Responses API 非流式响应转换为 Chat Completions 并写入 writer +func HandleResponsesNonStreamToChat(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response body: %w", err) + } + if resp.StatusCode != http.StatusOK { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(resp.StatusCode) + writer.Write(body) + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + var responsesResp dto.OpenAIResponsesResponse + if err := json.Unmarshal(body, &responsesResp); err != nil { + return nil, fmt.Errorf("parse responses response: %w", err) + } + + chatID := fmt.Sprintf("chatcmpl-%s", info.RequestID) + model := info.ChannelMeta.UpstreamModelName + if !info.ChannelMeta.IsModelMapped && responsesResp.Model != "" { + model = responsesResp.Model + } + + chatResp, usage, err := ResponsesResponseToChatCompletions(&responsesResp, chatID, model) + if err != nil { + return nil, fmt.Errorf("convert responses to chat: %w", err) + } + if info.ChannelMeta.IsModelMapped { + chatResp.Model = info.OriginModelName + } + + resultBody, err := json.Marshal(chatResp) + if err != nil { + return nil, fmt.Errorf("marshal chat response: %w", err) + } + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + writer.Write(resultBody) + return usage, nil +} diff --git a/relay/channel/openai/embedding.go b/relay/channel/openai/embedding.go new file mode 100644 index 0000000..1f991c1 --- /dev/null +++ b/relay/channel/openai/embedding.go @@ -0,0 +1,48 @@ +package openai + +import ( + "context" + "encoding/json" + "io" + "net/http" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/dto" +) + +// handleEmbeddingResponse 处理 Embeddings 非流式响应 +func (a *Adaptor) handleEmbeddingResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "read response body failed", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + if info.ChannelMeta.IsModelMapped { + body = replaceModelName(body, info.OriginModelName) + } + + var embedResp dto.EmbeddingResponse + if err := json.Unmarshal(body, &embedResp); err == nil { + return &common.Usage{ + PromptTokens: embedResp.Usage.PromptTokens, + CompletionTokens: embedResp.Usage.CompletionTokens, + TotalTokens: embedResp.Usage.TotalTokens, + PromptTokensDetails: common.DtoTokenDetailsToCommon(embedResp.Usage.PromptTokensDetails), + CompletionTokenDetails: common.DtoTokenDetailsToCommon(embedResp.Usage.CompletionTokenDetails), + }, nil + } + + // 解析失败也透传 body + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(resp.StatusCode) + _, _ = writer.Write(body) + + return &common.Usage{}, nil +} diff --git a/relay/channel/openai/gemini_response.go b/relay/channel/openai/gemini_response.go new file mode 100644 index 0000000..de94f5f --- /dev/null +++ b/relay/channel/openai/gemini_response.go @@ -0,0 +1,370 @@ +package openai + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/dto" + "github.com/qianfree/team-api/relay/helper" +) + +// handleGeminiInboundNonStream 将 OpenAI 非流式响应转换为 Gemini 格式 +func handleGeminiInboundNonStream(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response body failed: %w", err) + } + + if resp.StatusCode != http.StatusOK { + writeOpenAIErrorAsGemini(writer, body, resp.StatusCode) + return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + var openaiResp dto.ChatCompletionResponse + if err := json.Unmarshal(body, &openaiResp); err != nil { + return nil, fmt.Errorf("invalid response body: %w", err) + } + + geminiResp := openAIToGeminiResponse(&openaiResp, info) + + respBody, _ := json.Marshal(geminiResp) + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(respBody) + + usage := &common.Usage{ + PromptTokens: openaiResp.Usage.PromptTokens, + CompletionTokens: openaiResp.Usage.CompletionTokens, + TotalTokens: openaiResp.Usage.TotalTokens, + PromptTokensDetails: common.DtoTokenDetailsToCommon(openaiResp.Usage.PromptTokensDetails), + CompletionTokenDetails: common.DtoTokenDetailsToCommon(openaiResp.Usage.CompletionTokenDetails), + CacheIncludedInPrompt: true, + } + return usage, nil +} + +// handleGeminiInboundStream 将 OpenAI SSE 流转换为 Gemini SSE 流 +func handleGeminiInboundStream(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + writeOpenAIErrorAsGemini(writer, body, resp.StatusCode) + return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + helper.SetEventStreamHeaders(writer) + defer helper.PingTicker(writer, 15*time.Second)() + + scanner := bufio.NewScanner(resp.Body) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 10*1024*1024) + + var ( + usage common.Usage + finishReason string + ) + + for scanner.Scan() { + select { + case <-ctx.Done(): + info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, ctx.Err()) + return &usage, common.ErrStreamInterrupted + default: + } + + line := scanner.Text() + if line == "" { + continue + } + + if !strings.HasPrefix(line, "data:") { + continue + } + + data, _ := helper.ExtractSSEData(line) + + if data != "" && data != "[DONE]" { + info.SetFirstResponseTime() + } + + if data == "[DONE]" { + break + } + + var streamResp dto.ChatCompletionStreamResponse + if err := json.Unmarshal([]byte(data), &streamResp); err != nil { + continue + } + + // 收集 usage(通常在最后一个 chunk) + if streamResp.Usage != nil { + usage.PromptTokens = streamResp.Usage.PromptTokens + usage.CompletionTokens = streamResp.Usage.CompletionTokens + usage.TotalTokens = streamResp.Usage.TotalTokens + usage.PromptTokensDetails = common.DtoTokenDetailsToCommon(streamResp.Usage.PromptTokensDetails) + usage.CompletionTokenDetails = common.DtoTokenDetailsToCommon(streamResp.Usage.CompletionTokenDetails) + usage.CacheIncludedInPrompt = true + } + + // 构造 Gemini 响应 chunk + geminiChunk := dto.GeminiChatResponse{} + + for _, choice := range streamResp.Choices { + if choice.FinishReason != nil && *choice.FinishReason != "" { + finishReason = *choice.FinishReason + } + + parts := buildGeminiPartsFromDelta(&choice.Delta) + if len(parts) == 0 && choice.FinishReason == nil { + continue + } + + candidate := dto.GeminiCandidate{ + Index: choice.Index, + Content: &dto.GeminiContent{ + Role: "model", + Parts: parts, + }, + } + + geminiChunk.Candidates = append(geminiChunk.Candidates, candidate) + } + + if len(geminiChunk.Candidates) > 0 { + chunkData, _ := json.Marshal(geminiChunk) + _ = helper.WriteSSEData(writer, string(chunkData)) + } + } + + // 发送包含 finishReason 和 usageMetadata 的最终 chunk + finalChunk := dto.GeminiChatResponse{} + reason := common.OpenAIFinishReasonToGemini(finishReason) + finalChunk.Candidates = []dto.GeminiCandidate{{ + Content: &dto.GeminiContent{ + Role: "model", + }, + FinishReason: reason, + }} + if usage.PromptTokens > 0 || usage.CompletionTokens > 0 { + finalChunk.UsageMetadata = &dto.GeminiUsageMetadata{ + PromptTokenCount: usage.PromptTokens, + CandidatesTokenCount: usage.CompletionTokens, + TotalTokenCount: usage.TotalTokens, + } + if usage.PromptTokensDetails != nil { + finalChunk.UsageMetadata.CachedContentTokenCount = usage.PromptTokensDetails.CachedTokens + } + if usage.CompletionTokenDetails != nil { + finalChunk.UsageMetadata.ThoughtsTokenCount = usage.CompletionTokenDetails.ReasoningTokens + } + } + chunkData, _ := json.Marshal(finalChunk) + _ = helper.WriteSSEData(writer, string(chunkData)) + + helper.WriteSSEData(writer, "[DONE]") + info.StreamStatus.SetEndReason(common.StreamEndReasonDone, nil) + + if err := scanner.Err(); err != nil && err != io.EOF && ctx.Err() == nil { + info.StreamStatus.SetEndReason(common.StreamEndReasonError, err) + return &usage, fmt.Errorf("stream scanner error: %w", err) + } + + return &usage, nil +} + +// openAIToGeminiResponse 将 OpenAI ChatCompletion 响应转换为 Gemini Chat 响应 +func openAIToGeminiResponse(openaiResp *dto.ChatCompletionResponse, info *common.RelayInfo) dto.GeminiChatResponse { + resp := dto.GeminiChatResponse{} + + if len(openaiResp.Choices) == 0 { + return resp + } + + choice := openaiResp.Choices[0] + parts := buildGeminiPartsFromMessage(&choice.Message) + + candidate := dto.GeminiCandidate{ + Index: choice.Index, + Content: &dto.GeminiContent{ + Role: "model", + Parts: parts, + }, + FinishReason: common.OpenAIFinishReasonToGemini(choice.FinishReason), + } + + resp.Candidates = []dto.GeminiCandidate{candidate} + + resp.UsageMetadata = &dto.GeminiUsageMetadata{ + PromptTokenCount: openaiResp.Usage.PromptTokens, + CandidatesTokenCount: openaiResp.Usage.CompletionTokens, + TotalTokenCount: openaiResp.Usage.TotalTokens, + } + if openaiResp.Usage.PromptTokensDetails != nil { + resp.UsageMetadata.CachedContentTokenCount = openaiResp.Usage.PromptTokensDetails.CachedTokens + } + if openaiResp.Usage.CompletionTokenDetails != nil { + resp.UsageMetadata.ThoughtsTokenCount = openaiResp.Usage.CompletionTokenDetails.ReasoningTokens + } + + return resp +} + +// buildGeminiPartsFromMessage 将 OpenAI Message 转换为 Gemini Parts +func buildGeminiPartsFromMessage(msg *dto.Message) []dto.GeminiPart { + var parts []dto.GeminiPart + + // thinking 内容 → thought part + if msg.ReasoningContent != nil && *msg.ReasoningContent != "" { + parts = append(parts, dto.GeminiPart{ + Text: *msg.ReasoningContent, + Thought: boolPtr(true), + }) + } + + // 文本内容 + if text, ok := msg.Content.(string); ok && text != "" { + parts = append(parts, dto.GeminiPart{Text: text}) + } + + // 工具调用 → functionCall parts + for _, tc := range msg.ToolCalls { + var args any + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { + args = map[string]any{} + } + parts = append(parts, dto.GeminiPart{ + FunctionCall: &dto.GeminiFunctionCall{ + ID: tc.ID, + FunctionName: tc.Function.Name, + Arguments: args, + }, + }) + } + + if len(parts) == 0 { + parts = append(parts, dto.GeminiPart{Text: ""}) + } + + return parts +} + +// buildGeminiPartsFromDelta 将 OpenAI 流式 Delta 转换为 Gemini Parts +func buildGeminiPartsFromDelta(delta *dto.Message) []dto.GeminiPart { + var parts []dto.GeminiPart + + // thinking 内容 + if delta.ReasoningContent != nil && *delta.ReasoningContent != "" { + parts = append(parts, dto.GeminiPart{ + Text: *delta.ReasoningContent, + Thought: boolPtr(true), + }) + } + + // 文本内容 + if text, ok := delta.Content.(string); ok && text != "" { + parts = append(parts, dto.GeminiPart{Text: text}) + } + + // 工具调用 + for _, tc := range delta.ToolCalls { + var args any + if tc.Function.Arguments != "" { + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { + args = map[string]any{} + } + } + parts = append(parts, dto.GeminiPart{ + FunctionCall: &dto.GeminiFunctionCall{ + ID: tc.ID, + FunctionName: tc.Function.Name, + Arguments: args, + }, + }) + } + + return parts +} + +// writeOpenAIErrorAsGemini 将上游 OpenAI 错误转换为 Gemini RPC Status 格式写入响应 +func writeOpenAIErrorAsGemini(writer http.ResponseWriter, body []byte, defaultStatusCode int) { + var openaiErr struct { + Error struct { + Message string `json:"message"` + Type string `json:"type"` + Code any `json:"code"` + } `json:"error"` + } + statusCode := defaultStatusCode + message := string(body) + + if err := json.Unmarshal(body, &openaiErr); err == nil && openaiErr.Error.Message != "" { + message = openaiErr.Error.Message + statusCode = openAIErrorTypeToHTTPStatus(openaiErr.Error.Type, defaultStatusCode) + } + + geminiErr, _ := json.Marshal(map[string]any{ + "error": map[string]any{ + "code": statusCode, + "message": message, + "status": openAIErrorTypeToGeminiStatus(openaiErr.Error.Type), + }, + }) + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(statusCode) + _, _ = writer.Write(geminiErr) +} + +// openAIErrorTypeToHTTPStatus 将 OpenAI error type 映射为 HTTP 状态码 +func openAIErrorTypeToHTTPStatus(errorType string, defaultCode int) int { + switch errorType { + case "authentication_error": + return 401 + case "permission_error": + return 403 + case "invalid_request_error": + return 400 + case "rate_limit_error": + return 429 + case "server_error", "internal_error": + return 500 + case "timeout_error": + return 504 + default: + return defaultCode + } +} + +// openAIErrorTypeToGeminiStatus 将 OpenAI error type 映射为 Gemini RPC status +func openAIErrorTypeToGeminiStatus(errorType string) string { + switch errorType { + case "authentication_error": + return "UNAUTHENTICATED" + case "permission_error": + return "PERMISSION_DENIED" + case "invalid_request_error": + return "INVALID_ARGUMENT" + case "rate_limit_error": + return "RESOURCE_EXHAUSTED" + case "server_error", "internal_error": + return "INTERNAL" + case "timeout_error": + return "DEADLINE_EXCEEDED" + default: + return "INTERNAL" + } +} + +// boolPtr 返回 bool 的指针 +func boolPtr(v bool) *bool { + return &v +} diff --git a/relay/channel/openai/image.go b/relay/channel/openai/image.go new file mode 100644 index 0000000..67b6264 --- /dev/null +++ b/relay/channel/openai/image.go @@ -0,0 +1,32 @@ +package openai + +import ( + "context" + "io" + "net/http" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" +) + +// handleImageResponse 处理图像生成非流式响应 +func (a *Adaptor) handleImageResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "read response body failed", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + // 图像生成响应中没有 model 字段需要替换,直接透传 + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(resp.StatusCode) + _, _ = writer.Write(body) + + // 图像生成没有 token 用量 + return &common.Usage{}, nil +} diff --git a/relay/channel/openai/realtime.go b/relay/channel/openai/realtime.go new file mode 100644 index 0000000..fa31b5d --- /dev/null +++ b/relay/channel/openai/realtime.go @@ -0,0 +1,240 @@ +package openai + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gorilla/websocket" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/dto" +) + +// RealtimeProxy Realtime API WebSocket 双向代理 +type RealtimeProxy struct { + info *common.RelayInfo + clientConn *websocket.Conn + targetConn *websocket.Conn +} + +// NewRealtimeProxy 创建 Realtime 代理 +func NewRealtimeProxy(info *common.RelayInfo) *RealtimeProxy { + return &RealtimeProxy{info: info} +} + +// GetTargetConn 返回上游 WebSocket 连接(用于首条消息转发) +func (p *RealtimeProxy) GetTargetConn() *websocket.Conn { + return p.targetConn +} + +// buildRealtimeURL 将 HTTP(S) BaseURL 转换为 WS(S) URL +func buildRealtimeURL(baseURL string) string { + u := strings.TrimSuffix(baseURL, "/") + u = strings.Replace(u, "https://", "wss://", 1) + u = strings.Replace(u, "http://", "ws://", 1) + return u + "/v1/realtime" +} + +// buildRealtimeHeaders 构建上游 WebSocket 连接头 +func buildRealtimeHeaders(info *common.RelayInfo) http.Header { + header := http.Header{} + + // 如果客户端带 Sec-WebSocket-Protocol,构造 OpenAI realtime 子协议 + if info.RequestHeaders != nil { + if swsp := info.RequestHeaders.Get("Sec-WebSocket-Protocol"); swsp != "" { + header.Set("Sec-WebSocket-Protocol", + fmt.Sprintf("realtime,openai-insecure-api-key.%s,openai-beta.realtime-v1", info.ChannelMeta.ApiKey)) + return header + } + } + + // 否则使用标准 Bearer 认证 + header.Set("Authorization", "Bearer "+info.ChannelMeta.ApiKey) + header.Set("OpenAI-Beta", "realtime=v1") + return header +} + +// DialUpstream 建立到上游的 WebSocket 连接 +func (p *RealtimeProxy) DialUpstream() error { + targetURL := buildRealtimeURL(p.info.ChannelMeta.BaseURL) + header := buildRealtimeHeaders(p.info) + + timeout := p.info.ChannelMeta.Settings.TimeoutSeconds + if timeout <= 0 { + timeout = 60 + } + + dialer := websocket.Dialer{ + HandshakeTimeout: time.Duration(timeout) * time.Second, + Subprotocols: []string{"realtime"}, + } + + // 渠道启用代理时,WebSocket 连接也走代理 + if p.info.ChannelMeta.Settings.UseProxy { + if proxyURL := common.GetSystemProxyURL(); proxyURL != "" { + if parsed, err := url.Parse(proxyURL); err == nil { + dialer.Proxy = http.ProxyURL(parsed) + } + } + } + + conn, resp, err := dialer.Dial(targetURL, header) + if err != nil { + if resp != nil { + body := make([]byte, 0) + if resp.Body != nil { + buf := make([]byte, 4096) + for { + n, readErr := resp.Body.Read(buf) + if n > 0 { + body = append(body, buf[:n]...) + } + if readErr != nil { + break + } + } + resp.Body.Close() + } + return fmt.Errorf("dial upstream websocket failed (status %d): %s, body: %s", resp.StatusCode, err, string(body)) + } + return fmt.Errorf("dial upstream websocket failed: %w", err) + } + + p.targetConn = conn + return nil +} + +// Proxy 启动双向代理,阻塞直到连接关闭 +func (p *RealtimeProxy) Proxy(ctx context.Context) (*common.Usage, error) { + var ( + sumUsage dto.RealtimeUsage + usageMu sync.Mutex + clientClosed = make(chan struct{}) + targetClosed = make(chan struct{}) + errChan = make(chan error, 2) + ) + + // Client → Upstream + go func() { + defer close(clientClosed) + for { + _, message, err := p.clientConn.ReadMessage() + if err != nil { + if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + g.Log().Debugf(ctx, "[Realtime] client read error: %v", err) + } + return + } + + if err := p.targetConn.WriteMessage(websocket.TextMessage, message); err != nil { + g.Log().Debugf(ctx, "[Realtime] upstream write error: %v", err) + return + } + + // 累计 input tokens + var event dto.RealtimeEvent + if json.Unmarshal(message, &event) == nil { + // input_audio_buffer.append 或 conversation.item.create 等事件可计入 input + if event.Type == "conversation.item.create" { + usageMu.Lock() + estimateInputFromEvent(&sumUsage, message) + usageMu.Unlock() + } + } + } + }() + + // Upstream → Client + go func() { + defer close(targetClosed) + for { + _, message, err := p.targetConn.ReadMessage() + if err != nil { + if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + g.Log().Debugf(ctx, "[Realtime] upstream read error: %v", err) + } + return + } + + if err := p.clientConn.WriteMessage(websocket.TextMessage, message); err != nil { + g.Log().Debugf(ctx, "[Realtime] client write error: %v", err) + return + } + + // 从 response.done 事件提取 usage + var event dto.RealtimeEvent + if json.Unmarshal(message, &event) == nil && event.Type == "response.done" { + if event.Response != nil && event.Response.Usage != nil { + usageMu.Lock() + accumulateUsage(&sumUsage, event.Response.Usage) + usageMu.Unlock() + } + } + } + }() + + // 等待任一方向关闭 + select { + case <-clientClosed: + case <-targetClosed: + case err := <-errChan: + return nil, err + case <-ctx.Done(): + } + + // 关闭连接 + _ = p.targetConn.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + _ = p.clientConn.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + + // 等待清理 + select { + case <-clientClosed: + case <-time.After(2 * time.Second): + } + select { + case <-targetClosed: + case <-time.After(2 * time.Second): + } + + usageMu.Lock() + defer usageMu.Unlock() + return &common.Usage{ + PromptTokens: sumUsage.InputTokens, + CompletionTokens: sumUsage.OutputTokens, + TotalTokens: sumUsage.TotalTokens, + }, nil +} + +// accumulateUsage 累加 Realtime 使用量 +func accumulateUsage(sum *dto.RealtimeUsage, u *dto.RealtimeUsage) { + sum.TotalTokens += u.TotalTokens + sum.InputTokens += u.InputTokens + sum.OutputTokens += u.OutputTokens +} + +// estimateInputFromEvent 从客户端事件粗略估算 input tokens +func estimateInputFromEvent(sum *dto.RealtimeUsage, message []byte) { + // 粗略估算:消息长度 / 4 + estimated := len(message) / 4 + if estimated == 0 { + estimated = 1 + } + sum.InputTokens += estimated + sum.TotalTokens = sum.InputTokens + sum.OutputTokens +} + +// Close 关闭 WebSocket 连接 +func (p *RealtimeProxy) Close() { + if p.targetConn != nil { + _ = p.targetConn.Close() + } +} diff --git a/relay/channel/openai/rerank.go b/relay/channel/openai/rerank.go new file mode 100644 index 0000000..18783f4 --- /dev/null +++ b/relay/channel/openai/rerank.go @@ -0,0 +1,63 @@ +package openai + +import ( + "context" + "encoding/json" + "io" + "net/http" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/dto" + + "github.com/gogf/gf/v2/frame/g" +) + +// handleRerankResponse 处理重排响应(JSON) +func handleRerankResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "read rerank response failed", err) + } + + if resp.StatusCode != http.StatusOK { + if isUpstreamOpenAIError(body) { + writeUpstreamErrorResponse(writer, resp.StatusCode, body) + return &common.Usage{}, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) + } + + if info.ChannelMeta.IsModelMapped { + body = replaceModelName(body, info.OriginModelName) + } + + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(body) + + // 提取 usage + usage := extractRerankUsage(body) + return usage, nil +} + +// extractRerankUsage 从重排响应中提取 token 使用量 +func extractRerankUsage(body []byte) *common.Usage { + var rerankResp dto.RerankResponse + if err := json.Unmarshal(body, &rerankResp); err != nil { + g.Log().Warningf(nil, "[OpenAI.extractRerankUsage] unmarshal rerank response failed: %v", err) + return &common.Usage{} + } + + promptTokens := rerankResp.Usage.PromptTokens + if promptTokens == 0 { + promptTokens = rerankResp.Usage.TotalTokens + } + + return &common.Usage{ + PromptTokens: promptTokens, + TotalTokens: rerankResp.Usage.TotalTokens, + } +} diff --git a/relay/channel/openai/responses.go b/relay/channel/openai/responses.go new file mode 100644 index 0000000..d0ef7eb --- /dev/null +++ b/relay/channel/openai/responses.go @@ -0,0 +1,611 @@ +package openai + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/dto" + "github.com/qianfree/team-api/relay/helper" +) + +// ========== 响应转换:Chat Completions → Responses ========== + +// handleResponsesInboundNonStream 将 Chat Completions 非流式响应转换为 Responses 格式 +func (a *Adaptor) handleResponsesInboundNonStream(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response body failed: %w", err) + } + + // 非 200:转换为 Responses 格式的错误 + if resp.StatusCode != http.StatusOK { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(resp.StatusCode) + _, _ = writer.Write(body) + return &common.Usage{}, nil + } + + // 解析 Chat Completions 响应 + var chatResp dto.ChatCompletionResponse + if err := json.Unmarshal(body, &chatResp); err != nil { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(resp.StatusCode) + _, _ = writer.Write(body) + return &common.Usage{}, nil + } + + // 转换为 Responses 格式 + responsesResp := chatCompletionToResponsesResponse(&chatResp, info) + responsesBody, err := json.Marshal(responsesResp) + if err != nil { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(resp.StatusCode) + _, _ = writer.Write(body) + return &common.Usage{}, nil + } + + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(responsesBody) + + return &common.Usage{ + PromptTokens: chatResp.Usage.PromptTokens, + CompletionTokens: chatResp.Usage.CompletionTokens, + TotalTokens: chatResp.Usage.TotalTokens, + CacheIncludedInPrompt: true, + }, nil +} + +// chatCompletionToResponsesResponse 将 Chat Completions 响应转换为 Responses API 响应 +func chatCompletionToResponsesResponse(chatResp *dto.ChatCompletionResponse, info *common.RelayInfo) *dto.OpenAIResponsesResponse { + modelName := info.OriginModelName + + // 构建 output + output := make([]dto.ResponsesOutput, 0) + for _, choice := range chatResp.Choices { + // 文本内容 + content := make([]dto.ResponsesOutputContent, 0) + if choice.Message.Content != nil { + var textContent string + switch v := choice.Message.Content.(type) { + case string: + textContent = v + default: + b, _ := json.Marshal(v) + textContent = string(b) + } + if textContent != "" { + content = append(content, dto.ResponsesOutputContent{ + Type: "output_text", + Text: textContent, + Annotations: []dto.ResponsesAnnotation{}, + }) + } + } + + msgOutput := dto.ResponsesOutput{ + Type: "message", + ID: fmt.Sprintf("msg_%s", chatResp.ID), + Status: "completed", + Role: "assistant", + Content: content, + } + output = append(output, msgOutput) + + // 工具调用 + for _, tc := range choice.Message.ToolCalls { + output = append(output, dto.ResponsesOutput{ + Type: "function_call", + ID: tc.ID, + CallID: tc.ID, + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + Status: "completed", + }) + } + } + + return &dto.OpenAIResponsesResponse{ + ID: fmt.Sprintf("resp_%s", chatResp.ID), + Object: "response", + CreatedAt: int(chatResp.Created), + CompletedAt: int(chatResp.Created) + 1, + Status: json.RawMessage(`"completed"`), + Error: nil, + IncompleteDetails: nil, + Instructions: nil, + MaxOutputTokens: nil, + Model: modelName, + Output: output, + ParallelToolCalls: true, + PreviousResponseID: nil, + Reasoning: &dto.ResponsesReasoning{Effort: nil, Summary: nil}, + Store: true, + Temperature: float64Ptr(1.0), + Text: &dto.ResponsesText{Format: dto.ResponsesTextFormat{Type: "text"}}, + ToolChoice: "auto", + Tools: make([]any, 0), + TopP: float64Ptr(1.0), + Truncation: "disabled", + User: nil, + Metadata: make(map[string]any), + Usage: &dto.ResponsesUsage{ + InputTokens: chatResp.Usage.PromptTokens, + OutputTokens: chatResp.Usage.CompletionTokens, + TotalTokens: chatResp.Usage.TotalTokens, + InputTokensDetails: &dto.InputTokenDetails{ + CachedTokens: chatResp.Usage.PromptTokensDetails.CachedTokens, + AudioTokens: chatResp.Usage.PromptTokensDetails.AudioTokens, + }, + OutputTokenDetails: &dto.OutputTokenDetails{ + ReasoningTokens: chatResp.Usage.CompletionTokenDetails.ReasoningTokens, + AcceptedPredictionTokens: chatResp.Usage.CompletionTokenDetails.AcceptedPredictionTokens, + RejectedPredictionTokens: chatResp.Usage.CompletionTokenDetails.RejectedPredictionTokens, + }, + }, + } +} + +// ========== 流式响应转换:Chat Completions SSE → Responses SSE ========== + +// handleResponsesInboundStream 将 Chat Completions 流式响应转换为 Responses 格式的 SSE +func (a *Adaptor) handleResponsesInboundStream(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(resp.StatusCode) + _, _ = writer.Write(body) + return &common.Usage{}, nil + } + + helper.SetEventStreamHeaders(writer) + defer helper.PingTicker(writer, 15*time.Second)() + + scanner := bufio.NewScanner(resp.Body) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 10*1024*1024) + + modelName := info.OriginModelName + respID := fmt.Sprintf("resp_%d", time.Now().UnixNano()) + msgID := fmt.Sprintf("msg_%d", time.Now().UnixNano()) + createdAt := int(time.Now().Unix()) + + var usage common.Usage + var contentBuilder strings.Builder + sentCreated := false + sentTextDone := false + outputIndex := 0 + contentIndex := 0 + toolCallIndexByID := make(map[string]int) + toolCallArgsByID := make(map[string]string) + toolCallNameByID := make(map[string]string) + // 通过 index 追踪 tool call ID(OpenAI 流式中后续 chunk 的 ID 为空,只有 index) + toolCallIDByIndex := make(map[int]string) + + for scanner.Scan() { + select { + case <-ctx.Done(): + info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, ctx.Err()) + return &usage, common.ErrStreamInterrupted + default: + } + + line := scanner.Text() + if line == "" { + continue + } + if strings.HasPrefix(line, "event:") { + continue + } + if !strings.HasPrefix(line, "data:") { + continue + } + + data, _ := helper.ExtractSSEData(line) + + if data != "" && data != "[DONE]" { + info.SetFirstResponseTime() + } + if data == "[DONE]" { + break + } + + var chunk dto.ChatCompletionStreamResponse + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + continue + } + + // 第一个 chunk:发送 response.created + output_item.added + content_part.added + if !sentCreated { + if chunk.ID != "" { + respID = fmt.Sprintf("resp_%s", chunk.ID) + msgID = fmt.Sprintf("msg_%s", chunk.ID) + } + if chunk.Created > 0 { + createdAt = int(chunk.Created) + } + if chunk.Model != "" && !info.ChannelMeta.IsModelMapped { + modelName = chunk.Model + } + + // response.created + emitResponsesSSE(writer, "response.created", map[string]any{ + "type": "response.created", + "response": buildResponsesObjectMap(respID, createdAt, "in_progress", modelName, []any{}, nil, nil), + }) + + // response.output_item.added + emitResponsesSSE(writer, "response.output_item.added", map[string]any{ + "type": "response.output_item.added", + "output_index": outputIndex, + "item": map[string]any{ + "type": "message", + "id": msgID, + "status": "in_progress", + "role": "assistant", + "content": []any{}, + }, + }) + + // response.content_part.added + emitResponsesSSE(writer, "response.content_part.added", map[string]any{ + "type": "response.content_part.added", + "item_id": msgID, + "output_index": outputIndex, + "content_index": contentIndex, + "part": map[string]any{ + "type": "output_text", + "text": "", + "annotations": []any{}, + }, + }) + + sentCreated = true + } + + // 提取 usage + if chunk.Usage != nil { + usage.PromptTokens = chunk.Usage.PromptTokens + usage.CompletionTokens = chunk.Usage.CompletionTokens + usage.TotalTokens = chunk.Usage.TotalTokens + } + + // 处理 choices delta + for _, choice := range chunk.Choices { + // 文本内容 delta + if choice.Delta.Content != nil { + var deltaText string + switch v := choice.Delta.Content.(type) { + case string: + deltaText = v + } + if deltaText != "" { + contentBuilder.WriteString(deltaText) + emitResponsesSSE(writer, "response.output_text.delta", map[string]any{ + "type": "response.output_text.delta", + "item_id": msgID, + "output_index": outputIndex, + "content_index": contentIndex, + "delta": deltaText, + }) + } + } + + // 推理内容 + if choice.Delta.ReasoningContent != nil && *choice.Delta.ReasoningContent != "" { + emitResponsesSSE(writer, "response.reasoning_summary_text.delta", map[string]any{ + "type": "response.reasoning_summary_text.delta", + "item_id": msgID, + "output_index": outputIndex, + "summary_index": 0, + "delta": *choice.Delta.ReasoningContent, + }) + } + + // tool calls + for _, tc := range choice.Delta.ToolCalls { + callID := tc.ID + + // 新 tool call:有 ID 和 name + if callID != "" && tc.Function.Name != "" { + // 记录 index → callID 映射,用于后续参数 chunk 的查找 + toolCallIDByIndex[tc.Index] = callID + + // 先关闭文本 content part + if !sentTextDone { + finishedText := contentBuilder.String() + emitResponsesSSE(writer, "response.output_text.done", map[string]any{ + "type": "response.output_text.done", + "item_id": msgID, + "output_index": outputIndex, + "content_index": contentIndex, + "text": finishedText, + }) + emitResponsesSSE(writer, "response.content_part.done", map[string]any{ + "type": "response.content_part.done", + "item_id": msgID, + "output_index": outputIndex, + "content_index": contentIndex, + "part": map[string]any{ + "type": "output_text", + "text": finishedText, + "annotations": []any{}, + }, + }) + emitResponsesSSE(writer, "response.output_item.done", map[string]any{ + "type": "response.output_item.done", + "output_index": outputIndex, + "item": map[string]any{ + "type": "message", + "id": msgID, + "status": "completed", + "role": "assistant", + "content": []map[string]any{ + { + "type": "output_text", + "text": finishedText, + "annotations": []any{}, + }, + }, + }, + }) + sentTextDone = true + outputIndex++ + } + + toolCallIndexByID[callID] = outputIndex + toolCallNameByID[callID] = tc.Function.Name + toolCallArgsByID[callID] = "" + + emitResponsesSSE(writer, "response.output_item.added", map[string]any{ + "type": "response.output_item.added", + "output_index": outputIndex, + "item": map[string]any{ + "type": "function_call", + "id": callID, + "call_id": callID, + "name": tc.Function.Name, + "status": "in_progress", + }, + }) + outputIndex++ + } + + // 参数 chunk:ID 可能为空,通过 index 查找对应的 callID + if callID == "" { + callID = toolCallIDByIndex[tc.Index] + } + if callID == "" { + continue + } + + // tool call arguments delta + if tc.Function.Arguments != "" { + toolCallArgsByID[callID] += tc.Function.Arguments + emitResponsesSSE(writer, "response.function_call_arguments.delta", map[string]any{ + "type": "response.function_call_arguments.delta", + "item_id": callID, + "output_index": toolCallIndexByID[callID], + "delta": tc.Function.Arguments, + }) + } + } + + // finish_reason + if choice.FinishReason != nil && *choice.FinishReason != "" { + finishedText := contentBuilder.String() + + // 关闭文本 content part(如果尚未关闭) + if !sentTextDone { + emitResponsesSSE(writer, "response.output_text.done", map[string]any{ + "type": "response.output_text.done", + "item_id": msgID, + "output_index": outputIndex, + "content_index": contentIndex, + "text": finishedText, + }) + emitResponsesSSE(writer, "response.content_part.done", map[string]any{ + "type": "response.content_part.done", + "item_id": msgID, + "output_index": outputIndex, + "content_index": contentIndex, + "part": map[string]any{ + "type": "output_text", + "text": finishedText, + "annotations": []any{}, + }, + }) + emitResponsesSSE(writer, "response.output_item.done", map[string]any{ + "type": "response.output_item.done", + "output_index": outputIndex, + "item": map[string]any{ + "type": "message", + "id": msgID, + "status": "completed", + "role": "assistant", + "content": []map[string]any{ + { + "type": "output_text", + "text": finishedText, + "annotations": []any{}, + }, + }, + }, + }) + } + + // 发送每个 tool call 的 function_call_arguments.done + output_item.done + for tcID, tcIdx := range toolCallIndexByID { + emitResponsesSSE(writer, "response.function_call_arguments.done", map[string]any{ + "type": "response.function_call_arguments.done", + "item_id": tcID, + "output_index": tcIdx, + "arguments": toolCallArgsByID[tcID], + }) + emitResponsesSSE(writer, "response.output_item.done", map[string]any{ + "type": "response.output_item.done", + "output_index": tcIdx, + "item": map[string]any{ + "type": "function_call", + "id": tcID, + "call_id": tcID, + "name": toolCallNameByID[tcID], + "arguments": toolCallArgsByID[tcID], + "status": "completed", + }, + }) + } + } + } + } + + if err := scanner.Err(); err != nil { + if err != io.EOF && ctx.Err() == nil { + info.StreamStatus.SetEndReason(common.StreamEndReasonError, err) + return &usage, fmt.Errorf("stream scanner error: %w", err) + } + } + + // 估算 usage + if usage.CompletionTokens == 0 { + text := contentBuilder.String() + if len(text) > 0 { + usage.CompletionTokens = len(text) / 4 + } + } + if usage.TotalTokens == 0 { + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + } + + // 构建 response.completed 的 output 数组(包含文本消息 + 所有 tool call) + finalOutput := make([]map[string]any, 0) + if !sentTextDone || contentBuilder.Len() > 0 { + finalOutput = append(finalOutput, map[string]any{ + "type": "message", + "id": msgID, + "status": "completed", + "role": "assistant", + "content": []map[string]any{ + { + "type": "output_text", + "text": contentBuilder.String(), + "annotations": []any{}, + }, + }, + }) + } + for tcID := range toolCallIndexByID { + finalOutput = append(finalOutput, map[string]any{ + "type": "function_call", + "id": tcID, + "call_id": tcID, + "name": toolCallNameByID[tcID], + "arguments": toolCallArgsByID[tcID], + "status": "completed", + }) + } + + // response.completed + completedAt := int(time.Now().Unix()) + emitResponsesSSE(writer, "response.completed", map[string]any{ + "type": "response.completed", + "response": buildResponsesObjectMap(respID, createdAt, "completed", modelName, finalOutput, buildResponsesUsageMap(&usage), &completedAt), + }) + + if info.StreamStatus.GetEndReason() == "" { + info.StreamStatus.SetEndReason(common.StreamEndReasonDone, nil) + } + + usage.CacheIncludedInPrompt = true + return &usage, nil +} + +// emitResponsesSSE 发送一个 Responses API 格式的 SSE 事件 +func emitResponsesSSE(w http.ResponseWriter, eventType string, data any) { + jsonData, err := json.Marshal(data) + if err != nil { + return + } + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventType, string(jsonData)) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } +} + +// buildResponsesObjectMap 构建 Responses API response 对象的完整字段 map +func buildResponsesObjectMap(respID string, createdAt int, status string, model string, output any, usageObj map[string]any, completedAt *int) map[string]any { + m := map[string]any{ + "id": respID, + "object": "response", + "created_at": createdAt, + "status": status, + "error": nil, + "incomplete_details": nil, + "instructions": nil, + "max_output_tokens": nil, + "model": model, + "output": output, + "parallel_tool_calls": true, + "previous_response_id": nil, + "reasoning": map[string]any{"effort": nil, "summary": nil}, + "store": true, + "temperature": 1.0, + "text": map[string]any{"format": map[string]any{"type": "text"}}, + "tool_choice": "auto", + "tools": []any{}, + "top_p": 1.0, + "truncation": "disabled", + "user": nil, + "metadata": map[string]any{}, + } + if completedAt != nil { + m["completed_at"] = *completedAt + } + if usageObj != nil { + m["usage"] = usageObj + } + return m +} + +// buildResponsesUsageMap 构建 Responses API usage 对象 +func buildResponsesUsageMap(usage *common.Usage) map[string]any { + inputDetails := map[string]any{"cached_tokens": 0} + outputDetails := map[string]any{"reasoning_tokens": 0} + if usage.PromptTokensDetails != nil { + inputDetails = map[string]any{ + "cached_tokens": usage.PromptTokensDetails.CachedTokens, + "audio_tokens": usage.PromptTokensDetails.AudioTokens, + } + } + if usage.CompletionTokenDetails != nil { + outputDetails = map[string]any{ + "reasoning_tokens": usage.CompletionTokenDetails.ReasoningTokens, + "audio_tokens": usage.CompletionTokenDetails.AudioTokens, + "accepted_prediction_tokens": usage.CompletionTokenDetails.AcceptedPredictionTokens, + "rejected_prediction_tokens": usage.CompletionTokenDetails.RejectedPredictionTokens, + } + } + return map[string]any{ + "input_tokens": usage.PromptTokens, + "output_tokens": usage.CompletionTokens, + "total_tokens": usage.TotalTokens, + "input_tokens_details": inputDetails, + "output_tokens_details": outputDetails, + } +} + +// float64Ptr 返回 float64 的指针 +func float64Ptr(v float64) *float64 { + return &v +} diff --git a/relay/channel/openai/stream.go b/relay/channel/openai/stream.go new file mode 100644 index 0000000..31d368a --- /dev/null +++ b/relay/channel/openai/stream.go @@ -0,0 +1,133 @@ +package openai + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/dto" + "github.com/qianfree/team-api/relay/helper" +) + +// StreamHandler 处理 OpenAI SSE 流式响应(goroutine + channel 解耦读写) +func StreamHandler(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + var totalUsage dto.UsageWithDetails + var responseTextBuf strings.Builder + + helper.StreamScannerHandler(ctx, resp, info, writer, func(data string, sr *helper.StreamResult) { + if info.ChannelMeta.IsModelMapped { + data = string(replaceModelName([]byte(data), info.OriginModelName)) + } + + var streamResp dto.ChatCompletionStreamResponse + if err := json.Unmarshal([]byte(data), &streamResp); err == nil { + if streamResp.Usage != nil { + totalUsage.PromptTokens = streamResp.Usage.PromptTokens + totalUsage.CompletionTokens = streamResp.Usage.CompletionTokens + totalUsage.TotalTokens = streamResp.Usage.TotalTokens + if streamResp.Usage.PromptTokensDetails != nil { + totalUsage.PromptTokensDetails = streamResp.Usage.PromptTokensDetails + } + if streamResp.Usage.CompletionTokenDetails != nil { + totalUsage.CompletionTokenDetails = streamResp.Usage.CompletionTokenDetails + } + } + for _, choice := range streamResp.Choices { + if choice.Delta.Content != nil { + if text, ok := choice.Delta.Content.(string); ok { + responseTextBuf.WriteString(text) + } + } + if choice.Delta.ReasoningContent != nil && *choice.Delta.ReasoningContent != "" { + responseTextBuf.WriteString(*choice.Delta.ReasoningContent) + } + } + } + + if err := helper.WriteSSEData(writer, data); err != nil { + sr.Stop(fmt.Errorf("write sse data failed: %w", err)) + return + } + }) + + // 根据流结束原因决定返回值 + // relay_handler.go 依赖返回的 error 判断是否需要部分结算或退费 + usage := &common.Usage{ + PromptTokens: totalUsage.PromptTokens, + CompletionTokens: totalUsage.CompletionTokens, + TotalTokens: totalUsage.TotalTokens, + PromptTokensDetails: common.DtoTokenDetailsToCommon(totalUsage.PromptTokensDetails), + CompletionTokenDetails: common.DtoTokenDetailsToCommon(totalUsage.CompletionTokenDetails), + CacheIncludedInPrompt: true, + } + + // 如果上游未返回 usage,用累积的响应文本估算 + if usage.TotalTokens == 0 && responseTextBuf.Len() > 0 { + estimated := responseTextBuf.Len() / 4 + usage.CompletionTokens = estimated + usage.TotalTokens = usage.PromptTokens + estimated + } + + if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() { + return usage, common.ErrStreamInterrupted + } + return usage, nil +} + +// StreamHandlerForCompletions 处理 Completions 流式响应(goroutine + channel 解耦读写) +func StreamHandlerForCompletions(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + var totalUsage dto.UsageWithDetails + var responseTextBuf strings.Builder + + helper.StreamScannerHandler(ctx, resp, info, writer, func(data string, sr *helper.StreamResult) { + if info.ChannelMeta.IsModelMapped { + data = string(replaceModelName([]byte(data), info.OriginModelName)) + } + + var streamResp dto.CompletionsStreamResponse + if err := json.Unmarshal([]byte(data), &streamResp); err == nil { + if streamResp.Usage != nil { + totalUsage.PromptTokens = streamResp.Usage.PromptTokens + totalUsage.CompletionTokens = streamResp.Usage.CompletionTokens + totalUsage.TotalTokens = streamResp.Usage.TotalTokens + if streamResp.Usage.PromptTokensDetails != nil { + totalUsage.PromptTokensDetails = streamResp.Usage.PromptTokensDetails + } + if streamResp.Usage.CompletionTokenDetails != nil { + totalUsage.CompletionTokenDetails = streamResp.Usage.CompletionTokenDetails + } + } + for _, choice := range streamResp.Choices { + responseTextBuf.WriteString(choice.Text) + } + } + + if err := helper.WriteSSEData(writer, data); err != nil { + sr.Stop(fmt.Errorf("write sse data failed: %w", err)) + return + } + }) + + usage := &common.Usage{ + PromptTokens: totalUsage.PromptTokens, + CompletionTokens: totalUsage.CompletionTokens, + TotalTokens: totalUsage.TotalTokens, + PromptTokensDetails: common.DtoTokenDetailsToCommon(totalUsage.PromptTokensDetails), + CompletionTokenDetails: common.DtoTokenDetailsToCommon(totalUsage.CompletionTokenDetails), + CacheIncludedInPrompt: true, + } + + if usage.TotalTokens == 0 && responseTextBuf.Len() > 0 { + estimated := responseTextBuf.Len() / 4 + usage.CompletionTokens = estimated + usage.TotalTokens = usage.PromptTokens + estimated + } + + if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() { + return usage, common.ErrStreamInterrupted + } + return usage, nil +} diff --git a/relay/channel/registry.go b/relay/channel/registry.go new file mode 100644 index 0000000..c08d9dd --- /dev/null +++ b/relay/channel/registry.go @@ -0,0 +1,94 @@ +package channel + +import ( + "github.com/qianfree/team-api/relay/channel/ali" + "github.com/qianfree/team-api/relay/channel/aws" + "github.com/qianfree/team-api/relay/channel/baidu_v2" + "github.com/qianfree/team-api/relay/channel/claude" + "github.com/qianfree/team-api/relay/channel/cloudflare" + "github.com/qianfree/team-api/relay/channel/codex" + "github.com/qianfree/team-api/relay/channel/coze" + "github.com/qianfree/team-api/relay/channel/deepseek" + "github.com/qianfree/team-api/relay/channel/dify" + "github.com/qianfree/team-api/relay/channel/gemini" + "github.com/qianfree/team-api/relay/channel/jimeng" + "github.com/qianfree/team-api/relay/channel/minimax" + "github.com/qianfree/team-api/relay/channel/mistral" + "github.com/qianfree/team-api/relay/channel/moonshot" + "github.com/qianfree/team-api/relay/channel/ollama" + "github.com/qianfree/team-api/relay/channel/openai" + "github.com/qianfree/team-api/relay/channel/siliconflow" + "github.com/qianfree/team-api/relay/channel/submodel" + "github.com/qianfree/team-api/relay/channel/tencent" + "github.com/qianfree/team-api/relay/channel/vertex" + "github.com/qianfree/team-api/relay/channel/volcengine" + "github.com/qianfree/team-api/relay/channel/xai" + "github.com/qianfree/team-api/relay/channel/xunfei" + "github.com/qianfree/team-api/relay/channel/zhipu" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" +) + +// GetAdaptor 根据供应商类型创建对应的适配器实例。 +func GetAdaptor(providerType int) common.Adaptor { + switch constant.ProviderType(providerType) { + case constant.ProviderOpenAI: + return &openai.Adaptor{} + case constant.ProviderClaude: + return &claude.Adaptor{} + case constant.ProviderGemini: + return &gemini.Adaptor{} + case constant.ProviderAli: + return &ali.Adaptor{} + case constant.ProviderDeepSeek: + return &deepseek.Adaptor{} + case constant.ProviderZhipu: + return &zhipu.Adaptor{} + case constant.ProviderMoonshot: + return &moonshot.Adaptor{} + case constant.ProviderMistral: + return &mistral.Adaptor{} + case constant.ProviderXAI: + return &xai.Adaptor{} + case constant.ProviderSiliconFlow: + return &siliconflow.Adaptor{} + case constant.ProviderCloudflare: + return &cloudflare.Adaptor{} + case constant.ProviderSubmodel: + return &submodel.Adaptor{} + case constant.ProviderBaiduV2: + return &baidu_v2.Adaptor{} + case constant.ProviderVolcengine: + return &volcengine.Adaptor{} + case constant.ProviderMiniMax: + return &minimax.Adaptor{} + case constant.ProviderOllama: + return &ollama.Adaptor{} + case constant.ProviderVertex: + return &vertex.Adaptor{} + case constant.ProviderAWS: + return &aws.Adaptor{} + case constant.ProviderAzure: + return &openai.Adaptor{} // Azure OpenAI 兼容 OpenAI 协议 + case constant.ProviderTencent: + return &tencent.Adaptor{} + case constant.ProviderXunfei: + return &xunfei.Adaptor{} + case constant.ProviderCoze: + return &coze.Adaptor{} + case constant.ProviderDify: + return &dify.Adaptor{} + case constant.ProviderJimeng: + return &jimeng.Adaptor{} + case constant.ProviderCodex: + return &codex.Adaptor{} + // OpenAI 兼容供应商(纯透传) + case constant.ProviderAI360, + constant.ProviderLingyi, + constant.ProviderOpenRouter, + constant.ProviderXInference: + return &openai.Adaptor{} + default: + return nil + } +} diff --git a/relay/channel/siliconflow/adaptor.go b/relay/channel/siliconflow/adaptor.go new file mode 100644 index 0000000..1f56d48 --- /dev/null +++ b/relay/channel/siliconflow/adaptor.go @@ -0,0 +1,139 @@ +package siliconflow + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/channel/openai" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/override" +) + +// Adaptor SiliconFlow 供应商适配器 +// SiliconFlow 兼容 OpenAI 接口格式,图像生成请求需要字段映射 +type Adaptor struct { + info *common.RelayInfo +} + +// 确保接口实现 +var _ common.Adaptor = (*Adaptor)(nil) + +// Init 初始化适配器 +func (a *Adaptor) Init(info *common.RelayInfo) { + a.info = info +} + +// GetRequestURL 构建上游请求 URL +func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") + + switch constant.RelayMode(info.RelayMode) { + case constant.RelayModeChatCompletions: + return baseURL + "/v1/chat/completions", nil + case constant.RelayModeCompletions: + return baseURL + "/v1/completions", nil + case constant.RelayModeEmbeddings: + return baseURL + "/v1/embeddings", nil + case constant.RelayModeImagesGenerations: + return baseURL + "/v1/images/generations", nil + default: + return "", fmt.Errorf("siliconflow: unsupported relay mode: %d", info.RelayMode) + } +} + +// SetupRequestHeader 设置上游请求头 +func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) error { + header.Set("Authorization", "Bearer "+info.ChannelMeta.ApiKey) + header.Set("Content-Type", "application/json") + header.Set("Accept", "application/json") + return nil +} + +// ConvertRequest 转换请求体 +// 对于图像生成请求,将 "size" 映射为 "image_size","n" 映射为 "batch_size" +// 其他请求只做模型名替换 +func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { + // 非 OpenAI 格式先转换为 OpenAI + if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { + c, err := openai.ConvertToOpenAI(requestBody, info) + if err != nil { + return nil, err + } + requestBody = c + } + + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(requestBody, &rawMap); err != nil { + return bytes.NewReader(requestBody), nil + } + + // 模型名映射 + if info.ChannelMeta.IsModelMapped { + rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) + } + + // 图像生成请求:字段映射 + if constant.RelayMode(info.RelayMode) == constant.RelayModeImagesGenerations { + if size, ok := rawMap["size"]; ok { + rawMap["image_size"] = size + delete(rawMap, "size") + } + if n, ok := rawMap["n"]; ok { + rawMap["batch_size"] = n + delete(rawMap, "n") + } + } + + converted, err := json.Marshal(rawMap) + if err != nil { + return nil, fmt.Errorf("siliconflow: marshal converted request failed: %w", err) + } + return bytes.NewReader(converted), nil +} + +// DoRequest 发送请求到上游 +func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + reqURL, err := a.GetRequestURL(info) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + if err := a.SetupRequestHeader(httpReq.Header, info); err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + + if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { + override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) + } + + timeout := info.ChannelMeta.Settings.TimeoutSeconds + if timeout <= 0 { + timeout = 60 + } + + client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) + return client.Do(httpReq) +} + +// DoResponse 处理上游响应,委托给 OpenAI 适配器 +func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + delegate := &openai.Adaptor{} + delegate.Init(info) + return delegate.DoResponse(ctx, resp, info, writer) +} + +// GetChannelName 返回渠道名称 +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/siliconflow/constants.go b/relay/channel/siliconflow/constants.go new file mode 100644 index 0000000..57a0748 --- /dev/null +++ b/relay/channel/siliconflow/constants.go @@ -0,0 +1,3 @@ +package siliconflow + +var ChannelName = "SiliconFlow" diff --git a/relay/channel/submodel/adaptor.go b/relay/channel/submodel/adaptor.go new file mode 100644 index 0000000..f197541 --- /dev/null +++ b/relay/channel/submodel/adaptor.go @@ -0,0 +1,112 @@ +package submodel + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/channel/openai" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/override" +) + +// Adaptor Submodel 供应商适配器。 +// 仅支持 Chat Completions 端点,其他端点返回错误。 +type Adaptor struct { + info *common.RelayInfo +} + +func (a *Adaptor) Init(info *common.RelayInfo) { + a.info = info +} + +// GetRequestURL 构建上游请求 URL。Submodel 只支持 chat completions。 +func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") + return baseURL + "/v1/chat/completions", nil +} + +func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) error { + header.Set("Authorization", "Bearer "+info.ChannelMeta.ApiKey) + header.Set("Content-Type", "application/json") + header.Set("Accept", "application/json") + return nil +} + +// ConvertRequest 转换请求体。 +// 仅允许 ChatCompletions 模式,其余模式返回错误。 +func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { + // 非 OpenAI 格式先转换为 OpenAI + if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { + converted, err := openai.ConvertToOpenAI(requestBody, info) + if err != nil { + return nil, err + } + requestBody = converted + } + + if constant.RelayMode(info.RelayMode) != constant.RelayModeChatCompletions { + return nil, fmt.Errorf("submodel: endpoint not supported") + } + + if info.ChannelMeta.IsModelMapped { + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(requestBody, &rawMap); err != nil { + return bytes.NewReader(requestBody), nil + } + rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) + converted, err := json.Marshal(rawMap) + if err != nil { + return nil, fmt.Errorf("marshal converted request failed: %w", err) + } + return bytes.NewReader(converted), nil + } + return bytes.NewReader(requestBody), nil +} + +func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + reqURL, err := a.GetRequestURL(info) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + if err := a.SetupRequestHeader(httpReq.Header, info); err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + + if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { + override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) + } + + timeout := info.ChannelMeta.Settings.TimeoutSeconds + if timeout <= 0 { + timeout = 60 + } + + client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) + + return client.Do(httpReq) +} + +// DoResponse 处理上游响应。Submodel 返回格式与 OpenAI 一致,委托 OpenAI 适配器处理。 +func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + delegate := &openai.Adaptor{} + delegate.Init(info) + return delegate.DoResponse(ctx, resp, info, writer) +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} + +var _ common.Adaptor = (*Adaptor)(nil) diff --git a/relay/channel/submodel/constants.go b/relay/channel/submodel/constants.go new file mode 100644 index 0000000..fa6db68 --- /dev/null +++ b/relay/channel/submodel/constants.go @@ -0,0 +1,3 @@ +package submodel + +var ChannelName = "Submodel" diff --git a/relay/channel/tencent/adaptor.go b/relay/channel/tencent/adaptor.go new file mode 100644 index 0000000..697f511 --- /dev/null +++ b/relay/channel/tencent/adaptor.go @@ -0,0 +1,190 @@ +package tencent + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/qianfree/team-api/relay/channel/openai" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/override" +) + +const ( + defaultHost = "hunyuan.tencentcloudapi.com" + defaultBaseURL = "https://hunyuan.tencentcloudapi.com/" + apiVersion = "2023-09-01" + serviceName = "hunyuan" +) + +// Adaptor 腾讯混元供应商适配器。 +// 腾讯混元新版 API 兼容 OpenAI 格式,响应委托 OpenAI 适配器处理。 +// +// ApiKey 格式:"secretId|secretKey"(管道分隔) +type Adaptor struct { + info *common.RelayInfo + secretID string + secretKey string +} + +func (a *Adaptor) Init(info *common.RelayInfo) { + a.info = info + + // 解析 ApiKey:"secretId|secretKey" + key := info.ChannelMeta.ApiKey + if parts := strings.SplitN(key, "|", 2); len(parts) == 2 { + a.secretID = strings.TrimSpace(parts[0]) + a.secretKey = strings.TrimSpace(parts[1]) + } else { + // 降级:当作纯 API Key 使用(用于已签名的代理网关场景) + a.secretID = key + } +} + +// GetRequestURL 返回腾讯混元 API 地址。 +// 如果配置了 BaseURL 则使用配置值,否则使用默认地址。 +func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { + if info.ChannelMeta.BaseURL != "" { + return strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") + "/", nil + } + return defaultBaseURL, nil +} + +// SetupRequestHeader 设置腾讯云 API 请求头,包括 TC3-HMAC-SHA256 签名。 +func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) error { + contentType := "application/json" + header.Set("Content-Type", contentType) + header.Set("Accept", "application/json") + + // 确定 Host + host := defaultHost + if info.ChannelMeta.BaseURL != "" { + // 从 BaseURL 提取 host + trimmed := strings.TrimPrefix(info.ChannelMeta.BaseURL, "https://") + trimmed = strings.TrimPrefix(trimmed, "http://") + trimmed = strings.TrimSuffix(trimmed, "/") + if trimmed != "" { + host = trimmed + } + } + header.Set("Host", host) + + // 确定 Action + action := "ChatCompletions" + switch constant.RelayMode(info.RelayMode) { + case constant.RelayModeEmbeddings: + action = "GetEmbedding" + } + + header.Set("X-TC-Action", action) + header.Set("X-TC-Version", apiVersion) + + timestamp := time.Now().Unix() + header.Set("X-TC-Timestamp", fmt.Sprintf("%d", timestamp)) + + // 如果有 secretKey 则计算签名;否则跳过(代理网关场景) + if a.secretKey != "" { + // 签名需要请求体,此处在 DoRequest 中重新计算 + // SetupRequestHeader 只设置非签名头,签名在 DoRequest 中补充 + } + + return nil +} + +// ConvertRequest 转换请求体。腾讯混元新版 API 兼容 OpenAI 格式,做模型名映射即可。 +func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { + // 非 OpenAI 格式先转换为 OpenAI + if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { + converted, err := openai.ConvertToOpenAI(requestBody, info) + if err != nil { + return nil, err + } + requestBody = converted + } + + if info.ChannelMeta.IsModelMapped { + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(requestBody, &rawMap); err != nil { + return bytes.NewReader(requestBody), nil + } + rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) + converted, err := json.Marshal(rawMap) + if err != nil { + return nil, fmt.Errorf("marshal converted request failed: %w", err) + } + return bytes.NewReader(converted), nil + } + return bytes.NewReader(requestBody), nil +} + +// DoRequest 发送请求到腾讯混元 API。 +// 由于 TC3 签名需要完整的请求体,签名在此方法中计算。 +func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + reqURL, err := a.GetRequestURL(info) + if err != nil { + return nil, err + } + + // 读取请求体用于签名 + var bodyBytes []byte + if requestBody != nil { + bodyBytes, err = io.ReadAll(requestBody) + if err != nil { + return nil, fmt.Errorf("read request body failed: %w", err) + } + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + if err := a.SetupRequestHeader(httpReq.Header, info); err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + + if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { + override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) + } + + // 计算 TC3-HMAC-SHA256 签名(需要完整请求体) + if a.secretKey != "" { + contentType := httpReq.Header.Get("Content-Type") + host := httpReq.Header.Get("Host") + timestamp := time.Now().Unix() + + // 更新时间戳(确保签名和头一致) + httpReq.Header.Set("X-TC-Timestamp", fmt.Sprintf("%d", timestamp)) + + authorization := sign(a.secretID, a.secretKey, serviceName, host, contentType, bodyBytes, timestamp) + httpReq.Header.Set("Authorization", authorization) + } + + timeout := info.ChannelMeta.Settings.TimeoutSeconds + if timeout <= 0 { + timeout = 60 + } + + client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) + + return client.Do(httpReq) +} + +// DoResponse 处理上游响应。腾讯混元新版 API 返回 OpenAI 格式,委托 OpenAI 适配器处理。 +func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + delegate := &openai.Adaptor{} + delegate.Init(info) + return delegate.DoResponse(ctx, resp, info, writer) +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} + +var _ common.Adaptor = (*Adaptor)(nil) diff --git a/relay/channel/tencent/constants.go b/relay/channel/tencent/constants.go new file mode 100644 index 0000000..1afafb6 --- /dev/null +++ b/relay/channel/tencent/constants.go @@ -0,0 +1,4 @@ +package tencent + +// ChannelName 渠道名称 +var ChannelName = "Tencent" diff --git a/relay/channel/tencent/signing.go b/relay/channel/tencent/signing.go new file mode 100644 index 0000000..4c495ff --- /dev/null +++ b/relay/channel/tencent/signing.go @@ -0,0 +1,81 @@ +package tencent + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "strconv" + "strings" + "time" +) + +// sign 使用 TC3-HMAC-SHA256 算法计算腾讯云 API 签名,返回完整的 Authorization 头值。 +// +// 签名流程: +// 1. 拼接规范请求串(CanonicalRequest) +// 2. 拼接待签名字符串(StringToSign) +// 3. 逐层 HMAC 派生签名密钥(SecretDate → SecretService → SecretSigning) +// 4. 计算签名并组装 Authorization 头 +func sign(secretID, secretKey, service, host, contentType string, payload []byte, timestamp int64) string { + // 日期:UTC 格式 YYYY-MM-DD + date := time.Unix(timestamp, 0).UTC().Format("2006-01-02") + + // ---- 步骤 1:拼接规范请求 ---- + httpRequestMethod := "POST" + canonicalURI := "/" + canonicalQueryString := "" + + // 签名头必须包含 content-type 和 host,且按字典序排列 + signedHeaders := "content-type;host" + canonicalHeaders := fmt.Sprintf("content-type:%s\nhost:%s\n", contentType, host) + + hashedPayload := sha256Hex(payload) + + canonicalRequest := strings.Join([]string{ + httpRequestMethod, + canonicalURI, + canonicalQueryString, + canonicalHeaders, + signedHeaders, + hashedPayload, + }, "\n") + + // ---- 步骤 2:拼接待签名字符串 ---- + algorithm := "TC3-HMAC-SHA256" + credentialScope := fmt.Sprintf("%s/%s/tc3_request", date, service) + + stringToSign := strings.Join([]string{ + algorithm, + strconv.FormatInt(timestamp, 10), + credentialScope, + sha256Hex([]byte(canonicalRequest)), + }, "\n") + + // ---- 步骤 3:计算签名密钥 ---- + secretDate := hmacSHA256([]byte("TC3"+secretKey), []byte(date)) + secretService := hmacSHA256(secretDate, []byte(service)) + secretSigning := hmacSHA256(secretService, []byte("tc3_request")) + + // ---- 步骤 4:计算签名 ---- + signature := hex.EncodeToString(hmacSHA256(secretSigning, []byte(stringToSign))) + + // ---- 组装 Authorization ---- + return fmt.Sprintf( + "%s Credential=%s/%s, SignedHeaders=%s, Signature=%s", + algorithm, secretID, credentialScope, signedHeaders, signature, + ) +} + +// hmacSHA256 计算 HMAC-SHA256 +func hmacSHA256(key, data []byte) []byte { + h := hmac.New(sha256.New, key) + h.Write(data) + return h.Sum(nil) +} + +// sha256Hex 计算 SHA-256 哈希并返回十六进制字符串 +func sha256Hex(data []byte) string { + h := sha256.Sum256(data) + return hex.EncodeToString(h[:]) +} diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go new file mode 100644 index 0000000..3a179e0 --- /dev/null +++ b/relay/channel/vertex/adaptor.go @@ -0,0 +1,188 @@ +package vertex + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/channel/claude" + "github.com/qianfree/team-api/relay/channel/gemini" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/override" +) + +// modelType 模型类型(决定使用哪个供应商的适配器进行请求转换和响应处理) +type modelType int + +const ( + modelTypeGemini modelType = iota + modelTypeClaude +) + +// Adaptor Vertex AI 供应商适配器 +// Vertex AI 是 Google Cloud 的 AI 平台,可同时接入 Gemini 和 Claude 模型。 +// 本适配器根据模型名称检测类型,委托对应的供应商适配器处理请求转换和响应。 +type Adaptor struct { + info *common.RelayInfo + detectedType modelType + isServiceAccount bool + projectID string + region string + + // 委托适配器 + geminiAdaptor *gemini.Adaptor + claudeAdaptor *claude.Adaptor +} + +// Init 初始化适配器 +func (a *Adaptor) Init(info *common.RelayInfo) { + a.info = info + + // 检测认证模式 + apiKey := info.ChannelMeta.ApiKey + if isServiceAccountJSON(apiKey) { + a.isServiceAccount = true + if pid, err := parseServiceAccountProjectID(apiKey); err == nil { + a.projectID = pid + } + } else { + a.isServiceAccount = false + // API Key 模式下,projectID 需要从 BaseURL 或其他配置中获取 + a.projectID = apiKey + } + + // 默认区域 + a.region = "us-central1" + + // 检测模型类型 + model := info.ChannelMeta.UpstreamModelName + if strings.Contains(strings.ToLower(model), "claude") { + a.detectedType = modelTypeClaude + } else { + a.detectedType = modelTypeGemini + } + + // 初始化委托适配器 + a.geminiAdaptor = &gemini.Adaptor{} + a.geminiAdaptor.Init(info) + a.claudeAdaptor = &claude.Adaptor{} + a.claudeAdaptor.Init(info) +} + +// GetRequestURL 构建上游请求 URL +func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") + model := info.ChannelMeta.UpstreamModelName + + switch a.detectedType { + case modelTypeClaude: + // Claude on Vertex AI + suffix := ":rawPredict" + if info.IsStream { + suffix = ":streamRawPredict" + } + return fmt.Sprintf("%s/v1/projects/%s/locations/%s/publishers/anthropic/models/%s%s", + baseURL, a.projectID, a.region, model, suffix), nil + + default: + // Gemini on Vertex AI + suffix := ":generateContent" + if info.IsStream { + suffix = ":streamGenerateContent?alt=sse" + } + return fmt.Sprintf("%s/v1/projects/%s/locations/%s/publishers/google/models/%s%s", + baseURL, a.projectID, a.region, model, suffix), nil + } +} + +// SetupRequestHeader 设置上游请求头 +func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) error { + header.Set("Content-Type", "application/json") + + if a.isServiceAccount { + // 服务账号模式:获取 OAuth2 访问令牌 + token, err := getVertexAccessToken(info.ChannelMeta.ApiKey) + if err != nil { + return fmt.Errorf("get Vertex access token failed: %w", err) + } + header.Set("Authorization", "Bearer "+token) + } else { + // API Key 模式(不常用,通常 Vertex 使用服务账号) + header.Set("Authorization", "Bearer "+info.ChannelMeta.ApiKey) + } + + if info.RequestHeaders != nil { + for _, h := range []string{"X-Request-Id"} { + if v := info.RequestHeaders.Get(h); v != "" { + header.Set(h, v) + } + } + } + + return nil +} + +// ConvertRequest 转换请求体(委托给对应的供应商适配器) +func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { + switch a.detectedType { + case modelTypeClaude: + return a.claudeAdaptor.ConvertRequest(ctx, info, requestBody) + default: + return a.geminiAdaptor.ConvertRequest(ctx, info, requestBody) + } +} + +// DoRequest 发送请求到上游 +func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + reqURL, err := a.GetRequestURL(info) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + if err := a.SetupRequestHeader(httpReq.Header, info); err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + + if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { + override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) + } + + timeout := info.ChannelMeta.Settings.TimeoutSeconds + if timeout <= 0 { + timeout = 60 + } + + client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) + + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("send request failed: %w", err) + } + + return resp, nil +} + +// DoResponse 处理上游响应(委托给对应的供应商适配器) +func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + switch a.detectedType { + case modelTypeClaude: + return a.claudeAdaptor.DoResponse(ctx, resp, info, writer) + default: + return a.geminiAdaptor.DoResponse(ctx, resp, info, writer) + } +} + +// GetChannelName 返回渠道名称 +func (a *Adaptor) GetChannelName() string { + return ChannelName +} + +// 确保接口实现 +var _ common.Adaptor = (*Adaptor)(nil) diff --git a/relay/channel/vertex/auth.go b/relay/channel/vertex/auth.go new file mode 100644 index 0000000..402786a --- /dev/null +++ b/relay/channel/vertex/auth.go @@ -0,0 +1,219 @@ +package vertex + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// serviceAccountKey Google 服务账号 JSON 密钥结构 +type serviceAccountKey struct { + Type string `json:"type"` + ProjectID string `json:"project_id"` + PrivateKeyID string `json:"private_key_id"` + PrivateKey string `json:"private_key"` + ClientEmail string `json:"client_email"` + ClientID string `json:"client_id"` + AuthURI string `json:"auth_uri"` + TokenURI string `json:"token_uri"` + AuthProviderX509CertURL string `json:"auth_provider_x509_cert_url"` + ClientX509CertURL string `json:"client_x509_cert_url"` +} + +// tokenEntry 缓存的访问令牌 +type tokenEntry struct { + accessToken string + expiry time.Time +} + +// tokenCache 全局令牌缓存(key 为 client_email) +var tokenCache sync.Map + +// getVertexAccessToken 获取 Vertex AI 访问令牌。 +// 通过服务账号 JSON 密钥签发 JWT,然后通过 Google OAuth2 端点交换访问令牌。 +// 令牌缓存在 sync.Map 中,未过期时复用。 +func getVertexAccessToken(serviceAccountJSON string) (string, error) { + var key serviceAccountKey + if err := json.Unmarshal([]byte(serviceAccountJSON), &key); err != nil { + return "", fmt.Errorf("parse service account JSON failed: %w", err) + } + + // 检查缓存 + if cached, ok := tokenCache.Load(key.ClientEmail); ok { + entry := cached.(*tokenEntry) + if time.Now().Before(entry.expiry.Add(-60 * time.Second)) { + return entry.accessToken, nil + } + } + + // 解析 RSA 私钥 + rsaKey, err := parseRSAPrivateKey(key.PrivateKey) + if err != nil { + return "", fmt.Errorf("parse private key failed: %w", err) + } + + // 创建 JWT + now := time.Now() + jwtToken, err := createSignedJWT(key.ClientEmail, rsaKey, now) + if err != nil { + return "", fmt.Errorf("create JWT failed: %w", err) + } + + // 交换访问令牌 + accessToken, expiresIn, err := exchangeJWTForToken(jwtToken) + if err != nil { + return "", fmt.Errorf("exchange token failed: %w", err) + } + + // 缓存令牌 + tokenCache.Store(key.ClientEmail, &tokenEntry{ + accessToken: accessToken, + expiry: now.Add(time.Duration(expiresIn) * time.Second), + }) + + return accessToken, nil +} + +// parseServiceAccountProjectID 从服务账号 JSON 中提取 project_id +func parseServiceAccountProjectID(serviceAccountJSON string) (string, error) { + var key serviceAccountKey + if err := json.Unmarshal([]byte(serviceAccountJSON), &key); err != nil { + return "", fmt.Errorf("parse service account JSON failed: %w", err) + } + if key.ProjectID == "" { + return "", fmt.Errorf("project_id not found in service account JSON") + } + return key.ProjectID, nil +} + +// isServiceAccountJSON 判断给定字符串是否为服务账号 JSON +func isServiceAccountJSON(s string) bool { + s = strings.TrimSpace(s) + if !strings.HasPrefix(s, "{") { + return false + } + var key struct { + Type string `json:"type"` + } + if err := json.Unmarshal([]byte(s), &key); err != nil { + return false + } + return key.Type == "service_account" +} + +// parseRSAPrivateKey 从 PEM 编码的私钥字符串解析 RSA 私钥 +func parseRSAPrivateKey(pemKey string) (*rsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(pemKey)) + if block == nil { + return nil, fmt.Errorf("failed to decode PEM block") + } + + // 尝试 PKCS8 格式 + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err == nil { + rsaKey, ok := key.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("key is not RSA") + } + return rsaKey, nil + } + + // 尝试 PKCS1 格式 + rsaKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse private key failed: %w", err) + } + return rsaKey, nil +} + +// createSignedJWT 创建并签名 JWT +func createSignedJWT(clientEmail string, privateKey *rsa.PrivateKey, now time.Time) (string, error) { + // JWT Header + header := map[string]string{ + "alg": "RS256", + "typ": "JWT", + } + headerJSON, err := json.Marshal(header) + if err != nil { + return "", err + } + + // JWT Claims + claims := map[string]interface{}{ + "iss": clientEmail, + "scope": "https://www.googleapis.com/auth/cloud-platform", + "aud": "https://www.googleapis.com/oauth2/v4/token", + "iat": now.Unix(), + "exp": now.Add(3600 * time.Second).Unix(), + } + claimsJSON, err := json.Marshal(claims) + if err != nil { + return "", err + } + + // 编码 Header 和 Claims + headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON) + claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON) + signingInput := headerB64 + "." + claimsB64 + + // RS256 签名 + hash := sha256.Sum256([]byte(signingInput)) + signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hash[:]) + if err != nil { + return "", fmt.Errorf("sign JWT failed: %w", err) + } + + signatureB64 := base64.RawURLEncoding.EncodeToString(signature) + return signingInput + "." + signatureB64, nil +} + +// exchangeJWTForToken 通过 JWT 交换 Google OAuth2 访问令牌 +func exchangeJWTForToken(jwtToken string) (string, int64, error) { + tokenURL := "https://www.googleapis.com/oauth2/v4/token" + + data := url.Values{} + data.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer") + data.Set("assertion", jwtToken) + + resp, err := http.Post(tokenURL, "application/x-www-form-urlencoded", strings.NewReader(data.Encode())) + if err != nil { + return "", 0, fmt.Errorf("token request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", 0, fmt.Errorf("read token response failed: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", 0, fmt.Errorf("token exchange failed (HTTP %d): %s", resp.StatusCode, string(body)) + } + + var tokenResp struct { + AccessToken string `json:"access_token"` + ExpiresIn int64 `json:"expires_in"` + TokenType string `json:"token_type"` + } + if err := json.Unmarshal(body, &tokenResp); err != nil { + return "", 0, fmt.Errorf("parse token response failed: %w", err) + } + + if tokenResp.AccessToken == "" { + return "", 0, fmt.Errorf("empty access token in response") + } + + return tokenResp.AccessToken, tokenResp.ExpiresIn, nil +} diff --git a/relay/channel/vertex/constants.go b/relay/channel/vertex/constants.go new file mode 100644 index 0000000..2aee262 --- /dev/null +++ b/relay/channel/vertex/constants.go @@ -0,0 +1,3 @@ +package vertex + +var ChannelName = "Vertex AI" diff --git a/relay/channel/volcengine/adaptor.go b/relay/channel/volcengine/adaptor.go new file mode 100644 index 0000000..5165fcd --- /dev/null +++ b/relay/channel/volcengine/adaptor.go @@ -0,0 +1,160 @@ +package volcengine + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/channel/claude" + "github.com/qianfree/team-api/relay/channel/openai" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/override" +) + +// Adaptor 火山引擎(豆包)供应商适配器。 +// OpenAI 兼容格式,bot- 前缀模型使用 bots 端点。 +type Adaptor struct { + info *common.RelayInfo +} + +func (a *Adaptor) Init(info *common.RelayInfo) { + a.info = info +} + +// GetRequestURL 构建上游请求 URL。 +// Claude 协议走 Anthropic 兼容端点,bot- 前缀模型使用 bots 端点,其余使用标准路径。 +func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") + + // 确定实际模型名 + modelName := info.OriginModelName + if info.ChannelMeta.IsModelMapped { + modelName = info.ChannelMeta.UpstreamModelName + } + + switch constant.RelayMode(info.RelayMode) { + case constant.RelayModeClaudeMessages: + return baseURL + "/api/coding/v1/messages", nil + case constant.RelayModeChatCompletions: + if strings.HasPrefix(modelName, "bot-") { + return baseURL + "/api/v3/bots/chat/completions", nil + } + return baseURL + "/api/v3/chat/completions", nil + case constant.RelayModeEmbeddings: + return baseURL + "/api/v3/embeddings", nil + case constant.RelayModeImagesGenerations: + return baseURL + "/api/v3/images/generations", nil + default: + return "", fmt.Errorf("volcengine: unsupported relay mode: %d", info.RelayMode) + } +} + +func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) error { + header.Set("Authorization", "Bearer "+info.ChannelMeta.ApiKey) + header.Set("Content-Type", "application/json") + header.Set("Accept", "application/json") + return nil +} + +// ConvertRequest 转换请求体。 +// Claude 入站直接透传到 Anthropic 兼容端点,其他格式先转为 OpenAI 再做模型映射。 +func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { + // Claude 入站:仅做模型映射 + if info.InboundFormat == constant.RelayFormatClaude { + return convertClaudeRequest(requestBody, info) + } + + // 非 OpenAI 格式先转换为 OpenAI + if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { + converted, err := openai.ConvertToOpenAI(requestBody, info) + if err != nil { + return nil, err + } + requestBody = converted + } + + if info.ChannelMeta.IsModelMapped { + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(requestBody, &rawMap); err != nil { + return bytes.NewReader(requestBody), nil + } + rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) + converted, err := json.Marshal(rawMap) + if err != nil { + return nil, fmt.Errorf("marshal converted request failed: %w", err) + } + return bytes.NewReader(converted), nil + } + return bytes.NewReader(requestBody), nil +} + +func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + reqURL, err := a.GetRequestURL(info) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + if err := a.SetupRequestHeader(httpReq.Header, info); err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + + if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { + override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) + } + + timeout := info.ChannelMeta.Settings.TimeoutSeconds + if timeout <= 0 { + timeout = 60 + } + + client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) + + return client.Do(httpReq) +} + +// DoResponse 处理上游响应。 +// Claude 入站委托 claude.Adaptor 原生直通;其他格式委托 openai.Adaptor。 +func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + if info.GetOriginalClientFormat() == constant.RelayFormatClaude { + delegate := &claude.Adaptor{} + delegate.Init(info) + return delegate.DoResponse(ctx, resp, info, writer) + } + + delegate := &openai.Adaptor{} + delegate.Init(info) + return delegate.DoResponse(ctx, resp, info, writer) +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} + +var _ common.Adaptor = (*Adaptor)(nil) + +// convertClaudeRequest 处理 Claude 入站请求的模型映射。 +func convertClaudeRequest(requestBody []byte, info *common.RelayInfo) (io.Reader, error) { + if !info.ChannelMeta.IsModelMapped { + return bytes.NewReader(requestBody), nil + } + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(requestBody, &rawMap); err != nil { + return bytes.NewReader(requestBody), nil + } + rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) + result, err := json.Marshal(rawMap) + if err != nil { + return bytes.NewReader(requestBody), nil + } + return bytes.NewReader(result), nil +} diff --git a/relay/channel/volcengine/constants.go b/relay/channel/volcengine/constants.go new file mode 100644 index 0000000..e03bbfe --- /dev/null +++ b/relay/channel/volcengine/constants.go @@ -0,0 +1,3 @@ +package volcengine + +var ChannelName = "Volcengine" diff --git a/relay/channel/xai/adaptor.go b/relay/channel/xai/adaptor.go new file mode 100644 index 0000000..22bf64a --- /dev/null +++ b/relay/channel/xai/adaptor.go @@ -0,0 +1,149 @@ +package xai + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/channel/openai" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/override" +) + +// Adaptor xAI (Grok) 供应商适配器 +type Adaptor struct { + info *common.RelayInfo +} + +func (a *Adaptor) Init(info *common.RelayInfo) { + a.info = info +} + +// GetRequestURL 构建上游请求 URL。xAI 使用标准 OpenAI 路径。 +func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") + + switch constant.RelayMode(info.RelayMode) { + case constant.RelayModeChatCompletions, constant.RelayModeClaudeMessages: + return baseURL + "/v1/chat/completions", nil + case constant.RelayModeCompletions: + return baseURL + "/v1/completions", nil + case constant.RelayModeEmbeddings: + return baseURL + "/v1/embeddings", nil + case constant.RelayModeImagesGenerations: + return baseURL + "/v1/images/generations", nil + default: + return baseURL + "/v1/chat/completions", nil + } +} + +func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) error { + header.Set("Authorization", "Bearer "+info.ChannelMeta.ApiKey) + header.Set("Content-Type", "application/json") + header.Set("Accept", "application/json") + return nil +} + +// ConvertRequest 转换请求体。 +// xAI 特殊处理: +// - 模型名以 "-search" 结尾:剥离后缀,添加 search_parameters.mode = "on" +// - 模型名以 "-high" 结尾:剥离后缀,添加 reasoning_effort = "high" +// - 模型名以 "-low" 结尾:剥离后缀,添加 reasoning_effort = "low" +// +// 剥离后缀后再做模型名映射(如果配置了映射)。 +func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { + // 非 OpenAI 格式先转换为 OpenAI + if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { + converted, err := openai.ConvertToOpenAI(requestBody, info) + if err != nil { + return nil, err + } + requestBody = converted + } + + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(requestBody, &rawMap); err != nil { + return bytes.NewReader(requestBody), nil + } + + // 获取当前模型名 + modelName := info.OriginModelName + + // 处理 "-search" 后缀 + if strings.HasSuffix(modelName, "-search") { + modelName = strings.TrimSuffix(modelName, "-search") + rawMap["search_parameters"] = json.RawMessage(`{"mode":"on"}`) + } + + // 处理 "-high" 后缀 + if strings.HasSuffix(modelName, "-high") { + modelName = strings.TrimSuffix(modelName, "-high") + rawMap["reasoning_effort"] = json.RawMessage(`"high"`) + } + + // 处理 "-low" 后缀 + if strings.HasSuffix(modelName, "-low") { + modelName = strings.TrimSuffix(modelName, "-low") + rawMap["reasoning_effort"] = json.RawMessage(`"low"`) + } + + // 模型名映射:优先使用上游映射名,否则使用剥离后缀后的名称 + if info.ChannelMeta.IsModelMapped { + rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) + } else { + rawMap["model"] = json.RawMessage(`"` + modelName + `"`) + } + + converted, err := json.Marshal(rawMap) + if err != nil { + return nil, fmt.Errorf("marshal converted request failed: %w", err) + } + return bytes.NewReader(converted), nil +} + +func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + reqURL, err := a.GetRequestURL(info) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + if err := a.SetupRequestHeader(httpReq.Header, info); err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + + if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { + override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) + } + + timeout := info.ChannelMeta.Settings.TimeoutSeconds + if timeout <= 0 { + timeout = 60 + } + + client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) + + return client.Do(httpReq) +} + +// DoResponse 处理上游响应。xAI 返回格式与 OpenAI 一致,委托 OpenAI 适配器处理。 +func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + delegate := &openai.Adaptor{} + delegate.Init(info) + return delegate.DoResponse(ctx, resp, info, writer) +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} + +var _ common.Adaptor = (*Adaptor)(nil) diff --git a/relay/channel/xai/constants.go b/relay/channel/xai/constants.go new file mode 100644 index 0000000..2685637 --- /dev/null +++ b/relay/channel/xai/constants.go @@ -0,0 +1,4 @@ +package xai + +// ChannelName 渠道名称 +var ChannelName = "xAI" diff --git a/relay/channel/xunfei/adaptor.go b/relay/channel/xunfei/adaptor.go new file mode 100644 index 0000000..a31f46d --- /dev/null +++ b/relay/channel/xunfei/adaptor.go @@ -0,0 +1,121 @@ +package xunfei + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/channel/openai" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/override" +) + +// Adaptor 讯飞星火供应商适配器(OpenAI 兼容模式)。 +// 讯飞提供了 OpenAI 兼容的 HTTP API(spark-api-open.xf-yun.com/v1), +// 本适配器使用该兼容接口,WebSocket 原生接口可后续作为独立渠道类型添加。 +type Adaptor struct { + info *common.RelayInfo +} + +func (a *Adaptor) Init(info *common.RelayInfo) { + a.info = info +} + +// GetRequestURL 构建上游请求 URL。 +// 讯飞 OpenAI 兼容接口的基础 URL 为 https://spark-api-open.xf-yun.com/v1 +func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") + + switch constant.RelayMode(info.RelayMode) { + case constant.RelayModeChatCompletions, constant.RelayModeClaudeMessages: + return baseURL + "/v1/chat/completions", nil + default: + return baseURL + "/v1/chat/completions", nil + } +} + +// SetupRequestHeader 设置上游请求头。 +// 讯飞 OpenAI 兼容接口的 ApiKey 格式为 "apiKey:apiSecret", +// 整体作为 Bearer Token 使用。 +func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) error { + header.Set("Authorization", "Bearer "+info.ChannelMeta.ApiKey) + header.Set("Content-Type", "application/json") + header.Set("Accept", "application/json") + return nil +} + +// ConvertRequest 转换请求体。讯飞 OpenAI 兼容接口格式与 OpenAI 一致, +// 只需做模型名映射。 +func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { + // 非 OpenAI 格式先转换为 OpenAI + if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { + converted, err := openai.ConvertToOpenAI(requestBody, info) + if err != nil { + return nil, err + } + requestBody = converted + } + + if info.ChannelMeta.IsModelMapped { + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(requestBody, &rawMap); err != nil { + return bytes.NewReader(requestBody), nil + } + rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) + converted, err := json.Marshal(rawMap) + if err != nil { + return nil, fmt.Errorf("marshal converted request failed: %w", err) + } + return bytes.NewReader(converted), nil + } + return bytes.NewReader(requestBody), nil +} + +// DoRequest 发送请求到上游 +func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + reqURL, err := a.GetRequestURL(info) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + if err := a.SetupRequestHeader(httpReq.Header, info); err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + + if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { + override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) + } + + timeout := info.ChannelMeta.Settings.TimeoutSeconds + if timeout <= 0 { + timeout = 60 + } + + client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) + + return client.Do(httpReq) +} + +// DoResponse 处理上游响应。讯飞 OpenAI 兼容接口返回格式与 OpenAI 一致, +// 委托 OpenAI 适配器处理。 +func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + delegate := &openai.Adaptor{} + delegate.Init(info) + return delegate.DoResponse(ctx, resp, info, writer) +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} + +var _ common.Adaptor = (*Adaptor)(nil) diff --git a/relay/channel/xunfei/constants.go b/relay/channel/xunfei/constants.go new file mode 100644 index 0000000..7a3946e --- /dev/null +++ b/relay/channel/xunfei/constants.go @@ -0,0 +1,4 @@ +package xunfei + +// ChannelName 渠道名称 +var ChannelName = "Xunfei" diff --git a/relay/channel/zhipu/adaptor.go b/relay/channel/zhipu/adaptor.go new file mode 100644 index 0000000..d7722d4 --- /dev/null +++ b/relay/channel/zhipu/adaptor.go @@ -0,0 +1,257 @@ +package zhipu + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/channel/claude" + "github.com/qianfree/team-api/relay/channel/openai" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/override" +) + +// Adaptor 智谱 GLM 供应商适配器(V4 OpenAI 兼容接口) +type Adaptor struct { + info *common.RelayInfo +} + +func (a *Adaptor) Init(info *common.RelayInfo) { + a.info = info +} + +// GetRequestURL 构建上游请求 URL。 +// 智谱 V4 API 路径为 /api/paas/v4/xxx,与标准 OpenAI 路径不同。 +// Claude 协议请求走 Anthropic 兼容端点 /api/anthropic/v1/messages。 +func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") + + switch constant.RelayMode(info.RelayMode) { + case constant.RelayModeClaudeMessages: + return baseURL + "/api/anthropic/v1/messages", nil + case constant.RelayModeChatCompletions, constant.RelayModeResponses: + return baseURL + "/api/paas/v4/chat/completions", nil + case constant.RelayModeEmbeddings: + return baseURL + "/api/paas/v4/embeddings", nil + case constant.RelayModeImagesGenerations: + return baseURL + "/api/paas/v4/images/generations", nil + default: + return baseURL + "/api/paas/v4/chat/completions", nil + } +} + +func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) error { + header.Set("Authorization", "Bearer "+info.ChannelMeta.ApiKey) + + // 透传客户端 Content-Type,无则回退 application/json + if ct := info.RequestHeaders.Get("Content-Type"); ct != "" { + header.Set("Content-Type", ct) + } else { + header.Set("Content-Type", "application/json") + } + + // 透传客户端 Accept,流式请求回退 text/event-stream,否则 application/json + if accept := info.RequestHeaders.Get("Accept"); accept != "" { + header.Set("Accept", accept) + } else if info.IsStream { + header.Set("Accept", "text/event-stream") + } else { + header.Set("Accept", "application/json") + } + + return nil +} + +// ConvertRequest 转换请求体。 +// Claude 入站格式直接透传到智谱 Anthropic 端点,其他格式先转为 OpenAI 再做 GLM 特有适配。 +func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { + // Claude 入站:透传请求体,仅做模型映射 + if info.InboundFormat == constant.RelayFormatClaude { + return convertClaudeRequestForZhipu(requestBody, info) + } + + // 非 OpenAI 格式先转换为 OpenAI + if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { + converted, err := openai.ConvertToOpenAI(requestBody, info) + if err != nil { + return nil, err + } + requestBody = converted + } + + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(requestBody, &rawMap); err != nil { + return bytes.NewReader(requestBody), nil + } + + // GLM 特有的转换(TopP 裁剪、图片前缀剥离) + rawMap = applyGLMCompatibility(rawMap) + + // 注入 stream_options(流式请求需要 usage 信息用于计费) + rawMap = injectStreamOptions(rawMap, info) + + // 注入思考模式参数 + rawMap = injectThinkingParams(rawMap, info) + + // 模型名映射 + if info.ChannelMeta.IsModelMapped { + rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) + } + + result, err := json.Marshal(rawMap) + if err != nil { + return nil, fmt.Errorf("marshal converted request failed: %w", err) + } + return bytes.NewReader(result), nil +} + +// injectStreamOptions 为流式请求注入 stream_options:{include_usage:true} +func injectStreamOptions(rawMap map[string]json.RawMessage, info *common.RelayInfo) map[string]json.RawMessage { + if !info.IsStream { + return rawMap + } + if _, exists := rawMap["stream_options"]; !exists { + rawMap["stream_options"] = json.RawMessage(`{"include_usage":true}`) + } + return rawMap +} + +// injectThinkingParams 根据 RelayInfo 中的思考后缀注入 GLM 思考模式参数。 +// +// GLM 思考模式参数说明(仅 GLM-4.5 及以上模型支持): +// - thinking.type: "enabled"(默认) / "disabled" +// - GLM-5.1/5/5v-Turbo/4.7/4.5V:强制思考 +// - GLM-4.6/4.6V/4.5:模型自动判断是否思考 +// - thinking.clear_thinking: 控制是否清除历史 reasoning_content(默认 true) +// +// 注入优先级:客户端已显式设置 > 后缀路由注入 > 不干预(保留 GLM 默认行为) +func injectThinkingParams(rawMap map[string]json.RawMessage, info *common.RelayInfo) map[string]json.RawMessage { + // 客户端已显式设置 thinking 参数 → 不干预 + if _, clientSet := rawMap["thinking"]; clientSet { + return rawMap + } + + // -nothinking 后缀:显式关闭思考 + if info.ThinkingDisabled { + rawMap["thinking"] = json.RawMessage(`{"type":"disabled"}`) + return rawMap + } + + // -thinking 后缀:显式开启思考 + if info.ThinkingEnabled { + rawMap["thinking"] = json.RawMessage(`{"type":"enabled"}`) + return rawMap + } + + // 无后缀:不干预,保留 GLM 默认行为(默认 enabled) + return rawMap +} + +// applyGLMCompatibility 处理 GLM 特有的兼容性问题:TopP 上限裁剪、base64 图片前缀剥离 +func applyGLMCompatibility(rawMap map[string]json.RawMessage) map[string]json.RawMessage { + // TopP 裁剪:GLM 要求 top_p < 1.0 + if topPRaw, ok := rawMap["top_p"]; ok { + var topP float64 + if err := json.Unmarshal(topPRaw, &topP); err == nil && topP >= 1.0 { + rawMap["top_p"] = json.RawMessage(`0.99`) + } + } + + // base64 图片 URL 前缀剥离:GLM 视觉模型要求纯 base64 数据 + if messagesRaw, ok := rawMap["messages"]; ok { + messagesBytes, changed := stripImageURLPrefixes(messagesRaw) + if changed { + rawMap["messages"] = messagesBytes + } + } + + return rawMap +} + +func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + reqURL, err := a.GetRequestURL(info) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + if err := a.SetupRequestHeader(httpReq.Header, info); err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + + if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { + override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) + } + + timeout := info.ChannelMeta.Settings.TimeoutSeconds + if timeout <= 0 { + timeout = 60 + if constant.RelayMode(info.RelayMode) == constant.RelayModeImagesGenerations { + timeout = 300 + } + } + + client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) + + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("send request failed: %w", err) + } + + // 请求体已完整发送,立即关闭释放资源 + if httpReq.Body != nil { + _ = httpReq.Body.Close() + } + + return resp, nil +} + +// DoResponse 处理上游响应。 +// Claude 入站委托 claude.Adaptor 原生直通;其他格式委托 openai.Adaptor。 +func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + clientFormat := info.GetOriginalClientFormat() + if clientFormat == constant.RelayFormatClaude { + delegate := &claude.Adaptor{} + delegate.Init(info) + return delegate.DoResponse(ctx, resp, info, writer) + } + + delegate := &openai.Adaptor{} + delegate.Init(info) + return delegate.DoResponse(ctx, resp, info, writer) +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} + +var _ common.Adaptor = (*Adaptor)(nil) + +// convertClaudeRequestForZhipu 处理 Claude 入站请求的 GLM 兼容适配(模型映射)。 +// Claude 协议请求体直接透传到智谱 Anthropic 兼容端点,无需格式转换。 +func convertClaudeRequestForZhipu(requestBody []byte, info *common.RelayInfo) (io.Reader, error) { + if !info.ChannelMeta.IsModelMapped { + return bytes.NewReader(requestBody), nil + } + + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(requestBody, &rawMap); err != nil { + return bytes.NewReader(requestBody), nil + } + rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) + + result, err := json.Marshal(rawMap) + if err != nil { + return bytes.NewReader(requestBody), nil + } + return bytes.NewReader(result), nil +} diff --git a/relay/channel/zhipu/constants.go b/relay/channel/zhipu/constants.go new file mode 100644 index 0000000..3245328 --- /dev/null +++ b/relay/channel/zhipu/constants.go @@ -0,0 +1,4 @@ +package zhipu + +// ChannelName 渠道名称 +var ChannelName = "Zhipu" diff --git a/relay/channel/zhipu/converter.go b/relay/channel/zhipu/converter.go new file mode 100644 index 0000000..4ea42ef --- /dev/null +++ b/relay/channel/zhipu/converter.go @@ -0,0 +1,92 @@ +package zhipu + +import ( + "encoding/json" + "strings" +) + +// stripImageURLPrefixes 遍历 messages 中的 image_url,去除 "data:image/...;base64," 前缀 +// GLM 视觉模型要求纯 base64 数据,不接受 data URI 格式 +func stripImageURLPrefixes(messagesRaw json.RawMessage) (json.RawMessage, bool) { + var messages []json.RawMessage + if err := json.Unmarshal(messagesRaw, &messages); err != nil { + return messagesRaw, false + } + + changed := false + for i, msgRaw := range messages { + var msg map[string]json.RawMessage + if err := json.Unmarshal(msgRaw, &msg); err != nil { + continue + } + + contentRaw, ok := msg["content"] + if !ok { + continue + } + + // content 可能是字符串或数组,只处理数组形式 + var contentParts []map[string]json.RawMessage + if err := json.Unmarshal(contentRaw, &contentParts); err != nil { + continue + } + + partChanged := false + for j, part := range contentParts { + typeRaw, ok := part["type"] + if !ok { + continue + } + var partType string + if err := json.Unmarshal(typeRaw, &partType); err != nil || partType != "image_url" { + continue + } + + imgURLRaw, ok := part["image_url"] + if !ok { + continue + } + + var imgURL map[string]json.RawMessage + if err := json.Unmarshal(imgURLRaw, &imgURL); err != nil { + continue + } + + urlRaw, ok := imgURL["url"] + if !ok { + continue + } + + var url string + if err := json.Unmarshal(urlRaw, &url); err != nil { + continue + } + + if strings.HasPrefix(url, "data:image/") { + if idx := strings.Index(url, ","); idx != -1 { + url = url[idx+1:] + imgURL["url"], _ = json.Marshal(url) + part["image_url"], _ = json.Marshal(imgURL) + contentParts[j] = part + partChanged = true + } + } + } + + if partChanged { + msg["content"], _ = json.Marshal(contentParts) + messages[i], _ = json.Marshal(msg) + changed = true + } + } + + if !changed { + return messagesRaw, false + } + + result, err := json.Marshal(messages) + if err != nil { + return messagesRaw, false + } + return result, true +} diff --git a/relay/common/adaptor.go b/relay/common/adaptor.go new file mode 100644 index 0000000..e763ba4 --- /dev/null +++ b/relay/common/adaptor.go @@ -0,0 +1,32 @@ +package common + +import ( + "context" + "io" + "net/http" +) + +// Adaptor 是所有 AI 供应商适配器必须实现的接口。 +// 每个方法对应 relay 管线中的一个步骤。 +type Adaptor interface { + // Init 使用渠道元数据初始化适配器 + Init(info *RelayInfo) + + // GetRequestURL 构建上游请求的完整 URL + GetRequestURL(info *RelayInfo) (string, error) + + // SetupRequestHeader 设置上游请求的 HTTP 头 + SetupRequestHeader(header http.Header, info *RelayInfo) error + + // ConvertRequest 将入站请求体转换为供应商原生格式。 + ConvertRequest(ctx context.Context, info *RelayInfo, requestBody []byte) (io.Reader, error) + + // DoRequest 发送 HTTP 请求到上游供应商 + DoRequest(ctx context.Context, info *RelayInfo, requestBody io.Reader) (*http.Response, error) + + // DoResponse 处理上游响应并写回客户端。 + DoResponse(ctx context.Context, resp *http.Response, info *RelayInfo, writer http.ResponseWriter) (*Usage, error) + + // GetChannelName 返回渠道名称(用于日志) + GetChannelName() string +} diff --git a/relay/common/billing.go b/relay/common/billing.go new file mode 100644 index 0000000..958c642 --- /dev/null +++ b/relay/common/billing.go @@ -0,0 +1,73 @@ +package common + +import "context" + +// BillingProvider 计费接口 +// 实现在 internal/logic/billing/ 中,通过接口解耦 relay 层和 GoFrame +type BillingProvider interface { + // PreDeduct 预扣费用 + // 检查余额 → 冻结预扣金额 → 返回是否成功 + PreDeduct(ctx context.Context, tenantID int64, modelName string, inputTokens, maxTokens int, isStream bool, requestID string) (preDeductAmount float64, err error) + + // Settle 结算费用(成功请求) + Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID, relayMode string, usage *Usage, preDeductAmount float64) error + + // SettleWithUsage 完整 Usage 结算(含 cache token + 计费快照) + SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, + modelName, requestID, relayMode string, + usage *Usage, preDeductAmount float64, relayInfo *RelayInfo) *SettlementResult + + // SettleFailed 失败请求结算(退还预扣) + SettleFailed(ctx context.Context, tenantID int64, requestID string, preDeductAmount float64) error + + // SettleStreamInterrupted 流式中断结算 + SettleStreamInterrupted(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID, relayMode string, usage *Usage, preDeductAmount float64) error + + // CheckRateLimit QPS 限流检查 + CheckRateLimit(ctx context.Context, tenantID, userID, apiKeyID int64) (allowed bool, limitLevel string, remaining int, resetAt int64) + + // AcquireConcurrent 并发限制检查(含租户级 + 模型级) + AcquireConcurrent(ctx context.Context, tenantID, userID, apiKeyID int64, modelName string) bool + + // ReleaseConcurrent 释放并发限制(含租户级 + 模型级) + ReleaseConcurrent(ctx context.Context, tenantID, userID, apiKeyID int64, modelName string) + + // CheckScope 检查 API Key scope + CheckScope(scope string, relayMode string) bool + + // CheckIPWhitelist 检查 IP 白名单 + CheckIPWhitelist(whitelist string, clientIP string) bool + + // CheckMemberQuota 检查成员额度是否足够 + CheckMemberQuota(ctx context.Context, tenantID, userID int64, preDeductAmount float64) error + + // IncrMemberQuotaUsed 结算后累加成员已用额度 + IncrMemberQuotaUsed(ctx context.Context, tenantID, userID int64, amount float64) +} + +// RateLimitInfo 限流信息(用于设置响应头) +type RateLimitInfo struct { + Limit int + Remaining int + ResetAt int64 +} + +// SettlementResult 结算结果(从 billing 层返回) +type SettlementResult struct { + PreDeductAmount float64 + BaseCost float64 + ActualCost float64 + RefundAmount float64 + SupplementAmount float64 + BillingRecordID int64 + BillingSnapshot string + BillingSummary string + InputCost float64 + OutputCost float64 + CacheCreationCost float64 + CacheReadCost float64 + TotalCost float64 + BillingMode string + BillingSource string + RateMultiplier float64 +} diff --git a/relay/common/cache_hash.go b/relay/common/cache_hash.go new file mode 100644 index 0000000..75d4175 --- /dev/null +++ b/relay/common/cache_hash.go @@ -0,0 +1,69 @@ +package common + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "sort" + "strings" +) + +// excludeCacheFields are request body fields that should be excluded from cache hash +// because they vary between requests but don't affect the model's output. +var excludeCacheFields = map[string]bool{ + "stream": true, + "seed": true, + "user": true, + "request_id": true, + "top_logprobs": true, + "logprobs": true, + "n": true, + "frequency_penalty": false, // include - affects output + "presence_penalty": false, // include - affects output + "temperature": false, // include - affects output + "max_tokens": false, // include - affects output + "max_completion_tokens": false, +} + +// ComputeCacheHash produces a deterministic SHA-256 hash from a normalized request body. +// It removes non-deterministic fields (stream, seed, user, request_id, top_logprobs, logprobs, n) +// and sorts JSON keys for consistent hashing. +func ComputeCacheHash(body []byte, modelName string) string { + var raw map[string]json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil { + // Fallback: hash raw body + model name + h := sha256.New() + h.Write([]byte(modelName)) + h.Write(body) + return hex.EncodeToString(h.Sum(nil)) + } + + // Remove excluded fields + for field := range excludeCacheFields { + delete(raw, field) + } + + // Sort keys for deterministic ordering + keys := make([]string, 0, len(raw)) + for k := range raw { + keys = append(keys, k) + } + sort.Strings(keys) + + // Rebuild JSON with sorted keys + var buf strings.Builder + for _, k := range keys { + if buf.Len() > 0 { + buf.WriteByte(',') + } + keyBytes, _ := json.Marshal(k) + buf.Write(keyBytes) + buf.WriteByte(':') + buf.Write(raw[k]) + } + + h := sha256.New() + h.Write([]byte(modelName)) + h.Write([]byte(buf.String())) + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/relay/common/forwarding_trace.go b/relay/common/forwarding_trace.go new file mode 100644 index 0000000..22ec013 --- /dev/null +++ b/relay/common/forwarding_trace.go @@ -0,0 +1,28 @@ +package common + +// ForwardingTrace 请求转发路径追踪(仅管理员可见) +type ForwardingTrace struct { + EntryPath string `json:"entry_path"` + EntryFormat string `json:"entry_format"` + RequestedModel string `json:"requested_model"` + UpstreamModel string `json:"upstream_model"` + ModelMapped bool `json:"model_mapped"` + Hops []ForwardingHop `json:"hops"` + TotalAttempts int `json:"total_attempts"` +} + +// ForwardingHop 单次转发跳转记录 +type ForwardingHop struct { + Attempt int `json:"attempt"` + ChannelID int64 `json:"channel_id"` + ChannelName string `json:"channel_name"` + ChannelType int `json:"channel_type"` + Provider string `json:"provider"` + BaseURL string `json:"base_url"` + UpstreamURL string `json:"upstream_url"` + UpstreamModel string `json:"upstream_model"` + ModelMapped bool `json:"model_mapped"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + LatencyMs float64 `json:"latency_ms"` +} diff --git a/relay/common/http_client.go b/relay/common/http_client.go new file mode 100644 index 0000000..e2e90a0 --- /dev/null +++ b/relay/common/http_client.go @@ -0,0 +1,157 @@ +package common + +import ( + "context" + "net/http" + "net/url" + "sync" + "sync/atomic" + "time" + + "github.com/gogf/gf/v2/frame/g" +) + +// sharedTransport 全局共享的 HTTP 传输层,所有渠道适配器共用一个连接池。 +// http.Client 本身是轻量的,真正持有 TCP 连接池的是 Transport。 +var sharedTransport = &http.Transport{ + MaxIdleConns: 500, + MaxIdleConnsPerHost: 100, + IdleConnTimeout: 90 * time.Second, + DisableKeepAlives: false, + ForceAttemptHTTP2: true, + DisableCompression: false, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 180 * time.Second, +} + +// nonStreamClient / streamClient 预创建的单例 Client(无代理)。 +var ( + nonStreamClient = &http.Client{ + Transport: sharedTransport, + Timeout: 300 * time.Second, + } + streamClient = &http.Client{ + Transport: sharedTransport, + } +) + +// proxiedState 管理带代理的 HTTP 客户端,按需初始化并在代理 URL 变更时重建。 +var proxiedState struct { + mu sync.RWMutex + proxyURL string + transport *http.Transport + nonStream *http.Client + stream *http.Client + proxyURLCached atomic.Value // string + cacheTime atomic.Int64 // unix seconds of last config read +} + +const proxyCacheTTL = 10 // seconds + +// GetSystemProxyURL reads channel_proxy_url from system config with local cache. +// Exported for use by WebSocket dialers and other non-HTTP-client consumers. +func GetSystemProxyURL() string { + now := time.Now().Unix() + last := proxiedState.cacheTime.Load() + if now-last < proxyCacheTTL { + if v, ok := proxiedState.proxyURLCached.Load().(string); ok { + return v + } + } + + proxyURL := g.Cfg().MustGet(context.Background(), "channel_proxy_url").String() + proxiedState.proxyURLCached.Store(proxyURL) + proxiedState.cacheTime.Store(now) + return proxyURL +} + +// getProxiedClients returns (nonStream, stream) clients configured with the current proxy. +// Rebuilds transport when proxy URL changes. +func getProxiedClients() (*http.Client, *http.Client) { + proxyURL := GetSystemProxyURL() + + proxiedState.mu.RLock() + if proxiedState.transport != nil && proxiedState.proxyURL == proxyURL { + ns, s := proxiedState.nonStream, proxiedState.stream + proxiedState.mu.RUnlock() + return ns, s + } + proxiedState.mu.RUnlock() + + proxiedState.mu.Lock() + defer proxiedState.mu.Unlock() + + // Double-check after acquiring write lock + if proxiedState.transport != nil && proxiedState.proxyURL == proxyURL { + return proxiedState.nonStream, proxiedState.stream + } + + // Build new proxied transport + transport := &http.Transport{ + MaxIdleConns: 500, + MaxIdleConnsPerHost: 100, + IdleConnTimeout: 90 * time.Second, + DisableKeepAlives: false, + ForceAttemptHTTP2: true, + DisableCompression: false, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 180 * time.Second, + } + + if proxyURL != "" { + parsed, err := url.Parse(proxyURL) + if err != nil { + g.Log().Warningf(context.Background(), "proxy: invalid proxy URL %q: %v", proxyURL, err) + } else { + transport.Proxy = http.ProxyURL(parsed) + } + } + + ns := &http.Client{ + Transport: transport, + Timeout: 300 * time.Second, + } + s := &http.Client{ + Transport: transport, + } + + proxiedState.proxyURL = proxyURL + proxiedState.transport = transport + proxiedState.nonStream = ns + proxiedState.stream = s + + return ns, s +} + +// NewPooledClient returns an http.Client with connection pooling. +// useProxy=true: uses the system proxy configured in channel_proxy_url. +// isStream=true: no Client.Timeout, managed by StreamScanner. +func NewPooledClient(timeoutSeconds int, useProxy bool, isStream ...bool) *http.Client { + if useProxy { + ns, s := getProxiedClients() + if len(isStream) > 0 && isStream[0] { + return s + } + if timeoutSeconds <= 0 { + return ns + } + proxiedState.mu.RLock() + transport := proxiedState.transport + proxiedState.mu.RUnlock() + return &http.Client{ + Transport: transport, + Timeout: time.Duration(timeoutSeconds) * time.Second, + } + } + + if len(isStream) > 0 && isStream[0] { + return streamClient + } + if timeoutSeconds <= 0 { + return nonStreamClient + } + return &http.Client{ + Transport: sharedTransport, + Timeout: time.Duration(timeoutSeconds) * time.Second, + } +} diff --git a/relay/common/provider.go b/relay/common/provider.go new file mode 100644 index 0000000..3516985 --- /dev/null +++ b/relay/common/provider.go @@ -0,0 +1,234 @@ +package common + +import ( + "context" + "errors" +) + +// ErrChannelUnavailable 没有可用渠道 +var ErrChannelUnavailable = errors.New("no available channel") + +// ErrModelNotFound 模型不存在 +var ErrModelNotFound = errors.New("model not found") + +// ErrStreamInterrupted 流式响应被客户端中断 +var ErrStreamInterrupted = errors.New("stream interrupted by client disconnect") + +// ErrTenantModelNotEnabled 租户未启用该模型 +var ErrTenantModelNotEnabled = errors.New("model not enabled for this tenant") + +// DeprecationInfo 模型弃用信息 +type DeprecationInfo struct { + Deprecated bool + SunsetDate string // 格式: "2006-01-02",空表示未设置 + ReplacementModel string +} + +// DataProvider 是 relay 层访问业务数据的接口。 +// 实现在 internal/logic/relay/provider.go 中,使用 GoFrame ORM。 +// relay/ 层通过此接口获取数据,不直接依赖 GoFrame。 +type DataProvider interface { + // ValidateApiKey 验证 API Key 并返回认证信息。 + // rawKey 是完整的 API Key 原值(如 sk-a1b2c3d4e5f6...)。 + ValidateApiKey(ctx context.Context, rawKey string) (*ApiKeyInfo, error) + + // GetChannelForModel 为指定模型选择最佳渠道。 + // tenantID 和 userID 用于亲和性计算。 + // excludeChannelIDs 是本次请求已尝试过且失败的渠道,用于重试时排除。 + GetChannelForModel(ctx context.Context, tenantID, userID int64, modelName string, excludeChannelIDs []int64) (*ChannelSelection, error) + + // GetModelMapping 获取模型映射信息。 + // 返回标准模型名和分类(chat/embedding/image 等)。 + GetModelMapping(ctx context.Context, modelName string) (standardName string, category string, err error) + + // RecordUsage 异步记录 API 调用用量日志。 + RecordUsage(ctx context.Context, record *UsageRecord) + + // RecordAudit 异步记录请求审计日志。 + RecordAudit(ctx context.Context, record *AuditRecord) + + // UpdateChannelHealth 更新渠道健康度(请求成功/失败后调用)。 + UpdateChannelHealth(ctx context.Context, channelID int64, success bool, latencyMs float64) + + // IncrementConsecutiveFailure 递增渠道连续失败计数。 + IncrementConsecutiveFailure(ctx context.Context, channelID int64) + + // ResetConsecutiveFailure 重置渠道连续失败计数为 0。 + ResetConsecutiveFailure(ctx context.Context, channelID int64) + + // GetAvailableModels 获取指定租户可用的模型列表。 + // apiKeyID > 0 时进一步按 API Key 的模型范围过滤。 + GetAvailableModels(ctx context.Context, tenantID int64, apiKeyID int64) ([]ModelInfo, error) + + // GetModelDetail 获取单个模型的详细信息。 + // tenantID 用于权限校验(检查租户是否有权使用该模型)。 + GetModelDetail(ctx context.Context, tenantID int64, modelName string) (*ModelDetail, error) + + // CheckTenantModelAccess 检查租户是否有权使用指定模型。 + // 返回是否启用和渠道范围(nil/空表示不限渠道)。 + CheckTenantModelAccess(ctx context.Context, tenantID int64, modelName string) (enabled bool, channelScope []int64, err error) + + // GetModelDeprecationInfo 获取模型弃用信息。 + // 返回 nil 表示模型未弃用。 + GetModelDeprecationInfo(ctx context.Context, modelName string) (*DeprecationInfo, error) + + // InvalidateModelCache 清除指定模型的缓存。 + InvalidateModelCache(modelName string) + + // CheckMemberModelAccess 检查成员是否有权使用指定模型。 + // 空 scope(无记录)表示不限制。 + CheckMemberModelAccess(ctx context.Context, tenantID, userID int64, modelName string) (bool, error) +} + +// ApiKeyInfo API Key 验证结果 +type ApiKeyInfo struct { + ID int64 + TenantID int64 + UserID int64 + ProjectID int64 // 项目密钥关联的项目 ID,个人密钥为 0 + Scope string // full / chat_only / embeddings_only / images_only / read_only / custom + Status string // active / disabled / expired +} + +// ChannelSelection 渠道选择结果 +type ChannelSelection struct { + ChannelID int64 + ChannelType int + ChannelName string + BaseURL string + ApiKey string // 解密后的上游 API Key + UpstreamModelName string + IsModelMapped bool + Settings ChannelSettings +} + +// UsageRecord API 调用用量记录 +type UsageRecord struct { + TenantID int64 + UserID int64 + ApiKeyID int64 + ProjectID int64 // 通过 API Key 关联的项目 ID + ChannelID int64 + ModelName string + RelayMode int + PromptTokens int + CompletionTokens int + TotalTokens int + CachedTokens int // 缓存读取 token 数 + AudioTokens int // 音频 token 数(输入+输出) + ImageTokens int // 图像 token 数 + ReasoningTokens int // 推理 token 数 + LatencyMs float64 + IsStream bool + Success bool + RequestID string + Status string // success / error / timeout / cancelled + ErrorMessage string + + // Cache token 细分 + CacheCreationTokens int // 缓存创建 token(合并) + CacheCreation5mTokens int // Claude 5分钟缓存创建 + CacheCreation1hTokens int // Claude 1小时缓存创建 + CacheReadTokens int // 缓存读取 token + + // 音频 token 分离 + AudioInputTokens int + AudioOutputTokens int + + // 其他 token + ImageOutputTokens int // 图像输出 token(DALL-E) + + // 请求元数据 + RequestedModel string // 用户请求的模型名 + UpstreamModel string // 上游实际模型名 + RequestType int // 1=sync, 2=stream, 3=websocket + UserAgent string + ClientIP string + FirstTokenMs int + ServiceTier string + ReasoningEffort string + InboundEndpoint string // 客户端请求路径 + UpstreamEndpoint string // 上游实际请求路径 + + // 渠道详情 + ChannelName string + ChannelType int + + // 计费元数据 + BillingMode string + BillingSource string + RateMultiplier float64 + RetryIndex int + + // 流式追踪 + StreamEndReason string + + // 图像生成 + ImageCount int + ImageSize string + + // 费用数据(由 relay handler 从结算结果填充) + InputCost float64 + OutputCost float64 + CacheCreationCost float64 + CacheReadCost float64 + TotalCost float64 + ActualCost float64 + Currency string + PreDeductAmount float64 + RefundAmount float64 + SupplementAmount float64 + + // 计费快照 + BillingSnapshot string // JSON 字符串 + BillingSummary string // 人类可读中文文本 +} + +// AuditRecord 请求审计日志记录 +type AuditRecord struct { + TenantID int64 + UserID int64 + ApiKeyID int64 + ProjectID int64 // 通过 API Key 关联的项目 ID + RequestID string + Method string + Path string + QueryParams string + StatusCode int + ClientIP string + UserAgent string + RequestBody string + ResponseBody string + LatencyMs int + FirstTokenMs int + IsStream bool + RequestHeaders map[string]string // 请求头(仅审计级别为 all 时记录) + ResponseHeaders map[string]string // 响应头(仅审计级别为 all 时记录) + ForwardingTrace *ForwardingTrace // 转发路径追踪(仅管理员可见) +} + +// ModelInfo 模型信息(用于 /v1/models 端点) +type ModelInfo struct { + ModelId string `json:"model_id"` + ModelName string `json:"model_name"` + Category string `json:"category"` + Status string `json:"status"` + MaxContextTokens int `json:"max_context_tokens"` + MaxOutputTokens int `json:"max_output_tokens"` + Capabilities map[string]bool `json:"capabilities"` +} + +// ModelDetail 模型详细信息(用于 /v1/models/{model_id} 端点) +type ModelDetail struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + OwnedBy string `json:"owned_by"` + ModelName string `json:"model_name,omitempty"` + Category string `json:"category,omitempty"` + Status string `json:"status,omitempty"` + MaxContextTokens int `json:"max_context_tokens,omitempty"` + MaxOutputTokens int `json:"max_output_tokens,omitempty"` + Description string `json:"description,omitempty"` + Capabilities map[string]bool `json:"capabilities,omitempty"` +} diff --git a/relay/common/reasonmap.go b/relay/common/reasonmap.go new file mode 100644 index 0000000..21bbafa --- /dev/null +++ b/relay/common/reasonmap.go @@ -0,0 +1,150 @@ +package common + +// OpenAI stop reason / finish_reason values +const ( + OpenAIStop = "stop" + OpenAILength = "length" + OpenAIToolCalls = "tool_calls" + OpenAIContentFilter = "content_filter" +) + +// Claude stop_reason values +const ( + ClaudeEndTurn = "end_turn" + ClaudeMaxTokens = "max_tokens" + ClaudeToolUse = "tool_use" + ClaudeStopSequence = "stop_sequence" + ClaudePauseTurn = "pause_turn" // 扩展思考暂停 + ClaudeRefusal = "refusal" // 内容拒绝 +) + +// Gemini finishReason values +const ( + GeminiSTOP = "STOP" + GeminiMAX_TOKENS = "MAX_TOKENS" + GeminiSAFETY = "SAFETY" + GeminiRECITATION = "RECITATION" + GeminiOTHER = "OTHER" + GeminiBLOCKLIST = "BLOCKLIST" + GeminiPROHIBITED = "PROHIBITED" + GeminiSPII = "SPII" + GeminiMALFORMED_FUNCTION_CALL = "MALFORMED_FUNCTION_CALL" + GeminiLANGUAGE = "LANGUAGE" + GeminiIMAGE_SAFETY = "IMAGE_SAFETY" + GeminiIMAGE_PROHIBITED_CONTENT = "IMAGE_PROHIBITED_CONTENT" + GeminiIMAGE_OTHER = "IMAGE_OTHER" + GeminiNO_IMAGE = "NO_IMAGE" + GeminiIMAGE_RECITATION = "IMAGE_RECITATION" + GeminiUNEXPECTED_TOOL_CALL = "UNEXPECTED_TOOL_CALL" + GeminiTOO_MANY_TOOL_CALLS = "TOO_MANY_TOOL_CALLS" + GeminiMISSING_THOUGHT_SIGNATURE = "MISSING_THOUGHT_SIGNATURE" + GeminiMALFORMED_RESPONSE = "MALFORMED_RESPONSE" + GeminiFINISH_REASON_UNSPECIFIED = "FINISH_REASON_UNSPECIFIED" +) + +// OpenAIFinishReasonToClaude 将 OpenAI finish_reason 转换为 Claude stop_reason +func OpenAIFinishReasonToClaude(reason string) string { + switch reason { + case OpenAIStop: + return ClaudeEndTurn + case OpenAILength: + return ClaudeMaxTokens + case OpenAIToolCalls: + return ClaudeToolUse + case OpenAIContentFilter: + return ClaudeRefusal + default: + if reason != "" { + return reason + } + return ClaudeEndTurn + } +} + +// ClaudeStopReasonToOpenAI 将 Claude stop_reason 转换为 OpenAI finish_reason +func ClaudeStopReasonToOpenAI(reason string) string { + switch reason { + case ClaudeEndTurn, ClaudeStopSequence, ClaudePauseTurn: + return OpenAIStop + case ClaudeMaxTokens: + return OpenAILength + case ClaudeToolUse: + return OpenAIToolCalls + case ClaudeRefusal: + return OpenAIContentFilter + default: + return reason + } +} + +// GeminiFinishReasonToOpenAI 将 Gemini finishReason 转换为 OpenAI finish_reason +func GeminiFinishReasonToOpenAI(reason string) string { + switch reason { + case GeminiSTOP, GeminiFINISH_REASON_UNSPECIFIED: + return OpenAIStop + case GeminiMAX_TOKENS: + return OpenAILength + case GeminiSAFETY, GeminiRECITATION, GeminiOTHER, GeminiBLOCKLIST, GeminiPROHIBITED, GeminiSPII, + GeminiLANGUAGE, GeminiIMAGE_SAFETY, GeminiIMAGE_PROHIBITED_CONTENT, GeminiIMAGE_OTHER, + GeminiNO_IMAGE, GeminiIMAGE_RECITATION: + return OpenAIContentFilter + case GeminiMALFORMED_FUNCTION_CALL, GeminiUNEXPECTED_TOOL_CALL, GeminiTOO_MANY_TOOL_CALLS: + return OpenAIToolCalls + case "TOOL_CALLS": + return OpenAIToolCalls + case GeminiMISSING_THOUGHT_SIGNATURE, GeminiMALFORMED_RESPONSE: + return OpenAIStop + default: + return reason + } +} + +// OpenAIFinishReasonToGemini 将 OpenAI finish_reason 转换为 Gemini finishReason +func OpenAIFinishReasonToGemini(reason string) string { + switch reason { + case OpenAIStop: + return GeminiSTOP + case OpenAILength: + return GeminiMAX_TOKENS + case OpenAIContentFilter: + return GeminiSAFETY + case OpenAIToolCalls: + return GeminiSTOP + default: + return reason + } +} + +// ClaudeStopReasonToGemini 将 Claude stop_reason 转换为 Gemini finishReason +func ClaudeStopReasonToGemini(reason string) string { + switch reason { + case ClaudeEndTurn, ClaudeStopSequence, ClaudePauseTurn: + return GeminiSTOP + case ClaudeMaxTokens: + return GeminiMAX_TOKENS + case ClaudeToolUse: + return GeminiSTOP + case ClaudeRefusal: + return GeminiSAFETY + default: + return reason + } +} + +// GeminiFinishReasonToClaude 将 Gemini finishReason 转换为 Claude stop_reason +func GeminiFinishReasonToClaude(reason string) string { + switch reason { + case GeminiSTOP, GeminiFINISH_REASON_UNSPECIFIED, GeminiMISSING_THOUGHT_SIGNATURE, GeminiMALFORMED_RESPONSE: + return ClaudeEndTurn + case GeminiMAX_TOKENS: + return ClaudeMaxTokens + case GeminiSAFETY, GeminiRECITATION, GeminiOTHER, GeminiBLOCKLIST, GeminiPROHIBITED, GeminiSPII, + GeminiLANGUAGE, GeminiIMAGE_SAFETY, GeminiIMAGE_PROHIBITED_CONTENT, GeminiIMAGE_OTHER, + GeminiNO_IMAGE, GeminiIMAGE_RECITATION: + return ClaudeRefusal + case GeminiMALFORMED_FUNCTION_CALL, GeminiUNEXPECTED_TOOL_CALL, GeminiTOO_MANY_TOOL_CALLS: + return ClaudeToolUse + default: + return reason + } +} diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go new file mode 100644 index 0000000..21aba2c --- /dev/null +++ b/relay/common/relay_info.go @@ -0,0 +1,131 @@ +package common + +import ( + "context" + "net/http" + "time" + + "github.com/qianfree/team-api/relay/constant" +) + +// ChannelMeta 渠道元信息,由调度器填充 +type ChannelMeta struct { + ChannelID int64 + ChannelType int // ProviderType 常量 + ChannelName string + BaseURL string + ApiKey string // 解密后的上游 API Key + UpstreamModelName string // 上游实际模型名(可能与用户请求不同) + IsModelMapped bool // 是否经过模型名映射 + Settings ChannelSettings +} + +// ChannelSettings 渠道配置(来自 chn_channels.settings JSONB) +type ChannelSettings struct { + TimeoutSeconds int `json:"timeout_seconds"` // 请求超时秒数,默认 60 + RetryCount int `json:"retry_count"` // 重试次数,默认 1 + ParamOverride map[string]any `json:"param_override,omitempty"` // 请求体改写规则 + HeaderOverride map[string]any `json:"header_override,omitempty"` // Header 改写规则 + ChatCompletionsViaResponses bool `json:"chat_completions_via_responses,omitempty"` // Chat Completions → Responses API 桥接 + PassThroughBodyEnabled bool `json:"pass_through_body_enabled,omitempty"` // 直连转发:跳过协议转换,原始请求体直接转发上游 + + // System Prompt 注入 + SystemPrompt string `json:"system_prompt,omitempty"` // 渠道级系统提示词 + SystemPromptOverride bool `json:"system_prompt_override,omitempty"` // true=替换已有 system message,false=追加 + + // 状态码重映射(JSON 格式如 {"429": 500, "403": 500}) + StatusCodeMapping string `json:"status_code_mapping,omitempty"` + + // 字段清理(安全与成本控制) + AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 允许 service_tier 字段(默认移除,避免额外费用) + AllowInferenceGeo bool `json:"allow_inference_geo,omitempty"` // 允许 inference_geo 字段(Claude 数据驻留) + AllowSpeed bool `json:"allow_speed,omitempty"` // 允许 speed 字段(Claude 推理速度模式) + DisableStore bool `json:"disable_store,omitempty"` // 禁用 store 字段(隐私保护) + AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 允许 safety_identifier 字段(默认移除) + + // UseProxy 启用代理,使用系统配置的代理地址转发请求 + UseProxy bool `json:"use_proxy,omitempty"` +} + +// RelayInfo 代理请求上下文,贯穿整个 relay 请求链路 +type RelayInfo struct { + Context context.Context + + // 认证信息(由 API Key 中间件设置) + TenantID int64 + UserID int64 + ApiKeyID int64 + ProjectID int64 // 通过 API Key 关联的项目 ID + + // 请求元信息 + RequestID string + RelayMode int // RelayMode 常量 + IsStream bool + OriginModelName string // 用户请求的模型名 + RequestURLPath string + RequestHeaders http.Header + StartTime time.Time + + // 渠道信息(由调度器设置) + ChannelMeta *ChannelMeta + + // 重试状态 + RetryIndex int + LastError error + + // 响应追踪 + StreamStatus *StreamStatus + FirstResponseTime time.Time + + // 入站格式:openai / claude / gemini / responses + // 决定适配器是否需要做格式转换 + InboundFormat constant.RelayFormat + + // ClientFormat 客户端原始请求格式(在格式转换前保存) + ClientFormat constant.RelayFormat + + // RuntimeHeadersOverride 由 ParamOverride 中的 set_header/delete_header 操作 + // 动态产生的 header 覆盖,优先级高于 ChannelSettings.HeaderOverride。 + RuntimeHeadersOverride map[string]string + + // UseResponsesAPI 桥接标志:客户端发送 Chat Completions,但请求应通过 Responses API 发送到上游 + UseResponsesAPI bool + + // Thinking 后缀路由(从模型名解析,供适配器消费) + ThinkingEnabled bool // 是否有 -thinking 后缀 + ThinkingDisabled bool // 是否有 -nothinking 后缀 + ReasoningEffort string // effort 级别:low/medium/high/xhigh/max/minimal + BaseModelName string // 去除 thinking/effort 后缀的基础模型名 + + // WebSocket 连接(仅 Realtime 模式使用) + ClientConn interface{} // *websocket.Conn — 使用 interface{} 避免 relay 层直接依赖 gorilla/websocket + TargetConn interface{} // *websocket.Conn — 上游 WebSocket 连接 +} + +// GetOriginalClientFormat 返回客户端原始请求格式 +func (info *RelayInfo) GetOriginalClientFormat() constant.RelayFormat { + if info.ClientFormat != "" { + return info.ClientFormat + } + return info.InboundFormat +} + +// SetFirstResponseTime 记录首次响应时间 +func (info *RelayInfo) SetFirstResponseTime() { + if info.FirstResponseTime.IsZero() { + info.FirstResponseTime = time.Now() + } +} + +// LatencyMs 返回首字节延迟(毫秒) +func (info *RelayInfo) LatencyMs() float64 { + if info.FirstResponseTime.IsZero() { + return 0 + } + return float64(info.FirstResponseTime.Sub(info.StartTime).Milliseconds()) +} + +// TotalLatencyMs 返回总延迟(毫秒) +func (info *RelayInfo) TotalLatencyMs() float64 { + return float64(time.Since(info.StartTime).Milliseconds()) +} diff --git a/relay/common/stream_status.go b/relay/common/stream_status.go new file mode 100644 index 0000000..e6443d2 --- /dev/null +++ b/relay/common/stream_status.go @@ -0,0 +1,109 @@ +package common + +import ( + "sync" +) + +// StreamEndReason 流式响应结束原因 +type StreamEndReason string + +const ( + StreamEndReasonDone StreamEndReason = "done" + StreamEndReasonTimeout StreamEndReason = "timeout" + StreamEndReasonClientGone StreamEndReason = "client_gone" + StreamEndReasonError StreamEndReason = "error" + StreamEndReasonPanic StreamEndReason = "panic" + StreamEndReasonHandlerStop StreamEndReason = "handler_stop" // dataHandler 调用了 Stop() + StreamEndReasonPingFail StreamEndReason = "ping_fail" // ping 写入失败 + StreamEndReasonScannerErr StreamEndReason = "scanner_err" // bufio.Scanner 错误 + StreamEndReasonEOF StreamEndReason = "eof" // scanner EOF 未收到 [DONE] +) + +const maxStreamErrors = 20 + +// StreamStatus 流式响应状态追踪 +type StreamStatus struct { + mu sync.RWMutex + endOnce sync.Once + reason StreamEndReason + err error + errors []string + errCount int +} + +// NewStreamStatus 创建新的流式状态追踪器 +func NewStreamStatus() *StreamStatus { + return &StreamStatus{} +} + +// SetEndReason 设置流结束原因(first-writer-wins,仅第一次调用生效) +func (s *StreamStatus) SetEndReason(reason StreamEndReason, err error) { + s.endOnce.Do(func() { + s.mu.Lock() + s.reason = reason + s.err = err + s.mu.Unlock() + }) +} + +// GetEndReason 获取流结束原因 +func (s *StreamStatus) GetEndReason() StreamEndReason { + s.mu.RLock() + defer s.mu.RUnlock() + return s.reason +} + +// RecordError 记录软错误(流继续处理) +func (s *StreamStatus) RecordError(msg string) { + s.mu.Lock() + defer s.mu.Unlock() + s.errCount++ + if len(s.errors) < maxStreamErrors { + s.errors = append(s.errors, msg) + } +} + +// ErrorCount 获取错误计数 +func (s *StreamStatus) ErrorCount() int { + s.mu.RLock() + defer s.mu.RUnlock() + return s.errCount +} + +// IsPartialStreamEnd 流已开始传输但未正常结束,应按已消费 usage 部分结算。 +func (s *StreamStatus) IsPartialStreamEnd() bool { + r := s.GetEndReason() + return r == StreamEndReasonClientGone || r == StreamEndReasonScannerErr || + r == StreamEndReasonTimeout || r == StreamEndReasonPingFail +} + +// IsNormalEnd 是否正常结束 +func (s *StreamStatus) IsNormalEnd() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.reason == StreamEndReasonDone || s.reason == StreamEndReasonHandlerStop || s.reason == "" +} + +// HasErrors 是否有错误 +func (s *StreamStatus) HasErrors() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.reason == StreamEndReasonError || s.reason == StreamEndReasonPanic || s.errCount > 0 +} + +// Error 获取错误信息 +func (s *StreamStatus) Error() error { + s.mu.RLock() + defer s.mu.RUnlock() + return s.err +} + +// Summary 返回状态摘要 +func (s *StreamStatus) Summary() string { + s.mu.RLock() + defer s.mu.RUnlock() + if s.err != nil { + return string(s.reason) + ": " + s.err.Error() + } + return string(s.reason) +} diff --git a/relay/common/task_adaptor.go b/relay/common/task_adaptor.go new file mode 100644 index 0000000..d958cc5 --- /dev/null +++ b/relay/common/task_adaptor.go @@ -0,0 +1,97 @@ +package common + +import ( + "context" + "io" + "net/http" +) + +// TaskAdaptor 异步任务适配器接口 +// 与同步 Adaptor 分离,因为异步任务有提交/轮询/解析等特殊方法 +type TaskAdaptor interface { + // Init 初始化适配器 + Init(info *RelayInfo) + + // ValidateRequest 校验请求参数 + ValidateRequest(ctx context.Context, info *RelayInfo, body []byte) *TaskError + + // EstimateBilling 估算任务费用(提交前) + // 返回计费比率 map,如 {"duration_ratio": 1.5, "resolution_ratio": 2.0} + EstimateBilling(ctx context.Context, info *RelayInfo, body []byte) map[string]float64 + + // AdjustBillingOnSubmit 提交后根据上游确认参数调整计费 + AdjustBillingOnSubmit(info *RelayInfo, taskData []byte) map[string]float64 + + // BuildRequestURL 构建上游请求 URL + BuildRequestURL(info *RelayInfo) (string, error) + + // BuildRequestHeader 构建上游请求 Header + BuildRequestHeader(header http.Header, info *RelayInfo) error + + // BuildRequestBody 构建上游请求体 + BuildRequestBody(ctx context.Context, info *RelayInfo, body []byte) (io.Reader, error) + + // DoRequest 发送请求到上游 + DoRequest(ctx context.Context, info *RelayInfo, requestBody io.Reader) (*http.Response, error) + + // DoResponse 解析上游提交响应,返回上游任务 ID 和任务数据 + DoResponse(ctx context.Context, resp *http.Response, info *RelayInfo) (upstreamTaskID string, taskData []byte, taskErr *TaskError) + + // FetchTask 查询上游任务状态 + FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) + + // ParseTaskResult 解析上游任务查询结果 + ParseTaskResult(body []byte) (*TaskInfo, error) + + // GetModelList 返回适配器支持的模型列表 + GetModelList() []string + + // GetChannelName 返回适配器名称 + GetChannelName() string +} + +// TaskInfo 异步任务查询结果 +type TaskInfo struct { + Status TaskStatusEnum + Progress string // "10%", "50%", "100%" + FailReason string + ResultURL string // 成功时的结果资源 URL + Data []byte // 上游原始响应 + SubTasks []SubTask // 子任务结果(如多图/多视频场景) + ActualCost float64 // 上游返回的实际费用(0 表示未提供,使用预扣金额) +} + +// TaskStatusEnum 任务状态枚举 +type TaskStatusEnum string + +const ( + TaskStatusNotStart TaskStatusEnum = "NOT_START" + TaskStatusSubmitted TaskStatusEnum = "SUBMITTED" + TaskStatusQueued TaskStatusEnum = "QUEUED" + TaskStatusInProgress TaskStatusEnum = "IN_PROGRESS" + TaskStatusSuccess TaskStatusEnum = "SUCCESS" + TaskStatusFailure TaskStatusEnum = "FAILURE" +) + +// IsTerminal 判断是否为终态 +func (s TaskStatusEnum) IsTerminal() bool { + return s == TaskStatusSuccess || s == TaskStatusFailure +} + +// SubTask 子任务结果 +type SubTask struct { + Index int + Status TaskStatusEnum + ResultURL string +} + +// TaskError 异步任务错误 +type TaskError struct { + StatusCode int // HTTP 状态码 + Message string // 错误消息 + ErrCode string // 错误码 +} + +func (e *TaskError) Error() string { + return e.Message +} diff --git a/relay/common/task_billing.go b/relay/common/task_billing.go new file mode 100644 index 0000000..35a2bca --- /dev/null +++ b/relay/common/task_billing.go @@ -0,0 +1,22 @@ +package common + +import "context" + +// TaskBillingProvider 异步任务计费接口 +// 异步任务计费基于时长/分辨率等比率,非 token 数 +type TaskBillingProvider interface { + // EstimateTaskCost 估算任务费用 + EstimateTaskCost(ctx context.Context, tenantID int64, modelName string, ratios map[string]float64) (float64, error) + + // PreDeductTask 预扣任务费用 + PreDeductTask(ctx context.Context, tenantID int64, requestID string, estimatedCost float64) (float64, error) + + // SettleTaskSuccess 任务成功结算 + SettleTaskSuccess(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID string, actualCost, preDeductAmount float64) error + + // SettleTaskFailed 任务失败退还预扣 + SettleTaskFailed(ctx context.Context, tenantID int64, requestID string, preDeductAmount float64) error + + // AdjustTaskBilling 调整预扣金额(提交后上游确认了新参数) + AdjustTaskBilling(ctx context.Context, tenantID int64, requestID string, preDeductAmount, newEstimatedCost float64) (float64, error) +} diff --git a/relay/common/task_provider.go b/relay/common/task_provider.go new file mode 100644 index 0000000..85d7e56 --- /dev/null +++ b/relay/common/task_provider.go @@ -0,0 +1,77 @@ +package common + +import ( + "context" + "encoding/json" + "time" +) + +// TaskDataProvider 异步任务数据持久化接口 +type TaskDataProvider interface { + // CreateTask 创建异步任务记录 + CreateTask(ctx context.Context, task *AsyncTask) error + + // UpdateTask 更新任务记录 + UpdateTask(ctx context.Context, task *AsyncTask) error + + // UpdateTaskCAS CAS 状态更新(防重复结算) + UpdateTaskCAS(ctx context.Context, task *AsyncTask, oldStatus string) error + + // GetTaskByPublicID 根据公开任务 ID 查询任务 + GetTaskByPublicID(ctx context.Context, publicTaskID string) (*AsyncTask, error) + + // GetTaskByPublicIDAndUser 根据公开任务 ID + 用户 ID 查询(客户端查询) + GetTaskByPublicIDAndUser(ctx context.Context, publicTaskID string, userID int64) (*AsyncTask, error) + + // GetNonTerminalTasks 获取所有非终态任务(轮询用) + GetNonTerminalTasks(ctx context.Context, limit int) ([]*AsyncTask, error) + + // GetTimedOutTasks 获取超时未完成任务 + GetTimedOutTasks(ctx context.Context, cutoffUnix int64, limit int) ([]*AsyncTask, error) + + // GetChannelByID 获取渠道基本信息 + GetChannelByID(ctx context.Context, channelID int64) (*ChannelBasicInfo, error) +} + +// AsyncTask 异步任务数据结构 +type AsyncTask struct { + ID int64 `json:"id"` + PublicTaskID string `json:"public_task_id"` // task_xxxxx + Platform string `json:"platform"` // sora, kling, suno + Action string `json:"action"` // generate, music, lyrics + Status string `json:"status"` + Progress string `json:"progress"` + FailReason string `json:"fail_reason,omitempty"` + + TenantID int64 `json:"tenant_id"` + UserID int64 `json:"user_id"` + ApiKeyID int64 `json:"api_key_id"` + ChannelID int64 `json:"channel_id"` + + ModelName string `json:"model_name"` + UpstreamModel string `json:"upstream_model,omitempty"` + + PreDeductAmount float64 `json:"pre_deduct_amount"` + ActualCost float64 `json:"actual_cost"` + BillingSettled bool `json:"billing_settled"` + + ResultURL string `json:"result_url,omitempty"` + Data json.RawMessage `json:"data,omitempty"` // 上游原始响应 + PrivateData json.RawMessage `json:"private_data,omitempty"` // 上游任务 ID、API Key 等敏感数据 + + SubmitTime *time.Time `json:"submit_time,omitempty"` + StartTime *time.Time `json:"start_time,omitempty"` + FinishTime *time.Time `json:"finish_time,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ChannelBasicInfo 渠道基本信息(轮询时查询) +type ChannelBasicInfo struct { + ID int64 + Type int // ProviderType + Name string + BaseURL string + ApiKey string + Settings json.RawMessage +} diff --git a/relay/common/usage.go b/relay/common/usage.go new file mode 100644 index 0000000..15b20f9 --- /dev/null +++ b/relay/common/usage.go @@ -0,0 +1,69 @@ +package common + +import "github.com/qianfree/team-api/relay/dto" + +// TokenDetails Token 使用量细分 +type TokenDetails struct { + CachedTokens int `json:"cached_tokens,omitempty"` + CachedCreationTokens int `json:"cached_creation_tokens,omitempty"` // Claude cache_creation_input_tokens + CachedCreation5mTokens int `json:"cached_creation_5m_tokens,omitempty"` // Claude 5分钟缓存创建 + CachedCreation1hTokens int `json:"cached_creation_1h_tokens,omitempty"` // Claude 1小时缓存创建 + AudioTokens int `json:"audio_tokens,omitempty"` + TextTokens int `json:"text_tokens,omitempty"` + ImageTokens int `json:"image_tokens,omitempty"` + ReasoningTokens int `json:"reasoning_tokens,omitempty"` + AcceptedPredictionTokens int `json:"accepted_prediction_tokens,omitempty"` + RejectedPredictionTokens int `json:"rejected_prediction_tokens,omitempty"` +} + +// Usage Token 使用量信息 +type Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + CacheCreationTokens int `json:"cache_creation_tokens,omitempty"` // 缓存创建 token 总量 + PromptTokensDetails *TokenDetails `json:"prompt_tokens_details,omitempty"` + CompletionTokenDetails *TokenDetails `json:"completion_token_details,omitempty"` + // CacheIncludedInPrompt 标记 PromptTokens 是否包含 cache tokens。 + // true: PromptTokens 是总量,cache 是其子集,计费时需扣减避免重复计费(OpenAI 原生 API) + // false(默认): PromptTokens 与 cache 独立,不扣减(Claude API、第三方兼容 API) + CacheIncludedInPrompt bool `json:"-"` +} + +// DtoTokenDetailsToCommon 将 dto.TokenDetails 转换为 common.TokenDetails +func DtoTokenDetailsToCommon(d *dto.TokenDetails) *TokenDetails { + if d == nil { + return nil + } + return &TokenDetails{ + CachedTokens: d.CachedTokens, + CachedCreationTokens: d.CachedCreationTokens, + CachedCreation5mTokens: d.CachedCreation5mTokens, + CachedCreation1hTokens: d.CachedCreation1hTokens, + AudioTokens: d.AudioTokens, + TextTokens: d.TextTokens, + ImageTokens: d.ImageTokens, + ReasoningTokens: d.ReasoningTokens, + AcceptedPredictionTokens: d.AcceptedPredictionTokens, + RejectedPredictionTokens: d.RejectedPredictionTokens, + } +} + +// CommonTokenDetailsToDto 将 common.TokenDetails 转换为 dto.TokenDetails +func CommonTokenDetailsToDto(c *TokenDetails) *dto.TokenDetails { + if c == nil { + return nil + } + return &dto.TokenDetails{ + CachedTokens: c.CachedTokens, + CachedCreationTokens: c.CachedCreationTokens, + CachedCreation5mTokens: c.CachedCreation5mTokens, + CachedCreation1hTokens: c.CachedCreation1hTokens, + AudioTokens: c.AudioTokens, + TextTokens: c.TextTokens, + ImageTokens: c.ImageTokens, + ReasoningTokens: c.ReasoningTokens, + AcceptedPredictionTokens: c.AcceptedPredictionTokens, + RejectedPredictionTokens: c.RejectedPredictionTokens, + } +} diff --git a/relay/constant/errors.go b/relay/constant/errors.go new file mode 100644 index 0000000..2258993 --- /dev/null +++ b/relay/constant/errors.go @@ -0,0 +1,118 @@ +package constant + +import ( + "errors" + + "github.com/gogf/gf/v2/errors/gerror" +) + +// Relay 层错误定义(使用 gerror 保留完整堆栈信息) +var ( + ErrChannelUnavailable = gerror.New("no available channel") + ErrModelNotFound = gerror.New("model not found") + ErrStreamInterrupted = gerror.New("stream interrupted by client disconnect") + ErrAllChannelsFailed = gerror.New("all channels failed") +) + +// RelayError 包装 relay 层错误,携带上游返回的状态码和信息 +type RelayError struct { + StatusCode int + Message string + Type string // upstream_error / channel_error / auth_error / request_error + Cause error +} + +func (e *RelayError) Error() string { + if e.Cause != nil { + return e.Message + ": " + e.Cause.Error() + } + return e.Message +} + +func (e *RelayError) Unwrap() error { + return e.Cause +} + +// NewUpstreamError 创建上游错误 +func NewUpstreamError(statusCode int, message string, cause error) *RelayError { + return &RelayError{ + StatusCode: statusCode, + Message: message, + Type: "upstream_error", + Cause: cause, + } +} + +// NewChannelError 创建渠道错误 +func NewChannelError(message string, cause error) *RelayError { + return &RelayError{ + StatusCode: 503, + Message: message, + Type: "channel_error", + Cause: cause, + } +} + +// NewAuthError 创建认证错误 +func NewAuthError(message string) *RelayError { + return &RelayError{ + StatusCode: 401, + Message: message, + Type: "auth_error", + } +} + +// NewRequestError 创建请求错误 +func NewRequestError(message string, cause error) *RelayError { + return &RelayError{ + StatusCode: 400, + Message: message, + Type: "request_error", + Cause: cause, + } +} + +// NewQuotaError 创建额度/余额不足错误 +func NewQuotaError(message string, cause error) *RelayError { + return &RelayError{ + StatusCode: 402, + Message: message, + Type: "insufficient_quota", + Cause: cause, + } +} + +// NewRateLimitError 创建频率限制错误 +func NewRateLimitError(message string) *RelayError { + return &RelayError{ + StatusCode: 429, + Message: message, + Type: "rate_limit_error", + } +} + +// NewModelGoneError 创建模型已下线错误(410 Gone) +func NewModelGoneError(modelName, sunsetDate string) *RelayError { + return &RelayError{ + StatusCode: 410, + Message: "model '" + modelName + "' has been sunset since " + sunsetDate, + Type: "model_gone", + } +} + +// IsRetryable 判断错误是否可重试 +func IsRetryable(err error) bool { + if err == nil { + return false + } + var relayErr *RelayError + if errors.As(err, &relayErr) { + switch relayErr.StatusCode { + case 429, 500, 502, 503, 504: + return true + default: + return false + } + } + return false +} diff --git a/relay/constant/provider.go b/relay/constant/provider.go new file mode 100644 index 0000000..61f5f09 --- /dev/null +++ b/relay/constant/provider.go @@ -0,0 +1,114 @@ +package constant + +// ProviderType 定义 AI 供应商类型,与 chn_channels.type 字段对应 +type ProviderType int + +const ( + ProviderOpenAI ProviderType = 1 + ProviderClaude ProviderType = 2 + ProviderGemini ProviderType = 3 + ProviderAli ProviderType = 4 + ProviderTencent ProviderType = 6 + ProviderZhipu ProviderType = 7 + ProviderDeepSeek ProviderType = 8 + ProviderMoonshot ProviderType = 9 + ProviderVolcengine ProviderType = 10 + ProviderAWS ProviderType = 11 + ProviderAzure ProviderType = 12 + ProviderVertex ProviderType = 13 + ProviderMistral ProviderType = 15 + ProviderXAI ProviderType = 16 + ProviderAI360 ProviderType = 17 + ProviderLingyi ProviderType = 18 + ProviderBaiduV2 ProviderType = 19 + ProviderCloudflare ProviderType = 20 + ProviderOllama ProviderType = 22 + ProviderSiliconFlow ProviderType = 25 + ProviderXunfei ProviderType = 26 + ProviderOpenRouter ProviderType = 27 + ProviderXInference ProviderType = 28 + ProviderMiniMax ProviderType = 29 + ProviderSubmodel ProviderType = 30 + ProviderCoze ProviderType = 32 + ProviderDify ProviderType = 33 + ProviderJimeng ProviderType = 34 + ProviderCodex ProviderType = 35 + ProviderSora ProviderType = 37 + ProviderKling ProviderType = 38 + ProviderSuno ProviderType = 39 + ProviderMidjourney ProviderType = 40 +) + +// String 返回供应商类型名称 +func (p ProviderType) String() string { + switch p { + case ProviderOpenAI: + return "OpenAI" + case ProviderClaude: + return "Claude" + case ProviderGemini: + return "Gemini" + case ProviderAli: + return "Ali" + case ProviderTencent: + return "Tencent" + case ProviderZhipu: + return "Zhipu" + case ProviderDeepSeek: + return "DeepSeek" + case ProviderMoonshot: + return "Moonshot" + case ProviderVolcengine: + return "Volcengine" + case ProviderAWS: + return "AWS Bedrock" + case ProviderAzure: + return "Azure OpenAI" + case ProviderVertex: + return "Vertex AI" + case ProviderMistral: + return "Mistral" + case ProviderXAI: + return "xAI" + case ProviderAI360: + return "360" + case ProviderLingyi: + return "Lingyi" + case ProviderBaiduV2: + return "Baidu V2" + case ProviderCloudflare: + return "Cloudflare" + case ProviderOllama: + return "Ollama" + case ProviderSiliconFlow: + return "SiliconFlow" + case ProviderXunfei: + return "Xunfei" + case ProviderOpenRouter: + return "OpenRouter" + case ProviderXInference: + return "XInference" + case ProviderMiniMax: + return "MiniMax" + case ProviderSubmodel: + return "Submodel" + case ProviderCoze: + return "Coze" + case ProviderDify: + return "Dify" + case ProviderJimeng: + return "Jimeng" + case ProviderCodex: + return "Codex" + case ProviderSora: + return "Sora" + case ProviderKling: + return "Kling" + case ProviderSuno: + return "Suno" + case ProviderMidjourney: + return "Midjourney" + default: + return "Unknown" + } +} diff --git a/relay/constant/relay_format.go b/relay/constant/relay_format.go new file mode 100644 index 0000000..da02316 --- /dev/null +++ b/relay/constant/relay_format.go @@ -0,0 +1,12 @@ +package constant + +// RelayFormat 定义入站请求和上游供应商的协议格式。 +// 用于决定适配器是否需要进行格式转换。 +type RelayFormat string + +const ( + RelayFormatOpenAI RelayFormat = "openai" // OpenAI Chat Completions / Completions / Embeddings 等格式 + RelayFormatClaude RelayFormat = "claude" // Claude Messages API 格式 + RelayFormatGemini RelayFormat = "gemini" // Google Gemini API 格式 + RelayFormatResponses RelayFormat = "responses" // OpenAI Responses API 格式 +) diff --git a/relay/constant/relay_mode.go b/relay/constant/relay_mode.go new file mode 100644 index 0000000..46e1ee4 --- /dev/null +++ b/relay/constant/relay_mode.go @@ -0,0 +1,163 @@ +package constant + +import ( + "strings" +) + +// RelayMode 定义代理请求的模式类型 +type RelayMode int + +const ( + RelayModeUnknown RelayMode = iota + RelayModeChatCompletions + RelayModeCompletions + RelayModeEmbeddings + RelayModeImagesGenerations + RelayModeAudioSpeech + RelayModeAudioTranscription + RelayModeRerank + RelayModeResponses + RelayModeRealtime + RelayModeClaudeMessages // Claude 原生 /v1/messages 端点 + RelayModeGeminiChat // Gemini /v1beta/models/{model}:generateContent 端点 + RelayModeAudioTranslation // /v1/audio/translations + RelayModeVideoGenerations // POST /v1/video/generations + RelayModeVideoFetch // GET /v1/video/generations/:id + RelayModeSunoSubmit // POST /suno/submit/:action + RelayModeSunoFetch // POST /suno/fetch 或 GET /suno/fetch/:id + RelayModeModerations // POST /v1/moderations + RelayModeImagesEdits // POST /v1/images/edits + RelayModeMjSubmit // POST /mj/submit/:action + RelayModeMjFetch // GET /mj/task/:id/fetch + RelayModeMjImage // GET /mj/image/:id + RelayModeResponsesCompact // POST /v1/responses/compact +) + +// Path2RelayMode 根据请求路径判断 RelayMode +// 使用前缀匹配以支持带查询参数的路径 +func Path2RelayMode(path string) RelayMode { + // 去除前导 /v1/ 或 /v1beta/ 前缀(如果有) + path = strings.TrimPrefix(path, "/v1beta") + path = strings.TrimPrefix(path, "/v1") + + // 检查非 /v1 前缀的路径(如 /mj/..., /suno/...) + if strings.HasPrefix(path, "/mj/") { + switch { + case strings.HasPrefix(path, "/mj/submit/"): + return RelayModeMjSubmit + case strings.HasPrefix(path, "/mj/task/") && strings.HasSuffix(path, "/fetch"): + return RelayModeMjFetch + case strings.HasPrefix(path, "/mj/image/"): + return RelayModeMjImage + } + } + + if strings.HasPrefix(path, "/suno/") { + switch { + case strings.HasPrefix(path, "/suno/submit/"): + return RelayModeSunoSubmit + case path == "/suno/fetch" || strings.HasPrefix(path, "/suno/fetch/"): + return RelayModeSunoFetch + } + } + + // 分离查询参数 + if idx := strings.Index(path, "?"); idx != -1 { + path = path[:idx] + } + + var result RelayMode + switch { + case strings.HasSuffix(path, "/chat/completions"): + result = RelayModeChatCompletions + case strings.HasSuffix(path, "/completions"): + result = RelayModeCompletions + case strings.HasSuffix(path, "/embeddings"): + result = RelayModeEmbeddings + case strings.HasSuffix(path, "/images/edits"): + result = RelayModeImagesEdits + case strings.HasSuffix(path, "/images/generations"): + result = RelayModeImagesGenerations + case strings.HasSuffix(path, "/audio/speech"): + result = RelayModeAudioSpeech + case strings.HasSuffix(path, "/audio/transcriptions"): + result = RelayModeAudioTranscription + case strings.HasSuffix(path, "/audio/translations"): + result = RelayModeAudioTranslation + case strings.HasSuffix(path, "/rerank"): + result = RelayModeRerank + // /responses/compact 必须在 /responses 之前匹配,避免被更短的后缀截获 + case strings.HasPrefix(path, "/responses/compact"): + result = RelayModeResponsesCompact + case strings.HasSuffix(path, "/responses"): + result = RelayModeResponses + case strings.HasSuffix(path, "/realtime"): + result = RelayModeRealtime + case strings.HasSuffix(path, "/messages"): + result = RelayModeClaudeMessages + case strings.HasSuffix(path, ":generateContent"), strings.HasSuffix(path, ":streamGenerateContent"): + result = RelayModeGeminiChat + case strings.HasSuffix(path, "/video/generations"): + result = RelayModeVideoGenerations + case strings.Contains(path, "/video/generations/"): + result = RelayModeVideoFetch + case strings.HasSuffix(path, "/moderations"): + result = RelayModeModerations + default: + result = RelayModeUnknown + } + + return result +} + +// String 返回 RelayMode 的字符串表示 +func (m RelayMode) String() string { + switch m { + case RelayModeChatCompletions: + return "chat_completions" + case RelayModeCompletions: + return "completions" + case RelayModeEmbeddings: + return "embeddings" + case RelayModeImagesGenerations: + return "images_generations" + case RelayModeAudioSpeech: + return "audio_speech" + case RelayModeAudioTranscription: + return "audio_transcriptions" + case RelayModeAudioTranslation: + return "audio_translations" + case RelayModeRerank: + return "rerank" + case RelayModeResponses: + return "responses" + case RelayModeResponsesCompact: + return "responses_compact" + case RelayModeRealtime: + return "realtime" + case RelayModeClaudeMessages: + return "claude_messages" + case RelayModeGeminiChat: + return "gemini_generate_content" + case RelayModeVideoGenerations: + return "video_generations" + case RelayModeVideoFetch: + return "video_fetch" + case RelayModeSunoSubmit: + return "suno_submit" + case RelayModeSunoFetch: + return "suno_fetch" + case RelayModeModerations: + return "moderations" + case RelayModeImagesEdits: + return "images_edits" + case RelayModeMjSubmit: + return "mj_submit" + case RelayModeMjFetch: + return "mj_fetch" + case RelayModeMjImage: + return "mj_image" + default: + return "unknown" + } +} diff --git a/relay/constant/task.go b/relay/constant/task.go new file mode 100644 index 0000000..77dbe52 --- /dev/null +++ b/relay/constant/task.go @@ -0,0 +1,101 @@ +package constant + +// TaskPlatform 异步任务平台类型 +type TaskPlatform string + +const ( + TaskPlatformSora TaskPlatform = "sora" + TaskPlatformKling TaskPlatform = "kling" + TaskPlatformSuno TaskPlatform = "suno" + TaskPlatformMidjourney TaskPlatform = "midjourney" + TaskPlatformVolcengine TaskPlatform = "volcengine" +) + +// TaskAction 异步任务动作类型 +type TaskAction string + +const ( + TaskActionGenerate TaskAction = "generate" // 视频生成 + TaskActionMusic TaskAction = "music" // 音乐生成 + TaskActionLyrics TaskAction = "lyrics" // 歌词生成 + + // Midjourney 动作 + TaskActionImagine TaskAction = "imagine" + TaskActionDescribe TaskAction = "describe" + TaskActionBlend TaskAction = "blend" + TaskActionUpscale TaskAction = "upscale" + TaskActionVariation TaskAction = "variation" + TaskActionReroll TaskAction = "reroll" + TaskActionInpaint TaskAction = "inpaint" + TaskActionModal TaskAction = "modal" + TaskActionZoom TaskAction = "zoom" + TaskActionCustomZoom TaskAction = "custom_zoom" + TaskActionShorten TaskAction = "shorten" + TaskActionHighVariation TaskAction = "high_variation" + TaskActionLowVariation TaskAction = "low_variation" + TaskActionPan TaskAction = "pan" + TaskActionSwapFace TaskAction = "swap_face" + TaskActionUpload TaskAction = "upload" + TaskActionVideo TaskAction = "video" + TaskActionEdits TaskAction = "edits" +) + +// MjActions 合法的 Midjourney 动作集合 +var MjActions = map[TaskAction]bool{ + TaskActionImagine: true, TaskActionDescribe: true, TaskActionBlend: true, + TaskActionUpscale: true, TaskActionVariation: true, TaskActionReroll: true, + TaskActionInpaint: true, TaskActionModal: true, TaskActionZoom: true, + TaskActionCustomZoom: true, TaskActionShorten: true, + TaskActionHighVariation: true, TaskActionLowVariation: true, + TaskActionPan: true, TaskActionSwapFace: true, TaskActionUpload: true, + TaskActionVideo: true, TaskActionEdits: true, +} + +// TaskStatus 异步任务状态 +type TaskStatus string + +const ( + TaskStatusNotStart TaskStatus = "NOT_START" + TaskStatusSubmitted TaskStatus = "SUBMITTED" + TaskStatusQueued TaskStatus = "QUEUED" + TaskStatusInProgress TaskStatus = "IN_PROGRESS" + TaskStatusSuccess TaskStatus = "SUCCESS" + TaskStatusFailure TaskStatus = "FAILURE" +) + +// IsTerminal 判断任务状态是否为终态 +func (s TaskStatus) IsTerminal() bool { + return s == TaskStatusSuccess || s == TaskStatusFailure +} + +// ProviderTypeToTaskPlatform 将供应商类型映射到异步任务平台 +func ProviderTypeToTaskPlatform(p ProviderType) (TaskPlatform, bool) { + switch p { + case ProviderSora: + return TaskPlatformSora, true + case ProviderKling: + return TaskPlatformKling, true + case ProviderSuno: + return TaskPlatformSuno, true + case ProviderMidjourney: + return TaskPlatformMidjourney, true + case ProviderVolcengine: + return TaskPlatformVolcengine, true + default: + return "", false + } +} + +// RelayModeToTaskAction 将 RelayMode 映射到异步任务动作 +func RelayModeToTaskAction(mode RelayMode) (TaskAction, bool) { + switch mode { + case RelayModeVideoGenerations: + return TaskActionGenerate, true + case RelayModeSunoSubmit: + return TaskActionMusic, true + case RelayModeMjSubmit: + return TaskActionImagine, true // 默认 imagine,具体由 URL action 覆盖 + default: + return "", false + } +} diff --git a/relay/dto/audio.go b/relay/dto/audio.go new file mode 100644 index 0000000..18a461d --- /dev/null +++ b/relay/dto/audio.go @@ -0,0 +1,53 @@ +package dto + +import "encoding/json" + +// AudioRequest TTS/STT/翻译统一请求结构 +type AudioRequest struct { + Model string `json:"model"` + Input string `json:"input,omitempty"` // TTS 输入文本 + Voice string `json:"voice,omitempty"` // TTS 语音(alloy/echo/fable/onyx/nova/shimmer) + Instructions string `json:"instructions,omitempty"` // TTS 指令 + ResponseFormat string `json:"response_format,omitempty"` // TTS 输出格式(mp3/opus/aac/flac/wav/pcm) + Speed *float64 `json:"speed,omitempty"` // TTS 速度(0.25-4.0) + StreamFormat string `json:"stream_format,omitempty"` // 流式音频格式 + Language string `json:"language,omitempty"` // STT 源语言(可选) + Prompt string `json:"prompt,omitempty"` // STT 提示词(可选) + TimestampGranularities []string `json:"timestamp_grularities,omitempty"` // STT 时间戳粒度(word/segment) + Metadata json.RawMessage `json:"metadata,omitempty"` // 自定义元数据 +} + +// AudioResponse STT/翻译响应 +type AudioResponse struct { + Text string `json:"text"` + Language string `json:"language,omitempty"` + Duration float64 `json:"duration,omitempty"` + Words []WordSegment `json:"words,omitempty"` + Segments []Segment `json:"segments,omitempty"` +} + +// WordSegment 词级别时间戳 +type WordSegment struct { + Word string `json:"word"` + Start float64 `json:"start"` + End float64 `json:"end"` +} + +// Segment 片段级别时间戳 +type Segment struct { + ID int `json:"id"` + Seek int `json:"seek"` + Start float64 `json:"start"` + End float64 `json:"end"` + Text string `json:"text"` +} + +// WhisperVerboseJSONResponse STT 详细响应格式(verbose_json) +type WhisperVerboseJSONResponse struct { + Task string `json:"task"` + Language string `json:"language"` + Duration float64 `json:"duration"` + Text string `json:"text"` + Segments []Segment `json:"segments"` + Words []WordSegment `json:"words,omitempty"` +} diff --git a/relay/dto/claude.go b/relay/dto/claude.go new file mode 100644 index 0000000..36c1c3a --- /dev/null +++ b/relay/dto/claude.go @@ -0,0 +1,143 @@ +package dto + +// ==================== Claude Messages API ==================== + +// ClaudeRequest Claude Messages API 请求 +type ClaudeRequest struct { + Model string `json:"model"` + System any `json:"system,omitempty"` // string 或 []ClaudeContentBlock + Messages []ClaudeMessage `json:"messages"` + MaxTokens *uint `json:"max_tokens,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` + Stream *bool `json:"stream,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + TopK *int `json:"top_k,omitempty"` + Thinking *ClaudeThinking `json:"thinking,omitempty"` + Tools []ClaudeTool `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + Metadata any `json:"metadata,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` // "auto" | "standard_only" + Container any `json:"container,omitempty"` // 容器沙箱配置 + McpServers any `json:"mcp_servers,omitempty"` // MCP 服务器配置 +} + +// ClaudeThinking Claude 扩展思维配置 +type ClaudeThinking struct { + Type string `json:"type"` + BudgetTokens *int `json:"budget_tokens,omitempty"` +} + +// ClaudeMessage Claude 消息 +type ClaudeMessage struct { + Role string `json:"role"` + Content any `json:"content"` // string 或 []ClaudeContentBlock +} + +// ClaudeContentBlock Claude 内容块 +type ClaudeContentBlock struct { + Type string `json:"type"` + Text *string `json:"text,omitempty"` + Thinking *string `json:"thinking,omitempty"` + Signature string `json:"signature,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input any `json:"input,omitempty"` + ToolUseID string `json:"tool_use_id,omitempty"` + Content any `json:"content,omitempty"` + IsError *bool `json:"is_error,omitempty"` // tool_result 错误标志 + Source *ClaudeSource `json:"source,omitempty"` + CacheControl *ClaudeCacheControl `json:"cache_control,omitempty"` +} + +// ClaudeSource 多模态内容源 +type ClaudeSource struct { + Type string `json:"type"` + MediaType string `json:"media_type,omitempty"` + Data string `json:"data,omitempty"` + URL string `json:"url,omitempty"` +} + +// ClaudeCacheControl 缓存控制 +type ClaudeCacheControl struct { + Type string `json:"type"` +} + +// ClaudeTool Claude 工具定义 +type ClaudeTool struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + InputSchema any `json:"input_schema,omitempty"` + Type string `json:"type,omitempty"` // "custom"(默认)或内置类型 + CacheControl *ClaudeCacheControl `json:"cache_control,omitempty"` // Prompt Caching 断点 +} + +// ClaudeToolChoice Claude 工具选择 +type ClaudeToolChoice struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + DisableParallelToolUse bool `json:"disable_parallel_tool_use,omitempty"` +} + +// ClaudeResponse Claude Messages API 响应(流式和非流式共用) +type ClaudeResponse struct { + ID string `json:"id,omitempty"` + Type string `json:"type"` // message, message_start, content_block_start, content_block_delta, message_delta, message_stop + Role string `json:"role,omitempty"` + Content []ClaudeContentBlock `json:"content,omitempty"` + StopReason string `json:"stop_reason,omitempty"` + StopSequence *string `json:"stop_sequence,omitempty"` + Model string `json:"model,omitempty"` + Usage *ClaudeUsage `json:"usage,omitempty"` + Index *int `json:"index,omitempty"` + ContentBlock *ClaudeContentBlock `json:"content_block,omitempty"` + Delta *ClaudeDelta `json:"delta,omitempty"` + Message *ClaudeMessageInfo `json:"message,omitempty"` + Error any `json:"error,omitempty"` + Container any `json:"container,omitempty"` +} + +// ClaudeDelta Claude 流式增量 +type ClaudeDelta struct { + Type string `json:"type,omitempty"` + Text *string `json:"text,omitempty"` + PartialJSON *string `json:"partial_json,omitempty"` + StopReason *string `json:"stop_reason,omitempty"` + StopSequence *string `json:"stop_sequence,omitempty"` + Thinking *string `json:"thinking,omitempty"` + Signature string `json:"signature,omitempty"` +} + +// ClaudeMessageInfo Claude 消息元信息(message_start 事件中的 message 字段) +type ClaudeMessageInfo struct { + ID string `json:"id"` + Type string `json:"type"` + Role string `json:"role"` + Content []ClaudeContentBlock `json:"content"` + Model string `json:"model"` + StopReason *string `json:"stop_reason"` + StopSequence *string `json:"stop_sequence"` + Usage *ClaudeUsage `json:"usage"` +} + +// ClaudeCacheUsage 缓存写入按 TTL 细分 +type ClaudeCacheUsage struct { + Ephemeral5mInputTokens int `json:"ephemeral_5m_input_tokens,omitempty"` + Ephemeral1hInputTokens int `json:"ephemeral_1h_input_tokens,omitempty"` +} + +// ClaudeServerToolUsage 服务器端内置工具使用统计 +type ClaudeServerToolUsage struct { + WebSearchRequests int `json:"web_search_requests,omitempty"` +} + +// ClaudeUsage Claude 用量 +type ClaudeUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens"` + CacheReadInputTokens int `json:"cache_read_input_tokens"` + CacheCreation *ClaudeCacheUsage `json:"cache_creation,omitempty"` + ServerToolUse *ClaudeServerToolUsage `json:"server_tool_use,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` +} diff --git a/relay/dto/gemini.go b/relay/dto/gemini.go new file mode 100644 index 0000000..6e29298 --- /dev/null +++ b/relay/dto/gemini.go @@ -0,0 +1,238 @@ +package dto + +import "encoding/json" + +// ==================== Gemini API ==================== + +// GeminiChatRequest Gemini Chat 请求 +type GeminiChatRequest struct { + Contents []GeminiContent `json:"contents"` + SafetySettings []GeminiSafetySetting `json:"safetySettings,omitempty"` + GenerationConfig *GeminiGenerationConfig `json:"generationConfig,omitempty"` + Tools json.RawMessage `json:"tools,omitempty"` + ToolConfig any `json:"toolConfig,omitempty"` + SystemInstruction *GeminiContent `json:"systemInstruction,omitempty"` + CachedContent string `json:"cachedContent,omitempty"` + ServiceTier string `json:"serviceTier,omitempty"` + Store *bool `json:"store,omitempty"` +} + +// GeminiContent Gemini 内容(角色 + 部分) +type GeminiContent struct { + Role string `json:"role"` + Parts []GeminiPart `json:"parts"` +} + +// GeminiPart Gemini 内容部分 +type GeminiPart struct { + Text string `json:"text,omitempty"` + InlineData *GeminiInlineData `json:"inlineData,omitempty"` + FileData *GeminiFileData `json:"fileData,omitempty"` + FunctionCall *GeminiFunctionCall `json:"functionCall,omitempty"` + FunctionResponse *GeminiFunctionResponse `json:"functionResponse,omitempty"` + Thought *bool `json:"thought,omitempty"` + ThoughtSignature string `json:"thoughtSignature,omitempty"` + ExecutableCode *GeminiExecutableCode `json:"executableCode,omitempty"` + CodeExecutionResult *GeminiCodeExecutionResult `json:"codeExecutionResult,omitempty"` + VideoMetadata *GeminiVideoMetadata `json:"videoMetadata,omitempty"` +} + +// GeminiInlineData Gemini 内联数据(图片、音频等) +type GeminiInlineData struct { + MimeType string `json:"mimeType"` + Data string `json:"data"` +} + +// GeminiFileData Gemini 文件引用数据 +type GeminiFileData struct { + MimeType string `json:"mimeType,omitempty"` + FileURI string `json:"fileUri,omitempty"` +} + +// GeminiFunctionCall Gemini 函数调用 +type GeminiFunctionCall struct { + ID string `json:"id,omitempty"` + FunctionName string `json:"name"` + Arguments any `json:"args,omitempty"` +} + +// GeminiFunctionResponse Gemini 函数响应 +type GeminiFunctionResponse struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Response any `json:"response,omitempty"` + WillContinue bool `json:"willContinue,omitempty"` + Scheduling string `json:"scheduling,omitempty"` +} + +// GeminiExecutableCode Gemini 可执行代码 +type GeminiExecutableCode struct { + Language string `json:"language"` + Code string `json:"code"` +} + +// GeminiCodeExecutionResult Gemini 代码执行结果 +type GeminiCodeExecutionResult struct { + Outcome string `json:"outcome"` + Output string `json:"output"` +} + +// GeminiVideoMetadata Gemini 视频元数据 +type GeminiVideoMetadata struct { + StartOffset string `json:"startOffset,omitempty"` + EndOffset string `json:"endOffset,omitempty"` +} + +// GeminiGenerationConfig Gemini 生成配置 +type GeminiGenerationConfig struct { + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"topP,omitempty"` + TopK *float64 `json:"topK,omitempty"` + MaxOutputTokens *uint `json:"maxOutputTokens,omitempty"` + CandidateCount *int `json:"candidateCount,omitempty"` + StopSequences []string `json:"stopSequences,omitempty"` + ResponseMimeType string `json:"responseMimeType,omitempty"` + ResponseSchema any `json:"responseSchema,omitempty"` + ResponseJsonSchema any `json:"responseJsonSchema,omitempty"` + ResponseModalities []string `json:"responseModalities,omitempty"` + PresencePenalty *float64 `json:"presencePenalty,omitempty"` + FrequencyPenalty *float64 `json:"frequencyPenalty,omitempty"` + Seed *int64 `json:"seed,omitempty"` + ResponseLogprobs *bool `json:"responseLogprobs,omitempty"` + Logprobs *int `json:"logprobs,omitempty"` + ThinkingConfig *GeminiThinkingConfig `json:"thinkingConfig,omitempty"` + SpeechConfig any `json:"speechConfig,omitempty"` + MediaResolution string `json:"mediaResolution,omitempty"` + ImageConfig *GeminiImageConfig `json:"imageConfig,omitempty"` +} + +// GeminiImageConfig Banana 内生图配置(用于 Chat 模式的图片生成模型) +type GeminiImageConfig struct { + AspectRatio string `json:"aspectRatio,omitempty"` + ImageSize string `json:"imageSize,omitempty"` +} + +// GeminiThinkingConfig Gemini 思考配置 +type GeminiThinkingConfig struct { + IncludeThoughts bool `json:"includeThoughts"` + ThoughtBudget *int `json:"thoughtBudget,omitempty"` + ThinkingLevel string `json:"thinkingLevel,omitempty"` +} + +// GeminiSafetySetting Gemini 安全设置 +type GeminiSafetySetting struct { + Category string `json:"category"` + Threshold string `json:"threshold"` +} + +// GeminiChatResponse Gemini Chat 响应 +type GeminiChatResponse struct { + Candidates []GeminiCandidate `json:"candidates,omitempty"` + PromptFeedback *GeminiPromptFeedback `json:"promptFeedback,omitempty"` + UsageMetadata *GeminiUsageMetadata `json:"usageMetadata,omitempty"` + ModelName string `json:"modelVersion,omitempty"` + ResponseID string `json:"responseId,omitempty"` +} + +// GeminiCandidate Gemini 候选结果 +type GeminiCandidate struct { + Content *GeminiContent `json:"content,omitempty"` + FinishReason string `json:"finishReason,omitempty"` + FinishMessage string `json:"finishMessage,omitempty"` + Index int `json:"index,omitempty"` + SafetyRatings []any `json:"safetyRatings,omitempty"` + CitationMetadata any `json:"citationMetadata,omitempty"` + GroundingMetadata any `json:"groundingMetadata,omitempty"` + TokenCount int `json:"tokenCount,omitempty"` + AvgLogprobs *float64 `json:"avgLogprobs,omitempty"` + LogprobsResult any `json:"logprobsResult,omitempty"` +} + +// GeminiPromptFeedback Gemini 提示反馈 +type GeminiPromptFeedback struct { + SafetyRatings []any `json:"safetyRatings,omitempty"` + BlockReason string `json:"blockReason,omitempty"` +} + +// GeminiUsageMetadata Gemini 用量元数据 +type GeminiUsageMetadata struct { + PromptTokenCount int `json:"promptTokenCount"` + CandidatesTokenCount int `json:"candidatesTokenCount"` + TotalTokenCount int `json:"totalTokenCount"` + CachedContentTokenCount int `json:"cachedContentTokenCount,omitempty"` + ThoughtsTokenCount int `json:"thoughtsTokenCount,omitempty"` + ToolUsePromptTokenCount int `json:"toolUsePromptTokenCount,omitempty"` + PromptTokensDetails []GeminiModalityTokenCount `json:"promptTokensDetails,omitempty"` + CandidatesTokensDetails []GeminiModalityTokenCount `json:"candidatesTokensDetails,omitempty"` + ToolUsePromptTokensDetails []GeminiModalityTokenCount `json:"toolUsePromptTokensDetails,omitempty"` + CacheTokensDetails []GeminiModalityTokenCount `json:"cacheTokensDetails,omitempty"` +} + +// GeminiModalityTokenCount 按模态的 Token 计数 +type GeminiModalityTokenCount struct { + Modality string `json:"modality"` + TokenCount int `json:"tokenCount"` +} + +// GeminiFunctionDeclaration Gemini 函数声明(用于工具定义序列化) +type GeminiFunctionDeclaration struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters any `json:"parameters,omitempty"` +} + +// GeminiModel Gemini 模型信息(/v1beta/models 响应) +type GeminiModel struct { + Name string `json:"name"` + BaseModelId string `json:"baseModelId,omitempty"` + Version string `json:"version,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Description string `json:"description,omitempty"` + InputTokenLimit int `json:"inputTokenLimit,omitempty"` + OutputTokenLimit int `json:"outputTokenLimit,omitempty"` + SupportedGenerationMethods []string `json:"supportedGenerationMethods,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + MaxTemperature *float64 `json:"maxTemperature,omitempty"` + TopP *float64 `json:"topP,omitempty"` + TopK *int `json:"topK,omitempty"` +} + +// GeminiModelsResponse Gemini 模型列表响应 +type GeminiModelsResponse struct { + Models []GeminiModel `json:"models"` + NextPageToken string `json:"nextPageToken,omitempty"` +} + +// ==================== Gemini Imagen Image API ==================== + +// GeminiImageRequest Imagen 图像生成请求 +type GeminiImageRequest struct { + Instances []GeminiImageInstance `json:"instances"` + Parameters GeminiImageParameters `json:"parameters"` +} + +// GeminiImageInstance Imagen 图像生成实例 +type GeminiImageInstance struct { + Prompt string `json:"prompt"` +} + +// GeminiImageParameters Imagen 图像生成参数 +type GeminiImageParameters struct { + SampleCount int `json:"sampleCount,omitempty"` + AspectRatio string `json:"aspectRatio,omitempty"` + PersonGeneration string `json:"personGeneration,omitempty"` + ImageSize string `json:"imageSize,omitempty"` +} + +// GeminiImageResponse Imagen 图像生成响应 +type GeminiImageResponse struct { + Predictions []GeminiImagePrediction `json:"predictions"` +} + +// GeminiImagePrediction Imagen 图像生成预测结果 +type GeminiImagePrediction struct { + MimeType string `json:"mimeType"` + BytesBase64Encoded string `json:"bytesBase64Encoded"` + RaiFilteredReason string `json:"raiFilteredReason,omitempty"` + SafetyAttributes any `json:"safetyAttributes,omitempty"` +} diff --git a/relay/dto/openai.go b/relay/dto/openai.go new file mode 100644 index 0000000..101ed48 --- /dev/null +++ b/relay/dto/openai.go @@ -0,0 +1,210 @@ +package dto + +import "encoding/json" + +// GeneralOpenAIRequest OpenAI 兼容的 Chat Completions 请求 +type GeneralOpenAIRequest struct { + Model string `json:"model"` + Messages []Message `json:"messages"` + MaxTokens *int `json:"max_tokens,omitempty"` + MaxCompletionTokens *int `json:"max_completion_tokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + TopK *int `json:"top_k,omitempty"` + N *int `json:"n,omitempty"` + Stream *bool `json:"stream,omitempty"` + StreamOptions *StreamOptions `json:"stream_options,omitempty"` + Stop any `json:"stop,omitempty"` + PresencePenalty *float64 `json:"presence_penalty,omitempty"` + FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` + User string `json:"user,omitempty"` + Seed *int64 `json:"seed,omitempty"` + Tools []Tool `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` + ResponseFormat *ResponseFormat `json:"response_format,omitempty"` + LogProbs *bool `json:"logprobs,omitempty"` + TopLogProbs *int `json:"top_logprobs,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + LogitBias map[string]int `json:"logit_bias,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` + Modalities []string `json:"modalities,omitempty"` + Audio json.RawMessage `json:"audio,omitempty"` + Store *bool `json:"store,omitempty"` + WebSearchOptions json.RawMessage `json:"web_search_options,omitempty"` + Prediction json.RawMessage `json:"prediction,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + SafetyIdentifier string `json:"safety_identifier,omitempty"` + Verbosity string `json:"verbosity,omitempty"` + PromptCacheKey string `json:"prompt_cache_key,omitempty"` + PromptCacheRetention string `json:"prompt_cache_retention,omitempty"` + ImageConfig *ImageConfigDTO `json:"image_config,omitempty"` +} + +// ImageConfigDTO OpenAI 请求中的图片生成配置透传字段(用于 Banana 等内生图模型) +type ImageConfigDTO struct { + AspectRatio string `json:"aspect_ratio,omitempty"` + ImageSize string `json:"image_size,omitempty"` +} + +// Message OpenAI 消息格式 +type Message struct { + Role string `json:"role"` + Content any `json:"content"` // string 或 []ContentPart + Refusal *string `json:"refusal,omitempty"` // 结构化输出拒绝原因 + ReasoningContent *string `json:"reasoning_content,omitempty"` // 推理模型(DeepSeek/o1)的思考内容 + Name string `json:"name,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + Annotations []any `json:"annotations,omitempty"` +} + +// ContentPart 多模态内容块 +type ContentPart struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ImageURL *ImageURL `json:"image_url,omitempty"` + InputAudio *InputAudio `json:"input_audio,omitempty"` + File *FileData `json:"file,omitempty"` +} + +// ImageURL 图片 URL +type ImageURL struct { + URL string `json:"url"` + Detail string `json:"detail,omitempty"` +} + +// InputAudio 音频输入 +type InputAudio struct { + Data string `json:"data"` + Format string `json:"format"` +} + +// FileData 文件附件 +type FileData struct { + FileID string `json:"file_id,omitempty"` + FileData string `json:"file_data,omitempty"` + Filename string `json:"filename,omitempty"` +} + +// Tool 工具定义 +type Tool struct { + Type string `json:"type"` + Function FunctionDef `json:"function"` +} + +// FunctionDef 函数定义 +type FunctionDef struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters any `json:"parameters,omitempty"` + Strict *bool `json:"strict,omitempty"` +} + +// ToolCall 工具调用 +type ToolCall struct { + Index int `json:"index,omitempty"` + ID string `json:"id"` + Type string `json:"type"` + Function FunctionCall `json:"function"` +} + +// FunctionCall 函数调用 +type FunctionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +// StreamOptions 流式选项 +type StreamOptions struct { + IncludeUsage bool `json:"include_usage"` + IncludeObfuscation bool `json:"include_obfuscation,omitempty"` +} + +// ResponseFormat 响应格式 +type ResponseFormat struct { + Type string `json:"type"` + JSONSchema any `json:"json_schema,omitempty"` +} + +// ChatCompletionResponse Chat Completions 非流式响应 +type ChatCompletionResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + SystemFingerprint *string `json:"system_fingerprint,omitempty"` + Choices []Choice `json:"choices"` + Usage UsageWithDetails `json:"usage"` + ServiceTier *string `json:"service_tier,omitempty"` +} + +// Choice 响应选择项 +type Choice struct { + Index int `json:"index"` + Message Message `json:"message"` + FinishReason string `json:"finish_reason"` + LogProbs any `json:"logprobs,omitempty"` +} + +// ChatCompletionStreamResponse Chat Completions 流式响应块 +type ChatCompletionStreamResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + SystemFingerprint *string `json:"system_fingerprint,omitempty"` + Choices []StreamChoice `json:"choices"` + Usage *UsageWithDetails `json:"usage"` +} + +// StreamChoice 流式选择项 +type StreamChoice struct { + Index int `json:"index"` + Delta Message `json:"delta"` + FinishReason *string `json:"finish_reason"` + LogProbs any `json:"logprobs,omitempty"` +} + +// ModelsResponse /v1/models 响应 +type ModelsResponse struct { + Object string `json:"object"` + Data []ModelDTO `json:"data"` +} + +// ModelDTO 模型信息 DTO +type ModelDTO struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + OwnedBy string `json:"owned_by"` + ModelName string `json:"model_name,omitempty"` + Category string `json:"category,omitempty"` + ContextWindow int `json:"context_window,omitempty"` + MaxOutputTokens int `json:"max_output_tokens,omitempty"` + Capabilities map[string]bool `json:"capabilities,omitempty"` + Modalities *ModelModalities `json:"modalities,omitempty"` +} + +// ModelDetailResponse /v1/models/{model_id} 响应 +type ModelDetailResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + OwnedBy string `json:"owned_by"` + ModelName string `json:"model_name,omitempty"` + Description string `json:"description,omitempty"` + Category string `json:"category,omitempty"` + Status string `json:"status,omitempty"` + ContextWindow int `json:"context_window,omitempty"` + MaxOutputTokens int `json:"max_output_tokens,omitempty"` + Capabilities map[string]bool `json:"capabilities,omitempty"` + Modalities *ModelModalities `json:"modalities,omitempty"` + Deprecated bool `json:"deprecated"` +} + +// ModelModalities 模型输入输出模态 +type ModelModalities struct { + Input []string `json:"input"` + Output []string `json:"output"` +} diff --git a/relay/dto/openai_responses.go b/relay/dto/openai_responses.go new file mode 100644 index 0000000..0a6ac43 --- /dev/null +++ b/relay/dto/openai_responses.go @@ -0,0 +1,292 @@ +package dto + +import "encoding/json" + +// ========== Responses API 请求 ========== + +// OpenAIResponsesRequest OpenAI Responses API 请求 +// https://platform.openai.com/docs/api-reference/responses/create +type OpenAIResponsesRequest struct { + Model string `json:"model"` + Input json.RawMessage `json:"input,omitempty"` + Include json.RawMessage `json:"include,omitempty"` + Instructions json.RawMessage `json:"instructions,omitempty"` + MaxOutputTokens *uint `json:"max_output_tokens,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"` + PreviousResponseID string `json:"previous_response_id,omitempty"` + Reasoning *Reasoning `json:"reasoning,omitempty"` + Store json.RawMessage `json:"store,omitempty"` + Stream *bool `json:"stream,omitempty"` + StreamOptions *StreamOptions `json:"stream_options,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + Text json.RawMessage `json:"text,omitempty"` + ToolChoice json.RawMessage `json:"tool_choice,omitempty"` + Tools json.RawMessage `json:"tools,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + Logprobs *int `json:"logprobs,omitempty"` + TopLogProbs *int `json:"top_logprobs,omitempty"` + Truncation json.RawMessage `json:"truncation,omitempty"` + User json.RawMessage `json:"user,omitempty"` + MaxToolCalls *uint `json:"max_tool_calls,omitempty"` + Prompt json.RawMessage `json:"prompt,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` + Conversation json.RawMessage `json:"conversation,omitempty"` + ContextManagement json.RawMessage `json:"context_management,omitempty"` + Background *bool `json:"background,omitempty"` + PromptCacheKey string `json:"prompt_cache_key,omitempty"` + PromptCacheRetention string `json:"prompt_cache_retention,omitempty"` + SafetyIdentifier string `json:"safety_identifier,omitempty"` +} + +// Reasoning 推理参数(用于 o1/o3 等推理模型) +type Reasoning struct { + Effort string `json:"effort,omitempty"` + Summary string `json:"summary,omitempty"` +} + +// ========== Responses API 响应 ========== + +// OpenAIResponsesResponse Responses API 非流式响应 +type OpenAIResponsesResponse struct { + ID string `json:"id"` + Object string `json:"object"` + CreatedAt int `json:"created_at"` + CompletedAt int `json:"completed_at,omitempty"` + Status json.RawMessage `json:"status"` + Error any `json:"error"` + IncompleteDetails any `json:"incomplete_details"` + Instructions any `json:"instructions"` + MaxOutputTokens *int `json:"max_output_tokens"` + MaxToolCalls *int `json:"max_tool_calls,omitempty"` + Model string `json:"model"` + Output []ResponsesOutput `json:"output"` + OutputText string `json:"output_text,omitempty"` + ParallelToolCalls bool `json:"parallel_tool_calls"` + PreviousResponseID any `json:"previous_response_id"` + Prompt any `json:"prompt"` + PromptCacheKey string `json:"prompt_cache_key,omitempty"` + PromptCacheRetention string `json:"prompt_cache_retention,omitempty"` + Reasoning *ResponsesReasoning `json:"reasoning"` + SafetyIdentifier string `json:"safety_identifier,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` + Store bool `json:"store"` + Background *bool `json:"background,omitempty"` + Conversation any `json:"conversation"` + Temperature *float64 `json:"temperature"` + Text *ResponsesText `json:"text,omitempty"` + ToolChoice any `json:"tool_choice"` + Tools []any `json:"tools"` + TopP *float64 `json:"top_p"` + Truncation any `json:"truncation"` + Usage *ResponsesUsage `json:"usage,omitempty"` + User any `json:"user"` + Metadata any `json:"metadata"` +} + +// IncompleteDetails 不完整响应详情 +type IncompleteDetails struct { + Reason string `json:"reason,omitempty"` +} + +// ResponsesUsage Responses API 使用量(字段名与 Chat Completions 不同) +type ResponsesUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` + InputTokensDetails *InputTokenDetails `json:"input_tokens_details"` + OutputTokenDetails *OutputTokenDetails `json:"output_tokens_details"` +} + +// ResponsesReasoning 推理配置(响应中的 reasoning 字段) +type ResponsesReasoning struct { + Effort any `json:"effort"` + Summary any `json:"summary"` +} + +// ResponsesText 文本配置(响应中的 text 字段) +type ResponsesText struct { + Format ResponsesTextFormat `json:"format"` +} + +// ResponsesTextFormat 文本格式 +type ResponsesTextFormat struct { + Type string `json:"type"` +} + +// InputTokenDetails 输入 token 细分 +type InputTokenDetails struct { + CachedTokens int `json:"cached_tokens"` + TextTokens int `json:"text_tokens,omitempty"` + AudioTokens int `json:"audio_tokens,omitempty"` + ImageTokens int `json:"image_tokens,omitempty"` +} + +// OutputTokenDetails 输出 token 细分 +type OutputTokenDetails struct { + TextTokens int `json:"text_tokens,omitempty"` + AudioTokens int `json:"audio_tokens,omitempty"` + ReasoningTokens int `json:"reasoning_tokens,omitempty"` + AcceptedPredictionTokens int `json:"accepted_prediction_tokens,omitempty"` + RejectedPredictionTokens int `json:"rejected_prediction_tokens,omitempty"` +} + +// ResponsesOutput 响应输出项(通用结构,支持所有 output item 类型) +// 具体 type 包括:message、function_call、web_search_call、file_search_call、 +// computer_call、reasoning、image_generation_call、code_interpreter_call、 +// shell_call、local_shell_call、apply_patch_call、mcp_call、mcp_list_tools、 +// mcp_approval_request、custom_tool_call、compaction +type ResponsesOutput struct { + Type string `json:"type"` + ID string `json:"id"` + Status string `json:"status,omitempty"` + Role string `json:"role,omitempty"` + Content []ResponsesOutputContent `json:"content,omitempty"` + CallID string `json:"call_id,omitempty"` + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` + // web_search_call + Action *ResponsesWebSearchAction `json:"action,omitempty"` + // file_search_call + Queries []string `json:"queries,omitempty"` + Results []ResponsesFileSearchResult `json:"results,omitempty"` + // computer_call + PendingSafetyChecks []ResponsesSafetyCheck `json:"pending_safety_checks,omitempty"` + // reasoning + Summary []ResponsesSummaryPart `json:"summary,omitempty"` + EncryptedContent string `json:"encrypted_content,omitempty"` + // image_generation_call + Result string `json:"result,omitempty"` + // code_interpreter_call + Code string `json:"code,omitempty"` + ContainerID string `json:"container_id,omitempty"` + Outputs []ResponsesCodeInterpreterOutput `json:"outputs,omitempty"` + // shell_call / local_shell_call + ShellAction *ResponsesShellAction `json:"action,omitempty"` + // apply_patch_call + PatchAction *ResponsesPatchAction `json:"action,omitempty"` + // mcp_call + ServerLabel string `json:"server_label,omitempty"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` + ApprovalRequestID string `json:"approval_request_id,omitempty"` + // mcp_list_tools + Tools []ResponsesMCPTool `json:"tools,omitempty"` + // mcp_approval_request + // custom_tool_call + Input string `json:"input,omitempty"` +} + +// ResponsesOutputContent 响应输出内容块 +type ResponsesOutputContent struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Annotations []ResponsesAnnotation `json:"annotations,omitempty"` + // refusal 内容类型 + Refusal string `json:"refusal,omitempty"` + // reasoning_text 内容类型 +} + +// ResponsesAnnotation 注解类型(联合结构,支持所有注解子类型) +type ResponsesAnnotation struct { + Type string `json:"type"` + FileID string `json:"file_id,omitempty"` + Filename string `json:"filename,omitempty"` + Index int `json:"index,omitempty"` + // url_citation + URL string `json:"url,omitempty"` + Title string `json:"title,omitempty"` + StartIndex int `json:"start_index,omitempty"` + EndIndex int `json:"end_index,omitempty"` +} + +// ResponsesWebSearchAction Web 搜索动作 +type ResponsesWebSearchAction struct { + Type string `json:"type"` + Query string `json:"query,omitempty"` + URL string `json:"url,omitempty"` + Pattern string `json:"pattern,omitempty"` + Sources []ResponsesWebSearchSource `json:"sources,omitempty"` +} + +// ResponsesWebSearchSource Web 搜索来源 +type ResponsesWebSearchSource struct { + Type string `json:"type"` + URL string `json:"url,omitempty"` +} + +// ResponsesFileSearchResult 文件搜索结果项 +type ResponsesFileSearchResult struct { + FileID string `json:"file_id"` + Text string `json:"text"` + Score float64 `json:"score"` +} + +// ResponsesSafetyCheck 安全检查项 +type ResponsesSafetyCheck struct { + ID string `json:"id"` + Code string `json:"code"` + Message string `json:"message"` +} + +// ResponsesCodeInterpreterOutput 代码解释器输出 +type ResponsesCodeInterpreterOutput struct { + Type string `json:"type"` + Logs string `json:"logs,omitempty"` + URL string `json:"url,omitempty"` +} + +// ResponsesShellAction Shell 命令动作 +type ResponsesShellAction struct { + Type string `json:"type"` + Command []string `json:"command,omitempty"` + MaxOutputLength *int `json:"max_output_length,omitempty"` + Timeout *int `json:"timeout,omitempty"` + Env map[string]string `json:"env,omitempty"` + User string `json:"user,omitempty"` + WorkingDirectory string `json:"working_directory,omitempty"` +} + +// ResponsesPatchAction 补丁动作 +type ResponsesPatchAction struct { + Type string `json:"type"` + Path string `json:"path,omitempty"` + Patch string `json:"patch,omitempty"` +} + +// ResponsesMCPTool MCP 工具描述 +type ResponsesMCPTool struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + InputSchema any `json:"inputSchema,omitempty"` + Annotations any `json:"annotations,omitempty"` +} + +// ========== Responses API 流式响应 ========== + +// ResponsesStreamResponse 流式事件包装 +type ResponsesStreamResponse struct { + Type string `json:"type"` + Response *OpenAIResponsesResponse `json:"response,omitempty"` + Delta string `json:"delta,omitempty"` + Item *ResponsesOutput `json:"item,omitempty"` + OutputIndex *int `json:"output_index,omitempty"` + ContentIndex *int `json:"content_index,omitempty"` + SummaryIndex *int `json:"summary_index,omitempty"` + ItemID string `json:"item_id,omitempty"` + Part *ResponsesSummaryPart `json:"part,omitempty"` +} + +// ResponsesSummaryPart 推理摘要部分 +type ResponsesSummaryPart struct { + Type string `json:"type"` + Text string `json:"text"` +} + +// HasError 检查响应是否包含错误 +func (r *OpenAIResponsesResponse) HasError() bool { + if r.Error == nil { + return false + } + return true +} diff --git a/relay/dto/realtime.go b/relay/dto/realtime.go new file mode 100644 index 0000000..0ba48b7 --- /dev/null +++ b/relay/dto/realtime.go @@ -0,0 +1,98 @@ +package dto + +import "encoding/json" + +// RealtimeEvent Realtime API WebSocket 事件 +type RealtimeEvent struct { + EventID string `json:"event_id,omitempty"` + Type string `json:"type"` + Session *RealtimeSession `json:"session,omitempty"` + Item *RealtimeItem `json:"item,omitempty"` + Response *RealtimeResponse `json:"response,omitempty"` + Delta json.RawMessage `json:"delta,omitempty"` + Error *RealtimeError `json:"error,omitempty"` + Audio string `json:"audio,omitempty"` +} + +// RealtimeSession 会话配置 +type RealtimeSession struct { + Model string `json:"model,omitempty"` + Modalities []string `json:"modalities,omitempty"` + Instructions string `json:"instructions,omitempty"` + Voice string `json:"voice,omitempty"` + InputAudioFormat string `json:"input_audio_format,omitempty"` + OutputAudioFormat string `json:"output_audio_format,omitempty"` + InputAudioTranscription *InputAudioTranscription `json:"input_audio_transcription,omitempty"` + TurnDetection json.RawMessage `json:"turn_detection,omitempty"` + Tools []RealTimeTool `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + MaxResponseOutputTokens any `json:"max_response_output_tokens,omitempty"` +} + +// InputAudioTranscription 输入音频转录配置 +type InputAudioTranscription struct { + Model string `json:"model,omitempty"` +} + +// RealtimeItem 会话项(消息、函数调用等) +type RealtimeItem struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Status string `json:"status,omitempty"` + Role string `json:"role,omitempty"` + Content []RealtimeContent `json:"content,omitempty"` + CallID string `json:"call_id,omitempty"` + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` + Output string `json:"output,omitempty"` +} + +// RealtimeContent 内容块 +type RealtimeContent struct { + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` + Audio string `json:"audio,omitempty"` + Transcript string `json:"transcript,omitempty"` +} + +// RealtimeResponse 响应对象 +type RealtimeResponse struct { + ID string `json:"id,omitempty"` + Object string `json:"object,omitempty"` + Status string `json:"status,omitempty"` + StatusDetails string `json:"status_details,omitempty"` + Output []RealtimeItem `json:"output,omitempty"` + Usage *RealtimeUsage `json:"usage,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} + +// RealtimeUsage Realtime API 使用量 +type RealtimeUsage struct { + TotalTokens int `json:"total_tokens"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + InputTokenDetails *RealtimeTokenDetails `json:"input_token_details,omitempty"` + OutputTokenDetails *RealtimeTokenDetails `json:"output_token_details,omitempty"` +} + +// RealtimeTokenDetails Token 类型细分 +type RealtimeTokenDetails struct { + CachedTokens int `json:"cached_tokens,omitempty"` + TextTokens int `json:"text_tokens,omitempty"` + AudioTokens int `json:"audio_tokens,omitempty"` +} + +// RealtimeError 错误信息 +type RealtimeError struct { + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +// RealTimeTool 工具定义 +type RealTimeTool struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Parameters json.RawMessage `json:"parameters,omitempty"` +} diff --git a/relay/dto/rerank.go b/relay/dto/rerank.go new file mode 100644 index 0000000..bd2d4c9 --- /dev/null +++ b/relay/dto/rerank.go @@ -0,0 +1,45 @@ +package dto + +// RerankRequest 重排请求 +type RerankRequest struct { + Model string `json:"model"` + Query string `json:"query"` + Documents []any `json:"documents"` + TopN *int `json:"top_n,omitempty"` + ReturnDocuments *bool `json:"return_documents,omitempty"` + MaxChunksPerDoc *int `json:"max_chunks_per_doc,omitempty"` + OverlapTokens *int `json:"overlap_tokens,omitempty"` +} + +// RerankResponse 重排响应 +type RerankResponse struct { + Results []RerankResponseResult `json:"results"` + Usage RerankUsage `json:"usage,omitempty"` + ID string `json:"id,omitempty"` + Meta *RerankMeta `json:"meta,omitempty"` +} + +// RerankResponseResult 单个重排结果 +type RerankResponseResult struct { + Index int `json:"index"` + RelevanceScore float64 `json:"relevance_score"` + Document any `json:"document,omitempty"` +} + +// RerankUsage 重排使用量 +type RerankUsage struct { + PromptTokens int `json:"prompt_tokens"` + TotalTokens int `json:"total_tokens"` + SearchUnits int `json:"search_units,omitempty"` +} + +// RerankMeta 重排元信息 +type RerankMeta struct { + BilledUnits RerankBilledUnits `json:"billed_units,omitempty"` +} + +// RerankBilledUnits 计费单位 +type RerankBilledUnits struct { + SearchDocuments int `json:"search_documents,omitempty"` + TotalTokens int `json:"total_tokens,omitempty"` +} diff --git a/relay/dto/task.go b/relay/dto/task.go new file mode 100644 index 0000000..f9b1a46 --- /dev/null +++ b/relay/dto/task.go @@ -0,0 +1,42 @@ +package dto + +import "encoding/json" + +// TaskSubmitRequest 异步任务提交请求(客户端 → 平台) +type TaskSubmitRequest struct { + Model string `json:"model"` + Input json.RawMessage `json:"input"` + Size string `json:"size,omitempty"` // 视频分辨率 + Length int `json:"length,omitempty"` // 视频时长(秒) +} + +// TaskSubmitResponse 异步任务提交响应(平台 → 客户端) +type TaskSubmitResponse struct { + ID string `json:"id"` + Status string `json:"status"` + Model string `json:"model"` + CreatedAt int64 `json:"created_at"` +} + +// TaskFetchResponse 异步任务查询响应 +type TaskFetchResponse struct { + ID string `json:"id"` + Status string `json:"status"` + Progress string `json:"progress,omitempty"` + Model string `json:"model"` + URL string `json:"url,omitempty"` + Error string `json:"error,omitempty"` + CreatedAt int64 `json:"created_at"` + CompletedAt int64 `json:"completed_at,omitempty"` +} + +// SunoSubmitRequest Suno 提交请求 +type SunoSubmitRequest struct { + // Suno 的请求参数由 action 决定,这里用通用 map 承载 + // 具体字段在适配器中解析 +} + +// SunoFetchResponse Suno 批量查询响应 +type SunoFetchResponse struct { + Tasks []TaskFetchResponse `json:"tasks"` +} diff --git a/relay/dto/usage.go b/relay/dto/usage.go new file mode 100644 index 0000000..efb50aa --- /dev/null +++ b/relay/dto/usage.go @@ -0,0 +1,145 @@ +package dto + +// Usage Token 使用量 +type Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +// UsageWithDetails 带 token 细分的 Usage(用于详细计费) +type UsageWithDetails struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + PromptTokensDetails *TokenDetails `json:"prompt_tokens_details,omitempty"` + CompletionTokenDetails *TokenDetails `json:"completion_tokens_details,omitempty"` +} + +// TokenDetails Token 类型细分 +type TokenDetails struct { + CachedTokens int `json:"cached_tokens,omitempty"` + CachedCreationTokens int `json:"cached_creation_tokens,omitempty"` // Claude cache_creation_input_tokens + CachedCreation5mTokens int `json:"cached_creation_5m_tokens,omitempty"` // Claude 5分钟缓存创建 + CachedCreation1hTokens int `json:"cached_creation_1h_tokens,omitempty"` // Claude 1小时缓存创建 + AudioTokens int `json:"audio_tokens,omitempty"` + TextTokens int `json:"text_tokens,omitempty"` + ImageTokens int `json:"image_tokens,omitempty"` + ReasoningTokens int `json:"reasoning_tokens,omitempty"` + AcceptedPredictionTokens int `json:"accepted_prediction_tokens,omitempty"` + RejectedPredictionTokens int `json:"rejected_prediction_tokens,omitempty"` +} + +// CompletionsRequest 文本补全请求 +type CompletionsRequest struct { + Model string `json:"model"` + Prompt any `json:"prompt"` // string 或 []string + MaxTokens *int `json:"max_tokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + N *int `json:"n,omitempty"` + Stream *bool `json:"stream,omitempty"` + StreamOptions *StreamOptions `json:"stream_options,omitempty"` + Stop any `json:"stop,omitempty"` + Suffix string `json:"suffix,omitempty"` + User string `json:"user,omitempty"` + Echo *bool `json:"echo,omitempty"` + LogProbs *bool `json:"logprobs,omitempty"` + TopLogProbs *int `json:"top_logprobs,omitempty"` + LogitBias map[string]int `json:"logit_bias,omitempty"` + PresencePenalty *float64 `json:"presence_penalty,omitempty"` + FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` + BestOf *int `json:"best_of,omitempty"` +} + +// CompletionsResponse 文本补全响应 +type CompletionsResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + SystemFingerprint *string `json:"system_fingerprint,omitempty"` + Choices []CompletionsChoice `json:"choices"` + Usage UsageWithDetails `json:"usage"` +} + +// CompletionsChoice 补全选择项 +type CompletionsChoice struct { + Index int `json:"index"` + Text string `json:"text"` + FinishReason string `json:"finish_reason"` + LogProbs any `json:"logprobs,omitempty"` +} + +// CompletionsStreamResponse 补全流式响应 +type CompletionsStreamResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + SystemFingerprint *string `json:"system_fingerprint,omitempty"` + Choices []CompletionsStreamChoice `json:"choices"` + Usage *UsageWithDetails `json:"usage"` +} + +// CompletionsStreamChoice 补全流式选择项 +type CompletionsStreamChoice struct { + Index int `json:"index"` + Text string `json:"text"` + FinishReason *string `json:"finish_reason"` +} + +// EmbeddingRequest 嵌入请求 +type EmbeddingRequest struct { + Model string `json:"model"` + Input any `json:"input"` // string 或 []string + EncodingFormat string `json:"encoding_format,omitempty"` + Dimensions *int `json:"dimensions,omitempty"` + User string `json:"user,omitempty"` +} + +// EmbeddingResponse 嵌入响应 +type EmbeddingResponse struct { + Object string `json:"object"` + Data []Embedding `json:"data"` + Model string `json:"model"` + Usage UsageWithDetails `json:"usage"` +} + +// Embedding 单个嵌入结果 +type Embedding struct { + Object string `json:"object"` + Index int `json:"index"` + Embedding []float64 `json:"embedding"` +} + +// ImageRequest 图像生成请求 +type ImageRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + N *int `json:"n,omitempty"` + Size string `json:"size,omitempty"` + Quality string `json:"quality,omitempty"` + ResponseFormat string `json:"response_format,omitempty"` + Style string `json:"style,omitempty"` + User string `json:"user,omitempty"` + OutputFormat string `json:"output_format,omitempty"` + OutputCompression *int `json:"output_compression,omitempty"` + Background string `json:"background,omitempty"` + Moderation string `json:"moderation,omitempty"` + InputImageMask any `json:"input_image_mask,omitempty"` +} + +// ImageResponse 图像生成响应 +type ImageResponse struct { + Created int64 `json:"created"` + Data []ImageData `json:"data"` +} + +// ImageData 单个图像数据 +type ImageData struct { + URL string `json:"url,omitempty"` + B64JSON string `json:"b64_json,omitempty"` + RevisedPrompt string `json:"revised_prompt,omitempty"` + ContentType string `json:"content_type,omitempty"` +} diff --git a/relay/handler/audio_handler.go b/relay/handler/audio_handler.go new file mode 100644 index 0000000..f2a2242 --- /dev/null +++ b/relay/handler/audio_handler.go @@ -0,0 +1,23 @@ +package handler + +import ( + "context" + "net/http" + + "github.com/qianfree/team-api/relay/common" +) + +// HandleAudioSpeech 处理 /v1/audio/speech 请求(TTS) +func HandleAudioSpeech(ctx context.Context, body []byte, path string, headers http.Header, rc *RelayContext, provider common.DataProvider, billing common.BillingProvider) (*common.Usage, *BillingResult, error) { + return RelayHandler(ctx, body, path, headers, rc, provider, billing) +} + +// HandleAudioTranscription 处理 /v1/audio/transcriptions 请求(STT) +func HandleAudioTranscription(ctx context.Context, body []byte, path string, headers http.Header, rc *RelayContext, provider common.DataProvider, billing common.BillingProvider) (*common.Usage, *BillingResult, error) { + return RelayHandler(ctx, body, path, headers, rc, provider, billing) +} + +// HandleAudioTranslation 处理 /v1/audio/translations 请求 +func HandleAudioTranslation(ctx context.Context, body []byte, path string, headers http.Header, rc *RelayContext, provider common.DataProvider, billing common.BillingProvider) (*common.Usage, *BillingResult, error) { + return RelayHandler(ctx, body, path, headers, rc, provider, billing) +} diff --git a/relay/handler/chat_handler.go b/relay/handler/chat_handler.go new file mode 100644 index 0000000..bdbaca8 --- /dev/null +++ b/relay/handler/chat_handler.go @@ -0,0 +1,189 @@ +package handler + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/gogf/gf/v2/frame/g" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/dto" +) + +// HandleChatCompletions 处理 /v1/chat/completions 请求 +func HandleChatCompletions(ctx context.Context, body []byte, path string, headers http.Header, rc *RelayContext, provider common.DataProvider, billing common.BillingProvider) (*common.Usage, *BillingResult, error) { + return RelayHandler(ctx, body, path, headers, rc, provider, billing) +} + +// HandleEmbeddings 处理 /v1/embeddings 请求 +func HandleEmbeddings(ctx context.Context, body []byte, path string, headers http.Header, rc *RelayContext, provider common.DataProvider, billing common.BillingProvider) (*common.Usage, *BillingResult, error) { + return RelayHandler(ctx, body, path, headers, rc, provider, billing) +} + +// HandleImagesGenerations 处理 /v1/images/generations 请求 +func HandleImagesGenerations(ctx context.Context, body []byte, path string, headers http.Header, rc *RelayContext, provider common.DataProvider, billing common.BillingProvider) (*common.Usage, *BillingResult, error) { + return RelayHandler(ctx, body, path, headers, rc, provider, billing) +} + +// HandleCompletions 处理 /v1/completions 请求 +func HandleCompletions(ctx context.Context, body []byte, path string, headers http.Header, rc *RelayContext, provider common.DataProvider, billing common.BillingProvider) (*common.Usage, *BillingResult, error) { + return RelayHandler(ctx, body, path, headers, rc, provider, billing) +} + +// HandleResponses 处理 /v1/responses 请求(OpenAI Responses API) +func HandleResponses(ctx context.Context, body []byte, path string, headers http.Header, rc *RelayContext, provider common.DataProvider, billing common.BillingProvider) (*common.Usage, *BillingResult, error) { + return RelayHandler(ctx, body, path, headers, rc, provider, billing) +} + +// HandleModels 处理 /v1/models 请求(列出可用模型) +// tenantID 用于过滤该租户有权使用的模型,apiKeyID 用于进一步按 Key 的模型范围过滤 +func HandleModels(ctx context.Context, tenantID int64, apiKeyID int64, provider common.DataProvider) (*dto.ModelsResponse, error) { + models, err := provider.GetAvailableModels(ctx, tenantID, apiKeyID) + if err != nil { + return nil, err + } + + data := make([]dto.ModelDTO, 0, len(models)) + for _, m := range models { + item := dto.ModelDTO{ + ID: m.ModelId, + Object: "model", + Created: 0, + OwnedBy: "platform", + ModelName: m.ModelName, + Category: m.Category, + ContextWindow: m.MaxContextTokens, + MaxOutputTokens: m.MaxOutputTokens, + Capabilities: m.Capabilities, + Modalities: buildModalities(m.Category, m.Capabilities), + } + data = append(data, item) + } + + return &dto.ModelsResponse{ + Object: "list", + Data: data, + }, nil +} + +// HandleModelDetail 处理 /v1/models/{model_id} 请求(获取单个模型详情) +func HandleModelDetail(ctx context.Context, tenantID int64, modelName string, provider common.DataProvider) (*dto.ModelDetailResponse, error) { + detail, err := provider.GetModelDetail(ctx, tenantID, modelName) + if err != nil { + return nil, err + } + + return &dto.ModelDetailResponse{ + ID: detail.ID, + Object: detail.Object, + Created: detail.Created, + OwnedBy: detail.OwnedBy, + ModelName: detail.ModelName, + Description: detail.Description, + Category: detail.Category, + Status: detail.Status, + ContextWindow: detail.MaxContextTokens, + MaxOutputTokens: detail.MaxOutputTokens, + Capabilities: detail.Capabilities, + Modalities: buildModalities(detail.Category, detail.Capabilities), + Deprecated: detail.Status == "deprecated", + }, nil +} + +func buildModalities(category string, capabilities map[string]bool) *dto.ModelModalities { + input := []string{"text"} + output := []string{"text"} + + switch category { + case "embedding": + output = []string{"embedding"} + case "image": + input = []string{"text"} + output = []string{"image"} + case "audio": + input = []string{"audio"} + output = []string{"text"} + } + + if capabilities != nil { + if capabilities["vision"] { + input = appendUnique(input, "image") + } + if capabilities["audio_input"] { + input = appendUnique(input, "audio") + } + if capabilities["audio_output"] { + output = appendUnique(output, "audio") + } + if capabilities["pdf_input"] { + input = appendUnique(input, "pdf") + } + } + + return &dto.ModelModalities{Input: input, Output: output} +} + +func appendUnique(slice []string, item string) []string { + for _, s := range slice { + if s == item { + return slice + } + } + return append(slice, item) +} + +// WriteRelayError 写入 relay 错误响应(导出,供 GoFrame handler 调用) +func WriteRelayError(w http.ResponseWriter, err error) { + // 流式中断(客户端已断开):降级为 INFO,跳过写响应 + if errors.Is(err, common.ErrStreamInterrupted) { + g.Log().Infof(context.Background(), "[RelayError] Client disconnected during stream") + return + } + + var relayErr *constant.RelayError + var rateLimitErr *RelayErrorWithRateLimit + statusCode := http.StatusInternalServerError + errMsg := err.Error() + errType := "internal_error" + + if errors.As(err, &rateLimitErr) { + statusCode = rateLimitErr.StatusCode + errMsg = rateLimitErr.Message + errType = "rate_limit_error" + w.Header().Set("X-RateLimit-Limit", "0") + w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", rateLimitErr.Remaining)) + w.Header().Set("X-RateLimit-Reset", fmt.Sprintf("%d", rateLimitErr.ResetAt)) + } else if errors.As(err, &relayErr) { + statusCode = relayErr.StatusCode + errMsg = relayErr.Message + if relayErr.Cause != nil { + errMsg = relayErr.Message + ": " + relayErr.Cause.Error() + } + errType = relayErr.Type + } + + if statusCode < 100 || statusCode > 599 { + statusCode = http.StatusInternalServerError + } + + // 记录错误日志 + g.Log().Errorf(context.Background(), "[RelayError] statusCode=%d type=%s message=%s originalError=%v", + statusCode, errType, errMsg, err) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + errBody, _ := json.Marshal(map[string]any{ + "error": map[string]any{ + "message": errMsg, + "type": errType, + "param": nil, + "code": nil, + }, + }) + _, _ = w.Write(errBody) +} diff --git a/relay/handler/claude_handler.go b/relay/handler/claude_handler.go new file mode 100644 index 0000000..0d83032 --- /dev/null +++ b/relay/handler/claude_handler.go @@ -0,0 +1,65 @@ +package handler + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "github.com/gogf/gf/v2/frame/g" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" +) + +// HandleClaudeMessages 处理 /v1/messages 请求(Claude 原生格式) +func HandleClaudeMessages(ctx context.Context, body []byte, path string, headers http.Header, rc *RelayContext, provider common.DataProvider, billing common.BillingProvider) (*common.Usage, *BillingResult, error) { + return RelayHandler(ctx, body, path, headers, rc, provider, billing) +} + +// WriteClaudeRelayError 写入 Claude 格式的错误响应 +func WriteClaudeRelayError(w http.ResponseWriter, err error) { + // 流式中断(客户端已断开):降级为 INFO,跳过写响应(客户端已不在) + if errors.Is(err, common.ErrStreamInterrupted) { + g.Log().Infof(context.Background(), "[ClaudeRelayError] Client disconnected during stream") + return + } + + var relayErr *constant.RelayError + var rateLimitErr *RelayErrorWithRateLimit + statusCode := http.StatusInternalServerError + errMsg := err.Error() + errType := "api_error" + + if errors.As(err, &rateLimitErr) { + statusCode = rateLimitErr.StatusCode + errMsg = rateLimitErr.Message + errType = "rate_limit_error" + } else if errors.As(err, &relayErr) { + statusCode = relayErr.StatusCode + errMsg = relayErr.Message + if relayErr.Cause != nil { + errMsg = relayErr.Message + ": " + relayErr.Cause.Error() + } + errType = relayErr.Type + } + + if statusCode < 100 || statusCode > 599 { + statusCode = http.StatusInternalServerError + } + + g.Log().Errorf(context.Background(), "[ClaudeRelayError] statusCode=%d type=%s message=%s originalError=%v", + statusCode, errType, errMsg, err) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + errBody, _ := json.Marshal(map[string]any{ + "type": "error", + "error": map[string]any{ + "type": errType, + "message": errMsg, + }, + }) + _, _ = w.Write(errBody) +} diff --git a/relay/handler/gemini_handler.go b/relay/handler/gemini_handler.go new file mode 100644 index 0000000..441a584 --- /dev/null +++ b/relay/handler/gemini_handler.go @@ -0,0 +1,112 @@ +package handler + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/gogf/gf/v2/frame/g" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/dto" +) + +// HandleGeminiGenerateContent 处理 /v1beta/models/{model}:generateContent 请求(Gemini 原生格式) +func HandleGeminiGenerateContent(ctx context.Context, body []byte, path string, headers http.Header, rc *RelayContext, provider common.DataProvider, billing common.BillingProvider) (*common.Usage, *BillingResult, error) { + return RelayHandler(ctx, body, path, headers, rc, provider, billing) +} + +// HandleGeminiModels 处理 GET /v1beta/models 请求(Gemini 原生格式模型列表) +func HandleGeminiModels(ctx context.Context, tenantID int64, apiKeyID int64, provider common.DataProvider) (*dto.GeminiModelsResponse, error) { + models, err := provider.GetAvailableModels(ctx, tenantID, apiKeyID) + if err != nil { + return nil, err + } + + items := make([]dto.GeminiModel, 0, len(models)) + for _, m := range models { + items = append(items, dto.GeminiModel{ + Name: fmt.Sprintf("models/%s", m.ModelId), + BaseModelId: m.ModelId, + DisplayName: m.ModelName, + SupportedGenerationMethods: []string{"generateContent", "countTokens"}, + }) + } + + return &dto.GeminiModelsResponse{ + Models: items, + }, nil +} + +// HandleGeminiModelDetail 处理 GET /v1beta/models/{model} 请求(Gemini 原生格式模型详情) +func HandleGeminiModelDetail(ctx context.Context, tenantID int64, modelName string, provider common.DataProvider) (*dto.GeminiModel, error) { + detail, err := provider.GetModelDetail(ctx, tenantID, modelName) + if err != nil { + return nil, err + } + + return &dto.GeminiModel{ + Name: fmt.Sprintf("models/%s", detail.ID), + BaseModelId: detail.ID, + DisplayName: detail.ModelName, + Description: detail.Description, + InputTokenLimit: detail.MaxContextTokens, + OutputTokenLimit: detail.MaxOutputTokens, + SupportedGenerationMethods: []string{"generateContent", "countTokens"}, + }, nil +} + +// WriteGeminiRelayError 写入 Gemini 格式的错误响应 +func WriteGeminiRelayError(w http.ResponseWriter, err error) { + var relayErr *constant.RelayError + var rateLimitErr *RelayErrorWithRateLimit + statusCode := http.StatusInternalServerError + errMsg := err.Error() + errStatus := "INTERNAL" + + if errors.As(err, &rateLimitErr) { + statusCode = rateLimitErr.StatusCode + errMsg = rateLimitErr.Message + errStatus = "RESOURCE_EXHAUSTED" + } else if errors.As(err, &relayErr) { + statusCode = relayErr.StatusCode + errMsg = relayErr.Message + if relayErr.Cause != nil { + errMsg = relayErr.Message + ": " + relayErr.Cause.Error() + } + switch statusCode { + case 401: + errStatus = "UNAUTHENTICATED" + case 403: + errStatus = "PERMISSION_DENIED" + case 400: + errStatus = "INVALID_ARGUMENT" + case 404: + errStatus = "NOT_FOUND" + default: + errStatus = "INTERNAL" + } + } + + if statusCode < 100 || statusCode > 599 { + statusCode = http.StatusInternalServerError + } + + g.Log().Errorf(context.Background(), "[GeminiRelayError] statusCode=%d status=%s message=%s originalError=%v", + statusCode, errStatus, errMsg, err) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + errBody, _ := json.Marshal(map[string]any{ + "error": map[string]any{ + "code": statusCode, + "message": errMsg, + "status": errStatus, + }, + }) + _, _ = w.Write(errBody) +} diff --git a/relay/handler/moderation_handler.go b/relay/handler/moderation_handler.go new file mode 100644 index 0000000..2d3ce61 --- /dev/null +++ b/relay/handler/moderation_handler.go @@ -0,0 +1,18 @@ +package handler + +import ( + "context" + "net/http" + + "github.com/qianfree/team-api/relay/common" +) + +// HandleModerations 处理 /v1/moderations 请求 +func HandleModerations(ctx context.Context, body []byte, path string, headers http.Header, rc *RelayContext, provider common.DataProvider, billing common.BillingProvider) (*common.Usage, *BillingResult, error) { + return RelayHandler(ctx, body, path, headers, rc, provider, billing) +} + +// HandleImagesEdits 处理 /v1/images/edits 请求 +func HandleImagesEdits(ctx context.Context, body []byte, path string, headers http.Header, rc *RelayContext, provider common.DataProvider, billing common.BillingProvider) (*common.Usage, *BillingResult, error) { + return RelayHandler(ctx, body, path, headers, rc, provider, billing) +} diff --git a/relay/handler/passthrough.go b/relay/handler/passthrough.go new file mode 100644 index 0000000..98be742 --- /dev/null +++ b/relay/handler/passthrough.go @@ -0,0 +1,50 @@ +package handler + +import ( + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" +) + +// canPassThrough 判断当前请求是否可以直连转发(跳过协议转换和参数改写) +func canPassThrough(info *common.RelayInfo) bool { + settings := info.ChannelMeta.Settings + + // 显式开启:运营者明确配置直连,不做额外检查 + if settings.PassThroughBodyEnabled { + return true + } + + // 自动检测:入站格式必须匹配上游原生格式 + if providerNativeFormat(info.ChannelMeta.ChannelType) != info.InboundFormat { + return false + } + // 需要模型名映射 → 必须经过转换来替换模型名 + if info.ChannelMeta.IsModelMapped { + return false + } + // 有参数改写规则 → 必须经过转换来应用改写 + if settings.ParamOverride != nil { + return false + } + // 有系统提示词注入 → 必须经过转换 + if settings.SystemPrompt != "" { + return false + } + // 有 thinking 后缀 → 必须经过转换来注入 thinking 参数 + if info.ThinkingEnabled || info.ThinkingDisabled || info.ReasoningEffort != "" { + return false + } + return true +} + +// providerNativeFormat 根据 ProviderType 返回上游的原生请求格式 +func providerNativeFormat(providerType int) constant.RelayFormat { + switch constant.ProviderType(providerType) { + case constant.ProviderClaude: + return constant.RelayFormatClaude + case constant.ProviderGemini: + return constant.RelayFormatGemini + default: + return constant.RelayFormatOpenAI + } +} diff --git a/relay/handler/passthrough_test.go b/relay/handler/passthrough_test.go new file mode 100644 index 0000000..e6d941f --- /dev/null +++ b/relay/handler/passthrough_test.go @@ -0,0 +1,161 @@ +package handler + +import ( + "testing" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" +) + +func TestProviderNativeFormat(t *testing.T) { + tests := []struct { + providerType int + want constant.RelayFormat + }{ + {int(constant.ProviderClaude), constant.RelayFormatClaude}, + {int(constant.ProviderGemini), constant.RelayFormatGemini}, + {int(constant.ProviderOpenAI), constant.RelayFormatOpenAI}, + {int(constant.ProviderDeepSeek), constant.RelayFormatOpenAI}, + {int(constant.ProviderAzure), constant.RelayFormatOpenAI}, + {int(constant.ProviderAWS), constant.RelayFormatOpenAI}, + {int(constant.ProviderVertex), constant.RelayFormatOpenAI}, + {int(constant.ProviderAli), constant.RelayFormatOpenAI}, + } + for _, tt := range tests { + got := providerNativeFormat(tt.providerType) + if got != tt.want { + t.Errorf("providerNativeFormat(%d) = %s, want %s", tt.providerType, got, tt.want) + } + } +} + +func TestCanPassThrough_ExplicitEnabled(t *testing.T) { + info := &common.RelayInfo{ + InboundFormat: constant.RelayFormatClaude, + ChannelMeta: &common.ChannelMeta{ + ChannelType: int(constant.ProviderOpenAI), + Settings: common.ChannelSettings{ + PassThroughBodyEnabled: true, + }, + }, + } + if !canPassThrough(info) { + t.Error("should pass through when explicitly enabled") + } +} + +func TestCanPassThrough_ExplicitEnabledIgnoresFormatMismatch(t *testing.T) { + info := &common.RelayInfo{ + InboundFormat: constant.RelayFormatOpenAI, + ChannelMeta: &common.ChannelMeta{ + ChannelType: int(constant.ProviderClaude), + IsModelMapped: true, + Settings: common.ChannelSettings{ + PassThroughBodyEnabled: true, + }, + }, + } + if !canPassThrough(info) { + t.Error("explicit PassThroughBodyEnabled should bypass format/mapping checks") + } +} + +func TestCanPassThrough_AutoDetect_FormatMatch(t *testing.T) { + info := &common.RelayInfo{ + InboundFormat: constant.RelayFormatOpenAI, + ChannelMeta: &common.ChannelMeta{ + ChannelType: int(constant.ProviderOpenAI), + IsModelMapped: false, + Settings: common.ChannelSettings{}, + }, + } + if !canPassThrough(info) { + t.Error("should auto-detect pass through for OpenAI client → OpenAI upstream") + } +} + +func TestCanPassThrough_AutoDetect_ClaudeMatch(t *testing.T) { + info := &common.RelayInfo{ + InboundFormat: constant.RelayFormatClaude, + ChannelMeta: &common.ChannelMeta{ + ChannelType: int(constant.ProviderClaude), + IsModelMapped: false, + Settings: common.ChannelSettings{}, + }, + } + if !canPassThrough(info) { + t.Error("should auto-detect pass through for Claude client → Claude upstream") + } +} + +func TestCanPassThrough_AutoDetect_GeminiMatch(t *testing.T) { + info := &common.RelayInfo{ + InboundFormat: constant.RelayFormatGemini, + ChannelMeta: &common.ChannelMeta{ + ChannelType: int(constant.ProviderGemini), + IsModelMapped: false, + Settings: common.ChannelSettings{}, + }, + } + if !canPassThrough(info) { + t.Error("should auto-detect pass through for Gemini client → Gemini upstream") + } +} + +func TestCanPassThrough_AutoDetect_FormatMismatch(t *testing.T) { + info := &common.RelayInfo{ + InboundFormat: constant.RelayFormatOpenAI, + ChannelMeta: &common.ChannelMeta{ + ChannelType: int(constant.ProviderClaude), + IsModelMapped: false, + Settings: common.ChannelSettings{}, + }, + } + if canPassThrough(info) { + t.Error("should NOT pass through when OpenAI client → Claude upstream (format mismatch)") + } +} + +func TestCanPassThrough_AutoDetect_ModelMapped(t *testing.T) { + info := &common.RelayInfo{ + InboundFormat: constant.RelayFormatOpenAI, + ChannelMeta: &common.ChannelMeta{ + ChannelType: int(constant.ProviderOpenAI), + IsModelMapped: true, + Settings: common.ChannelSettings{}, + }, + } + if canPassThrough(info) { + t.Error("should NOT pass through when model mapping is needed") + } +} + +func TestCanPassThrough_AutoDetect_HasParamOverride(t *testing.T) { + info := &common.RelayInfo{ + InboundFormat: constant.RelayFormatOpenAI, + ChannelMeta: &common.ChannelMeta{ + ChannelType: int(constant.ProviderOpenAI), + IsModelMapped: false, + Settings: common.ChannelSettings{ + ParamOverride: map[string]any{"temperature": 0.5}, + }, + }, + } + if canPassThrough(info) { + t.Error("should NOT pass through when ParamOverride is configured") + } +} + +func TestCanPassThrough_DeepSeekIsOpenAI(t *testing.T) { + info := &common.RelayInfo{ + InboundFormat: constant.RelayFormatOpenAI, + ChannelMeta: &common.ChannelMeta{ + ChannelType: int(constant.ProviderDeepSeek), + IsModelMapped: false, + Settings: common.ChannelSettings{}, + }, + } + if !canPassThrough(info) { + t.Error("DeepSeek is OpenAI-compatible, should pass through for OpenAI format requests") + } +} diff --git a/relay/handler/realtime_handler.go b/relay/handler/realtime_handler.go new file mode 100644 index 0000000..9acf22e --- /dev/null +++ b/relay/handler/realtime_handler.go @@ -0,0 +1,189 @@ +package handler + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gorilla/websocket" + + "github.com/qianfree/team-api/relay/channel" + "github.com/qianfree/team-api/relay/channel/openai" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" +) + +// websocketUpgrader HTTP → WebSocket 升级器 +var websocketUpgrader = websocket.Upgrader{ + Subprotocols: []string{"realtime"}, + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// RealtimeContext Realtime 请求上下文 +type RealtimeContext struct { + TenantID int64 + UserID int64 + ApiKeyID int64 + ProjectID int64 // 通过 API Key 关联的项目 ID + RequestID string + ClientIP string +} + +// HandleRealtime 处理 /v1/realtime WebSocket 请求 +func HandleRealtime(w http.ResponseWriter, r *http.Request, rc *RealtimeContext, provider common.DataProvider, billing common.BillingProvider) (*common.Usage, *BillingResult, error) { + ctx := r.Context() + + // 1. WebSocket 升级 + clientConn, err := websocketUpgrader.Upgrade(w, r, nil) + if err != nil { + g.Log().Errorf(ctx, "[HandleRealtime] WebSocket upgrade failed: %v", err) + return nil, nil, fmt.Errorf("websocket upgrade failed: %w", err) + } + defer clientConn.Close() + + // 2. 读取第一条消息获取 model 名称 + _, firstMsg, err := clientConn.ReadMessage() + if err != nil { + return nil, nil, fmt.Errorf("read first websocket message failed: %w", err) + } + + var firstEvent map[string]json.RawMessage + if err := json.Unmarshal(firstMsg, &firstEvent); err != nil { + return nil, nil, fmt.Errorf("parse first websocket message failed: %w", err) + } + + modelName := extractModelFromEvent(firstEvent) + if modelName == "" { + errMsg, _ := json.Marshal(map[string]any{ + "type": "error", + "error": map[string]string{"message": "model is required in the first message"}, + }) + _ = clientConn.WriteMessage(websocket.TextMessage, errMsg) + return nil, nil, fmt.Errorf("model not found in first websocket message") + } + + // 3. 验证模型 + _, _, err = provider.GetModelMapping(ctx, modelName) + if err != nil { + return nil, nil, fmt.Errorf("model not found: %s", modelName) + } + + // 4. 渠道选择 + selection, err := provider.GetChannelForModel(ctx, rc.TenantID, rc.UserID, modelName, nil) + if err != nil { + return nil, nil, constant.NewChannelError("no available channel for model: "+modelName, err) + } + + // 5. 构造 RelayInfo + info := &common.RelayInfo{ + Context: ctx, + TenantID: rc.TenantID, + UserID: rc.UserID, + ApiKeyID: rc.ApiKeyID, + RequestID: rc.RequestID, + RelayMode: int(constant.RelayModeRealtime), + OriginModelName: modelName, + RequestURLPath: "/realtime", + RequestHeaders: r.Header, + StartTime: time.Now(), + InboundFormat: constant.RelayFormatOpenAI, + ClientFormat: constant.RelayFormatOpenAI, + ChannelMeta: &common.ChannelMeta{ + ChannelID: selection.ChannelID, + ChannelType: selection.ChannelType, + ChannelName: selection.ChannelName, + BaseURL: selection.BaseURL, + ApiKey: selection.ApiKey, + UpstreamModelName: selection.UpstreamModelName, + IsModelMapped: selection.IsModelMapped, + Settings: selection.Settings, + }, + ClientConn: clientConn, + } + + // 6. 获取适配器 + adaptor := channel.GetAdaptor(selection.ChannelType) + if adaptor == nil { + return nil, nil, fmt.Errorf("unsupported channel type for realtime: %d", selection.ChannelType) + } + adaptor.Init(info) + + // 7. 预扣费用(Realtime 使用 0 input tokens 作为初始估算) + var preDeductAmount float64 + if billing != nil { + amt, billErr := billing.PreDeduct(ctx, rc.TenantID, modelName, 0, 0, false, rc.RequestID) + if billErr != nil { + return nil, nil, constant.NewQuotaError("insufficient balance", billErr) + } + preDeductAmount = amt + } + + billingResult := &BillingResult{PreDeductAmount: preDeductAmount} + + // 8. 建立 Realtime 代理 + proxy := openai.NewRealtimeProxy(info) + if err := proxy.DialUpstream(); err != nil { + if billing != nil && preDeductAmount > 0 { + _ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount) + } + return nil, billingResult, err + } + defer proxy.Close() + + // 把第一条消息转发到上游 + if err := proxy.GetTargetConn().WriteMessage(websocket.TextMessage, firstMsg); err != nil { + if billing != nil && preDeductAmount > 0 { + _ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount) + } + return nil, billingResult, err + } + + // 9. 启动双向代理 + usage, proxyErr := proxy.Proxy(ctx) + + // 10. 结算费用 + if billing != nil && preDeductAmount > 0 { + if proxyErr != nil { + streamUsage := usage + if streamUsage == nil { + streamUsage = &common.Usage{} + } + _ = billing.SettleStreamInterrupted(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, selection.ChannelID, + modelName, rc.RequestID, "realtime", streamUsage, preDeductAmount) + } else if usage != nil { + _ = billing.Settle(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, selection.ChannelID, + modelName, rc.RequestID, "realtime", usage, preDeductAmount) + } + } + + return usage, billingResult, proxyErr +} + +// extractModelFromEvent 从 WebSocket 事件中提取模型名称 +func extractModelFromEvent(event map[string]json.RawMessage) string { + // 顶层 model 字段 + if m, ok := event["model"]; ok { + var s string + if json.Unmarshal(m, &s) == nil { + return strings.Trim(s, `"`) + } + } + + // session.update 的 session.model + if sessionRaw, ok := event["session"]; ok { + var sess map[string]json.RawMessage + if json.Unmarshal(sessionRaw, &sess) == nil { + if m, ok := sess["model"]; ok { + var s string + if json.Unmarshal(m, &s) == nil { + return strings.Trim(s, `"`) + } + } + } + } + + return "" +} diff --git a/relay/handler/relay_handler.go b/relay/handler/relay_handler.go new file mode 100644 index 0000000..cf0eeeb --- /dev/null +++ b/relay/handler/relay_handler.go @@ -0,0 +1,718 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/gogf/gf/v2/frame/g" + + "github.com/qianfree/team-api/internal/logic/monitor" + "github.com/qianfree/team-api/relay/channel" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/helper" + "github.com/qianfree/team-api/relay/override" + "github.com/qianfree/team-api/relay/scheduler" +) + +// RelayContext relay 请求上下文(从 GoFrame handler 传入) +type RelayContext struct { + TenantID int64 + UserID int64 + ApiKeyID int64 + ProjectID int64 // 通过 API Key 关联的项目 ID + RequestID string + Writer http.ResponseWriter + Scope string // API Key scope + ClientIP string // 客户端 IP + ForwardingTrace *common.ForwardingTrace // 转发路径追踪(仅管理员可见) +} + +// BillingResult 计费结果(返回给调用方用于设置响应头) +type BillingResult struct { + PreDeductAmount float64 + ActualCost float64 + RateLimitInfo *common.RateLimitInfo + Deprecation *common.DeprecationInfo + FirstTokenMs int +} + +// RelayHandler 共享的 relay 请求编排逻辑(带重试 + 计费) +func RelayHandler(ctx context.Context, body []byte, path string, headers http.Header, rc *RelayContext, provider common.DataProvider, billing common.BillingProvider) (*common.Usage, *BillingResult, error) { + // 1. 确定 relay mode + relayMode := constant.Path2RelayMode(path) + if relayMode == constant.RelayModeUnknown { + g.Log().Errorf(ctx, "[RelayHandler] Unknown relay mode for path: %s", path) + return nil, nil, constant.NewRequestError("unsupported endpoint: "+path, nil) + } + + relayModeStr := relayModeString(relayMode) + + // 2. 解析请求体获取模型名 + var rawRequest map[string]json.RawMessage + if err := json.Unmarshal(body, &rawRequest); err != nil { + return nil, nil, constant.NewRequestError("invalid request body", err) + } + + modelName := "" + if v, ok := rawRequest["model"]; ok { + var m string + if err := json.Unmarshal(v, &m); err == nil { + modelName = m + } + } + if modelName == "" { + return nil, nil, constant.NewRequestError("model is required", nil) + } + + // 2.5 解析 thinking/effort 后缀 + thinkingInfo := helper.ParseThinkingSuffix(modelName) + lookupModel := modelName + if thinkingInfo.BaseModel != modelName { + lookupModel = thinkingInfo.BaseModel + } + + // 3. 验证模型存在且活跃 + _, _, modelErr := provider.GetModelMapping(ctx, modelName) + if modelErr != nil && thinkingInfo.BaseModel != modelName { + // 完整模型名查找失败,尝试基础模型名 + _, _, modelErr = provider.GetModelMapping(ctx, thinkingInfo.BaseModel) + if modelErr == nil { + lookupModel = thinkingInfo.BaseModel + } + } + if modelErr != nil { + if modelErr == common.ErrModelNotFound { + return nil, nil, constant.NewRequestError("model not found: "+modelName, modelErr) + } + return nil, nil, modelErr + } + + // 3.5 检查模型弃用状态 + depInfo, _ := provider.GetModelDeprecationInfo(ctx, lookupModel) + if depInfo != nil && depInfo.Deprecated && depInfo.SunsetDate != "" { + sunsetTime, _ := time.Parse("2006-01-02", depInfo.SunsetDate) + if !sunsetTime.IsZero() && time.Now().After(sunsetTime) { + return nil, nil, constant.NewModelGoneError(lookupModel, depInfo.SunsetDate) + } + } + + // 3.7 检查成员模型范围 + if allowed, err := provider.CheckMemberModelAccess(ctx, rc.TenantID, rc.UserID, lookupModel); err != nil { + return nil, nil, err + } else if !allowed { + return nil, nil, constant.NewAuthError("model not allowed for this member") + } + + // 4. API Key Scope 校验 + if billing != nil && !billing.CheckScope(rc.Scope, relayModeStr) { + return nil, nil, constant.NewAuthError("API key scope denied for this endpoint") + } + + // 5. QPS 限流检查 + billingResult := &BillingResult{Deprecation: depInfo} + if billing != nil { + allowed, limitLevel, remaining, resetAt := billing.CheckRateLimit(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID) + if !allowed { + return nil, nil, &RelayErrorWithRateLimit{ + StatusCode: 429, + Message: fmt.Sprintf("rate limit exceeded at %s level", limitLevel), + LimitLevel: limitLevel, + Remaining: remaining, + ResetAt: resetAt, + } + } + billingResult.RateLimitInfo = &common.RateLimitInfo{ + Limit: 0, // 由 middleware 设置具体值 + Remaining: remaining, + ResetAt: resetAt, + } + } + + // 6. 并发限制(含模型级并发控制) + if billing != nil { + if !billing.AcquireConcurrent(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, modelName) { + return nil, nil, constant.NewRateLimitError("concurrent request limit exceeded") + } + defer billing.ReleaseConcurrent(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, modelName) + } + + // 6.5 实时监控注册 + var isStream bool + if streamVal, ok := rawRequest["stream"]; ok { + _ = json.Unmarshal(streamVal, &isStream) + } + monitor.RegisterRequest(&monitor.TrackedRequest{ + RequestID: rc.RequestID, + TenantID: rc.TenantID, + UserID: rc.UserID, + ProjectID: rc.ProjectID, + ModelName: modelName, + IsStream: isStream, + StartTime: time.Now(), + Path: path, + }) + defer monitor.UnregisterRequest(rc.RequestID) + + // 7. 估算输入 token 数(粗略:按字符数 / 4) + estimatedInputTokens := estimateInputTokens(body) + + // 获取 max_tokens + var maxTokens int + if mtVal, ok := rawRequest["max_tokens"]; ok { + _ = json.Unmarshal(mtVal, &maxTokens) + } + if mcVal, ok := rawRequest["max_completion_tokens"]; ok { + var mc int + _ = json.Unmarshal(mcVal, &mc) + if mc > maxTokens { + maxTokens = mc + } + } + + // 7.5 成员额度预检查 + if billing != nil { + if err := billing.CheckMemberQuota(ctx, rc.TenantID, rc.UserID, 0); err != nil { + return nil, nil, constant.NewQuotaError("member quota exceeded", err) + } + } + + // 8. 预扣费用 + var preDeductAmount float64 + if billing != nil { + amt, err := billing.PreDeduct(ctx, rc.TenantID, modelName, estimatedInputTokens, maxTokens, isStream, rc.RequestID) + if err != nil { + return nil, nil, constant.NewQuotaError("insufficient balance", err) + } + preDeductAmount = amt + billingResult.PreDeductAmount = amt + } + + // 10. 带重试的请求调度 + excludeChannelIDs := make([]int64, 0) + maxRetries := 3 + originalBody := body // 保存原始请求体,避免重试时重复转换 + + // 初始化转发路径追踪 + trace := &common.ForwardingTrace{ + EntryPath: path, + EntryFormat: string(relayModeToInboundFormat(relayMode)), + RequestedModel: modelName, + } + + for attempt := 0; attempt <= maxRetries; attempt++ { + // 选择渠道(排除已失败的) + selection, err := provider.GetChannelForModel(ctx, rc.TenantID, rc.UserID, lookupModel, excludeChannelIDs) + if err != nil { + if err == common.ErrChannelUnavailable { + if len(excludeChannelIDs) > 0 { + // 所有渠道失败,退还预扣 + if billing != nil && preDeductAmount > 0 { + _ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount) + } + return nil, billingResult, constant.NewChannelError( + fmt.Sprintf("all %d channels failed for model: %s", len(excludeChannelIDs), modelName), + constant.ErrAllChannelsFailed, + ) + } + if billing != nil && preDeductAmount > 0 { + _ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount) + } + return nil, billingResult, constant.NewChannelError("no available channel for model: "+modelName, err) + } + if billing != nil && preDeductAmount > 0 { + _ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount) + } + return nil, billingResult, err + } + + // 11. 构建 RelayInfo + info := &common.RelayInfo{ + Context: ctx, + TenantID: rc.TenantID, + UserID: rc.UserID, + ApiKeyID: rc.ApiKeyID, + ProjectID: rc.ProjectID, + RequestID: rc.RequestID, + RelayMode: int(relayMode), + IsStream: isStream, + OriginModelName: modelName, + BaseModelName: lookupModel, + ThinkingEnabled: thinkingInfo.IsThinking, + ThinkingDisabled: thinkingInfo.IsNoThinking, + ReasoningEffort: thinkingInfo.EffortLevel, + RequestURLPath: path, + RequestHeaders: headers, + StartTime: time.Now(), + StreamStatus: common.NewStreamStatus(), + InboundFormat: relayModeToInboundFormat(relayMode), + ChannelMeta: &common.ChannelMeta{ + ChannelID: selection.ChannelID, + ChannelType: selection.ChannelType, + ChannelName: selection.ChannelName, + BaseURL: selection.BaseURL, + ApiKey: selection.ApiKey, + UpstreamModelName: selection.UpstreamModelName, + IsModelMapped: selection.IsModelMapped, + Settings: selection.Settings, + }, + } + + // 11.5 更新实时监控中的渠道信息 + if tr := monitor.GetTrackedRequest(rc.RequestID); tr != nil { + tr.ChannelID = selection.ChannelID + tr.ChannelName = selection.ChannelName + } + + // 12. 获取适配器 + adaptor := channel.GetAdaptor(selection.ChannelType) + if adaptor == nil { + g.Log().Errorf(ctx, "[RelayHandler] No adaptor found for channelType: %d", selection.ChannelType) + if billing != nil && preDeductAmount > 0 { + _ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount) + } + return nil, billingResult, fmt.Errorf("unsupported channel type: %d", selection.ChannelType) + } + adaptor.Init(info) + + // 捕获上游 URL 用于转发追踪 + var upstreamURL string + if u, err := adaptor.GetRequestURL(info); err == nil { + upstreamURL = u + } + hop := common.ForwardingHop{ + Attempt: attempt, + ChannelID: selection.ChannelID, + ChannelName: selection.ChannelName, + ChannelType: selection.ChannelType, + Provider: constant.ProviderType(selection.ChannelType).String(), + BaseURL: selection.BaseURL, + UpstreamURL: upstreamURL, + UpstreamModel: selection.UpstreamModelName, + ModelMapped: selection.IsModelMapped, + } + + // 12.5 保存客户端原始格式(用于响应方向转换) + // 每次重试从原始请求体开始转换,避免重复转换 + workingBody := originalBody + if info.ClientFormat == "" { + info.ClientFormat = info.InboundFormat + } + + // 13. 转换请求(直连模式跳过协议转换和参数改写) + var convertedBody io.Reader + if canPassThrough(info) { + // Gemini 原生格式通过 URL 路径控制流式,body 中的 "stream" 字段会导致上游报错 + if info.InboundFormat == constant.RelayFormatGemini { + workingBody = helper.StripStreamField(workingBody) + } + convertedBody = bytes.NewReader(workingBody) + } else { + var err error + convertedBody, err = adaptor.ConvertRequest(ctx, info, workingBody) + if err != nil { + g.Log().Errorf(ctx, "[RelayHandler] ConvertRequest failed: adaptor=%s, inboundFormat=%s, error=%v", + adaptor.GetChannelName(), info.InboundFormat, err) + if billing != nil && preDeductAmount > 0 { + _ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount) + } + return nil, billingResult, err + } + + // 13.3 注入渠道系统提示词 + if info.ChannelMeta.Settings.SystemPrompt != "" { + bodyBytes, _ := io.ReadAll(convertedBody) + bodyBytes = helper.InjectSystemPrompt(bodyBytes, info) + convertedBody = bytes.NewReader(bodyBytes) + } + + // 13.5 应用请求体改写(ParamOverride) + if info.ChannelMeta.Settings.ParamOverride != nil { + bodyBytes, err := io.ReadAll(convertedBody) + if err != nil { + g.Log().Errorf(ctx, "[RelayHandler] Read converted body for param override failed: %v", err) + if billing != nil && preDeductAmount > 0 { + _ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount) + } + return nil, billingResult, err + } + bodyBytes, err = override.ApplyParamOverride(bodyBytes, info) + if err != nil { + if retErr, ok := override.AsReturnError(err); ok { + return nil, billingResult, constant.NewUpstreamError(retErr.StatusCode, retErr.Message, retErr) + } + return nil, billingResult, err + } + convertedBody = bytes.NewReader(bodyBytes) + } + + // 13.7 字段清理 + { + sanitized, _ := io.ReadAll(convertedBody) + sanitized = helper.SanitizeFields(sanitized, info.ChannelMeta.Settings) + convertedBody = bytes.NewReader(sanitized) + } + } + + // 13.9 上游请求 context 解耦:客户端断开不应中断上游请求, + // 参考 new-api/sub2api:上游请求用 WithoutCancel 解耦,超时由 HTTP Client 和 StreamScanner 控制。 + upstreamCtx := context.WithoutCancel(ctx) + + // 独立结算 context(加超时保护,防止结算操作无限阻塞) + settleCtx, settleCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer settleCancel() + + // 14. 发送请求到上游 + resp, err := adaptor.DoRequest(upstreamCtx, info, convertedBody) + if err != nil { + provider.UpdateChannelHealth(settleCtx, selection.ChannelID, false, info.LatencyMs()) + provider.IncrementConsecutiveFailure(settleCtx, selection.ChannelID) + excludeChannelIDs = append(excludeChannelIDs, selection.ChannelID) + + // 亲和性渠道失败,清除亲和性 + scheduler.GetGlobalAffinity().Delete(rc.TenantID, rc.UserID, modelName) + + if constant.IsRetryable(err) && attempt < maxRetries { + hop.Success = false + hop.Error = err.Error() + hop.LatencyMs = info.LatencyMs() + trace.Hops = append(trace.Hops, hop) + continue + } + + // 不可重试或达到上限:退还预扣 + if billing != nil && preDeductAmount > 0 { + _ = billing.SettleFailed(settleCtx, rc.TenantID, rc.RequestID, preDeductAmount) + } + + recordFailedUsage(provider, rc, selection.ChannelID, modelName, relayMode, isStream, err) + + hop.Success = false + hop.Error = err.Error() + hop.LatencyMs = info.LatencyMs() + trace.Hops = append(trace.Hops, hop) + trace.TotalAttempts = attempt + 1 + trace.UpstreamModel = selection.UpstreamModelName + trace.ModelMapped = selection.IsModelMapped + rc.ForwardingTrace = trace + + return nil, billingResult, helper.RemapStatusCode(constant.NewUpstreamError(502, "upstream request failed", err), info.ChannelMeta.Settings.StatusCodeMapping) + } + + // 非流式:DoRequest 返回即表示收到上游首响应,设置首Token时间 + if !isStream { + info.SetFirstResponseTime() + } + + // 15. 处理响应 + usage, err := adaptor.DoResponse(ctx, resp, info, rc.Writer) + if err != nil { + err = helper.RemapStatusCode(err, info.ChannelMeta.Settings.StatusCodeMapping) + + // 流式中断(客户端断开、上游超时等):降级为 WARN,不影响渠道健康度 + if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() { + g.Log().Warningf(ctx, "[RelayHandler] Stream interrupted: adaptor=%s, model=%s, reason=%s", + adaptor.GetChannelName(), modelName, info.StreamStatus.Summary()) + + streamUsage := usage + if streamUsage == nil { + streamUsage = &common.Usage{} + } + if billing != nil && preDeductAmount > 0 { + _ = billing.SettleStreamInterrupted(settleCtx, rc.TenantID, rc.UserID, rc.ApiKeyID, selection.ChannelID, + modelName, rc.RequestID, relayModeStr, streamUsage, preDeductAmount) + } + recordFailedUsage(provider, rc, selection.ChannelID, modelName, relayMode, isStream, err) + hop.Success = false + hop.Error = err.Error() + hop.LatencyMs = info.LatencyMs() + trace.Hops = append(trace.Hops, hop) + trace.TotalAttempts = attempt + 1 + trace.UpstreamModel = selection.UpstreamModelName + trace.ModelMapped = selection.IsModelMapped + rc.ForwardingTrace = trace + return usage, billingResult, err + } + + // 非中断类错误:记录 ERROR 并更新渠道健康度 + g.Log().Errorf(ctx, "[RelayHandler] DoResponse failed: adaptor=%s, inboundFormat=%s, error=%v", + adaptor.GetChannelName(), info.InboundFormat, err) + provider.UpdateChannelHealth(settleCtx, selection.ChannelID, false, info.LatencyMs()) + provider.IncrementConsecutiveFailure(settleCtx, selection.ChannelID) + excludeChannelIDs = append(excludeChannelIDs, selection.ChannelID) + + // 亲和性渠道失败,清除亲和性 + scheduler.GetGlobalAffinity().Delete(rc.TenantID, rc.UserID, modelName) + + // 其他失败 + if isStream || !constant.IsRetryable(err) || attempt >= maxRetries { + // 退还预扣 + if billing != nil && preDeductAmount > 0 { + _ = billing.SettleFailed(settleCtx, rc.TenantID, rc.RequestID, preDeductAmount) + } + recordFailedUsage(provider, rc, selection.ChannelID, modelName, relayMode, isStream, err) + hop.Success = false + hop.Error = err.Error() + hop.LatencyMs = info.LatencyMs() + trace.Hops = append(trace.Hops, hop) + trace.TotalAttempts = attempt + 1 + trace.UpstreamModel = selection.UpstreamModelName + trace.ModelMapped = selection.IsModelMapped + rc.ForwardingTrace = trace + + return nil, billingResult, err + } + hop.Success = false + hop.Error = err.Error() + hop.LatencyMs = info.LatencyMs() + trace.Hops = append(trace.Hops, hop) + continue + } + + hop.Success = true + hop.LatencyMs = info.LatencyMs() + trace.Hops = append(trace.Hops, hop) + trace.TotalAttempts = attempt + 1 + trace.UpstreamModel = selection.UpstreamModelName + trace.ModelMapped = selection.IsModelMapped + rc.ForwardingTrace = trace + + // 16. 成功:结算费用(使用完整 Usage 含 cache token) + // 流式响应完成后客户端可能已断开,ctx 已取消, + // 结算/健康度/用量记录等后置操作必须使用独立 context,不受请求生命周期影响。 + // 重要:此处重新创建 context,因为上游 DoResponse 可能耗时很长(长文本流式输出), + // 循环开头创建的 settleCtx 此时可能已经过期。 + postCtx, postCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer postCancel() + + var settleResult *common.SettlementResult + if billing != nil && preDeductAmount > 0 { + settleResult = billing.SettleWithUsage(postCtx, rc.TenantID, rc.UserID, rc.ApiKeyID, selection.ChannelID, + modelName, rc.RequestID, relayModeStr, + usage, preDeductAmount, info) + if settleResult != nil { + billingResult.ActualCost = settleResult.ActualCost + } else { + // 结算失败,退还预扣冻结金额 + g.Log().Warningf(postCtx, "[RelayHandler] Settlement failed for request=%s model=%s, refunding pre-deduct amount=%.6f", + rc.RequestID, modelName, preDeductAmount) + _ = billing.SettleFailed(postCtx, rc.TenantID, rc.RequestID, preDeductAmount) + } + } + + // 16.5 累加成员已用额度 + if billing != nil && settleResult != nil && settleResult.ActualCost > 0 { + billing.IncrMemberQuotaUsed(postCtx, rc.TenantID, rc.UserID, settleResult.ActualCost) + } + + // 17. 更新健康度 + 记录用量 + 更新亲和性 + provider.UpdateChannelHealth(postCtx, selection.ChannelID, true, info.LatencyMs()) + provider.ResetConsecutiveFailure(postCtx, selection.ChannelID) + + scheduler.GetGlobalAffinity().Set(rc.TenantID, rc.UserID, modelName, selection.ChannelID) + + firstTokenMs := 0 + if !info.FirstResponseTime.IsZero() { + firstTokenMs = int(info.FirstResponseTime.Sub(info.StartTime).Milliseconds()) + } + + // 构建用量记录 + usageRecord := &common.UsageRecord{ + TenantID: rc.TenantID, + UserID: rc.UserID, + ApiKeyID: rc.ApiKeyID, + ProjectID: rc.ProjectID, + ChannelID: selection.ChannelID, + ModelName: modelName, + RelayMode: int(relayMode), + PromptTokens: usage.PromptTokens, + CompletionTokens: usage.CompletionTokens, + TotalTokens: usage.TotalTokens, + CachedTokens: tokenDetailField(usage.PromptTokensDetails, func(d *common.TokenDetails) int { return d.CachedTokens }), + AudioTokens: tokenDetailField(usage.PromptTokensDetails, func(d *common.TokenDetails) int { return d.AudioTokens }) + + tokenDetailField(usage.CompletionTokenDetails, func(d *common.TokenDetails) int { return d.AudioTokens }), + ImageTokens: tokenDetailField(usage.PromptTokensDetails, func(d *common.TokenDetails) int { return d.ImageTokens }), + ReasoningTokens: tokenDetailField(usage.CompletionTokenDetails, func(d *common.TokenDetails) int { return d.ReasoningTokens }), + LatencyMs: info.TotalLatencyMs(), + IsStream: isStream, + Success: true, + RequestID: rc.RequestID, + Status: "success", + + // Cache token 明细 + CacheCreationTokens: usage.CacheCreationTokens, + CacheCreation5mTokens: tokenDetailField(usage.PromptTokensDetails, func(d *common.TokenDetails) int { return d.CachedCreation5mTokens }), + CacheCreation1hTokens: tokenDetailField(usage.PromptTokensDetails, func(d *common.TokenDetails) int { return d.CachedCreation1hTokens }), + CacheReadTokens: tokenDetailField(usage.PromptTokensDetails, func(d *common.TokenDetails) int { return d.CachedTokens }), + + // 音频 token 分离 + AudioInputTokens: tokenDetailField(usage.PromptTokensDetails, func(d *common.TokenDetails) int { return d.AudioTokens }), + AudioOutputTokens: tokenDetailField(usage.CompletionTokenDetails, func(d *common.TokenDetails) int { return d.AudioTokens }), + + // 其他 token + ImageOutputTokens: tokenDetailField(usage.CompletionTokenDetails, func(d *common.TokenDetails) int { return d.ImageTokens }), + + // 请求元数据 + RequestedModel: modelName, + UpstreamModel: selection.UpstreamModelName, + RequestType: requestType(isStream), + UserAgent: headers.Get("User-Agent"), + ClientIP: rc.ClientIP, + FirstTokenMs: firstTokenMs, + ReasoningEffort: info.ReasoningEffort, + InboundEndpoint: path, + + // 渠道详情 + ChannelName: selection.ChannelName, + ChannelType: selection.ChannelType, + + // 重试 + RetryIndex: info.RetryIndex, + } + + // 填充结算费用数据 + if settleResult != nil { + usageRecord.TotalCost = settleResult.BaseCost + usageRecord.ActualCost = settleResult.ActualCost + usageRecord.Currency = "USD" + usageRecord.PreDeductAmount = settleResult.PreDeductAmount + usageRecord.RefundAmount = settleResult.RefundAmount + usageRecord.SupplementAmount = settleResult.SupplementAmount + usageRecord.BillingSnapshot = settleResult.BillingSnapshot + usageRecord.BillingSummary = settleResult.BillingSummary + usageRecord.BillingMode = settleResult.BillingMode + usageRecord.BillingSource = settleResult.BillingSource + usageRecord.RateMultiplier = settleResult.RateMultiplier + usageRecord.InputCost = settleResult.InputCost + usageRecord.OutputCost = settleResult.OutputCost + usageRecord.CacheCreationCost = settleResult.CacheCreationCost + usageRecord.CacheReadCost = settleResult.CacheReadCost + } + + billingResult.FirstTokenMs = firstTokenMs + + provider.RecordUsage(postCtx, usageRecord) + + return usage, billingResult, nil + } + + // 不应到达此处 + if billing != nil && preDeductAmount > 0 { + _ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount) + } + return nil, billingResult, constant.ErrAllChannelsFailed +} + +// RelayErrorWithRateLimit 带 429 限流信息的错误 +type RelayErrorWithRateLimit struct { + StatusCode int + Message string + LimitLevel string + Remaining int + ResetAt int64 +} + +func (e *RelayErrorWithRateLimit) Error() string { + return e.Message +} + +// recordFailedUsage 记录失败用量 +func recordFailedUsage(provider common.DataProvider, rc *RelayContext, channelID int64, modelName string, relayMode constant.RelayMode, isStream bool, err error) { + provider.RecordUsage(context.Background(), &common.UsageRecord{ + TenantID: rc.TenantID, + UserID: rc.UserID, + ApiKeyID: rc.ApiKeyID, + ProjectID: rc.ProjectID, + ChannelID: channelID, + ModelName: modelName, + RelayMode: int(relayMode), + LatencyMs: 0, + IsStream: isStream, + Success: false, + RequestID: rc.RequestID, + Status: "error", + ErrorMessage: err.Error(), + }) +} + +// estimateInputTokens 粗略估算输入 token 数(按字符数 / 4) +func estimateInputTokens(body []byte) int { + return len(body) / 4 +} + +// tokenDetailField 安全提取 TokenDetails 中的字段值 +func tokenDetailField(details *common.TokenDetails, getter func(*common.TokenDetails) int) int { + if details == nil { + return 0 + } + return getter(details) +} + +// requestType 根据 isStream 返回请求类型 +func requestType(isStream bool) int { + if isStream { + return 2 + } + return 1 +} + +// relayModeToInboundFormat 根据 relay mode 推断入站请求格式 +func relayModeToInboundFormat(mode constant.RelayMode) constant.RelayFormat { + switch mode { + case constant.RelayModeClaudeMessages: + return constant.RelayFormatClaude + case constant.RelayModeGeminiChat: + return constant.RelayFormatGemini + case constant.RelayModeResponses, constant.RelayModeResponsesCompact: + return constant.RelayFormatResponses + default: + return constant.RelayFormatOpenAI + } +} + +// relayModeString 转换 relay mode 为字符串 +func relayModeString(mode constant.RelayMode) string { + switch mode { + case constant.RelayModeChatCompletions: + return "chat_completions" + case constant.RelayModeCompletions: + return "completions" + case constant.RelayModeEmbeddings: + return "embeddings" + case constant.RelayModeImagesGenerations: + return "images_generations" + case constant.RelayModeAudioSpeech: + return "audio_speech" + case constant.RelayModeAudioTranscription: + return "audio_transcriptions" + case constant.RelayModeAudioTranslation: + return "audio_translations" + case constant.RelayModeRerank: + return "rerank" + case constant.RelayModeResponses, constant.RelayModeResponsesCompact: + return "responses" + case constant.RelayModeRealtime: + return "realtime" + case constant.RelayModeClaudeMessages: + return "claude_messages" + case constant.RelayModeGeminiChat: + return "gemini_generate_content" + case constant.RelayModeModerations: + return "moderations" + case constant.RelayModeImagesEdits: + return "images_edits" + case constant.RelayModeMjSubmit: + return "mj_submit" + case constant.RelayModeMjFetch: + return "mj_fetch" + case constant.RelayModeMjImage: + return "mj_image" + default: + return "" + } +} diff --git a/relay/handler/rerank_handler.go b/relay/handler/rerank_handler.go new file mode 100644 index 0000000..81719c2 --- /dev/null +++ b/relay/handler/rerank_handler.go @@ -0,0 +1,13 @@ +package handler + +import ( + "context" + "net/http" + + "github.com/qianfree/team-api/relay/common" +) + +// HandleRerank 处理 /v1/rerank 请求 +func HandleRerank(ctx context.Context, body []byte, path string, headers http.Header, rc *RelayContext, provider common.DataProvider, billing common.BillingProvider) (*common.Usage, *BillingResult, error) { + return RelayHandler(ctx, body, path, headers, rc, provider, billing) +} diff --git a/relay/handler/task_handler.go b/relay/handler/task_handler.go new file mode 100644 index 0000000..7660833 --- /dev/null +++ b/relay/handler/task_handler.go @@ -0,0 +1,313 @@ +package handler + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/taskchannel" + _ "github.com/qianfree/team-api/relay/taskchannel/kling" + "github.com/qianfree/team-api/relay/taskchannel/midjourney" + _ "github.com/qianfree/team-api/relay/taskchannel/sora" + _ "github.com/qianfree/team-api/relay/taskchannel/suno" + _ "github.com/qianfree/team-api/relay/taskchannel/volcengine" +) + +// TaskRelayContext 异步任务 relay 上下文 +type TaskRelayContext struct { + TenantID int64 + UserID int64 + ApiKeyID int64 + RequestID string + Writer http.ResponseWriter + Scope string + ClientIP string +} + +// HandleTaskSubmit 异步任务提交管线 +func HandleTaskSubmit( + ctx context.Context, + body []byte, + path string, + headers http.Header, + rc *TaskRelayContext, + dataProvider common.TaskDataProvider, + billingProvider common.TaskBillingProvider, + channelMeta *common.ChannelMeta, +) { + // 1. 解析模型名 + var req map[string]json.RawMessage + if err := json.Unmarshal(body, &req); err != nil { + writeTaskError(rc.Writer, http.StatusBadRequest, "invalid request body", "") + return + } + + modelName := "" + if v, ok := req["model"]; ok { + if err := json.Unmarshal(v, &modelName); err != nil { + writeTaskError(rc.Writer, http.StatusBadRequest, "invalid model field", "") + return + } + } + if modelName == "" { + writeTaskError(rc.Writer, http.StatusBadRequest, "model is required", "") + return + } + + // 2. 确定任务平台 + providerType := constant.ProviderType(channelMeta.ChannelType) + platform, ok := constant.ProviderTypeToTaskPlatform(providerType) + if !ok { + writeTaskError(rc.Writer, http.StatusBadRequest, "unsupported task platform", "") + return + } + + // 3. 获取 TaskAdaptor + adaptor, err := taskchannel.GetAdaptor(providerType) + if err != nil { + writeTaskError(rc.Writer, http.StatusInternalServerError, err.Error(), "") + return + } + + // 4. 构建 RelayInfo + info := &common.RelayInfo{ + Context: ctx, + TenantID: rc.TenantID, + UserID: rc.UserID, + ApiKeyID: rc.ApiKeyID, + RequestID: rc.RequestID, + RelayMode: int(constant.RelayModeVideoGenerations), + OriginModelName: modelName, + RequestURLPath: path, + RequestHeaders: headers, + StartTime: time.Now(), + ChannelMeta: channelMeta, + } + adaptor.Init(info) + + // 5. 校验请求 + if taskErr := adaptor.ValidateRequest(ctx, info, body); taskErr != nil { + writeTaskError(rc.Writer, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode) + return + } + + // 6. 估算计费 + 预扣 + ratios := adaptor.EstimateBilling(ctx, info, body) + estimatedCost, err := billingProvider.EstimateTaskCost(ctx, rc.TenantID, modelName, ratios) + if err != nil { + writeTaskError(rc.Writer, http.StatusInternalServerError, "estimate cost failed: "+err.Error(), "") + return + } + + preDeductAmount, err := billingProvider.PreDeductTask(ctx, rc.TenantID, rc.RequestID, estimatedCost) + if err != nil { + writeTaskError(rc.Writer, http.StatusPaymentRequired, "insufficient balance", "") + return + } + + // 7. 构建并发送请求 + requestBody, err := adaptor.BuildRequestBody(ctx, info, body) + if err != nil { + billingProvider.SettleTaskFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount) + writeTaskError(rc.Writer, http.StatusInternalServerError, "build request failed: "+err.Error(), "") + return + } + + resp, err := adaptor.DoRequest(ctx, info, requestBody) + if err != nil { + billingProvider.SettleTaskFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount) + writeTaskError(rc.Writer, http.StatusBadGateway, "upstream request failed: "+err.Error(), "") + return + } + defer resp.Body.Close() + + // 8. 解析响应 + upstreamTaskID, taskData, taskErr := adaptor.DoResponse(ctx, resp, info) + if taskErr != nil { + billingProvider.SettleTaskFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount) + writeTaskError(rc.Writer, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode) + return + } + + // 9. 调整计费 + adjustedRatios := adaptor.AdjustBillingOnSubmit(info, taskData) + if adjustedRatios != nil { + newCost, _ := billingProvider.EstimateTaskCost(ctx, rc.TenantID, modelName, adjustedRatios) + preDeductAmount, _ = billingProvider.AdjustTaskBilling(ctx, rc.TenantID, rc.RequestID, preDeductAmount, newCost) + } + + // 10. 生成公开任务 ID 并创建记录 + publicTaskID := generatePublicTaskID() + now := time.Now() + + privateData, _ := json.Marshal(map[string]any{ + "upstream_task_id": upstreamTaskID, + "task_type": platform, + }) + + task := &common.AsyncTask{ + PublicTaskID: publicTaskID, + Platform: string(platform), + Action: "generate", + Status: "SUBMITTED", + Progress: "0%", + TenantID: rc.TenantID, + UserID: rc.UserID, + ApiKeyID: rc.ApiKeyID, + ChannelID: channelMeta.ChannelID, + ModelName: modelName, + UpstreamModel: channelMeta.UpstreamModelName, + PreDeductAmount: preDeductAmount, + Data: taskData, + PrivateData: privateData, + SubmitTime: &now, + } + + if err := dataProvider.CreateTask(ctx, task); err != nil { + billingProvider.SettleTaskFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount) + writeTaskError(rc.Writer, http.StatusInternalServerError, "create task record failed: "+err.Error(), "") + return + } + + // 11. 返回响应 + respBody := map[string]any{ + "id": publicTaskID, + "status": "SUBMITTED", + "model": modelName, + "created_at": now.Unix(), + } + writeJSON(rc.Writer, http.StatusOK, respBody) +} + +// HandleTaskFetch 异步任务查询管线 +func HandleTaskFetch( + ctx context.Context, + publicTaskID string, + rc *TaskRelayContext, + dataProvider common.TaskDataProvider, +) { + task, err := dataProvider.GetTaskByPublicIDAndUser(ctx, publicTaskID, rc.UserID) + if err != nil { + writeTaskError(rc.Writer, http.StatusInternalServerError, "query task failed", "") + return + } + if task == nil { + writeTaskError(rc.Writer, http.StatusNotFound, "task not found", "") + return + } + + respBody := map[string]any{ + "id": task.PublicTaskID, + "status": task.Status, + "model": task.ModelName, + "created_at": task.CreatedAt.Unix(), + } + if task.Progress != "" { + respBody["progress"] = task.Progress + } + if task.ResultURL != "" { + respBody["url"] = task.ResultURL + } + if task.FailReason != "" { + respBody["error"] = task.FailReason + } + if task.FinishTime != nil { + respBody["completed_at"] = task.FinishTime.Unix() + } + + writeJSON(rc.Writer, http.StatusOK, respBody) +} + +// writeTaskError 写入错误响应 +func writeTaskError(w http.ResponseWriter, statusCode int, message, code string) { + resp := map[string]any{ + "error": map[string]any{ + "type": "invalid_request_error", + "message": message, + }, + } + if code != "" { + resp["error"].(map[string]any)["code"] = code + } + writeJSON(w, statusCode, resp) +} + +// writeJSON 写入 JSON 响应 +func writeJSON(w http.ResponseWriter, statusCode int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + json.NewEncoder(w).Encode(data) +} + +// generatePublicTaskID 生成公开任务 ID +func generatePublicTaskID() string { + return fmt.Sprintf("task_%s", randomHex(12)) +} + +func randomHex(n int) string { + b := make([]byte, n) + rand.Read(b) + return hex.EncodeToString(b)[:n] +} + +// HandleMjImageProxy Midjourney 图片代理 +func HandleMjImageProxy( + ctx context.Context, + publicTaskID string, + rc *TaskRelayContext, + dataProvider common.TaskDataProvider, + writer http.ResponseWriter, +) { + task, err := dataProvider.GetTaskByPublicIDAndUser(ctx, publicTaskID, rc.UserID) + if err != nil { + writeTaskError(writer, http.StatusInternalServerError, "query task failed", "") + return + } + if task == nil { + writeTaskError(writer, http.StatusNotFound, "task not found", "") + return + } + + // 从 PrivateData 提取上游任务 ID + var private struct { + UpstreamTaskID string `json:"upstream_task_id"` + } + if err := json.Unmarshal(task.PrivateData, &private); err != nil || private.UpstreamTaskID == "" { + writeTaskError(writer, http.StatusInternalServerError, "invalid task data", "") + return + } + + // 获取渠道信息 + channel, err := dataProvider.GetChannelByID(ctx, task.ChannelID) + if err != nil || channel == nil { + writeTaskError(writer, http.StatusInternalServerError, "channel not found", "") + return + } + + // 代理获取图片 + resp, err := midjourney.FetchImage(channel.BaseURL, channel.ApiKey, private.UpstreamTaskID) + if err != nil { + writeTaskError(writer, http.StatusBadGateway, "fetch image failed: "+err.Error(), "") + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + writeTaskError(writer, resp.StatusCode, "upstream image fetch failed", "") + return + } + + // 透传 Content-Type + if ct := resp.Header.Get("Content-Type"); ct != "" { + writer.Header().Set("Content-Type", ct) + } + writer.WriteHeader(http.StatusOK) + io.Copy(writer, resp.Body) +} diff --git a/relay/helper/sanitize.go b/relay/helper/sanitize.go new file mode 100644 index 0000000..b2286c2 --- /dev/null +++ b/relay/helper/sanitize.go @@ -0,0 +1,86 @@ +package helper + +import ( + "encoding/json" + + "github.com/qianfree/team-api/relay/common" +) + +// SanitizeFields 移除请求体中可能产生额外费用或隐私风险的字段 +// 默认移除:service_tier, inference_geo, speed, safety_identifier +// 默认保留:store(通过 DisableStore=true 移除) +func SanitizeFields(body []byte, settings common.ChannelSettings) []byte { + if settings.PassThroughBodyEnabled { + return body + } + + var data map[string]any + if err := json.Unmarshal(body, &data); err != nil { + return body + } + + changed := false + + if !settings.AllowServiceTier { + if _, ok := data["service_tier"]; ok { + delete(data, "service_tier") + changed = true + } + } + + if !settings.AllowInferenceGeo { + if _, ok := data["inference_geo"]; ok { + delete(data, "inference_geo") + changed = true + } + } + + if !settings.AllowSpeed { + if _, ok := data["speed"]; ok { + delete(data, "speed") + changed = true + } + } + + if settings.DisableStore { + if _, ok := data["store"]; ok { + delete(data, "store") + changed = true + } + } + + if !settings.AllowSafetyIdentifier { + if _, ok := data["safety_identifier"]; ok { + delete(data, "safety_identifier") + changed = true + } + } + + if !changed { + return body + } + + result, err := json.Marshal(data) + if err != nil { + return body + } + return result +} + +// StripStreamField 移除请求体中的 "stream" 字段 +// Gemini 原生 API 通过 URL 路径(:streamGenerateContent)控制流式,不识别 body 中的 "stream" 字段 +func StripStreamField(body []byte) []byte { + var data map[string]any + if err := json.Unmarshal(body, &data); err != nil { + return body + } + if _, ok := data["stream"]; !ok { + return body + } + delete(data, "stream") + result, err := json.Marshal(data) + if err != nil { + return body + } + return result +} diff --git a/relay/helper/sanitize_test.go b/relay/helper/sanitize_test.go new file mode 100644 index 0000000..d632b1d --- /dev/null +++ b/relay/helper/sanitize_test.go @@ -0,0 +1,92 @@ +package helper + +import ( + "testing" + + "github.com/qianfree/team-api/relay/common" +) + +func TestSanitizeFields(t *testing.T) { + tests := []struct { + name string + body string + settings common.ChannelSettings + want string + }{ + { + name: "no fields to remove", + body: `{"model":"gpt-4o","messages":[]}`, + settings: common.ChannelSettings{}, + want: `{"model":"gpt-4o","messages":[]}`, + }, + { + name: "remove service_tier by default", + body: `{"model":"gpt-4o","service_tier":"auto","messages":[]}`, + settings: common.ChannelSettings{}, + want: `{"messages":[],"model":"gpt-4o"}`, + }, + { + name: "keep service_tier when allowed", + body: `{"model":"gpt-4o","service_tier":"auto","messages":[]}`, + settings: common.ChannelSettings{AllowServiceTier: true}, + want: `{"model":"gpt-4o","service_tier":"auto","messages":[]}`, + }, + { + name: "remove inference_geo by default", + body: `{"model":"claude-3","inference_geo":"us","messages":[]}`, + settings: common.ChannelSettings{}, + want: `{"messages":[],"model":"claude-3"}`, + }, + { + name: "remove speed by default", + body: `{"model":"claude-3","speed":"fast","messages":[]}`, + settings: common.ChannelSettings{}, + want: `{"messages":[],"model":"claude-3"}`, + }, + { + name: "remove store when disabled", + body: `{"model":"gpt-4o","store":true,"messages":[]}`, + settings: common.ChannelSettings{DisableStore: true}, + want: `{"messages":[],"model":"gpt-4o"}`, + }, + { + name: "keep store by default", + body: `{"model":"gpt-4o","store":true,"messages":[]}`, + settings: common.ChannelSettings{}, + want: `{"model":"gpt-4o","store":true,"messages":[]}`, + }, + { + name: "remove safety_identifier by default", + body: `{"model":"gpt-4o","safety_identifier":"user123","messages":[]}`, + settings: common.ChannelSettings{}, + want: `{"messages":[],"model":"gpt-4o"}`, + }, + { + name: "remove multiple fields", + body: `{"model":"gpt-4o","service_tier":"auto","store":true,"safety_identifier":"u1","messages":[]}`, + settings: common.ChannelSettings{DisableStore: true}, + want: `{"messages":[],"model":"gpt-4o"}`, + }, + { + name: "skip when PassThroughBodyEnabled", + body: `{"model":"gpt-4o","service_tier":"auto","messages":[]}`, + settings: common.ChannelSettings{PassThroughBodyEnabled: true}, + want: `{"model":"gpt-4o","service_tier":"auto","messages":[]}`, + }, + { + name: "invalid JSON returns as-is", + body: `{invalid}`, + settings: common.ChannelSettings{}, + want: `{invalid}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := string(SanitizeFields([]byte(tt.body), tt.settings)) + if got != tt.want { + t.Errorf("SanitizeFields() = %s, want %s", got, tt.want) + } + }) + } +} diff --git a/relay/helper/status_code.go b/relay/helper/status_code.go new file mode 100644 index 0000000..72b7871 --- /dev/null +++ b/relay/helper/status_code.go @@ -0,0 +1,53 @@ +package helper + +import ( + "encoding/json" + "strconv" + + "github.com/qianfree/team-api/relay/constant" +) + +// RemapStatusCode 根据渠道配置重映射错误响应的 HTTP 状态码 +// mappingJSON 格式如 {"429": 500, "403": 500} +func RemapStatusCode(err error, mappingJSON string) error { + if err == nil || mappingJSON == "" || mappingJSON == "{}" { + return err + } + + relayErr, ok := err.(*constant.RelayError) + if !ok || relayErr.StatusCode == 0 || relayErr.StatusCode == 200 { + return err + } + + mapping := make(map[string]any) + if err := json.Unmarshal([]byte(mappingJSON), &mapping); err != nil { + return relayErr // mapping 解析失败,返回原始错误 + } + + codeStr := strconv.Itoa(relayErr.StatusCode) + if mapped, exists := mapping[codeStr]; exists { + newCode := parseMappingValue(mapped) + if newCode > 0 { + relayErr.StatusCode = newCode + } + } + + return relayErr +} + +func parseMappingValue(v any) int { + switch val := v.(type) { + case string: + n, _ := strconv.Atoi(val) + return n + case float64: + return int(val) + case int: + return val + case json.Number: + n, _ := val.Int64() + return int(n) + default: + return 0 + } +} diff --git a/relay/helper/status_code_test.go b/relay/helper/status_code_test.go new file mode 100644 index 0000000..a560bb1 --- /dev/null +++ b/relay/helper/status_code_test.go @@ -0,0 +1,89 @@ +package helper + +import ( + "encoding/json" + "testing" + + "github.com/qianfree/team-api/relay/constant" +) + +func TestRemapStatusCode(t *testing.T) { + tests := []struct { + name string + err error + mapping string + wantCode int + wantNil bool + }{ + { + name: "nil error returns nil", + err: nil, + mapping: `{"429": 500}`, + wantNil: true, + }, + { + name: "empty mapping returns same error", + err: constant.NewUpstreamError(429, "rate limited", nil), + mapping: "", + wantCode: 429, + }, + { + name: "remap 429 to 500", + err: constant.NewUpstreamError(429, "rate limited", nil), + mapping: `{"429": 500}`, + wantCode: 500, + }, + { + name: "remap 403 to 500", + err: constant.NewUpstreamError(403, "forbidden", nil), + mapping: `{"429": 500, "403": 500}`, + wantCode: 500, + }, + { + name: "no matching mapping keeps original", + err: constant.NewUpstreamError(400, "bad request", nil), + mapping: `{"429": 500, "403": 500}`, + wantCode: 400, + }, + { + name: "invalid JSON mapping returns original RelayError", + err: constant.NewUpstreamError(429, "rate limited", nil), + mapping: `{invalid}`, + wantCode: 429, + }, + { + name: "non-RelayError returns same error unchanged", + err: json.Unmarshal([]byte("{bad}"), &map[string]any{}), + mapping: `{"400": 500}`, + wantCode: 0, // not a RelayError, won't be modified + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := RemapStatusCode(tt.err, tt.mapping) + if tt.wantNil { + if result != nil { + t.Errorf("expected nil, got %v", result) + } + return + } + + relayErr, ok := result.(*constant.RelayError) + if tt.wantCode == 0 { + // Not a RelayError — just check it's not nil and not a RelayError + if ok { + t.Errorf("expected non-RelayError, got RelayError with code %d", relayErr.StatusCode) + } + return + } + + if !ok { + t.Fatalf("expected *RelayError, got %T", result) + } + if relayErr.StatusCode != tt.wantCode { + t.Errorf("StatusCode = %d, want %d", relayErr.StatusCode, tt.wantCode) + } + }) + } +} diff --git a/relay/helper/stream.go b/relay/helper/stream.go new file mode 100644 index 0000000..290d870 --- /dev/null +++ b/relay/helper/stream.go @@ -0,0 +1,106 @@ +package helper + +import ( + "fmt" + "net/http" + "strings" + "time" +) + +// SetEventStreamHeaders 设置 SSE 必要的响应头 +func SetEventStreamHeaders(w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") // 禁用 Nginx 缓冲 + w.WriteHeader(http.StatusOK) + + if f, ok := w.(http.Flusher); ok { + f.Flush() + } +} + +// WriteSSEData 写入一行 SSE 数据 +func WriteSSEData(w http.ResponseWriter, data string) error { + _, err := fmt.Fprintf(w, "data: %s\n\n", data) + if err != nil { + return err + } + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + return nil +} + +// WriteSSEEvent 写入一个完整的 SSE 事件(包含 event 和 data) +func WriteSSEEvent(w http.ResponseWriter, event string, data string) error { + _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, data) + if err != nil { + return err + } + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + return nil +} + +// WriteSSEPing 写入 SSE 保活注释(`: PING\n\n`) +// SSE 规范中以 `:` 开头的行是注释,客户端会忽略但会保持连接活跃 +func WriteSSEPing(w http.ResponseWriter) error { + _, err := fmt.Fprintf(w, ": PING\n\n") + if err != nil { + return err + } + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + return nil +} + +// WriteJSON 写入 JSON 响应 +func WriteJSON(w http.ResponseWriter, statusCode int, data any) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + _, err := fmt.Fprintf(w, "%s", data) + return err +} + +// ExtractSSEData 从 SSE 行中提取 data 字段内容 +// 兼容 "data: content"(带空格)和 "data:content"(不带空格)两种格式 +// 如果不是 data 行返回 ("", false) +func ExtractSSEData(line string) (string, bool) { + if !strings.HasPrefix(line, "data:") { + return "", false + } + data := line[5:] // 跳过 "data:" 前缀 + // 跳过可选的单个空格(SSE 规范:data: 后最多忽略一个空格) + if len(data) > 0 && data[0] == ' ' { + data = data[1:] + } + return strings.TrimSpace(data), true +} + +// PingTicker 在后台定期发送 SSE 保活注释 +// 返回一个 stop 函数用于停止 goroutine +func PingTicker(w http.ResponseWriter, interval time.Duration) (stop func()) { + ticker := time.NewTicker(interval) + done := make(chan struct{}) + + go func() { + defer ticker.Stop() + for { + select { + case <-ticker.C: + if err := WriteSSEPing(w); err != nil { + return + } + case <-done: + return + } + } + }() + + return func() { + close(done) + } +} diff --git a/relay/helper/stream_result.go b/relay/helper/stream_result.go new file mode 100644 index 0000000..ad0c689 --- /dev/null +++ b/relay/helper/stream_result.go @@ -0,0 +1,51 @@ +package helper + +import ( + "fmt" + + "github.com/qianfree/team-api/relay/common" +) + +// StreamResult 每次 dataHandler 调用的控制对象 +type StreamResult struct { + status *common.StreamStatus + stopped bool +} + +// NewStreamResult 创建 StreamResult +func NewStreamResult(status *common.StreamStatus) *StreamResult { + return &StreamResult{status: status} +} + +// Error 记录软错误(流继续处理) +func (sr *StreamResult) Error(err error) { + if sr.status != nil { + sr.status.RecordError(fmt.Sprintf("handler error: %v", err)) + } +} + +// Stop 记录致命错误并停止流 +func (sr *StreamResult) Stop(err error) { + sr.stopped = true + if sr.status != nil { + sr.status.SetEndReason(common.StreamEndReasonHandlerStop, err) + } +} + +// Done 标记正常完成并停止流 +func (sr *StreamResult) Done() { + sr.stopped = true + if sr.status != nil { + sr.status.SetEndReason(common.StreamEndReasonDone, nil) + } +} + +// IsStopped 是否已停止 +func (sr *StreamResult) IsStopped() bool { + return sr.stopped +} + +// reset 重置 stopped 标志(每次 dataHandler 调用前使用) +func (sr *StreamResult) reset() { + sr.stopped = false +} diff --git a/relay/helper/stream_scanner.go b/relay/helper/stream_scanner.go new file mode 100644 index 0000000..bdc7437 --- /dev/null +++ b/relay/helper/stream_scanner.go @@ -0,0 +1,232 @@ +package helper + +import ( + "bufio" + "context" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/gogf/gf/v2/frame/g" + + "github.com/qianfree/team-api/relay/common" +) + +const ( + defaultStreamTimeout = 300 * time.Second // 两个 chunk 之间的最大空闲间隔(非总超时) + defaultPingInterval = 15 * time.Second + dataChanSize = 10 + stopChanSize = 3 + shutdownWaitTimeout = 5 * time.Second + minLineLength = 6 + scannerInitialBuf = 64 * 1024 + scannerMaxBuf = 10 * 1024 * 1024 + resetChanSize = 8 +) + +// DataHandlerFunc data 处理回调 +type DataHandlerFunc func(data string, sr *StreamResult) + +// StreamScannerHandler 三协程 SSE 流扫描器 +// Scanner 协程:读取上游行,过滤 data: 行,推入 dataChan +// DataHandler 协程:从 dataChan 读取,调用 handler 回调,持有 writeMutex +// Ping 协程:定期写 SSE 保活注释,持有 writeMutex +// +// 超时策略:per-chunk 空闲超时(参考 new-api),每收到一个有效 chunk 就重置计时器。 +// 只要上游持续发送数据,流可以无限延续,适用于长时间思考的模型。 +func StreamScannerHandler( + ctx context.Context, + resp *http.Response, + info *common.RelayInfo, + writer http.ResponseWriter, + handler DataHandlerFunc, +) { + stopChan := make(chan bool, stopChanSize) + dataChan := make(chan string, dataChanSize) + resetChan := make(chan struct{}, resetChanSize) // 收到有效数据时通知主 goroutine 重置超时 + var writeMutex sync.Mutex + var wg sync.WaitGroup + streamTimeout := getStreamTimeout(info) + + SetEventStreamHeaders(writer) + + // Scanner 协程 + wg.Add(1) + go func() { + defer wg.Done() + defer close(dataChan) + + scanner := bufio.NewScanner(resp.Body) + buf := make([]byte, 0, scannerInitialBuf) + scanner.Buffer(buf, scannerMaxBuf) + + for scanner.Scan() { + select { + case <-stopChan: + return + case <-ctx.Done(): + info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, ctx.Err()) + return + default: + } + + line := scanner.Text() + if len(line) < minLineLength { + continue + } + + if line == "[DONE]" { + safeSendBool(stopChan) + info.StreamStatus.SetEndReason(common.StreamEndReasonDone, nil) + return + } + + if !strings.HasPrefix(line, "data:") { + continue + } + + data, ok := ExtractSSEData(line) + if !ok || data == "" { + continue + } + + if info.StreamStatus.GetEndReason() == "" { + info.SetFirstResponseTime() + } + + // 通知主 goroutine 重置空闲超时 + select { + case resetChan <- struct{}{}: + default: + } + + select { + case dataChan <- data: + case <-stopChan: + return + case <-ctx.Done(): + info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, ctx.Err()) + return + } + } + + if err := scanner.Err(); err != nil && err != io.EOF && ctx.Err() == nil { + info.StreamStatus.SetEndReason(common.StreamEndReasonScannerErr, err) + safeSendBool(stopChan) + } else if info.StreamStatus.GetEndReason() == "" { + info.StreamStatus.SetEndReason(common.StreamEndReasonEOF, nil) + safeSendBool(stopChan) + } + }() + + // DataHandler 协程 + sr := NewStreamResult(info.StreamStatus) + wg.Add(1) + go func() { + defer wg.Done() + for data := range dataChan { + sr.reset() + writeMutex.Lock() + handler(data, sr) + writeMutex.Unlock() + if sr.IsStopped() { + safeSendBool(stopChan) + return + } + } + }() + + // Ping 协程 + pingTicker := time.NewTicker(defaultPingInterval) + defer pingTicker.Stop() + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-pingTicker.C: + writeMutex.Lock() + if ctx.Err() != nil { + writeMutex.Unlock() + return + } + err := WriteSSEPing(writer) + writeMutex.Unlock() + if err != nil { + info.StreamStatus.SetEndReason(common.StreamEndReasonPingFail, err) + safeSendBool(stopChan) + return + } + case <-stopChan: + return + case <-ctx.Done(): + return + } + } + }() + + // 主 goroutine 阻塞等待 + // 使用 Timer 而非 Ticker,每次收到有效数据时 Reset,实现 per-chunk 空闲超时 + timeoutTimer := time.NewTimer(streamTimeout) + defer timeoutTimer.Stop() + + for { + select { + case <-timeoutTimer.C: + info.StreamStatus.SetEndReason(common.StreamEndReasonTimeout, + fmt.Errorf("stream idle timeout: no data received for %v", streamTimeout)) + goto shutdown + case <-stopChan: + goto shutdown + case <-ctx.Done(): + info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, ctx.Err()) + goto shutdown + case <-resetChan: + // 收到有效数据,重置空闲超时 + if !timeoutTimer.Stop() { + select { + case <-timeoutTimer.C: + default: + } + } + timeoutTimer.Reset(streamTimeout) + } + } + +shutdown: + // 等待所有协程退出 + waitDone := make(chan struct{}) + go func() { + wg.Wait() + close(waitDone) + }() + + select { + case <-waitDone: + case <-time.After(shutdownWaitTimeout): + g.Log().Warning(ctx, "StreamScanner: goroutine shutdown timeout") + } +} + +// getStreamTimeout 获取流式空闲超时时间(两个 chunk 之间的最大间隔),不低于 defaultStreamTimeout。 +func getStreamTimeout(info *common.RelayInfo) time.Duration { + if info.ChannelMeta != nil && info.ChannelMeta.Settings.TimeoutSeconds > 0 { + t := time.Duration(info.ChannelMeta.Settings.TimeoutSeconds) * time.Second + if t > defaultStreamTimeout { + return t + } + } + return defaultStreamTimeout +} + +// safeSendBool 安全发送到 stopChan(recover 避免向已关闭 channel 写入) +func safeSendBool(ch chan bool) { + defer func() { recover() }() + select { + case ch <- true: + default: + } +} diff --git a/relay/helper/system_prompt.go b/relay/helper/system_prompt.go new file mode 100644 index 0000000..749c274 --- /dev/null +++ b/relay/helper/system_prompt.go @@ -0,0 +1,227 @@ +package helper + +import ( + "encoding/json" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" +) + +// InjectSystemPrompt 在 ConvertRequest 之后、ParamOverride 之前注入系统提示词 +// 此时 body 已转换为目标供应商的原生格式 +func InjectSystemPrompt(body []byte, info *common.RelayInfo) []byte { + prompt := info.ChannelMeta.Settings.SystemPrompt + if prompt == "" { + return body + } + + nativeFormat := providerNativeFormat(info.ChannelMeta.ChannelType) + switch nativeFormat { + case constant.RelayFormatClaude: + return injectSystemPromptClaude(body, prompt, info.ChannelMeta.Settings.SystemPromptOverride) + case constant.RelayFormatGemini: + return injectSystemPromptGemini(body, prompt, info.ChannelMeta.Settings.SystemPromptOverride) + default: + return injectSystemPromptOpenAI(body, prompt, info.ChannelMeta.Settings.SystemPromptOverride) + } +} + +// injectSystemPromptOpenAI 向 OpenAI 格式请求注入系统提示词 +func injectSystemPromptOpenAI(body []byte, prompt string, override bool) []byte { + var req map[string]json.RawMessage + if err := json.Unmarshal(body, &req); err != nil { + return body + } + + messagesRaw, ok := req["messages"] + if !ok { + return body + } + + var messages []json.RawMessage + if err := json.Unmarshal(messagesRaw, &messages); err != nil { + return body + } + + systemMsg := map[string]any{"role": "system", "content": prompt} + + if len(messages) == 0 { + // 无消息,插入系统消息 + systemRaw, _ := json.Marshal(systemMsg) + messages = append([]json.RawMessage{systemRaw}, messages...) + } else { + // 检查第一条是否为 system 消息 + var first map[string]json.RawMessage + _ = json.Unmarshal(messages[0], &first) + + if roleBytes, ok := first["role"]; ok { + var role string + _ = json.Unmarshal(roleBytes, &role) + if role == "system" { + if override { + // 替换模式:用渠道系统提示词替换 + systemRaw, _ := json.Marshal(systemMsg) + messages[0] = systemRaw + } else { + // 追加模式:在原有 system content 前面插入提示词 + var contentVal any + if contentRaw, ok := first["content"]; ok { + _ = json.Unmarshal(contentRaw, &contentVal) + } + newContent := prompt + if contentVal != nil { + switch cv := contentVal.(type) { + case string: + newContent = prompt + "\n" + cv + } + } + first["content"], _ = json.Marshal(newContent) + messages[0], _ = json.Marshal(first) + } + } else { + // 第一条不是 system,在前面插入 + systemRaw, _ := json.Marshal(systemMsg) + messages = append([]json.RawMessage{systemRaw}, messages...) + } + } else { + systemRaw, _ := json.Marshal(systemMsg) + messages = append([]json.RawMessage{systemRaw}, messages...) + } + } + + req["messages"], _ = json.Marshal(messages) + result, err := json.Marshal(req) + if err != nil { + return body + } + return result +} + +// injectSystemPromptClaude 向 Claude 格式请求注入系统提示词 +func injectSystemPromptClaude(body []byte, prompt string, override bool) []byte { + var req map[string]json.RawMessage + if err := json.Unmarshal(body, &req); err != nil { + return body + } + + systemRaw, hasSystem := req["system"] + + if !hasSystem || override { + // 无 system 或 override 模式:直接设置 + req["system"] = marshalClaudeSystem(prompt) + } else { + // 追加模式:在原有 system 前面插入提示词 + req["system"] = prependClaudeSystem(systemRaw, prompt) + } + + result, err := json.Marshal(req) + if err != nil { + return body + } + return result +} + +func marshalClaudeSystem(prompt string) json.RawMessage { + // Claude system 可以是 string 或 array of content blocks + return json.RawMessage(`"` + jsonEscapeString(prompt) + `"`) +} + +func prependClaudeSystem(systemRaw json.RawMessage, prompt string) json.RawMessage { + // 尝试解析为 string + var strVal string + if err := json.Unmarshal(systemRaw, &strVal); err == nil { + newVal := prompt + "\n" + strVal + b, _ := json.Marshal(newVal) + return b + } + + // 尝试解析为 array + var arr []map[string]any + if err := json.Unmarshal(systemRaw, &arr); err == nil { + // 在第一个 text block 前面插入 + textBlock := map[string]any{"type": "text", "text": prompt + "\n"} + arr = append([]map[string]any{textBlock}, arr...) + b, _ := json.Marshal(arr) + return b + } + + // 无法解析,直接替换 + return marshalClaudeSystem(prompt) +} + +// injectSystemPromptGemini 向 Gemini 格式请求注入系统提示词 +func injectSystemPromptGemini(body []byte, prompt string, override bool) []byte { + var req map[string]json.RawMessage + if err := json.Unmarshal(body, &req); err != nil { + return body + } + + instrRaw, hasInstr := req["systemInstruction"] + + if !hasInstr || override { + req["systemInstruction"] = marshalGeminiSystemInstruction(prompt) + } else { + // 追加模式 + req["systemInstruction"] = prependGeminiSystemInstruction(instrRaw, prompt) + } + + result, err := json.Marshal(req) + if err != nil { + return body + } + return result +} + +func marshalGeminiSystemInstruction(prompt string) json.RawMessage { + instr := map[string]any{ + "parts": []map[string]any{{"text": prompt}}, + } + b, _ := json.Marshal(instr) + return b +} + +func prependGeminiSystemInstruction(instrRaw json.RawMessage, prompt string) json.RawMessage { + var instr map[string]json.RawMessage + if err := json.Unmarshal(instrRaw, &instr); err != nil { + return marshalGeminiSystemInstruction(prompt) + } + + partsRaw, ok := instr["parts"] + if !ok { + return marshalGeminiSystemInstruction(prompt) + } + + var parts []map[string]any + if err := json.Unmarshal(partsRaw, &parts); err != nil { + return marshalGeminiSystemInstruction(prompt) + } + + // 在第一个 text part 前面插入 + textPart := map[string]any{"text": prompt + "\n"} + parts = append([]map[string]any{textPart}, parts...) + instr["parts"], _ = json.Marshal(parts) + + b, _ := json.Marshal(instr) + return b +} + +// providerNativeFormat 返回供应商的原生请求格式(与 passthrough.go 中的逻辑一致) +func providerNativeFormat(providerType int) constant.RelayFormat { + switch constant.ProviderType(providerType) { + case constant.ProviderClaude: + return constant.RelayFormatClaude + case constant.ProviderGemini: + return constant.RelayFormatGemini + default: + return constant.RelayFormatOpenAI + } +} + +// jsonEscapeString 对字符串进行 JSON 转义 +func jsonEscapeString(s string) string { + b, _ := json.Marshal(s) + if len(b) >= 2 { + return string(b[1 : len(b)-1]) + } + return s +} diff --git a/relay/helper/system_prompt_test.go b/relay/helper/system_prompt_test.go new file mode 100644 index 0000000..41011c4 --- /dev/null +++ b/relay/helper/system_prompt_test.go @@ -0,0 +1,209 @@ +package helper + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" +) + +func TestInjectSystemPromptOpenAI(t *testing.T) { + tests := []struct { + name string + body string + prompt string + override bool + wantSystem string // expected system message content + wantSystems int // expected number of system messages + }{ + { + name: "prepend to empty messages", + body: `{"model":"gpt-4o","messages":[]}`, + prompt: "You are helpful.", + override: false, + wantSystem: "You are helpful.", + wantSystems: 1, + }, + { + name: "prepend to existing system message", + body: `{"model":"gpt-4o","messages":[{"role":"system","content":"Be safe."},{"role":"user","content":"Hi"}]}`, + prompt: "You are helpful.", + override: false, + wantSystem: "You are helpful.\nBe safe.", + wantSystems: 1, + }, + { + name: "override existing system message", + body: `{"model":"gpt-4o","messages":[{"role":"system","content":"Be safe."},{"role":"user","content":"Hi"}]}`, + prompt: "You are helpful.", + override: true, + wantSystem: "You are helpful.", + wantSystems: 1, + }, + { + name: "insert before non-system first message", + body: `{"model":"gpt-4o","messages":[{"role":"user","content":"Hi"}]}`, + prompt: "You are helpful.", + override: false, + wantSystem: "You are helpful.", + wantSystems: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + info := &common.RelayInfo{ + ChannelMeta: &common.ChannelMeta{ + ChannelType: int(constant.ProviderOpenAI), + Settings: common.ChannelSettings{ + SystemPrompt: tt.prompt, + SystemPromptOverride: tt.override, + }, + }, + } + result := InjectSystemPrompt([]byte(tt.body), info) + + var req map[string]json.RawMessage + if err := json.Unmarshal(result, &req); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + var messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } + if err := json.Unmarshal(req["messages"], &messages); err != nil { + t.Fatalf("invalid messages: %v", err) + } + + systemCount := 0 + for _, m := range messages { + if m.Role == "system" { + systemCount++ + if m.Content != tt.wantSystem { + t.Errorf("system content = %q, want %q", m.Content, tt.wantSystem) + } + } + } + if systemCount != tt.wantSystems { + t.Errorf("system message count = %d, want %d", systemCount, tt.wantSystems) + } + }) + } +} + +func TestInjectSystemPromptClaude(t *testing.T) { + tests := []struct { + name string + body string + prompt string + override bool + wantSystem string + }{ + { + name: "set system when nil", + body: `{"model":"claude-3","messages":[{"role":"user","content":"Hi"}],"max_tokens":1024}`, + prompt: "You are helpful.", + override: false, + wantSystem: "You are helpful.", + }, + { + name: "override existing system", + body: `{"model":"claude-3","system":"Be safe.","messages":[],"max_tokens":1024}`, + prompt: "You are helpful.", + override: true, + wantSystem: "You are helpful.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + info := &common.RelayInfo{ + ChannelMeta: &common.ChannelMeta{ + ChannelType: int(constant.ProviderClaude), + Settings: common.ChannelSettings{ + SystemPrompt: tt.prompt, + SystemPromptOverride: tt.override, + }, + }, + } + result := InjectSystemPrompt([]byte(tt.body), info) + + var req map[string]json.RawMessage + if err := json.Unmarshal(result, &req); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + var system string + if err := json.Unmarshal(req["system"], &system); err != nil { + t.Fatalf("invalid system: %v", err) + } + if system != tt.wantSystem { + t.Errorf("system = %q, want %q", system, tt.wantSystem) + } + }) + } +} + +func TestInjectSystemPromptGemini(t *testing.T) { + tests := []struct { + name string + body string + prompt string + override bool + wantInInstr bool + }{ + { + name: "set systemInstruction when nil", + body: `{"contents":[{"role":"user","parts":[{"text":"Hi"}]}]}`, + prompt: "You are helpful.", + override: false, + wantInInstr: true, + }, + { + name: "override existing systemInstruction", + body: `{"systemInstruction":{"parts":[{"text":"Be safe."}]},"contents":[]}`, + prompt: "You are helpful.", + override: true, + wantInInstr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + info := &common.RelayInfo{ + ChannelMeta: &common.ChannelMeta{ + ChannelType: int(constant.ProviderGemini), + Settings: common.ChannelSettings{ + SystemPrompt: tt.prompt, + SystemPromptOverride: tt.override, + }, + }, + } + result := InjectSystemPrompt([]byte(tt.body), info) + + if !strings.Contains(string(result), "systemInstruction") { + t.Error("expected systemInstruction in result") + } + if !strings.Contains(string(result), tt.prompt) { + t.Errorf("expected prompt %q in result", tt.prompt) + } + }) + } +} + +func TestInjectSystemPromptEmpty(t *testing.T) { + body := `{"model":"gpt-4o","messages":[]}` + info := &common.RelayInfo{ + ChannelMeta: &common.ChannelMeta{ + ChannelType: int(constant.ProviderOpenAI), + Settings: common.ChannelSettings{}, + }, + } + result := string(InjectSystemPrompt([]byte(body), info)) + if result != body { + t.Errorf("expected unchanged body when SystemPrompt is empty, got: %s", result) + } +} diff --git a/relay/helper/thinking.go b/relay/helper/thinking.go new file mode 100644 index 0000000..c263056 --- /dev/null +++ b/relay/helper/thinking.go @@ -0,0 +1,58 @@ +package helper + +import "strings" + +// ThinkingInfo 从模型名解析出的 thinking 配置 +type ThinkingInfo struct { + BaseModel string // 去除后缀的基础模型名 + IsThinking bool // 是否有 -thinking 后缀 + EffortLevel string // effort 级别:low/medium/high/xhigh/max/minimal + IsNoThinking bool // 是否有 -nothinking 后缀 +} + +// 后缀列表,按长度降序排列以避免误匹配(如 -minimal 优先于 -min) +var effortSuffixes = []struct { + suffix string + level string +}{ + {"-minimal", "minimal"}, + {"-medium", "medium"}, + {"-xhigh", "xhigh"}, + {"-high", "high"}, + {"-low", "low"}, + {"-max", "max"}, +} + +// ParseThinkingSuffix 从模型名解析 thinking 和 effort 后缀 +func ParseThinkingSuffix(modelName string) ThinkingInfo { + info := ThinkingInfo{} + + if strings.HasSuffix(modelName, "-thinking") { + info.IsThinking = true + info.BaseModel = modelName[:len(modelName)-len("-thinking")] + return info + } + + if strings.HasSuffix(modelName, "-nothinking") { + info.IsNoThinking = true + info.BaseModel = modelName[:len(modelName)-len("-nothinking")] + return info + } + + if strings.HasSuffix(modelName, "-none") { + info.IsNoThinking = true + info.BaseModel = modelName[:len(modelName)-len("-none")] + return info + } + + for _, s := range effortSuffixes { + if strings.HasSuffix(modelName, s.suffix) { + info.EffortLevel = s.level + info.BaseModel = modelName[:len(modelName)-len(s.suffix)] + return info + } + } + + info.BaseModel = modelName + return info +} diff --git a/relay/helper/thinking_test.go b/relay/helper/thinking_test.go new file mode 100644 index 0000000..3cf663f --- /dev/null +++ b/relay/helper/thinking_test.go @@ -0,0 +1,43 @@ +package helper + +import "testing" + +func TestParseThinkingSuffix(t *testing.T) { + tests := []struct { + input string + wantBase string + wantThink bool + wantEffort string + wantNoThink bool + }{ + {"gpt-4o", "gpt-4o", false, "", false}, + {"claude-3-5-sonnet-thinking", "claude-3-5-sonnet", true, "", false}, + {"gpt-4o-nothinking", "gpt-4o", false, "", true}, + {"claude-3-opus-high", "claude-3-opus", false, "high", false}, + {"gpt-4o-low", "gpt-4o", false, "low", false}, + {"gemini-2.5-pro-medium", "gemini-2.5-pro", false, "medium", false}, + {"o3-xhigh", "o3", false, "xhigh", false}, + {"o3-max", "o3", false, "max", false}, + {"gemini-2.5-flash-minimal", "gemini-2.5-flash", false, "minimal", false}, + {"claude-opus-4-20250514-thinking", "claude-opus-4-20250514", true, "", false}, + {"gpt-4o-0613", "gpt-4o-0613", false, "", false}, // not a thinking suffix + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + info := ParseThinkingSuffix(tt.input) + if info.BaseModel != tt.wantBase { + t.Errorf("BaseModel = %q, want %q", info.BaseModel, tt.wantBase) + } + if info.IsThinking != tt.wantThink { + t.Errorf("IsThinking = %v, want %v", info.IsThinking, tt.wantThink) + } + if info.EffortLevel != tt.wantEffort { + t.Errorf("EffortLevel = %q, want %q", info.EffortLevel, tt.wantEffort) + } + if info.IsNoThinking != tt.wantNoThink { + t.Errorf("IsNoThinking = %v, want %v", info.IsNoThinking, tt.wantNoThink) + } + }) + } +} diff --git a/relay/override/header.go b/relay/override/header.go new file mode 100644 index 0000000..7208794 --- /dev/null +++ b/relay/override/header.go @@ -0,0 +1,116 @@ +package override + +import ( + "net/http" + "regexp" + "strings" + + "github.com/qianfree/team-api/relay/common" +) + +// ApplyHeaderOverride 处理渠道级 Header 覆盖规则。 +// 返回最终要设置的 header map(覆盖 adaptor 的默认 header)。 +func ApplyHeaderOverride(info *common.RelayInfo) (map[string]string, error) { + headerOverride := info.ChannelMeta.Settings.HeaderOverride + if len(headerOverride) == 0 && info.RuntimeHeadersOverride == nil { + return nil, nil + } + + result := make(map[string]string) + + // 1. 先处理渠道级静态 HeaderOverride + for key, value := range headerOverride { + strVal := toString(value) + + // 通配符透传:* 表示透传所有客户端 header(排除不安全的) + if key == "*" { + if info.RequestHeaders != nil { + for h, vals := range info.RequestHeaders { + if isUnsafeHeader(h) { + continue + } + if len(vals) > 0 && vals[0] != "" { + result[h] = vals[0] + } + } + } + continue + } + + // 正则透传:re:pattern 或 regex:pattern + if strings.HasPrefix(key, "re:") || strings.HasPrefix(key, "regex:") { + pattern := key + if strings.HasPrefix(key, "regex:") { + pattern = key[6:] + } else { + pattern = key[3:] + } + re, err := regexp.Compile(pattern) + if err != nil { + continue + } + if info.RequestHeaders != nil { + for h, vals := range info.RequestHeaders { + if isUnsafeHeader(h) { + continue + } + if re.MatchString(h) && len(vals) > 0 && vals[0] != "" { + result[h] = vals[0] + } + } + } + continue + } + + // 替换占位符 + resolved := resolveHeaderPlaceholder(strVal, info) + result[key] = resolved + } + + // 2. 运行时 header 覆盖(来自 ParamOverride 中的 set_header/delete_header)优先级最高 + if info.RuntimeHeadersOverride != nil { + for k, v := range info.RuntimeHeadersOverride { + result[k] = v + } + } + + return result, nil +} + +// resolveHeaderPlaceholder 替换 header 值中的占位符 +func resolveHeaderPlaceholder(template string, info *common.RelayInfo) string { + result := template + + // {api_key} → 渠道 API Key + result = replaceAllStr(result, "{api_key}", info.ChannelMeta.ApiKey) + + // {client_header:xxx} → 客户端原始请求 header + for { + start := strings.Index(result, "{client_header:") + if start == -1 { + break + } + end := strings.Index(result[start:], "}") + if end == -1 { + break + } + end += start + headerName := result[start+len("{client_header:") : end] + clientValue := "" + if info.RequestHeaders != nil { + if vals := info.RequestHeaders.Values(headerName); len(vals) > 0 { + clientValue = vals[0] + } + } + result = result[:start] + clientValue + result[end+1:] + } + + return result +} + +// MergeHeaderOverrides 将 header override 合并到已有的 HTTP header 上 +func MergeHeaderOverrides(header http.Header, overrides map[string]string) { + for k, v := range overrides { + header.Set(k, v) + } +} diff --git a/relay/override/operations.go b/relay/override/operations.go new file mode 100644 index 0000000..80e6230 --- /dev/null +++ b/relay/override/operations.go @@ -0,0 +1,250 @@ +package override + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +func opSet(jsonStr, path string, value any, keepOrigin bool) (string, error) { + if keepOrigin { + if gjson.Get(jsonStr, path).Exists() { + return jsonStr, nil + } + } + valJSON := valueToJSON(value) + result, err := sjson.SetRaw(jsonStr, path, string(valJSON)) + if err != nil { + return jsonStr, nil // 路径不存在时静默忽略 + } + return result, nil +} + +func opDelete(jsonStr, path string) (string, error) { + result, err := sjson.Delete(jsonStr, path) + if err != nil { + return jsonStr, nil + } + return result, nil +} + +func opMove(jsonStr, from, to string) (string, error) { + val := gjson.Get(jsonStr, from) + if !val.Exists() { + return jsonStr, nil + } + result, err := sjson.Delete(jsonStr, from) + if err != nil { + return jsonStr, nil + } + result, err = sjson.SetRaw(result, to, val.Raw) + if err != nil { + return jsonStr, nil + } + return result, nil +} + +func opCopy(jsonStr, from, to string) (string, error) { + val := gjson.Get(jsonStr, from) + if !val.Exists() { + return jsonStr, nil + } + result, err := sjson.SetRaw(jsonStr, to, val.Raw) + if err != nil { + return jsonStr, nil + } + return result, nil +} + +func opAppend(jsonStr, path string, value any, keepOrigin bool) (string, error) { + if keepOrigin { + if gjson.Get(jsonStr, path).Exists() { + return jsonStr, nil + } + } + + existing := gjson.Get(jsonStr, path) + if !existing.Exists() { + return opSet(jsonStr, path, value, false) + } + + // 如果目标不存在,直接设置 + switch existing.Type { + case gjson.String: + newVal := existing.Str + fmt.Sprintf("%v", value) + return sjson.Set(jsonStr, path, newVal) + case gjson.JSON: + var arr []any + if err := json.Unmarshal([]byte(existing.Raw), &arr); err == nil { + arr = append(arr, value) + arrJSON, _ := json.Marshal(arr) + return sjson.SetRaw(jsonStr, path, string(arrJSON)) + } + case gjson.Number: + // 数字追加无意义,跳过 + return jsonStr, nil + default: + // 其他类型,设置为数组 [existing, value] + arrJSON, _ := json.Marshal([]any{existing.Value(), value}) + return sjson.SetRaw(jsonStr, path, string(arrJSON)) + } + + return jsonStr, nil +} + +func opPrepend(jsonStr, path string, value any, keepOrigin bool) (string, error) { + if keepOrigin { + if gjson.Get(jsonStr, path).Exists() { + return jsonStr, nil + } + + } + + existing := gjson.Get(jsonStr, path) + if !existing.Exists() { + return opSet(jsonStr, path, value, false) + } + + switch existing.Type { + case gjson.String: + newVal := fmt.Sprintf("%v", value) + existing.Str + return sjson.Set(jsonStr, path, newVal) + case gjson.JSON: + var arr []any + if err := json.Unmarshal([]byte(existing.Raw), &arr); err == nil { + arr = append([]any{value}, arr...) + arrJSON, _ := json.Marshal(arr) + return sjson.SetRaw(jsonStr, path, string(arrJSON)) + } + default: + arrJSON, _ := json.Marshal([]any{value, existing.Value()}) + return sjson.SetRaw(jsonStr, path, string(arrJSON)) + } + + return jsonStr, nil +} + +func opReplace(jsonStr, path string, value any) (string, error) { + m, ok := value.(map[string]any) + if !ok { + return jsonStr, nil + } + oldStr := toString(m["old"]) + newStr := toString(m["new"]) + if oldStr == "" { + return jsonStr, nil + } + + target := gjson.Get(jsonStr, path) + if !target.Exists() { + return jsonStr, nil + } + + replaced := replaceAllStr(target.Str, oldStr, newStr) + return sjson.Set(jsonStr, path, replaced) +} + +func opRegexReplace(jsonStr, path string, value any) (string, error) { + m, ok := value.(map[string]any) + if !ok { + return jsonStr, nil + } + pattern := toString(m["pattern"]) + replacement := toString(m["replacement"]) + if pattern == "" { + return jsonStr, nil + } + + re, err := regexp.Compile(pattern) + if err != nil { + return jsonStr, fmt.Errorf("invalid regex pattern %q: %w", pattern, err) + } + + target := gjson.Get(jsonStr, path) + if !target.Exists() { + return jsonStr, nil + } + + replaced := re.ReplaceAllString(target.Str, replacement) + return sjson.Set(jsonStr, path, replaced) +} + +func opToLower(jsonStr, path string) (string, error) { + target := gjson.Get(jsonStr, path) + if !target.Exists() { + return jsonStr, nil + } + if target.Type == gjson.String { + return sjson.Set(jsonStr, path, strings.ToLower(target.Str)) + } + return jsonStr, nil +} + +func opToUpper(jsonStr, path string) (string, error) { + target := gjson.Get(jsonStr, path) + if !target.Exists() { + return jsonStr, nil + } + if target.Type == gjson.String { + return sjson.Set(jsonStr, path, strings.ToUpper(target.Str)) + } + return jsonStr, nil +} + +func opTrimPrefix(jsonStr, path string, value any) (string, error) { + target := gjson.Get(jsonStr, path) + if !target.Exists() || target.Type != gjson.String { + return jsonStr, nil + } + prefix := fmt.Sprintf("%v", value) + result := strings.TrimPrefix(target.Str, prefix) + return sjson.Set(jsonStr, path, result) +} + +func opTrimSuffix(jsonStr, path string, value any) (string, error) { + target := gjson.Get(jsonStr, path) + if !target.Exists() || target.Type != gjson.String { + return jsonStr, nil + } + suffix := fmt.Sprintf("%v", value) + result := strings.TrimSuffix(target.Str, suffix) + return sjson.Set(jsonStr, path, result) +} + +func opEnsurePrefix(jsonStr, path string, value any) (string, error) { + target := gjson.Get(jsonStr, path) + if !target.Exists() || target.Type != gjson.String { + return jsonStr, nil + } + prefix := fmt.Sprintf("%v", value) + result := target.Str + if !strings.HasPrefix(result, prefix) { + result = prefix + result + } + return sjson.Set(jsonStr, path, result) +} + +func opEnsureSuffix(jsonStr, path string, value any) (string, error) { + target := gjson.Get(jsonStr, path) + if !target.Exists() || target.Type != gjson.String { + return jsonStr, nil + } + suffix := fmt.Sprintf("%v", value) + result := target.Str + if !strings.HasSuffix(result, suffix) { + result = result + suffix + } + return sjson.Set(jsonStr, path, result) +} + +func opTrimSpace(jsonStr, path string) (string, error) { + target := gjson.Get(jsonStr, path) + if !target.Exists() || target.Type != gjson.String { + return jsonStr, nil + } + return sjson.Set(jsonStr, path, strings.TrimSpace(target.Str)) +} diff --git a/relay/override/override.go b/relay/override/override.go new file mode 100644 index 0000000..c42b092 --- /dev/null +++ b/relay/override/override.go @@ -0,0 +1,613 @@ +package override + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + + "github.com/qianfree/team-api/relay/common" +) + +// Operation 请求体改写操作 +type Operation struct { + Path string `json:"path"` + Mode string `json:"mode"` + Value any `json:"value"` + KeepOrigin bool `json:"keep_origin"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + Conditions []Condition `json:"conditions,omitempty"` + Logic string `json:"logic,omitempty"` // "AND"/"OR",默认 OR +} + +// Condition 操作执行条件 +type Condition struct { + Path string `json:"path"` + Mode string `json:"mode"` // full/prefix/suffix/contains/gt/gte/lt/lte + Value any `json:"value"` + Invert bool `json:"invert"` + PassMissingKey bool `json:"pass_missing_key"` +} + +// ReturnError return_error 操作返回的错误 +type ReturnError struct { + Message string + StatusCode int + Code string + Type string + SkipRetry bool +} + +func (e *ReturnError) Error() string { + if e == nil || e.Message == "" { + return "request blocked by param override" + } + return e.Message +} + +// ApplyParamOverride 对请求体执行改写规则 +func ApplyParamOverride(body []byte, info *common.RelayInfo) ([]byte, error) { + paramOverride := info.ChannelMeta.Settings.ParamOverride + if len(paramOverride) == 0 { + return body, nil + } + + ctx := BuildOverrideContext(info) + + var result []byte + var err error + // 尝试解析为 operations 数组格式 + if operations, ok := tryParseOperations(paramOverride); ok { + result, err = applyOperations(string(body), operations, ctx) + } else { + // 兼容旧版 key-value 格式(直接设置/删除字段) + result, err = applyLegacy(body, paramOverride, ctx) + } + + if err != nil { + return nil, err + } + + // 将 ctx 中 header_override 同步回 info.RuntimeHeadersOverride + if h, ok := ctx["header_override"].(map[string]string); ok && len(h) > 0 { + if info.RuntimeHeadersOverride == nil { + info.RuntimeHeadersOverride = make(map[string]string) + } + for k, v := range h { + info.RuntimeHeadersOverride[k] = v + } + } + + return result, nil +} + +// BuildOverrideContext 构建条件求值可用的上下文变量 +func BuildOverrideContext(info *common.RelayInfo) map[string]any { + ctx := map[string]any{ + "model": info.OriginModelName, + "original_model": info.OriginModelName, + "upstream_model": info.ChannelMeta.UpstreamModelName, + "request_path": info.RequestURLPath, + "is_retry": info.RetryIndex > 0, + "retry_index": info.RetryIndex, + } + + // request_headers 转为小写 map + if info.RequestHeaders != nil { + headers := make(map[string]string) + for k, v := range info.RequestHeaders { + for _, vv := range v { + if vv != "" { + headers[k] = vv + break + } + } + } + ctx["request_headers"] = headers + } + + // 运行时 header 覆盖(来自 set_header/delete_header 操作) + if info.RuntimeHeadersOverride != nil { + ctx["header_override"] = info.RuntimeHeadersOverride + } + + return ctx +} + +// tryParseOperations 尝试从 paramOverride 中解析 operations 数组 +func tryParseOperations(paramOverride map[string]any) ([]Operation, bool) { + raw, ok := paramOverride["operations"] + if !ok { + return nil, false + } + + arr, ok := raw.([]any) + if !ok { + return nil, false + } + + var operations []Operation + for _, item := range arr { + m, ok := item.(map[string]any) + if !ok { + continue + } + op := Operation{ + Path: toString(m["path"]), + Mode: toString(m["mode"]), + Value: m["value"], + KeepOrigin: toBool(m["keep_origin"]), + From: toString(m["from"]), + To: toString(m["to"]), + Logic: toString(m["logic"]), + } + if conds, ok := m["conditions"].([]any); ok { + for _, c := range conds { + cm, ok := c.(map[string]any) + if !ok { + continue + } + op.Conditions = append(op.Conditions, Condition{ + Path: toString(cm["path"]), + Mode: toString(cm["mode"]), + Value: cm["value"], + Invert: toBool(cm["invert"]), + PassMissingKey: toBool(cm["pass_missing_key"]), + }) + } + } + if op.Mode != "" { + operations = append(operations, op) + } + } + + return operations, len(operations) > 0 +} + +// applyOperations 执行 operations 数组 +func applyOperations(jsonStr string, operations []Operation, ctx map[string]any) ([]byte, error) { + for _, op := range operations { + if !checkConditions(jsonStr, ctx, op.Conditions, op.Logic) { + continue + } + + result, err := applySingleOperation(jsonStr, op, ctx) + if err != nil { + return nil, err + } + jsonStr = result + } + return []byte(jsonStr), nil +} + +// applyLegacy 兼容旧版 key-value 格式 +func applyLegacy(body []byte, paramOverride map[string]any, ctx map[string]any) ([]byte, error) { + // 分离 operations 和 legacy key-value + legacy := make(map[string]any) + for k, v := range paramOverride { + if k == "operations" { + continue + } + legacy[k] = v + } + + if len(legacy) == 0 { + return body, nil + } + + jsonStr := string(body) + for path, value := range legacy { + result, err := sjson.SetRaw(jsonStr, path, string(valueToJSON(value))) + if err != nil { + // 路径不存在时忽略 + continue + } + jsonStr = result + } + return []byte(jsonStr), nil +} + +// applySingleOperation 执行单个操作 +func applySingleOperation(jsonStr string, op Operation, ctx map[string]any) (string, error) { + // 解析上下文变量占位符 + path := resolveContextPath(op.Path, ctx) + from := resolveContextPath(op.From, ctx) + to := resolveContextPath(op.To, ctx) + + switch op.Mode { + case "set": + return opSet(jsonStr, path, op.Value, op.KeepOrigin) + case "delete": + return opDelete(jsonStr, path) + case "move": + return opMove(jsonStr, from, to) + case "copy": + return opCopy(jsonStr, from, to) + case "append": + return opAppend(jsonStr, path, op.Value, op.KeepOrigin) + case "prepend": + return opPrepend(jsonStr, path, op.Value, op.KeepOrigin) + case "replace": + return opReplace(jsonStr, path, op.Value) + case "regex_replace": + return opRegexReplace(jsonStr, path, op.Value) + case "to_lower": + return opToLower(jsonStr, path) + case "to_upper": + return opToUpper(jsonStr, path) + case "trim_prefix": + return opTrimPrefix(jsonStr, path, op.Value) + case "trim_suffix": + return opTrimSuffix(jsonStr, path, op.Value) + case "ensure_prefix": + return opEnsurePrefix(jsonStr, path, op.Value) + case "ensure_suffix": + return opEnsureSuffix(jsonStr, path, op.Value) + case "trim_space": + return opTrimSpace(jsonStr, path) + case "return_error": + return "", opReturnError(op.Value) + case "set_header": + opSetHeader(op.Path, op.Value, ctx) + return jsonStr, nil + case "delete_header": + opDeleteHeader(op.Path, ctx) + return jsonStr, nil + case "copy_header": + opCopyHeader(op.From, op.To, ctx) + return jsonStr, nil + case "move_header": + opMoveHeader(op.From, op.To, ctx) + return jsonStr, nil + case "pass_headers": + opPassHeaders(op.Value, ctx) + return jsonStr, nil + default: + return jsonStr, fmt.Errorf("unknown override mode: %s", op.Mode) + } +} + +// checkConditions 检查所有条件是否满足 +func checkConditions(jsonStr string, ctx map[string]any, conditions []Condition, logic string) bool { + if len(conditions) == 0 { + return true + } + + isAnd := logic == "AND" + + for _, cond := range conditions { + condPath := resolveContextPath(cond.Path, ctx) + var target any + var found bool + + // 先查上下文变量 + if val, ok := ctx[condPath]; ok { + strVal := toString(val) + if strVal != "" { + target = val + found = true + } + } + if !found { + // 再查请求体 JSON + res := gjson.Get(jsonStr, condPath) + if res.Exists() { + target = res.Value() + found = true + } + } + + if !found { + if cond.PassMissingKey { + continue + } + if isAnd { + return false + } + continue + } + + matched := evaluateCondition(target, cond.Mode, cond.Value) + if cond.Invert { + matched = !matched + } + + if isAnd && !matched { + return false + } + if !isAnd && matched { + return true + } + } + + return isAnd // AND: all matched; OR: none matched +} + +// evaluateCondition 评估单个条件 +func evaluateCondition(target any, mode string, expected any) bool { + if mode == "" { + mode = "full" + } + + targetStr := fmt.Sprintf("%v", target) + expectedStr := fmt.Sprintf("%v", expected) + + switch mode { + case "full": + return targetStr == expectedStr + case "prefix": + return len(targetStr) >= len(expectedStr) && targetStr[:len(expectedStr)] == expectedStr + case "suffix": + if len(expectedStr) > len(targetStr) { + return false + } + return targetStr[len(targetStr)-len(expectedStr):] == expectedStr + case "contains": + return containsStr(targetStr, expectedStr) + case "gt": + return compareNumeric(targetStr, expectedStr) > 0 + case "gte": + return compareNumeric(targetStr, expectedStr) >= 0 + case "lt": + return compareNumeric(targetStr, expectedStr) < 0 + case "lte": + return compareNumeric(targetStr, expectedStr) <= 0 + default: + return targetStr == expectedStr + } +} + +// resolveContextPath 解析上下文变量占位符(如 {model}, {upstream_model}) +func resolveContextPath(path string, ctx map[string]any) string { + if path == "" { + return path + } + // 快速检查:如果没有 { 就不需要处理 + if len(path) < 3 { + return path + } + + result := path + for k, v := range ctx { + placeholder := "{" + k + "}" + if containsStr(result, placeholder) { + val := fmt.Sprintf("%v", v) + result = replaceAllStr(result, placeholder, val) + } + } + return result +} + +// Header 操作函数(修改 RelayInfo.RuntimeHeadersOverride) + +func opSetHeader(key string, value any, ctx map[string]any) { + headers := getRuntimeHeaders(ctx) + headers[key] = fmt.Sprintf("%v", value) +} + +func opDeleteHeader(key string, ctx map[string]any) { + headers := getRuntimeHeaders(ctx) + delete(headers, key) +} + +func opCopyHeader(from, to string, ctx map[string]any) { + headers := getRuntimeHeaders(ctx) + if v, ok := headers[from]; ok { + headers[to] = v + } +} + +func opMoveHeader(from, to string, ctx map[string]any) { + headers := getRuntimeHeaders(ctx) + if v, ok := headers[from]; ok { + headers[to] = v + delete(headers, from) + } +} + +func opPassHeaders(value any, ctx map[string]any) { + headers := getRuntimeHeaders(ctx) + // value 可以是 true 或者是具体规则 + if requestHeaders, ok := ctx["request_headers"].(map[string]string); ok { + for k, v := range requestHeaders { + if isUnsafeHeader(k) { + continue + } + headers[k] = v + } + } +} + +func getRuntimeHeaders(ctx map[string]any) map[string]string { + h, ok := ctx["header_override"].(map[string]string) + if !ok || h == nil { + h = make(map[string]string) + ctx["header_override"] = h + } + return h +} + +// --- 辅助函数 --- + +func toString(v any) string { + if v == nil { + return "" + } + s, ok := v.(string) + if ok { + return s + } + return fmt.Sprintf("%v", v) +} + +func toBool(v any) bool { + if v == nil { + return false + } + b, ok := v.(bool) + if ok { + return b + } + return false +} + +func valueToJSON(v any) json.RawMessage { + b, err := json.Marshal(v) + if err != nil { + return json.RawMessage(`null`) + } + return b +} + +func containsStr(s, substr string) bool { + return len(s) >= len(substr) && (len(substr) == 0 || containsStrImpl(s, substr)) +} + +func containsStrImpl(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +func replaceAllStr(s, old, new string) string { + result := make([]byte, 0, len(s)) + for { + idx := indexOfStr(s, old) + if idx == -1 { + result = append(result, s...) + break + } + result = append(result, s[:idx]...) + result = append(result, new...) + s = s[idx+len(old):] + } + return string(result) +} + +func indexOfStr(s, substr string) int { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} + +func compareNumeric(a, b string) int { + af, aok := parseFloat(a) + bf, bok := parseFloat(b) + if !aok || !bok { + // 退化为字符串比较 + if a < b { + return -1 + } + if a > b { + return 1 + } + return 0 + } + if af < bf { + return -1 + } + if af > bf { + return 1 + } + return 0 +} + +func parseFloat(s string) (float64, bool) { + var f float64 + n := 0 + for _, c := range s { + if c >= '0' && c <= '9' { + f = f*10 + float64(c-'0') + n++ + } else if c == '.' && n > 0 { + dec := 1.0 + for i := indexOfStr(s, ".") + 1; i < len(s); i++ { + cc := s[i] + if cc >= '0' && cc <= '9' { + f += float64(cc-'0') / dec + dec *= 10 + } else { + return 0, false + } + } + return f, true + } else { + return 0, false + } + } + return f, true +} + +func opReturnError(value any) error { + m, ok := value.(map[string]any) + if !ok { + return &ReturnError{Message: "request blocked", StatusCode: 400} + } + return &ReturnError{ + Message: toString(m["message"]), + StatusCode: parseIntDefault(toString(m["status_code"]), 400), + Code: toString(m["code"]), + Type: toString(m["type"]), + SkipRetry: toBool(m["skip_retry"]), + } +} + +func parseIntDefault(s string, def int) int { + if s == "" { + return def + } + n := 0 + for _, c := range s { + if c >= '0' && c <= '9' { + n = n*10 + int(c-'0') + } else { + return def + } + } + return n +} + +func isUnsafeHeader(key string) bool { + lower := toLowerStr(key) + switch lower { + case "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailer", "transfer-encoding", "upgrade", + "cookie", "host", "content-length", "accept-encoding", + "authorization", "x-api-key", "x-goog-api-key": + return true + } + return false +} + +func toLowerStr(s string) string { + result := make([]byte, len(s)) + for i := range s { + c := s[i] + if c >= 'A' && c <= 'Z' { + c += 'a' - 'A' + } + result[i] = c + } + return string(result) +} + +// AsReturnError 检查错误是否为 ReturnError +func AsReturnError(err error) (*ReturnError, bool) { + if err == nil { + return nil, false + } + var target *ReturnError + if errors.As(err, &target) { + return target, true + } + return nil, false +} diff --git a/relay/override/override_test.go b/relay/override/override_test.go new file mode 100644 index 0000000..9090901 --- /dev/null +++ b/relay/override/override_test.go @@ -0,0 +1,552 @@ +package override + +import ( + "testing" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + + "github.com/qianfree/team-api/relay/common" +) + +func TestApplyParamOverride_Set(t *testing.T) { + body := `{"model":"gpt-4o","temperature":0.7,"messages":[{"role":"user","content":"hello"}]}` + info := newTestInfo() + info.ChannelMeta.Settings.ParamOverride = map[string]any{ + "operations": []any{ + map[string]any{"path": "temperature", "mode": "set", "value": 0.5}, + }, + } + + result, err := ApplyParamOverride([]byte(body), info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + temp := gjson.Get(string(result), "temperature").Float() + if temp != 0.5 { + t.Errorf("expected temperature=0.5, got %v", temp) + } +} + +func TestApplyParamOverride_Delete(t *testing.T) { + body := `{"model":"gpt-4o","temperature":0.7,"stream":true}` + info := newTestInfo() + info.ChannelMeta.Settings.ParamOverride = map[string]any{ + "operations": []any{ + map[string]any{"path": "stream", "mode": "delete"}, + }, + } + + result, err := ApplyParamOverride([]byte(body), info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if gjson.Get(string(result), "stream").Exists() { + t.Error("expected stream to be deleted") + } +} + +func TestApplyParamOverride_Move(t *testing.T) { + body := `{"model":"gpt-4o","key":"value"}` + info := newTestInfo() + info.ChannelMeta.Settings.ParamOverride = map[string]any{ + "operations": []any{ + map[string]any{"from": "key", "to": "renamed_key", "mode": "move"}, + }, + } + + result, err := ApplyParamOverride([]byte(body), info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if gjson.Get(string(result), "key").Exists() { + t.Error("expected key to be removed after move") + } + if gjson.Get(string(result), "renamed_key").Str != "value" { + t.Errorf("expected renamed_key=value, got %s", gjson.Get(string(result), "renamed_key").Str) + } +} + +func TestApplyParamOverride_Copy(t *testing.T) { + body := `{"model":"gpt-4o","key":"value"}` + info := newTestInfo() + info.ChannelMeta.Settings.ParamOverride = map[string]any{ + "operations": []any{ + map[string]any{"from": "key", "to": "key_copy", "mode": "copy"}, + }, + } + + result, err := ApplyParamOverride([]byte(body), info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if gjson.Get(string(result), "key").Str != "value" { + t.Error("original key should remain") + } + if gjson.Get(string(result), "key_copy").Str != "value" { + t.Error("key_copy should equal value") + } +} + +func TestApplyParamOverride_AppendPrepend(t *testing.T) { + body := `{"model":"gpt-4o","tags":["a","b"]}` + info := newTestInfo() + + // append + info.ChannelMeta.Settings.ParamOverride = map[string]any{ + "operations": []any{ + map[string]any{"path": "tags", "mode": "append", "value": "c"}, + }, + } + result, err := ApplyParamOverride([]byte(body), info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + arr := gjson.Get(string(result), "tags").Array() + if len(arr) != 3 || arr[2].Str != "c" { + t.Errorf("expected tags=[a,b,c], got %v", arr) + } + + // prepend + body = `{"model":"gpt-4o","tags":["a","b"]}` + info.ChannelMeta.Settings.ParamOverride = map[string]any{ + "operations": []any{ + map[string]any{"path": "tags", "mode": "prepend", "value": "z"}, + }, + } + result, err = ApplyParamOverride([]byte(body), info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + arr = gjson.Get(string(result), "tags").Array() + if len(arr) != 3 || arr[0].Str != "z" { + t.Errorf("expected tags=[z,a,b], got %v", arr) + } +} + +func TestApplyParamOverride_StringOps(t *testing.T) { + body := `{"model":"gpt-4o","text":" Hello World "}` + info := newTestInfo() + + // to_lower + info.ChannelMeta.Settings.ParamOverride = map[string]any{ + "operations": []any{ + map[string]any{"path": "text", "mode": "to_lower"}, + }, + } + result, err := ApplyParamOverride([]byte(body), info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gjson.Get(string(result), "text").Str != " hello world " { + t.Errorf("expected lowercase text, got %q", gjson.Get(string(result), "text").Str) + } + + // trim_space + info.ChannelMeta.Settings.ParamOverride = map[string]any{ + "operations": []any{ + map[string]any{"path": "text", "mode": "trim_space"}, + }, + } + result, err = ApplyParamOverride([]byte(body), info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gjson.Get(string(result), "text").Str != "Hello World" { + t.Errorf("expected trimmed text, got %q", gjson.Get(string(result), "text").Str) + } +} + +func TestApplyParamOverride_Replace(t *testing.T) { + body := `{"model":"gpt-4o","prompt":"hello world"}` + info := newTestInfo() + info.ChannelMeta.Settings.ParamOverride = map[string]any{ + "operations": []any{ + map[string]any{"path": "prompt", "mode": "replace", "value": map[string]any{"old": "world", "new": "universe"}}, + }, + } + + result, err := ApplyParamOverride([]byte(body), info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gjson.Get(string(result), "prompt").Str != "hello universe" { + t.Errorf("expected 'hello universe', got %q", gjson.Get(string(result), "prompt").Str) + } +} + +func TestApplyParamOverride_EnsurePrefixSuffix(t *testing.T) { + body := `{"model":"gpt-4o","path":"/api/v1"}` + info := newTestInfo() + + // ensure_prefix — path already has /v1 prefix + info.ChannelMeta.Settings.ParamOverride = map[string]any{ + "operations": []any{ + map[string]any{"path": "path", "mode": "ensure_prefix", "value": "/api"}, + }, + } + result, err := ApplyParamOverride([]byte(body), info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gjson.Get(string(result), "path").Str != "/api/v1" { + t.Errorf("expected /api/v1 (unchanged), got %q", gjson.Get(string(result), "path").Str) + } + + // ensure_suffix + body = `{"model":"gpt-4o","path":"/api/v1"}` + info.ChannelMeta.Settings.ParamOverride = map[string]any{ + "operations": []any{ + map[string]any{"path": "path", "mode": "ensure_suffix", "value": "/chat"}, + }, + } + result, err = ApplyParamOverride([]byte(body), info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gjson.Get(string(result), "path").Str != "/api/v1/chat" { + t.Errorf("expected /api/v1/chat, got %q", gjson.Get(string(result), "path").Str) + } +} + +func TestApplyParamOverride_ReturnError(t *testing.T) { + body := `{"model":"gpt-4o"}` + info := newTestInfo() + info.ChannelMeta.Settings.ParamOverride = map[string]any{ + "operations": []any{ + map[string]any{ + "path": "model", "mode": "return_error", + "value": map[string]any{ + "message": "model not allowed", + "status_code": 403, + "skip_retry": true, + }, + }, + }, + } + + _, err := ApplyParamOverride([]byte(body), info) + if err == nil { + t.Fatal("expected error from return_error") + } + + retErr, ok := AsReturnError(err) + if !ok { + t.Fatalf("expected ReturnError, got %T: %v", err, err) + } + if retErr.StatusCode != 403 { + t.Errorf("expected status 403, got %d", retErr.StatusCode) + } + if retErr.Message != "model not allowed" { + t.Errorf("expected 'model not allowed', got %q", retErr.Message) + } + if !retErr.SkipRetry { + t.Error("expected skip_retry=true") + } +} + +func TestApplyParamOverride_Conditions(t *testing.T) { + body := `{"model":"gpt-4o","temperature":0.7}` + info := newTestInfo() + + // Condition: model prefix matches → set temperature to 0 + info.ChannelMeta.Settings.ParamOverride = map[string]any{ + "operations": []any{ + map[string]any{ + "path": "temperature", + "mode": "set", + "value": 0, + "conditions": []any{ + map[string]any{"path": "model", "mode": "prefix", "value": "o1-"}, + }, + }, + }, + } + + // Should match: model starts with "o1-" + body = `{"model":"o1-preview","temperature":0.7}` + result, err := ApplyParamOverride([]byte(body), info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gjson.Get(string(result), "temperature").Float() != 0 { + t.Errorf("expected temperature=0 (condition matched), got %v", gjson.Get(string(result), "temperature").Float()) + } + + // Should NOT match: model is "gpt-4o", not "o1-" + body = `{"model":"gpt-4o","temperature":0.7}` + result, err = ApplyParamOverride([]byte(body), info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gjson.Get(string(result), "temperature").Float() != 0.7 { + t.Errorf("expected temperature=0.7 (condition not matched), got %v", gjson.Get(string(result), "temperature").Float()) + } +} + +func TestApplyParamOverride_ContextVariables(t *testing.T) { + body := `{"model":"gpt-4o"}` + info := newTestInfo() + info.OriginModelName = "gpt-4o" + info.ChannelMeta.UpstreamModelName = "gpt-4o-turbo" + info.RetryIndex = 1 + + info.ChannelMeta.Settings.ParamOverride = map[string]any{ + "operations": []any{ + map[string]any{"path": "upstream_model_tag", "mode": "set", "value": "context variable test"}, + map[string]any{"path": "retry_tag", "mode": "set", "value": "yes"}, + }, + } + + result, err := ApplyParamOverride([]byte(body), info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Context variable in path should be resolved + if gjson.Get(string(result), "retry_tag").Str != "yes" { + t.Errorf("expected retry_tag=yes (context variable), got %q", gjson.Get(string(result), "retry_tag").Str) + } +} + +func TestApplyParamOverride_KeepOrigin(t *testing.T) { + body := `{"model":"gpt-4o","temperature":0.7}` + info := newTestInfo() + info.ChannelMeta.Settings.ParamOverride = map[string]any{ + "operations": []any{ + map[string]any{"path": "temperature", "mode": "set", "value": 0.3, "keep_origin": true}, + }, + } + + result, err := ApplyParamOverride([]byte(body), info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // KeepOrigin: temperature already exists (0.7), so should NOT be overwritten + if gjson.Get(string(result), "temperature").Float() != 0.7 { + t.Errorf("expected temperature=0.7 (keep_origin), got %v", gjson.Get(string(result), "temperature").Float()) + } +} + +func TestApplyParamOverride_SetHeader(t *testing.T) { + body := `{"model":"gpt-4o"}` + info := newTestInfo() + info.ChannelMeta.Settings.ParamOverride = map[string]any{ + "operations": []any{ + map[string]any{"path": "X-Custom", "mode": "set_header", "value": "test-value"}, + }, + } + + _, err := ApplyParamOverride([]byte(body), info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if info.RuntimeHeadersOverride == nil { + t.Fatal("expected RuntimeHeadersOverride to be set") + } + if info.RuntimeHeadersOverride["X-Custom"] != "test-value" { + t.Errorf("expected X-Custom=test-value, got %q", info.RuntimeHeadersOverride["X-Custom"]) + } +} + +func TestApplyParamOverride_Legacy(t *testing.T) { + body := `{"model":"gpt-4o","temperature":0.7}` + info := newTestInfo() + info.ChannelMeta.Settings.ParamOverride = map[string]any{ + "temperature": 0.3, + } + + result, err := ApplyParamOverride([]byte(body), info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if gjson.Get(string(result), "temperature").Float() != 0.3 { + t.Errorf("expected temperature=0.3 (legacy), got %v", gjson.Get(string(result), "temperature").Float()) + } +} + +func TestApplyParamOverride_RegexReplace(t *testing.T) { + body := `{"model":"gpt-4o","text":"hello-123-world"}` + info := newTestInfo() + info.ChannelMeta.Settings.ParamOverride = map[string]any{ + "operations": []any{ + map[string]any{"path": "text", "mode": "regex_replace", "value": map[string]any{"pattern": `\d+`, "replacement": "REDACTED"}}, + }, + } + + result, err := ApplyParamOverride([]byte(body), info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gjson.Get(string(result), "text").Str != "hello-REDACTED-world" { + t.Errorf("expected 'hello-REDACTED-world', got %q", gjson.Get(string(result), "text").Str) + } +} + +func TestCheckConditions(t *testing.T) { + tests := []struct { + name string + target any + mode string + expected any + invert bool + wantMatch bool + }{ + {"full match", "hello", "full", "hello", false, true}, + {"full no match", "hello", "full", "world", false, false}, + {"prefix match", "hello-world", "prefix", "hello", false, true}, + {"prefix no match", "hello-world", "prefix", "world", false, false}, + {"suffix match", "hello-world", "suffix", "world", false, true}, + {"contains match", "hello-world", "contains", "lo-w", false, true}, + {"gt", "5", "gt", "3", false, true}, + {"gt false", "5", "gt", "5", false, false}, + {"gte", "5", "gte", "5", false, true}, + {"lt", "3", "lt", "5", false, true}, + {"lte", "3", "lte", "3", false, true}, + {"invert", "hello", "full", "hello", true, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := evaluateCondition(tt.target, tt.mode, tt.expected) + if tt.invert { + result = !result + } + if result != tt.wantMatch { + t.Errorf("evaluateCondition(%v, %q, %v) = %v, want %v", tt.target, tt.mode, tt.expected, result, tt.wantMatch) + } + }) + } +} + +func TestResolveContextPath(t *testing.T) { + ctx := map[string]any{ + "model": "gpt-4o", + "upstream_model": "gpt-4o-turbo", + } + + tests := []struct { + input string + expected string + }{ + {"{model}", "gpt-4o"}, + {"{upstream_model}", "gpt-4o-turbo"}, + {"prefix_{model}_suffix", "prefix_gpt-4o_suffix"}, + {"no-placeholder", "no-placeholder"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := resolveContextPath(tt.input, ctx) + if result != tt.expected { + t.Errorf("resolveContextPath(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +// --- Header Override Tests --- + +func TestApplyHeaderOverride_StaticValue(t *testing.T) { + info := newTestInfo() + info.ChannelMeta.Settings.HeaderOverride = map[string]any{ + "X-Custom": "static-value", + } + + headers, err := ApplyHeaderOverride(info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if headers["X-Custom"] != "static-value" { + t.Errorf("expected X-Custom=static-value, got %q", headers["X-Custom"]) + } +} + +func TestApplyHeaderOverride_ApiKeyPlaceholder(t *testing.T) { + info := newTestInfo() + info.ChannelMeta.ApiKey = "sk-test123" + info.ChannelMeta.Settings.HeaderOverride = map[string]any{ + "Authorization": "Bearer {api_key}", + } + + headers, err := ApplyHeaderOverride(info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if headers["Authorization"] != "Bearer sk-test123" { + t.Errorf("expected Authorization='Bearer sk-test123', got %q", headers["Authorization"]) + } +} + +func TestApplyHeaderOverride_RuntimeOverride(t *testing.T) { + info := newTestInfo() + info.ChannelMeta.Settings.HeaderOverride = map[string]any{ + "X-Static": "original", + } + info.RuntimeHeadersOverride = map[string]string{ + "X-Static": "overridden", + "X-Dynamic": "from-param-override", + } + + headers, err := ApplyHeaderOverride(info) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if headers["X-Static"] != "overridden" { + t.Errorf("expected runtime override to take precedence") + } + if headers["X-Dynamic"] != "from-param-override" { + t.Errorf("expected dynamic header") + } +} + +func TestIsUnsafeHeader(t *testing.T) { + tests := []struct { + header string + unsafe bool + }{ + {"authorization", true}, + {"Authorization", true}, + {"X-Api-Key", true}, + {"cookie", true}, + {"host", true}, + {"content-length", true}, + {"x-custom", false}, + {"X-Request-Id", false}, + {"accept", false}, + } + + for _, tt := range tests { + t.Run(tt.header, func(t *testing.T) { + result := isUnsafeHeader(tt.header) + if result != tt.unsafe { + t.Errorf("isUnsafeHeader(%q) = %v, want %v", tt.header, result, tt.unsafe) + } + }) + } +} + +// --- Helper --- + +func newTestInfo() *common.RelayInfo { + return &common.RelayInfo{ + ChannelMeta: &common.ChannelMeta{ + Settings: common.ChannelSettings{}, + }, + } +} + +func init() { + // Suppress unused import warning for sjson (used by tests indirectly) + _ = sjson.SetRaw +} diff --git a/relay/scheduler/affinity.go b/relay/scheduler/affinity.go new file mode 100644 index 0000000..5099991 --- /dev/null +++ b/relay/scheduler/affinity.go @@ -0,0 +1,121 @@ +package scheduler + +import ( + "sync" + "time" +) + +const ( + // AffinityTTL 亲和性缓存默认 TTL(秒) + AffinityTTL = 1800 +) + +// affinityKey 亲和性缓存键 +type affinityKey struct { + TenantID int64 + UserID int64 + ModelName string +} + +// AffinityStore 亲和性内存缓存 +// 用户+模型 → 渠道ID 映射,避免频繁切换渠道导致上下文丢失 +type AffinityStore struct { + mu sync.RWMutex + entries map[affinityKey]*affinityEntry +} + +type affinityEntry struct { + ChannelID int64 + HitCount int + ExpiresAt time.Time +} + +// NewAffinityStore 创建亲和性缓存 +func NewAffinityStore() *AffinityStore { + return &AffinityStore{ + entries: make(map[affinityKey]*affinityEntry), + } +} + +// Get 获取亲和性渠道 +func (s *AffinityStore) Get(tenantID, userID int64, modelName string) (int64, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + key := affinityKey{TenantID: tenantID, UserID: userID, ModelName: modelName} + entry, ok := s.entries[key] + if !ok || time.Now().After(entry.ExpiresAt) { + return 0, false + } + return entry.ChannelID, true +} + +// Set 设置亲和性渠道 +func (s *AffinityStore) Set(tenantID, userID int64, modelName string, channelID int64) { + s.mu.Lock() + defer s.mu.Unlock() + + key := affinityKey{TenantID: tenantID, UserID: userID, ModelName: modelName} + if existing, ok := s.entries[key]; ok { + existing.ChannelID = channelID + existing.HitCount++ + existing.ExpiresAt = time.Now().Add(AffinityTTL * time.Second) + } else { + s.entries[key] = &affinityEntry{ + ChannelID: channelID, + HitCount: 1, + ExpiresAt: time.Now().Add(AffinityTTL * time.Second), + } + } +} + +// Delete 删除亲和性记录 +func (s *AffinityStore) Delete(tenantID, userID int64, modelName string) { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.entries, affinityKey{TenantID: tenantID, UserID: userID, ModelName: modelName}) +} + +// DeleteByChannel 删除某渠道的所有亲和性记录 +func (s *AffinityStore) DeleteByChannel(channelID int64) { + s.mu.Lock() + defer s.mu.Unlock() + + for key, entry := range s.entries { + if entry.ChannelID == channelID { + delete(s.entries, key) + } + } +} + +// CleanExpired 清理过期记录 +func (s *AffinityStore) CleanExpired() int { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + count := 0 + for key, entry := range s.entries { + if now.After(entry.ExpiresAt) { + delete(s.entries, key) + count++ + } + } + return count +} + +// Size 返回缓存条目数 +func (s *AffinityStore) Size() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.entries) +} + +// 全局亲和性缓存实例 +var globalAffinity = NewAffinityStore() + +// GetGlobalAffinity 获取全局亲和性缓存 +func GetGlobalAffinity() *AffinityStore { + return globalAffinity +} diff --git a/relay/scheduler/affinity_test.go b/relay/scheduler/affinity_test.go new file mode 100644 index 0000000..e15910e --- /dev/null +++ b/relay/scheduler/affinity_test.go @@ -0,0 +1,171 @@ +package scheduler + +import ( + "testing" + "time" + + "github.com/qianfree/team-api/relay/constant" +) + +func TestAffinityStore_SetGet(t *testing.T) { + store := NewAffinityStore() + + store.Set(1, 1, "gpt-4o", 100) + ch, ok := store.Get(1, 1, "gpt-4o") + if !ok { + t.Fatal("expected affinity to be found") + } + if ch != 100 { + t.Errorf("expected channel 100, got %d", ch) + } +} + +func TestAffinityStore_NotFound(t *testing.T) { + store := NewAffinityStore() + + _, ok := store.Get(1, 999, "nonexistent") + if ok { + t.Error("expected affinity not to be found") + } +} + +func TestAffinityStore_Expiry(t *testing.T) { + store := NewAffinityStore() + + // Manually set an expired entry + store.mu.Lock() + store.entries[affinityKey{TenantID: 1, UserID: 1, ModelName: "test"}] = &affinityEntry{ + ChannelID: 100, + ExpiresAt: time.Now().Add(-1 * time.Second), + } + store.mu.Unlock() + + _, ok := store.Get(1, 1, "test") + if ok { + t.Error("expected expired affinity not to be found") + } +} + +func TestAffinityStore_Delete(t *testing.T) { + store := NewAffinityStore() + store.Set(1, 1, "gpt-4o", 100) + store.Delete(1, 1, "gpt-4o") + + _, ok := store.Get(1, 1, "gpt-4o") + if ok { + t.Error("expected affinity to be deleted") + } +} + +func TestAffinityStore_DeleteByChannel(t *testing.T) { + store := NewAffinityStore() + store.Set(1, 1, "gpt-4o", 100) + store.Set(1, 2, "gpt-4o", 100) // same model, different user + store.Set(1, 1, "claude-3", 200) + + store.DeleteByChannel(100) + + _, ok1 := store.Get(1, 1, "gpt-4o") + _, ok2 := store.Get(1, 2, "gpt-4o") + _, ok3 := store.Get(1, 1, "claude-3") + + if ok1 || ok2 { + t.Error("expected channel 100 affinities to be deleted") + } + if !ok3 { + t.Error("expected channel 200 affinity to remain") + } +} + +func TestAffinityStore_HitCount(t *testing.T) { + store := NewAffinityStore() + + store.Set(1, 1, "gpt-4o", 100) + store.Set(1, 1, "gpt-4o", 100) // same key, should increment + + store.mu.RLock() + entry := store.entries[affinityKey{TenantID: 1, UserID: 1, ModelName: "gpt-4o"}] + store.mu.RUnlock() + + if entry.HitCount != 2 { + t.Errorf("expected hit count 2, got %d", entry.HitCount) + } +} + +func TestAffinityStore_TenantIsolation(t *testing.T) { + store := NewAffinityStore() + + // Tenant 1, User 1 → channel 100 + store.Set(1, 1, "gpt-4o", 100) + // Tenant 2, User 1 → channel 200 (same userID, different tenant) + store.Set(2, 1, "gpt-4o", 200) + + ch1, ok1 := store.Get(1, 1, "gpt-4o") + ch2, ok2 := store.Get(2, 1, "gpt-4o") + + if !ok1 || ch1 != 100 { + t.Errorf("tenant 1 expected channel 100, got %d, ok=%v", ch1, ok1) + } + if !ok2 || ch2 != 200 { + t.Errorf("tenant 2 expected channel 200, got %d, ok=%v", ch2, ok2) + } + + // Deleting tenant 1's affinity should not affect tenant 2 + store.Delete(1, 1, "gpt-4o") + _, ok1 = store.Get(1, 1, "gpt-4o") + _, ok2 = store.Get(2, 1, "gpt-4o") + if ok1 { + t.Error("expected tenant 1 affinity to be deleted") + } + if !ok2 { + t.Error("expected tenant 2 affinity to remain") + } +} + +func TestAffinityStore_CleanExpired(t *testing.T) { + store := NewAffinityStore() + + store.Set(1, 1, "active", 100) + store.Set(1, 2, "active", 200) + + // Manually add an expired entry + store.mu.Lock() + store.entries[affinityKey{TenantID: 1, UserID: 3, ModelName: "expired"}] = &affinityEntry{ + ChannelID: 300, + ExpiresAt: time.Now().Add(-1 * time.Second), + } + store.mu.Unlock() + + cleaned := store.CleanExpired() + if cleaned != 1 { + t.Errorf("expected 1 expired entry cleaned, got %d", cleaned) + } + if store.Size() != 2 { + t.Errorf("expected 2 remaining entries, got %d", store.Size()) + } +} + +func TestRetryStrategy_ShouldRetry(t *testing.T) { + tests := []struct { + name string + err error + retryCount int + maxRetries int + shouldRetry bool + }{ + {"nil error", nil, 0, 3, false}, + {"non-retryable", constant.NewRequestError("bad request", nil), 0, 3, false}, + {"retryable first attempt", constant.NewUpstreamError(500, "server error", nil), 0, 3, true}, + {"retryable at max", constant.NewUpstreamError(500, "server error", nil), 3, 3, false}, + {"429 rate limit", constant.NewUpstreamError(429, "rate limit", nil), 1, 3, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ShouldRetry(tt.err, tt.retryCount, tt.maxRetries) + if result != tt.shouldRetry { + t.Errorf("ShouldRetry() = %v, want %v", result, tt.shouldRetry) + } + }) + } +} diff --git a/relay/scheduler/retry.go b/relay/scheduler/retry.go new file mode 100644 index 0000000..0b7a895 --- /dev/null +++ b/relay/scheduler/retry.go @@ -0,0 +1,30 @@ +package scheduler + +import ( + "github.com/qianfree/team-api/relay/constant" +) + +// MaxRetries 最大重试次数 +const MaxRetries = 3 + +// RetryStrategy 重试策略 +type RetryStrategy struct { + MaxRetries int + ExcludeChannel int64 +} + +// ShouldRetry 判断是否应该重试 +func ShouldRetry(err error, retryCount int, maxRetries int) bool { + if retryCount >= maxRetries { + return false + } + return constant.IsRetryable(err) +} + +// DefaultRetryStrategy 默认重试策略 +func DefaultRetryStrategy(excludeChannelID int64) *RetryStrategy { + return &RetryStrategy{ + MaxRetries: MaxRetries, + ExcludeChannel: excludeChannelID, + } +} diff --git a/relay/scheduler/scheduler.go b/relay/scheduler/scheduler.go new file mode 100644 index 0000000..b6fd310 --- /dev/null +++ b/relay/scheduler/scheduler.go @@ -0,0 +1,166 @@ +package scheduler + +import ( + "math/rand/v2" + "sort" + + "github.com/qianfree/team-api/relay/common" +) + +// ChannelCandidate 调度候选渠道 +type ChannelCandidate struct { + ChannelID int64 + ChannelType int + ChannelName string + BaseURL string + Priority int + Weight int + HealthScore float64 + UpstreamModel string + IsModelMapped bool + Settings common.ChannelSettings +} + +// SchedulerResult 调度结果 +type SchedulerResult struct { + ChannelID int64 + ChannelType int + ChannelName string + BaseURL string + Priority int + Weight int + HealthScore float64 + UpstreamModelName string + IsModelMapped bool + Settings common.ChannelSettings +} + +// Select 从候选渠道中选择最佳渠道 +// 算法: +// 1. 排除健康度 < 20 的渠道 +// 2. 按优先级分组,取最高优先级组 +// 3. 组内按权重加权随机选择 +func Select(candidates []ChannelCandidate) *SchedulerResult { + if len(candidates) == 0 { + return nil + } + + // 排除健康度过低的渠道 + healthy := make([]ChannelCandidate, 0, len(candidates)) + for _, c := range candidates { + if c.HealthScore >= 20 { + healthy = append(healthy, c) + } + } + + // 如果全部不健康,降级使用所有渠道 + if len(healthy) == 0 { + healthy = candidates + } + + // 按优先级分组 + groups := groupByPriority(healthy) + + // 取最高优先级组 + highestPriority := groups[len(groups)-1] + if len(highestPriority) == 1 { + return candidateToResult(highestPriority[0]) + } + + // 按权重随机选择 + selected := weightedRandomSelect(highestPriority) + return candidateToResult(selected) +} + +// groupByPriority 按优先级分组,返回按优先级升序排列的组 +func groupByPriority(candidates []ChannelCandidate) [][]ChannelCandidate { + if len(candidates) == 0 { + return nil + } + + // 按优先级排序 + sorted := make([]ChannelCandidate, len(candidates)) + copy(sorted, candidates) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Priority < sorted[j].Priority + }) + + var groups [][]ChannelCandidate + currentPriority := sorted[0].Priority + currentGroup := []ChannelCandidate{sorted[0]} + + for i := 1; i < len(sorted); i++ { + if sorted[i].Priority == currentPriority { + currentGroup = append(currentGroup, sorted[i]) + } else { + groups = append(groups, currentGroup) + currentPriority = sorted[i].Priority + currentGroup = []ChannelCandidate{sorted[i]} + } + } + groups = append(groups, currentGroup) + + return groups +} + +// weightedRandomSelect 在同优先级组内按权重加权随机选择 +func weightedRandomSelect(candidates []ChannelCandidate) ChannelCandidate { + totalWeight := 0 + for _, c := range candidates { + w := c.Weight + if w <= 0 { + w = 1 + } + + // 健康度降权:50-79 权重减半,20-49 权重降到 1/4 + if c.HealthScore < 80 { + if c.HealthScore >= 50 { + w = w / 2 + } else { + w = w / 4 + } + } + totalWeight += w + } + + if totalWeight <= 0 { + return candidates[0] + } + + r := rand.IntN(totalWeight) + cumWeight := 0 + for _, c := range candidates { + w := c.Weight + if w <= 0 { + w = 1 + } + if c.HealthScore < 80 { + if c.HealthScore >= 50 { + w = w / 2 + } else { + w = w / 4 + } + } + cumWeight += w + if r < cumWeight { + return c + } + } + + return candidates[len(candidates)-1] +} + +func candidateToResult(c ChannelCandidate) *SchedulerResult { + return &SchedulerResult{ + ChannelID: c.ChannelID, + ChannelType: c.ChannelType, + ChannelName: c.ChannelName, + BaseURL: c.BaseURL, + Priority: c.Priority, + Weight: c.Weight, + HealthScore: c.HealthScore, + UpstreamModelName: c.UpstreamModel, + IsModelMapped: c.IsModelMapped, + Settings: c.Settings, + } +} diff --git a/relay/scheduler/scheduler_test.go b/relay/scheduler/scheduler_test.go new file mode 100644 index 0000000..4ab71c2 --- /dev/null +++ b/relay/scheduler/scheduler_test.go @@ -0,0 +1,148 @@ +package scheduler + +import ( + "testing" +) + +func TestSelect_Empty(t *testing.T) { + result := Select(nil) + if result != nil { + t.Error("expected nil for empty candidates") + } +} + +func TestSelect_SingleCandidate(t *testing.T) { + candidates := []ChannelCandidate{ + {ChannelID: 1, Priority: 1, Weight: 100, HealthScore: 80}, + } + result := Select(candidates) + if result == nil { + t.Fatal("expected non-nil result") + } + if result.ChannelID != 1 { + t.Errorf("expected ChannelID 1, got %d", result.ChannelID) + } +} + +func TestSelect_PriorityGrouping(t *testing.T) { + candidates := []ChannelCandidate{ + {ChannelID: 1, Priority: 1, Weight: 100, HealthScore: 80}, + {ChannelID: 2, Priority: 10, Weight: 100, HealthScore: 80}, + {ChannelID: 3, Priority: 10, Weight: 100, HealthScore: 80}, + } + + // Should always select from priority 10 group + for i := 0; i < 20; i++ { + result := Select(candidates) + if result.ChannelID != 2 && result.ChannelID != 3 { + t.Errorf("expected ChannelID 2 or 3, got %d", result.ChannelID) + } + } +} + +func TestSelect_HealthFiltering(t *testing.T) { + candidates := []ChannelCandidate{ + {ChannelID: 1, Priority: 10, Weight: 100, HealthScore: 10}, // below 20 + {ChannelID: 2, Priority: 1, Weight: 100, HealthScore: 80}, + } + + // Channel 1 has health < 20 but is only candidate in highest priority + // Should fall back to all candidates since only one healthy candidate exists + // Actually channel 2 is healthy (80 >= 20), so it should be selected + result := Select(candidates) + if result == nil { + t.Fatal("expected non-nil result") + } + if result.ChannelID != 2 { + t.Errorf("expected ChannelID 2 (healthy), got %d", result.ChannelID) + } +} + +func TestSelect_AllUnhealthy_Fallback(t *testing.T) { + candidates := []ChannelCandidate{ + {ChannelID: 1, Priority: 10, Weight: 100, HealthScore: 10}, + {ChannelID: 2, Priority: 1, Weight: 100, HealthScore: 5}, + } + + // All unhealthy, should still return the highest priority + result := Select(candidates) + if result == nil { + t.Fatal("expected non-nil result (fallback to all)") + } + if result.ChannelID != 1 { + t.Errorf("expected ChannelID 1 (highest priority fallback), got %d", result.ChannelID) + } +} + +func TestSelect_WeightedRandom(t *testing.T) { + // Channel 1 has weight 100, Channel 2 has weight 1 + // Channel 1 should be selected much more often + candidates := []ChannelCandidate{ + {ChannelID: 1, Priority: 10, Weight: 100, HealthScore: 80}, + {ChannelID: 2, Priority: 10, Weight: 1, HealthScore: 80}, + } + + counts := map[int64]int{} + for i := 0; i < 100; i++ { + result := Select(candidates) + counts[result.ChannelID]++ + } + + // Channel 1 should be selected significantly more often + if counts[1] <= counts[2] { + t.Errorf("expected channel 1 to be selected more often: ch1=%d, ch2=%d", counts[1], counts[2]) + } +} + +func TestSelect_HealthDegradation(t *testing.T) { + // Both same priority, but one has lower health (50-79 range → weight halved) + candidates := []ChannelCandidate{ + {ChannelID: 1, Priority: 10, Weight: 100, HealthScore: 80}, // full weight + {ChannelID: 2, Priority: 10, Weight: 100, HealthScore: 60}, // weight halved + } + + counts := map[int64]int{} + for i := 0; i < 100; i++ { + result := Select(candidates) + counts[result.ChannelID]++ + } + + // Channel 1 should be selected more often due to higher effective weight + if counts[1] <= counts[2] { + t.Errorf("expected healthy channel to be selected more often: ch1=%d, ch2=%d", counts[1], counts[2]) + } +} + +func TestGroupByPriority(t *testing.T) { + candidates := []ChannelCandidate{ + {Priority: 1, ChannelID: 1}, + {Priority: 3, ChannelID: 3}, + {Priority: 2, ChannelID: 2}, + {Priority: 1, ChannelID: 4}, + {Priority: 3, ChannelID: 5}, + } + + groups := groupByPriority(candidates) + + if len(groups) != 3 { + t.Fatalf("expected 3 groups, got %d", len(groups)) + } + + // Groups should be in ascending priority order + if groups[0][0].Priority != 1 { + t.Errorf("expected first group priority 1, got %d", groups[0][0].Priority) + } + if groups[1][0].Priority != 2 { + t.Errorf("expected second group priority 2, got %d", groups[1][0].Priority) + } + if groups[2][0].Priority != 3 { + t.Errorf("expected third group priority 3, got %d", groups[2][0].Priority) + } + + if len(groups[0]) != 2 { + t.Errorf("expected priority 1 group to have 2 members, got %d", len(groups[0])) + } + if len(groups[2]) != 2 { + t.Errorf("expected priority 3 group to have 2 members, got %d", len(groups[2])) + } +} diff --git a/relay/taskchannel/kling/adaptor.go b/relay/taskchannel/kling/adaptor.go new file mode 100644 index 0000000..3ce4626 --- /dev/null +++ b/relay/taskchannel/kling/adaptor.go @@ -0,0 +1,226 @@ +package kling + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/taskchannel" +) + +const channelName = "Kling" + +func init() { + taskchannel.Register(constant.ProviderKling, func() common.TaskAdaptor { + return &KlingAdaptor{} + }) +} + +type KlingAdaptor struct { + info *common.RelayInfo +} + +func (a *KlingAdaptor) Init(info *common.RelayInfo) { + a.info = info +} + +func (a *KlingAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, _ []byte) *common.TaskError { + return nil +} + +func (a *KlingAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]float64 { + ratios := map[string]float64{"base": 1.0} + var req map[string]any + if json.Unmarshal(body, &req) == nil { + // 高清模式加价 + if mode, ok := req["mode"]; ok && fmt.Sprintf("%v", mode) == "hq" { + ratios["quality"] = 2.0 + } + } + return ratios +} + +func (a *KlingAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 { + return nil +} + +func (a *KlingAdaptor) BuildRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimRight(info.ChannelMeta.BaseURL, "/") + // 根据是否有 image 参数决定 text2video 或 image2video + // 默认使用 text2video + return fmt.Sprintf("%s/v1/videos/text2video", baseURL), nil +} + +func (a *KlingAdaptor) BuildRequestHeader(header http.Header, info *common.RelayInfo) error { + apiKey := info.ChannelMeta.ApiKey + // 如果是 sk- 开头的 key,直接用 Bearer token + if strings.HasPrefix(apiKey, "sk-") { + header.Set("Authorization", "Bearer "+apiKey) + } else { + // 否则生成 JWT token(accessKey|secretKey 格式) + token := buildKlingJWT(apiKey) + header.Set("Authorization", "Bearer "+token) + } + header.Set("Content-Type", "application/json") + return nil +} + +func (a *KlingAdaptor) BuildRequestBody(_ context.Context, info *common.RelayInfo, body []byte) (io.Reader, error) { + var req map[string]any + if err := json.Unmarshal(body, &req); err != nil { + return strings.NewReader(string(body)), nil + } + + if info.ChannelMeta.UpstreamModelName != "" { + req["model_name"] = info.ChannelMeta.UpstreamModelName + req["model"] = info.ChannelMeta.UpstreamModelName + } + + // 如果没有指定 mode,默认 std + if _, ok := req["mode"]; !ok { + req["mode"] = "std" + } + // 如果没有指定 duration,默认 5 + if _, ok := req["duration"]; !ok { + req["duration"] = "5" + } + + data, _ := json.Marshal(req) + return strings.NewReader(string(data)), nil +} + +func (a *KlingAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + url, err := a.BuildRequestURL(info) + if err != nil { + return nil, err + } + req, err := http.NewRequest("POST", url, requestBody) + if err != nil { + return nil, err + } + a.BuildRequestHeader(req.Header, info) + client := &http.Client{Timeout: 120 * 1e9} + return client.Do(req) +} + +func (a *KlingAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *common.RelayInfo) (string, []byte, *common.TaskError) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", nil, &common.TaskError{StatusCode: 500, Message: "read response failed"} + } + + var result struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + TaskID string `json:"task_id"` + } `json:"data"` + } + if err := json.Unmarshal(body, &result); err != nil { + return "", body, &common.TaskError{StatusCode: 500, Message: "parse response failed"} + } + if result.Code != 0 { + return "", body, &common.TaskError{ + StatusCode: resp.StatusCode, + Message: result.Message, + ErrCode: fmt.Sprintf("%d", result.Code), + } + } + if result.Data.TaskID == "" { + return "", body, &common.TaskError{StatusCode: 500, Message: "upstream returned empty task id"} + } + return result.Data.TaskID, body, nil +} + +func (a *KlingAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) { + var data struct { + TaskID string `json:"task_id"` + TaskType string `json:"task_type"` // text2video 或 image2video + } + if err := json.Unmarshal(taskData, &data); err != nil { + return nil, fmt.Errorf("kling: invalid task data: %w", err) + } + + taskType := data.TaskType + if taskType == "" { + taskType = "text2video" + } + url := fmt.Sprintf("%s/v1/videos/%s/%s", strings.TrimRight(baseURL, "/"), taskType, data.TaskID) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + if strings.HasPrefix(apiKey, "sk-") { + req.Header.Set("Authorization", "Bearer "+apiKey) + } else { + req.Header.Set("Authorization", "Bearer "+buildKlingJWT(apiKey)) + } + + client := &http.Client{Timeout: 30 * 1e9} + return client.Do(req) +} + +func (a *KlingAdaptor) ParseTaskResult(body []byte) (*common.TaskInfo, error) { + var resp struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + TaskStatus string `json:"task_status"` + TaskStatusMsg string `json:"task_status_msg"` + TaskResult struct { + Videos []struct { + ID string `json:"id"` + URL string `json:"url"` + Duration string `json:"duration"` + } `json:"videos"` + } `json:"task_result"` + } `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("kling: parse task result: %w", err) + } + + info := &common.TaskInfo{Data: body} + + if resp.Code != 0 { + info.Status = common.TaskStatusFailure + info.FailReason = resp.Message + return info, nil + } + + switch resp.Data.TaskStatus { + case "submitted": + info.Status = common.TaskStatusSubmitted + case "processing": + info.Status = common.TaskStatusInProgress + info.Progress = "50%" + case "succeed": + info.Status = common.TaskStatusSuccess + info.Progress = "100%" + if len(resp.Data.TaskResult.Videos) > 0 { + info.ResultURL = resp.Data.TaskResult.Videos[0].URL + } + case "failed": + info.Status = common.TaskStatusFailure + info.FailReason = resp.Data.TaskStatusMsg + default: + info.Status = common.TaskStatusSubmitted + } + + return info, nil +} + +func (a *KlingAdaptor) GetModelList() []string { + return []string{"kling-v1", "kling-v1-6", "kling-v2-master"} +} + +func (a *KlingAdaptor) GetChannelName() string { + return channelName +} diff --git a/relay/taskchannel/kling/jwt.go b/relay/taskchannel/kling/jwt.go new file mode 100644 index 0000000..b13a0a1 --- /dev/null +++ b/relay/taskchannel/kling/jwt.go @@ -0,0 +1,32 @@ +package kling + +import ( + "fmt" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// buildKlingJWT 从 accessKey|secretKey 格式的 API Key 生成 Kling JWT token +func buildKlingJWT(apiKey string) string { + parts := strings.SplitN(apiKey, "|", 2) + if len(parts) != 2 { + return apiKey + } + accessKey, secretKey := parts[0], parts[1] + + now := time.Now() + claims := jwt.MapClaims{ + "iss": accessKey, + "exp": now.Add(1800 * time.Second).Unix(), + "nbf": now.Add(-5 * time.Second).Unix(), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString([]byte(secretKey)) + if err != nil { + fmt.Printf("kling jwt sign error: %v\n", err) + return apiKey + } + return signed +} diff --git a/relay/taskchannel/midjourney/adaptor.go b/relay/taskchannel/midjourney/adaptor.go new file mode 100644 index 0000000..07472e8 --- /dev/null +++ b/relay/taskchannel/midjourney/adaptor.go @@ -0,0 +1,231 @@ +package midjourney + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/taskchannel" +) + +const channelName = "Midjourney" + +func init() { + taskchannel.Register(constant.ProviderMidjourney, func() common.TaskAdaptor { + return &MjAdaptor{} + }) +} + +type MjAdaptor struct { + info *common.RelayInfo +} + +func (a *MjAdaptor) Init(info *common.RelayInfo) { + a.info = info +} + +// extractAction 从 URL 路径中提取 MJ action +func (a *MjAdaptor) extractAction() string { + if a.info == nil { + return "" + } + path := a.info.RequestURLPath + // /mj/submit/:action + parts := strings.Split(strings.TrimPrefix(path, "/mj/submit/"), "/") + if len(parts) > 0 && parts[0] != "" { + return parts[0] + } + return "imagine" +} + +func (a *MjAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, _ []byte) *common.TaskError { + action := constant.TaskAction(a.extractAction()) + if !constant.MjActions[action] { + return &common.TaskError{ + StatusCode: http.StatusBadRequest, + Message: fmt.Sprintf("invalid midjourney action: %s", action), + ErrCode: "invalid_action", + } + } + return nil +} + +func (a *MjAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, _ []byte) map[string]float64 { + ratios := map[string]float64{"base": 1.0} + action := constant.TaskAction(a.extractAction()) + switch action { + case constant.TaskActionUpscale: + ratios["action"] = 0.5 + case constant.TaskActionBlend, constant.TaskActionVariation: + ratios["action"] = 1.5 + case constant.TaskActionVideo: + ratios["action"] = 3.0 + } + return ratios +} + +func (a *MjAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 { + return nil +} + +func (a *MjAdaptor) BuildRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimRight(info.ChannelMeta.BaseURL, "/") + action := a.extractAction() + return fmt.Sprintf("%s/mj/submit/%s", baseURL, action), nil +} + +func (a *MjAdaptor) BuildRequestHeader(header http.Header, info *common.RelayInfo) error { + header.Set("mj-api-secret", info.ChannelMeta.ApiKey) + header.Set("Content-Type", "application/json") + return nil +} + +func (a *MjAdaptor) BuildRequestBody(_ context.Context, info *common.RelayInfo, body []byte) (io.Reader, error) { + return strings.NewReader(string(body)), nil +} + +func (a *MjAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + url, err := a.BuildRequestURL(info) + if err != nil { + return nil, err + } + req, err := http.NewRequest("POST", url, requestBody) + if err != nil { + return nil, err + } + a.BuildRequestHeader(req.Header, info) + client := &http.Client{Timeout: 120 * 1e9} + return client.Do(req) +} + +func (a *MjAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *common.RelayInfo) (string, []byte, *common.TaskError) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", nil, &common.TaskError{StatusCode: 500, Message: "read response failed"} + } + if resp.StatusCode != http.StatusOK { + return "", body, &common.TaskError{StatusCode: resp.StatusCode, Message: string(body)} + } + + // 尝试解析标准 MJ proxy 响应格式 + var result struct { + Code int `json:"code"` + Description string `json:"description"` + Result string `json:"result"` + } + if err := json.Unmarshal(body, &result); err != nil { + return "", body, &common.TaskError{StatusCode: 500, Message: "parse response failed"} + } + + // code 1 = success, 21 = exists, 22 = queued, 23 = queue full, 24 = sensitive word + if result.Code == 3 { + return "", body, &common.TaskError{ + StatusCode: http.StatusServiceUnavailable, + Message: "no available midjourney account", + ErrCode: "no_account", + } + } + if result.Code == 24 { + return "", body, &common.TaskError{ + StatusCode: http.StatusBadRequest, + Message: result.Description, + ErrCode: "sensitive_word", + } + } + if result.Result == "" { + return "", body, &common.TaskError{StatusCode: 500, Message: "upstream returned empty task id"} + } + + return result.Result, body, nil +} + +func (a *MjAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) { + var data struct { + UpstreamTaskID string `json:"upstream_task_id"` + } + if err := json.Unmarshal(taskData, &data); err != nil { + return nil, fmt.Errorf("mj: invalid task data: %w", err) + } + + url := fmt.Sprintf("%s/mj/task/%s/fetch", strings.TrimRight(baseURL, "/"), data.UpstreamTaskID) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("mj-api-secret", apiKey) + client := &http.Client{Timeout: 30 * 1e9} + return client.Do(req) +} + +func (a *MjAdaptor) ParseTaskResult(body []byte) (*common.TaskInfo, error) { + var resp struct { + Status string `json:"status"` + Progress string `json:"progress"` + ImageURL string `json:"imageUrl"` + FailReason string `json:"failReason"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("mj: parse task result: %w", err) + } + + info := &common.TaskInfo{ + Progress: resp.Progress, + Data: body, + } + + switch strings.ToUpper(resp.Status) { + case "NOT_START": + info.Status = common.TaskStatusNotStart + case "SUBMITTED": + info.Status = common.TaskStatusSubmitted + case "IN_PROGRESS", "QUEUED": + info.Status = common.TaskStatusInProgress + case "SUCCESS": + info.Status = common.TaskStatusSuccess + info.Progress = "100%" + info.ResultURL = resp.ImageURL + case "FAILURE": + info.Status = common.TaskStatusFailure + info.FailReason = resp.FailReason + default: + info.Status = common.TaskStatusSubmitted + } + + return info, nil +} + +func (a *MjAdaptor) GetModelList() []string { + return []string{ + "mj_imagine", "mj_describe", "mj_blend", "mj_upscale", + "mj_variation", "mj_reroll", "mj_inpaint", "mj_modal", + "mj_zoom", "mj_custom_zoom", "mj_shorten", + "mj_high_variation", "mj_low_variation", "mj_pan", + "mj_upload", "mj_video", "mj_edits", + "swap_face", + } +} + +func (a *MjAdaptor) GetChannelName() string { + return channelName +} + +// FetchImage 代理获取 MJ 图片 +func FetchImage(baseURL, apiKey, upstreamTaskID string) (*http.Response, error) { + url := fmt.Sprintf("%s/mj/image/%s", strings.TrimRight(baseURL, "/"), upstreamTaskID) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("mj-api-secret", apiKey) + client := &http.Client{Timeout: 60 * 1e9} + return client.Do(req) +} + +// unused guard +var _ = bytes.NewReader diff --git a/relay/taskchannel/registry.go b/relay/taskchannel/registry.go new file mode 100644 index 0000000..3395495 --- /dev/null +++ b/relay/taskchannel/registry.go @@ -0,0 +1,48 @@ +package taskchannel + +import ( + "fmt" + "sync" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" +) + +// AdaptorFactory 创建 TaskAdaptor 的工厂函数 +type AdaptorFactory func() common.TaskAdaptor + +var ( + registry = make(map[constant.ProviderType]AdaptorFactory) + registryMu sync.RWMutex +) + +// Register 注册任务适配器 +func Register(providerType constant.ProviderType, factory AdaptorFactory) { + registryMu.Lock() + defer registryMu.Unlock() + registry[providerType] = factory +} + +// GetAdaptor 根据供应商类型获取任务适配器 +func GetAdaptor(providerType constant.ProviderType) (common.TaskAdaptor, error) { + registryMu.RLock() + factory, ok := registry[providerType] + registryMu.RUnlock() + + if !ok { + return nil, fmt.Errorf("no task adaptor registered for provider type %d (%s)", providerType, providerType.String()) + } + return factory(), nil +} + +// GetRegisteredPlatforms 返回所有已注册的平台类型 +func GetRegisteredPlatforms() []constant.ProviderType { + registryMu.RLock() + defer registryMu.RUnlock() + + platforms := make([]constant.ProviderType, 0, len(registry)) + for pt := range registry { + platforms = append(platforms, pt) + } + return platforms +} diff --git a/relay/taskchannel/sora/adaptor.go b/relay/taskchannel/sora/adaptor.go new file mode 100644 index 0000000..88dbcc2 --- /dev/null +++ b/relay/taskchannel/sora/adaptor.go @@ -0,0 +1,184 @@ +package sora + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/taskchannel" +) + +const channelName = "Sora" + +func init() { + taskchannel.Register(constant.ProviderSora, func() common.TaskAdaptor { + return &SoraAdaptor{} + }) +} + +type SoraAdaptor struct { + info *common.RelayInfo +} + +func (a *SoraAdaptor) Init(info *common.RelayInfo) { + a.info = info +} + +func (a *SoraAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, _ []byte) *common.TaskError { + return nil +} + +func (a *SoraAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]float64 { + ratios := map[string]float64{"base": 1.0} + var req map[string]json.RawMessage + if json.Unmarshal(body, &req) == nil { + // 按秒数加价 + if secs, ok := req["seconds"]; ok { + var s string + if json.Unmarshal(secs, &s) == nil && s != "" { + if s == "10" || s == "15" || s == "20" { + ratios["duration"] = 1.5 + } + } + } + if dur, ok := req["duration"]; ok { + var d float64 + if json.Unmarshal(dur, &d) == nil && d > 10 { + ratios["duration"] = 1.5 + } + } + } + return ratios +} + +func (a *SoraAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 { + return nil +} + +func (a *SoraAdaptor) BuildRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimRight(info.ChannelMeta.BaseURL, "/") + return fmt.Sprintf("%s/v1/videos", baseURL), nil +} + +func (a *SoraAdaptor) BuildRequestHeader(header http.Header, info *common.RelayInfo) error { + header.Set("Authorization", "Bearer "+info.ChannelMeta.ApiKey) + header.Set("Content-Type", "application/json") + return nil +} + +func (a *SoraAdaptor) BuildRequestBody(_ context.Context, info *common.RelayInfo, body []byte) (io.Reader, error) { + var req map[string]any + if err := json.Unmarshal(body, &req); err != nil { + return strings.NewReader(string(body)), nil + } + // 使用上游模型名 + if info.ChannelMeta.UpstreamModelName != "" { + req["model"] = info.ChannelMeta.UpstreamModelName + } + data, _ := json.Marshal(req) + return strings.NewReader(string(data)), nil +} + +func (a *SoraAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + url, err := a.BuildRequestURL(info) + if err != nil { + return nil, err + } + req, err := http.NewRequest("POST", url, requestBody) + if err != nil { + return nil, err + } + a.BuildRequestHeader(req.Header, info) + + client := &http.Client{Timeout: 120 * 1e9 /* 120s */} + return client.Do(req) +} + +func (a *SoraAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *common.RelayInfo) (string, []byte, *common.TaskError) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", nil, &common.TaskError{StatusCode: 500, Message: "read response failed"} + } + + if resp.StatusCode != http.StatusOK { + return "", body, &common.TaskError{StatusCode: resp.StatusCode, Message: string(body)} + } + + var result struct { + ID string `json:"id"` + } + if err := json.Unmarshal(body, &result); err != nil { + return "", body, &common.TaskError{StatusCode: 500, Message: "parse response failed"} + } + if result.ID == "" { + return "", body, &common.TaskError{StatusCode: 500, Message: "upstream returned empty task id"} + } + return result.ID, body, nil +} + +func (a *SoraAdaptor) FetchTask(baseURL, apiKey string, _ []byte) (*http.Response, error) { + // Sora 的 FetchTask 需要上游任务 ID,通过 taskData 传入 + return nil, fmt.Errorf("sora: use FetchTaskByID instead") +} + +// FetchTaskByID 根据上游任务 ID 查询状态 +func (a *SoraAdaptor) FetchTaskByID(baseURL, apiKey, taskID string) (*http.Response, error) { + url := fmt.Sprintf("%s/v1/videos/%s", strings.TrimRight(baseURL, "/"), taskID) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + client := &http.Client{Timeout: 30 * 1e9} + return client.Do(req) +} + +func (a *SoraAdaptor) ParseTaskResult(body []byte) (*common.TaskInfo, error) { + var resp struct { + Status string `json:"status"` + Progress int `json:"progress"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("sora: parse task result: %w", err) + } + + info := &common.TaskInfo{ + Progress: fmt.Sprintf("%d%%", resp.Progress), + Data: body, + } + + switch resp.Status { + case "queued", "pending": + info.Status = common.TaskStatusQueued + case "processing", "in_progress": + info.Status = common.TaskStatusInProgress + case "completed": + info.Status = common.TaskStatusSuccess + info.Progress = "100%" + case "failed", "cancelled": + info.Status = common.TaskStatusFailure + if resp.Error != nil { + info.FailReason = resp.Error.Message + } + default: + info.Status = common.TaskStatusSubmitted + } + + return info, nil +} + +func (a *SoraAdaptor) GetModelList() []string { + return []string{"sora-2", "sora-2-pro"} +} + +func (a *SoraAdaptor) GetChannelName() string { + return channelName +} diff --git a/relay/taskchannel/suno/adaptor.go b/relay/taskchannel/suno/adaptor.go new file mode 100644 index 0000000..2c581e9 --- /dev/null +++ b/relay/taskchannel/suno/adaptor.go @@ -0,0 +1,193 @@ +package suno + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/taskchannel" +) + +const channelName = "Suno" + +func init() { + taskchannel.Register(constant.ProviderSuno, func() common.TaskAdaptor { + return &SunoAdaptor{} + }) +} + +type SunoAdaptor struct { + info *common.RelayInfo +} + +func (a *SunoAdaptor) Init(info *common.RelayInfo) { + a.info = info +} + +func (a *SunoAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, _ []byte) *common.TaskError { + return nil +} + +func (a *SunoAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, _ []byte) map[string]float64 { + return map[string]float64{"base": 1.0} +} + +func (a *SunoAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 { + return nil +} + +func (a *SunoAdaptor) BuildRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimRight(info.ChannelMeta.BaseURL, "/") + // action 从 RelayInfo 上下文中提取 + action := "music" + if info.RequestURLPath != "" { + parts := strings.Split(strings.TrimPrefix(info.RequestURLPath, "/suno/submit/"), "/") + if len(parts) > 0 && parts[0] != "" { + action = parts[0] + } + } + return fmt.Sprintf("%s/suno/submit/%s", baseURL, action), nil +} + +func (a *SunoAdaptor) BuildRequestHeader(header http.Header, info *common.RelayInfo) error { + header.Set("Authorization", "Bearer "+info.ChannelMeta.ApiKey) + header.Set("Content-Type", "application/json") + return nil +} + +func (a *SunoAdaptor) BuildRequestBody(_ context.Context, info *common.RelayInfo, body []byte) (io.Reader, error) { + var req map[string]any + if err := json.Unmarshal(body, &req); err != nil { + return strings.NewReader(string(body)), nil + } + // 默认使用 chirp-v3-0 + if _, ok := req["mv"]; !ok { + req["mv"] = "chirp-v3-0" + } + data, _ := json.Marshal(req) + return strings.NewReader(string(data)), nil +} + +func (a *SunoAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + url, err := a.BuildRequestURL(info) + if err != nil { + return nil, err + } + req, err := http.NewRequest("POST", url, requestBody) + if err != nil { + return nil, err + } + a.BuildRequestHeader(req.Header, info) + client := &http.Client{Timeout: 120 * 1e9} + return client.Do(req) +} + +func (a *SunoAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *common.RelayInfo) (string, []byte, *common.TaskError) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", nil, &common.TaskError{StatusCode: 500, Message: "read response failed"} + } + + if resp.StatusCode != http.StatusOK { + return "", body, &common.TaskError{StatusCode: resp.StatusCode, Message: string(body)} + } + + var result struct { + Code string `json:"code"` + Message string `json:"message"` + Data string `json:"data"` + } + if err := json.Unmarshal(body, &result); err != nil { + return "", body, &common.TaskError{StatusCode: 500, Message: "parse response failed"} + } + if result.Code != "success" { + return "", body, &common.TaskError{ + StatusCode: resp.StatusCode, + Message: result.Message, + ErrCode: result.Code, + } + } + if result.Data == "" { + return "", body, &common.TaskError{StatusCode: 500, Message: "upstream returned empty task id"} + } + return result.Data, body, nil +} + +func (a *SunoAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) { + url := fmt.Sprintf("%s/suno/fetch", strings.TrimRight(baseURL, "/")) + req, err := http.NewRequest("POST", url, bytes.NewReader(taskData)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + client := &http.Client{Timeout: 30 * 1e9} + return client.Do(req) +} + +func (a *SunoAdaptor) ParseTaskResult(body []byte) (*common.TaskInfo, error) { + var resp struct { + Code string `json:"code"` + Message string `json:"message"` + Data []struct { + TaskID string `json:"task_id"` + Status string `json:"status"` + FailReason string `json:"fail_reason"` + } `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("suno: parse task result: %w", err) + } + + if resp.Code != "success" { + return &common.TaskInfo{ + Status: common.TaskStatusFailure, + FailReason: resp.Message, + Data: body, + }, nil + } + + if len(resp.Data) == 0 { + return &common.TaskInfo{ + Status: common.TaskStatusSubmitted, + Data: body, + }, nil + } + + task := resp.Data[0] + info := &common.TaskInfo{Data: body} + + switch task.Status { + case "submitted": + info.Status = common.TaskStatusSubmitted + case "queueing": + info.Status = common.TaskStatusQueued + case "processing": + info.Status = common.TaskStatusInProgress + info.Progress = "50%" + case "success": + info.Status = common.TaskStatusSuccess + info.Progress = "100%" + case "failed": + info.Status = common.TaskStatusFailure + info.FailReason = task.FailReason + default: + info.Status = common.TaskStatusSubmitted + } + + return info, nil +} + +func (a *SunoAdaptor) GetModelList() []string { + return []string{"suno_music", "suno_lyrics"} +} + +func (a *SunoAdaptor) GetChannelName() string { + return channelName +} diff --git a/relay/taskchannel/volcengine/adaptor.go b/relay/taskchannel/volcengine/adaptor.go new file mode 100644 index 0000000..dd693f5 --- /dev/null +++ b/relay/taskchannel/volcengine/adaptor.go @@ -0,0 +1,384 @@ +package volcengine + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/taskchannel" +) + +func init() { + taskchannel.Register(constant.ProviderVolcengine, func() common.TaskAdaptor { + return &VolcengineVideoAdaptor{} + }) +} + +// ==================== 请求/响应结构体 ==================== + +// contentItem 火山引擎 content 数组项 +type contentItem struct { + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` + ImageURL *mediaURL `json:"image_url,omitempty"` + VideoURL *mediaURL `json:"video_url,omitempty"` + AudioURL *mediaURL `json:"audio_url,omitempty"` + Role string `json:"role,omitempty"` +} + +type mediaURL struct { + URL string `json:"url,omitempty"` +} + +// submitRequest 提交任务请求体 +type submitRequest struct { + Model string `json:"model"` + Content []contentItem `json:"content,omitempty"` + Resolution string `json:"resolution,omitempty"` + Ratio string `json:"ratio,omitempty"` + Duration *int `json:"duration,omitempty"` + Seed *int `json:"seed,omitempty"` + Watermark *bool `json:"watermark,omitempty"` + GenerateAudio *bool `json:"generate_audio,omitempty"` + ReturnLastFrame *bool `json:"return_last_frame,omitempty"` +} + +// submitResponse 提交任务响应 +type submitResponse struct { + ID string `json:"id"` +} + +// taskResponse 查询任务响应 +type taskResponse struct { + ID string `json:"id"` + Model string `json:"model"` + Status string `json:"status"` + Content struct { + VideoURL string `json:"video_url"` + } `json:"content"` + Seed int `json:"seed"` + Duration int `json:"duration"` + Ratio string `json:"ratio"` + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +// ==================== Adaptor 实现 ==================== + +type VolcengineVideoAdaptor struct { + info *common.RelayInfo +} + +func (a *VolcengineVideoAdaptor) Init(info *common.RelayInfo) { + a.info = info +} + +func (a *VolcengineVideoAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, body []byte) *common.TaskError { + var req map[string]any + if err := json.Unmarshal(body, &req); err != nil { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid request body"} + } + if _, ok := req["model"]; !ok { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "model is required"} + } + return nil +} + +func (a *VolcengineVideoAdaptor) EstimateBilling(_ context.Context, info *common.RelayInfo, body []byte) map[string]float64 { + ratios := map[string]float64{"base": 1.0} + + // 检测是否含视频输入,应用折扣比率 + var req map[string]any + if json.Unmarshal(body, &req) != nil { + return ratios + } + if hasVideoInput(req) { + if ratio, ok := getVideoInputRatio(info.ChannelMeta.UpstreamModelName); ok { + ratios["video_input"] = ratio + } + } + return ratios +} + +func (a *VolcengineVideoAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 { + return nil +} + +func (a *VolcengineVideoAdaptor) BuildRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimRight(info.ChannelMeta.BaseURL, "/") + return fmt.Sprintf("%s/api/v3/contents/generations/tasks", baseURL), nil +} + +func (a *VolcengineVideoAdaptor) BuildRequestHeader(header http.Header, info *common.RelayInfo) error { + header.Set("Authorization", "Bearer "+info.ChannelMeta.ApiKey) + header.Set("Content-Type", "application/json") + header.Set("Accept", "application/json") + return nil +} + +func (a *VolcengineVideoAdaptor) BuildRequestBody(_ context.Context, info *common.RelayInfo, body []byte) (io.Reader, error) { + var req map[string]any + if err := json.Unmarshal(body, &req); err != nil { + return strings.NewReader(string(body)), nil + } + + // 构建火山引擎请求 + volcReq := submitRequest{} + + // 模型名 + if info.ChannelMeta.IsModelMapped && info.ChannelMeta.UpstreamModelName != "" { + volcReq.Model = info.ChannelMeta.UpstreamModelName + } else if m, ok := req["model"].(string); ok { + volcReq.Model = m + } + + // content 数组 + volcReq.Content = []contentItem{} + + // 从 prompt 提取文本 + if prompt, ok := req["prompt"].(string); ok && prompt != "" { + volcReq.Content = append(volcReq.Content, contentItem{ + Type: "text", + Text: prompt, + }) + } + + // 从 images 数组提取图片 + if images, ok := req["images"].([]any); ok { + for _, img := range images { + if url, ok := img.(string); ok { + volcReq.Content = append(volcReq.Content, contentItem{ + Type: "image_url", + ImageURL: &mediaURL{URL: url}, + }) + } + } + } + + // 从 metadata 中提取额外参数 + if metadata, ok := req["metadata"].(map[string]any); ok { + // content 数组中的额外项(如 video_url、audio_url) + if contentRaw, ok := metadata["content"].([]any); ok { + for _, item := range contentRaw { + if itemMap, ok := item.(map[string]any); ok { + ci := contentItem{} + if t, ok := itemMap["type"].(string); ok { + ci.Type = t + } + if t, ok := itemMap["text"].(string); ok { + ci.Text = t + } + if imgURL, ok := itemMap["image_url"].(map[string]any); ok { + if u, ok := imgURL["url"].(string); ok { + ci.ImageURL = &mediaURL{URL: u} + } + } + if vidURL, ok := itemMap["video_url"].(map[string]any); ok { + if u, ok := vidURL["url"].(string); ok { + ci.VideoURL = &mediaURL{URL: u} + } + } + if audURL, ok := itemMap["audio_url"].(map[string]any); ok { + if u, ok := audURL["url"].(string); ok { + ci.AudioURL = &mediaURL{URL: u} + } + } + if ci.Type != "" || ci.Text != "" { + volcReq.Content = append(volcReq.Content, ci) + } + } + } + } + + // 其他参数 + if v, ok := metadata["resolution"].(string); ok { + volcReq.Resolution = v + } + if v, ok := metadata["ratio"].(string); ok { + volcReq.Ratio = v + } + if v, ok := metadata["duration"].(float64); ok && v > 0 { + d := int(v) + volcReq.Duration = &d + } + if v, ok := metadata["seed"].(float64); ok { + s := int(v) + volcReq.Seed = &s + } + if v, ok := metadata["watermark"].(bool); ok { + volcReq.Watermark = &v + } + if v, ok := metadata["generate_audio"].(bool); ok { + volcReq.GenerateAudio = &v + } + if v, ok := metadata["return_last_frame"].(bool); ok { + volcReq.ReturnLastFrame = &v + } + } + + // seconds 字段映射到 duration + if seconds, ok := req["seconds"].(string); ok { + if s, err := parseInt(seconds); err == nil && s > 0 { + volcReq.Duration = &s + } + } + + // 确保至少有一个 text content + hasText := false + for _, c := range volcReq.Content { + if c.Type == "text" { + hasText = true + break + } + } + if !hasText { + if prompt, ok := req["prompt"].(string); ok { + volcReq.Content = append(volcReq.Content, contentItem{Type: "text", Text: prompt}) + } + } + + data, err := json.Marshal(volcReq) + if err != nil { + return nil, fmt.Errorf("marshal volcengine request: %w", err) + } + return strings.NewReader(string(data)), nil +} + +func (a *VolcengineVideoAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + url, err := a.BuildRequestURL(info) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodPost, url, requestBody) + if err != nil { + return nil, err + } + if err := a.BuildRequestHeader(req.Header, info); err != nil { + return nil, err + } + client := &http.Client{Timeout: 120 * 1e9} + return client.Do(req) +} + +func (a *VolcengineVideoAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *common.RelayInfo) (string, []byte, *common.TaskError) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", nil, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "read response failed"} + } + + // 非成功状态码 + if resp.StatusCode != http.StatusOK { + return "", body, &common.TaskError{ + StatusCode: resp.StatusCode, + Message: string(body), + } + } + + var result submitResponse + if err := json.Unmarshal(body, &result); err != nil { + return "", body, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "parse response failed"} + } + if result.ID == "" { + return "", body, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "upstream returned empty task id"} + } + return result.ID, body, nil +} + +func (a *VolcengineVideoAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) { + var data struct { + TaskID string `json:"task_id"` + } + if err := json.Unmarshal(taskData, &data); err != nil { + return nil, fmt.Errorf("volcengine: invalid task data: %w", err) + } + + url := fmt.Sprintf("%s/api/v3/contents/generations/tasks/%s", strings.TrimRight(baseURL, "/"), data.TaskID) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 30 * 1e9} + return client.Do(req) +} + +func (a *VolcengineVideoAdaptor) ParseTaskResult(body []byte) (*common.TaskInfo, error) { + var resp taskResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("volcengine: parse task result: %w", err) + } + + info := &common.TaskInfo{Data: body} + + switch resp.Status { + case "pending", "queued": + info.Status = common.TaskStatusQueued + info.Progress = "10%" + case "processing", "running": + info.Status = common.TaskStatusInProgress + info.Progress = "50%" + case "succeeded": + info.Status = common.TaskStatusSuccess + info.Progress = "100%" + info.ResultURL = resp.Content.VideoURL + case "failed": + info.Status = common.TaskStatusFailure + info.FailReason = resp.Error.Message + default: + info.Status = common.TaskStatusInProgress + info.Progress = "30%" + } + + return info, nil +} + +func (a *VolcengineVideoAdaptor) GetModelList() []string { + return ModelList +} + +func (a *VolcengineVideoAdaptor) GetChannelName() string { + return channelName +} + +// ==================== 辅助函数 ==================== + +// hasVideoInput 检测请求是否包含视频输入 +func hasVideoInput(req map[string]any) bool { + // 检查 metadata.content 中是否有 video_url 类型 + metadata, _ := req["metadata"].(map[string]any) + if metadata == nil { + return false + } + content, _ := metadata["content"].([]any) + for _, item := range content { + if m, ok := item.(map[string]any); ok { + if m["type"] == "video_url" { + return true + } + if _, has := m["video_url"]; has { + return true + } + } + } + return false +} + +func parseInt(s string) (int, error) { + var n int + _, err := fmt.Sscanf(s, "%d", &n) + return n, err +} diff --git a/relay/taskchannel/volcengine/constants.go b/relay/taskchannel/volcengine/constants.go new file mode 100644 index 0000000..2390856 --- /dev/null +++ b/relay/taskchannel/volcengine/constants.go @@ -0,0 +1,24 @@ +package volcengine + +// Seedance 视频生成模型列表 +var ModelList = []string{ + "doubao-seedance-1-0-pro-250528", + "doubao-seedance-1-0-lite-t2v", + "doubao-seedance-1-0-lite-i2v", + "doubao-seedance-1-5-pro-251215", + "doubao-seedance-2-0-260128", + "doubao-seedance-2-0-fast-260128", +} + +const channelName = "VolcengineVideo" + +// videoInputRatioMap 视频输入折扣比率(含视频单价 / 不含视频单价) +var videoInputRatioMap = map[string]float64{ + "doubao-seedance-2-0-260128": 28.0 / 46.0, + "doubao-seedance-2-0-fast-260128": 22.0 / 37.0, +} + +func getVideoInputRatio(modelName string) (float64, bool) { + r, ok := videoInputRatioMap[modelName] + return r, ok +}