From ceddfd3d6b76dcf72aab4e563bb4aaa68afd4373 Mon Sep 17 00:00:00 2001 From: cneicy Date: Sat, 26 Sep 2026 04:49:11 +0800 Subject: [PATCH] feat: sync deployed v1.0.0-rc.38-custom.42 Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging. --- .agents/github/ISSUE.md | 42 +- .agents/github/PR.md | 49 +- .agents/rules/billing.md | 28 + .github/ISSUE_TEMPLATE/bug_report.yml | 19 +- .github/ISSUE_TEMPLATE/bug_report_en.yml | 19 +- .github/ISSUE_TEMPLATE/feature_request.yml | 27 +- .github/ISSUE_TEMPLATE/feature_request_en.yml | 27 +- .github/PULL_REQUEST_TEMPLATE.md | 12 +- .github/PULL_REQUEST_TEMPLATE/en.md | 12 +- AGENTS.md | 35 +- CLAUDE.md | 1 + CUSTOMIZATION.md | 38 +- Dockerfile.compact-uptime | 3 + Dockerfile.deployed | 3 + Dockerfile.fingerprint-runtime | 4 + Dockerfile.local-release | 31 + Dockerfile.public-pelican | 3 + Dockerfile.request-status | 15 +- THIRD-PARTY-LICENSES.md | 4 + VERSION | 2 +- build-fingerprint-backend.sh | 9 + common/advanced_custom_presets.go | 28 + common/api_type.go | 2 +- common/audio.go | 2 + common/audio_case_test.go | 54 + common/endpoint_type.go | 2 + common/json.go | 6 +- common/model.go | 27 +- common/model_test.go | 28 + common/rate-limit.go | 231 +- common/rate-limit_test.go | 150 + constant/channel.go | 18 + constant/context_key.go | 29 +- controller/astra_sampling.go | 256 + controller/astra_sampling_test.go | 37 + controller/audit.go | 34 + controller/auth_flow_test.go | 321 +- controller/auth_session_test.go | 2 +- controller/billing_option_test.go | 44 +- controller/channel-billing.go | 73 +- controller/channel.go | 146 +- controller/channel_billing_test.go | 116 + controller/channel_inference.go | 242 + controller/channel_inference_test.go | 196 + controller/channel_multi_key_test.go | 111 + controller/channel_pin_retry_test.go | 140 +- controller/channel_test_internal_test.go | 27 + controller/channel_upstream_update.go | 9 +- controller/detection.go | 498 + controller/detection_html.go | 63 + controller/detection_test.go | 171 + controller/detection_worker.go | 410 + controller/live_status_test.go | 28 + controller/log.go | 8 +- controller/login_verification.go | 36 +- controller/manxue_artwork_url.go | 27 + controller/manxue_artwork_url_test.go | 19 + controller/manxue_public.go | 64 + controller/manxue_public_test.go | 80 + controller/misc.go | 6 +- controller/model.go | 12 + controller/model_list_test.go | 40 +- controller/model_management_test.go | 537 +- controller/model_pricing_config.go | 38 + controller/oauth.go | 123 +- controller/option.go | 102 +- controller/passkey.go | 81 +- controller/passkey_test.go | 754 + controller/pelican_fallback.go | 96 + controller/pelican_fallback_test.go | 14 + controller/pelican_local_integration_test.go | 50 + controller/perf_metrics.go | 17 +- controller/relay.go | 388 +- controller/relay_attempt.go | 69 + controller/relay_attempt_test.go | 66 + controller/relay_client_error_test.go | 18 + controller/relay_task_plugin_test.go | 198 +- controller/request_policy.go | 27 + controller/responses_websocket.go | 129 + controller/responses_websocket_test.go | 1328 + controller/secure_verification.go | 19 + controller/security_enrollment_test.go | 2 +- controller/status_page.go | 84 + controller/system_task.go | 31 +- controller/system_task_handlers.go | 72 + controller/system_task_test.go | 73 + controller/task_plugin.go | 41 +- controller/task_plugin_test.go | 59 +- controller/token.go | 185 +- controller/token_routing.go | 66 + controller/token_test.go | 104 + controller/uptime_kuma.go | 229 +- controller/uptime_kuma_test.go | 222 +- controller/user.go | 15 +- controller/wechat.go | 2 +- docs/astra-response-guard.md | 19 + docs/error-log-group-custom19.md | 9 + docs/plugin-api/README.md | 14 +- docs/plugin-api/v1.d.ts | 40 +- docs/plugin-api/v1.md | 251 +- docs/plugin-api/v1.schema.json | 35 +- docs/token-routing-custom18.md | 45 + dto/channel_constraints.go | 4 + dto/task.go | 2 + i18n/keys.go | 16 +- i18n/locales/en.yaml | 9 +- i18n/locales/zh-CN.yaml | 9 +- i18n/locales/zh-TW.yaml | 9 +- main.go | 3 + middleware/auth.go | 64 +- middleware/auth_test.go | 79 + middleware/distributor.go | 180 +- middleware/distributor_test.go | 128 +- middleware/model-rate-limit.go | 28 +- middleware/model_rate_limit_test.go | 68 + middleware/task_plugin.go | 138 +- middleware/task_plugin_origin_task_test.go | 50 - middleware/task_plugin_test.go | 50 + model/ability.go | 21 +- model/account_security.go | 22 + model/channel.go | 38 +- model/channel_cache.go | 28 +- model/channel_cache_test.go | 60 + model/channel_constraint.go | 45 +- model/channel_settings_test.go | 116 + model/detection.go | 229 + model/detection_schedule.go | 61 + model/detection_schedule_test.go | 138 + model/detection_test.go | 142 + model/detection_upgrade_test.go | 51 + model/legacy_dalle_pricing.go | 37 + model/log.go | 87 +- model/log_format_test.go | 54 + model/log_other.go | 54 +- model/log_routing_group.go | 34 + model/log_routing_group_test.go | 38 + model/login_verification.go | 7 +- model/main.go | 20 + model/migration_dialector.go | 45 +- model/migration_dialector_test.go | 69 + model/model_pricing_config.go | 297 +- model/model_pricing_conversion.go | 368 + model/option.go | 51 + model/option_primary_key_migration.go | 218 + model/passkey.go | 72 +- model/passkey_option.go | 270 + model/prefill_group_migration.go | 65 +- model/prefill_group_migration_test.go | 291 +- model/pricing.go | 67 +- model/pricing_usage_schema_test.go | 158 + model/relay_attempt.go | 287 + model/relay_attempt_test.go | 225 + model/request_policy.go | 258 + model/request_policy_test.go | 122 + model/selection_trace.go | 52 + model/status_page.go | 90 + model/system_task.go | 66 +- model/system_task_test.go | 109 + model/task_plugin_channel_select_test.go | 24 + model/token.go | 102 +- model/token_astra_migration.go | 42 + model/token_auto_groups_cache_test.go | 21 + model/token_cache.go | 27 +- model/token_group_pool_test.go | 34 + model/token_routing.go | 185 + model/usedata.go | 29 +- model/user.go | 8 - model/user_authentication_test.go | 21 +- oauth/github.go | 46 + oauth/provider.go | 7 + pkg/billingexpr/billingexpr_test.go | 133 + pkg/billingexpr/compile.go | 81 +- pkg/billingexpr/detection_bound.go | 111 + pkg/billingexpr/expr.md | 196 +- pkg/billingexpr/fixed.go | 87 + pkg/billingexpr/run.go | 66 +- pkg/billingexpr/settle.go | 20 +- .../testdata/frontend_simulation.json | 423 + pkg/billingexpr/types.go | 46 +- pkg/jsplugin/engine.go | 72 +- pkg/jsplugin/engine_test.go | 327 +- pkg/jsplugin/json_state.go | 428 + pkg/jsplugin/registry.go | 282 +- pkg/jsplugin/registry_test.go | 171 + pkg/jsplugin/routing.go | 25 +- pkg/jsplugin/routing_test.go | 57 +- pkg/jsplugin/utils.go | 19 + pkg/model_fingerprint/LICENSE-ModelTrace.txt | 21 + .../LICENSE-gpt-model-check.txt | 24 + .../LICENSE-is-gpt-nerfed.txt | 21 + pkg/model_fingerprint/bank.json | 24285 ++++++++++++++++ pkg/model_fingerprint/fingerprint.go | 146 + pkg/model_fingerprint/fingerprint_test.go | 68 + pkg/model_fingerprint/response.go | 124 + pkg/model_fingerprint/scorer.js | 212 + .../testdata/astra-reference.json | 1 + pkg/perf_metrics/live_status.go | 63 + pkg/perf_metrics/live_status_test.go | 36 + pkg/perf_metrics/metrics.go | 105 +- pkg/perf_metrics/metrics_test.go | 213 + pkg/perf_metrics/outcome.go | 122 + pkg/perf_metrics/types.go | 19 +- pkg/wsmanager/wsmanager.go | 250 + pkg/wsmanager/wsmanager_test.go | 188 + plugins/alibaba_responses_test.go | 729 +- plugins/alibaba_wan3_test.go | 2 +- plugins/hailuo_responses_test.go | 69 + plugins/tasks/alibaba/plugin.js | 785 +- plugins/tasks/hailuo/plugin.js | 128 +- plugins/tasks/sora/plugin.js | 3 +- relay/channel/advancedcustom/adaptor.go | 53 +- relay/channel/advancedcustom/adaptor_test.go | 137 + relay/channel/advancedcustom/presets_test.go | 134 + relay/channel/advancedcustom/rerank.go | 46 + relay/channel/ali/adaptor_test.go | 76 + relay/channel/ali/dto.go | 2 +- relay/channel/ali/image.go | 69 +- relay/channel/ali/image_wan.go | 14 +- relay/channel/api_request.go | 90 +- relay/channel/api_request_test.go | 12 + relay/channel/claude/relay-claude.go | 17 +- relay/channel/claude/relay_responses.go | 8 +- relay/channel/gemini/relay-gemini-native.go | 3 + relay/channel/gemini/relay-gemini.go | 16 + relay/channel/gemini/relay_responses.go | 3 + relay/channel/ollama/adaptor.go | 14 + relay/channel/ollama/stream.go | 88 +- relay/channel/ollama/stream_test.go | 145 + relay/channel/openai/adaptor.go | 44 +- relay/channel/openai/chat_via_responses.go | 12 +- relay/channel/openai/helper.go | 5 +- relay/channel/openai/image_stream_test.go | 184 + relay/channel/openai/relay-openai.go | 18 +- relay/channel/openai/relay_image.go | 99 +- relay/channel/openai/relay_responses.go | 166 +- .../openai/relay_responses_billing_test.go | 127 + relay/channel/openai/responses_via_chat.go | 2 + .../openai/stream_tool_billing_test.go | 15 +- relay/channel/task/jsplugin/adaptor.go | 287 +- relay/channel/task/jsplugin/adaptor_test.go | 542 +- .../channel/task/jsplugin/performance_test.go | 190 + relay/channel/task/jsplugin/submit_stream.go | 146 + relay/chat_completions_via_responses_test.go | 54 + relay/common/relay_info.go | 73 +- relay/common/relay_info_test.go | 98 + relay/common/response_model.go | 48 + relay/common/response_model_guard.go | 358 + relay/common/stream_status.go | 169 +- relay/common/stream_status_test.go | 11 + relay/convert_request_error_test.go | 203 + relay/helper/billing_expr_request.go | 38 + relay/helper/max_tokens_bounds_test.go | 22 + relay/helper/openai_image_request_test.go | 81 + relay/helper/price.go | 54 +- relay/helper/price_test.go | 41 + relay/helper/reasoning_suffix.go | 7 +- relay/helper/stream_result.go | 11 +- relay/helper/stream_scanner.go | 58 +- relay/helper/stream_scanner_test.go | 42 +- relay/helper/valid_request.go | 56 +- relay/image_handler.go | 60 +- relay/mjproxy_handler.go | 36 +- relay/relay_attempt_test.go | 121 + relay/relay_task.go | 50 +- relay/relay_task_test.go | 173 +- relay/request_billing.go | 87 + relay/response_model_test.go | 447 + relay/responses_handler.go | 172 +- relay/responses_request.go | 87 + relay/responses_websocket.go | 939 + relay/responses_websocket_test.go | 682 + relay/websocket.go | 15 + relaykit/dto/billing_usage.go | 9 +- relaykit/dto/billing_usage_test.go | 27 + relaykit/dto/channel_settings.go | 65 +- relaykit/dto/channel_settings_test.go | 78 + relaykit/dto/claude.go | 2 + relaykit/dto/legacy_dalle_image.go | 58 + relaykit/dto/openai_compaction.go | 2 +- relaykit/dto/openai_image.go | 58 +- relaykit/dto/openai_request.go | 30 + .../dto/openai_request_zero_value_test.go | 20 + relaykit/dto/openai_response.go | 52 +- relaykit/dto/usage_merge.go | 21 +- relaykit/dto/user_settings.go | 1 + relaykit/dto/values.go | 5 + relaykit/dto/values_test.go | 47 + .../claude_default_max_tokens_test.go | 44 + .../claude_messages/to_oai_chat_req.go | 5 +- .../claude_messages/to_oai_responses_req.go | 14 +- .../internal/gemini_chat/to_oai_chat_req.go | 38 +- .../oai_chat/to_claude_messages_req.go | 4 +- .../internal/oai_chat/to_gemini_chat_req.go | 2 +- .../internal/oai_chat/to_oai_responses_req.go | 7 +- .../oai_chat/to_oai_responses_req_test.go | 13 +- .../oai_chat/to_oai_responses_resp.go | 2 +- .../oai_chat/to_oai_responses_stream_resp.go | 4 +- .../oai_responses/to_claude_messages_req.go | 4 +- .../oai_responses/to_gemini_chat_req.go | 6 +- .../internal/oai_responses/to_oai_chat_req.go | 11 +- .../oai_responses/to_oai_chat_req_test.go | 21 +- .../oai_responses/to_oai_chat_resp.go | 9 +- .../oai_responses/to_oai_chat_resp_test.go | 43 + .../internal/shared/claude/reasoning.go | 16 +- .../internal/shared/gemini/request.go | 76 +- relaykit/relayconvert/reasoning/claude.go | 40 +- relaykit/relayconvert/reasoning/gemini.go | 287 +- .../relayconvert/reasoning/gemini_test.go | 365 + relaykit/relayconvert/reasoning/intent.go | 281 +- .../relayconvert/reasoning/intent_test.go | 163 +- relaykit/relayconvert/request_compat.go | 15 +- relaykit/relayconvert/request_registry.go | 20 +- .../relayconvert/request_registry_test.go | 72 + router/api-router.go | 20 +- router/channel-router.go | 4 + router/channel_router_test.go | 16 +- router/relay-router.go | 16 + service/account_security.go | 2 +- service/astra_sampling.go | 322 + service/astra_sampling_test.go | 155 + service/authz/adapter.go | 41 +- service/authz/authz_test.go | 49 + service/authz/seed.go | 6 +- service/billing_session.go | 26 +- service/channel.go | 12 + service/channel_affinity.go | 19 +- service/channel_affinity_template_test.go | 133 + service/channel_select.go | 370 +- service/channel_select_test.go | 45 +- service/detection.go | 84 + service/detection_schedule.go | 195 + service/detection_schedule_test.go | 153 + service/detection_status.go | 249 + service/group_availability.go | 134 + service/group_pool_test.go | 153 + service/image_billing.go | 94 + service/log_info_generate.go | 52 +- service/login_verification.go | 72 +- service/midjourney.go | 34 +- service/passkey/service.go | 96 +- service/passkey/session.go | 6 + service/probe_history.go | 42 + service/probe_history_test.go | 21 + service/quota.go | 13 +- service/relay_attempt.go | 350 + service/relay_attempt_test.go | 31 + service/relay_error.go | 122 + service/relay_error_test.go | 291 + service/request_details.go | 115 + service/request_policy.go | 194 + service/responses_usage.go | 170 + service/responses_usage_test.go | 341 + service/security_verification.go | 2 +- service/system_task.go | 18 +- service/task_billing.go | 20 + service/task_polling.go | 7 +- service/text_quota.go | 40 +- service/text_quota_test.go | 274 + service/tiered_settle.go | 80 +- service/tiered_settle_test.go | 65 + service/token_routing.go | 185 + service/token_routing_test.go | 69 + service/ws_close.go | 13 + setting/billing_setting/builtin_billing.go | 5 + .../billing_setting/builtin_billing_test.go | 39 + setting/billing_setting/tiered_billing.go | 92 +- .../billing_setting/tiered_billing_test.go | 6 + .../channel_affinity_setting.go | 7 +- setting/ratio_setting/cache_ratio.go | 9 +- setting/ratio_setting/model_ratio.go | 43 +- setting/system_setting/passkey.go | 145 +- web/.oxlintrc.json | 7 + web/AGENTS.md | 31 +- web/bun.lock | 1 + web/package.json | 1 + web/pnpm-lock.yaml | 13081 +++++++++ .../oxlint/__tests__/intl-locale.test.ts | 164 + web/scripts/oxlint/intl-locale.mjs | 180 + web/src/assets/brand-icons/sglang.svg | 1 + web/src/assets/custom/icon-wan.tsx | 31 + web/src/assets/custom/wan.png | Bin 0 -> 4287 bytes web/src/components/data-table/index.ts | 1 + .../toolbar/__tests__/mobile-filter.test.tsx | 161 + .../toolbar/mobile-filter-panel.tsx | 98 + .../components/data-table/toolbar/toolbar.tsx | 35 +- web/src/components/floating-window.tsx | 477 + .../__tests__/floating-window.test.tsx | 175 + .../layout/components/app-header.tsx | 70 +- .../layout/components/chat-presets-item.tsx | 3 +- .../components/layout/components/footer.tsx | 26 +- .../layout/components/public-header.tsx | 107 +- .../layout/components/system-brand.tsx | 4 +- .../layout/config/system-settings.config.ts | 11 +- web/src/components/multi-select.tsx | 60 +- .../__tests__/option-hint.test.tsx | 53 + .../multi-select/__tests__/selection.test.tsx | 159 + web/src/components/sign-out-dialog.tsx | 7 +- .../components/ui/__tests__/combobox.test.tsx | 87 + .../components/ui/__tests__/label.test.tsx | 88 + web/src/components/ui/combobox-input.tsx | 169 +- web/src/components/ui/combobox.tsx | 10 +- web/src/components/ui/label.tsx | 20 +- web/src/components/ui/markdown.tsx | 28 +- web/src/features/about/index.tsx | 3 +- .../components/forgot-password-form.tsx | 11 +- .../auth/hooks/use-email-verification.ts | 14 +- .../features/auth/hooks/use-oauth-login.ts | 39 +- .../features/auth/otp/components/otp-form.tsx | 3 +- .../__tests__/domain-verification.test.tsx | 447 + web/src/features/auth/passkey/api.ts | 25 +- web/src/features/auth/passkey/assertion.ts | 120 + .../components/passkey-domain-selector.tsx | 113 + web/src/features/auth/passkey/types.ts | 1 + .../auth/reset-password-confirm/index.tsx | 25 +- .../__tests__/verification-flow.test.tsx | 22 +- .../features/auth/secure-verification/api.ts | 109 +- .../components/secure-verification-dialog.tsx | 13 +- .../hooks/use-secure-verification.ts | 20 +- .../auth/secure-verification/types.ts | 2 +- .../sign-in/components/user-auth-form.tsx | 100 +- .../auth/sign-up/components/sign-up-form.tsx | 20 +- web/src/features/channels/api.ts | 37 +- .../__tests__/channel-configuration.test.tsx | 2597 ++ .../__tests__/channel-quick-options.test.tsx | 311 + .../__tests__/inference-status.test.tsx | 516 + .../model-mapping-batch-dialog.test.tsx | 142 + .../__tests__/model-mapping-editor.test.tsx | 262 + .../__tests__/provider-selection.test.tsx | 867 + .../responses-websocket-setting.test.tsx | 197 + .../upstream-model-selection.test.tsx | 253 + .../components/astra-model-guard-setting.tsx | 47 + .../components/channel-plugin-extensions.tsx | 88 + .../components/channel-quick-options.tsx | 241 + .../components/channel-type-badge.tsx | 3 +- .../channels/components/channels-columns.tsx | 78 +- .../channels/components/channels-dialogs.tsx | 17 + .../channels/components/channels-provider.tsx | 1 + .../channels/components/channels-table.tsx | 88 +- .../dialogs/advanced-custom-editor-dialog.tsx | 149 +- .../dialogs/balance-query-dialog.tsx | 14 +- .../dialogs/channel-test-dialog.tsx | 27 +- .../components/dialogs/codex-usage-dialog.tsx | 8 +- .../dialogs/configure-models-dialog.tsx | 160 + .../components/dialogs/edit-tag-dialog.tsx | 41 +- .../dialogs/fetch-models-dialog.tsx | 302 +- .../dialogs/inference-status-dialog.tsx | 549 + .../dialogs/multi-key-manage-dialog.tsx | 33 +- .../dialogs/ollama-models-dialog.tsx | 21 +- .../dialogs/param-override-editor-dialog.tsx | 336 +- .../dialogs/passthrough-warning-dialog.tsx | 106 + .../dialogs/tag-batch-edit-dialog.tsx | 167 +- .../drawers/channel-configuration.tsx | 194 + .../drawers/channel-mutate-drawer.tsx | 7640 ++--- .../drawers/channel-provider-picker.tsx | 452 + .../components/model-mapping-batch-dialog.tsx | 453 + .../components/model-mapping-editor.tsx | 297 +- .../components/model-redirect-panel.tsx | 163 + .../responses-websocket-setting.tsx | 79 + .../components/upstream-model-selection.tsx | 424 + web/src/features/channels/constants.ts | 136 +- .../use-redirect-panel-placement.test.tsx | 88 + .../hooks/use-channel-key-disclosure.ts | 6 +- .../hooks/use-channel-model-discovery.ts | 84 + .../channels/hooks/use-channel-mutate-form.ts | 43 +- .../hooks/use-channel-upstream-updates.ts | 57 +- .../hooks/use-redirect-panel-placement.ts | 67 + web/src/features/channels/index.tsx | 7 +- .../lib/__tests__/header-passthrough.test.ts | 97 + .../lib/__tests__/inference-status.test.ts | 149 + .../lib/__tests__/model-mapping-rules.test.ts | 128 + .../__tests__/model-naming-patterns.test.ts | 92 + .../lib/__tests__/new-api-channel.test.ts | 58 +- .../features/channels/lib/advanced-custom.ts | 24 + .../features/channels/lib/channel-actions.ts | 121 +- .../channels/lib/channel-configuration.ts | 240 + .../channels/lib/channel-form-errors.ts | 2 + web/src/features/channels/lib/channel-form.ts | 46 +- .../channels/lib/channel-plugin-extensions.ts | 48 + .../channels/lib/channel-type-config.ts | 41 +- .../features/channels/lib/channel-utils.ts | 2 + .../channels/lib/header-passthrough.ts | 83 + web/src/features/channels/lib/index.ts | 2 + .../features/channels/lib/inference-status.ts | 140 + .../features/channels/lib/model-categories.ts | 140 +- .../channels/lib/model-mapping-rules.ts | 138 + .../channels/lib/model-naming-patterns.ts | 159 + .../channels/lib/responses-websocket.ts | 37 + web/src/features/channels/types.ts | 8 + .../chat/hooks/use-active-chat-key.ts | 11 +- .../dashboard/components/flow/flow-charts.tsx | 4 +- .../models/performance-overview.tsx | 64 +- .../overview/overview-dashboard.tsx | 10 +- .../overview/performance-health-panel.tsx | 53 +- .../components/overview/summary-cards.tsx | 13 +- .../components/overview/uptime-panel.tsx | 2 +- .../components/users/user-charts.tsx | 4 +- web/src/features/dashboard/constants.ts | 2 +- .../features/dashboard/lib/flow-selection.ts | 3 +- web/src/features/dashboard/types.ts | 55 + .../__tests__/adaptive-schedule.test.tsx | 64 + .../group-status/__tests__/detection.test.tsx | 156 + .../group-status/__tests__/metrics.test.tsx | 239 +- .../__tests__/probe-history.test.tsx | 34 + .../__tests__/status-page.test.tsx | 239 + .../group-status/adaptive-schedule.tsx | 22 + .../features/group-status/detection-panel.tsx | 297 + .../group-status/fingerprint-panel.tsx | 187 + web/src/features/group-status/index.tsx | 136 +- .../features/group-status/probe-history.tsx | 79 + .../features/group-status/status-page-api.ts | 28 + .../group-status/status-page-settings.tsx | 170 + web/src/features/group-status/status-page.tsx | 474 + .../home/hooks/use-home-page-content.ts | 6 +- .../__tests__/api-keys-mutate-drawer.test.tsx | 191 +- .../__tests__/astra-cost-notice.test.tsx | 109 + .../keys/components/api-keys-columns.tsx | 9 +- .../components/api-keys-delete-dialog.tsx | 7 +- .../api-keys-multi-delete-dialog.tsx | 9 +- .../components/api-keys-mutate-drawer.tsx | 296 +- .../keys/components/api-keys-provider.tsx | 16 +- .../keys/components/api-keys-table.tsx | 17 +- .../keys/components/astra-cost-notice.tsx | 80 + .../components/auto-group-order-editor.tsx | 6 +- .../components/data-table-row-actions.tsx | 7 +- .../components/dialogs/cc-switch-dialog.tsx | 14 +- .../keys/components/routing-pool-editor.tsx | 384 + web/src/features/keys/index.tsx | 2 + .../lib/__tests__/auto-group-form.test.ts | 12 +- web/src/features/keys/lib/api-key-form.ts | 85 +- web/src/features/keys/types.ts | 26 + .../__tests__/editor-currency.test.tsx | 534 +- .../__tests__/editor-layout.test.tsx | 137 +- .../model-pricing/__tests__/pricing.test.ts | 92 + .../__tests__/save-errors.test.tsx | 151 + web/src/features/model-pricing/api.ts | 99 +- .../model-pricing/model-pricing-panel.tsx | 231 +- .../pricing-conversion-dialog.tsx | 204 + web/src/features/model-pricing/pricing.ts | 133 +- .../models/components/deployments-table.tsx | 26 +- .../dialogs/create-deployment-drawer.tsx | 96 +- .../dialogs/extend-deployment-dialog.tsx | 47 +- .../dialogs/missing-models-dialog.tsx | 3 +- .../dialogs/model-delete-dialog.tsx | 3 +- .../prefill-group-management-dialog.tsx | 8 +- .../dialogs/rename-deployment-dialog.tsx | 11 +- .../components/dialogs/sync-wizard-dialog.tsx | 57 +- .../dialogs/update-config-dialog.tsx | 11 +- .../dialogs/vendor-mutate-dialog.tsx | 7 +- .../dialogs/vendor-operation-dialog.tsx | 3 +- .../dialogs/vendors-management-dialog.tsx | 3 +- .../dialogs/view-details-dialog.tsx | 17 +- .../components/dialogs/view-logs-dialog.tsx | 50 +- .../drawers/model-mutate-drawer.tsx | 22 +- .../drawers/prefill-group-form-drawer.tsx | 71 +- .../models/components/models-table.tsx | 32 +- .../components/vendor-linked-models.tsx | 3 +- .../models/components/vendors-table.tsx | 3 +- web/src/features/models/index.tsx | 4 +- web/src/features/models/lib/model-actions.ts | 27 +- web/src/features/models/lib/vendor-actions.ts | 10 +- web/src/features/models/vendor-api.ts | 10 +- .../__tests__/summary.test.tsx | 208 + web/src/features/performance-metrics/types.ts | 13 + web/src/features/playground/api.ts | 3 + .../playground/hooks/use-chat-handler.ts | 5 +- .../hooks/use-playground-options.ts | 15 +- .../__tests__/breakdown-tier-match.test.ts | 14 +- .../pricing/__tests__/dynamic-price.test.ts | 324 + .../pricing/__tests__/model-cards.test.tsx | 86 +- .../pricing/__tests__/task-matrix.test.ts | 79 +- .../__tests__/task-price-display.test.tsx | 335 +- .../pricing/components/cached-price-cell.tsx | 151 + .../components/dynamic-pricing-breakdown.tsx | 128 +- .../pricing/components/model-card-grid.tsx | 9 +- .../pricing/components/model-card.tsx | 155 +- .../components/model-details-performance.tsx | 123 +- .../model-details-uptime-sparkline.tsx | 45 +- .../pricing/components/model-details.tsx | 232 +- .../pricing/components/model-perf-badge.tsx | 19 +- .../pricing/components/model-price-cell.tsx | 104 +- .../pricing/components/pricing-columns.tsx | 95 +- .../pricing/components/pricing-toolbar.tsx | 2 +- .../pricing/hooks/use-billing-time.ts | 78 + .../pricing/hooks/use-pricing-data.ts | 3 +- .../lib/__tests__/billing-expression.test.ts | 511 + web/src/features/pricing/lib/billing-expr.ts | 477 +- .../pricing/lib/billing-expression/README.md | 143 + .../billing-expression/condition-display.ts | 268 + .../pricing/lib/billing-expression/display.ts | 437 + .../pricing/lib/billing-expression/parser.ts | 541 + .../pricing/lib/billing-expression/runtime.ts | 573 + .../lib/billing-expression/structure.ts | 87 + .../pricing/lib/billing-expression/types.ts | 212 + .../pricing/lib/billing-expression/visual.ts | 496 + .../pricing/lib/breakdown-tier-match.ts | 14 +- web/src/features/pricing/lib/dynamic-price.ts | 256 +- .../features/pricing/lib/plugin-pricing.ts | 96 + web/src/features/pricing/lib/task-expr.ts | 108 +- .../pricing/lib/task-price-display.ts | 9 + web/src/features/pricing/lib/tier-expr.ts | 111 +- web/src/features/pricing/types.ts | 12 + .../profile/__tests__/settings.test.tsx | 2 + .../components/checkin-calendar-card.tsx | 10 +- .../components/language-preferences-card.tsx | 25 +- .../components/sidebar-modules-card.tsx | 7 +- .../components/tabs/notification-tab.tsx | 7 +- web/src/features/profile/hooks/use-profile.ts | 20 +- web/src/features/profile/lib/user-settings.ts | 1 + web/src/features/profile/types.ts | 3 + .../features/rankings/hooks/use-rankings.ts | 4 +- .../components/__tests__/bulk-delete.test.tsx | 4 +- .../redemptions-mutate-drawer.test.tsx | 32 +- .../components/data-table-bulk-actions.tsx | 7 +- .../components/data-table-row-actions.tsx | 21 +- .../components/redemptions-delete-dialog.tsx | 5 + .../components/redemptions-mutate-drawer.tsx | 8 +- .../redemptions-primary-buttons.tsx | 3 +- .../components/redemptions-table.tsx | 17 +- .../__tests__/account-security.test.tsx | 35 +- .../security/__tests__/enrollment.test.tsx | 26 +- .../__tests__/privacy-card.test.tsx | 2 +- .../dialogs/telegram-bind-dialog.tsx | 9 +- .../components/login-sessions-card.tsx | 15 +- .../security/components/passkey-card.tsx | 5 +- .../security/components/privacy-card.tsx | 7 +- .../security/hooks/use-access-token.ts | 3 +- .../security/hooks/use-account-security.ts | 3 +- .../security/hooks/use-two-fa-setup.ts | 7 +- web/src/features/setup/setup-wizard.tsx | 15 +- .../dialogs/reset-subscriptions-dialog.tsx | 7 +- .../dialogs/subscription-purchase-dialog.tsx | 74 +- .../dialogs/toggle-status-dialog.tsx | 7 +- .../dialogs/user-subscriptions-dialog.tsx | 52 +- .../subscriptions-mutate-drawer.tsx | 115 +- .../components/subscriptions-table.tsx | 3 +- .../__tests__/system-tasks-panel.test.tsx | 276 + web/src/features/system-info/api.ts | 12 + .../components/system-instances-panel.tsx | 12 +- .../components/system-task-history.tsx | 240 + .../components/system-tasks-panel.tsx | 246 +- .../components/system-tasks-table.tsx | 177 + web/src/features/system-info/constants.ts | 27 + web/src/features/system-info/types.ts | 6 + web/src/features/system-settings/api.ts | 24 +- .../auth/__tests__/passkey-settings.test.tsx | 643 + .../hooks/use-custom-oauth-mutations.ts | 20 +- .../hooks/use-custom-oauth-providers.ts | 4 +- .../features/system-settings/auth/index.tsx | 1 + .../system-settings/auth/oauth-section.tsx | 6 +- .../system-settings/auth/passkey-section.tsx | 529 +- .../system-settings/auth/section-registry.tsx | 1 + .../system-settings/billing/index.tsx | 1 + .../billing/section-registry.tsx | 1 + .../components/settings-accordion.tsx | 6 +- .../components/settings-page.tsx | 14 +- .../content/announcements-section.tsx | 9 +- .../content/api-info-section.tsx | 9 +- .../system-settings/content/faq-section.tsx | 9 +- .../content/section-registry.tsx | 12 +- .../content/uptime-kuma-section.tsx | 9 +- .../channel-affinity/cache-stats-dialog.tsx | 48 +- .../general/channel-affinity/index.tsx | 778 +- .../channel-affinity/rule-editor-dialog.tsx | 62 +- .../channel-affinity/session-rules-table.tsx | 298 + .../general/channel-affinity/types.ts | 4 + .../hooks/use-system-options.ts | 4 +- .../hooks/use-update-option.ts | 48 +- .../integrations/payment-settings-section.tsx | 16 +- .../waffo-pancake-settings-section.tsx | 52 +- .../integrations/waffo-settings-section.tsx | 2 +- .../maintenance/log-settings-section.tsx | 22 +- .../maintenance/performance-section.tsx | 29 +- .../maintenance/update-checker-section.tsx | 178 +- .../models/__tests__/plugin-pricing.test.tsx | 413 + .../__tests__/request-simulation.test.tsx | 237 + .../__tests__/task-pricing-copy.test.tsx | 187 +- .../__tests__/visual-billing-editor.test.tsx | 982 + .../models/billing-time-fields.tsx | 220 + .../models/draft-number-input.tsx | 116 + .../features/system-settings/models/index.tsx | 19 +- .../models/model-pricing-core.ts | 114 +- .../models/model-pricing-sheet.tsx | 749 +- .../models/model-pricing-snapshots.ts | 28 +- .../models/model-ratio-form.tsx | 22 +- .../models/model-ratio-visual-editor.tsx | 42 +- .../models/ratio-settings-card.tsx | 10 +- .../models/request-simulation.tsx | 388 + .../models/section-registry.tsx | 52 +- .../models/task-plugin-pricing-editor.tsx | 285 + .../models/task-pricing-matrix.tsx | 7 +- .../models/task-usage-pricing-editor.tsx | 185 +- .../models/tier-price-fields.tsx | 289 + .../models/tiered-pricing-editor.tsx | 996 +- .../models/upstream-ratio-sync.tsx | 25 +- .../models/visual-billing-document-editor.tsx | 402 + .../models/visual-condition-tree.css | 139 + .../models/visual-condition-tree.tsx | 476 + .../__tests__/channel-health-layout.test.tsx | 101 + .../__tests__/routing.test.tsx | 797 + .../__tests__/settings.test.tsx | 368 + .../system-settings/request-policies/api.ts | 44 + .../channel-health-section.tsx} | 442 +- .../request-policies/decision-record.tsx | 91 + .../request-policies/defaults.ts | 72 + .../request-policies/index.tsx | 36 + .../request-policies/policy-label.ts | 108 + .../request-policies/related-policy-link.tsx | 55 + .../request-checks-section.tsx} | 103 +- .../request-policies/retry-section.tsx | 86 + .../request-policies/routing-form.ts | 82 + .../request-policies/routing-section.tsx | 329 + .../request-policies/section-registry.tsx | 63 + .../request-policies/use-save-policy.ts | 23 + .../security/section-registry.tsx | 15 +- web/src/features/system-settings/types.ts | 57 +- .../system-update/__tests__/releases.test.ts | 73 + .../__tests__/update-checking.test.tsx | 810 + web/src/features/system-update/api.ts | 72 + web/src/features/system-update/releases.ts | 115 + web/src/features/system-update/store.ts | 176 + .../system-update/system-update-action.tsx | 167 + .../system-update/system-update-dialog.tsx | 191 + .../system-update/use-system-update.ts | 197 + .../marketplace-install-dialog.test.tsx | 52 +- .../__tests__/plugin-changelog-panel.test.tsx | 232 + .../__tests__/plugin-changelog.test.ts | 331 + .../__tests__/plugin-detail-sheet.test.tsx | 60 + .../__tests__/usage-schema-table.test.tsx | 29 +- web/src/features/task-plugins/api.ts | 3 +- .../components/marketplace-install-dialog.tsx | 46 +- .../components/marketplace-panel.tsx | 1 + .../components/marketplace-plugin-card.tsx | 1 + .../components/marketplace-sources-dialog.tsx | 3 +- .../components/plugin-changelog-panel.tsx | 127 + .../components/plugin-detail-sheet.tsx | 85 +- .../task-plugins/components/plugin-icon.tsx | 1 + .../components/plugin-sandbox.tsx | 33 +- .../components/plugin-url-import-field.tsx | 1 + .../task-plugins/components/plugins-table.tsx | 7 +- .../components/usage-schema-table.tsx | 13 +- web/src/features/task-plugins/index.tsx | 7 +- .../task-plugins/lib/plugin-changelog.ts | 330 + web/src/features/task-plugins/types.ts | 10 +- web/src/features/usage-logs/audit/api.ts | 5 +- .../audit/components/audit-log-viewer.tsx | 4 +- .../__tests__/admin-request-details.test.tsx | 177 + .../__tests__/cost-display.test.tsx | 108 +- .../__tests__/detail-preview.test.tsx | 91 +- .../__tests__/group-filter.test.tsx | 2 +- .../components/__tests__/mobile-card.test.tsx | 79 +- .../components/__tests__/model-badge.test.tsx | 478 + .../components/__tests__/usage-facts.test.tsx | 75 +- .../columns/common-logs-columns.tsx | 46 +- .../components/common-log-mobile-card.tsx | 27 +- .../components/common-logs-filter-bar.tsx | 37 +- .../components/common-logs-stats.tsx | 5 +- .../components/dialogs/details-dialog.tsx | 251 +- .../components/dialogs/user-info-dialog.tsx | 38 +- .../components/log-cost-display.tsx | 86 +- .../components/logs-filter-toolbar.tsx | 189 +- .../usage-logs/components/model-badge.tsx | 376 +- .../components/routing-attempts.tsx | 69 + .../usage-logs/components/task-artifacts.tsx | 4 +- .../components/usage-logs-table.tsx | 33 +- web/src/features/usage-logs/lib/columns.ts | 5 +- web/src/features/usage-logs/lib/filter.ts | 4 + web/src/features/usage-logs/lib/format.ts | 72 +- web/src/features/usage-logs/lib/utils.ts | 4 + web/src/features/usage-logs/types.ts | 48 + web/src/features/users/api.ts | 2 + .../components/data-table-row-actions.tsx | 21 +- .../__tests__/user-binding-dialog.test.tsx | 190 +- .../dialogs/user-binding-dialog.tsx | 41 +- .../users/components/user-quota-dialog.tsx | 15 +- .../users/components/users-delete-dialog.tsx | 7 +- .../users/components/users-mutate-drawer.tsx | 71 +- .../features/users/components/users-table.tsx | 8 +- .../components/subscription-plans-card.tsx | 19 +- .../features/wallet/hooks/use-affiliate.ts | 11 +- .../wallet/hooks/use-billing-history.ts | 16 +- .../wallet/hooks/use-creem-payment.ts | 6 +- web/src/features/wallet/hooks/use-payment.ts | 8 +- .../features/wallet/hooks/use-redemption.ts | 5 +- .../features/wallet/hooks/use-topup-info.ts | 8 +- .../wallet/hooks/use-waffo-pancake-payment.ts | 13 +- .../wallet/hooks/use-waffo-payment.ts | 15 +- web/src/hooks/use-notifications.ts | 14 +- web/src/hooks/use-status.ts | 57 +- web/src/hooks/use-system-config.ts | 114 +- web/src/hooks/use-top-nav-links.ts | 5 +- web/src/i18n/locales/en.json | 773 +- web/src/i18n/locales/fr.json | 794 +- web/src/i18n/locales/ja.json | 794 +- web/src/i18n/locales/ru.json | 794 +- web/src/i18n/locales/vi.json | 794 +- web/src/i18n/locales/zh-TW.json | 798 +- web/src/i18n/locales/zh.json | 777 +- web/src/i18n/static-keys.ts | 133 + .../server-error-notifications.test.ts | 444 + web/src/lib/__tests__/status-query.test.tsx | 265 + web/src/lib/api.ts | 3 +- web/src/lib/handle-server-error.ts | 52 +- web/src/lib/http-client.ts | 50 +- web/src/lib/lobe-icon.tsx | 13 + web/src/lib/model-provider.ts | 244 + web/src/lib/nav-modules.ts | 66 +- web/src/lib/query-client.ts | 65 + web/src/lib/secure-verification.ts | 6 +- web/src/lib/server-error-message.ts | 137 +- web/src/lib/status-query.ts | 186 + web/src/main.tsx | 87 +- web/src/routeTree.gen.ts | 46 + web/src/routes/(auth)/oauth.tsx | 19 +- web/src/routes/_authenticated/chat2link.tsx | 3 +- .../system-settings/models/$section.tsx | 14 + .../system-settings/operations/$section.tsx | 5 +- .../request-policies/$section.tsx | 37 + .../request-policies/index.tsx | 31 + .../system-settings/security/$section.tsx | 7 + .../_authenticated/usage-logs/$section.tsx | 21 + web/src/routes/index.tsx | 4 +- web/src/routes/oauth/$provider.tsx | 22 +- web/src/routes/pricing/$modelId/index.tsx | 6 +- web/src/routes/pricing/index.tsx | 6 +- web/src/routes/rankings/index.tsx | 9 +- web/src/test-setup.ts | 18 +- web/tsconfig.node.json | 6 +- web/vitest.config.ts | 11 +- 828 files changed, 122253 insertions(+), 13702 deletions(-) create mode 100644 .agents/rules/billing.md create mode 100644 Dockerfile.compact-uptime create mode 100644 Dockerfile.deployed create mode 100644 Dockerfile.fingerprint-runtime create mode 100644 Dockerfile.local-release create mode 100644 Dockerfile.public-pelican create mode 100644 build-fingerprint-backend.sh create mode 100644 common/advanced_custom_presets.go create mode 100644 common/audio_case_test.go create mode 100644 common/model_test.go create mode 100644 common/rate-limit_test.go create mode 100644 controller/astra_sampling.go create mode 100644 controller/astra_sampling_test.go create mode 100644 controller/channel_billing_test.go create mode 100644 controller/channel_inference.go create mode 100644 controller/channel_inference_test.go create mode 100644 controller/channel_multi_key_test.go create mode 100644 controller/detection.go create mode 100644 controller/detection_html.go create mode 100644 controller/detection_test.go create mode 100644 controller/detection_worker.go create mode 100644 controller/live_status_test.go create mode 100644 controller/manxue_artwork_url.go create mode 100644 controller/manxue_artwork_url_test.go create mode 100644 controller/manxue_public.go create mode 100644 controller/manxue_public_test.go create mode 100644 controller/pelican_fallback.go create mode 100644 controller/pelican_fallback_test.go create mode 100644 controller/pelican_local_integration_test.go create mode 100644 controller/relay_attempt.go create mode 100644 controller/relay_attempt_test.go create mode 100644 controller/request_policy.go create mode 100644 controller/responses_websocket.go create mode 100644 controller/responses_websocket_test.go create mode 100644 controller/status_page.go create mode 100644 controller/system_task_test.go create mode 100644 controller/token_routing.go create mode 100644 docs/astra-response-guard.md create mode 100644 docs/error-log-group-custom19.md create mode 100644 docs/token-routing-custom18.md create mode 100644 model/channel_cache_test.go create mode 100644 model/detection.go create mode 100644 model/detection_schedule.go create mode 100644 model/detection_schedule_test.go create mode 100644 model/detection_test.go create mode 100644 model/detection_upgrade_test.go create mode 100644 model/legacy_dalle_pricing.go create mode 100644 model/log_routing_group.go create mode 100644 model/log_routing_group_test.go create mode 100644 model/model_pricing_conversion.go create mode 100644 model/option_primary_key_migration.go create mode 100644 model/passkey_option.go create mode 100644 model/relay_attempt.go create mode 100644 model/relay_attempt_test.go create mode 100644 model/request_policy.go create mode 100644 model/request_policy_test.go create mode 100644 model/selection_trace.go create mode 100644 model/status_page.go create mode 100644 model/token_astra_migration.go create mode 100644 model/token_group_pool_test.go create mode 100644 model/token_routing.go create mode 100644 pkg/billingexpr/detection_bound.go create mode 100644 pkg/billingexpr/fixed.go create mode 100644 pkg/billingexpr/testdata/frontend_simulation.json create mode 100644 pkg/jsplugin/json_state.go create mode 100644 pkg/model_fingerprint/LICENSE-ModelTrace.txt create mode 100644 pkg/model_fingerprint/LICENSE-gpt-model-check.txt create mode 100644 pkg/model_fingerprint/LICENSE-is-gpt-nerfed.txt create mode 100644 pkg/model_fingerprint/bank.json create mode 100644 pkg/model_fingerprint/fingerprint.go create mode 100644 pkg/model_fingerprint/fingerprint_test.go create mode 100644 pkg/model_fingerprint/response.go create mode 100644 pkg/model_fingerprint/scorer.js create mode 100644 pkg/model_fingerprint/testdata/astra-reference.json create mode 100644 pkg/perf_metrics/live_status.go create mode 100644 pkg/perf_metrics/live_status_test.go create mode 100644 pkg/perf_metrics/metrics_test.go create mode 100644 pkg/perf_metrics/outcome.go create mode 100644 pkg/wsmanager/wsmanager.go create mode 100644 pkg/wsmanager/wsmanager_test.go create mode 100644 relay/channel/advancedcustom/presets_test.go create mode 100644 relay/channel/advancedcustom/rerank.go create mode 100644 relay/channel/task/jsplugin/performance_test.go create mode 100644 relay/channel/task/jsplugin/submit_stream.go create mode 100644 relay/common/response_model.go create mode 100644 relay/common/response_model_guard.go create mode 100644 relay/relay_attempt_test.go create mode 100644 relay/request_billing.go create mode 100644 relay/response_model_test.go create mode 100644 relay/responses_request.go create mode 100644 relay/responses_websocket.go create mode 100644 relay/responses_websocket_test.go create mode 100644 relaykit/dto/legacy_dalle_image.go create mode 100644 relaykit/dto/values_test.go create mode 100644 relaykit/relayconvert/reasoning/gemini_test.go create mode 100644 service/astra_sampling.go create mode 100644 service/astra_sampling_test.go create mode 100644 service/detection.go create mode 100644 service/detection_schedule.go create mode 100644 service/detection_schedule_test.go create mode 100644 service/detection_status.go create mode 100644 service/group_availability.go create mode 100644 service/group_pool_test.go create mode 100644 service/image_billing.go create mode 100644 service/probe_history.go create mode 100644 service/probe_history_test.go create mode 100644 service/relay_attempt.go create mode 100644 service/relay_attempt_test.go create mode 100644 service/relay_error.go create mode 100644 service/relay_error_test.go create mode 100644 service/request_details.go create mode 100644 service/request_policy.go create mode 100644 service/responses_usage.go create mode 100644 service/responses_usage_test.go create mode 100644 service/token_routing.go create mode 100644 service/token_routing_test.go create mode 100644 service/ws_close.go create mode 100644 web/pnpm-lock.yaml create mode 100644 web/scripts/oxlint/__tests__/intl-locale.test.ts create mode 100644 web/scripts/oxlint/intl-locale.mjs create mode 100644 web/src/assets/brand-icons/sglang.svg create mode 100644 web/src/assets/custom/icon-wan.tsx create mode 100644 web/src/assets/custom/wan.png create mode 100644 web/src/components/data-table/toolbar/__tests__/mobile-filter.test.tsx create mode 100644 web/src/components/data-table/toolbar/mobile-filter-panel.tsx create mode 100644 web/src/components/floating-window.tsx create mode 100644 web/src/components/floating-window/__tests__/floating-window.test.tsx create mode 100644 web/src/components/multi-select/__tests__/option-hint.test.tsx create mode 100644 web/src/components/multi-select/__tests__/selection.test.tsx create mode 100644 web/src/components/ui/__tests__/label.test.tsx create mode 100644 web/src/features/auth/passkey/__tests__/domain-verification.test.tsx create mode 100644 web/src/features/auth/passkey/assertion.ts create mode 100644 web/src/features/auth/passkey/components/passkey-domain-selector.tsx create mode 100644 web/src/features/channels/components/__tests__/channel-configuration.test.tsx create mode 100644 web/src/features/channels/components/__tests__/channel-quick-options.test.tsx create mode 100644 web/src/features/channels/components/__tests__/inference-status.test.tsx create mode 100644 web/src/features/channels/components/__tests__/model-mapping-batch-dialog.test.tsx create mode 100644 web/src/features/channels/components/__tests__/model-mapping-editor.test.tsx create mode 100644 web/src/features/channels/components/__tests__/provider-selection.test.tsx create mode 100644 web/src/features/channels/components/__tests__/responses-websocket-setting.test.tsx create mode 100644 web/src/features/channels/components/__tests__/upstream-model-selection.test.tsx create mode 100644 web/src/features/channels/components/astra-model-guard-setting.tsx create mode 100644 web/src/features/channels/components/channel-plugin-extensions.tsx create mode 100644 web/src/features/channels/components/channel-quick-options.tsx create mode 100644 web/src/features/channels/components/dialogs/configure-models-dialog.tsx create mode 100644 web/src/features/channels/components/dialogs/inference-status-dialog.tsx create mode 100644 web/src/features/channels/components/dialogs/passthrough-warning-dialog.tsx create mode 100644 web/src/features/channels/components/drawers/channel-configuration.tsx create mode 100644 web/src/features/channels/components/drawers/channel-provider-picker.tsx create mode 100644 web/src/features/channels/components/model-mapping-batch-dialog.tsx create mode 100644 web/src/features/channels/components/model-redirect-panel.tsx create mode 100644 web/src/features/channels/components/responses-websocket-setting.tsx create mode 100644 web/src/features/channels/components/upstream-model-selection.tsx create mode 100644 web/src/features/channels/hooks/__tests__/use-redirect-panel-placement.test.tsx create mode 100644 web/src/features/channels/hooks/use-channel-model-discovery.ts create mode 100644 web/src/features/channels/hooks/use-redirect-panel-placement.ts create mode 100644 web/src/features/channels/lib/__tests__/header-passthrough.test.ts create mode 100644 web/src/features/channels/lib/__tests__/inference-status.test.ts create mode 100644 web/src/features/channels/lib/__tests__/model-mapping-rules.test.ts create mode 100644 web/src/features/channels/lib/__tests__/model-naming-patterns.test.ts create mode 100644 web/src/features/channels/lib/channel-configuration.ts create mode 100644 web/src/features/channels/lib/channel-plugin-extensions.ts create mode 100644 web/src/features/channels/lib/header-passthrough.ts create mode 100644 web/src/features/channels/lib/inference-status.ts create mode 100644 web/src/features/channels/lib/model-mapping-rules.ts create mode 100644 web/src/features/channels/lib/model-naming-patterns.ts create mode 100644 web/src/features/channels/lib/responses-websocket.ts create mode 100644 web/src/features/group-status/__tests__/adaptive-schedule.test.tsx create mode 100644 web/src/features/group-status/__tests__/detection.test.tsx create mode 100644 web/src/features/group-status/__tests__/probe-history.test.tsx create mode 100644 web/src/features/group-status/__tests__/status-page.test.tsx create mode 100644 web/src/features/group-status/adaptive-schedule.tsx create mode 100644 web/src/features/group-status/detection-panel.tsx create mode 100644 web/src/features/group-status/fingerprint-panel.tsx create mode 100644 web/src/features/group-status/probe-history.tsx create mode 100644 web/src/features/group-status/status-page-api.ts create mode 100644 web/src/features/group-status/status-page-settings.tsx create mode 100644 web/src/features/group-status/status-page.tsx create mode 100644 web/src/features/keys/components/__tests__/astra-cost-notice.test.tsx create mode 100644 web/src/features/keys/components/astra-cost-notice.tsx create mode 100644 web/src/features/keys/components/routing-pool-editor.tsx create mode 100644 web/src/features/model-pricing/__tests__/save-errors.test.tsx create mode 100644 web/src/features/model-pricing/pricing-conversion-dialog.tsx create mode 100644 web/src/features/performance-metrics/__tests__/summary.test.tsx create mode 100644 web/src/features/pricing/components/cached-price-cell.tsx create mode 100644 web/src/features/pricing/hooks/use-billing-time.ts create mode 100644 web/src/features/pricing/lib/__tests__/billing-expression.test.ts create mode 100644 web/src/features/pricing/lib/billing-expression/README.md create mode 100644 web/src/features/pricing/lib/billing-expression/condition-display.ts create mode 100644 web/src/features/pricing/lib/billing-expression/display.ts create mode 100644 web/src/features/pricing/lib/billing-expression/parser.ts create mode 100644 web/src/features/pricing/lib/billing-expression/runtime.ts create mode 100644 web/src/features/pricing/lib/billing-expression/structure.ts create mode 100644 web/src/features/pricing/lib/billing-expression/types.ts create mode 100644 web/src/features/pricing/lib/billing-expression/visual.ts create mode 100644 web/src/features/pricing/lib/plugin-pricing.ts create mode 100644 web/src/features/system-info/__tests__/system-tasks-panel.test.tsx create mode 100644 web/src/features/system-info/components/system-task-history.tsx create mode 100644 web/src/features/system-info/components/system-tasks-table.tsx create mode 100644 web/src/features/system-info/constants.ts create mode 100644 web/src/features/system-settings/auth/__tests__/passkey-settings.test.tsx create mode 100644 web/src/features/system-settings/general/channel-affinity/session-rules-table.tsx create mode 100644 web/src/features/system-settings/models/__tests__/plugin-pricing.test.tsx create mode 100644 web/src/features/system-settings/models/__tests__/request-simulation.test.tsx create mode 100644 web/src/features/system-settings/models/__tests__/visual-billing-editor.test.tsx create mode 100644 web/src/features/system-settings/models/billing-time-fields.tsx create mode 100644 web/src/features/system-settings/models/draft-number-input.tsx create mode 100644 web/src/features/system-settings/models/request-simulation.tsx create mode 100644 web/src/features/system-settings/models/task-plugin-pricing-editor.tsx create mode 100644 web/src/features/system-settings/models/tier-price-fields.tsx create mode 100644 web/src/features/system-settings/models/visual-billing-document-editor.tsx create mode 100644 web/src/features/system-settings/models/visual-condition-tree.css create mode 100644 web/src/features/system-settings/models/visual-condition-tree.tsx create mode 100644 web/src/features/system-settings/request-policies/__tests__/channel-health-layout.test.tsx create mode 100644 web/src/features/system-settings/request-policies/__tests__/routing.test.tsx create mode 100644 web/src/features/system-settings/request-policies/__tests__/settings.test.tsx create mode 100644 web/src/features/system-settings/request-policies/api.ts rename web/src/features/system-settings/{models/routing-reliability-section.tsx => request-policies/channel-health-section.tsx} (58%) create mode 100644 web/src/features/system-settings/request-policies/decision-record.tsx create mode 100644 web/src/features/system-settings/request-policies/defaults.ts create mode 100644 web/src/features/system-settings/request-policies/index.tsx create mode 100644 web/src/features/system-settings/request-policies/policy-label.ts create mode 100644 web/src/features/system-settings/request-policies/related-policy-link.tsx rename web/src/features/system-settings/{request-limits/sensitive-words-section.tsx => request-policies/request-checks-section.tsx} (58%) create mode 100644 web/src/features/system-settings/request-policies/retry-section.tsx create mode 100644 web/src/features/system-settings/request-policies/routing-form.ts create mode 100644 web/src/features/system-settings/request-policies/routing-section.tsx create mode 100644 web/src/features/system-settings/request-policies/section-registry.tsx create mode 100644 web/src/features/system-settings/request-policies/use-save-policy.ts create mode 100644 web/src/features/system-update/__tests__/releases.test.ts create mode 100644 web/src/features/system-update/__tests__/update-checking.test.tsx create mode 100644 web/src/features/system-update/api.ts create mode 100644 web/src/features/system-update/releases.ts create mode 100644 web/src/features/system-update/store.ts create mode 100644 web/src/features/system-update/system-update-action.tsx create mode 100644 web/src/features/system-update/system-update-dialog.tsx create mode 100644 web/src/features/system-update/use-system-update.ts create mode 100644 web/src/features/task-plugins/__tests__/plugin-changelog-panel.test.tsx create mode 100644 web/src/features/task-plugins/__tests__/plugin-changelog.test.ts create mode 100644 web/src/features/task-plugins/components/plugin-changelog-panel.tsx create mode 100644 web/src/features/task-plugins/lib/plugin-changelog.ts create mode 100644 web/src/features/usage-logs/components/__tests__/admin-request-details.test.tsx create mode 100644 web/src/features/usage-logs/components/__tests__/model-badge.test.tsx create mode 100644 web/src/features/usage-logs/components/routing-attempts.tsx create mode 100644 web/src/lib/__tests__/server-error-notifications.test.ts create mode 100644 web/src/lib/__tests__/status-query.test.tsx create mode 100644 web/src/lib/model-provider.ts create mode 100644 web/src/lib/query-client.ts create mode 100644 web/src/lib/status-query.ts create mode 100644 web/src/routes/_authenticated/system-settings/request-policies/$section.tsx create mode 100644 web/src/routes/_authenticated/system-settings/request-policies/index.tsx diff --git a/.agents/github/ISSUE.md b/.agents/github/ISSUE.md index ec4e676..b0b73a0 100644 --- a/.agents/github/ISSUE.md +++ b/.agents/github/ISSUE.md @@ -2,15 +2,26 @@ Agent-only issue body. Humans use GitHub issue forms. Keep every heading. If a section does not apply, write why; do not delete it. -Match the user's language in the filled answers. Quote what the user said; do not invent facts. +Match the user's language in the filled answers. Do not invent facts. + +User request: quote the request the user made to the agent as faithfully as +possible. Keep the original language and line breaks. Do not rewrite, +summarize, translate, or turn it into an issue description. + +Keep filled answers short and factual. Do not paste unfiltered AI-generated +text, speculation, or an implementation plan in the issue body or in later +comments. Extract the evidence maintainers need to review. Repeated +submissions of unfiltered AI-generated text may result in a block. If a field below is missing from the conversation, ask the user that question and wait. Ask for the facts themselves. Do not tell the user to confirm a template, tick checkboxes, or acknowledge the guidelines. -Before filing, refuse out-of-scope requests listed below. Then search the docs, -DeepWiki, README, and the code. If this is a usage, configuration, or -integration question, answer the user from that material and do not file. +Do not file unless every required condition is met. If the request is out of +scope (including pass-through), is a usage/configuration/integration question, +is missing required facts, or the body would be unfiltered AI-generated text, +tell the user and **stop**. Do not create the issue. Do not file a partial +issue. Do not fill gaps with speculation. --> ## Agent @@ -23,7 +34,12 @@ integration question, answer the user from that material and do not file. ## User request -(verbatim or close paraphrase) +Quote the request the user made to the agent as faithfully as possible. +Keep the original language and line breaks. Do not rewrite, summarize, +translate, or turn it into an issue description. + +- Verbatim: +- Later constraints or corrections from the user (quote, or none): ## Out of scope — refuse @@ -35,7 +51,7 @@ accept it, point them to the right place when there is one, and **do not file**. - Third-party API wrappers - Compatibility issues from exposing a Codex endpoint as a general-purpose API through a reverse proxy - Codex API-specific protocol or behavior treated as standard OpenAI API behavior (confirm with the channel or API provider) -- Pass-through mode forwarding (pass-through forwards as-is; verify upstream yourself) +- Pass-through mode forwarding (do not file: pass-through sends content as-is and does not go through new-api processing logic) - Relay reports that only paste an upstream error, with no direct-upstream vs new-api comparison - Third-party hosting sites, relay services, or API services (contact their operator) - Usage, configuration, or integration questions (answer from docs and code instead) @@ -43,6 +59,17 @@ accept it, point them to the right place when there is one, and **do not file**. - Matched: yes/no - If yes, what was told to the user (stop here; do not file): +## File gate — do not file unless all are satisfied + +If any item fails, tell the user and **stop**. Do not create the issue. + +- Out of scope (including pass-through): no +- Usage / configuration / integration (answered instead of filing): no +- Required facts present without invention (actual behavior, impact, frequency, evidence in new-api, applicable type fields): yes +- Body is short and factual; no unfiltered AI-generated text: yes +- File: yes/no +- If no, what was told to the user (stop here): + ## Kind - [ ] Bug @@ -69,7 +96,7 @@ Search these yourself before filing. Do not send the user to "read the docs firs ## Problem facts -Ask the user for every item and write it down: +Ask the user for every item and write it down. Facts only; do not paste unfiltered AI-generated text: - Actual behavior: - Impact: @@ -134,6 +161,7 @@ Fill every applicable type. Write "not applicable" for the rest. Ask the user fo ## Research Open the docs and code. Do not write "already checked" without sources. +Do not replace the fields below with unfiltered AI-generated text. ### Docs diff --git a/.agents/github/PR.md b/.agents/github/PR.md index af60eb4..19bff46 100644 --- a/.agents/github/PR.md +++ b/.agents/github/PR.md @@ -2,7 +2,16 @@ Agent-only PR body. Humans use `.github/PULL_REQUEST_TEMPLATE.md`. Keep every heading. If a section does not apply, write why; do not delete it. -Match the user's language in the filled answers. Quote what the user said; do not invent facts. +Match the user's language in the filled answers. Do not invent facts. + +User request: quote the request the user made to the agent as faithfully as +possible. Keep the original language and line breaks. Do not rewrite, +summarize, translate, or turn it into a PR description. + +Keep the body short and factual. Do not paste unfiltered AI-generated text +in the PR body or in later comments. Repeated submissions of this kind may +result in a block. Verification must be commands actually run and what was +observed. Stating only that `go build` or tests passed is not proof. New features: link an issue; if none exists, file one first with `.agents/github/ISSUE.md`. Large or directional changes: maintainer agreement on that issue before this PR. @@ -12,10 +21,11 @@ frequency, evidence that the problem is in new-api, or the applicable relay / billing / frontend / deployment items, ask the user those questions and wait. Ask for the facts. Do not tell the user to confirm a template. -Before opening, refuse the same out-of-scope list as `.agents/github/ISSUE.md` -(Coding Plan, reverse-engineered channels, third-party wrappers, Codex reverse-proxy -compatibility, pass-through-only forwarding, third-party hosts, usage questions). -Tell the user and do not open a PR. +Do not open a PR unless every required condition is met. If the change is out +of scope (including pass-through-only forwarding), is a usage/configuration/ +integration question, is missing required issue facts, has only `go build` / +tests-passed as verification, or the body would be unfiltered AI-generated +text, tell the user and **stop**. Do not open the PR. Do not open a partial PR. Then search https://docs.newapi.ai/ , https://deepwiki.com/QuantumNous/new-api , the README, and the code. If this is a usage, configuration, or integration @@ -37,7 +47,12 @@ question, answer the user and do not open a PR. ## User request -(verbatim or close paraphrase) +Quote the request the user made to the agent as faithfully as possible. +Keep the original language and line breaks. Do not rewrite, summarize, +translate, or turn it into a PR description. + +- Verbatim: +- Later constraints or corrections from the user (quote, or none): ## Out of scope — refuse @@ -49,13 +64,25 @@ accept it and **do not open a PR**. - Third-party API wrappers - Codex channel-type changes, or compatibility from exposing Codex as a general-purpose API - Codex API-specific protocol or behavior treated as standard OpenAI API behavior -- Pass-through-only forwarding +- Pass-through-only forwarding (pass-through sends content as-is and does not go through new-api processing logic) - Third-party hosting sites, relay services, or API services - Usage, configuration, or integration (answer from docs and code instead) - Matched: yes/no - If yes, what was told to the user (stop here; do not open a PR): +## Open gate — do not open unless all are satisfied + +If any item fails, tell the user and **stop**. Do not open the PR. + +- Out of scope (including pass-through-only forwarding): no +- Usage / configuration / integration (answered instead of opening): no +- Required issue facts present without invention: yes +- Verification is actual commands or steps and observed results, not only `go build` or tests passed: yes +- Body is short and factual; no unfiltered AI-generated text: yes +- Open: yes/no +- If no, what was told to the user (stop here): + ## Kind - [ ] Bug fix @@ -76,7 +103,8 @@ Take these from the linked issue. If a needed item is empty, ask the user that q ## Change -(what changed, why it works, grounded in the code actually touched) +(what changed, why it works, grounded in the code actually touched. +Short and factual; do not paste unfiltered AI-generated text.) ## Research @@ -114,7 +142,8 @@ Open them. Do not write "already checked" without sources. ## Verification -Only what was actually run. +Only what was actually run. Do not state only that `go build` or tests passed. +Each item needs the command or steps and the observed result. - Commands and results: - Manual steps and observed result: @@ -133,4 +162,4 @@ Only what was actually run. - Single focused change: yes/no (if no, why): - Secrets included: no -- Out of scope (Coding Plan / reverse-engineered channel / third-party wrapper / Codex): no +- Out of scope (Coding Plan / reverse-engineered channel / third-party wrapper / Codex / pass-through-only forwarding): no diff --git a/.agents/rules/billing.md b/.agents/rules/billing.md new file mode 100644 index 0000000..5fac8de --- /dev/null +++ b/.agents/rules/billing.md @@ -0,0 +1,28 @@ +# Billing Rules + +Mandatory conventions for quota, pricing, pre-consume, settlement, refund, and usage-quantity code in the Go backend. `AGENTS.md` defines when a task touches billing (see **Billing rules (mandatory read gate)**); any task that matches MUST read this whole file before planning, coding, or reviewing. Tasks that match none of those triggers may skip it. Rules use the same MUST / Do NOT wording as `AGENTS.md` and carry the same weight. + +**Billing expression system:** When working on tiered/dynamic billing (expression-based pricing), MUST read `pkg/billingexpr/expr.md` first. It documents the design philosophy, expression language, full architecture, token normalization rules, quota conversion, and expression versioning. All billing expression changes must follow that document. + +**Built-in model pricing:** New built-in model prices MUST be defined as self-contained billing expressions in `setting/billing_setting/builtin_billing.go`, using real USD per million tokens. Do not add new built-in prices to the legacy model/completion/cache ratio tables. Preserve explicit administrator pricing overrides. Existing legacy prices are migrated only when explicitly requested. Verify published prices and cover applicable context-length thresholds and cache categories. + +**Billing safety invariants:** Quota/billing code MUST never produce a negative charge (a credit) from arithmetic overflow or unvalidated input. Apply defense in depth: + +- Every user-controlled quantity that becomes a billing multiplier (image `n`, video `seconds`/`duration`, resolution/quality ratios, batch counts) MUST be bounded before it reaches quota calculation. Reject out-of-range values at request validation with a 400. Existing bounds: `dto.MaxImageN` for image generation count, `relaycommon.MaxTaskDurationSeconds` for task video duration, `maxTokensLimit` (`relay/helper/valid_request.go`) for `max_tokens`-family fields on every relay format (OpenAI, Claude, Gemini, Responses). Reuse these constants instead of introducing new ad hoc limits for the same concepts. When adding a new relay format or request DTO, bound its max-tokens and count fields in its validator from day one. +- Watch for validation bypass paths: passthrough fields (e.g. `Extra["parameters"]`), task `metadata` maps, and multipart form fields can carry the same quantities around the standard DTO validation. Any adaptor that reads a multiplier from such a path must enforce the same bound (or clamp) locally. +- Durations parsed from media metadata are user/upstream-controlled too: audio file headers (transcription token counting, TTS response duration) and upstream deduction numbers (e.g. Kling `FinalUnitDeduction`) can claim absurd values. Convert them with saturation before they become token counts. +- Never convert a computed quota or token count to `int` with a bare cast like `int(float64(quota) * ratio)`, `int(math.Round(...))` on unbounded input, or `int(decimal.IntPart())`. All quota rounding/conversion is centralized in `common/quota_math.go`; use those helpers: `common.QuotaFromFloat` (truncating) for float products, `common.QuotaRound` (half-away-from-zero) where rounding is intended, and `common.QuotaFromDecimal` for decimal products. `billingexpr.QuotaRound` delegates to `common.QuotaRound`. Do not reintroduce local conversion helpers or bare casts. Single-request saturation stays at the int32 boundary so batch accumulation cannot approach 64-bit wraparound; wallet/top-up conversion uses `common.WalletQuotaFromDecimalStrict` with the JavaScript-safe `common.MaxWalletQuota` boundary. Every clamp/NaN fallback is logged via `common.SysError`. +- Saturation events are also audited: each helper has a `*Checked` variant (`common.QuotaFromFloatChecked` / `QuotaRoundChecked` / `QuotaFromDecimalChecked`) that additionally returns a `*common.QuotaClamp` when clamping occurred. Billing paths that compute a charge capture that clamp onto `relayInfo.QuotaClamp` (or thread it into task settlement) and, right before writing the consume/task log, call `attachQuotaSaturation` (in `service/log_info_generate.go`) which nests the marker under the log's `other.admin_info.quota_saturation` and emits a request-correlated `logger.LogWarn`. Nesting under `admin_info` makes it admin-only for free (non-admin log views strip `admin_info`). When adding a new billing path, use the `*Checked` variant and surface the clamp the same way so the anomaly stays auditable in both the admin log UI and backend logs. +- Multiplier maps go through `types.PriceData.AddOtherRatio`, which rejects non-positive, NaN, and +Inf ratios. Do not write to `PriceData.OtherRatios` directly, and do not weaken these guards. +- Pre-consume (预扣费) and settle (结算/差额) must both be safe: a saturated oversized quota must fail pre-consume with insufficient-quota, never silently wrap. When adding a new billing path (new relay format, new task platform, new adjustment hook), trace the full chain — validation → EstimateBilling/OtherRatios → quota conversion → pre-consume → settle/refund — and confirm each step preserves these invariants. +- Fields parsed into unsigned types (`*uint`) accept huge positive JSON numbers (e.g. `18446744073686646784`, a wrapped negative); a `>= 0` check is not sufficient, an upper bound is mandatory. +- Regression tests for these invariants belong with the boundary they protect (request validators, converter helpers). See `relay/helper/openai_image_request_test.go`, `relay/common/relay_utils_test.go`, and `common/quota_math_test.go` for the expected style. + +**Upstream image quantity settlement:** The billable image count may be lowered or raised from the upstream response only through `RelayInfo.UpdateImageCount`; a zero or out-of-range count leaves the reserved request quantity in place. Derive the count from image payloads, never from JSON structure: + +- Do NOT count `data` with gjson `data.#`, `len(data)`, or any element count. Providers behind OpenAI-compatible channels return non-standard shapes: `data` as a single object, one image split into a `url` entry and a `b64_json` entry, or entries carrying only `revised_prompt`. +- An object-shaped `data` is one image when it has a non-empty `url` or `b64_json`, otherwise zero. For arrays the count is `max(entries with non-empty url, entries with non-empty b64_json)`: a standard response uses one `response_format` so this equals the entry count, a split image bills once, and payload-less entries bill nothing. The reference implementation is `openaiImageResponseCount` in `relay/channel/openai/relay_image.go`; reuse it (or the same rule) for any channel that forwards or rebuilds OpenAI-format image responses. The xAI adaptor already inherits it through `OpenaiImageHandler`. +- When an OpenAI-format JSON body is re-emitted as SSE, forward one `image_generation.completed` event per image payload using the same shape handling (object → one event, payload-less entries dropped). The client must never receive only `[DONE]` while being charged for images. +- Real SSE streams count `image_generation.completed` / `image_edit.completed` events and only trust a lower count when upstream finished the stream (`done` / `eof`); a client abort must not reduce the charge below the requested quantity. +- Channels with their own usage field (Ali `usage.image_count`) use that value, bounded by `dto.MaxImageN`, and fall back to the converted response length. Channels that rebuild responses from typed structs count the typed slice; none of them may read `data.#`. +- Regression cases for object, split, and payload-less shapes belong in `relay/channel/openai/image_stream_test.go` (fixed-price, tiered-expression, and JSON-as-SSE forwarding tables). Extend those tables instead of adding new files. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5f7bbdc..f428df0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -9,14 +9,23 @@ body: - ## 提交前必读(请勿删除本节) + - type: textarea + id: read-first + attributes: + label: 提交前必读 + description: GitHub 的 markdown 说明提交后不会进入 issue。本节会写入正文,请勿删除或清空。 + value: | - 文档:https://docs.newapi.ai/ - 使用问题先看或先问:https://deepwiki.com/QuantumNous/new-api - - 转发类问题必须先确认问题发生在 new-api 这一层:请使用等价的脱敏请求分别直连上游和经过 new-api,对比响应状态、错误体与服务端日志。透传模式或未经对比、仅展示上游报错的反馈不接受 issue。 + - 开启透传请勿反馈问题;透传会直接发送内容,不会经过 new-api 的逻辑处理。 + - 请以简练语言陈述事实与证据。Issue 正文及后续评论均不得直接粘贴未经人工过滤的大段 AI 文本。 + - 转发类问题必须先确认问题发生在 new-api 这一层:请使用等价的脱敏请求分别直连上游和经过 new-api,对比响应状态、错误体与服务端日志。未经对比、仅展示上游报错的反馈不接受 issue。 - 不接受 Coding Plan、逆向渠道、第三方封装接口,以及将 Codex 接口反代为通用 API 后产生的兼容性问题;Codex API 自身特有的协议或行为也不应被当作标准 OpenAI API 行为,相关问题请先向渠道或接口提供方确认。 - 本项目不提供官方托管服务,也不为第三方托管站、中转站或 API 服务提供技术支持;此类问题请联系对应运营方。 - - 警告:删除本模板、删除小节标题或随意清空内容的 issue,可能会被直接关闭;重复恶意提交者可能会被 block。 + - 警告:删除本模板、删除小节标题或随意清空内容的 issue,可能会被直接关闭;多次提交未经人工过滤的大段 AI 文本,或重复恶意提交者,可能会被 block。 + validations: + required: true - type: dropdown id: deployment-source @@ -52,11 +61,13 @@ body: required: true - label: "**非第三方服务:** 我确认这不是第三方托管站、中转站、API 服务或未经本仓库原版验证的 fork 所独有的问题;第三方实例问题应联系其运营方。" required: true + - label: "**非透传:** 我确认相关渠道未开启透传。开启透传会直接发送内容,不会经过 new-api 的逻辑处理,此类问题不接受 issue。" + required: true - label: "**问题归属:** 我已提供足以区分客户端、new-api 和上游的证据;转发问题已使用等价的脱敏请求对比直连上游与经过 new-api 的结果,确认错误由 new-api 引入或改变,而非仅原样转发上游报错。" required: true - label: "**渠道与协议边界:** 我确认问题不是由 Coding Plan、逆向渠道、第三方封装接口、Codex 反代接口,或 Codex API 自身特有行为导致;若问题最初出现于此类接口,我已在本仓库支持的标准 API 协议上复现。" required: true - - label: "**维护成本:** 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭。" + - label: "**维护成本:** 我理解项目维护者精力有限。Issue 正文及后续评论均不得直接粘贴未经人工过滤的大段 AI 文本,须先提炼可复核的要点;不遵循模板要求或缺少要点、无法复核的内容可能会被无视或直接关闭。多次提交此类内容可能会被 block。" required: true - type: markdown diff --git a/.github/ISSUE_TEMPLATE/bug_report_en.yml b/.github/ISSUE_TEMPLATE/bug_report_en.yml index a707f28..d7627c1 100644 --- a/.github/ISSUE_TEMPLATE/bug_report_en.yml +++ b/.github/ISSUE_TEMPLATE/bug_report_en.yml @@ -9,14 +9,23 @@ body: - ## Read This First (Do Not Remove This Section) + - type: textarea + id: read-first + attributes: + label: Read This First + description: GitHub markdown instructions are not included in the submitted issue. This section is written into the issue body. Do not delete or clear it. + value: | - Docs: https://docs.newapi.ai/ - Usage questions first: https://deepwiki.com/QuantumNous/new-api - - Relay reports must first establish that the issue occurs in the new-api layer. Use equivalent redacted requests to compare the direct upstream result with the result through new-api, including response status, error body, and server logs. Reports involving pass-through mode, or showing only an upstream error without this comparison, are not accepted. + - Do not report issues when pass-through is enabled; pass-through sends content as-is and does not go through new-api processing logic. + - State facts and evidence concisely. Do not paste unfiltered AI-generated text in the issue body or in later comments. + - Relay reports must first establish that the issue occurs in the new-api layer. Use equivalent redacted requests to compare the direct upstream result with the result through new-api, including response status, error body, and server logs. Reports that skip this comparison or only show an upstream error are not accepted. - Reports about Coding Plan services, reverse-engineered channels, third-party API wrappers, or compatibility issues caused by exposing a Codex endpoint through a reverse proxy as a general-purpose API are not accepted. Protocols or behavior specific to the Codex API should not be treated as standard OpenAI API behavior; confirm such issues with the channel or API provider first. - This project does not operate an official hosted service or provide technical support for third-party hosting sites, relay services, or API services. Contact the relevant operator for those issues. - - Warning: issues with this template removed, section headings deleted, or content cleared may be closed directly. Repeated abusive submissions may result in a block. + - Warning: issues with this template removed, section headings deleted, or content cleared may be closed directly. Repeated submissions of unfiltered AI-generated text, or repeated abusive submissions, may result in a block. + validations: + required: true - type: dropdown id: deployment-source @@ -52,11 +61,13 @@ body: required: true - label: "**Not a third-party service:** I confirm that this issue is not exclusive to a third-party hosting site, relay, API service, or fork that has not been verified against the unmodified repository. Third-party instance issues must be reported to their operator." required: true + - label: "**Not pass-through:** I confirm that pass-through is not enabled on the relevant channel. Pass-through sends content as-is and does not go through new-api processing logic; such reports are not accepted." + required: true - label: "**Issue attribution:** I have provided evidence that distinguishes the client, new-api, and upstream layers. For relay issues, I compared equivalent redacted requests sent directly upstream and through new-api, confirming that new-api introduces or changes the error rather than merely forwarding the upstream error unchanged." required: true - label: "**Channel and protocol boundary:** I confirm that the issue is not caused by a Coding Plan service, reverse-engineered channel, third-party API wrapper, Codex reverse-proxy endpoint, or behavior specific to the Codex API. If first observed through such an interface, I have reproduced it using a standard API protocol supported by this repository." required: true - - label: "**Maintainer time:** I understand that maintainers have limited time and issues that do not follow this template may be ignored or closed directly." + - label: "**Maintainer time:** I understand that maintainers have limited time. Do not paste unfiltered AI-generated text in the issue body or in later comments; extract the points needed for review first. Submissions that do not follow this template, or that omit the necessary points and cannot be reviewed, may be ignored or closed. Repeated submissions of this kind may result in a block." required: true - type: markdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index c5453cc..1bd2f83 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -9,19 +9,28 @@ body: - ## 提交前必读(请勿删除本节) + - type: textarea + id: read-first + attributes: + label: 提交前必读 + description: GitHub 的 markdown 说明提交后不会进入 issue。本节会写入正文,请勿删除或清空。 + value: | - 文档:https://docs.newapi.ai/ - 使用问题先看或先问:https://deepwiki.com/QuantumNous/new-api - - 开启透传后的转发相关反馈不接受 issue;透传模式会直接转发请求,请自行确认上游行为。 + - 开启透传请勿反馈问题;透传会直接发送内容,不会经过 new-api 的逻辑处理。 + - 请以简练语言陈述事实与证据。Issue 正文及后续评论均不得直接粘贴未经人工过滤的大段 AI 文本。 - 不接受 Coding Plan、逆向渠道、第三方封装接口,以及将 Codex 接口反代为通用 API 后产生的兼容性问题;Codex API 自身特有的协议或行为也不应被当作标准 OpenAI API 行为,相关问题请先向渠道或接口提供方确认。 - - 警告:删除本模板、删除小节标题或随意清空内容的 issue,可能会被直接关闭;重复恶意提交者可能会被 block。 + - 本项目不提供官方托管服务,也不为第三方托管站、中转站或 API 服务提供技术支持;此类问题请联系对应运营方。 + - 警告:删除本模板、删除小节标题或随意清空内容的 issue,可能会被直接关闭;多次提交未经人工过滤的大段 AI 文本,或重复恶意提交者,可能会被 block。 + validations: + required: true - type: input id: version attributes: label: 您当前的 newapi 版本 - description: 请填写,例如:`v1.0.0` + description: 请填写准确的版本号、commit 或镜像标签,例如 `v1.0.0`;`latest`、`unknown` 等无法定位代码的内容不视为有效版本信息。 validations: required: true @@ -34,9 +43,17 @@ body: required: true - label: "**提交前必读:** 我已完整阅读上方“提交前必读”,并已查看文档 https://docs.newapi.ai/、项目 README 且向 AI 提问,确认这不是使用、配置或接入类问题,且现有版本无法满足需求。" required: true + - label: "**受支持版本:** 我已提供准确的版本号、commit 或镜像标签(不是 `latest` 或 `unknown`),并确认这是针对本仓库未修改的受支持版本提出的功能需求。" + required: true + - label: "**非第三方服务:** 我确认这不是第三方托管站、中转站、API 服务或未经本仓库原版验证的 fork 所独有的需求;第三方实例问题应联系其运营方。" + required: true + - label: "**非透传:** 我确认这不是开启透传后的转发相关反馈;透传会直接发送内容,不会经过 new-api 的逻辑处理。" + required: true + - label: "**渠道与协议边界:** 我确认这不是 Coding Plan、逆向渠道、第三方封装接口、Codex 反代接口,或把 Codex API 自身特有行为当作标准 OpenAI API 行为的功能请求;若需求最初出现于此类接口,我已基于本仓库支持的标准 API 协议提出。" + required: true - label: "**模板完整:** 我未删除此模板中的任何引导内容或小节标题,并会按要求完整填写。" required: true - - label: "**维护成本:** 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭。" + - label: "**维护成本:** 我理解项目维护者精力有限。Issue 正文及后续评论均不得直接粘贴未经人工过滤的大段 AI 文本,须先提炼可复核的要点;不遵循模板要求或缺少要点、无法复核的内容可能会被无视或直接关闭。多次提交此类内容可能会被 block。" required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/feature_request_en.yml b/.github/ISSUE_TEMPLATE/feature_request_en.yml index c442682..d3a3533 100644 --- a/.github/ISSUE_TEMPLATE/feature_request_en.yml +++ b/.github/ISSUE_TEMPLATE/feature_request_en.yml @@ -9,19 +9,28 @@ body: - ## Read This First (Do Not Remove This Section) + - type: textarea + id: read-first + attributes: + label: Read This First + description: GitHub markdown instructions are not included in the submitted issue. This section is written into the issue body. Do not delete or clear it. + value: | - Docs: https://docs.newapi.ai/ - Usage questions first: https://deepwiki.com/QuantumNous/new-api - - Issues about forwarding behavior after enabling pass-through mode are not accepted; pass-through mode forwards requests directly, so please verify upstream behavior yourself. + - Do not report issues when pass-through is enabled; pass-through sends content as-is and does not go through new-api processing logic. + - State facts and evidence concisely. Do not paste unfiltered AI-generated text in the issue body or in later comments. - Reports about Coding Plan services, reverse-engineered channels, third-party API wrappers, or compatibility issues caused by exposing a Codex endpoint through a reverse proxy as a general-purpose API are not accepted. Protocols or behavior specific to the Codex API should not be treated as standard OpenAI API behavior; confirm such issues with the channel or API provider first. - - Warning: issues with this template removed, section headings deleted, or content cleared may be closed directly. Repeated abusive submissions may result in a block. + - This project does not provide official hosting and does not support third-party hosting sites, relays, or API services; contact their operator instead. + - Warning: issues with this template removed, section headings deleted, or content cleared may be closed directly. Repeated submissions of unfiltered AI-generated text, or repeated abusive submissions, may result in a block. + validations: + required: true - type: input id: version attributes: label: Your current newapi version - description: "Please fill this in, for example: `v1.0.0`" + description: "Provide an exact version, commit, or image tag, for example `v1.0.0`. Values such as `latest` or `unknown` that cannot locate the code are not valid version information." validations: required: true @@ -34,9 +43,17 @@ body: required: true - label: "**Read this first:** I have fully read the section above, reviewed the docs at https://docs.newapi.ai/ and the project README, and asked AI first, confirming this is not a usage, configuration, or integration question, and that the current version cannot meet my needs." required: true + - label: "**Supported version:** I have provided an exact version, commit, or image tag (not `latest` or `unknown`) and confirm this feature request is for an unmodified, supported version from this repository." + required: true + - label: "**Not a third-party service:** I confirm that this request is not exclusive to a third-party hosting site, relay, API service, or fork that has not been verified against the unmodified repository. Third-party instance issues must be reported to their operator." + required: true + - label: "**Not pass-through:** I confirm this is not a forwarding report after enabling pass-through; pass-through sends content as-is and does not go through new-api processing logic." + required: true + - label: "**Channel and protocol boundary:** I confirm this is not a feature request for a Coding Plan service, reverse-engineered channel, third-party API wrapper, Codex reverse-proxy endpoint, or treating Codex API-specific behavior as standard OpenAI API behavior. If first observed through such an interface, I have based the request on a standard API protocol supported by this repository." + required: true - label: "**Template intact:** I have not removed any guidance or section headings from this template and will complete it as requested." required: true - - label: "**Maintainer time:** I understand that maintainers have limited time and issues that do not follow this template may be ignored or closed directly." + - label: "**Maintainer time:** I understand that maintainers have limited time. Do not paste unfiltered AI-generated text in the issue body or in later comments; extract the points needed for review first. Submissions that do not follow this template, or that omit the necessary points and cannot be reviewed, may be ignored or closed. Repeated submissions of this kind may result in a block." required: true - type: textarea diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 237c383..f080aed 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -7,7 +7,8 @@ English template: `.github/PULL_REQUEST_TEMPLATE/en.md` > [!IMPORTANT] > -> - 描述可用 AI 辅助。提交前请审阅全文,并**声明对其负责**,避免未经核对的直接粘贴。 +> - 描述可用 AI 辅助。提交前请审阅并精炼全文,仅保留维护者需要复核的要点,并**声明对其负责**。PR 正文及后续评论均不得直接粘贴未经人工过滤的大段 AI 文本;多次提交此类内容可能会被 block。 +> - 开启透传请勿反馈问题;透传会直接发送内容,不会经过 new-api 的逻辑处理。 > - 请按本模板填写后再提交。 ## 🔗 关联任务 / Related Issue @@ -24,17 +25,18 @@ English template: `.github/PULL_REQUEST_TEMPLATE/en.md` - [ ] 📝 文档更新 (Documentation) ## 📝 变更描述 / Description -(简述做了什么、为什么生效。如果难以简述,建议先拆分范围,或在 Issue 中与维护者对齐。) +(简述做了什么、为什么生效。不得直接粘贴未经人工过滤的大段 AI 文本。如果难以简述,建议先拆分范围,或在 Issue 中与维护者对齐。) ## 📸 运行证明 / Proof of Work -(请写明如何验证:实际步骤与观察结果。UI 变更请附截图或录屏;Bug 修复请说明复现过程与修复后结果。) +(请写明如何验证:实际命令或步骤,以及观察结果。仅声明 `go build` 通过或测试通过,不视为有效证明。UI 变更请附截图或录屏;Bug 修复请说明复现过程与修复后结果。) ## ✅ 提交前检查项 / Checklist -- [ ] **人工确认:** 无论描述是否由 AI 生成,我已审阅全部内容,并声明对其准确性与完整性负责。 +- [ ] **人工确认:** 无论描述是否由 AI 生成,我已审阅并精炼全文,仅保留可复核的要点,并声明对其准确性与完整性负责。PR 正文及后续评论均未直接粘贴未经人工过滤的大段 AI 文本。 - [ ] **非重复提交:** 我已搜索现有的 [Issues](https://github.com/QuantumNous/new-api/issues) 与 [PRs](https://github.com/QuantumNous/new-api/pulls),确认不是重复提交。 - [ ] **新功能关联 Issue:** 若此 PR 标记为 `New feature`,我已关联对应 Issue;若尚无 Issue,我已先自行创建。 - [ ] **事前沟通:** 若改动较大或涉及方向性变更,已在关联 Issue 中与维护者沟通并达成一致。 - [ ] **功能范围:** 本 PR 不是 Coding Plan、逆向渠道、第三方封装接口,也不是对 Codex 渠道类型的改动。 +- [ ] **非透传:** 本 PR 不是针对开启透传后的转发行为;透传会直接发送内容,不会经过 new-api 的逻辑处理。 - [ ] **范围聚焦:** 本 PR 为一项聚焦改动,未包含无关代码。 -- [ ] **本地验证:** 已在本地运行并通过测试或手动验证,维护者可以据此复核。 +- [ ] **本地验证:** 已按变更路径实际验证,并写明命令与观察结果。仅声明 `go build` 通过或测试通过,不视为有效证明。 - [ ] **安全合规:** 代码中无敏感凭据,且符合项目代码规范。 diff --git a/.github/PULL_REQUEST_TEMPLATE/en.md b/.github/PULL_REQUEST_TEMPLATE/en.md index 446e9cb..ab4a35c 100644 --- a/.github/PULL_REQUEST_TEMPLATE/en.md +++ b/.github/PULL_REQUEST_TEMPLATE/en.md @@ -7,7 +7,8 @@ Chinese template: `.github/PULL_REQUEST_TEMPLATE.md` > [!IMPORTANT] > -> - AI-assisted descriptions are welcome. Please review the full text before submitting and **take responsibility** for it. Avoid pasting unreviewed content. +> - AI-assisted descriptions are welcome. Review and condense the full text before submitting, keep only the points maintainers need to re-check, and **take responsibility** for it. Do not paste unfiltered AI-generated text in the PR body or in later comments. Repeated submissions of this kind may result in a block. +> - Do not report issues when pass-through is enabled; pass-through sends content as-is and does not go through new-api processing logic. > - Please complete this template before submitting. ## Related Issue @@ -24,17 +25,18 @@ Chinese template: `.github/PULL_REQUEST_TEMPLATE.md` - [ ] Documentation ## Description -(Briefly describe what changed and why it works. If that is hard to summarize, consider splitting the scope or aligning with maintainers in an Issue first.) +(Briefly describe what changed and why it works. Do not paste unfiltered AI-generated text. If that is hard to summarize, consider splitting the scope or aligning with maintainers in an Issue first.) ## Proof of Work -(Please describe how this was verified: the steps run and what was observed. For UI changes, please include a screenshot or recording. For bug fixes, please describe the reproduction and the result after the fix.) +(Record how this was verified: the actual commands or steps and the observed results. Stating only that `go build` or tests passed is not valid proof. For UI changes, include a screenshot or recording. For bug fixes, describe the reproduction and the result after the fix.) ## Checklist -- [ ] **Human review:** Whether or not the description was AI-generated, I have reviewed the full content and take responsibility for its accuracy and completeness. +- [ ] **Human review:** Whether or not the description was AI-generated, I have reviewed and condensed the full text, retained only the points needed for review, and take responsibility for its accuracy and completeness. I have not pasted unfiltered AI-generated text in the PR body or in later comments. - [ ] **Not a duplicate:** I have searched existing [Issues](https://github.com/QuantumNous/new-api/issues) and [PRs](https://github.com/QuantumNous/new-api/pulls) and confirmed this is not a duplicate. - [ ] **Feature issue:** If this PR is a New feature, I have linked a corresponding Issue; if none existed, I created one first. - [ ] **Prior discussion:** If this is a large or directional change, I have discussed it with maintainers in the linked Issue and reached agreement. - [ ] **Scope:** This PR is not a Coding Plan, reverse-engineered channel, third-party API wrapper, or a change to the Codex channel type. +- [ ] **Not pass-through:** This PR is not about forwarding behavior after enabling pass-through; pass-through sends content as-is and does not go through new-api processing logic. - [ ] **Focused change:** This PR is a single focused change and does not include unrelated code. -- [ ] **Local verification:** I have run tests or manually verified locally so maintainers can re-check from this evidence. +- [ ] **Local verification:** I verified the changed path and recorded the commands and observed results. Stating only that `go build` or tests passed is not valid proof. - [ ] **Security:** This change does not include secrets and follows the project's coding guidelines. diff --git a/AGENTS.md b/AGENTS.md index 8241b5a..7d68eb9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,21 +115,22 @@ Inside `relaykit/`, use `kitutil.*` from `relaykit/relayconvert/kitutil/json.go` - Preserve explicit zero values in upstream relay request DTOs: absent client JSON fields must become `nil` and be omitted, while explicit `0`, `0.0`, or `false` values must remain non-`nil` and be sent upstream. - Avoid non-pointer scalars with `omitempty` for optional request parameters, because zero values will be silently dropped during marshal. -**Billing expression system:** When working on tiered/dynamic billing (expression-based pricing), MUST read `pkg/billingexpr/expr.md` first. It documents the design philosophy, expression language, full architecture, token normalization rules, quota conversion, and expression versioning. All billing expression changes must follow that document. +**JavaScript task plugins (mandatory):** -**Built-in model pricing:** New built-in model prices MUST be defined as self-contained billing expressions in `setting/billing_setting/builtin_billing.go`, using real USD per million tokens. Do not add new built-in prices to the legacy model/completion/cache ratio tables. Preserve explicit administrator pricing overrides. Existing legacy prices are migrated only when explicitly requested. Verify published prices and cover applicable context-length thresholds and cache categories. +- Before implementing, modifying, or reviewing JavaScript task plugins or their host API/runtime, MUST read [Task Plugin API v1](docs/plugin-api/v1.md), including its description writing and translation conventions. When changing the plugin contract, also check `docs/plugin-api/v1.schema.json` and `docs/plugin-api/v1.d.ts` for consistency. +- For numeric billing fields in `usageSchema` and `usageProfiles[].schema`, `description` MUST name the **billing subject + unit price**, because it labels the price input in the UI. For example, `image_count` uses `Image generation unit price` / `图片生成单价`, not `Generated image count` / `生成图片张数`; `seconds` uses `Video generation unit price` / `视频生成单价`. The field value remains a usage quantity, not a price. +- Put units in `unit`. Keep protocol limits, usage sources, defaults, estimation, and settlement details in code comments or technical documentation. Descriptions must be short, equivalent across languages, and free of numeric prices and trailing punctuation. Follow the API document's separate wording rules for actions, booleans, and other enum conditions. +- Review metadata wording explicitly before completing plugin work. These are authoring requirements; successful compilation, schema validation, or tests do not verify that descriptions follow them. -**Billing safety invariants:** Quota/billing code MUST never produce a negative charge (a credit) from arithmetic overflow or unvalidated input. Apply defense in depth: +**Billing rules (mandatory read gate):** `.agents/rules/billing.md` holds the billing conventions (expression system, built-in pricing, safety invariants, deriving billable quantities from upstream responses). If a task touches billing as defined below, you MUST read that file in full with your file-read tool — not a grep, a partial skim, memory, or a summary — before planning, coding, or reviewing, and then follow every rule in it. A task touches billing when it meets any of these: -- Every user-controlled quantity that becomes a billing multiplier (image `n`, video `seconds`/`duration`, resolution/quality ratios, batch counts) MUST be bounded before it reaches quota calculation. Reject out-of-range values at request validation with a 400. Existing bounds: `dto.MaxImageN` for image generation count, `relaycommon.MaxTaskDurationSeconds` for task video duration, `maxTokensLimit` (`relay/helper/valid_request.go`) for `max_tokens`-family fields on every relay format (OpenAI, Claude, Gemini, Responses). Reuse these constants instead of introducing new ad hoc limits for the same concepts. When adding a new relay format or request DTO, bound its max-tokens and count fields in its validator from day one. -- Watch for validation bypass paths: passthrough fields (e.g. `Extra["parameters"]`), task `metadata` maps, and multipart form fields can carry the same quantities around the standard DTO validation. Any adaptor that reads a multiplier from such a path must enforce the same bound (or clamp) locally. -- Durations parsed from media metadata are user/upstream-controlled too: audio file headers (transcription token counting, TTS response duration) and upstream deduction numbers (e.g. Kling `FinalUnitDeduction`) can claim absurd values. Convert them with saturation before they become token counts. -- Never convert a computed quota or token count to `int` with a bare cast like `int(float64(quota) * ratio)`, `int(math.Round(...))` on unbounded input, or `int(decimal.IntPart())`. All quota rounding/conversion is centralized in `common/quota_math.go`; use those helpers: `common.QuotaFromFloat` (truncating) for float products, `common.QuotaRound` (half-away-from-zero) where rounding is intended, and `common.QuotaFromDecimal` for decimal products. `billingexpr.QuotaRound` delegates to `common.QuotaRound`. Do not reintroduce local conversion helpers or bare casts. Single-request saturation stays at the int32 boundary so batch accumulation cannot approach 64-bit wraparound; wallet/top-up conversion uses `common.WalletQuotaFromDecimalStrict` with the JavaScript-safe `common.MaxWalletQuota` boundary. Every clamp/NaN fallback is logged via `common.SysError`. -- Saturation events are also audited: each helper has a `*Checked` variant (`common.QuotaFromFloatChecked` / `QuotaRoundChecked` / `QuotaFromDecimalChecked`) that additionally returns a `*common.QuotaClamp` when clamping occurred. Billing paths that compute a charge capture that clamp onto `relayInfo.QuotaClamp` (or thread it into task settlement) and, right before writing the consume/task log, call `attachQuotaSaturation` (in `service/log_info_generate.go`) which nests the marker under the log's `other.admin_info.quota_saturation` and emits a request-correlated `logger.LogWarn`. Nesting under `admin_info` makes it admin-only for free (non-admin log views strip `admin_info`). When adding a new billing path, use the `*Checked` variant and surface the clamp the same way so the anomaly stays auditable in both the admin log UI and backend logs. -- Multiplier maps go through `types.PriceData.AddOtherRatio`, which rejects non-positive, NaN, and +Inf ratios. Do not write to `PriceData.OtherRatios` directly, and do not weaken these guards. -- Pre-consume (预扣费) and settle (结算/差额) must both be safe: a saturated oversized quota must fail pre-consume with insufficient-quota, never silently wrap. When adding a new billing path (new relay format, new task platform, new adjustment hook), trace the full chain — validation → EstimateBilling/OtherRatios → quota conversion → pre-consume → settle/refund — and confirm each step preserves these invariants. -- Fields parsed into unsigned types (`*uint`) accept huge positive JSON numbers (e.g. `18446744073686646784`, a wrapped negative); a `>= 0` check is not sufficient, an upper bound is mandatory. -- Regression tests for these invariants belong with the boundary they protect (request validators, converter helpers). See `relay/helper/openai_image_request_test.go`, `relay/common/relay_utils_test.go`, and `common/quota_math_test.go` for the expected style. +- Edits `pkg/billingexpr/`, `setting/billing_setting/`, `common/quota_math.go`, `types/price_data.go`, `relay/request_billing.go`, `relay/image_handler.go`, `relay/relay_task.go`, `relay/helper/price.go`, `relay/helper/billing_expr_request.go`, `relay/helper/valid_request.go`, `service/quota.go`, `service/text_quota.go`, `service/image_billing.go`, `service/tiered_settle.go`, `service/task_billing.go`, `service/responses_usage.go`, the billing part of `service/log_info_generate.go`, the `model/pricing*.go` / `model/model_pricing*.go` files, or the billing fields and methods of `relay/common/relay_info.go` (`PriceData`, `TieredBillingSnapshot`, `BillingImageCount`, `UpdateImageCount`). +- Reads or writes `PriceData` / `OtherRatios`, quota pre-consume, settlement, refund, or consume-log billing fields anywhere else. +- Derives a billable quantity or `Usage` from an upstream response or stream (image counts, seconds, tokens, task deductions) in any channel adaptor, response handler, or task plugin. +- Validates, bounds, or forwards a request field that becomes a billing multiplier (`n`, `max_tokens`-family fields, duration, resolution or quality, batch counts), including passthrough and multipart paths. +- Adds or changes model prices, or numeric `usageSchema` / `usageProfiles[].schema` fields in a task plugin. + +Tasks that touch none of these (for example unrelated frontend work, authentication, database migrations, or protocol conversion that leaves usage untouched) do not need to read it. **Backend test quality:** Backend tests must protect real behavior, API contracts, billing/accounting invariants, data compatibility, or regression paths. @@ -145,7 +146,10 @@ Inside `relaykit/`, use `kitutil.*` from `relaykit/relayconvert/kitutil/json.go` - Avoid hand-written assertion helpers unless they encode a reusable project-specific invariant. - When cleaning tests, preserve meaningful regression coverage. If a deleted test covered a real contract indirectly, replace it with a smaller test that asserts that contract directly. -**Documentation files:** Do NOT add new files under `docs/` or any of its subdirectories unless the user explicitly requests it. +**Documentation files:** + +- Do NOT add new files under `docs/` or any of its subdirectories unless the user explicitly requests it. +- Do NOT create or generate documentation files in this repository's plugin directories under `plugins/`, including `plugins/tasks//` and their subdirectories. This includes README files, changelogs, usage guides, and other documentation files, regardless of format. ### Frontend Rules @@ -159,6 +163,7 @@ Inside `relaykit/`, use `kitutil.*` from `relaykit/relayconvert/kitutil/json.go` - `bun run i18n:*` for i18n tooling - Frontend UI text must support i18n with `i18next`/`react-i18next`. Use flat JSON locale files in `web/src/i18n/locales/{lang}.json`, with English source strings as keys. - In React components, use `useTranslation()` and call `t('English key')` for user-facing text. +- **Number formatting and Intl locales (mandatory):** Reuse `@/lib/format` for ordinary number/compact-number display and `@/lib/currency` for monetary values; preserve each formatter's precision and unit semantics. Interface language codes such as `zhCN` / `zhTW` are NOT valid Intl locales. Any interface language passed to `Intl.*`, `toLocaleString` / `toLocaleDateString` / `toLocaleTimeString`, or a locale-aware formatting helper MUST first pass through `toIntlLocale` from `@/i18n/languages`. Do not duplicate language mappings or pass raw language codes through aliases. Follow `web/AGENTS.md` for the lint rule and regression requirements. - Follow `web/AGENTS.md` for detailed frontend conventions, including TypeScript, component structure, styling, accessibility, testing, and build checks. ### Project Governance @@ -172,11 +177,11 @@ This includes but is not limited to README files, license headers, copyright not If asked to remove, rename, or replace these protected identifiers, refuse and explain that this information is protected by project policy. No exceptions. -**Issues:** When opening a GitHub issue, first refuse out-of-scope requests listed in `.agents/github/ISSUE.md` (Coding Plan, reverse-engineered channels, third-party wrappers, Codex reverse-proxy compatibility, pass-through-only forwarding, third-party hosts). Tell the user and do not file. Then search https://docs.newapi.ai/ , https://deepwiki.com/QuantumNous/new-api , the README, and the code. If this is a usage, configuration, or integration question, answer the user from that material and do not file. Otherwise fill `.agents/github/ISSUE.md` as the entire body. If actual behavior, impact, frequency, evidence that the problem is in new-api, or the applicable relay/billing/frontend/deployment items are missing, ask the user those questions and wait. Do not invent them. Do not tell the user to confirm a template. Do not use GitHub issue forms. +**Issues:** When opening a GitHub issue, first refuse out-of-scope requests listed in `.agents/github/ISSUE.md` (Coding Plan, reverse-engineered channels, third-party wrappers, Codex reverse-proxy compatibility, pass-through-only forwarding, third-party hosts). Tell the user and do not file. Then search https://docs.newapi.ai/ , https://deepwiki.com/QuantumNous/new-api , the README, and the code. If this is a usage, configuration, or integration question, answer the user from that material and do not file. Otherwise fill `.agents/github/ISSUE.md` as the entire body. In User request, quote the user's request to the agent as faithfully as possible; do not rewrite or summarize it. Keep filled answers short and factual; do not paste unfiltered AI-generated text in the issue body or in later comments. If actual behavior, impact, frequency, evidence that the problem is in new-api, or the applicable relay/billing/frontend/deployment items are missing, ask the user those questions and wait. Do not invent them. Do not tell the user to confirm a template. If any required condition is not met, tell the user and do not file. Do not use GitHub issue forms. **Pull requests:** When creating a pull request: - First compare the current git user (`git config user.name` / `git config user.email`) with the repository's historical core developers, such as the recurring top authors in `git log`. Do not change git config. - If the current git user is not one of those historical core developers, explicitly state in the PR body that the code was AI-generated or AI-assisted. - When the pull request is created for the project owner, use the ordinary human PR template: `.github/PULL_REQUEST_TEMPLATE.md` for Chinese requests or `.github/PULL_REQUEST_TEMPLATE/en.md` for English requests. Project-owner pull requests MUST NOT use `.agents/github/PR.md` unless the owner explicitly asks for it. -- For all other agent-created pull requests, fill `.agents/github/PR.md` as the entire PR body. Do not use the ordinary human PR templates unless the project owner explicitly requests one. +- For all other agent-created pull requests, fill `.agents/github/PR.md` as the entire PR body. Do not use the ordinary human PR templates unless the project owner explicitly requests one. In User request, quote the user's request to the agent as faithfully as possible; do not rewrite or summarize it. Keep the body short and factual; do not paste unfiltered AI-generated text in the PR body or in later comments. Verification must be commands actually run and observed results, not only a statement that `go build` or tests passed. If any required condition is not met, tell the user and do not open the PR. diff --git a/CLAUDE.md b/CLAUDE.md index 97ba749..78f2071 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,3 +13,4 @@ Rules: - Do not replace the Read with a grep, glob, or partial skim. - After reading, follow every rule in `AGENTS.md` for the rest of the work. - If the task touches `web/`, also Read `web/AGENTS.md` before editing frontend files. +- If the task touches billing as defined under **Billing rules (mandatory read gate)** in `AGENTS.md`, also Read `.agents/rules/billing.md` in full before planning or editing. Tasks outside that definition may skip it. diff --git a/CUSTOMIZATION.md b/CUSTOMIZATION.md index 8f8d08a..261bd45 100644 --- a/CUSTOMIZATION.md +++ b/CUSTOMIZATION.md @@ -1,20 +1,32 @@ # 自定义版本说明 -当前生产版本基于官方 `v1.0.0-rc.36`(`ea7cb0ba4e0f82e2bfa5e55752eb68bdf902f71b`),版本号为 `v1.0.0-rc.36-custom.2`。 +当前版本为 v1.0.0-rc.38-custom.42-20260926,基于官方 v1.0.0-rc.38,对应 2026-09-26 已部署的自定义源码。 -包含以下生产定制: +## 主要定制 -- 仅展示存在可用渠道的分组;禁用、无渠道和不可用分组不进入用户筛选。 -- 固定令牌分组可配置一个备用分组;主分组无路由或发生可重试上游错误时按顺序切换,实际命中的分组用于计费和日志。 -- 固定令牌配置了备用分组时,备用分组切换优先于会话亲和规则的“失败后不重试”;上游返回可重试错误(如 503)时不再停在主分组渠道。不可重试错误、显式指定渠道和 auto 分组行为保持不变。 -- 分组状态页展示请求成功率、首字延迟、平均延迟、TPS、请求缓存和令牌缓存指标。 -- 支持按分组配置动态计费表达式,并在价格页按所选分组展示。 -- 用户侧隐藏上游错误细节,完整错误仅保留在 root 日志范围;客户端断开不计为上游失败。 +- 固定令牌支持有序分组池、备用分组、可重试错误切换及分组可用性判断;按实际命中的分组计费和记录日志。 +- 支持分组动态计费表达式、请求明细、路由过程及逐次请求结算。 +- 用户侧隐藏上游错误细节;客户端断开与真实上游故障分别处理。 +- Astra 模型指纹抽检、响应模型守卫、鹈鹕检测及检测费用结算。 +- 独立公开 uptime 页,使用 shadcn/ui,支持分组别名、隐藏分组和管理员重置当前分组检测记录。 +- 主站 /group-status 使用真实分组名并展示全部分组,检测请求使用真实分组标识;独立 uptime 页应用公开展示配置。 +- 渠道每 15 分钟探活,展示最近 18 次记录;正常用户请求可恢复分组正常状态。 +- 支持 Astra 的分组排在前面,其他分组使用紧凑布局;鹈鹕样例按时间倒序横向滚动,最新在左,点击缩略图预览。 +- 鹈鹕检测通过 manxue.ai 访客接口请求公开作品,保存可跳转的作品链接;连接失败时本地生成并标记为不确定。 +- 作品脚本在隔离 iframe 中运行,以保留腿部、踏板等动画绘制;缩略图按容器宽度等比缩放。 -生产镜像通过以下命令构建: +## 从源码构建 -```sh -docker build --pull=false -f Dockerfile.request-status -t local/new-api:v1.0.0-rc.36-custom.2-20260911 . -``` + docker build -f Dockerfile.local-release -t local/new-api:v1.0.0-rc.38-custom.42-20260926 . -`Dockerfile.request-status` 会执行前端类型检查、相关 Vitest 与 oxlint 检查、Go 回归测试和生产构建。 +此入口从 Bun、Go 和 Debian 基础镜像构建前端与服务,不要求预先存在旧版 local/new-api 镜像。VERSION 用于编译版本号;Compose 如果设置了 VERSION 环境变量,也应同步更新。 + +其他 Dockerfile.* 和 build-fingerprint-backend.sh 保留为历史构建参考。部分增量构建文件依赖服务器上的旧版镜像或已编译二进制,新环境请使用 Dockerfile.local-release。 + +## 运行与升级 + +使用现有外部 PostgreSQL 和 Valkey,连接信息通过部署环境提供。仓库不包含生产凭据、数据库内容或线上 Compose 配置。 + +升级前备份数据库及 Compose 配置,部署后检查容器健康、版本号、主站分组状态和公开 uptime 页面。Uptime Kuma 的停用与域名反向代理属于部署配置,不会仅因替换应用镜像自动完成。 + +公开作品链接受 manxue.ai 的保留期限约束;没有公开链接的历史作品继续使用本站预览。 diff --git a/Dockerfile.compact-uptime b/Dockerfile.compact-uptime new file mode 100644 index 0000000..f313c94 --- /dev/null +++ b/Dockerfile.compact-uptime @@ -0,0 +1,3 @@ +FROM local/new-api:v1.0.0-rc.38-custom.41-20260926 +COPY new-api /new-api +LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.42-20260926" diff --git a/Dockerfile.deployed b/Dockerfile.deployed new file mode 100644 index 0000000..dac0392 --- /dev/null +++ b/Dockerfile.deployed @@ -0,0 +1,3 @@ +FROM local/new-api:v1.0.0-rc.38-custom.24-20260923 +COPY new-api /new-api +LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.25-20260923" diff --git a/Dockerfile.fingerprint-runtime b/Dockerfile.fingerprint-runtime new file mode 100644 index 0000000..151c21c --- /dev/null +++ b/Dockerfile.fingerprint-runtime @@ -0,0 +1,4 @@ +FROM local/new-api:v1.0.0-rc.38-custom.26-20260923 +COPY new-api /new-api +COPY LICENSE NOTICE THIRD-PARTY-LICENSES.md /licenses/ +COPY pkg/model_fingerprint/LICENSE-*.txt /licenses/model-fingerprint/ diff --git a/Dockerfile.local-release b/Dockerfile.local-release new file mode 100644 index 0000000..bd4d5ca --- /dev/null +++ b/Dockerfile.local-release @@ -0,0 +1,31 @@ +FROM oven/bun:1.4.0 AS web +WORKDIR /build/web +COPY web/package.json web/bun.lock ./ +RUN bun install --frozen-lockfile +COPY web/ ./ +RUN bun run build + +FROM golang:1.25.1 AS build +ENV GOMAXPROCS=2 GOFLAGS=-p=2 GO111MODULE=on CGO_ENABLED=0 GOWORK=off +ARG TARGETOS +ARG TARGETARCH +ENV GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} +WORKDIR /build +COPY . . +COPY --from=web /build/web/dist ./web/dist +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + go build -buildvcs=false -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api + +FROM debian:bookworm-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates tzdata libasan8 wget \ + && rm -rf /var/lib/apt/lists/* \ + && update-ca-certificates +COPY --from=build /build/new-api / +COPY LICENSE NOTICE THIRD-PARTY-LICENSES.md /licenses/ +COPY pkg/model_fingerprint/LICENSE-*.txt /licenses/model-fingerprint/ +LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.42-20260926" +EXPOSE 3000 +WORKDIR /data +ENTRYPOINT ["/new-api"] diff --git a/Dockerfile.public-pelican b/Dockerfile.public-pelican new file mode 100644 index 0000000..26fafe5 --- /dev/null +++ b/Dockerfile.public-pelican @@ -0,0 +1,3 @@ +FROM local/new-api:v1.0.0-rc.38-custom.38-20260926 +COPY new-api /new-api +LABEL org.opencontainers.image.version="v1.0.0-rc.38-custom.39-20260926" diff --git a/Dockerfile.request-status b/Dockerfile.request-status index a91c5e8..989cc50 100644 --- a/Dockerfile.request-status +++ b/Dockerfile.request-status @@ -8,9 +8,10 @@ WORKDIR /build/web COPY --from=webdeps /build/web/node_modules ./node_modules COPY web ./ COPY VERSION /build/VERSION +COPY pkg/billingexpr/testdata /build/pkg/billingexpr/testdata RUN npm run typecheck -RUN node node_modules/vitest/vitest.mjs run src/features/group-status/__tests__ src/features/pricing/__tests__/group-pricing.test.ts src/features/keys/lib/__tests__/auto-group-form.test.ts src/features/keys/components/__tests__/api-keys-mutate-drawer.test.tsx -RUN npx --no-install oxlint -c .oxlintrc.json src/features/group-status src/features/dashboard/types.ts src/features/keys/components/api-keys-mutate-drawer.tsx src/features/keys/lib/api-key-form.ts src/features/keys/types.ts +RUN node node_modules/vitest/vitest.mjs run src/features/usage-logs/components/__tests__ src/features/system-settings/request-policies src/features/group-status/__tests__ src/features/pricing/__tests__/group-pricing.test.ts src/features/keys/lib/__tests__/auto-group-form.test.ts src/features/keys/components/__tests__/api-keys-mutate-drawer.test.tsx src/features/keys/components/__tests__/astra-cost-notice.test.tsx src/features/channels/components/__tests__/responses-websocket-setting.test.tsx src/features/profile/__tests__/settings.test.tsx +RUN npx --no-install oxlint -c .oxlintrc.json src/features/group-status src/features/dashboard/types.ts src/features/keys/components/astra-cost-notice.tsx src/features/keys/components/__tests__/astra-cost-notice.test.tsx src/features/keys/components/api-keys-mutate-drawer.tsx src/features/keys/lib/api-key-form.ts src/features/keys/types.ts src/features/channels/components/astra-model-guard-setting.tsx src/features/channels/components/drawers/channel-mutate-drawer.tsx src/features/channels/components/__tests__/responses-websocket-setting.test.tsx src/features/channels/lib/channel-form.ts src/features/channels/lib/channel-configuration.ts src/features/profile/lib/user-settings.ts src/features/profile/types.ts src/features/usage-logs/components/routing-attempts.tsx src/features/usage-logs/components/model-badge.tsx src/features/usage-logs/components/common-logs-filter-bar.tsx RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) npm run build FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS build @@ -25,7 +26,12 @@ RUN go mod download COPY . . COPY --from=webbuild /build/web/dist ./web/dist RUN go test ./pkg/perf_metrics ./model ./controller ./service ./relay/helper -run 'TestGroupBuckets|TestRequestStatus|TestGroupModelPricing|TestGetEnabledGroupSetRequiresEnabledAbilityAndChannel|TestSelectableGroupsRequireEnabledChannel|TestAddTokenPersistsFixedBackupGroup|TestUpdateTokenAutoGroupsTriStateAndFixedBackup|TestAddTokenRejectsInvalidFixedBackupGroup|TestCacheGetRandomSatisfiedChannelUsesTokenAutoGroupsWhenGlobalAutoIsEmpty|TestCacheGetRandomSatisfiedChannelUsesFixedTokenBackupGroup|TestCacheGetRandomSatisfiedChannelSkipsUnavailableFixedPrimary|TestAppendStreamStatusTreatsClientGoneAsDisplayNormal|TestClientFacingOpenAIErrorHidesUpstreamDetails|TestClientFacingClaudeErrorHidesUpstreamDetails|TestClientFacingErrorKeepsLocalValidationMessage|TestPublicUpstreamErrorContent' -count=1 -RUN go test ./controller ./service -run 'TestFixedBackup|TestShouldSkipRetryAfterChannelAffinityFailure|TestShouldRetryHonorsPinRetryMode' -count=1 +RUN go test ./model ./controller ./service -run 'TestChannelGetSettingDefaultsAstraGuardOn|TestFixedBackup|TestShouldSkipRetryAfterChannelAffinityFailure|TestShouldRetryHonorsPinRetryMode|TestGroupPool|TestBuildGroupPoolTiers|TestNextTierIndex|TestGroupAvailability|TestGetGroupPool|TestSetGroupPool' -count=1 +RUN go test ./model ./service ./controller ./relay/helper -run 'TestRequestDetails|TestSelectionTrace|TestResponsesInterruptedStreamHealth|TestResponsesWebSocketReusesConnectionAndSettlesEachRequest|TestStreamScanner' -count=1 +RUN cd relaykit && GOWORK=off go build ./... +RUN go test ./relay -run 'TestResponseGuard|TestResponseModel' -count=1 +RUN go test ./controller ./middleware ./service -run 'TestTokenAstraMigration|TestRelayAttemptGuardResult|TestRelayAttemptSwitchSafety|TestTokenAuth|TestAstra' -count=1 +RUN go test ./pkg/model_fingerprint ./controller ./service ./router -run 'TestFingerprint|TestAstraSampling|TestSystemTask|TestChannelPermission' -count=1 RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a @@ -35,6 +41,7 @@ RUN apt-get update \ && update-ca-certificates COPY --from=build /build/new-api / COPY LICENSE NOTICE THIRD-PARTY-LICENSES.md /licenses/ +COPY pkg/model_fingerprint/LICENSE-*.txt /licenses/model-fingerprint/ EXPOSE 3000 WORKDIR /data -ENTRYPOINT ["/new-api"] \ No newline at end of file +ENTRYPOINT ["/new-api"] diff --git a/THIRD-PARTY-LICENSES.md b/THIRD-PARTY-LICENSES.md index e67e613..3f992f6 100644 --- a/THIRD-PARTY-LICENSES.md +++ b/THIRD-PARTY-LICENSES.md @@ -322,3 +322,7 @@ this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means. For more information, please refer to https://unlicense.org/ + +## Numerical model fingerprint assets + +The embedded bank and scorer in `pkg/model_fingerprint` are from [gpt-model-check](https://github.com/Katao123/gpt-model-check), commit `3d6716a0903cf061daea001d5d44ee410846634e`, using the ModelTrace bank dated 2026-09-05. The upstream gpt-model-check, ModelTrace, and is-gpt-nerfed MIT notices are included alongside the sources and under `/licenses/model-fingerprint/` in the image. Relative candidate weights are not calibrated identity probabilities. diff --git a/VERSION b/VERSION index fe2106a..b6ad8f4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0.0-rc.36-custom.2 +v1.0.0-rc.38-custom.42-20260926 \ No newline at end of file diff --git a/build-fingerprint-backend.sh b/build-fingerprint-backend.sh new file mode 100644 index 0000000..f3a8ff8 --- /dev/null +++ b/build-fingerprint-backend.sh @@ -0,0 +1,9 @@ +set -eu +go test ./pkg/perf_metrics ./model ./controller ./service ./relay/helper -run 'TestGroupBuckets|TestRequestStatus|TestGroupModelPricing|TestGetEnabledGroupSetRequiresEnabledAbilityAndChannel|TestSelectableGroupsRequireEnabledChannel|TestAddTokenPersistsFixedBackupGroup|TestUpdateTokenAutoGroupsTriStateAndFixedBackup|TestAddTokenRejectsInvalidFixedBackupGroup|TestCacheGetRandomSatisfiedChannelUsesTokenAutoGroupsWhenGlobalAutoIsEmpty|TestCacheGetRandomSatisfiedChannelUsesFixedTokenBackupGroup|TestCacheGetRandomSatisfiedChannelSkipsUnavailableFixedPrimary|TestAppendStreamStatusTreatsClientGoneAsDisplayNormal|TestClientFacingOpenAIErrorHidesUpstreamDetails|TestClientFacingClaudeErrorHidesUpstreamDetails|TestClientFacingErrorKeepsLocalValidationMessage|TestPublicUpstreamErrorContent' -count=1 +go test ./model ./controller ./service -run 'TestChannelGetSettingDefaultsAstraGuardOn|TestFixedBackup|TestShouldSkipRetryAfterChannelAffinityFailure|TestShouldRetryHonorsPinRetryMode|TestGroupPool|TestBuildGroupPoolTiers|TestNextTierIndex|TestGroupAvailability|TestGetGroupPool|TestSetGroupPool' -count=1 +go test ./model ./service ./controller ./relay/helper -run 'TestRequestDetails|TestSelectionTrace|TestResponsesInterruptedStreamHealth|TestResponsesWebSocketReusesConnectionAndSettlesEachRequest|TestStreamScanner' -count=1 +(cd relaykit && GOWORK=off go build ./...) +go test ./relay -run 'TestResponseGuard|TestResponseModel' -count=1 +go test ./controller ./middleware ./service -run 'TestTokenAstraMigration|TestRelayAttemptGuardResult|TestRelayAttemptSwitchSafety|TestTokenAuth|TestAstra' -count=1 +go test ./pkg/model_fingerprint ./controller ./service ./router -run 'TestFingerprint|TestAstraSampling|TestSystemTask|TestChannelPermission' -count=1 +go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api diff --git a/common/advanced_custom_presets.go b/common/advanced_custom_presets.go new file mode 100644 index 0000000..34c229c --- /dev/null +++ b/common/advanced_custom_presets.go @@ -0,0 +1,28 @@ +package common + +import ( + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/relaykit/dto" +) + +// GetAdvancedCustomPreset returns fresh defaults. +func GetAdvancedCustomPreset(channelType int) *dto.AdvancedCustomConfig { + if channelType != constant.ChannelTypeVLLM && channelType != constant.ChannelTypeSGLang { + return nil + } + config := &dto.AdvancedCustomConfig{} + for _, path := range []string{"/v1/chat/completions", "/v1/completions", "/v1/responses", "/v1/embeddings", "/v1/messages", dto.AdvancedCustomModelListPath} { + config.Routes = append(config.Routes, dto.AdvancedCustomRoute{ + IncomingPath: path, UpstreamPath: path, Converter: "none", + Auth: &dto.AdvancedCustomRouteAuth{Type: dto.AdvancedCustomAuthTypeHeader, Name: "Authorization", Value: "Bearer {api_key}"}, + }) + } + if channelType == constant.ChannelTypeSGLang { + for _, path := range []string{"/v1/rerank", "/rerank"} { + config.Routes = append(config.Routes, dto.AdvancedCustomRoute{ + IncomingPath: path, UpstreamPath: "/v1/rerank", Converter: dto.AdvancedCustomConverterSGLangRerank, + }) + } + } + return config +} diff --git a/common/api_type.go b/common/api_type.go index b4ca706..560dd77 100644 --- a/common/api_type.go +++ b/common/api_type.go @@ -75,7 +75,7 @@ func ChannelType2APIType(channelType int) (int, bool) { apiType = constant.APITypeReplicate case constant.ChannelTypeCodex: apiType = constant.APITypeCodex - case constant.ChannelTypeAdvancedCustom: + case constant.ChannelTypeAdvancedCustom, constant.ChannelTypeVLLM, constant.ChannelTypeSGLang: apiType = constant.APITypeAdvancedCustom case constant.ChannelTypeSub2API: apiType = constant.APITypeSub2API diff --git a/common/audio.go b/common/audio.go index 466cd2c..eb210dc 100644 --- a/common/audio.go +++ b/common/audio.go @@ -5,6 +5,7 @@ import ( "encoding/binary" "fmt" "io" + "strings" "github.com/abema/go-mp4" "github.com/go-audio/aiff" @@ -20,6 +21,7 @@ import ( // 它不再依赖外部的 ffmpeg 或 ffprobe 程序。 func GetAudioDuration(ctx context.Context, f io.ReadSeeker, ext string) (duration float64, err error) { SysLog(fmt.Sprintf("GetAudioDuration: ext=%s", ext)) + ext = strings.ToLower(ext) // 根据文件扩展名选择解析器 switch ext { case ".mp3": diff --git a/common/audio_case_test.go b/common/audio_case_test.go new file mode 100644 index 0000000..394ae5f --- /dev/null +++ b/common/audio_case_test.go @@ -0,0 +1,54 @@ +package common + +import ( + "bytes" + "context" + "encoding/binary" + "strings" + "testing" +) + +// minimalWAV builds a valid 16-bit PCM mono WAV with the given number of sample frames. +func minimalWAV(sampleRate uint32, frames int) []byte { + const numChannels = 1 + const bitsPerSample = 16 + dataSize := frames * numChannels * (bitsPerSample / 8) + buf := &bytes.Buffer{} + buf.WriteString("RIFF") + binary.Write(buf, binary.LittleEndian, uint32(36+dataSize)) + buf.WriteString("WAVE") + buf.WriteString("fmt ") + binary.Write(buf, binary.LittleEndian, uint32(16)) + binary.Write(buf, binary.LittleEndian, uint16(1)) // PCM + binary.Write(buf, binary.LittleEndian, uint16(numChannels)) + binary.Write(buf, binary.LittleEndian, sampleRate) + byteRate := sampleRate * numChannels * (bitsPerSample / 8) + binary.Write(buf, binary.LittleEndian, byteRate) + binary.Write(buf, binary.LittleEndian, uint16(numChannels*(bitsPerSample/8))) + binary.Write(buf, binary.LittleEndian, uint16(bitsPerSample)) + buf.WriteString("data") + binary.Write(buf, binary.LittleEndian, uint32(dataSize)) + buf.Write(make([]byte, dataSize)) + return buf.Bytes() +} + +func TestGetAudioDurationCaseInsensitiveExt(t *testing.T) { + wav := minimalWAV(8000, 8000) // 1 second + + for _, ext := range []string{".wav", ".WAV", ".Wav"} { + d, err := GetAudioDuration(context.Background(), bytes.NewReader(wav), ext) + if err != nil { + t.Fatalf("ext %q: unexpected error: %v", ext, err) + } + if d < 0.99 || d > 1.01 { + t.Fatalf("ext %q: expected ~1s duration, got %v", ext, d) + } + } +} + +func TestGetAudioDurationUppercaseNotUnsupported(t *testing.T) { + _, err := GetAudioDuration(context.Background(), bytes.NewReader([]byte("not audio")), ".MP3") + if err != nil && strings.Contains(err.Error(), "unsupported audio format") { + t.Fatalf("uppercase extension rejected as unsupported: %v", err) + } +} diff --git a/common/endpoint_type.go b/common/endpoint_type.go index 126df3c..1e5a33c 100644 --- a/common/endpoint_type.go +++ b/common/endpoint_type.go @@ -28,6 +28,8 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI} case constant.ChannelTypeXai: endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI, constant.EndpointTypeOpenAIResponse} + case constant.ChannelTypeVLLM, constant.ChannelTypeSGLang: + endpointTypes = GetAdvancedCustomPreset(channelType).SupportedEndpointTypesForModel(modelName) case constant.ChannelTypeSora: endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIVideo} case constant.ChannelTypeSub2API, constant.ChannelTypeNewAPI: diff --git a/common/json.go b/common/json.go index ee095f4..fd028b4 100644 --- a/common/json.go +++ b/common/json.go @@ -9,6 +9,8 @@ import ( "github.com/gin-gonic/gin/binding" ) +type RawMessage = json.RawMessage + // hostJSONCodec is the single place where the host chooses its JSON engine. // Swap the implementation here (for example to sonic.ConfigStd) and every // common.* and kitutil.* JSON helper, including relaykit DTO (un)marshalling, @@ -73,11 +75,11 @@ func IndentJson(data []byte) ([]byte, error) { return buffer.Bytes(), nil } -func GetJsonType(data json.RawMessage) string { +func GetJsonType(data RawMessage) string { return kitutil.GetJsonType(data) } // JsonRawMessageToString returns JSON strings as their decoded value and other JSON values as raw text. -func JsonRawMessageToString(data json.RawMessage) string { +func JsonRawMessageToString(data RawMessage) string { return kitutil.JsonRawMessageToString(data) } diff --git a/common/model.go b/common/model.go index 4ebc7b5..eb8b36f 100644 --- a/common/model.go +++ b/common/model.go @@ -2,6 +2,9 @@ package common import "strings" +// ZImagePromptExtendMultiplier is the legacy Ali image request surcharge. +const ZImagePromptExtendMultiplier = 2 + var ( // OpenAIResponseOnlyModels is a list of models that are only available for OpenAI responses. OpenAIResponseOnlyModels = []string{ @@ -12,7 +15,20 @@ var ( ImageGenerationModels = []string{ "dall-e-3", "dall-e-2", - "gpt-image-1", + "prefix:dall-e", // Deprecated upstream models; retained for compatible routes. + "gpt-image-", + "qwen-image", + "z-image", + "wan2.7-image-pro", + "wan2.7-image", + "wan2.6-image", + "wan2.6-t2i", + "wan2.5-t2i-preview", + "wan2.2-t2i-flash", + "wan2.2-t2i-plus", + "wanx2.1-t2i-turbo", + "wanx2.1-t2i-plus", + "wanx2.0-t2i-turbo", "prefix:imagen-", "flux-", "flux.1-", @@ -38,10 +54,13 @@ func IsOpenAIResponseOnlyModel(modelName string) bool { func IsImageGenerationModel(modelName string) bool { modelName = strings.ToLower(modelName) for _, m := range ImageGenerationModels { - if strings.Contains(modelName, m) { - return true + if prefix, ok := strings.CutPrefix(m, "prefix:"); ok { + if strings.HasPrefix(modelName, prefix) { + return true + } + continue } - if strings.HasPrefix(m, "prefix:") && strings.HasPrefix(modelName, strings.TrimPrefix(m, "prefix:")) { + if strings.Contains(modelName, m) { return true } } diff --git a/common/model_test.go b/common/model_test.go new file mode 100644 index 0000000..60538e9 --- /dev/null +++ b/common/model_test.go @@ -0,0 +1,28 @@ +package common_test + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/stretchr/testify/assert" +) + +func TestWanEndpointsDistinguishImagesFromVideos(t *testing.T) { + for _, name := range []string{ + "wan2.7-image-pro", "wan2.7-image", "wan2.6-image", "wan2.6-t2i", + "wan2.5-t2i-preview", "wan2.2-t2i-flash", "wan2.2-t2i-plus", + "wanx2.1-t2i-turbo", "wanx2.1-t2i-plus", "wanx2.0-t2i-turbo", + } { + t.Run(name, func(t *testing.T) { + assert.Contains(t, common.GetEndpointTypesByChannelType(constant.ChannelTypeAli, name), constant.EndpointTypeImageGeneration) + }) + } + for _, name := range []string{ + "wanx2.1-t2v-plus", "wanx2.1-t2v-turbo", "wanx2.1-i2v-plus", "wanx2.1-i2v-turbo", + } { + t.Run(name, func(t *testing.T) { + assert.NotContains(t, common.GetEndpointTypesByChannelType(constant.ChannelTypeAli, name), constant.EndpointTypeImageGeneration) + }) + } +} diff --git a/common/rate-limit.go b/common/rate-limit.go index 301c101..a0880f4 100644 --- a/common/rate-limit.go +++ b/common/rate-limit.go @@ -1,70 +1,211 @@ package common import ( + "container/list" "sync" "time" ) +// rateLimitRequest stores one accepted request in a key's sliding window. +type rateLimitRequest struct { + next *rateLimitRequest + timestamp int64 +} + +// rateLimitQueue keeps accepted requests ordered from oldest to newest. +type rateLimitQueue struct { + head *rateLimitRequest + tail *rateLimitRequest + length int +} + +// append adds an accepted request without preallocating for the configured limit. +func (q *rateLimitQueue) append(timestamp int64) { + request := &rateLimitRequest{timestamp: timestamp} + if q.tail == nil { + q.head = request + q.tail = request + } else { + q.tail.next = request + q.tail = request + } + q.length++ +} + +// removeExpired releases every expired request at the front of the queue. +func (q *rateLimitQueue) removeExpired(now int64, duration int64) { + if q.head == nil || now-q.head.timestamp < duration { + return + } + + // Requests are time ordered, so an expired tail means the whole queue expired. + if now-q.tail.timestamp >= duration { + q.clear() + return + } + + for now-q.head.timestamp >= duration { + expired := q.head + q.head = expired.next + expired.next = nil + q.length-- + } +} + +// clear releases the whole request chain when its key expires. +func (q *rateLimitQueue) clear() { + q.head = nil + q.tail = nil + q.length = 0 +} + +// rateLimitEntry is both a rate-limit bucket and a node in the key-level LRU. +type rateLimitEntry struct { + lastActive time.Time + element *list.Element + requests rateLimitQueue + key string +} + +// InMemoryRateLimiter implements a sliding-window limiter with idle-key eviction. +// Reservations are tracked apart from the LRU so an in-flight request survives +// its key being evicted while it is still running. type InMemoryRateLimiter struct { - store map[string]*[]int64 + store map[string]*rateLimitEntry + lru *list.List + reservations map[string]int mutex sync.Mutex expirationDuration time.Duration } +// Init initializes the limiter once. Repeated calls leave the first configuration unchanged. func (l *InMemoryRateLimiter) Init(expirationDuration time.Duration) { - if l.store == nil { - l.mutex.Lock() - if l.store == nil { - l.store = make(map[string]*[]int64) - l.expirationDuration = expirationDuration - if expirationDuration > 0 { - go l.clearExpiredItems() - } - } - l.mutex.Unlock() + l.mutex.Lock() + defer l.mutex.Unlock() + + if l.store != nil { + return + } + + l.store = make(map[string]*rateLimitEntry) + l.lru = list.New() + l.reservations = make(map[string]int) + l.expirationDuration = expirationDuration + if expirationDuration > 0 { + go l.clearExpiredItems(time.NewTicker(expirationDuration).C) } } -func (l *InMemoryRateLimiter) clearExpiredItems() { - for { - time.Sleep(l.expirationDuration) - l.mutex.Lock() - now := time.Now().Unix() - for key := range l.store { - queue := l.store[key] - size := len(*queue) - if size == 0 || now-(*queue)[size-1] > int64(l.expirationDuration.Seconds()) { - delete(l.store, key) - } - } - l.mutex.Unlock() +// clearExpiredItems periodically removes expired entries from the LRU tail. +func (l *InMemoryRateLimiter) clearExpiredItems(ticks <-chan time.Time) { + for now := range ticks { + l.deleteExpiredEntries(now) } } +// deleteExpiredEntries walks only the oldest LRU entries and stops at the first active key. +func (l *InMemoryRateLimiter) deleteExpiredEntries(now time.Time) { + if l.expirationDuration <= 0 { + return + } + + l.mutex.Lock() + defer l.mutex.Unlock() + + for { + oldest := l.lru.Back() + if oldest == nil { + return + } + + entry := oldest.Value.(*rateLimitEntry) + if now.Sub(entry.lastActive) < l.expirationDuration { + return + } + + delete(l.store, entry.key) + l.lru.Remove(oldest) + entry.element = nil + entry.requests.clear() + } +} + +// touchEntry returns the key's bucket, creating it on demand, and marks it as +// the most recently active key. The caller must hold the mutex. +func (l *InMemoryRateLimiter) touchEntry(key string, now time.Time) *rateLimitEntry { + entry, ok := l.store[key] + if !ok { + entry = &rateLimitEntry{ + key: key, + lastActive: now, + } + entry.element = l.lru.PushFront(entry) + l.store[key] = entry + return entry + } + entry.lastActive = now + l.lru.MoveToFront(entry.element) + return entry +} + // Request parameter duration's unit is seconds func (l *InMemoryRateLimiter) Request(key string, maxRequestNum int, duration int64) bool { l.mutex.Lock() defer l.mutex.Unlock() - // [old <-- new] - queue, ok := l.store[key] - now := time.Now().Unix() - if ok { - if len(*queue) < maxRequestNum { - *queue = append(*queue, now) - return true - } else { - if now-(*queue)[0] >= duration { - *queue = (*queue)[1:] - *queue = append(*queue, now) - return true - } else { - return false - } - } - } else { - s := make([]int64, 0, maxRequestNum) - l.store[key] = &s - *(l.store[key]) = append(*(l.store[key]), now) + + now := time.Now() + entry := l.touchEntry(key, now) + entry.requests.removeExpired(now.Unix(), duration) + + allowed := entry.requests.length < maxRequestNum + if allowed { + entry.requests.append(now.Unix()) } - return true + + return allowed +} + +// RateLimitReservation counts an active request towards admission until its +// outcome is known. Failed requests release the slot without consuming quota. +type RateLimitReservation struct { + limiter *InMemoryRateLimiter + key string + once sync.Once +} + +// Reserve admits a request when the key's accepted requests plus its in-flight +// reservations stay below maxRequests. It returns nil when the key is saturated. +func (l *InMemoryRateLimiter) Reserve(key string, maxRequests int, duration int64) *RateLimitReservation { + l.mutex.Lock() + defer l.mutex.Unlock() + + now := time.Now() + entry := l.touchEntry(key, now) + entry.requests.removeExpired(now.Unix(), duration) + if entry.requests.length+l.reservations[key] >= maxRequests { + return nil + } + l.reservations[key]++ + return &RateLimitReservation{limiter: l, key: key} +} + +// Complete releases the reservation. A successful outcome records the request +// in the key's sliding window; a failed one only frees the slot. +func (r *RateLimitReservation) Complete(success bool) { + if r == nil { + return + } + r.once.Do(func() { + l := r.limiter + l.mutex.Lock() + defer l.mutex.Unlock() + l.reservations[r.key]-- + if l.reservations[r.key] == 0 { + delete(l.reservations, r.key) + } + if success { + now := time.Now() + l.touchEntry(r.key, now).requests.append(now.Unix()) + } + }) } diff --git a/common/rate-limit_test.go b/common/rate-limit_test.go new file mode 100644 index 0000000..5809f37 --- /dev/null +++ b/common/rate-limit_test.go @@ -0,0 +1,150 @@ +package common + +import ( + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInMemoryRateLimiterAllocatesTimestampsOnDemand(t *testing.T) { + var limiter InMemoryRateLimiter + limiter.Init(0) + + require.True(t, limiter.Request("client", 1_000_000, 60)) + + entry := limiter.store["client"] + require.NotNil(t, entry) + assert.Equal(t, 1, entry.requests.length) + assert.Same(t, entry.requests.head, entry.requests.tail) + assert.Nil(t, entry.requests.head.next) + + limiter.deleteExpiredEntries(time.Now().Add(time.Hour)) + assert.Contains(t, limiter.store, "client") +} + +func TestInMemoryRateLimiterEnforcesWindowBoundary(t *testing.T) { + var queue rateLimitQueue + queue.append(100) + queue.append(100) + + queue.removeExpired(159, 60) + assert.Equal(t, 2, queue.length) + + queue.removeExpired(160, 60) + assert.Zero(t, queue.length) + assert.Nil(t, queue.head) + assert.Nil(t, queue.tail) +} + +func TestInMemoryRateLimiterRemovesAllExpiredRequestsPerCall(t *testing.T) { + var queue rateLimitQueue + for range 5 { + queue.append(100) + } + queue.append(110) + + queue.removeExpired(110, 10) + assert.Equal(t, 1, queue.length) + require.NotNil(t, queue.head) + assert.EqualValues(t, 110, queue.head.timestamp) + assert.Same(t, queue.head, queue.tail) +} + +func TestInMemoryRateLimiterKeepsNonExpiredRequests(t *testing.T) { + var queue rateLimitQueue + for range 3 { + queue.append(100) + } + + queue.removeExpired(109, 10) + assert.Equal(t, 3, queue.length) + assert.NotNil(t, queue.head) + assert.NotNil(t, queue.tail) +} + +func TestInMemoryRateLimiterEvictsOnlyExpiredLRUTail(t *testing.T) { + var limiter InMemoryRateLimiter + limiter.Init(0) + limiter.expirationDuration = 10 * time.Second + + require.True(t, limiter.Request("active", 1, 100)) + require.True(t, limiter.Request("idle", 1, 100)) + assert.False(t, limiter.Request("active", 1, 100)) + + assert.Equal(t, "active", limiter.lru.Front().Value.(*rateLimitEntry).key) + assert.Equal(t, "idle", limiter.lru.Back().Value.(*rateLimitEntry).key) + + idleEntry := limiter.store["idle"] + now := time.Now() + idleEntry.lastActive = now.Add(-10 * time.Second) + limiter.store["active"].lastActive = now + limiter.deleteExpiredEntries(now) + + assert.NotContains(t, limiter.store, "idle") + assert.Contains(t, limiter.store, "active") + assert.Equal(t, 1, limiter.lru.Len()) + assert.Nil(t, idleEntry.element) + assert.Nil(t, idleEntry.requests.head) + assert.Nil(t, idleEntry.requests.tail) + assert.Zero(t, idleEntry.requests.length) +} + +func TestInMemoryRateLimiterCleanupTicksEvictExpiredKeys(t *testing.T) { + var limiter InMemoryRateLimiter + limiter.Init(0) + limiter.expirationDuration = 10 * time.Second + require.True(t, limiter.Request("idle", 1, 100)) + + now := time.Now() + limiter.store["idle"].lastActive = now.Add(-10 * time.Second) + ticks := make(chan time.Time, 1) + done := make(chan struct{}) + go func() { + limiter.clearExpiredItems(ticks) + close(done) + }() + + ticks <- now + close(ticks) + <-done + + assert.Empty(t, limiter.store) + assert.Zero(t, limiter.lru.Len()) +} + +func TestInMemoryRateLimiterInitKeepsFirstConfiguration(t *testing.T) { + var limiter InMemoryRateLimiter + limiter.Init(time.Hour) + limiter.Init(0) + + assert.Equal(t, time.Hour, limiter.expirationDuration) + require.True(t, limiter.Request("client", 1, 60)) + limiter.deleteExpiredEntries(time.Now().Add(time.Hour)) + assert.NotContains(t, limiter.store, "client") +} + +func TestInMemoryRateLimiterConcurrentInitializationAndRequests(t *testing.T) { + var limiter InMemoryRateLimiter + var allowed atomic.Int64 + var waitGroup sync.WaitGroup + + for range 100 { + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + limiter.Init(0) + if limiter.Request("client", 10, 60) { + allowed.Add(1) + } + }() + } + + waitGroup.Wait() + assert.EqualValues(t, 10, allowed.Load()) + assert.Len(t, limiter.store, 1) + assert.Equal(t, 10, limiter.store["client"].requests.length) +} diff --git a/constant/channel.go b/constant/channel.go index 354dd0e..ec1e891 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -59,10 +59,14 @@ const ( ChannelTypeSub2API = 59 ChannelTypeNewAPI = 60 ChannelTypeTaskPlugin = 61 + ChannelTypeVLLM = 62 + ChannelTypeSGLang = 63 ChannelTypeDummy // this one is only for count, do not add any channel after this ) +// ChannelBaseURLs 保存各渠道类型的内置默认 Base URL。 +// 非空值会通过 /api/channel/default_base_urls 下发到前端,作为渠道表单的 API 地址占位提示。 var ChannelBaseURLs = []string{ "", // 0 "https://api.openai.com", // 1 @@ -126,6 +130,8 @@ var ChannelBaseURLs = []string{ "", //59 "", //60 "", //61 + "", //62 + "", //63 } func GetChannelBaseURL(channelType int) string { @@ -194,6 +200,8 @@ var ChannelTypeNames = map[int]string{ ChannelTypeSub2API: "Sub2API", ChannelTypeNewAPI: "New API", ChannelTypeTaskPlugin: "Task Plugin", + ChannelTypeVLLM: "vLLM", + ChannelTypeSGLang: "SGLang", } func GetChannelTypeName(channelType int) string { @@ -226,3 +234,13 @@ var ChannelSpecialBases = map[string]ChannelSpecialBase{ OpenAIBaseURL: "https://ark.cn-beijing.volces.com/api/coding/v3", }, } + +// IsAdvancedCustomChannel includes named channels backed by route presets. +func IsAdvancedCustomChannel(channelType int) bool { + switch channelType { + case ChannelTypeAdvancedCustom, ChannelTypeVLLM, ChannelTypeSGLang: + return true + default: + return false + } +} diff --git a/constant/context_key.go b/constant/context_key.go index 994e657..cd95f90 100644 --- a/constant/context_key.go +++ b/constant/context_key.go @@ -11,16 +11,20 @@ const ( ContextKeyRequestStartTime ContextKey = "request_start_time" /* token related keys */ - ContextKeyTokenUnlimited ContextKey = "token_unlimited_quota" - ContextKeyTokenKey ContextKey = "token_key" - ContextKeyTokenId ContextKey = "token_id" - ContextKeyTokenGroup ContextKey = "token_group" - ContextKeyOriginTasks ContextKey = "origin_tasks" - ContextKeyChannelConstraints ContextKey = "channel_constraints" - ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled" - ContextKeyTokenModelLimit ContextKey = "token_model_limit" - ContextKeyTokenCrossGroupRetry ContextKey = "token_cross_group_retry" - ContextKeyTokenAutoGroups ContextKey = "token_auto_groups" + ContextKeyTokenUnlimited ContextKey = "token_unlimited_quota" + ContextKeyTokenKey ContextKey = "token_key" + ContextKeyTokenId ContextKey = "token_id" + ContextKeyTokenGroup ContextKey = "token_group" + ContextKeyOriginTasks ContextKey = "origin_tasks" + ContextKeyChannelConstraints ContextKey = "channel_constraints" + ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled" + ContextKeyTokenModelLimit ContextKey = "token_model_limit" + ContextKeyTokenCrossGroupRetry ContextKey = "token_cross_group_retry" + ContextKeyTokenAstraDetectionDisabled ContextKey = "token_astra_detection_disabled" + ContextKeyTokenAstraAutoSwitch ContextKey = "token_astra_auto_switch" + ContextKeyTokenAutoGroups ContextKey = "token_auto_groups" + ContextKeyTokenGroupPool ContextKey = "token_group_pool" + ContextKeyTokenFailedGroups ContextKey = "token_failed_groups" /* channel related keys */ ContextKeyChannelId ContextKey = "channel_id" @@ -66,8 +70,9 @@ const ( ContextKeyAdminRejectReason ContextKey = "admin_reject_reason" // ContextKeyLanguage stores the user's language preference for i18n - ContextKeyLanguage ContextKey = "language" - ContextKeyIsStream ContextKey = "is_stream" + ContextKeyLanguage ContextKey = "language" + ContextKeyIsStream ContextKey = "is_stream" + ContextKeyResponseStreamStatus ContextKey = "response_stream_status" // ContextKeyAuditLogged marks that the current request has already recorded // a manage/operation audit log inside the handler. When set, the admin-audit diff --git a/controller/astra_sampling.go b/controller/astra_sampling.go new file mode 100644 index 0000000..5262c70 --- /dev/null +++ b/controller/astra_sampling.go @@ -0,0 +1,256 @@ +package controller + +import ( + "bytes" + "context" + "crypto/rand" + "errors" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/middleware" + "github.com/QuantumNous/new-api/model" + fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint" + "github.com/QuantumNous/new-api/relay" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relaykit/dto" + "github.com/QuantumNous/new-api/relaykit/types" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" +) + +type astraSamplingHandler struct{} + +type astraSamplingTaskPayload struct { + Force bool `json:"force,omitempty"` +} + +func (astraSamplingHandler) Type() string { return service.AstraSamplingTaskType } +func (astraSamplingHandler) Enabled() bool { return service.AstraSamplingEnabled() } +func (astraSamplingHandler) Interval() time.Duration { return service.AstraSamplingSchedulerInterval() } +func (astraSamplingHandler) ScheduleFromCreation() bool { return true } +func (astraSamplingHandler) NewPayload() any { return nil } + +func (astraSamplingHandler) ScheduleDue(ctx context.Context, now time.Time) (bool, error) { + channels, err := service.AstraSamplingChannelsDue(ctx, now) + return len(channels) > 0, err +} + +func (astraSamplingHandler) Run(parent context.Context, task *model.SystemTask, runnerID string) { + ctx, cancel := context.WithTimeout(parent, 25*time.Minute) + defer cancel() + payload := astraSamplingTaskPayload{} + if err := task.DecodePayload(&payload); err != nil { + finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusFailed, nil, err) + return + } + channels, err := service.AstraSamplingChannelsDue(ctx, time.Now()) + if payload.Force { + channels, err = service.AstraSamplingChannels(ctx) + } + if err != nil { + finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusFailed, nil, errors.New("channel query failed")) + return + } + state := service.AstraSamplingState{Rounds: []service.AstraSamplingRound{}} + var mu sync.Mutex + var persistErr error + // Persist before sending, and after every probe. A failed lease or restart + // never resumes a paid probe; unfinished records remain visible as errors. + persist := func() bool { + if persistErr != nil { + return false + } + persistErr = model.UpdateSystemTaskState(task.TaskID, runnerID, state) + if persistErr != nil { + cancel() + } + return persistErr == nil + } + jobs := make(chan int) + var workers sync.WaitGroup + for worker := 0; worker < 2; worker++ { + workers.Add(1) + go func() { + defer workers.Done() + for id := range jobs { + if ctx.Err() != nil { + continue + } + mu.Lock() + index := len(state.Rounds) + state.Rounds = append(state.Rounds, service.AstraSamplingRound{ID: common.GetRandomString(16), ChannelID: id, StartedAt: time.Now().Unix(), Version: fingerprint.Version, Result: fingerprint.Result{Status: "running"}}) + ok := persist() + mu.Unlock() + if !ok { + continue + } + channel, loadErr := model.GetChannelById(id, true) + samples := []fingerprint.Sample{} + reason := "" + usage := []fingerprint.ProbeResponse{} + if loadErr != nil || channel.Status != common.ChannelStatusEnabled { + reason = "channel_unavailable" + } else { + for probe := 0; probe < 3 && ctx.Err() == nil; probe++ { + mu.Lock() + state.Rounds[index].Sent++ + ok = persist() + mu.Unlock() + if !ok { + break + } + answer, count, probeErr := runAstraFingerprintProbe(ctx, channel) + usage = append(usage, answer) + if probeErr != nil { + if errors.Is(probeErr, fingerprint.ErrInvalidSample) { + reason = "invalid_sample" + } else { + reason = "transport_error" + break + } + } else { + samples = append(samples, fingerprint.Sample{Text: answer.Text, Count: count}) + } + mu.Lock() + state.Rounds[index].Usage = append([]fingerprint.ProbeResponse(nil), usage...) + ok = persist() + mu.Unlock() + if !ok { + break + } + } + } + result, scoreErr := fingerprint.Score(samples) + if scoreErr != nil { + reason = "scoring_error" + } + if ctx.Err() != nil { + reason = "interrupted" + } + if reason != "" && reason != "invalid_sample" { + result.Status = "error" + } + mu.Lock() + state.Rounds[index].Result = result + state.Rounds[index].Reason = reason + state.Rounds[index].Usage = usage + state.Rounds[index].FinishedAt = time.Now().Unix() + if persist() && !payload.Force { + if err := model.ApplyDetectionScheduleResult(ctx, id, "fingerprint", state.Rounds[index].ID, state.Rounds[index].FinishedAt, service.FingerprintScheduleOutcome(service.AstraSamplingResultStatus(result))); err != nil { + persistErr = err + cancel() + } + } + mu.Unlock() + } + }() + } + for _, channel := range channels { + select { + case jobs <- channel.Id: + case <-ctx.Done(): + } + } + close(jobs) + workers.Wait() + if persistErr != nil || ctx.Err() != nil { + finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusFailed, nil, errors.New("sampling interrupted")) + return + } + finishSystemTaskHandler(task, runnerID, model.SystemTaskStatusSucceeded, map[string]any{"channels": len(state.Rounds), "model": fingerprint.Model}, nil) +} + +func runAstraFingerprintProbe(parent context.Context, channel *model.Channel, paid ...bool) (fingerprint.ProbeResponse, int, error) { + ctx, cancel := context.WithTimeout(parent, 180*time.Second) + defer cancel() + seed, err := rand.Int(rand.Reader, big.NewInt(82)) + if err != nil { + return fingerprint.ProbeResponse{}, 0, err + } + count := 292 + int(seed.Int64()/2) + prompt := fmt.Sprintf("Directly choose %d integers from 1 through 355, inclusive. Allow repeats. Do not sort, balance frequencies, repair duplicates, or deliberately create an arithmetic pattern.\nReply directly with one JSON integer array and no explanation. Do not call tools, read files, execute code or ask another model. Do not continue the preceding task.", count) + if seed.Int64()%2 == 0 { + prompt = fmt.Sprintf("直接选择 %d 个 1 到 355(含边界)的整数。允许重复;不要排序、平衡频数、修复重复,也不要刻意构造等差规律。\n直接回答一个 JSON 整数数组,不要解释。不要调用工具、读文件、运行代码或让其他模型代答;不要继续之前的任务。", count) + } + input, _ := common.Marshal([]map[string]any{{"role": "user", "content": []map[string]string{{"type": "input_text", "text": prompt}}}}) + stream := true + limit := uint(4096) + request := &dto.OpenAIResponsesRequest{Model: fingerprint.Model, Input: input, Instructions: []byte(`""`), Stream: &stream, MaxOutputTokens: &limit, Store: []byte("false"), Tools: []byte("[]"), ToolChoice: []byte(`"none"`)} + encoded, err := common.Marshal(request) + if err != nil { + return fingerprint.ProbeResponse{}, count, err + } + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, "/v1/responses", bytes.NewReader(encoded)) + c.Request.Header.Set("Content-Type", "application/json") + if apiErr := middleware.SetupContextForSelectedChannel(c, channel, fingerprint.Model); apiErr != nil { + return fingerprint.ProbeResponse{}, count, errors.New("channel setup failed") + } + info, err := relaycommon.GenRelayInfo(c, types.RelayFormatOpenAIResponses, request, nil) + if err != nil { + return fingerprint.ProbeResponse{}, count, err + } + info.IsChannelTest = true + info.DisablePing = true + info.AstraDetectionDisabled = true + info.AstraAutoSwitch = false + adaptor, body, closer, apiErr := relay.PrepareResponsesRequest(c, info, request) + if closer != nil { + defer closer.Close() + } + if apiErr != nil { + return fingerprint.ProbeResponse{}, count, errors.New("probe preparation failed") + } + if len(paid) > 0 && paid[0] { + bounded, boundedCloser, boundErr := boundDetectionBody(body, request) + if boundErr != nil { + return fingerprint.ProbeResponse{}, count, boundErr + } + defer boundedCloser.Close() + body = bounded + } + response, err := adaptor.DoRequest(c, info, body) + if err != nil { + return fingerprint.ProbeResponse{}, count, errors.New("probe transport failed") + } + resp, ok := response.(*http.Response) + if !ok || resp == nil { + return fingerprint.ProbeResponse{}, count, errors.New("unsupported probe transport") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fingerprint.ProbeResponse{}, count, errors.New("probe HTTP failure") + } + // Deliberately bypass DoResponse: this is a platform-funded probe, with no + // customer token, wallet charge, session binding or generated response relay. + answer, err := fingerprint.ReadResponse(resp.Body, resp.Header.Get("Content-Type")) + return answer, count, err +} + +func TestAstraFingerprint(c *gin.Context) { + if !service.AstraSamplingEnabled() { + c.JSON(http.StatusConflict, gin.H{"success": false, "message": "Astra sampling is disabled"}) + return + } + latest, err := model.GetLatestSystemTasks([]string{service.AstraSamplingTaskType}) + if err != nil { + common.ApiError(c, err) + return + } + if last := latest[service.AstraSamplingTaskType]; last != nil && time.Now().Unix()-last.CreatedAt < 300 { + c.JSON(http.StatusTooManyRequests, gin.H{"success": false, "message": "Please wait five minutes between sampling rounds"}) + return + } + task, created, err := service.EnqueueSystemTask(service.AstraSamplingTaskType, astraSamplingTaskPayload{Force: true}) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"task_id": task.TaskID, "created": created}}) +} diff --git a/controller/astra_sampling_test.go b/controller/astra_sampling_test.go new file mode 100644 index 0000000..80d92b0 --- /dev/null +++ b/controller/astra_sampling_test.go @@ -0,0 +1,37 @@ +package controller + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint" + "github.com/tidwall/gjson" +) + +func TestAstraSamplingUsesIsolatedResponsesWithoutGuard(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + body, _ := io.ReadAll(r.Body) + if r.URL.Path != "/v1/responses" || gjson.GetBytes(body, "model").String() != fingerprint.Model || !gjson.GetBytes(body, "stream").Bool() { + t.Errorf("wrong probe request %s %s", r.URL.Path, body) + } + if gjson.GetBytes(body, "previous_response_id").Exists() || gjson.GetBytes(body, "conversation").Exists() { + t.Error("probe carried customer session") + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"model\":\"gpt-5.6-luna\",\"output\":[{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"[1,2,3]\"}]}]}}\n\n") + })) + defer server.Close() + url := server.URL + channel := &model.Channel{Id: 1, Type: 1, Key: "test-only", BaseURL: &url, Status: common.ChannelStatusEnabled, Models: fingerprint.Model} + r, count, err := runAstraFingerprintProbe(context.Background(), channel) + if err != nil || r.DeclaredModel != "gpt-5.6-luna" || count < 292 || count > 332 || requests != 1 { + t.Fatalf("probe %+v count=%d requests=%d err=%v", r, count, requests, err) + } +} diff --git a/controller/audit.go b/controller/audit.go index d9c5bc4..5ec1ec4 100644 --- a/controller/audit.go +++ b/controller/audit.go @@ -1,8 +1,10 @@ package controller import ( + "errors" "fmt" "os" + "strings" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" @@ -43,6 +45,11 @@ var auditContentTemplates = map[string]string{ "user.reset_passkey": "Reset the user passkey", "option.update": "Updated system setting ${key}", + "option.passkey_domains": "Updated Passkey domains: removed ${domains}; affected ${known}; unknown ${unknown}", + "option.passkey_domains_confirmed": "Confirmed removal of Passkey domains: ${domains}; affected ${known}; unknown ${unknown}", + "option.passkey_domains_blocked": "Passkey domain change blocked: ${domains}; affected ${known}; unknown ${unknown}", + "option.passkey_domains_failed": "Passkey domain update failed", + "channel.create": "Created channel ${name} (type ${type}, count ${count})", "channel.update": "Updated channel ${name} (ID: ${id})", "channel.delete": "Deleted channel ${name} (ID: ${id})", @@ -65,6 +72,33 @@ var auditContentTemplates = map[string]string{ "subscription.user_plan_reset": "Reset active plan ${plan_id} subscriptions for user ${target_user_id}", } +func recordPasskeyDomainAudit(c *gin.Context, change *model.PasskeyDomainChange, confirmed bool, err error) { + confirmed = confirmed && err == nil && change != nil && len(change.RemovedRPIDs) > 0 + params := map[string]any{"success": err == nil, "confirmed": confirmed} + if change != nil { + params["domains"] = strings.Join(change.RemovedRPIDs, ", ") + params["removed_rp_ids"] = change.RemovedRPIDs + params["known"] = change.AffectedCredentials + params["unknown"] = change.UnknownCredentials + params["previous_rp_id"] = change.PreviousRPID + params["effective_rp_id"] = change.EffectiveRPID + } + action := "option.passkey_domains" + if errors.Is(err, model.ErrPasskeyDomainRemovalConfirmation) { + action = "option.passkey_domains_blocked" + } else if err != nil { + action = "option.passkey_domains_failed" + } else if confirmed && change != nil && len(change.RemovedRPIDs) > 0 { + action = "option.passkey_domains_confirmed" + } + auditInfo := &model.AuditRequestInfo{ + Method: c.Request.Method, Route: c.FullPath(), Path: c.FullPath(), + Status: c.Writer.Status(), Success: err == nil, + } + model.RecordOperationAuditLog(c.GetInt("id"), c.GetInt("role"), auditContentEN(action, params), c.ClientIP(), action, params, auditOperatorInfo(c), auditInfo, c) + markAuditLogged(c) +} + // auditContentEN 按 action 模板渲染英文兜底文本;未登记的 action 退回 action 本身。 func auditContentEN(action string, params map[string]any) string { tmpl, ok := auditContentTemplates[action] diff --git a/controller/auth_flow_test.go b/controller/auth_flow_test.go index b8c9030..e69ece4 100644 --- a/controller/auth_flow_test.go +++ b/controller/auth_flow_test.go @@ -76,7 +76,7 @@ func TestSecurityLoginCodeCompletesOnce(t *testing.T) { user, _ := setupSecurityEnrollmentTest(t) factor := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true} require.NoError(t, model.DB.Create(factor).Error) - challenge, err := service.StartLoginVerification(user, "password") + challenge, err := service.StartLoginVerification(user, "password", nil) require.NoError(t, err) require.NotNil(t, challenge) code, err := totp.GenerateCode(factor.Secret, time.Now()) @@ -121,7 +121,7 @@ func TestSecurityLoginRejectsChangedOrExpiredAuthorization(t *testing.T) { user, _ := setupSecurityEnrollmentTest(t) factor := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true} require.NoError(t, model.DB.Create(factor).Error) - challenge, err := service.StartLoginVerification(user, "password") + challenge, err := service.StartLoginVerification(user, "password", nil) require.NoError(t, err) method := "2fa" switch change { @@ -183,7 +183,7 @@ func TestSecurityLoginPasskeyDoesNotRequireAdditionalTwoFA(t *testing.T) { require.Equal(t, "required", result.Data.Options.PublicKey.UserVerification) flowToken, challenge = result.Data.FlowToken, result.Data.Options.PublicKey.Challenge } else { - pending, err := service.StartLoginVerification(user, "oauth:github") + pending, err := service.StartLoginVerification(user, "oauth:github", nil) require.NoError(t, err) parentToken = pending.FlowToken flowToken, challenge = beginSecurityLoginPasskey(t, parentToken) @@ -225,7 +225,7 @@ func TestSecurityLoginPasskeyDoesNotRequireAdditionalTwoFA(t *testing.T) { func TestSecurityLoginPasskeyConcurrentCompletionCreatesOneSession(t *testing.T) { user, _ := setupSecurityEnrollmentTest(t) key := newSecurityLoginPasskey(t, user.Id) - pending, err := service.StartLoginVerification(user, "password") + pending, err := service.StartLoginVerification(user, "password", nil) require.NoError(t, err) requests := make([]string, 2) for index := range requests { @@ -266,7 +266,7 @@ func TestSecurityLoginPasskeyConcurrentCompletionCreatesOneSession(t *testing.T) func TestSecurityLoginSessionFailureRollsBackChallengeConsumption(t *testing.T) { user, _ := setupSecurityEnrollmentTest(t) key := newSecurityLoginPasskey(t, user.Id) - pending, err := service.StartLoginVerification(user, "password") + pending, err := service.StartLoginVerification(user, "password", nil) require.NoError(t, err) require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("login_session_failure", func(tx *gorm.DB) { if tx.Statement.Table == "user_sessions" { @@ -410,7 +410,7 @@ func TestSecurityLoginPasskeyCannotCompleteAnotherChallenge(t *testing.T) { t.Run(fmt.Sprintf("other-user=%t", otherUser), func(t *testing.T) { user, _ := setupSecurityEnrollmentTest(t) key := newSecurityLoginPasskey(t, user.Id) - first, err := service.StartLoginVerification(user, "password") + first, err := service.StartLoginVerification(user, "password", nil) require.NoError(t, err) passkeyToken, challenge := beginSecurityLoginPasskey(t, first.FlowToken) if otherUser { @@ -418,7 +418,7 @@ func TestSecurityLoginPasskeyCannotCompleteAnotherChallenge(t *testing.T) { require.NoError(t, model.DB.Create(user).Error) newSecurityLoginPasskey(t, user.Id) } - second, err := service.StartLoginVerification(user, "password") + second, err := service.StartLoginVerification(user, "password", nil) require.NoError(t, err) body, err := common.Marshal(map[string]any{"flow_token": second.FlowToken, "passkey_flow_token": passkeyToken, "credential": securityPasskeyResponse(t, key, challenge, false, 0)}) require.NoError(t, err) @@ -816,3 +816,310 @@ func TestOAuthBindProviderErrorConsumesSessionBoundFlow(t *testing.T) { assert.Zero(t, provider.exchangeCalls) assert.Zero(t, provider.userInfoCalls) } + +// legacyGitHubOAuthProvider follows the GitHub provider contract: the numeric +// account ID identifies the user, Extra["legacy_id"] carries the username and +// the verified email list is served on demand. Lookups go through the real +// github_id model queries. +type legacyGitHubOAuthProvider struct { + authFlowTestOAuthProvider + providerUserID string + legacyID string + verifiedEmails []string + verifiedEmailsErr error + verifiedEmailCalls int +} + +func (*legacyGitHubOAuthProvider) GetName() string { return "GitHub" } +func (provider *legacyGitHubOAuthProvider) GetUserInfo(context.Context, *oauth.OAuthToken) (*oauth.OAuthUser, error) { + return &oauth.OAuthUser{ProviderUserID: provider.providerUserID, Username: provider.legacyID, Extra: map[string]any{"legacy_id": provider.legacyID}}, nil +} +func (provider *legacyGitHubOAuthProvider) GetVerifiedEmails(context.Context, *oauth.OAuthToken) ([]string, error) { + provider.verifiedEmailCalls++ + return provider.verifiedEmails, provider.verifiedEmailsErr +} +func (*legacyGitHubOAuthProvider) IsUserIDTaken(providerUserID string) bool { + return model.IsGitHubIdAlreadyTaken(providerUserID) +} +func (*legacyGitHubOAuthProvider) FillUserByProviderID(user *model.User, providerUserID string) error { + user.GitHubId = providerUserID + return user.FillUserByGitHubId() +} +func (*legacyGitHubOAuthProvider) SetProviderUserID(user *model.User, providerUserID string) { + user.GitHubId = providerUserID +} +func (*legacyGitHubOAuthProvider) GetProviderPrefix() string { return "github_" } +func (*legacyGitHubOAuthProvider) ProviderUserIDColumn() string { return "github_id" } + +// legacyGitHubOAuthLogin registers provider for one test and completes an OAuth +// login callback with it. +func legacyGitHubOAuthLogin(t *testing.T, provider *legacyGitHubOAuthProvider) *httptest.ResponseRecorder { + t.Helper() + const slug = "github-legacy-login-test" + oauth.Register(slug, provider) + t.Cleanup(func() { oauth.Unregister(slug) }) + token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{Purpose: model.AuthFlowPurposeOAuth, Provider: slug, Intent: model.AuthFlowIntentLogin, Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute)}) + require.NoError(t, err) + router := gin.New() + router.GET("/api/oauth/:provider", HandleOAuth) + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/oauth/"+slug+"?state="+token+"&code=provider-code", nil)) + return response +} + +// legacyGitHubBindingAudits returns the account binding audit rows in creation +// order together with their encoded parameters. +func legacyGitHubBindingAudits(t *testing.T) ([]model.AuditLog, []string) { + t.Helper() + var audits []model.AuditLog + require.NoError(t, model.LOG_DB.Where("action = ?", "user.binding_bind").Order("id").Find(&audits).Error) + params := make([]string, 0, len(audits)) + for _, audit := range audits { + require.NotNil(t, audit.Other.Op) + encoded, err := common.Marshal(audit.Other.Op.Params) + require.NoError(t, err) + params = append(params, string(encoded)) + } + return audits, params +} + +func TestOAuthLoginLegacyGitHubBindingRequiresAccountEvidence(t *testing.T) { + const declined = "This GitHub account cannot be linked to an existing account automatically, please sign in or register another way and then link GitHub in account settings" + tests := []struct { + name string + existingGitHubID string + existingDeleted bool + withoutEmail bool + legacyID string + verifiedEmails []string + verifiedEmailsErr error + registerEnabled bool + expectLogin bool + expectMigration bool + expectNewAccount bool + expectEmailCalls int + expectAuditParams string + }{ + {name: "numeric legacy value registers a new account", existingGitHubID: "424242", legacyID: "424242", verifiedEmails: []string{"legacy-github@example.com"}, registerEnabled: true, expectLogin: true, expectNewAccount: true}, + {name: "numeric legacy value with registration disabled", existingGitHubID: "424242", legacyID: "424242", verifiedEmails: []string{"legacy-github@example.com"}}, + {name: "soft-deleted legacy row registers a new account", existingGitHubID: "octocat-legacy", existingDeleted: true, legacyID: "octocat-legacy", verifiedEmails: []string{"legacy-github@example.com"}, registerEnabled: true, expectLogin: true, expectNewAccount: true}, + { + name: "verified email match migrates", existingGitHubID: "octocat-legacy", legacyID: "octocat-legacy", + verifiedEmails: []string{"other@example.com", " Legacy-GitHub@Example.com "}, expectLogin: true, expectMigration: true, expectEmailCalls: 1, + // No SMTP server is configured in tests, so the notification attempt is recorded as failed. + expectAuditParams: `{"provider":"github","legacy_migration":true,"legacy_id":"octocat-legacy","provider_user_id":"900001","verified_email_matched":true,"success":true,"notification_failed":true}`, + }, + { + name: "no matching verified email declines", existingGitHubID: "octocat-legacy", legacyID: "octocat-legacy", + verifiedEmails: []string{"other@example.com"}, registerEnabled: true, expectEmailCalls: 1, + expectAuditParams: `{"provider":"github","legacy_migration":true,"legacy_id":"octocat-legacy","provider_user_id":"900001","success":false,"reason":"no_matching_evidence"}`, + }, + { + name: "verified emails failure declines", existingGitHubID: "octocat-legacy", legacyID: "octocat-legacy", + verifiedEmailsErr: errors.New("emails unavailable"), registerEnabled: true, expectEmailCalls: 1, + expectAuditParams: `{"provider":"github","legacy_migration":true,"legacy_id":"octocat-legacy","provider_user_id":"900001","success":false,"reason":"verified_emails_unavailable"}`, + }, + { + name: "account without email declines before asking the provider", existingGitHubID: "octocat-legacy", withoutEmail: true, legacyID: "octocat-legacy", + verifiedEmails: []string{"legacy-github@example.com"}, registerEnabled: true, + expectAuditParams: `{"provider":"github","legacy_migration":true,"legacy_id":"octocat-legacy","provider_user_id":"900001","success":false,"reason":"no_matching_evidence"}`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + setupSecurityEnrollmentTest(t) + previousRegister := common.RegisterEnabled + common.RegisterEnabled = test.registerEnabled + t.Cleanup(func() { common.RegisterEnabled = previousRegister }) + existing := &model.User{Username: "legacy-github", Email: "legacy-github@example.com", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AffCode: "legacy-github", AuthVersion: 1, GitHubId: test.existingGitHubID} + if test.withoutEmail { + existing.Email = "" + } + require.NoError(t, model.DB.Create(existing).Error) + if test.existingDeleted { + require.NoError(t, model.DB.Delete(existing).Error) + } + provider := &legacyGitHubOAuthProvider{providerUserID: "900001", legacyID: test.legacyID, verifiedEmails: test.verifiedEmails, verifiedEmailsErr: test.verifiedEmailsErr} + response := legacyGitHubOAuthLogin(t, provider) + var result struct { + Success bool `json:"success"` + Message string `json:"message"` + Data struct { + User struct { + Id int `json:"id"` + } `json:"user"` + } `json:"data"` + } + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + require.Equal(t, test.expectLogin, result.Success, response.Body.String()) + assert.Equal(t, test.expectEmailCalls, provider.verifiedEmailCalls) + + var reloaded model.User + require.NoError(t, model.DB.Unscoped().First(&reloaded, existing.Id).Error) + audits, params := legacyGitHubBindingAudits(t) + if test.expectAuditParams == "" { + assert.Empty(t, audits) + } else { + require.Len(t, audits, 1) + assert.Equal(t, existing.Id, audits[0].UserId) + assert.Equal(t, common.RoleCommonUser, audits[0].ActorRole) + assert.Equal(t, test.expectMigration, audits[0].Success) + assert.JSONEq(t, test.expectAuditParams, params[0]) + } + if test.expectMigration { + assert.Equal(t, existing.Id, result.Data.User.Id) + assert.Equal(t, "900001", reloaded.GitHubId) + assert.NotEmpty(t, response.Header().Values("Set-Cookie")) + return + } + assert.Equal(t, test.existingGitHubID, reloaded.GitHubId, "the existing binding must stay untouched") + if test.expectNewAccount { + var created model.User + require.NoError(t, model.DB.Where("github_id = ?", "900001").First(&created).Error) + assert.NotEqual(t, existing.Id, created.Id) + assert.Equal(t, created.Id, result.Data.User.Id) + return + } + assert.Empty(t, response.Header().Values("Set-Cookie")) + if test.expectAuditParams != "" { + assert.Equal(t, declined, result.Message) + } + }) + } +} + +func TestOAuthLoginLegacyGitHubBindingMigratesAfterLoginVerification(t *testing.T) { + for _, interference := range []string{"none", "pending id claimed by another account", "binding relinked meanwhile"} { + t.Run(interference, func(t *testing.T) { + setupSecurityEnrollmentTest(t) + existing := &model.User{Username: "legacy-github", Email: "legacy-github@example.com", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AffCode: "legacy-github", AuthVersion: 1, GitHubId: "octocat-legacy"} + require.NoError(t, model.DB.Create(existing).Error) + factor := &model.TwoFA{UserId: existing.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true} + require.NoError(t, model.DB.Create(factor).Error) + provider := &legacyGitHubOAuthProvider{providerUserID: "900001", legacyID: "octocat-legacy", verifiedEmails: []string{"legacy-github@example.com"}} + response := legacyGitHubOAuthLogin(t, provider) + var challenge struct { + Success bool `json:"success"` + Data service.LoginChallenge `json:"data"` + } + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &challenge)) + require.True(t, challenge.Success, response.Body.String()) + require.True(t, challenge.Data.RequireVerification) + assert.Equal(t, []service.VerificationMethodOption{{Method: "2fa", Available: true}}, challenge.Data.Methods) + assert.Empty(t, response.Header().Values("Set-Cookie")) + assert.Zero(t, provider.verifiedEmailCalls, "a second factor is checked before the provider emails") + var reloaded model.User + require.NoError(t, model.DB.First(&reloaded, existing.Id).Error) + assert.Equal(t, "octocat-legacy", reloaded.GitHubId, "the binding waits for the verification") + audits, _ := legacyGitHubBindingAudits(t) + assert.Empty(t, audits) + + expectedGitHubID, expectLogin := "900001", true + switch interference { + case "pending id claimed by another account": + other := &model.User{Username: "other-github", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AffCode: "other-github", AuthVersion: 1, GitHubId: "900001"} + require.NoError(t, model.DB.Create(other).Error) + expectedGitHubID, expectLogin = "octocat-legacy", false + case "binding relinked meanwhile": + require.NoError(t, model.DB.Model(existing).Update("github_id", "relinked-legacy").Error) + expectedGitHubID = "relinked-legacy" + } + code, err := totp.GenerateCode(factor.Secret, time.Now()) + require.NoError(t, err) + body, err := common.Marshal(map[string]string{"flow_token": challenge.Data.FlowToken, "code": code}) + require.NoError(t, err) + for attempt := range 2 { + response = securityEnrollmentRequest(http.MethodPost, "/api/user/login/verify", string(body), "", service.AuthIdentity{}, VerifyLogin) + var result securityEnrollmentResponse + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + assert.Equal(t, attempt == 0 && expectLogin, result.Success, response.Body.String()) + if attempt == 0 && !expectLogin { + assert.Equal(t, "ACCOUNT_ALREADY_BOUND", result.Code) + } + if result.Success { + assert.NotEmpty(t, response.Header().Values("Set-Cookie")) + } else { + assert.Empty(t, response.Header().Values("Set-Cookie")) + } + } + require.NoError(t, model.DB.First(&reloaded, existing.Id).Error) + assert.Equal(t, expectedGitHubID, reloaded.GitHubId) + sessions, err := model.CountActiveUserSessions(existing.Id, time.Now().Unix()) + require.NoError(t, err) + expectedSessions := 0 + if expectLogin { + expectedSessions = 1 + } + assert.EqualValues(t, expectedSessions, sessions, "the consumed challenge cannot be replayed") + audits, params := legacyGitHubBindingAudits(t) + if interference != "none" { + assert.Empty(t, audits) + return + } + require.Len(t, audits, 1) + assert.Equal(t, existing.Id, audits[0].UserId) + assert.Equal(t, common.RoleCommonUser, audits[0].ActorRole) + assert.True(t, audits[0].Success) + // No SMTP server is configured in tests, so the notification attempt is recorded as failed. + assert.JSONEq(t, `{"provider":"github","legacy_migration":true,"legacy_id":"octocat-legacy","provider_user_id":"900001","verification_method":"2fa","success":true,"notification_failed":true}`, params[0]) + }) + } +} + +func TestOAuthBindIgnoresLegacyGitHubUsernames(t *testing.T) { + tests := []struct { + name string + ownGitHubID string + otherGitHubID string + legacyID string + expectBound bool + }{ + {name: "another account's legacy username does not block binding", otherGitHubID: "octocat-legacy", legacyID: "octocat-legacy", expectBound: true}, + {name: "own legacy username is replaced", ownGitHubID: "octocat-legacy", otherGitHubID: "unrelated-legacy", legacyID: "octocat-legacy", expectBound: true}, + {name: "numeric legacy value does not collide", otherGitHubID: "424242", legacyID: "424242", expectBound: true}, + {name: "numeric ID held by another account is rejected", otherGitHubID: "900001", legacyID: "octocat-legacy"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + user, identity := setupSecurityEnrollmentTest(t) + if test.ownGitHubID != "" { + require.NoError(t, model.DB.Model(user).Update("github_id", test.ownGitHubID).Error) + } + other := &model.User{Username: "other-github", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AffCode: "other-github", AuthVersion: 1, GitHubId: test.otherGitHubID} + require.NoError(t, model.DB.Create(other).Error) + const slug = "github-legacy-bind-test" + oauth.Register(slug, &legacyGitHubOAuthProvider{providerUserID: "900001", legacyID: test.legacyID}) + t.Cleanup(func() { oauth.Unregister(slug) }) + proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: []byte(`{"provider":"` + slug + `"}`)}, service.VerificationMethodPassword) + started := securityEnrollmentRequest(http.MethodPost, "/api/oauth/state", `{"provider":"`+slug+`","intent":"bind"}`, proof, identity, GenerateOAuthCode) + var flow struct { + Data struct { + FlowToken string `json:"flow_token"` + } `json:"data"` + } + require.NoError(t, common.Unmarshal(started.Body.Bytes(), &flow)) + require.NotEmpty(t, flow.Data.FlowToken, started.Body.String()) + response := securityEnrollmentRequest(http.MethodGet, "/api/oauth/"+slug+"?state="+flow.Data.FlowToken+"&code=provider-code", "", "", identity, func(c *gin.Context) { + c.Params = gin.Params{{Key: "provider", Value: slug}} + HandleOAuth(c) + }) + var result struct { + Success bool `json:"success"` + Message string `json:"message"` + } + require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) + assert.Equal(t, test.expectBound, result.Success, response.Body.String()) + + var bound, untouched model.User + require.NoError(t, model.DB.First(&bound, user.Id).Error) + require.NoError(t, model.DB.First(&untouched, other.Id).Error) + assert.Equal(t, test.otherGitHubID, untouched.GitHubId) + if test.expectBound { + assert.Equal(t, "900001", bound.GitHubId) + return + } + assert.Equal(t, test.ownGitHubID, bound.GitHubId) + assert.Equal(t, "This GitHub account has already been bound", result.Message) + }) + } +} diff --git a/controller/auth_session_test.go b/controller/auth_session_test.go index d4e0a8f..db7f7bf 100644 --- a/controller/auth_session_test.go +++ b/controller/auth_session_test.go @@ -146,7 +146,7 @@ func TestSessionLimitDoesNotRecordRejectedLoginAsSuccessful(t *testing.T) { recorder := httptest.NewRecorder() c, _ := gin.CreateTestContext(recorder) c.Request = httptest.NewRequest(http.MethodPost, "/api/user/login", nil) - setupLogin(user, c) + setupLogin(user, nil, c) assert.Equal(t, http.StatusConflict, recorder.Code) var stored model.User diff --git a/controller/billing_option_test.go b/controller/billing_option_test.go index 529fa6e..12469b8 100644 --- a/controller/billing_option_test.go +++ b/controller/billing_option_test.go @@ -11,10 +11,8 @@ import ( "github.com/QuantumNous/new-api/pkg/jsplugin" "github.com/QuantumNous/new-api/setting/config" "github.com/gin-gonic/gin" - "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gorm.io/gorm" ) func TestUpdateOptionRejectsInvalidTaskBillingExpressions(t *testing.T) { @@ -103,42 +101,16 @@ func TestUpdateOptionRejectsUsageExpressionWithoutTaskPlugin(t *testing.T) { assert.Contains(t, recorder.Body.String(), "no task plugin usage schema") } -func setupBillingAliasOptionDB(t *testing.T) { - t.Helper() - previousDB := model.DB - previousLogDB := model.LOG_DB - previousType := common.MainDatabaseType() - previousCache := common.MemoryCacheEnabled - previousMap := common.OptionMap - previousRedis := common.RedisEnabled - database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - require.NoError(t, err) - require.NoError(t, database.AutoMigrate(&model.Channel{}, &model.Option{}, &model.Log{}, &model.AuditLog{}, &model.User{})) - model.DB = database - model.LOG_DB = database - common.SetMainDatabaseType(common.DatabaseTypeSQLite) - common.MemoryCacheEnabled = false - common.RedisEnabled = false - common.OptionMap = map[string]string{} - t.Cleanup(func() { - model.DB = previousDB - model.LOG_DB = previousLogDB - common.SetMainDatabaseType(previousType) - common.MemoryCacheEnabled = previousCache - common.OptionMap = previousMap - common.RedisEnabled = previousRedis - model.InitChannelCache() - }) -} - func TestUpdateOptionAliasBillingExprUsesPluginSchema(t *testing.T) { - setupBillingAliasOptionDB(t) + database := modelManagementDB(t, "sqlite", "") + require.NoError(t, database.AutoMigrate(&model.Log{})) const pluginKey = "billing-alias-probe" source := ` export const meta = { apiVersion: 1, key: "billing-alias-probe", name: "Billing Alias Probe", version: "1.0.0", author: {name: "Test"}, models: ["declared-model"], fetchMode: "per_task", - usageSchema: {seconds: {type: "number", unit: "second"}} + usageSchema: {seconds: {type: "number", unit: "second"}, image_count: {type: "number", unit: "count"}}, + usageProfiles: [{models: ["declared-model"], schema: {seconds: {type: "number", unit: "second"}}}] }; export function buildSubmitRequest() { return {}; } export function parseSubmitResponse() { return {}; } @@ -191,10 +163,14 @@ export function parseTaskResult() { return {}; } assert.Equal(t, http.StatusOK, accepted.Code) assert.Contains(t, accepted.Body.String(), `"success":true`) - rejectedKey := putExpr("alias-model", `u("clips")`) + rejectedKey := putExpr("alias-model", `u("image_count")`) assert.Equal(t, http.StatusOK, rejectedKey.Code) assert.Contains(t, rejectedKey.Body.String(), `"success":false`) - assert.Contains(t, rejectedKey.Body.String(), `usage key \"clips\" is not declared`) + assert.Contains(t, rejectedKey.Body.String(), `usage key \"image_count\" is not declared`) + + rejectedDeclared := putExpr("declared-model", `u("image_count")`) + assert.Contains(t, rejectedDeclared.Body.String(), `"success":false`) + assert.Contains(t, rejectedDeclared.Body.String(), `usage key \"image_count\" is not declared`) unresolvable := putExpr("unknown-alias-model", `u("seconds")`) assert.Equal(t, http.StatusOK, unresolvable.Code) diff --git a/controller/channel-billing.go b/controller/channel-billing.go index 36cd887..c573b1b 100644 --- a/controller/channel-billing.go +++ b/controller/channel-billing.go @@ -275,6 +275,67 @@ func updateChannelSiliconFlowBalance(channel *model.Channel) (float64, error) { return balance, nil } +func getDeepSeekBalanceUSD(response DeepSeekUsageResponse, usdExchangeRate float64) (float64, error) { + var usdBalance *string + var cnyBalance *string + + for i := range response.BalanceInfos { + balanceInfo := &response.BalanceInfos[i] + switch balanceInfo.Currency { + case "USD": + if usdBalance == nil { + usdBalance = &balanceInfo.TotalBalance + } + case "CNY": + if cnyBalance == nil { + cnyBalance = &balanceInfo.TotalBalance + } + } + } + + if usdBalance != nil { + balance, err := strconv.ParseFloat(*usdBalance, 64) + if err != nil { + return 0, err + } + if math.IsNaN(balance) || math.IsInf(balance, 0) { + return 0, errors.New("USD balance must be finite") + } + if balance < 0 { + return 0, errors.New("USD balance must be non-negative") + } + return balance, nil + } + if cnyBalance == nil { + return 0, errors.New("currency USD or CNY not found") + } + if math.IsNaN(usdExchangeRate) || math.IsInf(usdExchangeRate, 0) { + return 0, errors.New("USD exchange rate must be finite") + } + if usdExchangeRate <= 0 { + return 0, errors.New("USD exchange rate must be greater than zero") + } + + balanceCNY, err := strconv.ParseFloat(*cnyBalance, 64) + if err != nil { + return 0, err + } + if math.IsNaN(balanceCNY) || math.IsInf(balanceCNY, 0) { + return 0, errors.New("CNY balance must be finite") + } + if balanceCNY < 0 { + return 0, errors.New("CNY balance must be non-negative") + } + balanceUSD := decimal.NewFromFloat(balanceCNY).Div(decimal.NewFromFloat(usdExchangeRate)).InexactFloat64() + if math.IsNaN(balanceUSD) || math.IsInf(balanceUSD, 0) { + return 0, errors.New("converted USD balance must be finite") + } + if balanceUSD < 0 { + return 0, errors.New("converted USD balance must be non-negative") + } + return balanceUSD, nil +} + func updateChannelDeepSeekBalance(channel *model.Channel) (float64, error) { url := "https://api.deepseek.com/user/balance" body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) @@ -286,17 +347,7 @@ func updateChannelDeepSeekBalance(channel *model.Channel) (float64, error) { if err != nil { return 0, err } - index := -1 - for i, balanceInfo := range response.BalanceInfos { - if balanceInfo.Currency == "CNY" { - index = i - break - } - } - if index == -1 { - return 0, errors.New("currency CNY not found") - } - balance, err := strconv.ParseFloat(response.BalanceInfos[index].TotalBalance, 64) + balance, err := getDeepSeekBalanceUSD(response, operation_setting.USDExchangeRate) if err != nil { return 0, err } diff --git a/controller/channel.go b/controller/channel.go index b0bde66..a20b9a1 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -73,6 +73,68 @@ func clearChannelInfo(channel *model.Channel) { } } +func channelIDsFromChannels(channels []*model.Channel) []int { + ids := make([]int, 0, len(channels)) + for _, channel := range channels { + if channel != nil && channel.Id > 0 { + ids = append(ids, channel.Id) + } + } + return ids +} + +func closeActiveChannelWebSockets(channelIDs []int) { + service.CloseActiveWebSocketsForChannels(channelIDs, service.ChannelDisabledCloseReason) +} + +func hasEnabledMultiKey(channel *model.Channel) bool { + if channel == nil || !channel.ChannelInfo.IsMultiKey { + return true + } + keys := channel.GetKeys() + if len(keys) == 0 { + return false + } + for i := range keys { + if channel.ChannelInfo.MultiKeyStatusList == nil { + return true + } + if status, ok := channel.ChannelInfo.MultiKeyStatusList[i]; !ok || status == common.ChannelStatusEnabled { + return true + } + } + return false +} + +func disableMultiKeyChannelIfUnavailable(channel *model.Channel) bool { + if channel == nil || !channel.ChannelInfo.IsMultiKey || hasEnabledMultiKey(channel) { + return false + } + if channel.Status != common.ChannelStatusEnabled { + return true + } + channel.Status = common.ChannelStatusManuallyDisabled + info := channel.GetOtherInfo() + info["status_reason"] = model.ChannelStatusReasonAllKeysDisabled + info["status_time"] = common.GetTimestamp() + channel.SetOtherInfo(info) + return true +} + +func restoreMultiKeyChannelIfAvailable(channel *model.Channel) { + if channel.Status != common.ChannelStatusManuallyDisabled || !hasEnabledMultiKey(channel) { + return + } + info := channel.GetOtherInfo() + if info["status_reason"] != model.ChannelStatusReasonAllKeysDisabled { + return + } + channel.Status = common.ChannelStatusEnabled + info["status_reason"] = "" + info["status_time"] = common.GetTimestamp() + channel.SetOtherInfo(info) +} + func applyChannelStatusFilter(query *gorm.DB, statusFilter int) *gorm.DB { if statusFilter == common.ChannelStatusEnabled { return query.Where("status = ?", common.ChannelStatusEnabled) @@ -94,11 +156,38 @@ func buildChannelListQuery(group string, statusFilter int, typeFilter int) *gorm } func GetChannelOps(c *gin.Context) { + snapshot := model.CurrentRequestPolicy() + automaticDisable, source := snapshot.AutoDisable, "global" + if value, present := c.GetQuery("auto_ban"); present { + autoBan, err := strconv.ParseBool(value) + if err != nil { + common.ApiError(c, err) + return + } + automaticDisable = snapshot.AutoDisable && autoBan + if snapshot.AutoDisable { + source = "global_and_channel" + if !autoBan { + source = "channel" + } + } + } common.ApiSuccess(c, gin.H{ - "retry_times": common.RetryTimes, + "retry_times": snapshot.RetryTimes, + "request_policy": gin.H{"automatic_disable": automaticDisable, "source": source}, }) } +func GetChannelDefaultBaseURLs(c *gin.Context) { + baseURLs := make(map[int]string) + for channelType, baseURL := range constant.ChannelBaseURLs { + if baseURL != "" { + baseURLs[channelType] = baseURL + } + } + common.ApiSuccess(c, baseURLs) +} + func GetAllChannels(c *gin.Context) { pageInfo := common.GetPageQuery(c) channelData := make([]*model.Channel, 0) @@ -485,6 +574,12 @@ func validateChannel(channel *model.Channel, isAdd bool) error { if channel.Type == constant.ChannelTypeNewAPI && strings.TrimSpace(channel.GetBaseURL()) == "" { return fmt.Errorf("New API channel base URL cannot be empty") } + if channel.Type == constant.ChannelTypeVLLM && strings.TrimSpace(channel.GetBaseURL()) == "" { + return fmt.Errorf("vLLM channel base URL cannot be empty") + } + if channel.Type == constant.ChannelTypeSGLang && strings.TrimSpace(channel.GetBaseURL()) == "" { + return fmt.Errorf("SGLang channel base URL cannot be empty") + } // 如果是添加操作,检查 channel 和 key 是否为空 if isAdd { @@ -757,6 +852,7 @@ func DeleteChannel(c *gin.Context) { "id": id, "name": channelName, }) + closeActiveChannelWebSockets([]int{id}) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", @@ -765,6 +861,13 @@ func DeleteChannel(c *gin.Context) { } func DeleteDisabledChannel(c *gin.Context) { + var ids []int + if err := model.DB.Model(&model.Channel{}). + Where("status = ? or status = ?", common.ChannelStatusAutoDisabled, common.ChannelStatusManuallyDisabled). + Pluck("id", &ids).Error; err != nil { + common.ApiError(c, err) + return + } rows, err := model.DeleteDisabledChannel() if err != nil { common.ApiError(c, err) @@ -777,6 +880,7 @@ func DeleteDisabledChannel(c *gin.Context) { recordManageAudit(c, "channel.delete_disabled", map[string]any{ "count": rows, }) + closeActiveChannelWebSockets(ids) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", @@ -807,6 +911,12 @@ func DisableTagChannels(c *gin.Context) { }) return } + channels, err := model.GetChannelsByTag(channelTag.Tag, false, false) + if err != nil { + common.ApiError(c, err) + return + } + ids := channelIDsFromChannels(channels) err = model.DisableChannelByTag(channelTag.Tag) if err != nil { common.ApiError(c, err) @@ -816,6 +926,7 @@ func DisableTagChannels(c *gin.Context) { recordManageAudit(c, "channel.tag_disable", map[string]any{ "tag": channelTag.Tag, }) + closeActiveChannelWebSockets(ids) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", @@ -936,6 +1047,7 @@ func DeleteChannelBatch(c *gin.Context) { recordManageAudit(c, "channel.delete_batch", map[string]any{ "count": deletedCount, }) + closeActiveChannelWebSockets(channelBatch.Ids) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", @@ -1170,6 +1282,9 @@ func UpdateChannelStatus(c *gin.Context) { changed := model.UpdateChannelStatus(id, "", req.Status, "manual operation") if changed { model.InitChannelCache() + if req.Status != common.ChannelStatusEnabled { + closeActiveChannelWebSockets([]int{id}) + } } recordManageAudit(c, "channel.status_update", map[string]any{ "id": id, @@ -1190,14 +1305,21 @@ func BatchUpdateChannelStatus(c *gin.Context) { return } changedCount := 0 + var disabledIDs []int for _, id := range req.Ids { if model.UpdateChannelStatus(id, "", req.Status, "manual batch operation") { changedCount++ + if req.Status != common.ChannelStatusEnabled { + disabledIDs = append(disabledIDs, id) + } } } if changedCount > 0 { model.InitChannelCache() } + if len(disabledIDs) > 0 { + closeActiveChannelWebSockets(disabledIDs) + } recordManageAudit(c, "channel.status_update_batch", map[string]any{ "count": changedCount, "total": len(req.Ids), @@ -1714,13 +1836,16 @@ func ManageMultiKeys(c *gin.Context) { channel.ChannelInfo.MultiKeyStatusList[keyIndex] = 2 // disabled + shouldCloseWebSocket := disableMultiKeyChannelIfUnavailable(channel) err = channel.Update() if err != nil { common.ApiError(c, err) return } - model.InitChannelCache() + if shouldCloseWebSocket { + closeActiveChannelWebSockets([]int{channel.Id}) + } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "密钥已禁用", @@ -1755,6 +1880,7 @@ func ManageMultiKeys(c *gin.Context) { if channel.ChannelInfo.MultiKeyDisabledReason != nil { delete(channel.ChannelInfo.MultiKeyDisabledReason, keyIndex) } + restoreMultiKeyChannelIfAvailable(channel) err = channel.Update() if err != nil { @@ -1779,6 +1905,7 @@ func ManageMultiKeys(c *gin.Context) { channel.ChannelInfo.MultiKeyStatusList = make(map[int]int) channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64) channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string) + restoreMultiKeyChannelIfAvailable(channel) err = channel.Update() if err != nil { @@ -1827,13 +1954,16 @@ func ManageMultiKeys(c *gin.Context) { return } + shouldCloseWebSocket := disableMultiKeyChannelIfUnavailable(channel) err = channel.Update() if err != nil { common.ApiError(c, err) return } - model.InitChannelCache() + if shouldCloseWebSocket { + closeActiveChannelWebSockets([]int{channel.Id}) + } c.JSON(http.StatusOK, gin.H{ "success": true, "message": fmt.Sprintf("已禁用 %d 个密钥", disabledCount), @@ -1907,13 +2037,16 @@ func ManageMultiKeys(c *gin.Context) { channel.ChannelInfo.MultiKeyDisabledTime = newDisabledTime channel.ChannelInfo.MultiKeyDisabledReason = newDisabledReason + shouldCloseWebSocket := disableMultiKeyChannelIfUnavailable(channel) err = channel.Update() if err != nil { common.ApiError(c, err) return } - model.InitChannelCache() + if shouldCloseWebSocket { + closeActiveChannelWebSockets([]int{channel.Id}) + } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "密钥已删除", @@ -1975,13 +2108,16 @@ func ManageMultiKeys(c *gin.Context) { channel.ChannelInfo.MultiKeyDisabledTime = newDisabledTime channel.ChannelInfo.MultiKeyDisabledReason = newDisabledReason + shouldCloseWebSocket := disableMultiKeyChannelIfUnavailable(channel) err = channel.Update() if err != nil { common.ApiError(c, err) return } - model.InitChannelCache() + if shouldCloseWebSocket { + closeActiveChannelWebSockets([]int{channel.Id}) + } c.JSON(http.StatusOK, gin.H{ "success": true, "message": fmt.Sprintf("已删除 %d 个自动禁用的密钥", deletedCount), diff --git a/controller/channel_billing_test.go b/controller/channel_billing_test.go new file mode 100644 index 0000000..ab978fc --- /dev/null +++ b/controller/channel_billing_test.go @@ -0,0 +1,116 @@ +package controller + +import ( + "math" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetDeepSeekBalanceUSD(t *testing.T) { + tests := []struct { + name string + responseJSON string + usdExchangeRate float64 + want float64 + wantErrContains string + }{ + { + name: "prefers USD when USD precedes CNY", + responseJSON: `{"balance_infos":[{"currency":"USD","total_balance":"12.50"},{"currency":"CNY","total_balance":"73.00"}]}`, + usdExchangeRate: 7.3, + want: 12.5, + }, + { + name: "prefers USD when CNY precedes USD", + responseJSON: `{"balance_infos":[{"currency":"CNY","total_balance":"73.00"},{"currency":"USD","total_balance":"12.50"}]}`, + usdExchangeRate: 7.3, + want: 12.5, + }, + { + name: "converts CNY when USD is absent", + responseJSON: `{"balance_infos":[{"currency":"CNY","total_balance":"73.00"}]}`, + usdExchangeRate: 7.3, + want: 10, + }, + { + name: "returns error when USD and CNY are absent", + responseJSON: `{"balance_infos":[{"currency":"EUR","total_balance":"10.00"}]}`, + usdExchangeRate: 7.3, + wantErrContains: "currency USD or CNY not found", + }, + { + name: "returns USD parse error instead of falling back to CNY", + responseJSON: `{"balance_infos":[{"currency":"USD","total_balance":"invalid"},{"currency":"CNY","total_balance":"73.00"}]}`, + usdExchangeRate: 7.3, + wantErrContains: "invalid syntax", + }, + { + name: "rejects NaN USD balance", + responseJSON: `{"balance_infos":[{"currency":"USD","total_balance":"NaN"}]}`, + usdExchangeRate: 7.3, + wantErrContains: "USD balance must be finite", + }, + { + name: "rejects negative USD balance", + responseJSON: `{"balance_infos":[{"currency":"USD","total_balance":"-1.00"}]}`, + usdExchangeRate: 7.3, + wantErrContains: "USD balance must be non-negative", + }, + { + name: "rejects positive infinity CNY balance", + responseJSON: `{"balance_infos":[{"currency":"CNY","total_balance":"+Inf"}]}`, + usdExchangeRate: 7.3, + wantErrContains: "CNY balance must be finite", + }, + { + name: "rejects negative CNY balance", + responseJSON: `{"balance_infos":[{"currency":"CNY","total_balance":"-7.30"}]}`, + usdExchangeRate: 7.3, + wantErrContains: "CNY balance must be non-negative", + }, + { + name: "returns error for non-positive CNY exchange rate", + responseJSON: `{"balance_infos":[{"currency":"CNY","total_balance":"73.00"}]}`, + usdExchangeRate: 0, + wantErrContains: "USD exchange rate must be greater than zero", + }, + { + name: "rejects NaN CNY exchange rate", + responseJSON: `{"balance_infos":[{"currency":"CNY","total_balance":"73.00"}]}`, + usdExchangeRate: math.NaN(), + wantErrContains: "USD exchange rate must be finite", + }, + { + name: "rejects positive infinity CNY exchange rate", + responseJSON: `{"balance_infos":[{"currency":"CNY","total_balance":"73.00"}]}`, + usdExchangeRate: math.Inf(1), + wantErrContains: "USD exchange rate must be finite", + }, + { + name: "rejects CNY conversion overflow", + responseJSON: `{"balance_infos":[{"currency":"CNY","total_balance":"1.7976931348623157e+308"}]}`, + usdExchangeRate: math.SmallestNonzeroFloat64, + wantErrContains: "converted USD balance must be finite", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var response DeepSeekUsageResponse + require.NoError(t, common.Unmarshal([]byte(test.responseJSON), &response)) + + balance, err := getDeepSeekBalanceUSD(response, test.usdExchangeRate) + if test.wantErrContains != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), test.wantErrContains) + return + } + + require.NoError(t, err) + assert.InDelta(t, test.want, balance, 1e-12) + }) + } +} diff --git a/controller/channel_inference.go b/controller/channel_inference.go new file mode 100644 index 0000000..ccbdfa4 --- /dev/null +++ b/controller/channel_inference.go @@ -0,0 +1,242 @@ +package controller + +import ( + "context" + "errors" + "io" + "math" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" + "github.com/prometheus/common/expfmt" +) + +type inferenceEndpointStatus struct { + Status int `json:"status"` + Error string `json:"error,omitempty"` +} + +type inferenceModel struct { + ID string `json:"id"` + Root string `json:"root"` + MaxModelLen *int64 `json:"max_model_len"` +} + +type inferenceMetric struct { + Name string `json:"name"` + Labels map[string]string `json:"labels"` + Value float64 `json:"value"` +} + +type inferenceStatus struct { + SampledAt int64 `json:"sampled_at"` + Endpoints map[string]inferenceEndpointStatus `json:"endpoints"` + Version string `json:"version"` + Models []inferenceModel `json:"models"` + Metrics []inferenceMetric `json:"metrics"` + RawMetrics string `json:"raw_metrics"` +} + +func GetVLLMChannelStatus(c *gin.Context) { + getInferenceChannelStatus(c, constant.ChannelTypeVLLM) +} + +func GetSGLangChannelStatus(c *gin.Context) { + getInferenceChannelStatus(c, constant.ChannelTypeSGLang) +} + +func getInferenceChannelStatus(c *gin.Context, channelType int) { + c.Header("Cache-Control", "no-store") + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "Invalid channel id"}) + return + } + channel, err := model.GetChannelById(id, true) + if err != nil || channel == nil { + c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "Channel not found"}) + return + } + if channel.Type != channelType { + common.ApiError(c, errors.New("Channel type does not match the status endpoint")) + return + } + status, err := fetchInferenceStatus(c.Request.Context(), channel) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, status) +} + +// fetchInferenceStatus reads only fixed paths on an operator-configured channel. +// Each endpoint can fail independently; upstream errors never echo credentials. +func fetchInferenceStatus(ctx context.Context, channel *model.Channel) (*inferenceStatus, error) { + if channel.Type != constant.ChannelTypeVLLM && channel.Type != constant.ChannelTypeSGLang { + return nil, errors.New("This operation is only supported for vLLM or SGLang channels") + } + baseURL := strings.TrimRight(strings.TrimSpace(channel.GetBaseURL()), "/") + parsedURL, err := url.Parse(baseURL) + if err != nil || parsedURL.Host == "" || parsedURL.User != nil || parsedURL.RawQuery != "" || parsedURL.Fragment != "" || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") { + return nil, errors.New("Invalid inference server address") + } + key, _, keyErr := channel.GetNextEnabledKey() + if keyErr != nil { + return nil, errors.New("No enabled channel key") + } + key = strings.TrimSpace(key) + headers, err := buildFetchModelsHeaders(channel, key) + if err != nil { + return nil, errors.New("Invalid channel header override") + } + if (key == "" || key == "EMPTY") && headers.Get("Authorization") == "Bearer "+key { + headers.Del("Authorization") + } + settings := channel.GetSetting() + client, err := service.GetHttpClientWithProxySettings(settings.Proxy, settings) + if err != nil { + return nil, errors.New("Invalid channel proxy") + } + // Do not mutate the shared client or forward channel credentials on redirects. + statusClient := *client + statusClient.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + versionPath, metricPrefix := "/version", "vllm:" + if channel.Type == constant.ChannelTypeSGLang { + versionPath, metricPrefix = "/server_info", "sglang:" + } + paths := []string{"/health", versionPath, "/v1/models", "/metrics"} + results := make([]struct { + inferenceEndpointStatus + body []byte + at int64 + }, len(paths)) + var wg sync.WaitGroup + for i, path := range paths { + wg.Go(func() { + result := &results[i] + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+path, nil) + if err != nil { + result.Error = "request_failed" + return + } + req.Header = headers.Clone() + req.Host = headers.Get("Host") + resp, err := statusClient.Do(req) + if err != nil { + result.Error = "request_failed" + return + } + defer resp.Body.Close() + result.Status = resp.StatusCode + if resp.StatusCode != http.StatusOK { + result.Error = "http_error" + return + } + const maxResponseBytes = 4 << 20 + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1)) + if err != nil { + result.Error = "request_failed" + return + } + if len(body) > maxResponseBytes { + result.Error = "response_too_large" + return + } + result.body = body + result.at = time.Now().UnixMilli() + }) + } + wg.Wait() + if errors.Is(ctx.Err(), context.Canceled) { + return nil, context.Canceled + } + status := &inferenceStatus{ + SampledAt: time.Now().UnixMilli(), + Endpoints: make(map[string]inferenceEndpointStatus, len(paths)), + Models: []inferenceModel{}, + Metrics: []inferenceMetric{}, + } + for i, path := range paths { + result := &results[i] + if result.Error == "" { + switch path { + case versionPath: + // /server_info includes the launch configuration. Decode only the + // version so API keys and other server settings never reach the browser. + var version struct { + Version string `json:"version"` + } + if err := common.Unmarshal(result.body, &version); err != nil || version.Version == "" { + result.Error = "invalid_response" + } else { + status.Version = version.Version + } + case "/v1/models": + var models struct { + Data []inferenceModel `json:"data"` + } + if err := common.Unmarshal(result.body, &models); err != nil || models.Data == nil { + result.Error = "invalid_response" + } else { + status.Models = models.Data + } + case "/metrics": + metrics, err := parseInferenceMetrics(string(result.body), metricPrefix) + if err != nil { + result.Error = "invalid_response" + } else { + status.Metrics = metrics + status.RawMetrics = string(result.body) + status.SampledAt = result.at + } + } + } + status.Endpoints[path] = result.inferenceEndpointStatus + } + return status, nil +} + +func parseInferenceMetrics(raw string, prefix string) ([]inferenceMetric, error) { + var parser expfmt.TextParser + families, err := parser.TextToMetricFamilies(strings.NewReader(raw)) + if err != nil { + return nil, err + } + metrics := []inferenceMetric{} + for name, family := range families { + if !strings.HasPrefix(name, prefix) && name != "process_start_time_seconds" { + continue + } + samples, err := expfmt.ExtractSamples(&expfmt.DecodeOptions{}, family) + if err != nil { + return nil, err + } + for _, sample := range samples { + name := string(sample.Metric["__name__"]) + value := float64(sample.Value) + if strings.HasSuffix(name, "_bucket") || math.IsNaN(value) || math.IsInf(value, 0) || value < 0 { + continue + } + labels := make(map[string]string, len(sample.Metric)-1) + for label, value := range sample.Metric { + if label != "__name__" { + labels[string(label)] = string(value) + } + } + metrics = append(metrics, inferenceMetric{Name: name, Labels: labels, Value: value}) + } + } + return metrics, nil +} diff --git a/controller/channel_inference_test.go b/controller/channel_inference_test.go new file mode 100644 index 0000000..c6dba46 --- /dev/null +++ b/controller/channel_inference_test.go @@ -0,0 +1,196 @@ +package controller + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVLLMStatusPartialFailureAndCredentials(t *testing.T) { + service.InitHttpClient() + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "Bearer test-vllm-key", r.Header.Get("Authorization")) + assert.Equal(t, "status-panel", r.Header.Get("X-Source")) + switch r.URL.Path { + case "/prefix/health": + w.WriteHeader(http.StatusOK) + case "/prefix/version": + _, _ = w.Write([]byte(`{"version":"0.25.2"}`)) + case "/prefix/v1/models": + _, _ = w.Write([]byte(`{"data":[{"id":"served-model","root":"source/model","max_model_len":1048576}]}`)) + case "/prefix/metrics": + http.Error(w, "test-vllm-key should not reach the browser", http.StatusForbidden) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer upstream.Close() + base := upstream.URL + "/prefix/" + override := `{"X-Source":"status-panel"}` + channel := &model.Channel{Type: constant.ChannelTypeVLLM, BaseURL: &base, Key: "test-vllm-key", HeaderOverride: &override} + status, err := fetchInferenceStatus(context.Background(), channel) + require.NoError(t, err) + assert.Equal(t, 200, status.Endpoints["/health"].Status) + assert.Equal(t, "0.25.2", status.Version) + require.Len(t, status.Models, 1) + assert.Equal(t, "source/model", status.Models[0].Root) + require.NotNil(t, status.Models[0].MaxModelLen) + assert.EqualValues(t, 1048576, *status.Models[0].MaxModelLen) + assert.Equal(t, inferenceEndpointStatus{Status: 403, Error: "http_error"}, status.Endpoints["/metrics"]) + assert.Empty(t, status.RawMetrics) + assert.Empty(t, status.Metrics) + body, err := common.Marshal(status) + require.NoError(t, err) + assert.NotContains(t, string(body), channel.Key) +} + +func TestVLLMStatusRejectsInvalidTargetsAndRedirects(t *testing.T) { + service.InitHttpClient() + for _, base := range []string{"", "file:///etc/passwd", "http://user:secret@localhost", "http://localhost?key=secret"} { + _, err := fetchInferenceStatus(context.Background(), &model.Channel{Type: constant.ChannelTypeVLLM, BaseURL: &base}) + require.Error(t, err) + assert.NotContains(t, err.Error(), "secret") + } + _, err := fetchInferenceStatus(context.Background(), &model.Channel{Type: constant.ChannelTypeOllama}) + require.Error(t, err) + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("redirect must not forward channel credentials") + })) + defer target.Close() + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Empty(t, r.Header.Get("Authorization")) + http.Redirect(w, r, target.URL, http.StatusFound) + })) + defer upstream.Close() + status, err := fetchInferenceStatus(context.Background(), &model.Channel{Type: constant.ChannelTypeVLLM, BaseURL: &upstream.URL, Key: "EMPTY"}) + require.NoError(t, err) + for _, endpoint := range status.Endpoints { + assert.Equal(t, inferenceEndpointStatus{Status: 302, Error: "http_error"}, endpoint) + } +} + +func TestVLLMStatusInvalidAndOversizedResponses(t *testing.T) { + service.InitHttpClient() + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + w.WriteHeader(http.StatusServiceUnavailable) + case "/version": + _, _ = w.Write([]byte(`{"version":false}`)) + case "/v1/models": + _, _ = w.Write([]byte(`{"data":[]}`)) + case "/metrics": + _, _ = w.Write([]byte(strings.Repeat(" ", (4<<20)+1))) + } + })) + defer upstream.Close() + channel := &model.Channel{Type: constant.ChannelTypeVLLM, BaseURL: &upstream.URL, Key: "EMPTY"} + status, err := fetchInferenceStatus(context.Background(), channel) + require.NoError(t, err) + assert.Equal(t, 503, status.Endpoints["/health"].Status) + assert.Equal(t, "invalid_response", status.Endpoints["/version"].Error) + assert.Empty(t, status.Endpoints["/v1/models"].Error) + assert.Equal(t, "response_too_large", status.Endpoints["/metrics"].Error) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = fetchInferenceStatus(ctx, channel) + assert.ErrorIs(t, err, context.Canceled) +} + +func TestParseVLLMMetricsPreservesSeriesAndHistogramTotals(t *testing.T) { + metrics, err := parseInferenceMetrics(`# TYPE vllm:num_requests_running gauge +vllm:num_requests_running{engine="0",model_name="model,a"} 0 +vllm:num_requests_running{model_name="model,a",engine="1"} 2 +# TYPE vllm:time_to_first_token_seconds histogram +vllm:time_to_first_token_seconds_bucket{le="+Inf"} 4 +vllm:time_to_first_token_seconds_sum 10 +vllm:time_to_first_token_seconds_count 4 +# TYPE vllm:kv_cache_usage_perc gauge +vllm:kv_cache_usage_perc NaN +vllm:bad_value +Inf +# TYPE process_start_time_seconds gauge +process_start_time_seconds 100 +unrelated_metric 123 +`, "vllm:") + require.NoError(t, err) + assert.ElementsMatch(t, []inferenceMetric{ + {Name: "vllm:num_requests_running", Labels: map[string]string{"engine": "0", "model_name": "model,a"}, Value: 0}, + {Name: "vllm:num_requests_running", Labels: map[string]string{"engine": "1", "model_name": "model,a"}, Value: 2}, + {Name: "vllm:time_to_first_token_seconds_sum", Labels: map[string]string{}, Value: 10}, + {Name: "vllm:time_to_first_token_seconds_count", Labels: map[string]string{}, Value: 4}, + {Name: "process_start_time_seconds", Labels: map[string]string{}, Value: 100}, + }, metrics) + _, err = parseInferenceMetrics("vllm:broken{engine=oops} 1\n", "vllm:") + require.Error(t, err) +} + +// Opt-in verification against an operator-provided instance; credentials stay in the environment. +func TestVLLMStatusLive(t *testing.T) { + base := os.Getenv("NEW_API_VLLM_TEST_URL") + if base == "" { + t.Skip("NEW_API_VLLM_TEST_URL is not configured") + } + service.InitHttpClient() + status, err := fetchInferenceStatus(context.Background(), &model.Channel{ + Type: constant.ChannelTypeVLLM, BaseURL: &base, Key: os.Getenv("NEW_API_VLLM_TEST_KEY"), + }) + require.NoError(t, err) + for path, endpoint := range status.Endpoints { + assert.Equal(t, 200, endpoint.Status, path) + assert.Empty(t, endpoint.Error, path) + } + assert.NotEmpty(t, status.Version) + assert.NotEmpty(t, status.Models) + assert.NotEmpty(t, status.Metrics) + assert.NotEmpty(t, status.RawMetrics) +} + +func TestSGLangStatusUsesDocumentedEndpointsAndRedactsServerConfiguration(t *testing.T) { + service.InitHttpClient() + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "Bearer test-key", r.Header.Get("Authorization")) + switch r.URL.Path { + case "/health": + w.WriteHeader(http.StatusOK) + case "/server_info": + _, _ = w.Write([]byte(`{"version":"0.5.19","api_key":"server-secret","admin_api_key":"admin-secret","internal_states":[{"private":"private-state"}]}`)) + case "/v1/models": + _, _ = w.Write([]byte(`{"data":[{"id":"served-model","root":"served-model","max_model_len":32768}]}`)) + case "/metrics": + _, _ = w.Write([]byte("# TYPE sglang:num_running_reqs gauge\nsglang:num_running_reqs{dp_rank=\"0\"} 0\nsglang:num_running_reqs{dp_rank=\"1\"} 2\n# TYPE sglang:time_to_first_token_seconds histogram\nsglang:time_to_first_token_seconds_bucket{le=\"+Inf\"} 2\nsglang:time_to_first_token_seconds_sum 1.5\nsglang:time_to_first_token_seconds_count 2\nvllm:num_requests_running 9\n")) + default: + t.Errorf("unexpected SGLang path: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer upstream.Close() + status, err := fetchInferenceStatus(context.Background(), &model.Channel{Type: constant.ChannelTypeSGLang, BaseURL: &upstream.URL, Key: "test-key"}) + require.NoError(t, err) + assert.Equal(t, "0.5.19", status.Version) + require.Len(t, status.Models, 1) + assert.Equal(t, "served-model", status.Models[0].ID) + assert.ElementsMatch(t, []inferenceMetric{ + {Name: "sglang:num_running_reqs", Labels: map[string]string{"dp_rank": "0"}, Value: 0}, + {Name: "sglang:num_running_reqs", Labels: map[string]string{"dp_rank": "1"}, Value: 2}, + {Name: "sglang:time_to_first_token_seconds_sum", Labels: map[string]string{}, Value: 1.5}, + {Name: "sglang:time_to_first_token_seconds_count", Labels: map[string]string{}, Value: 2}, + }, status.Metrics) + encoded, err := common.Marshal(status) + require.NoError(t, err) + for _, secret := range []string{"test-key", "server-secret", "admin-secret", "private-state", "api_key", "internal_states"} { + assert.NotContains(t, string(encoded), secret) + } +} diff --git a/controller/channel_multi_key_test.go b/controller/channel_multi_key_test.go new file mode 100644 index 0000000..2b0ae26 --- /dev/null +++ b/controller/channel_multi_key_test.go @@ -0,0 +1,111 @@ +package controller + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMultiKeyEnableRestoresOnlyExhaustedChannels(t *testing.T) { + previousDB, previousLogDB := model.DB, model.LOG_DB + previousType, previousLogType := common.MainDatabaseType(), common.LogDatabaseType() + previousMaster, previousCache, previousRedis, previousSQLite := common.IsMasterNode, common.MemoryCacheEnabled, common.RedisEnabled, common.SQLitePath + t.Cleanup(func() { + model.DB, model.LOG_DB = previousDB, previousLogDB + common.SetDatabaseTypes(previousType, previousLogType) + common.IsMasterNode, common.MemoryCacheEnabled, common.RedisEnabled, common.SQLitePath = previousMaster, previousCache, previousRedis, previousSQLite + }) + t.Setenv("SQL_DSN", os.Getenv("TEST_CHANNEL_SQL_DSN")) + t.Setenv("LOG_SQL_DSN", "") + common.IsMasterNode, common.MemoryCacheEnabled, common.RedisEnabled = false, false, false + common.SQLitePath = filepath.Join(t.TempDir(), "channel.db") + require.NoError(t, model.InitDB()) + database := model.DB + sqlDB, err := database.DB() + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, sqlDB.Close()) }) + model.LOG_DB = database + common.SetLogDatabaseType(common.MainDatabaseType()) + require.NoError(t, database.AutoMigrate(&model.Channel{}, &model.Ability{}, &model.User{}, &model.Log{}, &model.AuditLog{})) + root := &model.User{Username: "multi-key-review-root", Role: common.RoleRootUser, Status: common.UserStatusEnabled} + require.NoError(t, database.Create(root).Error) + t.Cleanup(func() { require.NoError(t, database.Unscoped().Delete(root).Error) }) + versionQuery := "SELECT VERSION()" + if common.UsingMainDatabase(common.DatabaseTypeSQLite) { + versionQuery = "SELECT sqlite_version()" + } + var version string + require.NoError(t, database.Raw(versionQuery).Scan(&version).Error) + t.Logf("database=%s version=%s", common.MainDatabaseType(), version) + + for _, cacheEnabled := range []bool{false, true} { + for _, action := range []string{"enable_key", "enable_all_keys"} { + for _, tc := range []struct { + name string + initialStatus int + manualOverride string + wantStatus int + }{ + {name: "key exhaustion restores", initialStatus: common.ChannelStatusEnabled, wantStatus: common.ChannelStatusEnabled}, + {name: "manual disable is preserved", initialStatus: common.ChannelStatusManuallyDisabled, wantStatus: common.ChannelStatusManuallyDisabled}, + {name: "manual disable after exhaustion is preserved", initialStatus: common.ChannelStatusEnabled, manualOverride: "status", wantStatus: common.ChannelStatusManuallyDisabled}, + {name: "tag disable after exhaustion is preserved", initialStatus: common.ChannelStatusEnabled, manualOverride: "tag", wantStatus: common.ChannelStatusManuallyDisabled}, + } { + t.Run(fmt.Sprintf("cache=%t/%s/%s", cacheEnabled, action, tc.name), func(t *testing.T) { + common.MemoryCacheEnabled = cacheEnabled + tag := t.Name() + channel := &model.Channel{Name: t.Name(), Type: 1, Key: "key-one\nkey-two", Status: tc.initialStatus, Models: "test-model", Group: "default", Tag: &tag, + ChannelInfo: model.ChannelInfo{IsMultiKey: true, MultiKeySize: 2, MultiKeyStatusList: map[int]int{1: common.ChannelStatusManuallyDisabled}}, + } + require.NoError(t, channel.Insert()) + t.Cleanup(func() { + require.NoError(t, channel.Delete()) + model.InitChannelCache() + }) + for _, operation := range []string{"disable_key", action} { + if operation == action { + if tc.manualOverride == "status" { + model.UpdateChannelStatus(channel.Id, "", common.ChannelStatusManuallyDisabled, "manual operation") + } else if tc.manualOverride == "tag" { + require.NoError(t, model.DisableChannelByTag(tag)) + } + } + payload, err := common.Marshal(MultiKeyManageRequest{ChannelId: channel.Id, Action: operation, KeyIndex: common.GetPointer(0)}) + require.NoError(t, err) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Set("id", root.Id) + c.Set("role", common.RoleRootUser) + c.Request = httptest.NewRequest(http.MethodPost, "/api/channel/multi_key", bytes.NewReader(payload)) + c.Request.Header.Set("Content-Type", "application/json") + ManageMultiKeys(c) + var result struct { + Success bool `json:"success"` + } + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &result)) + require.True(t, result.Success, recorder.Body.String()) + } + loaded, err := model.GetChannelById(channel.Id, true) + require.NoError(t, err) + assert.Equal(t, tc.wantStatus, loaded.Status) + assert.NotContains(t, loaded.ChannelInfo.MultiKeyStatusList, 0) + assert.NotContains(t, loaded.ChannelInfo.MultiKeyDisabledReason, 0) + assert.NotContains(t, loaded.ChannelInfo.MultiKeyDisabledTime, 0) + var ability model.Ability + require.NoError(t, database.Where("channel_id = ?", channel.Id).First(&ability).Error) + assert.Equal(t, tc.wantStatus == common.ChannelStatusEnabled, ability.Enabled) + }) + } + } + } +} diff --git a/controller/channel_pin_retry_test.go b/controller/channel_pin_retry_test.go index f79b7b8..05013bd 100644 --- a/controller/channel_pin_retry_test.go +++ b/controller/channel_pin_retry_test.go @@ -2,13 +2,25 @@ package controller import ( "errors" + "fmt" + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/i18n" + "github.com/QuantumNous/new-api/middleware" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/setting/ratio_setting" "net/http" "net/http/httptest" + "os" + "slices" + "strings" "testing" "github.com/QuantumNous/new-api/dto" - "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/relaykit/types" + "github.com/QuantumNous/new-api/service" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -18,7 +30,7 @@ func TestShouldRetryHonorsPinRetryMode(t *testing.T) { openaiErr := types.NewOpenAIError(errors.New("upstream"), types.ErrorCodeBadResponseStatusCode, http.StatusInternalServerError) c := newPinRetryContext() - assert.True(t, shouldRetry(c, openaiErr, 1)) + assert.True(t, service.ShouldRetryRelayError(c, openaiErr, 1)) origin := newPinRetryContext() service.GetChannelConstraints(origin).AddPin(dto.ChannelPin{ @@ -27,7 +39,7 @@ func TestShouldRetryHonorsPinRetryMode(t *testing.T) { Rank: dto.PinRankOriginTask, RetryMode: dto.PinRetrySameChannel, }) - assert.True(t, shouldRetry(origin, openaiErr, 1), "origin pin retries on the same channel") + assert.True(t, service.ShouldRetryRelayError(origin, openaiErr, 1), "origin pin retries on the same channel") token := newPinRetryContext() service.GetChannelConstraints(token).AddPin(dto.ChannelPin{ @@ -36,14 +48,14 @@ func TestShouldRetryHonorsPinRetryMode(t *testing.T) { Rank: dto.PinRankToken, RetryMode: dto.PinRetrySingleAttempt, }) - assert.False(t, shouldRetry(token, openaiErr, 1), "token pin suppresses retry") + assert.Equal(t, service.PolicyDecision{Action: "stop", Reason: "pinned_channel", Source: "channel_constraint"}, service.DecideRelayRetry(token, openaiErr, 1), "token pin suppresses retry") } func TestShouldRetryTaskRelayHonorsPinRetryMode(t *testing.T) { taskErr := &dto.TaskError{StatusCode: http.StatusInternalServerError} c := newPinRetryContext() - assert.True(t, shouldRetryTaskRelay(c, 1, taskErr, 1)) + assert.Equal(t, "retry", decideTaskRetry(c, taskErr, 1).Action) origin := newPinRetryContext() service.GetChannelConstraints(origin).AddPin(dto.ChannelPin{ @@ -52,7 +64,7 @@ func TestShouldRetryTaskRelayHonorsPinRetryMode(t *testing.T) { Rank: dto.PinRankOriginTask, RetryMode: dto.PinRetrySameChannel, }) - assert.True(t, shouldRetryTaskRelay(origin, 2, taskErr, 1)) + assert.Equal(t, "retry", decideTaskRetry(origin, taskErr, 1).Action) token := newPinRetryContext() service.GetChannelConstraints(token).AddPin(dto.ChannelPin{ @@ -61,7 +73,7 @@ func TestShouldRetryTaskRelayHonorsPinRetryMode(t *testing.T) { Rank: dto.PinRankToken, RetryMode: dto.PinRetrySingleAttempt, }) - assert.False(t, shouldRetryTaskRelay(token, 1, taskErr, 1)) + assert.Equal(t, service.PolicyDecision{Action: "stop", Reason: "pinned_channel", Source: "channel_constraint"}, decideTaskRetry(token, taskErr, 1)) } func TestSameChannelPinsMergeToStricterRetryMode(t *testing.T) { @@ -84,7 +96,7 @@ func TestSameChannelPinsMergeToStricterRetryMode(t *testing.T) { assert.Equal(t, 7, pin.ChannelId) assert.Equal(t, dto.PinRetrySingleAttempt, pin.RetryMode) assert.Empty(t, overridden) - assert.False(t, shouldRetry(c, types.NewOpenAIError(errors.New("upstream"), types.ErrorCodeBadResponseStatusCode, http.StatusInternalServerError), 1)) + assert.False(t, service.ShouldRetryRelayError(c, types.NewOpenAIError(errors.New("upstream"), types.ErrorCodeBadResponseStatusCode, http.StatusInternalServerError), 1)) } func newPinRetryContext() *gin.Context { @@ -93,3 +105,115 @@ func newPinRetryContext() *gin.Context { c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) return c } + +func TestRequestPolicyConfigReturnsSettingsWithoutMigration(t *testing.T) { + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + GetRequestPolicy(ctx) + assert.Equal(t, http.StatusOK, recorder.Code) + var response struct { + Success bool `json:"success"` + Data map[string]any `json:"data"` + } + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + assert.True(t, response.Success) + assert.Contains(t, response.Data, "options") + assert.NotContains(t, response.Data, "migration") + assert.NotContains(t, response.Data, "differences") +} + +func TestRequestPolicyRoutingDatabaseMatrix(t *testing.T) { + require.NoError(t, i18n.Init()) + for _, dialect := range []struct{ kind, env string }{{"sqlite", ""}, {"mysql", "TEST_MYSQL_DSN"}, {"postgres", "TEST_POSTGRES_DSN"}} { + t.Run(dialect.kind, func(t *testing.T) { + dsn := "" + if dialect.env != "" { + dsn = os.Getenv(dialect.env) + if dsn == "" { + t.Skipf("%s not configured", dialect.env) + } + } + db := modelManagementDB(t, dialect.kind, dsn) + previousGroups := setting.UserUsableGroups2JSONString() + previousRatios, err := common.Marshal(ratio_setting.GetGroupRatioCopy()) + require.NoError(t, err) + require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default"}`)) + require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1}`)) + t.Cleanup(func() { + require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(previousGroups)) + require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(string(previousRatios))) + }) + // Channel 1 holds the session binding but is no longer usable. + channels := []model.Channel{ + {Id: 1, Name: "A", Type: 1, Key: "test-only", Status: common.ChannelStatusAutoDisabled, Models: "policy-test", Group: "default", Priority: common.GetPointer(int64(10))}, + {Id: 2, Name: "B", Type: 1, Key: "test-only", Status: common.ChannelStatusEnabled, Models: "policy-test", Group: "default", Priority: common.GetPointer(int64(5))}, + } + for i := range channels { + require.NoError(t, db.Create(&channels[i]).Error) + require.NoError(t, channels[i].AddAbilities(db)) + } + affinity := operation_setting.GetChannelAffinitySetting() + previousAffinity := *affinity + t.Cleanup(func() { *affinity = previousAffinity }) + for _, cached := range []bool{false, true} { + for _, keep := range []bool{false, true} { + for _, tc := range []struct { + globalMode, ruleMode string + blocked bool + }{ + {"strict", "inherit", true}, + {"prefer", "strict", true}, + {"prefer", "inherit", false}, + {"strict", "prefer", false}, + } { + t.Run(fmt.Sprintf("cache=%t/keep=%t/global=%s/rule=%s", cached, keep, tc.globalMode, tc.ruleMode), func(t *testing.T) { + common.MemoryCacheEnabled = cached + model.InitChannelCache() + snapshot, err := model.BuildRequestPolicy(map[string]string{ + "channel_affinity_setting.enabled": "true", + "channel_affinity_setting.session_mode": tc.globalMode, + "channel_affinity_setting.keep_on_channel_disabled": fmt.Sprint(keep), + "channel_affinity_setting.rules": fmt.Sprintf(`[{"name":"session","model_regex":[".*"],"key_sources":[{"type":"request_header","key":"X-Session"}],"session_mode":%q}]`, tc.ruleMode), + }) + require.NoError(t, err) + *affinity = snapshot.Affinity + seed := newPinRetryContext() + seed.Request.Header.Set("X-Session", t.Name()) + _, found := service.GetPreferredChannelByAffinity(seed, "policy-test", "default") + require.False(t, found) + seed.Set("channel_id", 1) + service.RecordChannelAffinity(seed, 1) + t.Cleanup(func() { service.ClearCurrentChannelAffinityCache(seed) }) + bound, found := service.GetPreferredChannelByAffinity(seed, "policy-test", "default") + require.True(t, found) + require.Equal(t, 1, bound) + + request := newPinRetryContext() + request.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"policy-test"}`)) + request.Request.Header.Set("Content-Type", "application/json") + request.Request.Header.Set("X-Session", t.Name()) + common.SetContextKey(request, constant.ContextKeyUsingGroup, "default") + middleware.Distribute()(request) + events := service.RequestPolicy(request).Events() + assert.True(t, slices.ContainsFunc(events, func(event service.PolicyEvent) bool { return event.Decision.Reason == "session_rule_matched" }), "decision events are recorded for every request") + bound, found = service.GetPreferredChannelByAffinity(seed, "policy-test", "default") + if tc.blocked { + assert.Equal(t, http.StatusServiceUnavailable, request.Writer.Status()) + assert.True(t, request.IsAborted()) + assert.Equal(t, keep, found, "binding retention is independent of strict request handling") + if found { + assert.Equal(t, 1, bound) + } + return + } + assert.False(t, request.IsAborted()) + assert.Equal(t, 2, common.GetContextKeyInt(request, constant.ContextKeyChannelId), "prefer falls back to the next eligible channel") + require.True(t, found) + assert.Equal(t, 2, bound, "a successful fallback rebinds the session") + }) + } + } + } + }) + } +} diff --git a/controller/channel_test_internal_test.go b/controller/channel_test_internal_test.go index 66a394a..4b7c3c8 100644 --- a/controller/channel_test_internal_test.go +++ b/controller/channel_test_internal_test.go @@ -23,6 +23,33 @@ import ( "github.com/stretchr/testify/require" ) +func TestGetChannelDefaultBaseURLsUsesBuiltInDefaults(t *testing.T) { + originalBaseURLs := constant.ChannelBaseURLs + constant.ChannelBaseURLs = append([]string(nil), originalBaseURLs...) + constant.ChannelBaseURLs[constant.ChannelTypeDeepSeek] = "https://deepseek.server.example" + t.Cleanup(func() { + constant.ChannelBaseURLs = originalBaseURLs + }) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/channel/default_base_urls", nil) + GetChannelDefaultBaseURLs(c) + + require.Equal(t, http.StatusOK, recorder.Code) + var response struct { + Success bool `json:"success"` + Data map[int]string `json:"data"` + } + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + require.True(t, response.Success) + assert.Equal(t, "https://deepseek.server.example", response.Data[constant.ChannelTypeDeepSeek]) + assert.Equal(t, "https://api.openai.com", response.Data[constant.ChannelTypeOpenAI]) + assert.NotContains(t, response.Data, constant.ChannelTypeAzure) + assert.NotContains(t, response.Data, constant.ChannelTypeNewAPI) + assert.NotContains(t, response.Data, constant.ChannelTypeTaskPlugin) +} + func TestValidateChannelProxy(t *testing.T) { tests := []struct { name string diff --git a/controller/channel_upstream_update.go b/controller/channel_upstream_update.go index ef1b6de..73adb78 100644 --- a/controller/channel_upstream_update.go +++ b/controller/channel_upstream_update.go @@ -398,7 +398,7 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) { return normalizeModelNames(models), nil } - if channel.Type == constant.ChannelTypeAdvancedCustom { + if constant.IsAdvancedCustomChannel(channel.Type) { return fetchAdvancedCustomUpstreamModelIDs(channel, baseURL) } @@ -417,10 +417,13 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) { url = fmt.Sprintf("%s/api/paas/v4/models", baseURL) } case constant.ChannelTypeVolcEngine: + // 火山方舟 OpenAI 兼容 API 根路径是 /api/v3(chat/embeddings 均为 + // {base}/api/v3/...,见 relay/channel/volcengine 的 GetRequestURL), + // 不存在 /v1/models 端点。原拼接会导致「获取模型列表」固定 404 失败。 if plan, ok := constant.ChannelSpecialBases[baseURL]; ok && plan.OpenAIBaseURL != "" { url = fmt.Sprintf("%s/v1/models", plan.OpenAIBaseURL) } else { - url = fmt.Sprintf("%s/v1/models", baseURL) + url = fmt.Sprintf("%s/api/v3/models", baseURL) } case constant.ChannelTypeMoonshot: if plan, ok := constant.ChannelSpecialBases[baseURL]; ok && plan.OpenAIBaseURL != "" { @@ -473,7 +476,7 @@ func fetchAdvancedCustomUpstreamModelIDs(channel *model.Channel, baseURL string) RelayMode: relayconstant.RelayModeUnknown, RequestURLPath: dto.AdvancedCustomModelListPath, ChannelMeta: &relaycommon.ChannelMeta{ - ChannelType: constant.ChannelTypeAdvancedCustom, + ChannelType: channel.Type, ChannelBaseUrl: baseURL, ApiKey: key, ChannelOtherSettings: channel.GetOtherSettings(), diff --git a/controller/detection.go b/controller/detection.go new file mode 100644 index 0000000..9af628b --- /dev/null +++ b/controller/detection.go @@ -0,0 +1,498 @@ +package controller + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/middleware" + "github.com/QuantumNous/new-api/model" + fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint" + "github.com/QuantumNous/new-api/relay" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relaykit/dto" + "github.com/QuantumNous/new-api/relaykit/types" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" +) + +var detectionKeyPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{16,96}$`) +var detectionCalls sync.Map + +type detectionRequest struct { + Group string `json:"group"` + Kind string `json:"kind"` + Key string `json:"key"` + PriceVersion string `json:"price_version"` +} + +func detectionInput(c *gin.Context) (detectionRequest, *model.User, service.DetectionPrice, int, error) { + var input detectionRequest + if err := c.ShouldBindJSON(&input); err != nil { + return input, nil, service.DetectionPrice{}, 0, errors.New("invalid detection request") + } + if input.Kind != "pelican" && input.Kind != "fingerprint" { + return input, nil, service.DetectionPrice{}, 0, errors.New("invalid detection type") + } + user, err := model.GetUserById(c.GetInt("id"), false) + if err != nil { + return input, nil, service.DetectionPrice{}, 0, err + } + if !service.IsUserSelectableGroup(user.Group, input.Group) { + return input, nil, service.DetectionPrice{}, 0, errors.New("group unavailable") + } + price, reserve, err := service.DetectionPricing(user.Group, input.Group, input.Kind) + return input, user, price, reserve, err +} + +func EstimateDetection(c *gin.Context) { + input, _, price, reserve, err := detectionInput(c) + if err != nil { + common.ApiError(c, err) + return + } + encoded, _ := common.Marshal(price) + calls := 1 + if input.Kind == "fingerprint" { + calls = 3 + } + c.JSON(200, gin.H{"success": true, "data": gin.H{"reserved": reserve, "ratio": price.Snapshot.GroupRatio, "calls": calls, "price_version": model.DetectionSecretHash(string(encoded))}}) +} + +func CreateUserDetection(c *gin.Context) { + input, user, price, reserve, err := detectionInput(c) + if err != nil { + common.ApiError(c, err) + return + } + if !detectionKeyPattern.MatchString(input.Key) { + common.ApiError(c, errors.New("invalid idempotency key")) + return + } + var previous []model.DetectionRun + key := fmt.Sprintf("user:%d:%s", user.Id, input.Key) + if err = model.DB.Where("request_key = ?", key).Find(&previous).Error; err != nil { + common.ApiError(c, err) + return + } + if len(previous) > 0 { + if previous[0].GroupName != input.Group || previous[0].Kind != input.Kind { + common.ApiError(c, errors.New("idempotency key conflict")) + return + } + c.JSON(200, gin.H{"success": true, "data": gin.H{"id": previous[0].ID}}) + return + } + encoded, _ := common.Marshal(price) + if input.PriceVersion != model.DetectionSecretHash(string(encoded)) { + common.ApiError(c, errors.New("pricing changed; request a new estimate")) + return + } + now := time.Now() + price.Request.At = &now + encoded, _ = common.Marshal(price) + channel, err := model.GetRandomSatisfiedChannel(input.Group, fingerprint.Model, 0, nil) + if err != nil || channel == nil || !service.ChannelSupportsAstra(channel) { + common.ApiError(c, errors.New("no Astra channel available")) + return + } + id, err := common.GenerateRandomCharsKey(32) + if err != nil { + common.ApiError(c, err) + return + } + run := &model.DetectionRun{ID: id, UserID: user.Id, RequestKey: fmt.Sprintf("user:%d:%s", user.Id, input.Key), ChannelID: channel.Id, GroupName: input.Group, Kind: input.Kind, Status: "pending", CreatedAt: time.Now().Unix(), Reserved: reserve, Price: string(encoded)} + if err = model.CreateDetection(run); err != nil { + common.ApiError(c, err) + return + } + c.JSON(200, gin.H{"success": true, "data": gin.H{"id": run.ID}}) +} + +func GetUserDetection(c *gin.Context) { + var run model.DetectionRun + query := model.DB.Where("user_id = ?", c.GetInt("id")) + if c.Param("id") == "latest" { + query = query.Where("group_name = ?", c.Query("group")).Order("created_at desc") + } else { + query = query.Where("id = ?", c.Param("id")) + } + if err := query.First(&run).Error; err != nil { + if c.Param("id") == "latest" { + c.JSON(200, gin.H{"success": true, "data": nil}) + } else { + c.JSON(404, gin.H{"success": false, "message": "Detection not found"}) + } + return + } + var calls []model.DetectionCall + if err := model.DB.Where("run_id = ?", run.ID).Find(&calls).Error; err != nil { + common.ApiError(c, err) + return + } + usage := []any{} + for _, call := range calls { + var parsed any + _ = common.UnmarshalJsonStr(call.Usage, &parsed) + if parsed != nil { + usage = append(usage, parsed) + } + if call.Error != "" && run.Error == "" { + run.Error = "usage_or_generation_incomplete" + } + } + c.JSON(200, gin.H{"success": true, "data": gin.H{"id": run.ID, "kind": run.Kind, "status": run.Status, "quality": run.Quality, "reserved": run.Reserved, "charged": run.Charged, "settled": run.Settled, "usage": usage, "error": run.Error}}) +} + +func GetDetectionArtwork(c *gin.Context) { + getDetectionArtwork(c, false) +} + +func GetSiteDetectionArtwork(c *gin.Context) { + getDetectionArtwork(c, true) +} + +func getDetectionArtwork(c *gin.Context, allGroups bool) { + var run model.DetectionRun + if err := model.DB.Select("html", "channel_id", "created_at").Where("id = ? AND created_at > ?", c.Param("id"), time.Now().Add(-6*time.Hour).Unix()).First(&run).Error; err != nil || run.HTML == "" { + c.Status(404) + return + } + settings, err := model.GetStatusPageGroups(c.Request.Context()) + if err != nil { + c.Status(http.StatusServiceUnavailable) + return + } + channels, err := model.GetStatusChannels(c.Request.Context()) + if err != nil { + c.Status(http.StatusServiceUnavailable) + return + } + usable := service.GetUserUsableGroups("") + visible := false + for _, channel := range channels { + if channel.Id != run.ChannelID { + continue + } + for group := range strings.SplitSeq(channel.Group, ",") { + group = strings.TrimSpace(group) + if _, ok := usable[group]; (allGroups || (ok && !settings[group].Hidden)) && run.CreatedAt > settings[group].ResetBefore { + visible = true + } + } + } + if !visible { + c.Status(http.StatusNotFound) + return + } + c.Header("Content-Security-Policy", "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; form-action 'none'; base-uri 'none'; frame-ancestors 'self'") + c.Header("X-Content-Type-Options", "nosniff") + c.Header("Cache-Control", "no-store") + c.Data(200, "text/html; charset=utf-8", []byte(sanitizeDetectionHTML(run.HTML))) +} + +// Decode only generation fields: external credentials cannot forward tools, +// previous responses, billing modifiers or arbitrary provider parameters. +func detectionProxyRequest(data []byte) (*dto.OpenAIResponsesRequest, error) { + var fields map[string]any + if len(data) > 64<<10 || common.Unmarshal(data, &fields) != nil { + return nil, errors.New("invalid request") + } + if name, ok := fields["model"].(string); !ok || name != fingerprint.Model { + return nil, errors.New("model not allowed") + } + for key := range fields { + switch key { + case "model", "input", "instructions", "stream", "max_output_tokens", "reasoning", "store", "tools", "tool_choice": + default: + return nil, errors.New("unsupported request field") + } + } + input, err := common.Marshal(fields["input"]) + if err != nil || len(input) > 16384 { + return nil, errors.New("input too large") + } + var checkText func(any) bool + checkText = func(v any) bool { + switch value := v.(type) { + case string: + return true + case []any: + for _, item := range value { + if !checkText(item) { + return false + } + } + return true + case map[string]any: + for k, item := range value { + switch k { + case "role": + role, ok := item.(string) + if !ok || (role != "user" && role != "system" && role != "developer") { + return false + } + case "type": + kind, ok := item.(string) + if !ok || (kind != "input_text" && kind != "message") { + return false + } + case "content", "text": + if !checkText(item) { + return false + } + default: + return false + } + } + return true + } + return false + } + if !checkText(fields["input"]) { + return nil, errors.New("text input required") + } + instructions, _ := fields["instructions"].(string) + if len(input)+len(instructions) > 16384 { + return nil, errors.New("input too large") + } + // Service-selected bounds cannot be overridden by the bearer. + fields = map[string]any{"model": fingerprint.Model, "input": fields["input"], "instructions": instructions, "stream": true, "max_output_tokens": 16384, "reasoning": map[string]string{"effort": "high"}, "store": false, "tools": []any{}} + encoded, _ := common.Marshal(fields) + var request dto.OpenAIResponsesRequest + err = common.Unmarshal(encoded, &request) + return &request, err +} + +func DetectionProxy(c *gin.Context) { + secret := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ") + if !strings.HasPrefix(secret, "det_") { + c.AbortWithStatus(401) + return + } + data, err := io.ReadAll(io.LimitReader(c.Request.Body, (64<<10)+1)) + if err != nil { + c.AbortWithStatus(400) + return + } + request, err := detectionProxyRequest(data) + if err != nil { + c.JSON(400, gin.H{"error": gin.H{"message": err.Error()}}) + return + } + call, err := model.ClaimDetectionCall(model.DetectionSecretHash(secret)) + if err != nil { + c.AbortWithStatus(401) + return + } + var run model.DetectionRun + if err = model.DB.First(&run, "id = ?", call.RunID).Error; err != nil { + c.AbortWithStatus(503) + return + } + ctx, cancel := context.WithDeadline(c.Request.Context(), time.Unix(run.Deadline-10, 0)) + detectionCalls.Store(call.ID, cancel) + defer func() { cancel(); detectionCalls.Delete(call.ID) }() + channel, err := model.GetChannelById(run.ChannelID, true) + var raw []byte + var contentType string + // Flush SSE comments while generation is buffered for accounting. Without + // these bytes, the public edge times out a valid long reasoning request. + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("X-Accel-Buffering", "no") + c.Writer.WriteHeader(http.StatusOK) + _, _ = c.Writer.WriteString(": detection started\n\n") + c.Writer.Flush() + if err == nil && channel.Status == common.ChannelStatusEnabled { + raw, contentType, err = waitDetectionResponse(c, ctx, channel, request) + } else { + err = errors.New("channel unavailable") + } + success := err == nil + call.Available = &success + if err != nil { + call.Error = "channel_call_failed" + if ctx.Err() != nil { + call.Available = nil + call.Error = "interrupted_usage_unknown" + } + } else { + usage, complete := detectionResponseUsage(raw, contentType) + if !complete { + call.Error = "incomplete_response" + call.Available = nil + } else { + call.Usage = usage.Raw + quota, quotaErr := service.DetectionUsageQuota(run.Price, usage.Get("input_tokens").Int(), usage.Get("output_tokens").Int(), usage.Get("input_tokens_details.cached_tokens").Int(), max(usage.Get("input_tokens_details.cached_creation_tokens").Int(), usage.Get("input_tokens_details.cache_creation_tokens").Int())) + if quotaErr != nil || quota > run.Reserved { + call.Error = "settlement_unavailable" + } else if run.UserID > 0 { + call.Quota = quota + } + } + } + if success { + if html := detectionHTML(raw, contentType); html != "" { + if saveErr := model.DB.Model(&model.DetectionRun{}).Where("id = ?", run.ID).Update("html", html).Error; saveErr != nil { + call.Error = "artwork_save_failed" + success = false + } + } + } + if err = model.FinishDetectionCall(call); err != nil { + success = false + } + if !success { + _, _ = c.Writer.WriteString("event: error\ndata: {\"type\":\"error\",\"error\":{\"message\":\"Detection generation failed\"}}\n\n") + c.Writer.Flush() + return + } + if strings.Contains(contentType, "text/event-stream") { + _, _ = c.Writer.Write(raw) + } else { + event, _ := common.Marshal(map[string]any{"type": "response.completed", "response": gjson.ParseBytes(raw).Value()}) + _, _ = fmt.Fprintf(c.Writer, "event: response.completed\ndata: %s\n\n", event) + } + c.Writer.Flush() +} + +func waitDetectionResponse(c *gin.Context, ctx context.Context, channel *model.Channel, request *dto.OpenAIResponsesRequest) ([]byte, string, error) { + type response struct { + raw []byte + contentType string + err error + } + done := make(chan response, 1) + go func() { + raw, contentType, err := runDetectionResponse(ctx, channel, request) + done <- response{raw, contentType, err} + }() + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + for { + select { + case result := <-done: + return result.raw, result.contentType, result.err + case <-ticker.C: + _, _ = c.Writer.WriteString(": waiting for generation\n\n") + c.Writer.Flush() + } + } +} + +func runDetectionResponse(ctx context.Context, channel *model.Channel, request *dto.OpenAIResponsesRequest) ([]byte, string, error) { + encoded, err := common.Marshal(request) + if err != nil { + return nil, "", err + } + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequestWithContext(ctx, http.MethodPost, "/v1/responses", bytes.NewReader(encoded)) + c.Request.Header.Set("Content-Type", "application/json") + if middleware.SetupContextForSelectedChannel(c, channel, fingerprint.Model) != nil { + return nil, "", errors.New("channel setup failed") + } + info, err := relaycommon.GenRelayInfo(c, types.RelayFormatOpenAIResponses, request, nil) + if err != nil { + return nil, "", err + } + info.IsChannelTest = true + info.DisablePing = true + info.AstraDetectionDisabled = true + info.AstraAutoSwitch = false + adaptor, body, closer, apiErr := relay.PrepareResponsesRequest(c, info, request) + if closer != nil { + defer closer.Close() + } + if apiErr != nil { + return nil, "", errors.New("preparation failed") + } + body, boundedCloser, err := boundDetectionBody(body, request) + if err != nil { + return nil, "", err + } + defer boundedCloser.Close() + response, err := adaptor.DoRequest(c, info, body) + if err != nil { + return nil, "", err + } + resp, ok := response.(*http.Response) + if !ok || resp == nil { + return nil, "", errors.New("unsupported transport") + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil, "", errors.New("upstream request failed") + } + raw, err := io.ReadAll(io.LimitReader(resp.Body, (8<<20)+1)) + if len(raw) > 8<<20 { + return nil, "", errors.New("response too large") + } + return raw, resp.Header.Get("Content-Type"), err +} + +func detectionResponseUsage(raw []byte, contentType string) (gjson.Result, bool) { + result := gjson.ParseBytes(raw) + if strings.Contains(contentType, "text/event-stream") { + result = gjson.Result{} + for line := range strings.SplitSeq(string(raw), "\n") { + if strings.HasPrefix(line, "data:") { + event := gjson.Parse(strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + switch event.Get("type").String() { + case "response.completed", "response.incomplete", "response.failed": + result = event.Get("response") + } + } + } + } + usage := result.Get("usage") + status := result.Get("status").String() + return usage, (status == "completed" || status == "incomplete" || status == "failed") && usage.Get("input_tokens").Type == gjson.Number && usage.Get("output_tokens").Type == gjson.Number +} + +// Apply billing and safety limits AFTER channel parameter overrides. Never let +// an administrative override turn a bounded paid test into unbounded tool work. +func boundDetectionBody(body common.ReplayableBody, request *dto.OpenAIResponsesRequest) (common.ReplayableBody, io.Closer, error) { + raw, err := io.ReadAll(io.LimitReader(body, 128<<10)) + if err != nil { + return nil, nil, err + } + var prepared map[string]any + if common.Unmarshal(raw, &prepared) != nil { + return nil, nil, errors.New("invalid prepared detection request") + } + var input any + if common.Unmarshal(request.Input, &input) != nil { + return nil, nil, errors.New("invalid detection input") + } + prepared["input"] = input + var instructions any + _ = common.Unmarshal(request.Instructions, &instructions) + prepared["instructions"] = instructions + prepared["max_output_tokens"] = *request.MaxOutputTokens + if *request.MaxOutputTokens == 16384 { + prepared["reasoning"] = map[string]string{"effort": "high"} + } + prepared["tools"] = []any{} + prepared["tool_choice"] = "none" + prepared["store"] = false + prepared["stream"] = true + for _, key := range []string{"previous_response_id", "conversation", "background", "max_tokens", "max_completion_tokens"} { + delete(prepared, key) + } + encoded, err := common.Marshal(prepared) + if err != nil { + return nil, nil, err + } + return relaycommon.NewOutboundJSONBody(encoded) +} diff --git a/controller/detection_html.go b/controller/detection_html.go new file mode 100644 index 0000000..5bf2438 --- /dev/null +++ b/controller/detection_html.go @@ -0,0 +1,63 @@ +package controller + +import ( + "bytes" + "strings" + + "golang.org/x/net/html" +) + +// Artwork scripts run only inside an opaque-origin sandbox. CSP blocks network +// access; navigation, forms, and embedded documents are removed here. +func sanitizeDetectionHTML(source string) string { + root, err := html.Parse(strings.NewReader(source)) + if err != nil { + return "" + } + var walk func(*html.Node) + walk = func(n *html.Node) { + for child := n.FirstChild; child != nil; { + next := child.NextSibling + switch strings.ToLower(child.Data) { + case "iframe", "frame", "frameset", "object", "embed", "base", "meta", "link", "form", "input", "textarea", "select", "audio", "video", "source", "foreignobject": + if child.Type == html.ElementNode { + n.RemoveChild(child) + child = next + continue + } + } + if child.Type == html.ElementNode { + attrs := child.Attr[:0] + for _, attr := range child.Attr { + name := strings.ToLower(attr.Key) + value := strings.TrimSpace(attr.Val) + if strings.HasPrefix(name, "on") { + continue + } + switch name { + case "href": + if !strings.HasPrefix(value, "#") { + continue + } + case "src": + if child.Data != "img" || !(strings.HasPrefix(value, "data:image/png;") || strings.HasPrefix(value, "data:image/jpeg;") || strings.HasPrefix(value, "data:image/webp;")) { + continue + } + case "srcset", "srcdoc", "action", "formaction", "ping", "target", "download", "http-equiv": + continue + } + attrs = append(attrs, attr) + } + child.Attr = attrs + } + walk(child) + child = next + } + } + walk(root) + var out bytes.Buffer + if html.Render(&out, root) != nil { + return "" + } + return out.String() +} diff --git a/controller/detection_test.go b/controller/detection_test.go new file mode 100644 index 0000000..54cd7fc --- /dev/null +++ b/controller/detection_test.go @@ -0,0 +1,171 @@ +package controller + +import ( + "bufio" + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/pkg/billingexpr" + fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestDetectionProxyFlushesBeforeGenerationAndConsumesCredentialOnce(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + connection, err := db.DB() + require.NoError(t, err) + connection.SetMaxOpenConns(1) + oldDB := model.DB + model.DB = db + t.Cleanup(func() { model.DB = oldDB; _ = connection.Close() }) + require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.DetectionRun{}, &model.DetectionCall{})) + release := make(chan struct{}) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-release: + case <-r.Context().Done(): + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"status":"completed","usage":{"input_tokens":10,"output_tokens":5},"output":[{"type":"message","content":[{"type":"output_text","text":""}]}]}`) + })) + defer upstream.Close() + url := upstream.URL + channel := model.Channel{Type: 1, Key: "test-only", BaseURL: &url, Status: common.ChannelStatusEnabled, Models: fingerprint.Model} + require.NoError(t, db.Create(&channel).Error) + price, _ := common.Marshal(service.DetectionPrice{Snapshot: billingexpr.BillingSnapshot{ExprString: "p+c", ExprHash: billingexpr.ExprHashString("p+c"), GroupRatio: 1, QuotaPerUnit: 500000, ExprVersion: 1}}) + run := model.DetectionRun{ID: "stream-test", RequestKey: "stream-test", Kind: "pelican", ChannelID: channel.Id, Status: "running", Deadline: time.Now().Unix() + 60, Reserved: 100, Price: string(price)} + require.NoError(t, db.Create(&run).Error) + call := model.DetectionCall{ID: "stream-call", RunID: run.ID, SecretHash: model.DetectionSecretHash("det_test"), State: "ready", ExpiresAt: time.Now().Unix() + 60} + require.NoError(t, db.Create(&call).Error) + router := gin.New() + router.POST("/responses", DetectionProxy) + gateway := httptest.NewServer(router) + defer gateway.Close() + client := http.Client{Timeout: 5 * time.Second} + request, err := http.NewRequest(http.MethodPost, gateway.URL+"/responses", strings.NewReader(`{"model":"gpt-6-astra","input":"draw"}`)) + require.NoError(t, err) + request.Header.Set("Authorization", "Bearer det_test") + response, err := client.Do(request) + close(release) + require.NoError(t, err) + defer response.Body.Close() + reader := bufio.NewReader(response.Body) + first, err := reader.ReadString('\n') + require.NoError(t, err) + assert.Equal(t, ": detection started\n", first) + raw, err := io.ReadAll(reader) + require.NoError(t, err) + assert.Contains(t, string(raw), "response.completed") + require.NoError(t, db.First(&call, "id = ?", call.ID).Error) + assert.Equal(t, "finished", call.State) + assert.Contains(t, call.Usage, "input_tokens") + replay := httptest.NewRecorder() + request = httptest.NewRequest(http.MethodPost, "/responses", strings.NewReader(`{"model":"gpt-6-astra","input":"draw"}`)) + request.Header.Set("Authorization", "Bearer det_test") + router.ServeHTTP(replay, request) + assert.Equal(t, 401, replay.Code) +} + +func TestDetectionProxyRejectsEscapesAndEnforcesBounds(t *testing.T) { + for _, body := range []string{ + `{"model":[],"input":"hi"}`, + `{"model":"gpt-6-luna","input":"hi"}`, + `{"model":"gpt-6-astra","input":[{"role":[],"content":"hi"}]}`, + `{"model":"gpt-6-astra","input":"hi","previous_response_id":"x"}`, + `{"model":"gpt-6-astra","input":[{"type":"input_image","image_url":"file:///etc/passwd"}]}`, + } { + _, err := detectionProxyRequest([]byte(body)) + require.Error(t, err) + } + request, err := detectionProxyRequest([]byte(`{"model":"gpt-6-astra","input":"draw a pelican","max_output_tokens":1000000,"tools":[{"type":"web_search"}]}`)) + require.NoError(t, err) + require.NotNil(t, request.MaxOutputTokens) + assert.EqualValues(t, 16384, *request.MaxOutputTokens) + assert.Equal(t, "[]", string(request.Tools)) +} + +func TestDetectionOnlyBillsCompleteReportedUsage(t *testing.T) { + answer, parseErr := fingerprint.ReadResponse(strings.NewReader(`{"status":"incomplete","usage":{"input_tokens":100,"output_tokens":10,"input_tokens_details":{"cached_tokens":40,"cache_creation_tokens":5}}}`), "application/json") + require.ErrorIs(t, parseErr, fingerprint.ErrIncomplete) + assert.True(t, answer.HasUsage) + assert.EqualValues(t, 5, answer.CacheWriteTokens) + assert.Empty(t, answer.Text) + _, ok := detectionResponseUsage([]byte(`{"status":"completed"}`), "application/json") + assert.False(t, ok) + _, ok = detectionResponseUsage([]byte("data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n"), "text/event-stream") + assert.False(t, ok) + usage, ok := detectionResponseUsage([]byte("data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":100,\"output_tokens\":10}}}\n\n"), "text/event-stream") + require.True(t, ok) + assert.EqualValues(t, 100, usage.Get("input_tokens").Int()) + expression := `p * 2 + cr * 0.2 + c * 10` + price := service.DetectionPrice{Snapshot: billingexpr.BillingSnapshot{ExprString: expression, ExprHash: billingexpr.ExprHashString(expression), GroupRatio: 0.18, QuotaPerUnit: 500000, ExprVersion: 1}} + encoded, err := common.Marshal(price) + require.NoError(t, err) + quota, err := service.DetectionUsageQuota(string(encoded), 100, 10, 40) + require.NoError(t, err) + assert.Equal(t, 21, quota) + _, err = service.DetectionUsageQuota(string(encoded), 10, 20, 11) + assert.Error(t, err) + at := time.Date(2026, 9, 24, 9, 0, 0, 0, time.UTC) + price.Snapshot.ExprString = `hour("UTC") == 9 ? p * 2 : p * 100` + price.Snapshot.ExprHash = billingexpr.ExprHashString(price.Snapshot.ExprString) + price.Request.At = &at + encoded, err = common.Marshal(price) + require.NoError(t, err) + quota, err = service.DetectionUsageQuota(string(encoded), 100, 0, 0) + require.NoError(t, err) + assert.Equal(t, 18, quota) +} + +func TestDetectionArtworkCannotNavigate(t *testing.T) { + cleaned := sanitizeDetectionHTML(`x`) + for _, bad := range []string{"evil.test", "onclick", "") +} + +func TestDetectionPriceBoundIncludesIntermediateTiers(t *testing.T) { + bound, err := billingexpr.DetectionCostBound(`len < 100 ? p * 2 : len < 1000 ? p * 100 : p * 1`, 32768, 16384) + require.NoError(t, err) + assert.Equal(t, float64(3276800), bound) + _, err = billingexpr.DetectionCostBound(`p / (c + 1)`, 32768, 16384) + require.Error(t, err) + bound, err = billingexpr.DetectionCostBound(`len <= 272000 ? tier("standard", p * 10 + c * 50 + cr * 1 + cc * 12) : tier("long", p * 20 + c * 100 + cr * 2 + cc * 25)`, 32768, 16384) + require.NoError(t, err) + assert.Greater(t, bound, float64(16384*100)) +} + +func TestDetectionLatestPerChannelStatus(t *testing.T) { + now := time.Now().Unix() + for _, tc := range []struct { + name string + ids []int + evidence map[int]service.ChannelDetectionEvidence + want int + }{ + {"successful fingerprint", []int{1}, map[int]service.ChannelDetectionEvidence{1: {At: now, Success: true}}, 1}, + {"failed availability", []int{1}, map[int]service.ChannelDetectionEvidence{1: {At: now, Success: false}}, 0}, + {"partial coverage", []int{1, 2}, map[int]service.ChannelDetectionEvidence{1: {At: now, Success: true}}, 2}, + {"failure and unknown", []int{1, 2}, map[int]service.ChannelDetectionEvidence{1: {At: now, Success: false}}, -1}, + {"expired success", []int{1}, map[int]service.ChannelDetectionEvidence{1: {At: now - 7201, Success: true}}, -1}, + } { + t.Run(tc.name, func(t *testing.T) { + status, _ := service.DetectionFallbackStatus(tc.ids, tc.evidence, now) + assert.Equal(t, tc.want, status) + }) + } +} diff --git a/controller/detection_worker.go b/controller/detection_worker.go new file mode 100644 index 0000000..64cfbdb --- /dev/null +++ b/controller/detection_worker.go @@ -0,0 +1,410 @@ +package controller + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + fingerprint "github.com/QuantumNous/new-api/pkg/model_fingerprint" + "github.com/QuantumNous/new-api/service" + "github.com/tidwall/gjson" +) + +type detectionHandler struct{} + +func pelicanCredential(id string) string { + mac := hmac.New(sha256.New, []byte(common.SessionSecret)) + mac.Write([]byte("newapi-pelican-credential-v1:" + id)) + return "det_" + hex.EncodeToString(mac.Sum(nil)) +} + +func (detectionHandler) Type() string { return "group_detection" } +func (detectionHandler) Enabled() bool { + return common.GetEnvOrDefaultBool("PELICAN_DETECTION_ENABLED", true) +} +func (detectionHandler) Interval() time.Duration { return 15 * time.Second } +func (detectionHandler) NewPayload() any { return nil } + +func (detectionHandler) Run(ctx context.Context, task *model.SystemTask, runner string) { + _ = model.DeliverDetectionLogs() + // Keep accounting facts, but expire bulky generated artwork after the public window. + _ = model.DB.Model(&model.DetectionRun{}).Where("settled = ? AND created_at < ? AND html <> ?", true, time.Now().Add(-6*time.Hour).Unix(), "").Update("html", "").Error + scheduleErr := schedulePelicanDetections(ctx) + var err error + { + var runs []model.DetectionRun + err = model.DB.Where("settled = ?", false).Order("created_at asc").Find(&runs).Error + if err == nil { + jobs := make(chan model.DetectionRun) + var workers sync.WaitGroup + for range 2 { + workers.Go(func() { + for run := range jobs { + processDetection(ctx, run) + } + }) + } + for _, run := range runs { + select { + case jobs <- run: + case <-ctx.Done(): + } + } + close(jobs) + workers.Wait() + } + } + if err == nil { + err = scheduleErr + } + status := model.SystemTaskStatusSucceeded + if err != nil { + status = model.SystemTaskStatusFailed + } + finishSystemTaskHandler(task, runner, status, nil, err) +} + +func schedulePelicanDetections(ctx context.Context) error { + channels, schedules, err := service.AutomaticDetectionChannelsDue(ctx, "pelican", time.Now()) + if err != nil { + return err + } + now := time.Now().Unix() + for _, channel := range channels { + groups := channel.GetGroups() + if len(groups) == 0 { + continue + } + price, reserve, priceErr := service.DetectionPricing("", groups[0], "pelican") + if priceErr != nil { + return priceErr + } + created := time.Unix(now, 0) + price.Request.At = &created + encoded, _ := common.Marshal(price) + id, err := common.GenerateRandomCharsKey(32) + if err != nil { + return err + } + run := &model.DetectionRun{ID: id, RequestKey: fmt.Sprintf("adaptive:%d:%s", channel.Id, schedules[channel.Id].LastResultID), ChannelID: channel.Id, GroupName: groups[0], Kind: "pelican", Status: "pending", CreatedAt: now, Price: string(encoded), Reserved: reserve} + if err = model.CreateDetection(run); err != nil { + return err + } + } + return nil +} + +func processDetection(ctx context.Context, run model.DetectionRun) { + if ctx.Err() != nil { + return + } + if run.Deadline == 0 { + run.Deadline = time.Now().Unix() + 600 + if model.DB.Model(&model.DetectionRun{}).Where("id = ? AND deadline = 0", run.ID).Updates(map[string]any{"deadline": run.Deadline, "status": "running"}).Error != nil { + return + } + } + taskCtx, taskCancel := context.WithDeadline(ctx, time.Unix(run.Deadline, 0)) + defer taskCancel() + if time.Now().Unix() >= run.Deadline { + var calls []model.DetectionCall + if model.DB.Where("run_id = ?", run.ID).Find(&calls).Error != nil { + return + } + for _, call := range calls { + if call.State != "running" { + continue + } + if cancel, ok := detectionCalls.Load(call.ID); ok { + cancel.(context.CancelFunc)() + return + } + // All proxy contexts expire before the task deadline. A vanished process + // cannot resume or recharge this consumed credential. + call.Error = "interrupted_usage_unknown" + call.Quota = 0 + _ = model.FinishDetectionCall(&call) + } + if run.RemoteID != "" { + cancelCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + _, _ = manxueRunRequest(cancelCtx, &run, http.MethodDelete, "/api/v1/tests/"+run.RemoteID, nil, "") + cancel() + } + finishDetection(run, "failed", "detection_timeout") + return + } + if run.Status == "succeeded" || run.Status == "failed" { + settleDetectionAndSchedule(run) + return + } + if run.Kind == "fingerprint" { + processPaidFingerprint(ctx, run) + return + } + if run.RemoteID == localPelicanID { + processLocalPelican(taskCtx, run) + return + } + if run.RemoteID == "" { + var existing int64 + if model.DB.Model(&model.DetectionCall{}).Where("run_id = ?", run.ID).Count(&existing).Error != nil { + return + } + // A domain-separated PRF allows safe retry after a submit/commit crash; + // only its digest is stored, and the random run ID prevents enumeration. + secret := pelicanCredential(run.ID) + call := model.DetectionCall{ID: run.ID, RunID: run.ID, SecretHash: model.DetectionSecretHash(secret), ExpiresAt: run.Deadline + 300, State: "ready"} + if existing == 0 && model.DB.Create(&call).Error != nil { + return + } + payload := map[string]any{"benchmark": "pelican", "base_url": "https://ai.shrinken.pw/api/detection-proxy/v1", "api_key": secret, "model": fingerprint.Model, "protocol": "responses", "reasoning_effort": "high"} + var result gjson.Result + var err error + for taskCtx.Err() == nil { + result, err = manxueRunRequest(taskCtx, &run, http.MethodPost, "/api/v1/tests", payload, run.ID) + if errors.Is(err, errDetectionServiceUnavailable) && taskCtx.Err() == nil { + processLocalPelican(taskCtx, run) + return + } + if err == nil { + break + } + select { + case <-taskCtx.Done(): + case <-time.After(5 * time.Second): + } + } + if err != nil || taskCtx.Err() != nil { + if ctx.Err() == nil { + processDetection(ctx, run) + } + return + } + remoteID := result.Get("id").String() + if remoteID == "" || len(remoteID) > 128 || strings.ContainsAny(remoteID, "/?#\\") { + finishDetection(run, "failed", "invalid_detection_response") + return + } + if model.DB.Model(&model.DetectionRun{}).Where("id = ?", run.ID).Updates(map[string]any{"remote_id": remoteID, "status": "running"}).Error != nil { + return + } + run.RemoteID = remoteID + } + // Each worker retains its slot until completion, limiting remote generation + // concurrency as well as local submission concurrency. + for time.Now().Unix() < run.Deadline { + result, err := manxueRunRequest(taskCtx, &run, http.MethodGet, "/api/v1/tests/"+run.RemoteID, nil, "") + if errors.Is(err, errDetectionServiceUnavailable) && taskCtx.Err() == nil { + processLocalPelican(taskCtx, run) + return + } + if err != nil { + select { + case <-ctx.Done(): + return + case <-time.After(5 * time.Second): + } + continue + } + switch result.Get("status").String() { + case "succeeded": + quality := result.Get("assessment.quality").String() + if quality != "normal" && quality != "degraded" { + quality = "unknown" + } + html := result.Get("result.html").String() + if len(html) > 2<<20 { + html = "" + } + metadata, _ := common.Marshal(map[string]string{"source_url": manxueArtworkURL(result)}) + if model.DB.Model(&model.DetectionRun{}).Where("id = ?", run.ID).Updates(map[string]any{"quality": quality, "html": html, "result": string(metadata)}).Error != nil { + return + } + finishDetection(run, "succeeded", "") + return + case "failed", "cancelled": + finishDetection(run, "failed", "detection_service_failed") + return + } + select { + case <-ctx.Done(): + return + case <-time.After(5 * time.Second): + } + } + processDetection(ctx, run) +} + +func finishDetection(run model.DetectionRun, status, reason string) { + updates := map[string]any{"status": status, "error": reason} + if status == "failed" && run.Kind == "pelican" { + updates["result"] = "" + } + if model.DB.Model(&model.DetectionRun{}).Where("id = ?", run.ID).Updates(updates).Error != nil { + return + } + settleDetectionAndSchedule(run) +} + +func settleDetectionAndSchedule(run model.DetectionRun) { + if model.SettleDetection(run.ID) != nil || run.UserID != 0 || run.Kind != "pelican" { + return + } + // Reconciliation also repairs the clock if a process exits after settlement. + if err := model.DB.Select("id", "channel_id", "finished_at", "status", "quality").First(&run, "id = ?", run.ID).Error; err != nil { + return + } + outcome := "unknown" + if run.Status == "succeeded" { + outcome = run.Quality + } + _ = model.ApplyDetectionScheduleResult(context.Background(), run.ChannelID, "pelican", run.ID, run.FinishedAt, outcome) +} + +func manxueRequest(ctx context.Context, method, path string, payload any, key string) (gjson.Result, error) { + client := &http.Client{Timeout: 35 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + return manxueRequestWithClient(ctx, client, method, path, payload, key) +} + +func manxueRequestWithClient(ctx context.Context, client *http.Client, method, path string, payload any, key string) (gjson.Result, error) { + var encoded []byte + if payload != nil { + var err error + encoded, err = common.Marshal(payload) + if err != nil { + return gjson.Result{}, err + } + } + for attempt := 0; attempt < 3; attempt++ { + req, err := http.NewRequestWithContext(ctx, method, "https://manxue.ai"+path, bytes.NewReader(encoded)) + if err != nil { + return gjson.Result{}, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "Mozilla/5.0") + if strings.HasPrefix(path, "/api/visitor/") { + req.Header.Set("Origin", "https://manxue.ai") + } + if key != "" { + req.Header.Set("Idempotency-Key", key) + } + resp, err := client.Do(req) + if err != nil { + return gjson.Result{}, errDetectionServiceUnavailable + } + body, readErr := io.ReadAll(io.LimitReader(resp.Body, (3<<20)+1)) + resp.Body.Close() + if readErr != nil || resp.StatusCode >= 500 { + return gjson.Result{}, errDetectionServiceUnavailable + } + if resp.StatusCode == 429 || resp.StatusCode == 503 { + seconds, _ := strconv.Atoi(resp.Header.Get("Retry-After")) + if at, err := http.ParseTime(resp.Header.Get("Retry-After")); err == nil { + seconds = int(time.Until(at).Seconds()) + 1 + } + seconds = max(seconds, 2< 3<<20 || resp.StatusCode < 200 || resp.StatusCode >= 300 || !gjson.ValidBytes(body) { + return gjson.Result{}, errors.New("invalid detection service response") + } + return gjson.ParseBytes(body), nil + } + return gjson.Result{}, errDetectionServiceUnavailable +} + +func processPaidFingerprint(ctx context.Context, run model.DetectionRun) { + channel, err := model.GetChannelById(run.ChannelID, true) + if err != nil || channel.Status != common.ChannelStatusEnabled { + finishDetection(run, "failed", "channel_unavailable") + return + } + samples := []fingerprint.Sample{} + for index := 0; index < 3; index++ { + id := fmt.Sprintf("%s_%d", run.ID, index) + var previous []model.DetectionCall + if model.DB.Where("id = ?", id).Find(&previous).Error != nil { + return + } + if len(previous) > 0 { + if previous[0].State == "running" { + return + } + var sample fingerprint.Sample + if previous[0].Sample != "" && common.UnmarshalJsonStr(previous[0].Sample, &sample) == nil { + samples = append(samples, sample) + } + continue + } + call := model.DetectionCall{ID: id, RunID: run.ID, SecretHash: model.DetectionSecretHash(id), State: "running", StartedAt: time.Now().Unix()} + if model.DB.Create(&call).Error != nil { + return + } + probeCtx, cancel := context.WithDeadline(ctx, time.Unix(run.Deadline-10, 0)) + detectionCalls.Store(call.ID, cancel) + answer, count, probeErr := runAstraFingerprintProbe(probeCtx, channel, true) + interrupted := probeCtx.Err() != nil + cancel() + available := probeErr == nil || answer.HasUsage + call.Available = &available + if !answer.HasUsage && (errors.Is(probeErr, fingerprint.ErrInvalidSample) || errors.Is(probeErr, fingerprint.ErrIncomplete) || interrupted) { + call.Available = nil + } + if probeErr != nil { + call.Error = "channel_call_failed" + } + if answer.HasUsage { + usage, _ := common.Marshal(answer) + call.Usage = string(usage) + quota, quotaErr := service.DetectionUsageQuota(run.Price, answer.InputTokens, answer.OutputTokens, answer.CachedTokens, answer.CacheWriteTokens) + if quotaErr != nil || quota > run.Reserved/3 { + call.Error = "settlement_unavailable" + } else { + call.Quota = quota + } + } else { + call.Error = "usage_unavailable" + } + if probeErr == nil && fingerprint.ValidArray(answer.Text, count) { + sample := fingerprint.Sample{Text: answer.Text, Count: count} + samples = append(samples, sample) + encoded, _ := common.Marshal(sample) + call.Sample = string(encoded) + } + if model.FinishDetectionCall(&call) != nil { + detectionCalls.Delete(call.ID) + return + } + detectionCalls.Delete(call.ID) + if ctx.Err() != nil { + return + } + } + result, err := fingerprint.Score(samples) + if err != nil { + finishDetection(run, "failed", "scoring_failed") + return + } + encoded, _ := common.Marshal(result) + if model.DB.Model(&model.DetectionRun{}).Where("id = ?", run.ID).Updates(map[string]any{"quality": result.Status, "result": string(encoded)}).Error != nil { + return + } + finishDetection(run, "succeeded", "") +} diff --git a/controller/live_status_test.go b/controller/live_status_test.go new file mode 100644 index 0000000..8d1e286 --- /dev/null +++ b/controller/live_status_test.go @@ -0,0 +1,28 @@ +package controller + +import ( + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +func TestSuccessfulUserRequestRestoresStatus(t *testing.T) { + now := time.Now().Unix() + for _, tc := range []struct { + name string + success, probe int64 + disabled bool + want int + }{ + {"fresh user overrides failed probe", now, now - 10, false, 1}, + {"newer failed probe wins", now - 10, now, false, 0}, + {"expired success", now - 901, now - 1000, false, 0}, + {"disabled remains disabled", now, now - 10, true, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + m := Monitor{Status: 0, ChannelDisabled: tc.disabled} + applyUserSuccess(&m, tc.success, tc.probe, now) + assert.Equal(t, tc.want, m.Status) + }) + } +} diff --git a/controller/log.go b/controller/log.go index b548499..2ffad1d 100644 --- a/controller/log.go +++ b/controller/log.go @@ -22,7 +22,7 @@ func GetAllLogs(c *gin.Context) { group := c.Query("group") requestId := c.Query("request_id") upstreamRequestId := c.Query("upstream_request_id") - logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group, requestId, upstreamRequestId) + logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group, requestId, upstreamRequestId, c.Query("correlation_key"), c.Query("attempt_status")) if err != nil { common.ApiError(c, err) return @@ -49,7 +49,7 @@ func GetUserLogs(c *gin.Context) { group := c.Query("group") requestId := c.Query("request_id") upstreamRequestId := c.Query("upstream_request_id") - logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group, requestId, upstreamRequestId) + logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group, requestId, upstreamRequestId,c.Query("attempt_status")) if err != nil { common.ApiError(c, err) return @@ -109,7 +109,7 @@ func GetLogsStat(c *gin.Context) { modelName := c.Query("model_name") channel, _ := strconv.Atoi(c.Query("channel")) group := c.Query("group") - stat, err := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channel, group) + stat, err := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channel, group, c.Query("correlation_key"), c.Query("attempt_status")) if err != nil { common.ApiError(c, err) return @@ -136,7 +136,7 @@ func GetLogsSelfStat(c *gin.Context) { modelName := c.Query("model_name") channel, _ := strconv.Atoi(c.Query("channel")) group := c.Query("group") - quotaNum, err := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channel, group) + quotaNum, err := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channel, group,"",c.Query("attempt_status")) if err != nil { common.ApiError(c, err) return diff --git a/controller/login_verification.go b/controller/login_verification.go index 6d89b30..e61c252 100644 --- a/controller/login_verification.go +++ b/controller/login_verification.go @@ -30,17 +30,18 @@ func VerifyLogin(c *gin.Context) { writeSecurityOperationError(c, service.ErrProofMethod) return } - bundle, err := service.VerifyLoginCode(request.FlowToken, request.Code, c.ClientIP(), c.Request.UserAgent()) + bundle, migration, err := service.VerifyLoginCode(request.FlowToken, request.Code, c.ClientIP(), c.Request.UserAgent()) if err != nil { writeSecurityOperationError(c, err) return } - completeVerifiedLoginResponse(c, bundle, service.VerificationMethodTwoFA) + completeVerifiedLoginResponse(c, bundle, service.VerificationMethodTwoFA, migration) } func LoginPasskeyBegin(c *gin.Context) { var request struct { FlowToken string `json:"flow_token"` + RPID string `json:"rp_id"` } if common.DecodeJson(c.Request.Body, &request) != nil || request.FlowToken == "" { common.ApiErrorMsg(c, "参数错误") @@ -56,7 +57,11 @@ func LoginPasskeyBegin(c *gin.Context) { writeSecurityOperationError(c, err) return } - wa, err := passkeysvc.BuildWebAuthn(c.Request) + credentialRPID := "" + if credential.RPID != nil { + credentialRPID = *credential.RPID + } + wa, rpIDs, err := passkeysvc.BuildLoginWebAuthn(c.Request, request.RPID, credentialRPID) if err != nil { writeSecurityOperationError(c, err) return @@ -75,7 +80,7 @@ func LoginPasskeyBegin(c *gin.Context) { writeSecurityOperationError(c, err) return } - common.ApiSuccess(c, gin.H{"flow_token": token, "expires_at": expiresAt, "options": options}) + common.ApiSuccess(c, gin.H{"flow_token": token, "expires_at": expiresAt, "options": options, "rp_ids": rpIDs}) } func LoginPasskeyFinish(c *gin.Context) { @@ -104,6 +109,7 @@ func LoginPasskeyFinish(c *gin.Context) { writeSecurityOperationError(c, err) return } + c.Set("passkey_rp_id", sessionData.RelyingPartyID) if security.LoginFlowID != verification.Flow.Id || sessionData.UserVerification != protocol.VerificationRequired { writeSecurityOperationError(c, model.ErrAuthFlowInvalid) return @@ -113,29 +119,33 @@ func LoginPasskeyFinish(c *gin.Context) { writeSecurityOperationError(c, err) return } - wa, err := passkeysvc.BuildWebAuthn(c.Request) + wa, err := passkeysvc.BuildWebAuthnForRPID(c.Request, sessionData.RelyingPartyID) if err != nil { writeSecurityOperationError(c, err) return } + if credential.RPID != nil && *credential.RPID != "" && *credential.RPID != sessionData.RelyingPartyID { + writeSecurityOperationError(c, service.ErrVerificationFailed) + return + } validated, err := wa.ValidateLogin(passkeysvc.NewWebAuthnUser(&model.User{Id: identity.UserID}, credential), *sessionData, parsed) if err != nil { writeSecurityOperationError(c, err) return } - if err := model.UpdatePasskeyAssertionState(identity.UserID, validated, time.Now()); err != nil { + if err := model.UpdatePasskeyAssertionState(identity.UserID, validated, time.Now(), sessionData.RelyingPartyID); err != nil { writeSecurityOperationError(c, err) return } - bundle, err := service.CompleteLoginVerification(request.FlowToken, verification, service.VerificationMethodPasskey, c.ClientIP(), c.Request.UserAgent()) + bundle, migration, err := service.CompleteLoginVerification(request.FlowToken, verification, service.VerificationMethodPasskey, c.ClientIP(), c.Request.UserAgent()) if err != nil { writeSecurityOperationError(c, err) return } - completeVerifiedLoginResponse(c, bundle, service.VerificationMethodPasskey) + completeVerifiedLoginResponse(c, bundle, service.VerificationMethodPasskey, migration) } -func completeVerifiedLoginResponse(c *gin.Context, bundle *service.AuthBundle, method string) { +func completeVerifiedLoginResponse(c *gin.Context, bundle *service.AuthBundle, method string, migration *service.LegacyGitHubMigration) { identity, err := service.ParseAccessToken(bundle.AccessToken) if err != nil { writeAuthSessionError(c, err) @@ -147,5 +157,13 @@ func completeVerifiedLoginResponse(c *gin.Context, bundle *service.AuthBundle, m return } c.Set("login_verification_method", method) + if migration != nil { + // The legacy GitHub binding was rewritten together with this session. + notificationFailed := service.NotifyAccountSecurityChange(user.Email, "Login account linked: GitHub") != nil + recordLegacyGitHubBindingAudit(c, user, true, map[string]any{ + "legacy_id": migration.LegacyID, "provider_user_id": migration.GitHubID, + "verification_method": method, "notification_failed": notificationFailed, + }) + } writeLoginResponse(c, user, bundle) } diff --git a/controller/manxue_artwork_url.go b/controller/manxue_artwork_url.go new file mode 100644 index 0000000..b31558e --- /dev/null +++ b/controller/manxue_artwork_url.go @@ -0,0 +1,27 @@ +package controller + +import ( + "github.com/tidwall/gjson" + "net/url" +) + +func manxueArtworkURL(result gjson.Result) string { + for _, key := range []string{"share_url", "public_url", "result_url", "result.share_url", "result.public_url"} { + raw := result.Get(key).String() + u, err := url.Parse(raw) + if err == nil && u.Scheme == "https" && u.Host == "manxue.ai" && u.User == nil && u.Query().Get("result") != "" { + return u.String() + } + } + id := result.Get("public_id").String() + if id == "" { + id = result.Get("result.public_id").String() + } + if id == "" && result.Get("public").Bool() { + id = result.Get("result.id").String() + } + if id == "" { + return "" + } + return "https://manxue.ai/?" + url.Values{"result": {id}}.Encode() +} diff --git a/controller/manxue_artwork_url_test.go b/controller/manxue_artwork_url_test.go new file mode 100644 index 0000000..898eca6 --- /dev/null +++ b/controller/manxue_artwork_url_test.go @@ -0,0 +1,19 @@ +package controller + +import ( + "github.com/stretchr/testify/assert" + "github.com/tidwall/gjson" + "testing" +) + +func TestManxueArtworkURL(t *testing.T) { + assert.Empty(t, manxueArtworkURL(gjson.Parse(`{"public":false,"result":{"id":"private"}}`))) + assert.Equal(t, "https://manxue.ai/?result=community-one", manxueArtworkURL(gjson.Parse(`{"public":true,"result":{"id":"community-one"}}`))) + assert.Empty(t, manxueArtworkURL(gjson.Parse(`{"share_url":"https://evil.test/?result=one"}`))) +} +func TestArtworkPreservesScriptDrawnLegs(t *testing.T) { + result := sanitizeDetectionHTML(``) + assert.Contains(t, result, " Configure'), + ], + [ + 'image', + english.replace( + '- Configure', + '- ![image](https://other.example/img) Configure' + ), + ], + ['additional heading', `${english}\n#### Notes\n- More.`], + [ + 'migration without changes', + `${english.slice( + 0, + english.indexOf('### Fixed') + )}### Migration\n\n- Configure prices.`, + ], + ['oversized body', english + 'x'.repeat(256 * 1024)], + ])('rejects %s', (_label, text) => { + expect(() => parsePluginChangelog(text, identity, sourceUrl)).toThrow() + }) +}) + +describe('changelog downloads', () => { + test('fetches the declared translation without credentials, redirects, or dashboard referrers', async () => { + const fetchMock = vi.fn( + async (url: string) => new Response(url === sourceUrl ? english : chinese) + ) + vi.stubGlobal('fetch', fetchMock) + const result = await load('zhCN') + expect(result?.locale).toBe('zh-CN') + expect(result?.sections[1].markdown).toContain('配置新分辨率档位的价格。') + expect(fetchMock).toHaveBeenLastCalledWith( + sourceUrl.replace('CHANGELOG.md', 'CHANGELOG.zh-CN.md'), + expect.objectContaining({ + credentials: 'omit', + redirect: 'error', + referrerPolicy: 'no-referrer', + }) + ) + }) + + test.each(['fr', 'ru', 'vi', 'zhTW', 'en-US'])( + 'falls back to English for %s rather than another available translation', + async (language) => { + const fetchMock = vi.fn(async () => new Response(english)) + vi.stubGlobal('fetch', fetchMock) + expect((await load(language))?.locale).toBe('en') + expect(fetchMock).toHaveBeenCalledTimes(1) + } + ) + + test.each(['404', 'network', 'identity', 'categories'])( + 'falls back to English when the requested translation fails with %s', + async (failure) => { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => { + if (url === sourceUrl) return new Response(english) + if (failure === 'network') throw new Error('network failure') + if (failure === '404') return new Response(null, { status: 404 }) + return new Response( + failure === 'identity' + ? chinese.replace('plugin: demo', 'plugin: other') + : chinese.replace('### Fixed', '### Added') + ) + }) + ) + expect((await load('zhCN'))?.locale).toBe('en') + } + ) + + test('a canonical 404 is an empty release; network and format failures remain retryable errors', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 404 })) + .mockResolvedValueOnce(new Response(null, { status: 503 })) + .mockResolvedValueOnce(new Response('invalid')) + vi.stubGlobal('fetch', fetchMock) + expect(await load()).toBeNull() + await expect(load()).rejects.toThrow() + await expect(load()).rejects.toThrow() + }) + + test('cancellation during a translation download is propagated instead of returned as English', async () => { + const controller = new AbortController() + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => { + if (url === sourceUrl) return new Response(english) + controller.abort() + throw controller.signal.reason + }) + ) + await expect(load('zhCN', controller.signal)).rejects.toMatchObject({ + name: 'AbortError', + }) + }) + + test('rejects oversized streamed content and invalid UTF-8', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce(new Response('x'.repeat(256 * 1024 + 1))) + .mockResolvedValueOnce(new Response(new Uint8Array([0xff]))) + ) + await expect(load()).rejects.toThrow() + await expect(load()).rejects.toThrow() + }) +}) diff --git a/web/src/features/task-plugins/__tests__/plugin-detail-sheet.test.tsx b/web/src/features/task-plugins/__tests__/plugin-detail-sheet.test.tsx index 1f6c9fb..ae2a0e3 100644 --- a/web/src/features/task-plugins/__tests__/plugin-detail-sheet.test.tsx +++ b/web/src/features/task-plugins/__tests__/plugin-detail-sheet.test.tsx @@ -548,3 +548,63 @@ test('comparison selection shows loading, retries failure, and resets after reop ).toHaveValue('') expect(screen.queryByText(/previous source/)).toBeNull() }) + +test.each([true, false])( + 'shows model usage profiles and only displays defaults for ungrouped models (%s)', + async (hasDefaultModel) => { + const user = userEvent.setup() + renderSheet({ + models: hasDefaultModel + ? ['image', 'video', 'legacy'] + : ['image', 'video'], + usageSchema: { + fallback_units: { type: 'number', unit: 'count' }, + }, + usageProfiles: [ + { + models: ['image'], + schema: { image_count: { type: 'number', unit: 'count' } }, + }, + { + models: ['video'], + schema: { + seconds: { type: 'number', unit: 'second' }, + resolution: { enum: ['720P', '1080P'] }, + }, + }, + ], + }) + await user.click(screen.getByRole('tab', { name: 'Billing parameters' })) + const image = within(screen.getByRole('region', { name: 'image' })) + expect(image.getByText('image_count')).toBeVisible() + expect(image.queryByText('seconds')).not.toBeInTheDocument() + expect(image.queryByText('resolution')).not.toBeInTheDocument() + const video = within(screen.getByRole('region', { name: 'video' })) + expect(video.getByText('seconds')).toBeVisible() + expect(video.getByText('resolution')).toBeVisible() + expect(video.queryByText('image_count')).not.toBeInTheDocument() + if (hasDefaultModel) { + const defaults = within(screen.getByRole('region', { name: 'Default' })) + expect(defaults.getByText('legacy')).toBeVisible() + expect(defaults.getByText('fallback_units')).toBeVisible() + expect(defaults.queryByText('image')).not.toBeInTheDocument() + } else { + expect( + screen.queryByRole('region', { name: 'Default' }) + ).not.toBeInTheDocument() + expect(screen.queryByText('fallback_units')).not.toBeInTheDocument() + } + } +) + +test('keeps the billing parameter table for plugins without usage profiles', async () => { + const user = userEvent.setup() + renderSheet({ + usageSchema: { seconds: { type: 'number', unit: 'second' } }, + }) + await user.click(screen.getByRole('tab', { name: 'Billing parameters' })) + expect(within(screen.getByRole('table')).getByText('seconds')).toBeVisible() + expect( + screen.queryByText('No billing parameters declared') + ).not.toBeInTheDocument() +}) diff --git a/web/src/features/task-plugins/__tests__/usage-schema-table.test.tsx b/web/src/features/task-plugins/__tests__/usage-schema-table.test.tsx index 8cda339..1c49a3f 100644 --- a/web/src/features/task-plugins/__tests__/usage-schema-table.test.tsx +++ b/web/src/features/task-plugins/__tests__/usage-schema-table.test.tsx @@ -16,8 +16,9 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { render, screen } from '@testing-library/react' -import { describe, expect, test } from 'vitest' +import { act, render, screen } from '@testing-library/react' +import i18next from 'i18next' +import { afterEach, describe, expect, test } from 'vitest' import type { BillingUsageSchema } from '@/features/pricing/types' @@ -36,6 +37,30 @@ const schema: BillingUsageSchema = { } describe('UsageSchemaTable layout', () => { + afterEach(async () => { + await act(() => i18next.changeLanguage('en')) + }) + + test('uses localized count unit labels while preserving legacy unit names', async () => { + render( + + ) + expect(screen.getByRole('cell', { name: 'image' })).toBeVisible() + expect(screen.getByRole('cell', { name: 'Count' })).toBeVisible() + await act(() => i18next.changeLanguage('zhCN')) + expect(screen.getByRole('cell', { name: '张' })).toBeVisible() + expect(screen.getByRole('cell', { name: i18next.t('Count') })).toBeVisible() + }) + test('given a usage schema, every declaration renders as a five-column table row', () => { render() diff --git a/web/src/features/task-plugins/api.ts b/web/src/features/task-plugins/api.ts index 159f490..a9dac1a 100644 --- a/web/src/features/task-plugins/api.ts +++ b/web/src/features/task-plugins/api.ts @@ -17,6 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { api, type ApiRequestConfig } from '@/lib/api' +import { createServerError } from '@/lib/server-error-message' import type { ApiResponse, @@ -60,7 +61,7 @@ function requireSuccess(response: ApiResponse): T { if (data?.channels && typeof data.in_flight_count === 'number') { throw new TaskPluginUsageError(response.message, data) } - throw new Error(response.message) + throw createServerError(response) } return response.data } diff --git a/web/src/features/task-plugins/components/marketplace-install-dialog.tsx b/web/src/features/task-plugins/components/marketplace-install-dialog.tsx index 02b2a83..b13a747 100644 --- a/web/src/features/task-plugins/components/marketplace-install-dialog.tsx +++ b/web/src/features/task-plugins/components/marketplace-install-dialog.tsx @@ -18,11 +18,12 @@ For commercial licensing, please contact support@quantumnous.com */ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { AlertTriangle, Download } from 'lucide-react' -import { useState } from 'react' +import { lazy, Suspense, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { Dialog } from '@/components/dialog' +import { LoadingState } from '@/components/loading-state' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { Combobox } from '@/components/ui/combobox' @@ -52,6 +53,12 @@ import { PluginIcon } from './plugin-icon' import { PluginIntegrityCheck } from './plugin-integrity-check' import { SourceDiff } from './source-diff' +const PluginChangelogPanel = lazy(() => + import('./plugin-changelog-panel').then((module) => ({ + default: module.PluginChangelogPanel, + })) +) + export type MarketplaceInstallTarget = { source: MarketplaceSource plugin: MarketplacePlugin @@ -98,6 +105,7 @@ function MarketplaceInstallContent( ], enabled: Boolean(entry), retry: false, + meta: { errorToast: false }, queryFn: async () => { if (!entry) throw new Error('missing marketplace entry') const url = resolvePluginSourceUrl(target.source.index_url, entry.path) @@ -299,7 +307,10 @@ function MarketplaceInstallContent( } installMutation.reset() setSelectedVersion(version) - if (target.installState.status !== 'not_installed') { + if ( + target.installState.status !== 'not_installed' && + reviewTab !== 'changelog' + ) { setReviewTab( version === target.installState.installedVersion ? 'details' @@ -322,13 +333,19 @@ function MarketplaceInstallContent( onValueChange={setReviewTab} className='min-w-0 gap-3' > - - {t('Plugin details')} - {hasInstalledPlugin && ( - {t('Version differences')} - )} - {t('Full source')} - +
+ + {t('Plugin details')} + {hasInstalledPlugin && ( + {t('Version differences')} + )} + {t('Full source')} + {t('Changelog')} + +
{hasInstalledPlugin && ( {sourceStatus} @@ -366,6 +383,17 @@ function MarketplaceInstallContent( }} /> + + {reviewTab === 'changelog' && ( + }> + + + )} + {sourceStatus} {!sourceQuery.isError && sourcePreview} diff --git a/web/src/features/task-plugins/components/marketplace-panel.tsx b/web/src/features/task-plugins/components/marketplace-panel.tsx index 3607f1c..b6ac067 100644 --- a/web/src/features/task-plugins/components/marketplace-panel.tsx +++ b/web/src/features/task-plugins/components/marketplace-panel.tsx @@ -74,6 +74,7 @@ export function MarketplacePanel() { queryKey: ['task-plugin-marketplace', selectedSource?.index_url], enabled: Boolean(selectedSource), retry: false, + meta: { errorToast: false }, queryFn: async (): Promise => { if (!selectedSource) throw new Error('marketplace source is not selected') const response = await fetch(selectedSource.index_url) diff --git a/web/src/features/task-plugins/components/marketplace-plugin-card.tsx b/web/src/features/task-plugins/components/marketplace-plugin-card.tsx index 5aa8048..eacf758 100644 --- a/web/src/features/task-plugins/components/marketplace-plugin-card.tsx +++ b/web/src/features/task-plugins/components/marketplace-plugin-card.tsx @@ -214,6 +214,7 @@ function MarketplacePluginLogo(props: MarketplacePluginLogoProps) { fetchPluginIconDataUri(props.iconUrl, { sha256: props.sha256 }), staleTime: Number.POSITIVE_INFINITY, retry: false, + meta: { errorToast: false }, }) return ( toast.error(error.message), + onError: (error) => handleServerError(error), }) const updateRow = (index: number, patch: Partial) => { diff --git a/web/src/features/task-plugins/components/plugin-changelog-panel.tsx b/web/src/features/task-plugins/components/plugin-changelog-panel.tsx new file mode 100644 index 0000000..0faace2 --- /dev/null +++ b/web/src/features/task-plugins/components/plugin-changelog-panel.tsx @@ -0,0 +1,127 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useQuery } from '@tanstack/react-query' +import { ExternalLink, History } from 'lucide-react' +import { useTranslation } from 'react-i18next' + +import { EmptyState } from '@/components/empty-state' +import { ErrorState } from '@/components/error-state' +import { LoadingState } from '@/components/loading-state' +import { Markdown } from '@/components/ui/markdown' + +import { findMarketplaceVersion } from '../lib/marketplace' +import { changelogLocale, fetchPluginChangelog } from '../lib/plugin-changelog' +import type { MarketplacePlugin } from '../types' + +type PluginChangelogPanelProps = { + indexUrl: string + plugin: MarketplacePlugin + version: string +} + +export function PluginChangelogPanel(props: PluginChangelogPanelProps) { + const { t, i18n } = useTranslation() + const locale = changelogLocale(i18n.language) + const entry = findMarketplaceVersion(props.plugin, props.version) + const query = useQuery({ + queryKey: [ + 'task-plugin-marketplace-changelog', + props.indexUrl, + props.plugin.key, + props.version, + entry?.path, + entry?.sha256, + locale, + ], + queryFn: ({ signal }) => + fetchPluginChangelog({ ...props, language: locale, signal }), + staleTime: 15 * 60 * 1000, + retry: false, + meta: { errorToast: false }, + }) + + if (query.isPending) return + if (query.isError && !query.data) { + return ( + void query.refetch()} + /> + ) + } + if (!query.data) { + return ( + + ) + } + + const changelog = query.data + const categoryLabels = { + Added: t('Added'), + Changed: t('Changed'), + Deprecated: t('Deprecated'), + Removed: t('Removed'), + Fixed: t('Fixed'), + Security: t('Security'), + Migration: t('Migration notes'), + } + return ( +
+
+ + v{changelog.version} + + + +
+ {locale !== 'en' && changelog.locale === 'en' && ( +

+ {t( + 'This changelog is not available in your language. Showing English.' + )} +

+ )} + {changelog.sections.map((section) => ( +
+

{categoryLabels[section.category]}

+
+ + {section.markdown} + +
+
+ ))} +
+ ) +} diff --git a/web/src/features/task-plugins/components/plugin-detail-sheet.tsx b/web/src/features/task-plugins/components/plugin-detail-sheet.tsx index dfa7f72..b4a8197 100644 --- a/web/src/features/task-plugins/components/plugin-detail-sheet.tsx +++ b/web/src/features/task-plugins/components/plugin-detail-sheet.tsx @@ -10,24 +10,6 @@ import { ErrorState } from '@/components/error-state' import { LoadingState } from '@/components/loading-state' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' -/* -Copyright (C) 2023-2026 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ import { Combobox } from '@/components/ui/combobox' import { Sheet, @@ -46,6 +28,25 @@ import { TableRow, } from '@/components/ui/table' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { handleServerError } from '@/lib/handle-server-error' import { resolveLocalizedText } from '@/lib/localized-text' import { @@ -57,6 +58,7 @@ import type { TaskPluginListItem } from '../types' import { JavaScriptViewer } from './javascript-viewer' import { PluginIcon } from './plugin-icon' import { PluginMetadataCard } from './plugin-metadata-card' +import { PluginModelList } from './plugin-model-list' import { PluginSandbox } from './plugin-sandbox' import { SourceDiff } from './source-diff' import { UsageSchemaTable } from './usage-schema-table' @@ -110,7 +112,7 @@ function PluginDetailContent(props: { plugin: TaskPluginListItem }) { queryClient.invalidateQueries({ queryKey: ['task-plugin', key] }) queryClient.invalidateQueries({ queryKey: ['task-plugin-versions', key] }) }, - onError: (error) => toast.error(error.message), + onError: (error) => handleServerError(error), }) const detail = detailQuery.data const versions = versionsQuery.data ?? [] @@ -144,6 +146,16 @@ function PluginDetailContent(props: { plugin: TaskPluginListItem }) { const panelClassName = 'min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-contain p-4 sm:p-6 data-hidden:hidden' + const usageProfiles = detail?.meta.usageProfiles ?? [] + const groupedUsageModels = new Set( + usageProfiles.flatMap((profile) => profile.models) + ) + const defaultUsageModels = (detail?.meta.models ?? []).filter( + (model) => !groupedUsageModels.has(model) + ) + const showDefaultUsage = + Object.keys(detail?.meta.usageSchema ?? {}).length > 0 && + (usageProfiles.length === 0 || defaultUsageModels.length > 0) return ( )}
- {detailState ?? - (detail?.meta.usageSchema && - Object.keys(detail.meta.usageSchema).length > 0 ? ( - - ) : ( - - ))} + {detailState ?? ( +
+ {showDefaultUsage && detail?.meta.usageSchema && ( +
+ {usageProfiles.length > 0 && ( + <> +

{t('Default')}

+ + + )} + +
+ )} + {usageProfiles.map((profile) => ( +
+ + +
+ ))} + {!showDefaultUsage && usageProfiles.length === 0 && ( + + )} +
+ )}
{detailState ?? diff --git a/web/src/features/task-plugins/components/plugin-icon.tsx b/web/src/features/task-plugins/components/plugin-icon.tsx index cd35a08..7d10c61 100644 --- a/web/src/features/task-plugins/components/plugin-icon.tsx +++ b/web/src/features/task-plugins/components/plugin-icon.tsx @@ -54,6 +54,7 @@ function GatewayPluginIcon(props: PluginIconProps) { }, staleTime: 5 * 60 * 1000, retry: false, + meta: { errorToast: false }, }) const [image, setImage] = useState<{ blob: Blob; src: string } | null>(null) useEffect(() => { diff --git a/web/src/features/task-plugins/components/plugin-sandbox.tsx b/web/src/features/task-plugins/components/plugin-sandbox.tsx index fa6e31f..6d8dfb7 100644 --- a/web/src/features/task-plugins/components/plugin-sandbox.tsx +++ b/web/src/features/task-plugins/components/plugin-sandbox.tsx @@ -6,18 +6,14 @@ it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. */ -import { Combobox } from '@/components/ui/combobox' import { useMutation } from '@tanstack/react-query' import { Play } from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { - CodeBlock, - CodeBlockEditor, -} from '@/components/ai-elements/code-block' +import { CodeBlock, CodeBlockEditor } from '@/components/ai-elements/code-block' import { Button } from '@/components/ui/button' - +import { Combobox } from '@/components/ui/combobox' import { dryRunTaskPlugin } from '../api' @@ -42,28 +38,33 @@ export function PluginSandbox(props: { pluginKey: string }) { const [args, setArgs] = useState('[{}]') const [output, setOutput] = useState('') const mutation = useMutation({ + meta: { errorToast: false }, mutationFn: async () => { const parsed = JSON.parse(args) as unknown - if (!Array.isArray(parsed)) throw new Error(t('Arguments must be a JSON array')) + if (!Array.isArray(parsed)) { + throw new Error(t('Arguments must be a JSON array')) + } const memberSeparator = hook.indexOf('.') return dryRunTaskPlugin(props.pluginKey, { hook: memberSeparator < 0 ? hook : hook.slice(0, memberSeparator), - member: memberSeparator < 0 ? undefined : hook.slice(memberSeparator + 1), + member: + memberSeparator < 0 ? undefined : hook.slice(memberSeparator + 1), args: parsed, }) }, onSuccess: (value) => setOutput(JSON.stringify(value, null, 2)), - onError: (error) => setOutput(JSON.stringify({ error: error.message }, null, 2)), + onError: (error) => + setOutput(JSON.stringify({ error: error.message }, null, 2)), }) return (
({ value: item, label: item }))} - value={hook} - onValueChange={(value) => setHook(value ?? '')} - aria-label={t('Hook')} -/> + options={hooks.map((item) => ({ value: item, label: item }))} + value={hook} + onValueChange={(value) => setHook(value ?? '')} + aria-label={t('Hook')} + />
) } diff --git a/web/src/features/task-plugins/components/plugin-url-import-field.tsx b/web/src/features/task-plugins/components/plugin-url-import-field.tsx index af431a4..c8e4299 100644 --- a/web/src/features/task-plugins/components/plugin-url-import-field.tsx +++ b/web/src/features/task-plugins/components/plugin-url-import-field.tsx @@ -52,6 +52,7 @@ type PluginUrlImportFieldProps = { export function PluginUrlImportField(props: PluginUrlImportFieldProps) { const { t } = useTranslation() const importMutation = useMutation({ + meta: { errorToast: false }, mutationFn: async () => { const normalized = normalizePluginSourceUrl(props.value) if (!normalized) { diff --git a/web/src/features/task-plugins/components/plugins-table.tsx b/web/src/features/task-plugins/components/plugins-table.tsx index d1e8fb5..548f3d9 100644 --- a/web/src/features/task-plugins/components/plugins-table.tsx +++ b/web/src/features/task-plugins/components/plugins-table.tsx @@ -35,6 +35,7 @@ import { } from '@/components/ui/dropdown-menu' import { Switch } from '@/components/ui/switch' import { getChannelTypeLabel } from '@/features/channels/lib' +import { handleServerError } from '@/lib/handle-server-error' import { resolveLocalizedText } from '@/lib/localized-text' import { @@ -101,7 +102,7 @@ export function PluginsTable(props: PluginsTableProps) { setBlockedAction('disable') return } - toast.error(error.message) + handleServerError(error) }, }) const deleteMutation = useMutation({ @@ -118,7 +119,7 @@ export function PluginsTable(props: PluginsTableProps) { setBlockedAction('delete') return } - toast.error(error.message) + handleServerError(error) }, }) const columns = useMemo[]>( @@ -471,7 +472,7 @@ export function PluginsTable(props: PluginsTableProps) { queryKey: ['task-plugins'], }) }) - .catch((error: Error) => toast.error(error.message)) + .catch((error: Error) => handleServerError(error)) } if (blockedAction === 'disable' && statusTarget) { statusMutation.mutate({ diff --git a/web/src/features/task-plugins/components/usage-schema-table.tsx b/web/src/features/task-plugins/components/usage-schema-table.tsx index 06b4531..26ccf26 100644 --- a/web/src/features/task-plugins/components/usage-schema-table.tsx +++ b/web/src/features/task-plugins/components/usage-schema-table.tsx @@ -26,7 +26,10 @@ import { TableHeader, TableRow, } from '@/components/ui/table' -import { taskEnumLabel } from '@/features/pricing/lib/task-price-display' +import { + taskEnumLabel, + taskUsageUnitLabel, +} from '@/features/pricing/lib/task-price-display' import type { BillingUsageSchema } from '@/features/pricing/types' import { resolveLocalizedText } from '@/lib/localized-text' @@ -78,7 +81,13 @@ export function UsageSchemaTable(props: UsageSchemaTableProps) { {name} {t(getUsageTypeLabelKey(definition.type))} - {formatUsageUnit(definition.unit, t)} + + {taskUsageUnitLabel( + definition, + i18n.language, + formatUsageUnit(definition.unit, t) + )} + {definition.enum ?.map((value) => { diff --git a/web/src/features/task-plugins/index.tsx b/web/src/features/task-plugins/index.tsx index 14c6226..c2b81ae 100644 --- a/web/src/features/task-plugins/index.tsx +++ b/web/src/features/task-plugins/index.tsx @@ -33,6 +33,7 @@ import { } from '@/components/ui/popover' import { Switch } from '@/components/ui/switch' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { handleServerError } from '@/lib/handle-server-error' import { getTaskPluginEnabledOption, @@ -67,7 +68,7 @@ export function TaskPlugins() { queryClient.setQueryData(['task-plugin-enabled'], enabled) toast.success(t('Task plugin setting updated')) }, - onError: (error) => toast.error(error.message), + onError: (error) => handleServerError(error), }) const openUpload = (key?: string) => { setUploadKey(key ?? null) @@ -108,9 +109,7 @@ export function TaskPlugins() {
-

- {t('Enable task plugins')} -

+

{t('Enable task plugins')}

{t( 'When disabled, the entire task plugin system stops serving, including factory and custom plugins.' diff --git a/web/src/features/task-plugins/lib/plugin-changelog.ts b/web/src/features/task-plugins/lib/plugin-changelog.ts new file mode 100644 index 0000000..5a3a47a --- /dev/null +++ b/web/src/features/task-plugins/lib/plugin-changelog.ts @@ -0,0 +1,330 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { Lexer, type MarkedToken, type Token } from 'marked' +import { parseDocument } from 'yaml' +import { z } from 'zod' + +import type { MarketplacePlugin } from '../types' +import { + DEFAULT_MARKETPLACE_INDEX_URL, + GITHUB_MARKETPLACE_INDEX_URL, + findMarketplaceVersion, + resolvePluginSourceUrl, +} from './marketplace' + +export const CHANGELOG_CATEGORIES = [ + 'Added', + 'Changed', + 'Deprecated', + 'Removed', + 'Fixed', + 'Security', + 'Migration', +] as const +export type ChangelogCategory = (typeof CHANGELOG_CATEGORIES)[number] +export type PluginChangelog = { + plugin: string + version: string + locale: string + sourceUrl: string + sections: { category: ChangelogCategory; markdown: string }[] +} + +type ChangelogIdentity = { plugin: string; version: string; locale: string } +const MAX_CHANGELOG_BYTES = 256 * 1024 +const metadataSchema = z + .object({ + changelogVersion: z.literal(1), + plugin: z.string().min(1), + version: z.string().min(1), + locale: z.string().min(1), + translations: z.record(z.string(), z.string()).optional(), + }) + .strict() + +/** Translate the app's language aliases to canonical release-note language tags. */ +export function changelogLocale(language: string): string { + const normalized = language.trim().replaceAll('_', '-').toLowerCase() + if (['zh', 'zhcn', 'zh-cn', 'zh-hans'].includes(normalized)) return 'zh-CN' + if (['zhtw', 'zh-tw', 'zh-hant'].includes(normalized)) return 'zh-TW' + try { + return Intl.getCanonicalLocales(normalized)[0] ?? 'en' + } catch { + return 'en' + } +} + +/** Resolve the sidecar for an index-registered version, including the official proxy index. */ +export function resolvePluginChangelogUrl( + indexUrl: string, + plugin: MarketplacePlugin, + version: string +): string | null { + const entry = findMarketplaceVersion(plugin, version) + if (!entry) return null + const source = resolvePluginSourceUrl(indexUrl, entry.path) + if (!source) return null + const url = new URL(source) + if (url.username || url.password || !url.pathname.endsWith('/plugin.js')) { + return null + } + // The official website rewrites plugin paths into download API URLs. Its + // release sidecars still live beside the same key/version in the official repo. + if (indexUrl.trim() === DEFAULT_MARKETPLACE_INDEX_URL) { + if ( + ![plugin.key, version].every( + (id) => /^[A-Za-z0-9._-]+$/.test(id) && id !== '.' && id !== '..' + ) + ) { + return null + } + const expected = new URL(`${plugin.key}/${version}/plugin.js`, indexUrl) + if (url.pathname !== expected.pathname) return null + return new URL( + `plugins/tasks/${plugin.key}/${version}/CHANGELOG.md`, + GITHUB_MARKETPLACE_INDEX_URL + ).href + } + return new URL('CHANGELOG.md', url).href +} + +/** No HTML, images, task checkboxes or nested blocks can become marketplace content. */ +function validateChangelogInline(tokens: Token[], depth = 0): void { + if (depth > 32) throw new Error('Changelog inline nesting exceeds the limit') + for (const token of tokens as MarkedToken[]) { + switch (token.type) { + case 'text': + if (token.tokens) validateChangelogInline(token.tokens, depth + 1) + break + case 'escape': + case 'codespan': + case 'br': + break + case 'strong': + case 'em': + case 'del': + case 'link': + validateChangelogInline(token.tokens, depth + 1) + break + default: + throw new Error('Unsupported changelog content') + } + } +} + +export function parsePluginChangelog( + text: string, + identity: ChangelogIdentity, + sourceUrl: string +): { document: PluginChangelog; translations: Record } { + if (new TextEncoder().encode(text).length > MAX_CHANGELOG_BYTES) { + throw new Error('Changelog exceeds the size limit') + } + const lines = text.replaceAll('\r\n', '\n').split('\n') + const end = lines.indexOf('---', 1) + if (lines[0] !== '---' || end < 1) { + throw new Error('Missing changelog metadata') + } + const yaml = parseDocument(lines.slice(1, end).join('\n'), { + schema: 'core', + uniqueKeys: true, + stringKeys: true, + }) + if (yaml.errors.length || yaml.warnings.length) { + throw new Error('Invalid changelog metadata') + } + const metadata = metadataSchema.parse(yaml.toJS({ maxAliasCount: 0 })) + if ( + metadata.plugin !== identity.plugin || + metadata.version !== identity.version || + metadata.locale !== identity.locale + ) { + throw new Error('Changelog identity does not match the selected version') + } + if ( + Intl.getCanonicalLocales(metadata.locale)[0] !== metadata.locale || + (metadata.locale !== 'en' && metadata.translations !== undefined) + ) { + throw new Error('Invalid changelog locale') + } + const translations = metadata.translations ?? {} + for (const [locale, file] of Object.entries(translations)) { + if ( + locale === 'en' || + Intl.getCanonicalLocales(locale)[0] !== locale || + file !== `CHANGELOG.${locale}.md` + ) { + throw new Error('Invalid changelog translation filename') + } + } + const tokens = Lexer.lex(lines.slice(end + 1).join('\n'), { + gfm: true, + }).filter((token) => token.type !== 'space') as MarkedToken[] + const title = tokens[0] + const release = tokens[1] + if ( + title?.type !== 'heading' || + title.depth !== 1 || + title.text !== 'Changelog' || + release?.type !== 'heading' || + release.depth !== 2 || + release.text !== `[${identity.version}]` + ) { + throw new Error('Invalid changelog release heading') + } + const sections: PluginChangelog['sections'] = [] + let previousCategory = -1 + for (let i = 2; i < tokens.length; i += 2) { + const heading = tokens[i] + const list = tokens[i + 1] + if ( + heading?.type !== 'heading' || + heading.depth !== 3 || + list?.type !== 'list' || + list.ordered || + !list.items.length + ) { + throw new Error('Invalid changelog category or list') + } + const categoryIndex = (CHANGELOG_CATEGORIES as readonly string[]).indexOf( + heading.text + ) + if (categoryIndex <= previousCategory) { + throw new Error('Invalid changelog category order') + } + previousCategory = categoryIndex + for (const item of list.items) { + const blocks = item.tokens.filter( + (token) => token.type !== 'space' + ) as MarkedToken[] + const paragraph = blocks[0] + if ( + item.task || + blocks.length !== 1 || + (paragraph?.type !== 'text' && paragraph?.type !== 'paragraph') || + !paragraph.text.trim() || + !paragraph.tokens + ) { + throw new Error('Changelog entries must be nonempty inline paragraphs') + } + validateChangelogInline(paragraph.tokens) + } + sections.push({ + category: CHANGELOG_CATEGORIES[categoryIndex], + markdown: list.raw, + }) + } + if (!sections.some((section) => section.category !== 'Migration')) { + throw new Error('Changelog must contain a change') + } + return { document: { ...identity, sourceUrl, sections }, translations } +} + +async function fetchChangelogFile( + url: string, + signal: AbortSignal +): Promise { + signal.throwIfAborted() + const response = await fetch(url, { + signal, + credentials: 'omit', + redirect: 'error', + referrerPolicy: 'no-referrer', + }) + if (response.status === 404) { + await response.body?.cancel() + return null + } + if (!response.ok) { + await response.body?.cancel() + throw new Error('Changelog download failed') + } + const reader = response.body?.getReader() + if (!reader) throw new Error('Missing changelog body') + const decoder = new TextDecoder('utf-8', { fatal: true }) + let bytes = 0 + let text = '' + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + bytes += value.byteLength + if (bytes > MAX_CHANGELOG_BYTES) { + throw new Error('Changelog exceeds the size limit') + } + text += decoder.decode(value, { stream: true }) + } + return text + decoder.decode() + } finally { + await reader.cancel() + } +} + +export async function fetchPluginChangelog(options: { + indexUrl: string + plugin: MarketplacePlugin + version: string + language: string + signal: AbortSignal +}): Promise { + const url = resolvePluginChangelogUrl( + options.indexUrl, + options.plugin, + options.version + ) + if (!url) throw new Error('No changelog path for the selected version') + const signal = AbortSignal.any([options.signal, AbortSignal.timeout(15_000)]) + const text = await fetchChangelogFile(url, signal) + if (text === null) return null + const identity = { + plugin: options.plugin.key, + version: options.version, + locale: 'en', + } + const canonical = parsePluginChangelog(text, identity, url) + const locale = changelogLocale(options.language) + const filename = canonical.translations[locale] + if (filename) { + try { + const translatedUrl = new URL(filename, url).href + const translation = await fetchChangelogFile(translatedUrl, signal) + if (translation !== null) { + const translated = parsePluginChangelog( + translation, + { ...identity, locale }, + translatedUrl + ) + if ( + translated.document.sections.length === + canonical.document.sections.length && + translated.document.sections.every( + (section, i) => + section.category === canonical.document.sections[i].category + ) + ) { + return translated.document + } + } + } catch { + // A missing, invalid or unavailable translation always falls back to English. + options.signal.throwIfAborted() + } + } + return canonical.document +} diff --git a/web/src/features/task-plugins/types.ts b/web/src/features/task-plugins/types.ts index b074ebf..c59b438 100644 --- a/web/src/features/task-plugins/types.ts +++ b/web/src/features/task-plugins/types.ts @@ -16,7 +16,10 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import type { BillingUsageSchema } from '@/features/pricing/types' +import type { + BillingUsageSchema, + BillingUsageExample, +} from '@/features/pricing/types' export type TaskPluginProtocolClaim = | string @@ -58,6 +61,11 @@ export type TaskPluginMeta = { routes?: TaskPluginRoute[] protocols?: TaskPluginProtocolClaim[] usageSchema?: BillingUsageSchema + usageProfiles?: { + models: string[] + schema: BillingUsageSchema + examples?: BillingUsageExample[] + }[] } export type TaskPluginRecord = { diff --git a/web/src/features/usage-logs/audit/api.ts b/web/src/features/usage-logs/audit/api.ts index adf0644..8555060 100644 --- a/web/src/features/usage-logs/audit/api.ts +++ b/web/src/features/usage-logs/audit/api.ts @@ -16,8 +16,11 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import { t } from 'i18next' + import type { ApiResponse } from '@/features/profile/types' import { api } from '@/lib/api' +import { createServerError } from '@/lib/server-error-message' export interface AuditLog { event_id: string @@ -59,7 +62,7 @@ export async function getAuditLogs( ApiResponse<{ items: AuditLog[]; total: number }> >(scope === 'all' ? '/api/audit' : '/api/audit/self', { params }) if (!response.data.success || !response.data.data) { - throw new Error(response.data.message || 'Failed to load audit records') + throw createServerError(response.data, t('Failed to load audit records')) } return response.data.data } diff --git a/web/src/features/usage-logs/audit/components/audit-log-viewer.tsx b/web/src/features/usage-logs/audit/components/audit-log-viewer.tsx index b3bb6b4..1d41a94 100644 --- a/web/src/features/usage-logs/audit/components/audit-log-viewer.tsx +++ b/web/src/features/usage-logs/audit/components/audit-log-viewer.tsx @@ -24,6 +24,7 @@ import { useTranslation } from 'react-i18next' import { DataTablePage, useDataTable } from '@/components/data-table' import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' +import { requireServerSuccess } from '@/lib/server-error-message' import { useAuthStore } from '@/stores/auth-store' import { getAuditLogs, type AuditFilters, type AuditLog } from '../api' @@ -58,7 +59,8 @@ export function AuditLogViewer(props: { filters.start_timestamp > filters.end_timestamp const query = useQuery({ queryKey: ['audit', userId, props.scope, params], - queryFn: () => getAuditLogs(props.scope, params), + queryFn: async () => + requireServerSuccess(await getAuditLogs(props.scope, params)), enabled: canQuery && !invalidRange, retry: false, }) diff --git a/web/src/features/usage-logs/components/__tests__/admin-request-details.test.tsx b/web/src/features/usage-logs/components/__tests__/admin-request-details.test.tsx new file mode 100644 index 0000000..df0bc93 --- /dev/null +++ b/web/src/features/usage-logs/components/__tests__/admin-request-details.test.tsx @@ -0,0 +1,177 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, cleanup } from '@testing-library/react' +import i18next from 'i18next' +import { afterEach, beforeAll, expect, test, vi } from 'vitest' + +import type { UsageLog } from '../../data/schema' +import { buildApiParams } from '../../lib/utils' +import { DetailsDialog } from '../dialogs/details-dialog' + +const navigate = vi.hoisted(() => vi.fn()) +vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigate })) +vi.mock('@lobehub/icons', () => ({})) +beforeAll(async () => { + await i18next.init({ lng: 'en', resources: { en: { translation: {} } } }) +}) +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +function show(isAdmin: boolean, historical = false) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const log: UsageLog = { + id: 1, + user_id: 1, + created_at: 1, + type: 2, + content: '', + username: 'fixture', + token_name: 'test', + model_name: 'requested', + quota: 10, + prompt_tokens: 2, + completion_tokens: 3, + use_time: 1, + is_stream: true, + channel: 7, + channel_name: 'fixture', + token_id: 1, + group: 'test', + ip: '', + request_id: 'request-copy-fixture', + upstream_request_id: '', + other: JSON.stringify( + historical + ? {} + : { + admin_info: { + request_details: { + session_id: 'private-session', + session_source: 'session', + correlation_key: 'user-scoped-key', + requested_model: 'requested', + upstream_model: 'mapped', + returned_model: 'returned', + status: 200, + model_guard: { + attempts: [ + { + number: 1, + channel_id: 7, + expected_model: 'gpt-6-astra', + returned_model: 'private-luna', + reason: 'model_mismatch', + elapsed_ms: 20, + delivered: false, + }, + ], + }, + }, + request_policy: [ + { + attempt: 1, + elapsed_ms: 10, + decision: { + action: 'selection', + reason: 'priority_weight_selection', + source: 'routing', + }, + candidate_total: 101, + candidates: [ + { + channel_id: 7, + name: 'private-candidate', + group: 'test', + model: 'requested', + priority: 1, + weight: 100, + reason: 'selected', + }, + ], + }, + ], + }, + stream_status: { status: 'error', end_reason: 'timeout' }, + } + ), + } + render( + + undefined} + /> + + ) + return client +} + +test('admin can inspect candidates and filter the user scoped session while retaining dates', () => { + const client = show(true) + expect(screen.getByRole('button', { name: 'Copy request ID' })).toBeVisible() + expect(screen.getByText('private-session')).toBeVisible() + expect(screen.getByText('Model verification #1')).toBeVisible() + expect(screen.getByText('private-luna')).toBeVisible() + expect(screen.getByText('model_mismatch')).toBeVisible() + fireEvent.click(screen.getByText('Candidate channels', { exact: false })) + expect(screen.getByText('#7 private-candidate')).toBeVisible() + expect( + screen.getByText( + 'Candidate list truncated; at most 100 candidates are stored per attempt.' + ) + ).toBeVisible() + fireEvent.click( + screen.getByRole('button', { name: 'Filter related requests' }) + ) + const options = navigate.mock.calls[0][0] + expect( + options.search({ + startTime: 123, + endTime: 456, + model: 'old', + channel: '9', + page: 5, + }) + ).toEqual({ + startTime: 123, + endTime: 456, + pageSize: undefined, + page: 1, + correlationKey: 'user-scoped-key', + }) + client.clear() +}) + +test('ordinary users never see supplied internal request details', () => { + const client = show(false) + expect(screen.queryByText('private-session')).toBeNull() + expect(screen.queryByText('private-luna')).toBeNull() + expect(screen.queryByText('private-candidate')).toBeNull() + expect( + screen.queryByRole('button', { name: 'Filter related requests' }) + ).toBeNull() + expect( + buildApiParams({ + page: 1, + pageSize: 10, + isAdmin: false, + searchParams: { correlationKey: 'private' }, + }) + ).not.toHaveProperty('correlation_key') + client.clear() +}) + +test('historical logs do not invent sessions or routing decisions', () => { + const client = show(true, true) + expect(screen.getAllByText('Not recorded').length).toBeGreaterThan(0) + expect( + screen.queryByRole('button', { name: 'Filter related requests' }) + ).toBeNull() + client.clear() +}) diff --git a/web/src/features/usage-logs/components/__tests__/cost-display.test.tsx b/web/src/features/usage-logs/components/__tests__/cost-display.test.tsx index 457a48d..bc02ed5 100644 --- a/web/src/features/usage-logs/components/__tests__/cost-display.test.tsx +++ b/web/src/features/usage-logs/components/__tests__/cost-display.test.tsx @@ -19,9 +19,16 @@ For commercial licensing, please contact support@quantumnous.com import { render, screen } from '@testing-library/react' import i18next from 'i18next' import type React from 'react' -import { beforeAll, describe, expect, test } from 'vitest' +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from 'vitest' -import { formatLogQuota } from '@/lib/format' +import { useSystemConfigStore } from '@/stores/system-config-store' import { LogCostDisplay } from '../log-cost-display' @@ -31,32 +38,102 @@ function renderCost( return render() } -function normalizedText(value: string | null): string { - return (value ?? '').replaceAll(/\s/g, '') -} - describe('log cost display', () => { beforeAll(() => { i18next.addResourceBundle('en', 'translation', { Subscription: 'Subscription', - 'Deducted by subscription': 'Deducted by subscription', + Wallet: 'Wallet', 'Includes tool-call surcharge': 'Includes tool-call surcharge', }) }) - test('keeps the regular cost visible and adds an accessible surcharge marker', () => { + beforeEach(() => { + useSystemConfigStore.setState(useSystemConfigStore.getInitialState(), true) + }) + + afterEach(() => { + useSystemConfigStore.setState(useSystemConfigStore.getInitialState(), true) + localStorage.clear() + }) + + test.each([ + { consumed: 12500, expected: '$0.025' }, + { consumed: 0, expected: '$0' }, + { consumed: 1, expected: '$0.000002' }, + { consumed: undefined, expected: '$0.01' }, + ])( + 'shows subscription deduction $consumed without hover, falling back only when absent', + ({ consumed, expected }) => { + renderCost({ + quota: 5000, + other: { + billing_source: 'subscription', + subscription_consumed: consumed, + }, + }) + + expect(screen.getByText(expected)).toBeVisible() + expect(screen.getByText('Subscription')).toBeVisible() + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() + } + ) + + test('shows wallet cost and source without using subscription metadata', () => { + renderCost({ + quota: 5000, + other: { billing_source: 'wallet', subscription_consumed: 12500 }, + showWalletSource: true, + }) + + expect(screen.getByText('$0.01')).toBeVisible() + expect(screen.getByText('Wallet')).toBeVisible() + expect(screen.queryByText('Subscription')).not.toBeInTheDocument() + }) + + test('hides the wallet label when subscriptions are unavailable', () => { + renderCost({ + quota: 5000, + other: { billing_source: 'wallet' }, + showWalletSource: false, + }) + + expect(screen.getByText('$0.01')).toBeVisible() + expect(screen.queryByText('Wallet')).not.toBeInTheDocument() + }) + + test('keeps legacy cost visible without inventing a funding source', () => { + renderCost({ quota: 5000, other: null }) + + expect(screen.getByText('$0.01')).toBeVisible() + expect(screen.queryByText('Wallet')).not.toBeInTheDocument() + expect(screen.queryByText('Subscription')).not.toBeInTheDocument() + }) + + test('keeps a large amount unabridged above the funding source', () => { const rendered = renderCost({ + quota: 2147483647, + other: { billing_source: 'subscription' }, + }) + + const amount = screen.getByText('$4,294.9673') + expect(amount).toBeVisible() + expect(amount).toHaveClass('tabular-nums', 'whitespace-nowrap') + expect(rendered.container.firstElementChild).toHaveClass('flex-col') + expect( + amount.compareDocumentPosition(screen.getByText('Subscription')) & + Node.DOCUMENT_POSITION_FOLLOWING + ).not.toBe(0) + }) + + test('keeps the regular cost visible and adds an accessible surcharge marker', () => { + renderCost({ quota: 12500, other: { tool_surcharges: [{ name: 'lookup_customer', count: 1, price: 5 }], }, }) - expect( - normalizedText(rendered.container.textContent).includes( - normalizedText(formatLogQuota(12500)) - ) - ).toBe(true) + expect(screen.getByText('$0.025')).toBeVisible() const marker = screen.getByRole('img', { name: 'Includes tool-call surcharge', }) @@ -64,7 +141,7 @@ describe('log cost display', () => { expect(marker).toHaveAttribute('tabindex', '0') }) - test('preserves the subscription badge and adds the same legacy surcharge marker', () => { + test('shows subscription cost and source alongside the legacy surcharge marker', () => { renderCost({ quota: 5000, other: { @@ -75,7 +152,8 @@ describe('log cost display', () => { }, }) - expect(screen.getByText('Subscription')).toBeInTheDocument() + expect(screen.getByText('$0.01')).toBeVisible() + expect(screen.getByText('Subscription')).toBeVisible() expect( screen.getByRole('img', { name: 'Includes tool-call surcharge' }) ).toHaveAttribute('data-tool-surcharge-indicator', 'true') diff --git a/web/src/features/usage-logs/components/__tests__/detail-preview.test.tsx b/web/src/features/usage-logs/components/__tests__/detail-preview.test.tsx index cf66218..7fb7068 100644 --- a/web/src/features/usage-logs/components/__tests__/detail-preview.test.tsx +++ b/web/src/features/usage-logs/components/__tests__/detail-preview.test.tsx @@ -22,7 +22,7 @@ import { getCoreRowModel, useReactTable, } from '@tanstack/react-table' -import { fireEvent, render, screen, within } from '@testing-library/react' +import { act, fireEvent, render, screen, within } from '@testing-library/react' import { createInstance } from 'i18next' import { I18nextProvider } from 'react-i18next' import { afterAll, afterEach, beforeEach, expect, test, vi } from 'vitest' @@ -128,6 +128,30 @@ function renderPreview(other: LogOtherData, isAdmin = true) { } test.each([ + { + name: 'fixed expression zero price', + other: { + billing_mode: 'tiered_expr', + billing_unit: 'request' as const, + fixed_price: 0, + matched_tier: 'free', + expr_b64: btoa('tier("free", fixed(0))'), + }, + expected: 'free · Per-call $0/request', + }, + { + name: 'fixed expression trace outside the display grammar', + other: { + billing_mode: 'tiered_expr', + billing_unit: 'request' as const, + fixed_price: 0.01, + matched_tier: 'priority', + expr_b64: btoa( + 'param("fast") == true ? tier("priority", fixed(0.01)) : tier("tokens", p * 2)' + ), + }, + expected: 'priority · Per-call $0.01/request', + }, { name: 'per-call', other: { model_price: 0.25 }, @@ -248,6 +272,71 @@ test.each([ } ) +test('task log prices use localized unit labels from pricing metadata', async () => { + client.setQueryData(['pricing'], { + data: [ + { + model_name: 'wan2.5-i2v-preview', + billing_usage_schema: { + images: { + type: 'number', + unit: 'count', + unitLabel: { en: 'image', zh: '张' }, + }, + }, + }, + ], + vendors: [], + }) + const preview = renderPreview({ + is_task: true, + billing_mode: 'tiered_expr', + expr_b64: btoa('tier("images", u("images") * 0.25)'), + matched_tier: 'images', + }) + expect(preview).toHaveTextContent('images · images $0.25/image') + await act(() => i18n.changeLanguage('zh-CN')) + expect( + screen.getByRole('button', { name: /images · images/ }) + ).toHaveTextContent('images · images $0.25/张') +}) + +test('task log prices select the executing provider’s schema', () => { + client.setQueryData(['pricing'], { + data: [ + { + model_name: 'wan2.5-i2v-preview', + billing_usage_schema: { seconds: { type: 'number', unit: 'second' } }, + billing_plugin_variants: [ + { + plugin_key: 'beta', + plugin_name: 'Beta', + billing_expr: 'tier("images", u("images") * 0.25)', + billing_usage_schema: { + images: { + type: 'number', + unit: 'count', + unitLabel: { en: 'image' }, + }, + }, + }, + ], + }, + ], + vendors: [], + }) + const preview = renderPreview({ + is_task: true, + billing_mode: 'tiered_expr', + expr_b64: btoa('tier("images", u("images") * 0.25)'), + matched_tier: 'images', + admin_info: { + task_plugin: { key: 'beta', name: 'Beta', version: '1.0.0' }, + }, + }) + expect(preview).toHaveTextContent('images · images $0.25/image') +}) + test.each(['missing schema', 'unsupported expression', 'unknown tier'])( 'task pricing with %s shows an explicit unavailable summary', (scenario) => { diff --git a/web/src/features/usage-logs/components/__tests__/group-filter.test.tsx b/web/src/features/usage-logs/components/__tests__/group-filter.test.tsx index 75db08e..d5aad49 100644 --- a/web/src/features/usage-logs/components/__tests__/group-filter.test.tsx +++ b/web/src/features/usage-logs/components/__tests__/group-filter.test.tsx @@ -227,7 +227,7 @@ it('keeps the compact input and masks the dropdown together with other sensitive const option = await screen.findByRole('option', { name: 'premium' }) const maskedField = input.closest('.\\[-webkit-text-security\\:disc\\]') expect(maskedField).not.toBeNull() - expect(maskedField).toContainElement(option) + expect(option.closest('.\\[-webkit-text-security\\:disc\\]')).not.toBeNull() await userEvent.keyboard('{Escape}') expect(input).toHaveAttribute('aria-expanded', 'false') await userEvent.tab() diff --git a/web/src/features/usage-logs/components/__tests__/mobile-card.test.tsx b/web/src/features/usage-logs/components/__tests__/mobile-card.test.tsx index 9cbebb5..3fedde5 100644 --- a/web/src/features/usage-logs/components/__tests__/mobile-card.test.tsx +++ b/web/src/features/usage-logs/components/__tests__/mobile-card.test.tsx @@ -97,6 +97,36 @@ function renderLogs(props: Parameters[0] = {}) { ) } +it('shows model mismatch evidence when tapping the mobile model badge', async () => { + const user = userEvent.setup() + renderLogs({ + logs: [ + { + ...log, + other: JSON.stringify({ + response_model: { + requested_model: longName, + upstream_model: 'mapped-model', + returned_model: 'unexpected-model', + mismatch: true, + }, + }), + }, + ], + }) + await user.click( + screen.getByRole('button', { + name: `Model: ${longName}, Response model: unexpected-model`, + }) + ) + const dialog = await screen.findByRole('dialog', { name: 'Model' }) + expect( + within(dialog).getByText('Response model: unexpected-model') + ).toBeVisible() + expect(within(dialog).getByText('mapped-model')).toBeVisible() + expect(within(dialog).getByText('unexpected-model')).toBeVisible() +}) + it('opens long channel text on tap and copies the complete value', async () => { const user = userEvent.setup() const copy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue() @@ -121,20 +151,41 @@ it('opens long channel text on tap and copies the complete value', async () => { ).toHaveFocus() }) -it('keeps the model clamped to two lines and exposes its full value with keyboard input', async () => { - const user = userEvent.setup() - renderLogs() - const button = screen.getByRole('button', { name: `Model: ${longName}` }) - expect(within(button).getByText(longName)).toHaveClass( - 'line-clamp-2', - '[overflow-wrap:anywhere]' - ) - button.focus() - await user.keyboard('{Enter}') - expect( - await screen.findByRole('dialog', { name: 'Model' }) - ).toHaveTextContent(longName) -}) +it.each([false, true])( + 'copies the full mobile model name with the keyboard without opening details when there is no mapping or difference (response observed: %s)', + async (observed) => { + const user = userEvent.setup() + const copy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue() + renderLogs({ + logs: [ + { + ...log, + other: observed + ? JSON.stringify({ + response_model: { + requested_model: longName, + upstream_model: longName, + returned_model: longName, + mismatch: false, + }, + }) + : log.other, + }, + ], + }) + const button = screen.getByRole('button', { name: `Model: ${longName}` }) + expect(within(button).getByText(longName)).toHaveClass( + 'line-clamp-2', + '[overflow-wrap:anywhere]' + ) + button.focus() + await user.keyboard('{Enter}') + expect(copy).toHaveBeenCalledWith(longName) + expect( + screen.queryByRole('dialog', { name: 'Model' }) + ).not.toBeInTheDocument() + } +) it('hides sensitive names and disables full-text inspection when privacy is enabled', async () => { const user = userEvent.setup() diff --git a/web/src/features/usage-logs/components/__tests__/model-badge.test.tsx b/web/src/features/usage-logs/components/__tests__/model-badge.test.tsx new file mode 100644 index 0000000..a6b5b02 --- /dev/null +++ b/web/src/features/usage-logs/components/__tests__/model-badge.test.tsx @@ -0,0 +1,478 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { expect, it, vi } from 'vitest' + +import { + categorizeModels, + getModelCategory, +} from '@/features/channels/lib/model-categories' +import { getLobeIconNames } from '@/lib/lobe-icon' + +import { ModelBadge } from '../model-badge' + +const providers = [ + { + category: 'Perplexity', + models: ['perplexity/sonar-pro', 'llama-3.1-sonar-small-128k-online'], + }, + { + category: 'NVIDIA', + models: [ + 'nvidia/llama-3.1-nemotron-70b-instruct', + 'nvidia.nemotron', + 'nemotron-mini', + ], + }, + { + category: 'OpenAI', + models: [ + 'codex-auto-review', + 'codex-mini-latest', + ' CODEX-AUTO-REVIEW ', + 'openai/codex-auto-review', + 'openai.custom', + 'gpt-5-codex', + 'chatgpt-4o-latest', + 'dall-e', + 'whisper', + 'omni-moderation', + 'text-moderation-latest', + 'text-embedding-custom', + 'text-embedding-ada-002', + 'text-embedding-3-large', + 'text-ada-001', + 'text-babbage-001', + 'text-curie-001', + 'davinci-002', + 'babbage-002', + 'computer-use-preview', + 'sora-2', + 'tts-1', + 'vendor-tts-1', + 'o1', + 'o3-mini', + 'o4-mini', + 'openai/o3:latest', + 'o3.preview', + 'vendor-o3', + ], + }, + { + category: 'Anthropic', + label: 'Claude', + models: ['claude-sonnet-4', 'anthropic/claude', 'claude'], + }, + { + category: 'Gemini', + models: [ + 'gemini-2.5-pro', + 'gemma-3', + 'learnlm-2', + 'imagen-4', + 'veo-3', + 'nano-banana', + 'palm-2', + 'google/aqa', + ], + }, + { + category: 'xAI', + label: 'Grok', + models: ['grok-4', 'x-ai/custom', 'xai/custom', 'xai-custom', 'grok'], + }, + { category: 'DeepSeek', models: ['deepseek-chat', 'deepseek'] }, + { + category: 'Qwen', + models: [ + 'qwen3', + 'qwq-32b', + 'qvq-max', + 'tongyi-intent-detect', + 'gte-Qwen2', + 'text-embedding-v3', + 'gui-plus', + 'z-image-turbo', + ], + }, + { + category: 'Wan', + models: [ + 'wan2.2-t2v', + 'wanx2.1-t2v', + 'wan-2.1', + 'wan_2.1', + 'Wan-AI/Wan2.2-T2V-A14B', + 'alibaba/wan-2.6', + ], + }, + { category: 'Moonshot', models: ['moonshot-v1', 'moonshot', 'kimi-k2'] }, + { + category: 'MiniMax', + models: [ + 'minimax-m2', + 'abab6.5', + 'hailuo-02', + 't2v-01', + 'i2v-01-live', + 's2v-01', + ], + }, + { + category: 'Doubao', + models: [ + 'doubao-pro', + 'doubao', + 'volcengine/custom', + 'seedance-1', + 'seedream-4', + 'seed-1-6', + ], + }, + { + category: 'Zhipu', + models: [ + 'glm-4', + 'zhipu/custom', + 'zai-org/GLM-4.5', + 'thudm/chatglm3', + 'chatglm', + 'cogview-4', + 'cogvideo', + 'glm_4', + 'glm', + ], + }, + { category: 'Baidu', models: ['ernie-4', 'baidu/custom', 'wenxin'] }, + { + category: 'Yi', + models: ['yi-large', '01-ai/yi-34b', 'yi_34b', 'yi', 'vendor-yi-large'], + }, + { + category: 'iFlytek', + label: 'iFlyTek', + models: ['spark', 'spark-max', 'sparkdesk-v3', 'iflytek/custom'], + }, + { + category: 'Tencent', + models: ['hunyuan-pro', 'tencent/custom', 'hy3-preview', 'hy'], + }, + { category: 'Baichuan', models: ['baichuan2'] }, + { category: 'InternLM', models: ['internlm3'] }, + { + category: 'StepFun', + models: ['step-3', 'step-tts-mini', 'stepfun/custom'], + }, + { category: 'MiMo', models: ['mimo-v2', 'xiaomi/custom'] }, + { + category: 'Mistral', + models: [ + 'mistral-large', + 'mixtral-8x7b', + 'codestral-latest', + 'ministral-8b', + 'pixtral-12b', + 'magistral-medium', + ], + }, + { + category: 'Meta', + models: [ + 'llama-3', + 'meta-llama/llama-3', + 'llama2', + 'llama3', + 'meta-custom', + ], + }, + { + category: 'Cohere', + models: [ + 'command-r', + 'cohere/command', + 'c4ai-aya-expanse', + 'aya-23', + 'command', + ], + }, + { category: 'Jina', models: ['jinaai/custom', 'jina-embeddings-v3'] }, + { category: 'BAAI', models: ['baai/bge-m3', 'bge-large'] }, + { + category: 'Black Forest Labs', + models: ['black-forest-labs/flux.1-dev', 'flux.1-pro'], + }, + { + category: 'Microsoft', + models: ['microsoft/phi-4', 'phi-4', 'phi_3', 'phi'], + }, + { + category: 'Amazon', + models: ['amazon/nova', 'amazon.titan', 'nova-pro', 'titan-embed'], + }, + { category: 'AI21 Labs', models: ['ai21/jamba', 'jamba-large'] }, + { + category: 'Stability AI', + models: [ + 'stabilityai/custom', + 'stable-diffusion-xl', + 'stable-image-ultra', + 'sdxl-turbo', + ], + }, + { category: 'Nous Research', models: ['nousresearch/hermes-3', 'hermes-3'] }, + { category: '360 AI', models: ['360gpt-pro', '360zhinao'] }, + { + category: 'Midjourney', + models: ['midjourney', 'mj_imagine', 'mj-blend', 'swap_face'], + }, + { category: 'Kling', models: ['kling-v2'] }, + { category: 'Vidu', models: ['vidu-q2'] }, + { category: 'Suno', models: ['suno-v4'] }, + { category: 'Jimeng', models: ['jimeng-v3'] }, +] + +it.each( + providers.flatMap((provider) => + provider.models.map((model) => ({ + model, + category: provider.category, + label: provider.label ?? provider.category, + })) + ) +)( + 'groups $model under $category and displays its provider icon', + ({ model, category, label }) => { + expect(getModelCategory(model)).toBe(category) + render() + expect(screen.getByText(model.trim())).toBeVisible() + const icon = screen.getByLabelText(label) + expect(icon).toBeVisible() + expect(icon.querySelector('svg, img')).not.toBeNull() + } +) + +it.each([ + '', + 'custom-model', + 'o2', + 'o30', + 'prefix_o3', + 'vendor-o3:latest', + 'wan', + 'aqa-extra', + 't2v-02', +])('keeps unknown model %s in Other with the dot fallback', (model) => { + expect(getModelCategory(model)).toBe('Other') + const { container } = render() + expect(container.querySelector('svg, img')).toBeNull() + expect(container.querySelector('[aria-hidden="true"]')).not.toBeNull() +}) + +it('preserves model names and input order when grouping models', () => { + expect( + categorizeModels([ + ' CODEX-AUTO-REVIEW ', + 'custom-model', + 'gpt-5', + 'spark', + 'codex-mini-latest', + ]) + ).toEqual({ + OpenAI: [' CODEX-AUTO-REVIEW ', 'gpt-5', 'codex-mini-latest'], + Other: ['custom-model'], + iFlytek: ['spark'], + }) +}) + +it('shows the OpenAI icon in the mobile model button', () => { + render( + + ) + expect( + screen.getByRole('button', { name: 'Model: codex-auto-review' }) + ).toContainElement(screen.getByLabelText('OpenAI')) +}) + +it('makes the Wan icon available to model icon selectors', () => { + expect(getLobeIconNames()).toContain('Wan') +}) + +it('opens the mismatch evidence with the keyboard and shows all three models', async () => { + const user = userEvent.setup() + const returned = + 'unexpected-provider-model-with-a-long-dated-version-2026-09-17' + render( + + ) + expect(screen.getByText(`Response model: ${returned}`)).toBeVisible() + await user.tab() + expect( + screen.getByRole('button', { + name: `Model: requested-model, Response model: ${returned}`, + }) + ).toHaveFocus() + await user.keyboard('{Enter}') + expect(await screen.findByText(returned)).toBeVisible() + expect(screen.getByText('mapped-model')).toBeVisible() + expect(screen.getByText('Request Model')).toBeVisible() + expect(screen.getByText('Upstream Model')).toBeVisible() + expect(screen.getByText('Response Model')).toBeVisible() + expect( + screen.getByText(/this warning alone does not prove model substitution/) + ).toBeVisible() +}) + +it('shows a rejected provider model to the log owner', () => { + render( + + ) + expect(screen.getByText('Response model: gpt-5.6-luna')).toBeVisible() +}) + +it('does not claim a matching upstream model declaration verifies identity', () => { + render( + + ) + expect(screen.queryByText('Verified')).not.toBeInTheDocument() + expect(screen.getByText('Not checked')).toBeVisible() +}) + +it('hides legacy verified routing status', () => { + render() + expect(screen.queryByText('Verified')).not.toBeInTheDocument() +}) + +it('explains a timeout when upstream declares no model', () => { + render( + + ) + expect( + screen.getByText('Model verification: confirmation_timeout') + ).toBeVisible() +}) + +it.each([false, true])( + 'copies the model without opening details when there is no mapping or difference (response observed: %s)', + async (observed) => { + const user = userEvent.setup() + const copy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue() + render( + + ) + expect(screen.queryByRole('button')).not.toBeInTheDocument() + await user.click(screen.getByText('requested-model')) + expect(copy).toHaveBeenCalledWith('requested-model') + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.queryByText('Response Model')).not.toBeInTheDocument() + } +) + +it('keeps mapped model details available when the response matches the upstream model', async () => { + const user = userEvent.setup() + render( + + ) + await user.click( + screen.getByRole('button', { name: 'Model: requested-model' }) + ) + expect(await screen.findByText('Response Model')).toBeVisible() + expect(screen.queryByText(/^Response model:/)).not.toBeInTheDocument() +}) + +it.each([ + 'requested-model-2026-09-17', + 'REQUESTED-MODEL', + 'mapped-model-2026-09-17', + 'MAPPED-MODEL', +])( + 'keeps the compatible response %s in the popover without a list annotation', + async (returned) => { + const user = userEvent.setup() + render( + + ) + expect(screen.queryByText(returned)).not.toBeInTheDocument() + expect(screen.queryByText(/^Response model:/)).not.toBeInTheDocument() + const trigger = screen.getByRole('button', { + name: 'Model: requested-model', + }) + expect(trigger).toHaveTextContent(/^requested-model$/) + await user.click(trigger) + expect(await screen.findByText(returned)).toBeVisible() + expect(screen.queryByText(/^Response model:/)).not.toBeInTheDocument() + expect( + screen.queryByText(/this warning alone does not prove model substitution/) + ).not.toBeInTheDocument() + } +) diff --git a/web/src/features/usage-logs/components/__tests__/usage-facts.test.tsx b/web/src/features/usage-logs/components/__tests__/usage-facts.test.tsx index bffc5e4..a526741 100644 --- a/web/src/features/usage-logs/components/__tests__/usage-facts.test.tsx +++ b/web/src/features/usage-logs/components/__tests__/usage-facts.test.tsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { render, screen } from '@testing-library/react' +import { render, screen, within } from '@testing-library/react' import i18next from 'i18next' import { afterEach, beforeAll, describe, expect, test } from 'vitest' @@ -64,7 +64,7 @@ function makeLog(other: LogOtherData): UsageLog { } } -function renderDetails(other: LogOtherData): QueryClient { +function renderDetails(other: LogOtherData, promptTokens = 0): QueryClient { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }) @@ -79,7 +79,7 @@ function renderDetails(other: LogOtherData): QueryClient { render( { + const queryClient = renderDetails({ + response_model: { + requested_model: 'requested-model', + upstream_model: 'mapped-model', + returned_model: 'unexpected-model', + mismatch: true, + }, + }) + expect(screen.getByText('Response model: unexpected-model')).toBeVisible() + expect(rowValue('Request Model')).toBe('requested-model') + expect(rowValue('Upstream Model')).toBe('mapped-model') + expect(screen.getByText('unexpected-model')).toBeVisible() + queryClient.clear() +}) + describe('usage facts billing details', () => { + test('shows the settled image count and a per-image price', () => { + const queryClient = renderDetails({ + billing_mode: 'tiered_expr', + expr_b64: btoa('tier("image", fixed(0.04)) * image_count'), + billing_unit: 'request', + fixed_price: 0.04, + image_count: 2, + matched_tier: 'image', + }) + expect( + screen.getByText('Billable image count').parentElement + ).toHaveTextContent('2') + expect(screen.getAllByText(/\/image/).length).toBeGreaterThan(0) + queryClient.clear() + }) const queryClients: QueryClient[] = [] + test('shows actual billable image and cache tokens while retaining the aggregate cache count', () => { + queryClients.push( + renderDetails( + { + billing_mode: 'tiered_expr', + expr_b64: btoa( + 'tier("standard", p * 5 + cr * 1.25 + img * 8 + img_cr * 2 + c * 30)' + ), + matched_tier: 'standard', + cache_tokens: 300, + image_cache_tokens: 200, + billing_tokens: { p: 300, cr: 100, img: 400, img_cr: 200, c: 100 }, + }, + 1000 + ) + ) + const billable = within( + screen.getByRole('group', { name: 'Billable token breakdown' }) + ) + expect( + billable.getByText('Image Cache').nextElementSibling + ).toHaveTextContent('200') + expect( + billable.getByText('Cache Read').nextElementSibling + ).toHaveTextContent('100') + expect(billable.getByText('Image In').nextElementSibling).toHaveTextContent( + '400' + ) + expect( + screen.getByText('Input Tokens').nextElementSibling + ).toHaveTextContent('1,000') + expect( + screen + .getAllByText('Cache Read') + .some((label) => label.nextElementSibling?.textContent === '300') + ).toBe(true) + }) + beforeAll(() => { i18next.addResourceBundle('en', 'translation', i18nKeys) }) diff --git a/web/src/features/usage-logs/components/columns/common-logs-columns.tsx b/web/src/features/usage-logs/components/columns/common-logs-columns.tsx index c9a41ba..4fdda68 100644 --- a/web/src/features/usage-logs/components/columns/common-logs-columns.tsx +++ b/web/src/features/usage-logs/components/columns/common-logs-columns.tsx @@ -44,6 +44,8 @@ import { formatTaskUsageUnitPrice, getTaskUsagePriceUnitLabelKey, } from '@/features/pricing/lib/dynamic-price' +import { pluginUsageSchema } from '@/features/pricing/lib/plugin-pricing' +import { taskUsageUnitLabel } from '@/features/pricing/lib/task-price-display' import type { BillingUsageSchema } from '@/features/pricing/types' import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar' import { formatBillingCurrencyFromUSD } from '@/lib/currency' @@ -110,9 +112,10 @@ function buildDetailSegments( other: LogOtherData | null, t: (key: string, opts?: Record) => string, isAdmin: boolean, + language: string, usageSchema?: BillingUsageSchema ): DetailSegment[] { - const segments = buildTypeDetailSegments(log, other, t, usageSchema) + const segments = buildTypeDetailSegments(log, other, t, language, usageSchema) const adminSegments: DetailSegment[] = [] // Quota saturation is a rare, admin-only anomaly marker; surface it first // and in danger styling so it stands out on the related billing log. The @@ -128,6 +131,7 @@ function buildTypeDetailSegments( log: UsageLog, other: LogOtherData | null, t: (key: string, opts?: Record) => string, + language: string, usageSchema?: BillingUsageSchema ): DetailSegment[] { // Top-up, audit, and login logs can carry a localized operation descriptor. @@ -188,9 +192,10 @@ function buildTypeDetailSegments( ) if (tier) { const prices = Object.entries(tier.unitPrices).map(([field, price]) => { - const unit = usageSchema?.[field]?.unit - const unitKey = getTaskUsagePriceUnitLabelKey(unit) - return `${field} ${formatTaskUsageUnitPrice(price, { tokenUnit: 'M' })}/${t(unitKey)}` + const definition = usageSchema?.[field] + const unitKey = getTaskUsagePriceUnitLabelKey(definition?.unit) + const unitLabel = taskUsageUnitLabel(definition, language, t(unitKey)) + return `${field} ${formatTaskUsageUnitPrice(price, { tokenUnit: 'M' })}/${unitLabel}` }) if (tier.constant > 0) { prices.push( @@ -245,7 +250,11 @@ function buildTypeDetailSegments( 'cacheCreate1hPrice', ].includes(entry.field) ) - .map((entry) => `${t(entry.shortLabel)} ${formatPrice(entry.price)}`) + .map((entry) => + entry.unit + ? `${tieredSummary.tier.label || t('Default')} · ${t(entry.shortLabel)} ${formatPriceCompact(entry.price)}/${t(entry.unit)}` + : `${t(entry.shortLabel)} ${formatPrice(entry.price)}` + ) if (otherEntries.length > 0) { segments.push({ text: otherEntries.join(' · '), @@ -330,7 +339,8 @@ function buildTypeDetailSegments( export function useCommonLogsColumns( isAdmin: boolean, - isRoot: boolean + isRoot: boolean, + showWalletSource = false ): ColumnDef[] { const { t } = useTranslation() const columns: ColumnDef[] = [ @@ -660,6 +670,10 @@ export function useCommonLogsColumns(

) @@ -747,7 +761,13 @@ export function useCommonLogsColumns( const quota = row.getValue('quota') as number const other = parseLogOther(log.other) - return + return ( + + ) }, }, @@ -776,7 +796,7 @@ export function useCommonLogsColumns( accessorKey: 'content', header: t('Details'), cell: function DetailsCell({ row }) { - const { t } = useTranslation() + const { t, i18n } = useTranslation() const [dialogOpen, setDialogOpen] = useState(false) const log = row.original const other = parseLogOther(log.other) @@ -786,14 +806,18 @@ export function useCommonLogsColumns( other?.is_task === true && other.billing_mode === 'tiered_expr' ) - const usageSchema = pricingData.models.find( - (model) => model.model_name === log.model_name - )?.billing_usage_schema + const usageSchema = pluginUsageSchema( + pricingData.models.find( + (model) => model.model_name === log.model_name + ), + other?.admin_info?.task_plugin?.key + ) const segments = buildDetailSegments( log, other, t, isAdmin, + i18n.language, usageSchema ) const primary = segments[0] diff --git a/web/src/features/usage-logs/components/common-log-mobile-card.tsx b/web/src/features/usage-logs/components/common-log-mobile-card.tsx index c50c421..9bdc20a 100644 --- a/web/src/features/usage-logs/components/common-log-mobile-card.tsx +++ b/web/src/features/usage-logs/components/common-log-mobile-card.tsx @@ -38,7 +38,7 @@ import { isDisplayableLogType, isTimingLogType, } from '../lib/utils' -import { ModelBadge } from './model-badge' +import { ModelBadge, ResponseModelDetails } from './model-badge' import { StreamTpsCell, TimingMetricsCell } from './timing-metrics-cell' import { useUsageLogsContext } from './usage-logs-provider' @@ -149,6 +149,10 @@ export function CommonLogMobileCard(props: { setSelectedField('model')} /> @@ -349,15 +353,20 @@ export function CommonLogMobileCard(props: {

{activeField.value}

- {selectedField === 'model' && model.actualModel && ( -
-

{t('Actual Model')}

-

- {model.actualModel} -

- -
+ {selectedField === 'model' && model.responseModel && ( + )} + {selectedField === 'model' && + !model.responseModel && + model.actualModel && ( +
+

{t('Actual Model')}

+

+ {model.actualModel} +

+ +
+ )} {selectedField === 'channel' && channelCell && (
{flexRender( diff --git a/web/src/features/usage-logs/components/common-logs-filter-bar.tsx b/web/src/features/usage-logs/components/common-logs-filter-bar.tsx index 5cec6e0..6059988 100644 --- a/web/src/features/usage-logs/components/common-logs-filter-bar.tsx +++ b/web/src/features/usage-logs/components/common-logs-filter-bar.tsx @@ -42,6 +42,7 @@ import { import { getGroups } from '@/features/users/api' import { useMediaQuery } from '@/hooks' import { getUserGroups } from '@/lib/api' +import { requireServerSuccess } from '@/lib/server-error-message' import { LOG_TYPE_ALL_VALUE, LOG_TYPE_FILTERS } from '../constants' import { buildSearchParams } from '../lib/filter' @@ -91,6 +92,8 @@ function buildSearchSourceKey(values: { group?: unknown username?: unknown requestId?: unknown + attemptStatus?: unknown + correlationKey?: unknown upstreamRequestId?: unknown type?: unknown }) { @@ -104,6 +107,8 @@ function buildSearchSourceKey(values: { values.username, values.requestId, values.upstreamRequestId, + values.correlationKey, + values.attemptStatus, Array.isArray(values.type) ? values.type.join(',') : values.type, ] .map((value) => String(value ?? '')) @@ -127,12 +132,12 @@ export function CommonLogsFilterBar( const fetchingLogs = useIsFetching({ queryKey: ['logs'] }) const { data: adminGroups } = useQuery({ queryKey: ['groups'], - queryFn: getGroups, + queryFn: async () => requireServerSuccess(await getGroups()), enabled: isAdmin, }) const { data: userGroups } = useQuery({ queryKey: ['user-groups'], - queryFn: getUserGroups, + queryFn: async () => requireServerSuccess(await getUserGroups()), enabled: !isAdmin, }) const groupOptions = useMemo(() => { @@ -155,6 +160,8 @@ export function CommonLogsFilterBar( group: searchParams.group, username: searchParams.username, requestId: searchParams.requestId, + attemptStatus: searchParams.attemptStatus, + correlationKey: searchParams.correlationKey, upstreamRequestId: searchParams.upstreamRequestId, type: searchParams.type, } @@ -169,6 +176,8 @@ export function CommonLogsFilterBar( group: searchParams.group || undefined, username: searchParams.username || undefined, requestId: searchParams.requestId || undefined, + attemptStatus: searchParams.attemptStatus || undefined, + correlationKey: searchParams.correlationKey || undefined, upstreamRequestId: searchParams.upstreamRequestId || undefined, } return { @@ -186,6 +195,8 @@ export function CommonLogsFilterBar( searchParams.username, searchParams.requestId, searchParams.upstreamRequestId, + searchParams.correlationKey, + searchParams.attemptStatus, searchParams.type, ]) const [draft, setDraft] = useState(() => searchState) @@ -265,6 +276,8 @@ export function CommonLogsFilterBar( !!filters.username || !!filters.channel || !!filters.requestId || + !!filters.attemptStatus || + !!filters.correlationKey || !!filters.upstreamRequestId const hasTypeFilter = logType !== LOG_TYPE_ALL_VALUE @@ -277,6 +290,8 @@ export function CommonLogsFilterBar( isAdmin ? filters.channel : undefined, filters.requestId, filters.upstreamRequestId, + filters.correlationKey, + filters.attemptStatus, ].filter(Boolean).length const sensitiveInputClass = sensitiveVisible ? undefined @@ -351,12 +366,18 @@ export function CommonLogsFilterBar( emptyText={t('No group found.')} placeholder={t('Group')} className='h-8 min-w-0 text-sm leading-5' + dropdownClassName={sensitiveInputClass} value={filters.group || ''} onValueChange={(value) => handleChange('group', value ?? '')} onKeyDown={handleKeyDown} /> ) + const attemptFilter = + handleChange('attemptStatus', value ?? '')} options={[ + {value:'verified',label:t('Verified')}, {value:'unchecked',label:t('Not checked')}, {value:'charged_rejection',label:t('Rejected and charged')}, {value:'rejected',label:t('Rejected without charge')}, {value:'switched_success',label:t('Switched successfully')}, {value:'mismatch_allowed',label:t('Model mismatch allowed')}, {value:'transport_error',label:t('Transport error')}, {value:'estimated',label:t('Estimated usage')}, + ]} /> + const typeFilter = (