diff --git a/.downstream/upstream-base b/.downstream/upstream-base index 6c8370e93b95..2b238cb28663 100644 --- a/.downstream/upstream-base +++ b/.downstream/upstream-base @@ -1 +1 @@ -v0.2.15 +v0.2.16 diff --git a/.downstream/upstream-review-v0.2.16.md b/.downstream/upstream-review-v0.2.16.md new file mode 100644 index 000000000000..1c115e92b54b --- /dev/null +++ b/.downstream/upstream-review-v0.2.16.md @@ -0,0 +1,46 @@ +# Official v0.2.16 integration + +- Official tag: `v0.2.16`, commit `01842576b3d5c1e2545b5a99ad555d36e28ed306`. +- Downstream starting commit: `157836bdc12424a71f1d0bd30a37fcca38842fc9`. +- The 17 initial conflicts cover gateway request/error handling, pricing, model + discovery, dependency injection, effort metadata, and account UI/tests. Ent and + Wire are regenerated from the combined schemas and provider sets. Existing + runtime dependency pins and Go 1.27.2 remain. +- Haiku 5.5 registration, validation, bridge, sampling and tier pricing follow + official behavior. Its downstream effort entry is adaptive with medium default + effort. Official API capacity evidence is 1M/128K, verified on 2026-10-13 from + the [model overview](https://platform.claude.com/docs/en/models/haiku-5-5/overview). +- Codex descriptors retain zero generated capacity defaults, including Sol's + upstream 500k default. Account, group and client capacities still use the shared + source-bound resolver. Sol subscription model-card pricing exemption uses the + actual account and upstream model, without changing API-key/custom-interval + pricing or downstream DeepSeek time-pricing application. +- Structured-input Chat fallback retains the official complete-body and safe + retry guards, plus downstream prompt-echo restoration, raw account classifiers, + request cancellation and session identity. Native Responses preserves downstream + request-terminal metadata (`NextAccountStop`, no account-health penalty), which + the inherited no-retry case verifies directly. +- Gemini explicit caching, Vertex authentication/model discovery, model-level + cooldown scopes, group translations and UI fixes follow official behavior. + Scheduler projections retain both downstream capabilities and the new cache + authentication/TTL fields; catalog readers share bounded response handling and + retain downstream Anthropic/Gemini pagination. +- Ordinary OpenAI HTTP scope tests use the production failover marker. Explicit + model-scoped 401 rules are also evaluated before the unmarked count/probe auth + fast path; missing/account-scoped rules retain the existing downstream behavior, + and credential-level/OAuth failures keep their authentication handling. The + unmarked API-key path still records its scheduling-neutral last upstream error. +- Existing Codex identity and strict STATE borrowing, Pelican jobs, prompt binding, + full administrator visibility, account/model mapping, capacity evidence and + flat-console hooks remain. The model picker keeps its explicit confirmation and + stale-response guard while showing the upstream sync error message. +- Official migrations `242_group_i18n`, `243_gemini_cached_contents`, + `244_gemini_cached_contents_channel_usage` and + `245_gemini_cached_contents_mutation_lease` are unchanged. Full filenames are + migration identities; matching numeric prefixes do not replace historical SQL. + Source VERSION is corrected from the official tag's stale 0.2.15 to 0.2.16. + +Local validation uses synthetic upstreams, UI fixtures and disposable PostgreSQL +18.1. Normal required PR CI remains authoritative. Source validation, immutable +release and US production availability are recorded separately in the operator +workspace delivery evidence. No real model generation is part of this integration. diff --git a/.downstream/upstream-risk.json b/.downstream/upstream-risk.json index 7348834e6ce7..4f4a434f5ba0 100644 --- a/.downstream/upstream-risk.json +++ b/.downstream/upstream-risk.json @@ -1,87 +1,95 @@ { - "critical_file_count": 62, + "critical_file_count": 64, "critical_files": [ - ".github/workflows/backend-ci.yml", - ".github/workflows/release.yml", - ".github/workflows/security-scan.yml", - "Dockerfile", + "backend/ent/group.go", + "backend/ent/group/group.go", + "backend/ent/group_create.go", + "backend/ent/group_update.go", + "backend/ent/migrate/schema.go", + "backend/ent/mutation.go", "backend/ent/runtime/runtime.go", - "backend/ent/schema/composite_model_route.go", - "backend/ent/schema/user_platform_quota.go", - "backend/go.mod", - "backend/go.sum", - "backend/internal/repository/ops_repo_dashboard.go", - "backend/internal/repository/ops_repo_dashboard_integration_test.go", - "backend/internal/repository/ops_repo_dashboard_timeout_test.go", - "backend/internal/repository/platform_list_db_integration_test.go", - "backend/internal/repository/user_platform_quota_repo.go", - "backend/internal/service/gateway_cache_ttl_order_test.go", + "backend/ent/schema/group.go", + "backend/internal/repository/api_key_repo.go", + "backend/internal/repository/api_key_repo_integration_test.go", + "backend/internal/repository/billing_inflight_cache.go", + "backend/internal/repository/billing_inflight_cache_test.go", + "backend/internal/repository/gemini_cached_content_repo.go", + "backend/internal/repository/gemini_cached_content_repo_integration_test.go", + "backend/internal/repository/group_repo.go", + "backend/internal/repository/group_repo_integration_test.go", + "backend/internal/repository/scheduler_cache.go", + "backend/internal/repository/scheduler_cache_test.go", + "backend/internal/repository/setting_repo.go", + "backend/internal/repository/wire.go", + "backend/internal/service/billing_cache_service.go", + "backend/internal/service/billing_inflight_reservation.go", + "backend/internal/service/billing_service.go", + "backend/internal/service/billing_service_test.go", + "backend/internal/service/gateway_anthropic_passthrough.go", + "backend/internal/service/gateway_bedrock.go", "backend/internal/service/gateway_claude_oauth_body.go", - "backend/internal/service/gateway_forward_as_chat_completions.go", - "backend/internal/service/gateway_forward_as_chat_completions_ping_test.go", - "backend/internal/service/gateway_forward_as_responses.go", + "backend/internal/service/gateway_context_management_test.go", + "backend/internal/service/gateway_count_tokens.go", + "backend/internal/service/gateway_forward.go", + "backend/internal/service/gateway_image_reasoning_pricing_test.go", + "backend/internal/service/gateway_request.go", + "backend/internal/service/gateway_request_haiku55_test.go", "backend/internal/service/gateway_service.go", - "backend/internal/service/gateway_tool_changes_beta_test.go", "backend/internal/service/gateway_upstream_request.go", - "backend/internal/service/openai_apikey_responses_probe.go", - "backend/internal/service/openai_codex_model_metadata.go", - "backend/internal/service/openai_codex_model_metadata_test.go", - "backend/internal/service/openai_codex_models_pinned.go", - "backend/internal/service/openai_codex_models_pinned_test.go", - "backend/internal/service/openai_codex_transform.go", + "backend/internal/service/gateway_upstream_response.go", + "backend/internal/service/gateway_usage_billing.go", + "backend/internal/service/gateway_usage_billing_request_id_test.go", + "backend/internal/service/openai_account_runtime_block_fastpath.go", + "backend/internal/service/openai_alpha_search_billing_test.go", + "backend/internal/service/openai_codex_models_service.go", + "backend/internal/service/openai_codex_models_service_test.go", + "backend/internal/service/openai_gateway_cc_pipeline.go", "backend/internal/service/openai_gateway_chat_completions.go", - "backend/internal/service/openai_gateway_chat_completions_anthropic_native.go", + "backend/internal/service/openai_gateway_chat_completions_raw.go", + "backend/internal/service/openai_gateway_chat_completions_test.go", + "backend/internal/service/openai_gateway_cn_fixes_test.go", "backend/internal/service/openai_gateway_count_tokens.go", "backend/internal/service/openai_gateway_forward.go", - "backend/internal/service/openai_gateway_messages.go", - "backend/internal/service/openai_gateway_messages_anthropic_native.go", - "backend/internal/service/openai_gateway_passthrough.go", - "backend/internal/service/openai_gateway_provider_claude_billing_test.go", - "backend/internal/service/openai_gateway_request_body.go", - "backend/internal/service/openai_gateway_response_handling.go", - "backend/internal/service/openai_gateway_responses_anthropic_native.go", - "backend/internal/service/openai_gateway_responses_empty_completed_test.go", - "backend/internal/service/openai_gateway_scheduling.go", - "backend/internal/service/openai_gateway_service_hotpath_test.go", - "backend/internal/service/openai_models_list.go", - "backend/internal/service/openai_models_list_test.go", - "backend/internal/service/openai_oauth_web_search_history.go", - "backend/internal/service/openai_oauth_web_search_history_forward_test.go", - "backend/internal/service/openai_oauth_web_search_history_test.go", - "backend/internal/service/openai_responses_namespace.go", - "backend/internal/service/openai_responses_namespace_benchmark_test.go", - "backend/internal/service/openai_responses_namespace_test.go", - "backend/internal/service/openai_ws_v2/passthrough_relay.go", - "backend/internal/service/openai_ws_v2/passthrough_relay_image_usage_test.go", - "backend/internal/service/openai_ws_v2_passthrough_adapter.go", - "backend/internal/service/openai_ws_v2_passthrough_image_usage_test.go", - "backend/internal/service/ratelimit_cline.go", - "backend/internal/service/ratelimit_cline_test.go", - "backend/internal/service/ratelimit_cn_providers.go", - "backend/internal/service/ratelimit_command_code.go", + "backend/internal/service/openai_gateway_responses_chat_fallback.go", + "backend/internal/service/openai_gateway_responses_chat_fallback_test.go", + "backend/internal/service/openai_gateway_search_surcharge_test.go", + "backend/internal/service/openai_gateway_service.go", + "backend/internal/service/openai_gateway_usage.go", + "backend/internal/service/openai_image_generation_controls_test.go", + "backend/internal/service/openai_subscription_long_context_test.go", + "backend/internal/service/pricing_service.go", + "backend/internal/service/pricing_service_test.go", "backend/internal/service/ratelimit_service.go", - "backend/migrations/242_drop_platform_check_constraints.sql", - "backend/migrations/platform_check_constraints_drop_migration_test.go", - "deploy/Dockerfile" + "backend/migrations/242_group_i18n.sql", + "backend/migrations/243_gemini_cached_contents.sql", + "backend/migrations/244_gemini_cached_contents_channel_usage.sql", + "backend/migrations/245_gemini_cached_contents_mutation_lease.sql", + "deploy/config.example.yaml" ], - "downstream_commit": "d469422e94b0655b22dd9d0a826decda61ffb91e", - "downstream_file_count": 1340, + "downstream_commit": "157836bdc12424a71f1d0bd30a37fcca38842fc9", + "downstream_file_count": 1377, "downstream_files": [ ".dockerignore", ".downstream/README.md", ".downstream/gateway-borrow-notices.md", ".downstream/gpt6-models-and-prompts.md", ".downstream/native-domains.md", + ".downstream/ranxi-borrow-reference.json", ".downstream/upstream-base", + ".downstream/upstream-review-ranxi-v2.10.3.md", + ".downstream/upstream-review-ranxi-v2.10.5.md", ".downstream/upstream-review-v0.2.13.md", ".downstream/upstream-review-v0.2.14.md", + ".downstream/upstream-review-v0.2.15.md", ".downstream/upstream-risk.json", ".gitattributes", ".github/audit-exceptions.yml", + ".github/buildkitd.toml", + ".github/scripts/generate_ranxi_borrow_reference.py", + ".github/scripts/prepare_test_images.py", ".github/scripts/release_preflight.py", ".github/scripts/test_agent_protocol.sh", ".github/scripts/test_model_context_capacity.sh", - ".github/scripts/test_reasoning_recovery.sh", ".github/scripts/test_release_preflight.py", ".github/scripts/test_upstream_automation_contract.py", ".github/scripts/test_upstream_risk.py", @@ -109,7 +117,6 @@ "README_JA.md", "THIRD_PARTY_NOTICES.md", "backend/.golangci.yml", - "backend/Dockerfile", "backend/cmd/cleanup-ingress-reject-logs/main.go", "backend/cmd/server/VERSION", "backend/cmd/server/main.go", @@ -202,10 +209,10 @@ "backend/internal/handler/admin/codex_gateway_borrow_handler_test.go", "backend/internal/handler/admin/group_handler_simple_mode_test.go", "backend/internal/handler/admin/payment_handler_test.go", + "backend/internal/handler/admin/pelican_background_handler.go", + "backend/internal/handler/admin/pelican_background_handler_test.go", "backend/internal/handler/admin/pelican_test_handler.go", "backend/internal/handler/admin/pelican_test_handler_test.go", - "backend/internal/handler/admin/reasoning_recovery_handler.go", - "backend/internal/handler/admin/reasoning_recovery_handler_test.go", "backend/internal/handler/admin/scheduled_test_handler.go", "backend/internal/handler/admin/setting_handler.go", "backend/internal/handler/admin/setting_handler_audit.go", @@ -242,7 +249,6 @@ "backend/internal/handler/openai_chat_completions.go", "backend/internal/handler/openai_codex_models_handler.go", "backend/internal/handler/openai_codex_models_handler_test.go", - "backend/internal/handler/openai_continuation_account_mismatch_test.go", "backend/internal/handler/openai_count_tokens_model_not_supported_test.go", "backend/internal/handler/openai_embeddings.go", "backend/internal/handler/openai_failover_retry.go", @@ -263,7 +269,6 @@ "backend/internal/handler/openai_official_http_failover.go", "backend/internal/handler/openai_official_http_failover_integration_test.go", "backend/internal/handler/openai_official_http_retry_test.go", - "backend/internal/handler/openai_reasoning_recovery_terminal_handler_test.go", "backend/internal/handler/openai_refusal_recovery_handler_test.go", "backend/internal/handler/openai_replayable_endpoints_failover_test.go", "backend/internal/handler/openai_request_rejected_test.go", @@ -271,6 +276,7 @@ "backend/internal/handler/openai_same_account_reacquire_slot_test.go", "backend/internal/handler/openai_ws_failover_policy.go", "backend/internal/handler/openai_ws_failover_policy_test.go", + "backend/internal/handler/ops_codex_borrow_test.go", "backend/internal/handler/ops_error_logger.go", "backend/internal/handler/ops_error_logger_continuation_test.go", "backend/internal/handler/ops_error_logger_test.go", @@ -388,8 +394,6 @@ "backend/internal/repository/first_party_plugin_purge_migration_integration_test.go", "backend/internal/repository/gateway_cache.go", "backend/internal/repository/gateway_cache_live_test.go", - "backend/internal/repository/gateway_cache_reasoning_state.go", - "backend/internal/repository/gateway_cache_reasoning_state_test.go", "backend/internal/repository/group_model_allowlist_repair_migration_integration_test.go", "backend/internal/repository/group_repo.go", "backend/internal/repository/group_usage_rollup_trigger_integration_test.go", @@ -406,6 +410,8 @@ "backend/internal/repository/ops_repo.go", "backend/internal/repository/ops_write_pressure_integration_test.go", "backend/internal/repository/passkey_repo.go", + "backend/internal/repository/pelican_background_repo.go", + "backend/internal/repository/pelican_background_repo_integration_test.go", "backend/internal/repository/pgx_sqlmock_test.go", "backend/internal/repository/postgres_batch_insert.go", "backend/internal/repository/postgres_batch_insert_test.go", @@ -451,7 +457,6 @@ "backend/internal/server/routes/gateway_models_pinned_test.go", "backend/internal/server/routes/gateway_test.go", "backend/internal/server/routes/pelican_tests.go", - "backend/internal/server/routes/reasoning_recovery_routes_test.go", "backend/internal/server/routes/system_prompt_routes_test.go", "backend/internal/service/account.go", "backend/internal/service/account_available_models_test.go", @@ -482,9 +487,10 @@ "backend/internal/service/account_test_service_openai_compact_test.go", "backend/internal/service/account_test_service_openai_image_test.go", "backend/internal/service/account_test_service_openai_test.go", - "backend/internal/service/account_test_service_opencode_go.go", "backend/internal/service/account_test_service_typesafe.go", "backend/internal/service/account_test_service_typesafe_test.go", + "backend/internal/service/account_test_sse_keepalive.go", + "backend/internal/service/account_test_sse_keepalive_test.go", "backend/internal/service/account_test_stream.go", "backend/internal/service/account_test_stream_contract_test.go", "backend/internal/service/account_usage_service.go", @@ -534,17 +540,57 @@ "backend/internal/service/codex_fingerprint_account_view_test.go", "backend/internal/service/codex_fingerprint_policy_test.go", "backend/internal/service/codex_gateway_borrow.go", + "backend/internal/service/codex_gateway_borrow_candidate_retry_test.go", + "backend/internal/service/codex_gateway_borrow_candidates.go", + "backend/internal/service/codex_gateway_borrow_cookie_contract_test.go", + "backend/internal/service/codex_gateway_borrow_coordination.go", + "backend/internal/service/codex_gateway_borrow_coordination_test.go", "backend/internal/service/codex_gateway_borrow_core_test.go", + "backend/internal/service/codex_gateway_borrow_diagnose.go", + "backend/internal/service/codex_gateway_borrow_diagnose_contract.go", + "backend/internal/service/codex_gateway_borrow_diagnose_contract_test.go", + "backend/internal/service/codex_gateway_borrow_diagnose_session.go", + "backend/internal/service/codex_gateway_borrow_diagnose_session_test.go", + "backend/internal/service/codex_gateway_borrow_diagnose_test.go", "backend/internal/service/codex_gateway_borrow_http.go", + "backend/internal/service/codex_gateway_borrow_http_observation.go", "backend/internal/service/codex_gateway_borrow_http_test.go", + "backend/internal/service/codex_gateway_borrow_immediate_test.go", "backend/internal/service/codex_gateway_borrow_pelican.go", + "backend/internal/service/codex_gateway_borrow_policy.go", + "backend/internal/service/codex_gateway_borrow_policy_test.go", "backend/internal/service/codex_gateway_borrow_preview.go", "backend/internal/service/codex_gateway_borrow_preview_test.go", "backend/internal/service/codex_gateway_borrow_probe.go", + "backend/internal/service/codex_gateway_borrow_probe_serial_test.go", "backend/internal/service/codex_gateway_borrow_probe_stream_test.go", + "backend/internal/service/codex_gateway_borrow_probe_terminal.go", + "backend/internal/service/codex_gateway_borrow_probe_wire.go", + "backend/internal/service/codex_gateway_borrow_probe_wire_test.go", + "backend/internal/service/codex_gateway_borrow_ranxi_generated_test.go", + "backend/internal/service/codex_gateway_borrow_ranxi_reference_test.go", + "backend/internal/service/codex_gateway_borrow_recovery.go", + "backend/internal/service/codex_gateway_borrow_recovery_test.go", + "backend/internal/service/codex_gateway_borrow_replay.go", + "backend/internal/service/codex_gateway_borrow_replay_test.go", + "backend/internal/service/codex_gateway_borrow_request_context.go", + "backend/internal/service/codex_gateway_borrow_request_context_test.go", + "backend/internal/service/codex_gateway_borrow_request_repair_test.go", + "backend/internal/service/codex_gateway_borrow_rotation.go", + "backend/internal/service/codex_gateway_borrow_rotation_cache.go", + "backend/internal/service/codex_gateway_borrow_rotation_http.go", + "backend/internal/service/codex_gateway_borrow_rotation_test.go", + "backend/internal/service/codex_gateway_borrow_source_contract_test.go", + "backend/internal/service/codex_gateway_borrow_source_self_test.go", + "backend/internal/service/codex_gateway_borrow_status.go", "backend/internal/service/codex_gateway_borrow_test_runner.go", "backend/internal/service/codex_gateway_borrow_test_runner_test.go", "backend/internal/service/codex_gateway_borrow_token_test.go", + "backend/internal/service/codex_gateway_borrow_transient_test.go", + "backend/internal/service/codex_gateway_borrow_transport.go", + "backend/internal/service/codex_gateway_borrow_transport_test.go", + "backend/internal/service/codex_gateway_borrow_usage.go", + "backend/internal/service/codex_gateway_borrow_usage_ws.go", "backend/internal/service/codex_gateway_borrow_ws_anchor.go", "backend/internal/service/codex_gateway_borrow_ws_passthrough_test.go", "backend/internal/service/codex_gateway_borrow_ws_phase_test.go", @@ -661,6 +707,7 @@ "backend/internal/service/openai_codex_version_sync_service.go", "backend/internal/service/openai_compact_fallback_test.go", "backend/internal/service/openai_compact_model_mapping_test.go", + "backend/internal/service/openai_compact_sse_keepalive.go", "backend/internal/service/openai_compact_stream_bridge.go", "backend/internal/service/openai_compat_host_observation_test.go", "backend/internal/service/openai_compat_model.go", @@ -674,7 +721,6 @@ "backend/internal/service/openai_cyber_session_block_test.go", "backend/internal/service/openai_embeddings.go", "backend/internal/service/openai_embeddings_test.go", - "backend/internal/service/openai_encrypted_content_lineage_legacy_test.go", "backend/internal/service/openai_failover_cached_body_test.go", "backend/internal/service/openai_fast_policy_test.go", "backend/internal/service/openai_gateway_anthropic_native_pump_test.go", @@ -734,7 +780,6 @@ "backend/internal/service/openai_images_responses.go", "backend/internal/service/openai_images_test.go", "backend/internal/service/openai_images_tool_cooldown_test.go", - "backend/internal/service/openai_lineage_regression_helpers_test.go", "backend/internal/service/openai_live.go", "backend/internal/service/openai_load_sticky_privacy_test.go", "backend/internal/service/openai_model_alias.go", @@ -768,15 +813,6 @@ "backend/internal/service/openai_reasoning_configuration_test.go", "backend/internal/service/openai_reasoning_effort_candidates_test.go", "backend/internal/service/openai_reasoning_effort_policy.go", - "backend/internal/service/openai_reasoning_recovery.go", - "backend/internal/service/openai_reasoning_recovery_integration_test.go", - "backend/internal/service/openai_reasoning_recovery_item_id_test.go", - "backend/internal/service/openai_reasoning_recovery_policy.go", - "backend/internal/service/openai_reasoning_recovery_policy_test.go", - "backend/internal/service/openai_reasoning_recovery_terminal_test.go", - "backend/internal/service/openai_reasoning_recovery_test.go", - "backend/internal/service/openai_reasoning_state_cache.go", - "backend/internal/service/openai_reasoning_state_cache_test.go", "backend/internal/service/openai_reasoning_summary_compat_test.go", "backend/internal/service/openai_refusal_recovery.go", "backend/internal/service/openai_refusal_recovery_json.go", @@ -858,6 +894,8 @@ "backend/internal/service/payment_order_result_test.go", "backend/internal/service/pelican_account_generator.go", "backend/internal/service/pelican_account_generator_test.go", + "backend/internal/service/pelican_background.go", + "backend/internal/service/pelican_background_test.go", "backend/internal/service/pelican_borrow_ws_test.go", "backend/internal/service/pelican_execution.go", "backend/internal/service/pelican_test_catalog.go", @@ -877,8 +915,6 @@ "backend/internal/service/ratelimit_service_openai_test.go", "backend/internal/service/ratelimit_session_window_test.go", "backend/internal/service/reasoning_effort_billing_test.go", - "backend/internal/service/reasoning_recovery_setting.go", - "backend/internal/service/reasoning_recovery_setting_test.go", "backend/internal/service/responses_chat_protocol_callers_test.go", "backend/internal/service/scheduler_snapshot_service.go", "backend/internal/service/setting_admin_observability.go", @@ -992,10 +1028,12 @@ "backend/migrations/274_purge_release_acceptance_api_keys.sql", "backend/migrations/275_codex_gateway_borrow_tests.sql", "backend/migrations/276_pelican_test_execution.sql", + "backend/migrations/277_drop_legacy_platform_check_constraints.sql", "backend/migrations/account_jobs_slim_migration_test.go", "backend/migrations/historical_cindy_null_total_migration_integration_test.go", "backend/migrations/minimax_platform_migration_test.go", "backend/migrations/opencode_go_platform_migration_test.go", + "backend/migrations/platform_check_constraints_drop_migration_test.go", "backend/migrations/platform_quota_forward_union_migration_integration_test.go", "backend/migrations/release_acceptance_retirement_migration_integration_test.go", "backend/migrations/remote_skill_sources_migration_integration_test.go", @@ -1036,11 +1074,9 @@ "frontend/src/api/admin/index.ts", "frontend/src/api/admin/ops.ts", "frontend/src/api/admin/pelicanTests.ts", - "frontend/src/api/admin/reasoningRecovery.ts", "frontend/src/api/admin/settings.ts", "frontend/src/api/admin/system.ts", "frontend/src/api/admin/systemPrompts.ts", - "frontend/src/api/admin/users.ts", "frontend/src/api/client.ts", "frontend/src/api/codex.ts", "frontend/src/api/downstreamStatus.ts", @@ -1136,10 +1172,16 @@ "frontend/src/components/admin/announcements/AnnouncementReadStatusDialog.vue", "frontend/src/components/admin/announcements/AnnouncementTargetingEditor.vue", "frontend/src/components/admin/codex/BorrowPelicanPreview.vue", + "frontend/src/components/admin/codex/CodexBorrowActivity.vue", "frontend/src/components/admin/codex/CodexContinuationDiagnostics.vue", + "frontend/src/components/admin/codex/CodexLayout.vue", "frontend/src/components/admin/codex/CodexRuntimeSettings.vue", + "frontend/src/components/admin/codex/PelicanResultCard.vue", + "frontend/src/components/admin/codex/PelicanTaskComposer.vue", "frontend/src/components/admin/codex/__tests__/BorrowPelicanPreview.spec.ts", + "frontend/src/components/admin/codex/__tests__/CodexBorrowActivity.spec.ts", "frontend/src/components/admin/codex/__tests__/CodexRuntime.spec.ts", + "frontend/src/components/admin/codex/__tests__/PelicanTaskComposer.spec.ts", "frontend/src/components/admin/group/CodexManifestAccountsField.vue", "frontend/src/components/admin/group/GroupRPMOverridesModal.vue", "frontend/src/components/admin/group/GroupRateMultipliersModal.vue", @@ -1220,7 +1262,6 @@ "frontend/src/components/user/MonitorDetailDialog.vue", "frontend/src/components/user/UserAttributesConfigModal.vue", "frontend/src/components/user/UserErrorRequestsTable.vue", - "frontend/src/components/user/UserPlatformQuotaCell.vue", "frontend/src/components/user/dashboard/UserDashboardCharts.vue", "frontend/src/components/user/dashboard/UserDashboardQuickActions.vue", "frontend/src/components/user/dashboard/UserDashboardStats.vue", @@ -1251,7 +1292,6 @@ "frontend/src/i18n/locales/en/admin/codexGatewayBorrow.ts", "frontend/src/i18n/locales/en/admin/index.ts", "frontend/src/i18n/locales/en/admin/ops.ts", - "frontend/src/i18n/locales/en/admin/reasoningRecovery.ts", "frontend/src/i18n/locales/en/admin/resources.ts", "frontend/src/i18n/locales/en/admin/settings.ts", "frontend/src/i18n/locales/en/admin/systemPrompts.ts", @@ -1263,7 +1303,6 @@ "frontend/src/i18n/locales/zh/admin/codexGatewayBorrow.ts", "frontend/src/i18n/locales/zh/admin/index.ts", "frontend/src/i18n/locales/zh/admin/ops.ts", - "frontend/src/i18n/locales/zh/admin/reasoningRecovery.ts", "frontend/src/i18n/locales/zh/admin/resources.ts", "frontend/src/i18n/locales/zh/admin/settings.ts", "frontend/src/i18n/locales/zh/admin/systemPrompts.ts", @@ -1297,6 +1336,7 @@ "frontend/src/utils/__tests__/accountQualityCheck.spec.ts", "frontend/src/utils/__tests__/modelContextCapacity.spec.ts", "frontend/src/utils/__tests__/openaiProtocolModeEdits.spec.ts", + "frontend/src/utils/__tests__/pelicanTaskSelection.spec.ts", "frontend/src/utils/__tests__/softWrap.spec.ts", "frontend/src/utils/accountImportParser.ts", "frontend/src/utils/accountImportWorker.ts", @@ -1310,6 +1350,7 @@ "frontend/src/utils/keyGroupProviders.ts", "frontend/src/utils/modelContextCapacity.ts", "frontend/src/utils/openaiProtocolModeEdits.ts", + "frontend/src/utils/pelicanTaskSelection.ts", "frontend/src/utils/planType.ts", "frontend/src/utils/platformColors.ts", "frontend/src/utils/softWrap.ts", @@ -1335,7 +1376,6 @@ "frontend/src/views/admin/PluginsView.vue", "frontend/src/views/admin/PromoCodesView.vue", "frontend/src/views/admin/ProxiesView.vue", - "frontend/src/views/admin/ReasoningRecoveryView.vue", "frontend/src/views/admin/RedeemView.vue", "frontend/src/views/admin/RiskControlView.vue", "frontend/src/views/admin/SettingsView.vue", @@ -1359,7 +1399,6 @@ "frontend/src/views/admin/__tests__/GroupsView.codexManifest.spec.ts", "frontend/src/views/admin/__tests__/GroupsView.duplicate.spec.ts", "frontend/src/views/admin/__tests__/ProxiesView.copyFormats.spec.ts", - "frontend/src/views/admin/__tests__/ReasoningRecoveryView.spec.ts", "frontend/src/views/admin/__tests__/SettingsView.spec.ts", "frontend/src/views/admin/__tests__/channelPlatformOptions.spec.ts", "frontend/src/views/admin/__tests__/groupModelAllowlist.spec.ts", @@ -1409,456 +1448,333 @@ "tools/check_pnpm_audit_exceptions.py" ], "initial_conflict_files": [ - ".github/workflows/backend-ci.yml", - "backend/.golangci.yml", - "backend/go.mod", - "backend/internal/handler/admin/account_handler.go", - "backend/internal/handler/admin/account_handler_available_models_test.go", - "backend/internal/handler/gateway_models_test.go", - "backend/internal/handler/openai_gateway_handler.go", - "backend/internal/service/account_test_service.go", - "backend/internal/service/model_rate_limit.go", + "backend/cmd/server/wire_gen.go", + "backend/internal/pkg/apicompat/anthropic_responses_test.go", + "backend/internal/pkg/claude/effort_catalog.go", + "backend/internal/repository/scheduler_cache.go", + "backend/internal/service/billing_service.go", + "backend/internal/service/gateway_service.go", + "backend/internal/service/openai_codex_models_service_test.go", + "backend/internal/service/openai_gateway_cc_pipeline.go", "backend/internal/service/openai_gateway_chat_completions.go", - "backend/internal/service/openai_gateway_forward.go", - "backend/internal/service/openai_gateway_request_body.go", - "backend/internal/service/openai_gateway_scheduling.go", - "backend/internal/service/openai_responses_namespace.go", - "frontend/src/api/__tests__/settings.authSourceDefaults.spec.ts", - "frontend/src/api/admin/settings.ts", - "frontend/src/api/admin/users.ts", + "backend/internal/service/openai_gateway_chat_completions_raw.go", + "backend/internal/service/openai_gateway_count_tokens.go", + "backend/internal/service/openai_gateway_usage.go", + "backend/internal/service/upstream_models.go", "frontend/src/components/account/AccountUsageCell.vue", - "frontend/src/components/account/CreateAccountModal.vue", - "frontend/src/components/account/EditAccountModal.vue", "frontend/src/components/account/ModelWhitelistSelector.vue", - "frontend/src/components/account/OpenCodeGoProtocolRulesEditor.vue", - "frontend/src/components/account/__tests__/EditAccountModal.spec.ts", "frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts", - "frontend/src/components/admin/account/ScheduledTestsPanel.vue", - "frontend/src/components/admin/group/GroupRPMOverridesModal.vue", - "frontend/src/components/admin/group/GroupRateMultipliersModal.vue", - "frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts", - "frontend/src/components/common/BaseDialog.vue", - "frontend/src/components/keys/__tests__/UseKeyModal.spec.ts", - "frontend/src/components/user/UserPlatformQuotaCell.vue", - "frontend/src/constants/__tests__/platforms.spec.ts", - "frontend/src/i18n/locales/en/admin/accounts.ts", - "frontend/src/i18n/locales/zh/admin/accounts.ts", - "frontend/src/utils/platformColors.ts", - "frontend/src/views/admin/AccountsView.vue", - "frontend/src/views/admin/ChannelsView.vue", - "frontend/src/views/admin/SettingsView.vue", - "frontend/src/views/admin/__tests__/channelPlatformOptions.spec.ts", - "frontend/src/views/user/__tests__/StripePaymentView.spec.ts" + "frontend/src/components/common/GroupBadge.vue" ], "merge_conflicts": [], - "overlap_file_count": 122, + "overlap_file_count": 87, "overlap_files": [ - ".github/workflows/backend-ci.yml", - ".github/workflows/release.yml", - ".github/workflows/security-scan.yml", - "Dockerfile", "Makefile", - "backend/.golangci.yml", - "backend/Dockerfile", "backend/cmd/server/VERSION", + "backend/cmd/server/wire.go", + "backend/cmd/server/wire_gen.go", + "backend/cmd/server/wire_gen_test.go", + "backend/ent/migrate/schema.go", + "backend/ent/mutation.go", "backend/ent/runtime/runtime.go", - "backend/go.mod", - "backend/go.sum", - "backend/internal/handler/admin/account_handler.go", - "backend/internal/handler/admin/account_handler_available_models_test.go", + "backend/internal/config/config.go", + "backend/internal/config/config_test.go", + "backend/internal/handler/dto/mappers.go", + "backend/internal/handler/dto/types.go", "backend/internal/handler/gateway_handler.go", - "backend/internal/handler/gateway_models_test.go", - "backend/internal/handler/openai_codex_models_handler_test.go", - "backend/internal/handler/openai_gateway_handler.go", - "backend/internal/handler/openai_gateway_handler_test.go", + "backend/internal/handler/ops_error_logger.go", + "backend/internal/handler/wire.go", "backend/internal/pkg/apicompat/anthropic_responses_test.go", - "backend/internal/pkg/apicompat/chatcompletions_to_responses.go", - "backend/internal/pkg/apicompat/responses_to_chatcompletions.go", - "backend/internal/pkg/apicompat/types.go", + "backend/internal/pkg/apicompat/responses_to_anthropic_request.go", "backend/internal/pkg/claude/constants.go", - "backend/internal/repository/user_platform_quota_repo.go", - "backend/internal/server/api_contract_test.go", + "backend/internal/pkg/claude/effort_catalog.go", + "backend/internal/pkg/claude/effort_catalog_test.go", + "backend/internal/repository/api_key_repo.go", + "backend/internal/repository/group_repo.go", + "backend/internal/repository/scheduler_cache.go", + "backend/internal/repository/wire.go", "backend/internal/server/routes/gateway.go", "backend/internal/service/account.go", - "backend/internal/service/account_scheduling_threshold_eval.go", - "backend/internal/service/account_service.go", "backend/internal/service/account_test_service.go", - "backend/internal/service/account_test_service_cn_adaptive.go", - "backend/internal/service/admin_account.go", - "backend/internal/service/cn_provider_balance_check_service.go", - "backend/internal/service/composite_platform_test.go", - "backend/internal/service/domain_constants.go", - "backend/internal/service/gateway_forward_as_chat_completions.go", - "backend/internal/service/gateway_forward_as_responses.go", + "backend/internal/service/admin_service.go", + "backend/internal/service/billing_service.go", + "backend/internal/service/gateway_anthropic_passthrough.go", + "backend/internal/service/gateway_forward.go", "backend/internal/service/gateway_service.go", + "backend/internal/service/gateway_upstream_response.go", + "backend/internal/service/gateway_usage_billing.go", + "backend/internal/service/gemini_chat_completions_compat_service.go", + "backend/internal/service/gemini_messages_compat_service.go", + "backend/internal/service/gemini_messages_compat_service_test.go", "backend/internal/service/model_rate_limit.go", - "backend/internal/service/openai_codex_model_metadata.go", - "backend/internal/service/openai_codex_model_metadata_test.go", - "backend/internal/service/openai_codex_transform.go", + "backend/internal/service/openai_account_runtime_block_fastpath.go", + "backend/internal/service/openai_codex_models_service.go", + "backend/internal/service/openai_codex_models_service_test.go", + "backend/internal/service/openai_gateway_cc_pipeline.go", "backend/internal/service/openai_gateway_chat_completions.go", - "backend/internal/service/openai_gateway_chat_completions_anthropic_native.go", + "backend/internal/service/openai_gateway_chat_completions_raw.go", + "backend/internal/service/openai_gateway_chat_completions_test.go", "backend/internal/service/openai_gateway_count_tokens.go", "backend/internal/service/openai_gateway_forward.go", - "backend/internal/service/openai_gateway_messages.go", - "backend/internal/service/openai_gateway_messages_anthropic_native.go", - "backend/internal/service/openai_gateway_passthrough.go", - "backend/internal/service/openai_gateway_request_body.go", - "backend/internal/service/openai_gateway_response_handling.go", - "backend/internal/service/openai_gateway_responses_anthropic_native.go", - "backend/internal/service/openai_gateway_scheduling.go", - "backend/internal/service/openai_gateway_service_hotpath_test.go", - "backend/internal/service/openai_models_list.go", - "backend/internal/service/openai_responses_namespace.go", - "backend/internal/service/openai_ws_v2_passthrough_adapter.go", + "backend/internal/service/openai_gateway_responses_chat_fallback.go", + "backend/internal/service/openai_gateway_responses_chat_fallback_test.go", + "backend/internal/service/openai_gateway_service.go", + "backend/internal/service/openai_gateway_usage.go", + "backend/internal/service/ops_upstream_context.go", + "backend/internal/service/pricing_service.go", "backend/internal/service/ratelimit_service.go", - "backend/internal/service/scheduler_snapshot_service.go", + "backend/internal/service/temp_unsched.go", "backend/internal/service/upstream_models.go", - "backend/internal/web/embed_on.go", + "backend/internal/service/wire.go", "backend/internal/web/embed_test.go", - "deploy/Dockerfile", - "frontend/src/api/__tests__/settings.authSourceDefaults.spec.ts", - "frontend/src/api/admin/ops.ts", - "frontend/src/api/admin/settings.ts", - "frontend/src/api/admin/users.ts", + "backend/resources/model-pricing/model_prices_and_context_window.json", + "deploy/config.example.yaml", + "frontend/src/components/account/AccountGroupsCell.vue", "frontend/src/components/account/AccountUsageCell.vue", "frontend/src/components/account/CreateAccountModal.vue", "frontend/src/components/account/EditAccountModal.vue", "frontend/src/components/account/ModelWhitelistSelector.vue", - "frontend/src/components/account/OpenCodeGoProtocolRulesEditor.vue", + "frontend/src/components/account/UsageProgressBar.vue", "frontend/src/components/account/__tests__/AccountUsageCell.spec.ts", "frontend/src/components/account/__tests__/CreateAccountModal.spec.ts", "frontend/src/components/account/__tests__/EditAccountModal.spec.ts", "frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts", - "frontend/src/components/account/__tests__/credentialsBuilder.spec.ts", - "frontend/src/components/account/credentialsBuilder.ts", - "frontend/src/components/admin/account/ScheduledTestsPanel.vue", + "frontend/src/components/account/__tests__/UsageProgressBar.spec.ts", "frontend/src/components/admin/group/GroupRPMOverridesModal.vue", "frontend/src/components/admin/group/GroupRateMultipliersModal.vue", - "frontend/src/components/admin/monitor/MonitorTemplateManagerDialog.vue", - "frontend/src/components/admin/usage/UsageTable.vue", - "frontend/src/components/admin/user/UserPlatformQuotaModal.vue", - "frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts", - "frontend/src/components/common/AnnouncementBell.vue", - "frontend/src/components/common/BaseDialog.vue", + "frontend/src/components/common/GroupBadge.vue", + "frontend/src/components/common/LocaleSwitcher.vue", + "frontend/src/components/common/PlatformTypeBadge.vue", "frontend/src/components/common/Select.vue", - "frontend/src/components/keys/UseKeyModal.vue", - "frontend/src/components/keys/__tests__/UseKeyModal.spec.ts", - "frontend/src/components/user/UserPlatformQuotaCell.vue", - "frontend/src/components/user/profile/ProfileBalanceNotifyCard.vue", - "frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue", "frontend/src/composables/__tests__/useModelWhitelist.spec.ts", "frontend/src/composables/useModelWhitelist.ts", - "frontend/src/constants/__tests__/platforms.spec.ts", - "frontend/src/constants/platforms.ts", "frontend/src/i18n/locales/en/admin/accounts.ts", "frontend/src/i18n/locales/en/admin/ops.ts", - "frontend/src/i18n/locales/en/admin/resources.ts", - "frontend/src/i18n/locales/en/dashboard.ts", "frontend/src/i18n/locales/zh/admin/accounts.ts", "frontend/src/i18n/locales/zh/admin/ops.ts", - "frontend/src/i18n/locales/zh/admin/resources.ts", - "frontend/src/i18n/locales/zh/dashboard.ts", "frontend/src/types/index.ts", - "frontend/src/utils/keyGroupProviders.ts", - "frontend/src/utils/platformColors.ts", - "frontend/src/views/admin/AccountsView.vue", - "frontend/src/views/admin/BackupView.vue", - "frontend/src/views/admin/ChannelsView.vue", "frontend/src/views/admin/GroupsView.vue", - "frontend/src/views/admin/PluginsView.vue", - "frontend/src/views/admin/SettingsView.vue", - "frontend/src/views/admin/__tests__/channelPlatformOptions.spec.ts", - "frontend/src/views/admin/ops/components/OpsAlertEventsCard.vue", - "frontend/src/views/admin/ops/components/OpsAlertRulesCard.vue", "frontend/src/views/admin/ops/components/OpsDashboardHeader.vue", - "frontend/src/views/admin/ops/components/OpsSettingsDialog.vue", - "frontend/src/views/user/CustomPageView.vue", - "frontend/src/views/user/StripePaymentView.vue", - "frontend/src/views/user/__tests__/StripePaymentView.spec.ts" + "frontend/src/views/admin/ops/components/OpsRequestDetailsModal.vue" ], "risk_class": "review_required", "schema": 1, - "upstream_base": "v0.2.14", - "upstream_file_count": 278, + "upstream_base": "v0.2.15", + "upstream_file_count": 213, "upstream_files": [ - ".github/workflows/backend-ci.yml", - ".github/workflows/release.yml", - ".github/workflows/security-scan.yml", - "Dockerfile", "Makefile", - "backend/.golangci.yml", - "backend/Dockerfile", "backend/cmd/server/VERSION", + "backend/cmd/server/wire.go", + "backend/cmd/server/wire_gen.go", + "backend/cmd/server/wire_gen_test.go", + "backend/ent/group.go", + "backend/ent/group/group.go", + "backend/ent/group_create.go", + "backend/ent/group_update.go", + "backend/ent/migrate/schema.go", + "backend/ent/mutation.go", "backend/ent/runtime/runtime.go", - "backend/ent/schema/composite_model_route.go", - "backend/ent/schema/user_platform_quota.go", - "backend/go.mod", - "backend/go.sum", - "backend/internal/domain/constants.go", - "backend/internal/domain/platforms.go", - "backend/internal/domain/platforms_test.go", - "backend/internal/handler/admin/account_handler.go", - "backend/internal/handler/admin/account_handler_available_models_test.go", - "backend/internal/handler/admin/channel_handler.go", + "backend/ent/schema/group.go", + "backend/internal/config/config.go", + "backend/internal/config/config_test.go", + "backend/internal/domain/group_i18n.go", + "backend/internal/domain/group_i18n_test.go", "backend/internal/handler/admin/group_handler.go", - "backend/internal/handler/admin/group_handler_platform_test.go", - "backend/internal/handler/admin/platform_validation.go", + "backend/internal/handler/api_key_handler.go", + "backend/internal/handler/available_channel_handler.go", + "backend/internal/handler/dto/group_localize.go", + "backend/internal/handler/dto/group_localize_test.go", + "backend/internal/handler/dto/mappers.go", + "backend/internal/handler/dto/types.go", + "backend/internal/handler/endpoint.go", + "backend/internal/handler/endpoint_test.go", "backend/internal/handler/gateway_handler.go", - "backend/internal/handler/gateway_models_test.go", - "backend/internal/handler/openai_codex_models_handler_test.go", - "backend/internal/handler/openai_gateway_handler.go", - "backend/internal/handler/openai_gateway_handler_test.go", - "backend/internal/handler/openai_gateway_ws_group_pricing_test.go", - "backend/internal/model/error_passthrough_rule.go", - "backend/internal/model/error_passthrough_rule_test.go", - "backend/internal/pkg/antigravity/claude_types.go", - "backend/internal/pkg/antigravity/claude_types_test.go", - "backend/internal/pkg/antigravity/stream_transformer.go", + "backend/internal/handler/gemini_cached_contents_billing_test.go", + "backend/internal/handler/gemini_cached_contents_handler.go", + "backend/internal/handler/gemini_cached_contents_handler_test.go", + "backend/internal/handler/gemini_v1beta_handler.go", + "backend/internal/handler/gemini_v1beta_handler_test.go", + "backend/internal/handler/gemini_vertex_models_test.go", + "backend/internal/handler/group_localize_test.go", + "backend/internal/handler/model_plaza_handler.go", + "backend/internal/handler/model_plaza_handler_test.go", + "backend/internal/handler/ops_error_logger.go", + "backend/internal/handler/ops_error_logger_google_test.go", + "backend/internal/handler/payment_handler.go", + "backend/internal/handler/redeem_handler.go", + "backend/internal/handler/security_audit_errors.go", + "backend/internal/handler/security_audit_order_test.go", + "backend/internal/handler/subscription_handler.go", + "backend/internal/handler/usage_handler.go", + "backend/internal/handler/wire.go", "backend/internal/pkg/apicompat/anthropic_responses_test.go", - "backend/internal/pkg/apicompat/chatcompletions_to_responses.go", - "backend/internal/pkg/apicompat/chatcompletions_to_responses_developer_role_test.go", - "backend/internal/pkg/apicompat/chatcompletions_to_responses_legacy_function_call_test.go", - "backend/internal/pkg/apicompat/chatcompletions_to_responses_tool_choice_test.go", - "backend/internal/pkg/apicompat/responses_to_chatcompletions.go", - "backend/internal/pkg/apicompat/responses_to_chatcompletions_refusal_test.go", - "backend/internal/pkg/apicompat/thinking_signature_wire_test.go", - "backend/internal/pkg/apicompat/types.go", + "backend/internal/pkg/apicompat/anthropic_to_responses_response.go", + "backend/internal/pkg/apicompat/responses_to_anthropic_request.go", "backend/internal/pkg/claude/constants.go", - "backend/internal/pkg/xai/sso_device.go", - "backend/internal/pkg/xai/sso_device_test.go", - "backend/internal/repository/ops_repo_dashboard.go", - "backend/internal/repository/ops_repo_dashboard_integration_test.go", - "backend/internal/repository/ops_repo_dashboard_timeout_test.go", - "backend/internal/repository/platform_list_db_integration_test.go", - "backend/internal/repository/user_platform_quota_repo.go", - "backend/internal/server/api_contract_test.go", + "backend/internal/pkg/claude/constants_model_test.go", + "backend/internal/pkg/claude/effort_catalog.go", + "backend/internal/pkg/claude/effort_catalog_test.go", + "backend/internal/repository/api_key_repo.go", + "backend/internal/repository/api_key_repo_integration_test.go", + "backend/internal/repository/billing_inflight_cache.go", + "backend/internal/repository/billing_inflight_cache_test.go", + "backend/internal/repository/gemini_cached_content_repo.go", + "backend/internal/repository/gemini_cached_content_repo_integration_test.go", + "backend/internal/repository/group_repo.go", + "backend/internal/repository/group_repo_integration_test.go", + "backend/internal/repository/scheduler_cache.go", + "backend/internal/repository/scheduler_cache_test.go", + "backend/internal/repository/setting_repo.go", + "backend/internal/repository/wire.go", "backend/internal/server/routes/gateway.go", + "backend/internal/server/routes/prompt_audit_route_coverage_test.go", "backend/internal/service/account.go", - "backend/internal/service/account_header_override.go", - "backend/internal/service/account_scheduling_threshold_eval.go", - "backend/internal/service/account_service.go", - "backend/internal/service/account_test_logging.go", - "backend/internal/service/account_test_logging_test.go", + "backend/internal/service/account_kimi_coding_test.go", + "backend/internal/service/account_stats_pricing_test.go", "backend/internal/service/account_test_service.go", - "backend/internal/service/account_test_service_cn_adaptive.go", - "backend/internal/service/admin_account.go", "backend/internal/service/admin_group.go", - "backend/internal/service/anthropic_apikey_auth.go", - "backend/internal/service/anthropic_buffered_negative_index_test.go", - "backend/internal/service/auth_service.go", + "backend/internal/service/admin_group_duplicate.go", + "backend/internal/service/admin_service.go", + "backend/internal/service/admin_service_group_i18n_test.go", + "backend/internal/service/antigravity_rate_limit_test.go", + "backend/internal/service/api_key_service.go", + "backend/internal/service/billing_cache_service.go", + "backend/internal/service/billing_inflight_reservation.go", + "backend/internal/service/billing_service.go", + "backend/internal/service/billing_service_test.go", + "backend/internal/service/channel_available.go", "backend/internal/service/channel_monitor_checker.go", - "backend/internal/service/channel_monitor_endpoint_test.go", - "backend/internal/service/channel_monitor_v2.go", - "backend/internal/service/channel_monitor_v2_test.go", - "backend/internal/service/channel_service.go", - "backend/internal/service/channel_service_test.go", - "backend/internal/service/cline.go", - "backend/internal/service/cline_test.go", - "backend/internal/service/cline_usage.go", - "backend/internal/service/cn_provider_balance_check_service.go", - "backend/internal/service/cn_provider_balance_service.go", - "backend/internal/service/cn_provider_quota_service.go", - "backend/internal/service/cn_providers_test.go", - "backend/internal/service/command_code.go", - "backend/internal/service/command_code_test.go", - "backend/internal/service/command_code_usage.go", - "backend/internal/service/command_code_usage_test.go", - "backend/internal/service/composite_platform.go", - "backend/internal/service/composite_platform_test.go", - "backend/internal/service/composite_route_resolver.go", - "backend/internal/service/composite_route_resolver_test.go", - "backend/internal/service/domain_constants.go", - "backend/internal/service/gateway_cache_ttl_order_test.go", + "backend/internal/service/channel_monitor_checker_body_test.go", + "backend/internal/service/cn_provider_probe_url_test.go", + "backend/internal/service/content_moderation.go", + "backend/internal/service/gateway_anthropic_passthrough.go", + "backend/internal/service/gateway_bedrock.go", "backend/internal/service/gateway_claude_oauth_body.go", - "backend/internal/service/gateway_forward_as_chat_completions.go", - "backend/internal/service/gateway_forward_as_chat_completions_ping_test.go", - "backend/internal/service/gateway_forward_as_responses.go", + "backend/internal/service/gateway_context_management_test.go", + "backend/internal/service/gateway_count_tokens.go", + "backend/internal/service/gateway_forward.go", + "backend/internal/service/gateway_image_reasoning_pricing_test.go", + "backend/internal/service/gateway_request.go", + "backend/internal/service/gateway_request_haiku55_test.go", "backend/internal/service/gateway_service.go", - "backend/internal/service/gateway_tool_changes_beta_test.go", "backend/internal/service/gateway_upstream_request.go", - "backend/internal/service/model_protocol_catalog.go", - "backend/internal/service/model_protocol_catalog_test.go", + "backend/internal/service/gateway_upstream_response.go", + "backend/internal/service/gateway_usage_billing.go", + "backend/internal/service/gateway_usage_billing_request_id_test.go", + "backend/internal/service/gemini_cache_storage_pricing.go", + "backend/internal/service/gemini_cache_storage_pricing_test.go", + "backend/internal/service/gemini_cached_content.go", + "backend/internal/service/gemini_cached_content_hardening_test.go", + "backend/internal/service/gemini_cached_content_not_found_test.go", + "backend/internal/service/gemini_cached_content_test.go", + "backend/internal/service/gemini_chat_completions_compat_service.go", + "backend/internal/service/gemini_error_policy_test.go", + "backend/internal/service/gemini_messages_compat_service.go", + "backend/internal/service/gemini_messages_compat_service_test.go", + "backend/internal/service/gemini_models_endpoints.go", + "backend/internal/service/gemini_models_endpoints_test.go", + "backend/internal/service/gemini_quota.go", + "backend/internal/service/gemini_quota_test.go", + "backend/internal/service/gemini_vertex_api_key_forward_test.go", + "backend/internal/service/group.go", + "backend/internal/service/group_i18n.go", + "backend/internal/service/group_i18n_test.go", + "backend/internal/service/model_plaza_service.go", "backend/internal/service/model_rate_limit.go", - "backend/internal/service/openai_apikey_responses_probe.go", - "backend/internal/service/openai_codex_model_metadata.go", - "backend/internal/service/openai_codex_model_metadata_test.go", - "backend/internal/service/openai_codex_models_pinned.go", - "backend/internal/service/openai_codex_models_pinned_test.go", - "backend/internal/service/openai_codex_transform.go", + "backend/internal/service/model_rate_limit_test.go", + "backend/internal/service/openai_account_runtime_block_fastpath.go", + "backend/internal/service/openai_alpha_search_billing_test.go", + "backend/internal/service/openai_codex_models_service.go", + "backend/internal/service/openai_codex_models_service_test.go", + "backend/internal/service/openai_gateway_cc_pipeline.go", "backend/internal/service/openai_gateway_chat_completions.go", - "backend/internal/service/openai_gateway_chat_completions_anthropic_native.go", + "backend/internal/service/openai_gateway_chat_completions_raw.go", + "backend/internal/service/openai_gateway_chat_completions_test.go", + "backend/internal/service/openai_gateway_cn_fixes_test.go", "backend/internal/service/openai_gateway_count_tokens.go", "backend/internal/service/openai_gateway_forward.go", - "backend/internal/service/openai_gateway_messages.go", - "backend/internal/service/openai_gateway_messages_anthropic_native.go", - "backend/internal/service/openai_gateway_passthrough.go", - "backend/internal/service/openai_gateway_provider_claude_billing_test.go", - "backend/internal/service/openai_gateway_request_body.go", - "backend/internal/service/openai_gateway_response_handling.go", - "backend/internal/service/openai_gateway_responses_anthropic_native.go", - "backend/internal/service/openai_gateway_responses_empty_completed_test.go", - "backend/internal/service/openai_gateway_scheduling.go", - "backend/internal/service/openai_gateway_service_hotpath_test.go", - "backend/internal/service/openai_models_list.go", - "backend/internal/service/openai_models_list_test.go", - "backend/internal/service/openai_oauth_web_search_history.go", - "backend/internal/service/openai_oauth_web_search_history_forward_test.go", - "backend/internal/service/openai_oauth_web_search_history_test.go", - "backend/internal/service/openai_responses_namespace.go", - "backend/internal/service/openai_responses_namespace_benchmark_test.go", - "backend/internal/service/openai_responses_namespace_test.go", - "backend/internal/service/openai_ws_v2/passthrough_relay.go", - "backend/internal/service/openai_ws_v2/passthrough_relay_image_usage_test.go", - "backend/internal/service/openai_ws_v2_passthrough_adapter.go", - "backend/internal/service/openai_ws_v2_passthrough_image_usage_test.go", - "backend/internal/service/opencode_go.go", - "backend/internal/service/opencode_go_test.go", - "backend/internal/service/opencode_go_usage.go", - "backend/internal/service/opencode_go_usage_test.go", - "backend/internal/service/opencode_unsupported_models_routing_test.go", - "backend/internal/service/opencode_unsupported_models_test.go", - "backend/internal/service/ops_dashboard_models.go", - "backend/internal/service/platform_catalog.go", - "backend/internal/service/platform_catalog_test.go", - "backend/internal/service/platform_list_test.go", - "backend/internal/service/provider_profile.go", - "backend/internal/service/provider_profile_test.go", - "backend/internal/service/ratelimit_cline.go", - "backend/internal/service/ratelimit_cline_test.go", - "backend/internal/service/ratelimit_cn_providers.go", - "backend/internal/service/ratelimit_command_code.go", + "backend/internal/service/openai_gateway_responses_chat_fallback.go", + "backend/internal/service/openai_gateway_responses_chat_fallback_test.go", + "backend/internal/service/openai_gateway_search_surcharge_test.go", + "backend/internal/service/openai_gateway_service.go", + "backend/internal/service/openai_gateway_usage.go", + "backend/internal/service/openai_image_generation_controls_test.go", + "backend/internal/service/openai_subscription_long_context_test.go", + "backend/internal/service/ops_upstream_context.go", + "backend/internal/service/payment_config_plans.go", + "backend/internal/service/pricing_service.go", + "backend/internal/service/pricing_service_test.go", "backend/internal/service/ratelimit_service.go", - "backend/internal/service/scheduler_snapshot_service.go", - "backend/internal/service/testdata/record_upstream_protocol_routing_main.py", - "backend/internal/service/testdata/upstream_protocol_routing_main_golden.json", - "backend/internal/service/upstream_billing_probe.go", - "backend/internal/service/upstream_billing_probe_test.go", + "backend/internal/service/temp_unsched.go", + "backend/internal/service/temp_unsched_scope_test.go", "backend/internal/service/upstream_models.go", - "backend/internal/service/upstream_protocol_routing.go", - "backend/internal/service/upstream_protocol_routing_matrix_test.go", - "backend/internal/service/upstream_protocol_routing_test.go", - "backend/internal/web/embed_on.go", + "backend/internal/service/upstream_models_vertex_test.go", + "backend/internal/service/vertex_service_account.go", + "backend/internal/service/vertex_service_account_test.go", + "backend/internal/service/wire.go", + "backend/internal/util/logredact/redact.go", + "backend/internal/util/logredact/redact_test.go", "backend/internal/web/embed_test.go", - "backend/migrations/242_drop_platform_check_constraints.sql", - "backend/migrations/platform_check_constraints_drop_migration_test.go", - "deploy/Dockerfile", - "frontend/src/api/__tests__/settings.authSourceDefaults.spec.ts", - "frontend/src/api/admin/cnProviders.ts", - "frontend/src/api/admin/ops.ts", - "frontend/src/api/admin/settings.ts", - "frontend/src/api/admin/users.ts", + "backend/migrations/242_group_i18n.sql", + "backend/migrations/243_gemini_cached_contents.sql", + "backend/migrations/244_gemini_cached_contents_channel_usage.sql", + "backend/migrations/245_gemini_cached_contents_mutation_lease.sql", + "backend/resources/model-pricing/model_prices_and_context_window.json", + "deploy/config.example.yaml", + "frontend/src/components/account/AccountGroupsCell.vue", "frontend/src/components/account/AccountUsageCell.vue", - "frontend/src/components/account/CNProviderBalanceCell.vue", - "frontend/src/components/account/CNProviderQuotaCell.vue", "frontend/src/components/account/CreateAccountModal.vue", "frontend/src/components/account/EditAccountModal.vue", "frontend/src/components/account/ModelWhitelistSelector.vue", - "frontend/src/components/account/OpenCodeGoProtocolRulesEditor.vue", + "frontend/src/components/account/UsageProgressBar.vue", "frontend/src/components/account/__tests__/AccountUsageCell.spec.ts", "frontend/src/components/account/__tests__/CreateAccountModal.spec.ts", "frontend/src/components/account/__tests__/EditAccountModal.spec.ts", "frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts", - "frontend/src/components/account/__tests__/OpenCodeGoProtocolRulesEditor.spec.ts", - "frontend/src/components/account/__tests__/credentialsBuilder.platformCatalog.spec.ts", - "frontend/src/components/account/__tests__/credentialsBuilder.spec.ts", - "frontend/src/components/account/credentialsBuilder.ts", - "frontend/src/components/admin/ErrorPassthroughRulesModal.vue", - "frontend/src/components/admin/__tests__/ErrorPassthroughRulesModal.toggle.spec.ts", - "frontend/src/components/admin/account/AccountTableFilters.vue", - "frontend/src/components/admin/account/ScheduledTestsPanel.vue", - "frontend/src/components/admin/account/__tests__/AccountTableFilters.spec.ts", - "frontend/src/components/admin/account/__tests__/ScheduledTestsPanel.results.spec.ts", - "frontend/src/components/admin/channel/PricingEntryCard.vue", - "frontend/src/components/admin/channel/__tests__/PricingEntryCard.modelDefaultPrice.spec.ts", + "frontend/src/components/account/__tests__/UsageProgressBar.spec.ts", + "frontend/src/components/admin/group/GroupI18nField.vue", "frontend/src/components/admin/group/GroupRPMOverridesModal.vue", "frontend/src/components/admin/group/GroupRateMultipliersModal.vue", - "frontend/src/components/admin/group/__tests__/GroupRPMOverridesModal.edit.spec.ts", - "frontend/src/components/admin/group/__tests__/GroupRateMultipliersModal.requests.spec.ts", - "frontend/src/components/admin/monitor/MonitorTemplateManagerDialog.vue", - "frontend/src/components/admin/monitor/__tests__/MonitorTemplateManagerDialog.requests.spec.ts", - "frontend/src/components/admin/usage/UsageCleanupDialog.vue", - "frontend/src/components/admin/usage/UsageTable.vue", - "frontend/src/components/admin/usage/__tests__/UsageCleanupDialog.spec.ts", - "frontend/src/components/admin/usage/__tests__/UsageTable.spec.ts", - "frontend/src/components/admin/user/BulkEditUserModal.vue", - "frontend/src/components/admin/user/UserPlatformQuotaModal.vue", - "frontend/src/components/admin/user/__tests__/BulkEditUserModal.spec.ts", - "frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts", - "frontend/src/components/auth/PendingOAuthCreateAccountForm.vue", - "frontend/src/components/auth/__tests__/PendingOAuthCreateAccountForm.spec.ts", - "frontend/src/components/common/AnnouncementBell.vue", - "frontend/src/components/common/BaseDialog.vue", - "frontend/src/components/common/PlatformIcon.vue", - "frontend/src/components/common/ProxySelector.vue", + "frontend/src/components/admin/group/__tests__/GroupI18nField.spec.ts", + "frontend/src/components/admin/group/__tests__/GroupRPMOverridesModal.requests.spec.ts", + "frontend/src/components/admin/group/__tests__/GroupRateMultipliersModal.edit.spec.ts", + "frontend/src/components/common/GroupBadge.vue", + "frontend/src/components/common/LocaleSwitcher.vue", + "frontend/src/components/common/PlatformTypeBadge.vue", "frontend/src/components/common/Select.vue", - "frontend/src/components/common/__tests__/AnnouncementBell.spec.ts", - "frontend/src/components/common/__tests__/BaseDialog.escape.spec.ts", - "frontend/src/components/common/__tests__/disabledSelectors.spec.ts", - "frontend/src/components/keys/UseKeyModal.vue", - "frontend/src/components/keys/__tests__/UseKeyModal.spec.ts", - "frontend/src/components/user/UserPlatformQuotaCell.vue", - "frontend/src/components/user/profile/ProfileBalanceNotifyCard.vue", - "frontend/src/components/user/profile/ProfileEditForm.vue", - "frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue", - "frontend/src/components/user/profile/__tests__/ProfileBalanceNotifyCard.spec.ts", - "frontend/src/components/user/profile/__tests__/ProfileEditForm.draft.spec.ts", - "frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts", - "frontend/src/composables/__tests__/useBatchImageAccess.retry.spec.ts", + "frontend/src/components/common/__tests__/LocaleSwitcher.spec.ts", + "frontend/src/components/common/__tests__/Select.ime.spec.ts", + "frontend/src/components/payment/PaymentQRDialog.vue", + "frontend/src/components/payment/PaymentStatusPanel.vue", + "frontend/src/components/payment/__tests__/paymentCountdown.spec.ts", + "frontend/src/composables/__tests__/useGeminiOAuth.errors.spec.ts", "frontend/src/composables/__tests__/useModelWhitelist.spec.ts", - "frontend/src/composables/__tests__/useStepUp.spec.ts", - "frontend/src/composables/useBatchImageAccess.ts", + "frontend/src/composables/useGeminiOAuth.ts", "frontend/src/composables/useModelWhitelist.ts", - "frontend/src/composables/useStepUp.ts", - "frontend/src/constants/__tests__/platforms.spec.ts", - "frontend/src/constants/platformCatalog.builtin.json", - "frontend/src/constants/platformCatalog.ts", - "frontend/src/constants/platforms.ts", + "frontend/src/features/channel-monitor-v2/MonitorSettingsPanel.vue", + "frontend/src/features/channel-monitor-v2/__tests__/MonitorSettingsPanel.spec.ts", "frontend/src/i18n/locales/en/admin/accounts.ts", "frontend/src/i18n/locales/en/admin/ops.ts", - "frontend/src/i18n/locales/en/admin/resources.ts", - "frontend/src/i18n/locales/en/dashboard.ts", + "frontend/src/i18n/locales/en/admin/overview.ts", + "frontend/src/i18n/locales/en/batchImage.ts", + "frontend/src/i18n/locales/en/channelMonitorV2.ts", "frontend/src/i18n/locales/zh/admin/accounts.ts", "frontend/src/i18n/locales/zh/admin/ops.ts", - "frontend/src/i18n/locales/zh/admin/resources.ts", - "frontend/src/i18n/locales/zh/dashboard.ts", - "frontend/src/stores/__tests__/adminCompliance.reset.spec.ts", - "frontend/src/stores/adminCompliance.ts", - "frontend/src/stores/announcements.ts", + "frontend/src/i18n/locales/zh/admin/overview.ts", + "frontend/src/i18n/locales/zh/batchImage.ts", + "frontend/src/i18n/locales/zh/channelMonitorV2.ts", "frontend/src/types/index.ts", - "frontend/src/utils/__tests__/formatBytes.spec.ts", - "frontend/src/utils/__tests__/latencyHealth.spec.ts", - "frontend/src/utils/__tests__/pricing.formatScaled.spec.ts", - "frontend/src/utils/format.ts", - "frontend/src/utils/keyGroupProviders.ts", - "frontend/src/utils/latencyHealth.ts", - "frontend/src/utils/platformColors.ts", - "frontend/src/utils/pricing.ts", - "frontend/src/views/admin/AccountsView.vue", - "frontend/src/views/admin/BackupView.vue", - "frontend/src/views/admin/ChannelsView.vue", + "frontend/src/utils/__tests__/geminiExplicitCache.spec.ts", + "frontend/src/utils/geminiExplicitCache.ts", "frontend/src/views/admin/GroupsView.vue", - "frontend/src/views/admin/PluginsView.vue", - "frontend/src/views/admin/SettingsView.vue", - "frontend/src/views/admin/__tests__/BackupView.spec.ts", - "frontend/src/views/admin/__tests__/ChannelsView.modelSync.spec.ts", - "frontend/src/views/admin/__tests__/PluginsView.spec.ts", - "frontend/src/views/admin/__tests__/channelPlatformOptions.spec.ts", - "frontend/src/views/admin/ops/components/OpsAlertEventsCard.vue", - "frontend/src/views/admin/ops/components/OpsAlertRulesCard.vue", "frontend/src/views/admin/ops/components/OpsDashboardHeader.vue", - "frontend/src/views/admin/ops/components/OpsDashboardSkeleton.vue", - "frontend/src/views/admin/ops/components/OpsSettingsDialog.vue", - "frontend/src/views/admin/ops/components/__tests__/OpsAlertEventsCard.pagination.spec.ts", - "frontend/src/views/admin/ops/components/__tests__/OpsAlertRulesCard.duration.spec.ts", - "frontend/src/views/admin/ops/components/__tests__/OpsDashboardHeader.spec.ts", - "frontend/src/views/admin/ops/components/__tests__/OpsSettingsDialog.loading.spec.ts", - "frontend/src/views/user/AirwallexPaymentView.vue", - "frontend/src/views/user/CustomPageView.vue", - "frontend/src/views/user/StripePaymentView.vue", - "frontend/src/views/user/__tests__/AirwallexPaymentView.lifecycle.spec.ts", - "frontend/src/views/user/__tests__/CustomPageView.race.spec.ts", - "frontend/src/views/user/__tests__/StripePaymentView.spec.ts", - "frontend/src/views/user/__tests__/UsageView.spec.ts" + "frontend/src/views/admin/ops/components/OpsRequestDetailsModal.vue", + "frontend/src/views/admin/ops/components/__tests__/OpsDashboardHeader.timezone.spec.ts", + "frontend/src/views/admin/ops/components/__tests__/OpsRequestDetailsModal.spec.ts", + "frontend/src/views/admin/ops/utils/accountNameMap.ts", + "frontend/src/views/user/ChannelStatusV1View.vue", + "frontend/src/views/user/PaymentQRCodeView.vue", + "frontend/src/views/user/__tests__/ChannelStatusV1View.details.spec.ts" ], - "upstream_tag": "v0.2.15" + "upstream_tag": "v0.2.16" } diff --git a/DOWNSTREAM.md b/DOWNSTREAM.md index 47d15b7dae15..326e0ce7ee67 100644 --- a/DOWNSTREAM.md +++ b/DOWNSTREAM.md @@ -5,7 +5,7 @@ This fork maintains the `codexrip` patch set over official Sub2API releases. ## Source baseline - Integrated official baseline: the release recorded in `.downstream/upstream-base`. -- Retained compatibility decisions: [upstream review](.downstream/upstream-review-v0.2.15.md). +- Retained compatibility decisions: [upstream review](.downstream/upstream-review-v0.2.16.md). - The operator workspace `docs/sub2api.md` owns the production version and image digest. A source merge or Release does not establish deployment completion. ## Settings and account operations @@ -186,9 +186,21 @@ Scheduled discovery may identify newer official releases. It cannot deploy production. Each integration records its selected official commit, conflicts and retained downstream contracts before a new immutable release is published. -`.downstream/upstream-risk.json` records the v0.2.14-to-v0.2.15 integration against -downstream commit `d469422e94b0655b22dd9d0a826decda61ffb91e`. The risk gate -recomputes those exact inputs. `initial_conflict_files` lists the 40 -conflicts resolved during this merge; `merge_conflicts: []` records that none -remain. A `review_required` candidate needs a real completed review and the -`upstream-reviewed` label before promotion. +`.downstream/upstream-risk.json` records the v0.2.15-to-v0.2.16 integration against +downstream commit `157836bdc12424a71f1d0bd30a37fcca38842fc9`. The risk gate +recomputes those exact inputs. `initial_conflict_files` records the 17 resolved +conflicts; `merge_conflicts: []` records that none remain. The candidate requires +a completed review and the `upstream-reviewed` label before promotion. + +Gemini explicit caches follow the official account/key binding, storage billing, +mutation lease and recovery rules. Vertex API-key authentication and service-account +model discovery, account/model temporary-unschedulable scopes and group display +translations follow v0.2.16. Existing unconfigured accounts and rules retain their +stored behavior. Official migrations 242 through 245 keep their original filenames +and coexist with downstream migrations of the same numeric prefixes. + +Haiku 5.5 uses adaptive thinking and medium default effort; its API limits are +resolved through the official evidence catalog. Local Codex descriptors still +carry no capacity defaults, including the official Sol 500k generation default. +Sol's subscription model-card long-context exemption follows the actual credential +and upstream model; API-key and custom-interval pricing keep their own policies. diff --git a/Makefile b/Makefile index 361e7835e098..d8fcfdae5086 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,7 @@ FRONTEND_CRITICAL_VITEST := \ src/api/__tests__/client.spec.ts \ src/api/__tests__/tokenRefresh.spec.ts \ src/api/__tests__/keys.bulkUpdate.spec.ts \ + src/composables/__tests__/useModelWhitelist.spec.ts \ src/components/account/__tests__/OpenAIReferralCell.spec.ts \ src/components/account/__tests__/OpenAIReferralCell.transport.spec.ts \ src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts \ diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index b005e307c519..9097948ffe8d 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.2.15 +0.2.16 diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index 691e869b3761..f8fbd4264a82 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -117,6 +117,7 @@ func provideCleanup( subscriptionExpiry *service.SubscriptionExpiryService, usageCleanup *service.UsageCleanupService, idempotencyCleanup *service.IdempotencyCleanupService, + geminiCachedContent *service.GeminiCachedContentService, batchImageCleanup *service.BatchImageCleanupService, batchImageWorker *service.BatchImageWorkerRuntime, pricing *service.PricingService, @@ -280,6 +281,12 @@ func provideCleanup( } return nil }}, + {"GeminiCachedContentService", func() error { + if geminiCachedContent != nil { + geminiCachedContent.Stop() + } + return nil + }}, {"BatchImageCleanupService", func() error { if batchImageCleanup != nil { batchImageCleanup.Stop() diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 92c3fe347f6a..ea875c897464 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -320,7 +320,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig) legacyEngine := securityaudit.NewLegacyModerationAdapter(contentModerationService) coordinator := securityaudit.NewCoordinator(legacyEngine, promptService) - gatewayHandler := handler.ProvideGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService, coordinator) + geminiCachedContentRepository := repository.NewGeminiCachedContentRepository(db) + geminiCachedContentService := service.ProvideGeminiCachedContentService(geminiCachedContentRepository, geminiMessagesCompatService, configConfig) + gatewayHandler := handler.ProvideGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService, coordinator, geminiCachedContentService) openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, gatewayService, pluginManager, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator, compositeRouteResolver) handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService) totpHandler := handler.NewTotpHandler(totpService) @@ -380,7 +382,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { channelMonitorV2Aggregator := service.ProvideChannelMonitorV2Aggregator(channelMonitorV2Repository, db, settingService) userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService) openAIQuotaAutoResetService := service.ProvideOpenAIQuotaAutoResetService(accountRepository, openAIQuotaService, rateLimitService, idempotencyCoordinator, auditLogService, settingService, leaderLockCache) - v := provideCleanup(client, redisClient, gatewayCache, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, codexClientIdentityBackfillService, claudeCodeVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, codexGatewayBorrowService, codexGatewayBorrowTestRunner, codexGatewayBorrowPreviewService, scheduledTestRunnerService, upstreamModelCatalogRefreshService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, auditLogService, openAIQuotaAutoResetService, promptService, accountJobRuntime, pluginManager) + v := provideCleanup(client, redisClient, gatewayCache, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, codexClientIdentityBackfillService, claudeCodeVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, geminiCachedContentService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, codexGatewayBorrowService, codexGatewayBorrowTestRunner, codexGatewayBorrowPreviewService, scheduledTestRunnerService, upstreamModelCatalogRefreshService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, auditLogService, openAIQuotaAutoResetService, promptService, accountJobRuntime, pluginManager) application := &Application{ Server: httpServer, PromptAudit: promptService, @@ -452,6 +454,7 @@ func provideCleanup( subscriptionExpiry *service.SubscriptionExpiryService, usageCleanup *service.UsageCleanupService, idempotencyCleanup *service.IdempotencyCleanupService, + geminiCachedContent *service.GeminiCachedContentService, batchImageCleanup *service.BatchImageCleanupService, batchImageWorker *service.BatchImageWorkerRuntime, pricing *service.PricingService, @@ -614,6 +617,12 @@ func provideCleanup( } return nil }}, + {"GeminiCachedContentService", func() error { + if geminiCachedContent != nil { + geminiCachedContent.Stop() + } + return nil + }}, {"BatchImageCleanupService", func() error { if batchImageCleanup != nil { batchImageCleanup.Stop() diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go index b7ccb7f5b1d7..6f140fa27bc7 100644 --- a/backend/cmd/server/wire_gen_test.go +++ b/backend/cmd/server/wire_gen_test.go @@ -84,6 +84,7 @@ func minimalDependencyCleanup(gatewayCache service.GatewayCache, autoReset *serv subscriptionExpirySvc, &service.UsageCleanupService{}, idempotencyCleanupSvc, + service.NewGeminiCachedContentService(nil, nil, cfg), &service.BatchImageCleanupService{}, nil, // batchImageWorker pricingSvc, diff --git a/backend/ent/group.go b/backend/ent/group.go index 5510c5aa1a70..747a09d1aa4f 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -30,6 +30,8 @@ type Group struct { Name string `json:"name,omitempty"` // Description holds the value of the "description" field. Description *string `json:"description,omitempty"` + // 名称与描述的界面语言译文:语言代码 -> {name, description};缺失的语言沿用 name/description + I18n domain.GroupI18n `json:"i18n,omitempty"` // RateMultiplier holds the value of the "rate_multiplier" field. RateMultiplier float64 `json:"rate_multiplier,omitempty"` // 是否启用高峰时段倍率 @@ -258,7 +260,7 @@ func (*Group) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelAllowlist, group.FieldCodexModelsManifestConfig, group.FieldReasoningEffortMappings: + case group.FieldI18n, group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelAllowlist, group.FieldCodexModelsManifestConfig, group.FieldReasoningEffortMappings: values[i] = new([]byte) case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldLongContextPricingEnabled, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldForceOpenaiFast, group.FieldFreeOpenaiFast, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled: values[i] = new(sql.NullBool) @@ -323,6 +325,14 @@ func (_m *Group) assignValues(columns []string, values []any) error { _m.Description = new(string) *_m.Description = value.String } + case group.FieldI18n: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field i18n", values[i]) + } else if value != nil && len(*value) > 0 { + if err := json.Unmarshal(*value, &_m.I18n); err != nil { + return fmt.Errorf("unmarshal field i18n: %w", err) + } + } case group.FieldRateMultiplier: if value, ok := values[i].(*sql.NullFloat64); !ok { return fmt.Errorf("unexpected type %T for field rate_multiplier", values[i]) @@ -817,6 +827,9 @@ func (_m *Group) String() string { builder.WriteString(*v) } builder.WriteString(", ") + builder.WriteString("i18n=") + builder.WriteString(fmt.Sprintf("%v", _m.I18n)) + builder.WriteString(", ") builder.WriteString("rate_multiplier=") builder.WriteString(fmt.Sprintf("%v", _m.RateMultiplier)) builder.WriteString(", ") diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index 66dbdc412de8..cf3feeb6c9e6 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -26,6 +26,8 @@ const ( FieldName = "name" // FieldDescription holds the string denoting the description field in the database. FieldDescription = "description" + // FieldI18n holds the string denoting the i18n field in the database. + FieldI18n = "i18n" // FieldRateMultiplier holds the string denoting the rate_multiplier field in the database. FieldRateMultiplier = "rate_multiplier" // FieldPeakRateEnabled holds the string denoting the peak_rate_enabled field in the database. @@ -228,6 +230,7 @@ var Columns = []string{ FieldDeletedAt, FieldName, FieldDescription, + FieldI18n, FieldRateMultiplier, FieldPeakRateEnabled, FieldPeakStart, @@ -326,6 +329,8 @@ var ( UpdateDefaultUpdatedAt func() time.Time // NameValidator is a validator for the "name" field. It is called by the builders before save. NameValidator func(string) error + // DefaultI18n holds the default value on creation for the "i18n" field. + DefaultI18n domain.GroupI18n // DefaultRateMultiplier holds the default value on creation for the "rate_multiplier" field. DefaultRateMultiplier float64 // DefaultPeakRateEnabled holds the default value on creation for the "peak_rate_enabled" field. diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index 7f4c6507088d..d2a3a0d0e310 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -92,6 +92,12 @@ func (_c *GroupCreate) SetNillableDescription(v *string) *GroupCreate { return _c } +// SetI18n sets the "i18n" field. +func (_c *GroupCreate) SetI18n(v domain.GroupI18n) *GroupCreate { + _c.mutation.SetI18n(v) + return _c +} + // SetRateMultiplier sets the "rate_multiplier" field. func (_c *GroupCreate) SetRateMultiplier(v float64) *GroupCreate { _c.mutation.SetRateMultiplier(v) @@ -1047,6 +1053,10 @@ func (_c *GroupCreate) defaults() error { v := group.DefaultUpdatedAt() _c.mutation.SetUpdatedAt(v) } + if _, ok := _c.mutation.I18n(); !ok { + v := group.DefaultI18n + _c.mutation.SetI18n(v) + } if _, ok := _c.mutation.RateMultiplier(); !ok { v := group.DefaultRateMultiplier _c.mutation.SetRateMultiplier(v) @@ -1230,6 +1240,9 @@ func (_c *GroupCreate) check() error { return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "Group.name": %w`, err)} } } + if _, ok := _c.mutation.I18n(); !ok { + return &ValidationError{Name: "i18n", err: errors.New(`ent: missing required field "Group.i18n"`)} + } if _, ok := _c.mutation.RateMultiplier(); !ok { return &ValidationError{Name: "rate_multiplier", err: errors.New(`ent: missing required field "Group.rate_multiplier"`)} } @@ -1465,6 +1478,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldDescription, field.TypeString, value) _node.Description = &value } + if value, ok := _c.mutation.I18n(); ok { + _spec.SetField(group.FieldI18n, field.TypeJSON, value) + _node.I18n = value + } if value, ok := _c.mutation.RateMultiplier(); ok { _spec.SetField(group.FieldRateMultiplier, field.TypeFloat64, value) _node.RateMultiplier = value @@ -1925,6 +1942,18 @@ func (u *GroupUpsert) ClearDescription() *GroupUpsert { return u } +// SetI18n sets the "i18n" field. +func (u *GroupUpsert) SetI18n(v domain.GroupI18n) *GroupUpsert { + u.Set(group.FieldI18n, v) + return u +} + +// UpdateI18n sets the "i18n" field to the value that was provided on create. +func (u *GroupUpsert) UpdateI18n() *GroupUpsert { + u.SetExcluded(group.FieldI18n) + return u +} + // SetRateMultiplier sets the "rate_multiplier" field. func (u *GroupUpsert) SetRateMultiplier(v float64) *GroupUpsert { u.Set(group.FieldRateMultiplier, v) @@ -3039,6 +3068,20 @@ func (u *GroupUpsertOne) ClearDescription() *GroupUpsertOne { }) } +// SetI18n sets the "i18n" field. +func (u *GroupUpsertOne) SetI18n(v domain.GroupI18n) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetI18n(v) + }) +} + +// UpdateI18n sets the "i18n" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateI18n() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateI18n() + }) +} + // SetRateMultiplier sets the "rate_multiplier" field. func (u *GroupUpsertOne) SetRateMultiplier(v float64) *GroupUpsertOne { return u.Update(func(s *GroupUpsert) { @@ -4485,6 +4528,20 @@ func (u *GroupUpsertBulk) ClearDescription() *GroupUpsertBulk { }) } +// SetI18n sets the "i18n" field. +func (u *GroupUpsertBulk) SetI18n(v domain.GroupI18n) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetI18n(v) + }) +} + +// UpdateI18n sets the "i18n" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateI18n() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateI18n() + }) +} + // SetRateMultiplier sets the "rate_multiplier" field. func (u *GroupUpsertBulk) SetRateMultiplier(v float64) *GroupUpsertBulk { return u.Update(func(s *GroupUpsert) { diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index 091466452f86..d4d8b8572913 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -97,6 +97,12 @@ func (_u *GroupUpdate) ClearDescription() *GroupUpdate { return _u } +// SetI18n sets the "i18n" field. +func (_u *GroupUpdate) SetI18n(v domain.GroupI18n) *GroupUpdate { + _u.mutation.SetI18n(v) + return _u +} + // SetRateMultiplier sets the "rate_multiplier" field. func (_u *GroupUpdate) SetRateMultiplier(v float64) *GroupUpdate { _u.mutation.ResetRateMultiplier() @@ -1581,6 +1587,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.DescriptionCleared() { _spec.ClearField(group.FieldDescription, field.TypeString) } + if value, ok := _u.mutation.I18n(); ok { + _spec.SetField(group.FieldI18n, field.TypeJSON, value) + } if value, ok := _u.mutation.RateMultiplier(); ok { _spec.SetField(group.FieldRateMultiplier, field.TypeFloat64, value) } @@ -2291,6 +2300,12 @@ func (_u *GroupUpdateOne) ClearDescription() *GroupUpdateOne { return _u } +// SetI18n sets the "i18n" field. +func (_u *GroupUpdateOne) SetI18n(v domain.GroupI18n) *GroupUpdateOne { + _u.mutation.SetI18n(v) + return _u +} + // SetRateMultiplier sets the "rate_multiplier" field. func (_u *GroupUpdateOne) SetRateMultiplier(v float64) *GroupUpdateOne { _u.mutation.ResetRateMultiplier() @@ -3805,6 +3820,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) if _u.mutation.DescriptionCleared() { _spec.ClearField(group.FieldDescription, field.TypeString) } + if value, ok := _u.mutation.I18n(); ok { + _spec.SetField(group.FieldI18n, field.TypeJSON, value) + } if value, ok := _u.mutation.RateMultiplier(); ok { _spec.SetField(group.FieldRateMultiplier, field.TypeFloat64, value) } diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index ab102c5c72fd..921e7b36603c 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -997,6 +997,7 @@ var ( {Name: "deleted_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "name", Type: field.TypeString, Size: 100}, {Name: "description", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}}, + {Name: "i18n", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}}, {Name: "rate_multiplier", Type: field.TypeFloat64, Default: 1, SchemaType: map[string]string{"postgres": "decimal(10,4)"}}, {Name: "peak_rate_enabled", Type: field.TypeBool, Default: false}, {Name: "peak_start", Type: field.TypeString, Size: 5, Default: ""}, @@ -1068,22 +1069,22 @@ var ( { Name: "group_status", Unique: false, - Columns: []*schema.Column{GroupsColumns[12]}, + Columns: []*schema.Column{GroupsColumns[13]}, }, { Name: "group_platform", Unique: false, - Columns: []*schema.Column{GroupsColumns[14]}, + Columns: []*schema.Column{GroupsColumns[15]}, }, { Name: "group_subscription_type", Unique: false, - Columns: []*schema.Column{GroupsColumns[15]}, + Columns: []*schema.Column{GroupsColumns[16]}, }, { Name: "group_is_exclusive", Unique: false, - Columns: []*schema.Column{GroupsColumns[11]}, + Columns: []*schema.Column{GroupsColumns[12]}, }, { Name: "group_deleted_at", @@ -1093,12 +1094,12 @@ var ( { Name: "group_sort_order", Unique: false, - Columns: []*schema.Column{GroupsColumns[49]}, + Columns: []*schema.Column{GroupsColumns[50]}, }, { Name: "idx_groups_duplicate_operation_id_active", Unique: true, - Columns: []*schema.Column{GroupsColumns[13]}, + Columns: []*schema.Column{GroupsColumns[14]}, Annotation: &entsql.IndexAnnotation{ Where: "duplicate_operation_id IS NOT NULL AND deleted_at IS NULL", }, diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 9283f899bb61..b627ad345d26 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -24053,6 +24053,7 @@ type GroupMutation struct { deleted_at *time.Time name *string description *string + i18n *domain.GroupI18n rate_multiplier *float64 addrate_multiplier *float64 peak_rate_enabled *bool @@ -24472,6 +24473,42 @@ func (m *GroupMutation) ResetDescription() { delete(m.clearedFields, group.FieldDescription) } +// SetI18n sets the "i18n" field. +func (m *GroupMutation) SetI18n(di domain.GroupI18n) { + m.i18n = &di +} + +// I18n returns the value of the "i18n" field in the mutation. +func (m *GroupMutation) I18n() (r domain.GroupI18n, exists bool) { + v := m.i18n + if v == nil { + return + } + return *v, true +} + +// OldI18n returns the old "i18n" field's value of the Group entity. +// If the Group object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *GroupMutation) OldI18n(ctx context.Context) (v domain.GroupI18n, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldI18n is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldI18n requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldI18n: %w", err) + } + return oldValue.I18n, nil +} + +// ResetI18n resets all changes to the "i18n" field. +func (m *GroupMutation) ResetI18n() { + m.i18n = nil +} + // SetRateMultiplier sets the "rate_multiplier" field. func (m *GroupMutation) SetRateMultiplier(f float64) { m.rate_multiplier = &f @@ -27888,7 +27925,7 @@ func (m *GroupMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *GroupMutation) Fields() []string { - fields := make([]string, 0, 66) + fields := make([]string, 0, 67) if m.created_at != nil { fields = append(fields, group.FieldCreatedAt) } @@ -27904,6 +27941,9 @@ func (m *GroupMutation) Fields() []string { if m.description != nil { fields = append(fields, group.FieldDescription) } + if m.i18n != nil { + fields = append(fields, group.FieldI18n) + } if m.rate_multiplier != nil { fields = append(fields, group.FieldRateMultiplier) } @@ -28105,6 +28145,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.Name() case group.FieldDescription: return m.Description() + case group.FieldI18n: + return m.I18n() case group.FieldRateMultiplier: return m.RateMultiplier() case group.FieldPeakRateEnabled: @@ -28246,6 +28288,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldName(ctx) case group.FieldDescription: return m.OldDescription(ctx) + case group.FieldI18n: + return m.OldI18n(ctx) case group.FieldRateMultiplier: return m.OldRateMultiplier(ctx) case group.FieldPeakRateEnabled: @@ -28412,6 +28456,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetDescription(v) return nil + case group.FieldI18n: + v, ok := value.(domain.GroupI18n) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetI18n(v) + return nil case group.FieldRateMultiplier: v, ok := value.(float64) if !ok { @@ -29365,6 +29416,9 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldDescription: m.ResetDescription() return nil + case group.FieldI18n: + m.ResetI18n() + return nil case group.FieldRateMultiplier: m.ResetRateMultiplier() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 1e12bb220466..0908c3fe3ead 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1156,204 +1156,208 @@ func init() { return nil } }() + // groupDescI18n is the schema descriptor for i18n field. + groupDescI18n := groupFields[2].Descriptor() + // group.DefaultI18n holds the default value on creation for the i18n field. + group.DefaultI18n = groupDescI18n.Default.(domain.GroupI18n) // groupDescRateMultiplier is the schema descriptor for rate_multiplier field. - groupDescRateMultiplier := groupFields[2].Descriptor() + groupDescRateMultiplier := groupFields[3].Descriptor() // group.DefaultRateMultiplier holds the default value on creation for the rate_multiplier field. group.DefaultRateMultiplier = groupDescRateMultiplier.Default.(float64) // groupDescPeakRateEnabled is the schema descriptor for peak_rate_enabled field. - groupDescPeakRateEnabled := groupFields[3].Descriptor() + groupDescPeakRateEnabled := groupFields[4].Descriptor() // group.DefaultPeakRateEnabled holds the default value on creation for the peak_rate_enabled field. group.DefaultPeakRateEnabled = groupDescPeakRateEnabled.Default.(bool) // groupDescPeakStart is the schema descriptor for peak_start field. - groupDescPeakStart := groupFields[4].Descriptor() + groupDescPeakStart := groupFields[5].Descriptor() // group.DefaultPeakStart holds the default value on creation for the peak_start field. group.DefaultPeakStart = groupDescPeakStart.Default.(string) // group.PeakStartValidator is a validator for the "peak_start" field. It is called by the builders before save. group.PeakStartValidator = groupDescPeakStart.Validators[0].(func(string) error) // groupDescPeakEnd is the schema descriptor for peak_end field. - groupDescPeakEnd := groupFields[5].Descriptor() + groupDescPeakEnd := groupFields[6].Descriptor() // group.DefaultPeakEnd holds the default value on creation for the peak_end field. group.DefaultPeakEnd = groupDescPeakEnd.Default.(string) // group.PeakEndValidator is a validator for the "peak_end" field. It is called by the builders before save. group.PeakEndValidator = groupDescPeakEnd.Validators[0].(func(string) error) // groupDescPeakRateMultiplier is the schema descriptor for peak_rate_multiplier field. - groupDescPeakRateMultiplier := groupFields[6].Descriptor() + groupDescPeakRateMultiplier := groupFields[7].Descriptor() // group.DefaultPeakRateMultiplier holds the default value on creation for the peak_rate_multiplier field. group.DefaultPeakRateMultiplier = groupDescPeakRateMultiplier.Default.(float64) // groupDescIsExclusive is the schema descriptor for is_exclusive field. - groupDescIsExclusive := groupFields[7].Descriptor() + groupDescIsExclusive := groupFields[8].Descriptor() // group.DefaultIsExclusive holds the default value on creation for the is_exclusive field. group.DefaultIsExclusive = groupDescIsExclusive.Default.(bool) // groupDescStatus is the schema descriptor for status field. - groupDescStatus := groupFields[8].Descriptor() + groupDescStatus := groupFields[9].Descriptor() // group.DefaultStatus holds the default value on creation for the status field. group.DefaultStatus = groupDescStatus.Default.(string) // group.StatusValidator is a validator for the "status" field. It is called by the builders before save. group.StatusValidator = groupDescStatus.Validators[0].(func(string) error) // groupDescDuplicateOperationID is the schema descriptor for duplicate_operation_id field. - groupDescDuplicateOperationID := groupFields[9].Descriptor() + groupDescDuplicateOperationID := groupFields[10].Descriptor() // group.DuplicateOperationIDValidator is a validator for the "duplicate_operation_id" field. It is called by the builders before save. group.DuplicateOperationIDValidator = groupDescDuplicateOperationID.Validators[0].(func(string) error) // groupDescPlatform is the schema descriptor for platform field. - groupDescPlatform := groupFields[10].Descriptor() + groupDescPlatform := groupFields[11].Descriptor() // group.DefaultPlatform holds the default value on creation for the platform field. group.DefaultPlatform = groupDescPlatform.Default.(string) // group.PlatformValidator is a validator for the "platform" field. It is called by the builders before save. group.PlatformValidator = groupDescPlatform.Validators[0].(func(string) error) // groupDescSubscriptionType is the schema descriptor for subscription_type field. - groupDescSubscriptionType := groupFields[11].Descriptor() + groupDescSubscriptionType := groupFields[12].Descriptor() // group.DefaultSubscriptionType holds the default value on creation for the subscription_type field. group.DefaultSubscriptionType = groupDescSubscriptionType.Default.(string) // group.SubscriptionTypeValidator is a validator for the "subscription_type" field. It is called by the builders before save. group.SubscriptionTypeValidator = groupDescSubscriptionType.Validators[0].(func(string) error) // groupDescDefaultValidityDays is the schema descriptor for default_validity_days field. - groupDescDefaultValidityDays := groupFields[15].Descriptor() + groupDescDefaultValidityDays := groupFields[16].Descriptor() // group.DefaultDefaultValidityDays holds the default value on creation for the default_validity_days field. group.DefaultDefaultValidityDays = groupDescDefaultValidityDays.Default.(int) // groupDescAllowImageGeneration is the schema descriptor for allow_image_generation field. - groupDescAllowImageGeneration := groupFields[16].Descriptor() + groupDescAllowImageGeneration := groupFields[17].Descriptor() // group.DefaultAllowImageGeneration holds the default value on creation for the allow_image_generation field. group.DefaultAllowImageGeneration = groupDescAllowImageGeneration.Default.(bool) // groupDescAllowBatchImageGeneration is the schema descriptor for allow_batch_image_generation field. - groupDescAllowBatchImageGeneration := groupFields[17].Descriptor() + groupDescAllowBatchImageGeneration := groupFields[18].Descriptor() // group.DefaultAllowBatchImageGeneration holds the default value on creation for the allow_batch_image_generation field. group.DefaultAllowBatchImageGeneration = groupDescAllowBatchImageGeneration.Default.(bool) // groupDescImageRateIndependent is the schema descriptor for image_rate_independent field. - groupDescImageRateIndependent := groupFields[18].Descriptor() + groupDescImageRateIndependent := groupFields[19].Descriptor() // group.DefaultImageRateIndependent holds the default value on creation for the image_rate_independent field. group.DefaultImageRateIndependent = groupDescImageRateIndependent.Default.(bool) // groupDescImageRateMultiplier is the schema descriptor for image_rate_multiplier field. - groupDescImageRateMultiplier := groupFields[19].Descriptor() + groupDescImageRateMultiplier := groupFields[20].Descriptor() // group.DefaultImageRateMultiplier holds the default value on creation for the image_rate_multiplier field. group.DefaultImageRateMultiplier = groupDescImageRateMultiplier.Default.(float64) // groupDescBatchImageDiscountMultiplier is the schema descriptor for batch_image_discount_multiplier field. - groupDescBatchImageDiscountMultiplier := groupFields[23].Descriptor() + groupDescBatchImageDiscountMultiplier := groupFields[24].Descriptor() // group.DefaultBatchImageDiscountMultiplier holds the default value on creation for the batch_image_discount_multiplier field. group.DefaultBatchImageDiscountMultiplier = groupDescBatchImageDiscountMultiplier.Default.(float64) // groupDescBatchImageHoldMultiplier is the schema descriptor for batch_image_hold_multiplier field. - groupDescBatchImageHoldMultiplier := groupFields[24].Descriptor() + groupDescBatchImageHoldMultiplier := groupFields[25].Descriptor() // group.DefaultBatchImageHoldMultiplier holds the default value on creation for the batch_image_hold_multiplier field. group.DefaultBatchImageHoldMultiplier = groupDescBatchImageHoldMultiplier.Default.(float64) // groupDescVideoRateIndependent is the schema descriptor for video_rate_independent field. - groupDescVideoRateIndependent := groupFields[25].Descriptor() + groupDescVideoRateIndependent := groupFields[26].Descriptor() // group.DefaultVideoRateIndependent holds the default value on creation for the video_rate_independent field. group.DefaultVideoRateIndependent = groupDescVideoRateIndependent.Default.(bool) // groupDescVideoRateMultiplier is the schema descriptor for video_rate_multiplier field. - groupDescVideoRateMultiplier := groupFields[26].Descriptor() + groupDescVideoRateMultiplier := groupFields[27].Descriptor() // group.DefaultVideoRateMultiplier holds the default value on creation for the video_rate_multiplier field. group.DefaultVideoRateMultiplier = groupDescVideoRateMultiplier.Default.(float64) // groupDescSearchPricePer1k is the schema descriptor for search_price_per_1k field. - groupDescSearchPricePer1k := groupFields[32].Descriptor() + groupDescSearchPricePer1k := groupFields[33].Descriptor() // group.SearchPricePer1kValidator is a validator for the "search_price_per_1k" field. It is called by the builders before save. group.SearchPricePer1kValidator = groupDescSearchPricePer1k.Validators[0].(func(float64) error) // groupDescAudioRealtimePricePerMin is the schema descriptor for audio_realtime_price_per_min field. - groupDescAudioRealtimePricePerMin := groupFields[33].Descriptor() + groupDescAudioRealtimePricePerMin := groupFields[34].Descriptor() // group.AudioRealtimePricePerMinValidator is a validator for the "audio_realtime_price_per_min" field. It is called by the builders before save. group.AudioRealtimePricePerMinValidator = groupDescAudioRealtimePricePerMin.Validators[0].(func(float64) error) // groupDescAudioTtsPricePerMillionChars is the schema descriptor for audio_tts_price_per_million_chars field. - groupDescAudioTtsPricePerMillionChars := groupFields[34].Descriptor() + groupDescAudioTtsPricePerMillionChars := groupFields[35].Descriptor() // group.AudioTtsPricePerMillionCharsValidator is a validator for the "audio_tts_price_per_million_chars" field. It is called by the builders before save. group.AudioTtsPricePerMillionCharsValidator = groupDescAudioTtsPricePerMillionChars.Validators[0].(func(float64) error) // groupDescAudioSttPricePerHour is the schema descriptor for audio_stt_price_per_hour field. - groupDescAudioSttPricePerHour := groupFields[35].Descriptor() + groupDescAudioSttPricePerHour := groupFields[36].Descriptor() // group.AudioSttPricePerHourValidator is a validator for the "audio_stt_price_per_hour" field. It is called by the builders before save. group.AudioSttPricePerHourValidator = groupDescAudioSttPricePerHour.Validators[0].(func(float64) error) // groupDescLongContextPricingEnabled is the schema descriptor for long_context_pricing_enabled field. - groupDescLongContextPricingEnabled := groupFields[36].Descriptor() + groupDescLongContextPricingEnabled := groupFields[37].Descriptor() // group.DefaultLongContextPricingEnabled holds the default value on creation for the long_context_pricing_enabled field. group.DefaultLongContextPricingEnabled = groupDescLongContextPricingEnabled.Default.(bool) // groupDescClaudeCodeOnly is the schema descriptor for claude_code_only field. - groupDescClaudeCodeOnly := groupFields[38].Descriptor() + groupDescClaudeCodeOnly := groupFields[39].Descriptor() // group.DefaultClaudeCodeOnly holds the default value on creation for the claude_code_only field. group.DefaultClaudeCodeOnly = groupDescClaudeCodeOnly.Default.(bool) // groupDescModelRoutingEnabled is the schema descriptor for model_routing_enabled field. - groupDescModelRoutingEnabled := groupFields[42].Descriptor() + groupDescModelRoutingEnabled := groupFields[43].Descriptor() // group.DefaultModelRoutingEnabled holds the default value on creation for the model_routing_enabled field. group.DefaultModelRoutingEnabled = groupDescModelRoutingEnabled.Default.(bool) // groupDescMcpXMLInject is the schema descriptor for mcp_xml_inject field. - groupDescMcpXMLInject := groupFields[43].Descriptor() + groupDescMcpXMLInject := groupFields[44].Descriptor() // group.DefaultMcpXMLInject holds the default value on creation for the mcp_xml_inject field. group.DefaultMcpXMLInject = groupDescMcpXMLInject.Default.(bool) // groupDescSupportedModelScopes is the schema descriptor for supported_model_scopes field. - groupDescSupportedModelScopes := groupFields[44].Descriptor() + groupDescSupportedModelScopes := groupFields[45].Descriptor() // group.DefaultSupportedModelScopes holds the default value on creation for the supported_model_scopes field. group.DefaultSupportedModelScopes = groupDescSupportedModelScopes.Default.([]string) // groupDescSortOrder is the schema descriptor for sort_order field. - groupDescSortOrder := groupFields[45].Descriptor() + groupDescSortOrder := groupFields[46].Descriptor() // group.DefaultSortOrder holds the default value on creation for the sort_order field. group.DefaultSortOrder = groupDescSortOrder.Default.(int) // groupDescAllowMessagesDispatch is the schema descriptor for allow_messages_dispatch field. - groupDescAllowMessagesDispatch := groupFields[46].Descriptor() + groupDescAllowMessagesDispatch := groupFields[47].Descriptor() // group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field. group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool) // groupDescAllowLive is the schema descriptor for allow_live field. - groupDescAllowLive := groupFields[47].Descriptor() + groupDescAllowLive := groupFields[48].Descriptor() // group.DefaultAllowLive holds the default value on creation for the allow_live field. group.DefaultAllowLive = groupDescAllowLive.Default.(bool) // groupDescForceOpenaiFast is the schema descriptor for force_openai_fast field. - groupDescForceOpenaiFast := groupFields[48].Descriptor() + groupDescForceOpenaiFast := groupFields[49].Descriptor() // group.DefaultForceOpenaiFast holds the default value on creation for the force_openai_fast field. group.DefaultForceOpenaiFast = groupDescForceOpenaiFast.Default.(bool) // groupDescFreeOpenaiFast is the schema descriptor for free_openai_fast field. - groupDescFreeOpenaiFast := groupFields[49].Descriptor() + groupDescFreeOpenaiFast := groupFields[50].Descriptor() // group.DefaultFreeOpenaiFast holds the default value on creation for the free_openai_fast field. group.DefaultFreeOpenaiFast = groupDescFreeOpenaiFast.Default.(bool) // groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field. - groupDescRequireOauthOnly := groupFields[50].Descriptor() + groupDescRequireOauthOnly := groupFields[51].Descriptor() // group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field. group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool) // groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field. - groupDescRequirePrivacySet := groupFields[51].Descriptor() + groupDescRequirePrivacySet := groupFields[52].Descriptor() // group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field. group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool) // groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field. - groupDescDefaultMappedModel := groupFields[52].Descriptor() + groupDescDefaultMappedModel := groupFields[53].Descriptor() // group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field. group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string) // group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save. group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error) // groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field. - groupDescMessagesDispatchModelConfig := groupFields[53].Descriptor() + groupDescMessagesDispatchModelConfig := groupFields[54].Descriptor() // group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field. group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig) // groupDescModelAllowlist is the schema descriptor for model_allowlist field. - groupDescModelAllowlist := groupFields[54].Descriptor() + groupDescModelAllowlist := groupFields[55].Descriptor() // group.DefaultModelAllowlist holds the default value on creation for the model_allowlist field. group.DefaultModelAllowlist = groupDescModelAllowlist.Default.(domain.GroupModelAllowlist) // groupDescCodexModelsManifestConfig is the schema descriptor for codex_models_manifest_config field. - groupDescCodexModelsManifestConfig := groupFields[55].Descriptor() + groupDescCodexModelsManifestConfig := groupFields[56].Descriptor() // group.DefaultCodexModelsManifestConfig holds the default value on creation for the codex_models_manifest_config field. group.DefaultCodexModelsManifestConfig = groupDescCodexModelsManifestConfig.Default.(domain.GroupCodexModelsManifestConfig) // groupDescRpmLimit is the schema descriptor for rpm_limit field. - groupDescRpmLimit := groupFields[56].Descriptor() + groupDescRpmLimit := groupFields[57].Descriptor() // group.DefaultRpmLimit holds the default value on creation for the rpm_limit field. group.DefaultRpmLimit = groupDescRpmLimit.Default.(int) // groupDescMaxReasoningEffort is the schema descriptor for max_reasoning_effort field. - groupDescMaxReasoningEffort := groupFields[57].Descriptor() + groupDescMaxReasoningEffort := groupFields[58].Descriptor() // group.DefaultMaxReasoningEffort holds the default value on creation for the max_reasoning_effort field. group.DefaultMaxReasoningEffort = groupDescMaxReasoningEffort.Default.(string) // group.MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save. group.MaxReasoningEffortValidator = groupDescMaxReasoningEffort.Validators[0].(func(string) error) // groupDescMaxReasoningEffortOverLimit is the schema descriptor for max_reasoning_effort_over_limit field. - groupDescMaxReasoningEffortOverLimit := groupFields[58].Descriptor() + groupDescMaxReasoningEffortOverLimit := groupFields[59].Descriptor() // group.DefaultMaxReasoningEffortOverLimit holds the default value on creation for the max_reasoning_effort_over_limit field. group.DefaultMaxReasoningEffortOverLimit = groupDescMaxReasoningEffortOverLimit.Default.(string) // group.MaxReasoningEffortOverLimitValidator is a validator for the "max_reasoning_effort_over_limit" field. It is called by the builders before save. group.MaxReasoningEffortOverLimitValidator = groupDescMaxReasoningEffortOverLimit.Validators[0].(func(string) error) // groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field. - groupDescReasoningEffortMappings := groupFields[59].Descriptor() + groupDescReasoningEffortMappings := groupFields[60].Descriptor() // group.DefaultReasoningEffortMappings holds the default value on creation for the reasoning_effort_mappings field. group.DefaultReasoningEffortMappings = groupDescReasoningEffortMappings.Default.([]domain.ReasoningEffortMapping) // groupDescProfitControlEnabled is the schema descriptor for profit_control_enabled field. - groupDescProfitControlEnabled := groupFields[60].Descriptor() + groupDescProfitControlEnabled := groupFields[61].Descriptor() // group.DefaultProfitControlEnabled holds the default value on creation for the profit_control_enabled field. group.DefaultProfitControlEnabled = groupDescProfitControlEnabled.Default.(bool) // groupDescProfitMinMargin is the schema descriptor for profit_min_margin field. - groupDescProfitMinMargin := groupFields[61].Descriptor() + groupDescProfitMinMargin := groupFields[62].Descriptor() // group.DefaultProfitMinMargin holds the default value on creation for the profit_min_margin field. group.DefaultProfitMinMargin = groupDescProfitMinMargin.Default.(float64) // groupDescProfitSafetyBuffer is the schema descriptor for profit_safety_buffer field. - groupDescProfitSafetyBuffer := groupFields[62].Descriptor() + groupDescProfitSafetyBuffer := groupFields[63].Descriptor() // group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field. group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64) idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin() diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index 2d39e5724ae1..48222c69c552 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -44,6 +44,11 @@ func (Group) Fields() []ent.Field { Optional(). Nillable(). SchemaType(map[string]string{dialect.Postgres: "text"}), + // 名称与描述的界面语言译文 (added by migration 242) + field.JSON("i18n", domain.GroupI18n{}). + Default(domain.GroupI18n{}). + SchemaType(map[string]string{dialect.Postgres: "jsonb"}). + Comment("名称与描述的界面语言译文:语言代码 -> {name, description};缺失的语言沿用 name/description"), field.Float("rate_multiplier"). SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}). Default(1.0), diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index f85506817ff5..ade5ff04535b 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1008,6 +1008,8 @@ type GatewayConfig struct { ProxyProbeResponseReadMaxBytes int64 `mapstructure:"proxy_probe_response_read_max_bytes"` // Gemini 上游响应头调试日志开关(默认关闭,避免高频日志开销) GeminiDebugResponseHeaders bool `mapstructure:"gemini_debug_response_headers"` + // GeminiCachedContentMaxTTLSeconds: Gemini 显式缓存单次可设置的最长有效期(秒),<=0 时按 86400 处理 + GeminiCachedContentMaxTTLSeconds int `mapstructure:"gemini_cached_content_max_ttl_seconds"` // ConnectionPoolIsolation: 上游连接池隔离策略(proxy/account/account_proxy) ConnectionPoolIsolation string `mapstructure:"connection_pool_isolation"` // ForceCodexCLI: 强制将 OpenAI `/v1/responses` 请求按 Codex CLI 处理。 @@ -2106,6 +2108,7 @@ func setDefaults() { "api.openai.com", "api.anthropic.com", "api.kimi.com", + "api.kimi.ai", "api.moonshot.ai", "api.moonshot.cn", "open.bigmodel.cn", @@ -2546,6 +2549,7 @@ func setDefaults() { viper.SetDefault("gateway.models_list_read_max_bytes", DefaultModelsListReadMaxBytes) viper.SetDefault("gateway.proxy_probe_response_read_max_bytes", int64(1024*1024)) viper.SetDefault("gateway.gemini_debug_response_headers", false) + viper.SetDefault("gateway.gemini_cached_content_max_ttl_seconds", 86400) viper.SetDefault("gateway.connection_pool_isolation", ConnectionPoolIsolationAccountProxy) // HTTP 上游连接池配置(针对 5000+ 并发用户优化) viper.SetDefault("gateway.max_idle_conns", 2560) // 最大空闲连接总数(高并发场景可调大) diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 13be1c98f983..6f29d412e732 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" "github.com/spf13/viper" "github.com/stretchr/testify/require" ) @@ -822,6 +823,7 @@ func TestLoadDefaultSecurityToggles(t *testing.T) { wantHosts := []string{ "api.kimi.com", + "api.kimi.ai", "api.moonshot.ai", "api.moonshot.cn", } @@ -834,6 +836,19 @@ func TestLoadDefaultSecurityToggles(t *testing.T) { t.Fatalf("URLAllowlist.UpstreamHosts missing %q; got %v", want, cfg.Security.URLAllowlist.UpstreamHosts) } } + opts := urlvalidator.ValidationOptions{ + AllowedHosts: cfg.Security.URLAllowlist.UpstreamHosts, + RequireAllowlist: true, + AllowPrivate: cfg.Security.URLAllowlist.AllowPrivateHosts, + } + for _, host := range []string{"api.kimi.com", "api.kimi.ai"} { + target := "https://" + host + "/coding/v1/usages" + normalized, err := urlvalidator.ValidateHTTPSURL(target, opts) + require.NoError(t, err) + require.Equal(t, target, normalized) + _, err = urlvalidator.ValidateHTTPSURL("https://"+host+".attacker.example/coding/v1/usages", opts) + require.Error(t, err) + } } func TestLoadDefaultServerMode(t *testing.T) { diff --git a/backend/internal/domain/group_i18n.go b/backend/internal/domain/group_i18n.go new file mode 100644 index 000000000000..c02b1c5a4781 --- /dev/null +++ b/backend/internal/domain/group_i18n.go @@ -0,0 +1,113 @@ +package domain + +import ( + "strconv" + "strings" +) + +// GroupLocaleText 是分组名称与描述在某个界面语言下的译文。 +// 字段为空表示该语言沿用分组自身的 name/description。 +type GroupLocaleText struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` +} + +// GroupI18n 以界面语言代码(如 "en"、"zh")为键保存分组展示文案的译文。 +// 仅用于页面展示;分组的唯一性、搜索与各类日志仍以 name 为准。 +type GroupI18n map[string]GroupLocaleText + +// Localize 返回 name、description 在请求首选语言下的版本;该语言没有译文的字段保持原值。 +// acceptLanguage 是 Accept-Language 请求头。只采用其中优先级最高的语言:原字段的语言 +// 未知,退到次选语言的译文不一定比原字段更合适。 +func (m GroupI18n) Localize(acceptLanguage, name, description string) (string, string) { + text, ok := m.lookup(preferredLanguage(acceptLanguage)) + if !ok { + return name, description + } + if text.Name != "" { + name = text.Name + } + if text.Description != "" { + description = text.Description + } + return name, description +} + +// LocalizeName 是只需要名称时的 Localize。 +func (m GroupI18n) LocalizeName(acceptLanguage, name string) string { + name, _ = m.Localize(acceptLanguage, name, "") + return name +} + +// CanonicalLocale 按 BCP 47 的惯用大小写书写语言代码:主语言小写,4 位文字代码首字母大写, +// 2 位地区代码大写,其余小写(zh-hant-tw -> zh-Hant-TW);单字母扩展标记之后的部分全部小写。 +// 只调整大小写,不校验代码是否合法。 +func CanonicalLocale(locale string) string { + subtags := strings.Split(locale, "-") + extension := false + for i, subtag := range subtags { + subtag = strings.ToLower(subtag) + switch { + case i == 0 || extension: + case len(subtag) == 1: + extension = true + case len(subtag) == 2: + subtag = strings.ToUpper(subtag) + case len(subtag) == 4: + subtag = strings.ToUpper(subtag[:1]) + subtag[1:] + } + subtags[i] = subtag + } + return strings.Join(subtags, "-") +} + +// lookup 先按完整语言代码匹配(zh-TW),再按主语言匹配(zh-CN -> zh),不区分大小写。 +// 写入时语言代码已统一为 CanonicalLocale,同一语言只有一个键;若存有仅大小写不同的多个键, +// 规范写法的键优先,其余按字节序取最小者,保证同一请求语言的结果固定。 +func (m GroupI18n) lookup(tag string) (GroupLocaleText, bool) { + if tag == "" || len(m) == 0 { + return GroupLocaleText{}, false + } + primary, _, _ := strings.Cut(tag, "-") + for _, candidate := range []string{tag, primary} { + candidate = CanonicalLocale(candidate) + if text, ok := m[candidate]; ok { + return text, true + } + matched := "" + for locale := range m { + if strings.EqualFold(locale, candidate) && (matched == "" || locale < matched) { + matched = locale + } + } + if matched != "" { + return m[matched], true + } + } + return GroupLocaleText{}, false +} + +// preferredLanguage 取 Accept-Language 中权重最高的语言代码,权重相同取靠前者; +// 通配符 * 与 q=0 的条目不参与。 +func preferredLanguage(acceptLanguage string) string { + best, bestQ := "", 0.0 + for _, part := range strings.Split(acceptLanguage, ",") { + tag, params, _ := strings.Cut(part, ";") + tag = strings.ReplaceAll(strings.TrimSpace(tag), "_", "-") + if tag == "" || tag == "*" { + continue + } + q := 1.0 + if value, ok := strings.CutPrefix(strings.TrimSpace(params), "q="); ok { + parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil { + continue + } + q = parsed + } + if q > bestQ { + best, bestQ = tag, q + } + } + return best +} diff --git a/backend/internal/domain/group_i18n_test.go b/backend/internal/domain/group_i18n_test.go new file mode 100644 index 000000000000..2860a0a6e349 --- /dev/null +++ b/backend/internal/domain/group_i18n_test.go @@ -0,0 +1,107 @@ +package domain + +import "testing" + +func TestGroupI18nLocalize(t *testing.T) { + i18n := GroupI18n{ + "en": {Name: "Standard", Description: "Pro + Max pool"}, + "zh-TW": {Name: "標準"}, + "ja": {Description: "説明のみ"}, + } + const name, description = "标准", "号池" + + tests := []struct { + title string + acceptLanguage string + wantName string + wantDescription string + }{ + {"exact locale", "en", "Standard", "Pro + Max pool"}, + {"region falls back to the primary language", "en-US", "Standard", "Pro + Max pool"}, + {"locale match ignores case and underscore", "ZH_tw", "標準", description}, + {"a missing field keeps the original text", "ja", name, "説明のみ"}, + {"no translation for the language keeps both", "zh", name, description}, + {"primary language does not match a regional translation", "zh-CN", name, description}, + {"empty header keeps both", "", name, description}, + {"wildcard only keeps both", "*", name, description}, + // 原字段的语言未知,不退到次选语言:浏览器的 zh 用户不应拿到英文译文。 + {"only the preferred language is considered", "zh-CN,zh;q=0.9,en;q=0.8", name, description}, + {"highest weight wins over position", "zh;q=0.5, en;q=0.9", "Standard", "Pro + Max pool"}, + {"equal weight keeps the first", "en, zh", "Standard", "Pro + Max pool"}, + {"entries with q=0 are ignored", "en;q=0, ja", name, "説明のみ"}, + {"malformed weight is skipped", "en;q=abc, ja", name, "説明のみ"}, + } + for _, tt := range tests { + t.Run(tt.title, func(t *testing.T) { + gotName, gotDescription := i18n.Localize(tt.acceptLanguage, name, description) + if gotName != tt.wantName || gotDescription != tt.wantDescription { + t.Fatalf("Localize(%q) = (%q, %q), want (%q, %q)", + tt.acceptLanguage, gotName, gotDescription, tt.wantName, tt.wantDescription) + } + }) + } +} + +func TestGroupI18nLocalize_NilMap(t *testing.T) { + var i18n GroupI18n + + name, description := i18n.Localize("en", "标准", "号池") + + if name != "标准" || description != "号池" { + t.Fatalf("nil translations must keep the original text, got (%q, %q)", name, description) + } + if got := i18n.LocalizeName("en", "标准"); got != "标准" { + t.Fatalf("LocalizeName on nil translations = %q", got) + } +} + +func TestGroupI18nLocalizeName(t *testing.T) { + i18n := GroupI18n{"en": {Name: "Standard"}} + + if got := i18n.LocalizeName("en", "标准"); got != "Standard" { + t.Fatalf("LocalizeName(en) = %q", got) + } + if got := i18n.LocalizeName("zh", "标准"); got != "标准" { + t.Fatalf("LocalizeName(zh) = %q", got) + } +} + +func TestCanonicalLocale(t *testing.T) { + tests := map[string]string{ + "en": "en", + "EN": "en", + "zh-tw": "zh-TW", + "ZH-Hant-tw": "zh-Hant-TW", + "es-419": "es-419", + "de-CH-1901": "de-CH-1901", + "en-US-x-Twain": "en-US-x-twain", + } + for in, want := range tests { + if got := CanonicalLocale(in); got != want { + t.Errorf("CanonicalLocale(%q) = %q, want %q", in, got, want) + } + } +} + +// 写入路径已拒绝仅大小写不同的语言代码;已存在的此类数据读取时也必须得到固定结果。 +func TestGroupI18nLocalize_CaseCollidingKeysAreDeterministic(t *testing.T) { + tests := []struct { + title string + i18n GroupI18n + acceptLanguage string + want string + }{ + {"canonical key wins", GroupI18n{"EN": {Name: "B"}, "en": {Name: "A"}, "En": {Name: "C"}}, "en", "A"}, + {"otherwise the smallest key wins", GroupI18n{"eN": {Name: "B"}, "EN": {Name: "A"}}, "en", "A"}, + {"primary language fallback is deterministic too", GroupI18n{"ZH": {Name: "B"}, "zh": {Name: "A"}}, "zh-CN", "A"}, + } + for _, tt := range tests { + t.Run(tt.title, func(t *testing.T) { + for range 200 { + if got := tt.i18n.LocalizeName(tt.acceptLanguage, "原名"); got != tt.want { + t.Fatalf("LocalizeName(%q) = %q, want %q", tt.acceptLanguage, got, tt.want) + } + } + }) + } +} diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index e35ed66571d9..4fed4b2bb4fb 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -142,6 +142,8 @@ type simpleModeGroupResponse struct { Platform string `json:"platform"` Status string `json:"status"` + I18n service.GroupI18n `json:"i18n,omitempty"` + AccountCount int64 `json:"account_count,omitempty"` ActiveAccountCount int64 `json:"active_account_count,omitempty"` RateLimitedAccountCount int64 `json:"rate_limited_account_count,omitempty"` @@ -157,6 +159,7 @@ func groupForSimpleMode(group *service.Group) *simpleModeGroupResponse { return &simpleModeGroupResponse{ ID: group.ID, Name: group.Name, Description: group.Description, Platform: group.Platform, Status: group.Status, + I18n: group.I18n, AccountCount: group.AccountCount, ActiveAccountCount: group.ActiveAccountCount, RateLimitedAccountCount: group.RateLimitedAccountCount, SortOrder: group.SortOrder, CreatedAt: group.CreatedAt, UpdatedAt: group.UpdatedAt, @@ -167,7 +170,7 @@ func sanitizeCreateGroupRequestForSimpleMode(req *CreateGroupRequest) { if req == nil { return } - allowed := CreateGroupRequest{Name: req.Name, Description: req.Description, Platform: req.Platform} + allowed := CreateGroupRequest{Name: req.Name, Description: req.Description, I18n: req.I18n, Platform: req.Platform} allowed.RateMultiplier = 1 allowed.SubscriptionType = service.SubscriptionTypeStandard *req = allowed @@ -177,13 +180,14 @@ func sanitizeUpdateGroupRequestForSimpleMode(req *UpdateGroupRequest) { if req == nil { return } - *req = UpdateGroupRequest{Name: req.Name, Description: req.Description} + *req = UpdateGroupRequest{Name: req.Name, Description: req.Description, I18n: req.I18n} } // CreateGroupRequest represents create group request type CreateGroupRequest struct { Name string `json:"name" binding:"required"` Description string `json:"description"` + I18n service.GroupI18n `json:"i18n"` // 名称与描述的界面语言译文 Platform string `json:"platform" binding:"omitempty,group_platform"` RateMultiplier float64 `json:"rate_multiplier"` IsExclusive bool `json:"is_exclusive"` @@ -258,6 +262,7 @@ type CreateGroupRequest struct { type UpdateGroupRequest struct { Name string `json:"name"` Description *string `json:"description"` + I18n *service.GroupI18n `json:"i18n"` // 缺省不修改,传入则整体替换 Platform string `json:"platform" binding:"omitempty,group_platform"` RateMultiplier *float64 `json:"rate_multiplier"` IsExclusive *bool `json:"is_exclusive"` @@ -664,6 +669,7 @@ func (h *GroupHandler) Create(c *gin.Context) { group, err := h.adminService.CreateGroup(c.Request.Context(), &service.CreateGroupInput{ Name: req.Name, Description: req.Description, + I18n: req.I18n, Platform: req.Platform, RateMultiplier: req.RateMultiplier, IsExclusive: req.IsExclusive, @@ -809,6 +815,7 @@ func (h *GroupHandler) Update(c *gin.Context) { group, err := h.adminService.UpdateGroup(c.Request.Context(), groupID, &service.UpdateGroupInput{ Name: req.Name, Description: req.Description, + I18n: req.I18n, Platform: req.Platform, RateMultiplier: req.RateMultiplier, IsExclusive: req.IsExclusive, diff --git a/backend/internal/handler/api_key_handler.go b/backend/internal/handler/api_key_handler.go index 6361b97dd600..0bce1e9fe68d 100644 --- a/backend/internal/handler/api_key_handler.go +++ b/backend/internal/handler/api_key_handler.go @@ -142,7 +142,7 @@ func (h *APIKeyHandler) List(c *gin.Context) { out := make([]dto.APIKey, 0, len(keys)) for i := range keys { - out = append(out, *dto.APIKeyFromService(&keys[i])) + out = append(out, *dto.APIKeyFromService(&keys[i]).Localized(c.GetHeader("Accept-Language"))) } response.Paginated(c, out, result.Total, page, pageSize) } @@ -174,7 +174,7 @@ func (h *APIKeyHandler) GetByID(c *gin.Context) { return } - response.Success(c, dto.APIKeyFromService(key)) + response.Success(c, dto.APIKeyFromService(key).Localized(c.GetHeader("Accept-Language"))) } // Create handles creating a new API key @@ -222,7 +222,7 @@ func (h *APIKeyHandler) Create(c *gin.Context) { if err != nil { return nil, err } - return dto.APIKeyFromService(key), nil + return dto.APIKeyFromService(key).Localized(c.GetHeader("Accept-Language")), nil }) } @@ -290,7 +290,7 @@ func (h *APIKeyHandler) Update(c *gin.Context) { return } - response.Success(c, dto.APIKeyFromService(key)) + response.Success(c, dto.APIKeyFromService(key).Localized(c.GetHeader("Accept-Language"))) } // Delete handles deleting an API key @@ -334,7 +334,7 @@ func (h *APIKeyHandler) GetAvailableGroups(c *gin.Context) { out := make([]dto.Group, 0, len(groups)) for i := range groups { - out = append(out, *dto.GroupFromService(&groups[i])) + out = append(out, *dto.GroupFromService(&groups[i]).Localized(c.GetHeader("Accept-Language"))) } response.Success(c, out) } diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go index 46e871a0c321..3d5a22c8ba38 100644 --- a/backend/internal/handler/available_channel_handler.go +++ b/backend/internal/handler/available_channel_handler.go @@ -62,6 +62,9 @@ type userAvailableGroup struct { PeakEnd string `json:"peak_end"` PeakRateMultiplier float64 `json:"peak_rate_multiplier"` IsExclusive bool `json:"is_exclusive"` + + // 不对外输出;List 按请求语言把 Name 换成译文后丢弃。 + i18n service.GroupI18n } // userSupportedModelPricing 用户可见的定价字段白名单。 @@ -154,6 +157,7 @@ func (h *AvailableChannelHandler) List(c *gin.Context) { return } + acceptLanguage := c.GetHeader("Accept-Language") out := make([]userAvailableChannel, 0, len(channels)) for _, ch := range channels { if ch.Status != service.StatusActive { @@ -163,6 +167,7 @@ func (h *AvailableChannelHandler) List(c *gin.Context) { if len(visibleGroups) == 0 { continue } + localizeUserAvailableGroups(visibleGroups, acceptLanguage) sections := buildPlatformSections(ch, visibleGroups) if len(sections) == 0 { continue @@ -177,6 +182,13 @@ func (h *AvailableChannelHandler) List(c *gin.Context) { response.Success(c, out) } +// localizeUserAvailableGroups 把分组名称就地换成请求首选语言的版本。 +func localizeUserAvailableGroups(groups []userAvailableGroup, acceptLanguage string) { + for i := range groups { + groups[i].Name = groups[i].i18n.LocalizeName(acceptLanguage, groups[i].Name) + } +} + // buildPlatformSections 把一个渠道按 visibleGroups 的平台集合拆成有序的 section 列表: // 每个 section 对应一个具体平台,只包含该平台的 groups 和 supported_models。 // @@ -254,6 +266,7 @@ func filterUserVisibleGroups( visible = append(visible, userAvailableGroup{ ID: g.ID, Name: g.Name, + i18n: g.I18n, Platform: g.Platform, SubscriptionType: g.SubscriptionType, RateMultiplier: g.RateMultiplier, diff --git a/backend/internal/handler/dto/group_localize.go b/backend/internal/handler/dto/group_localize.go new file mode 100644 index 000000000000..6a9ce7d8fd1c --- /dev/null +++ b/backend/internal/handler/dto/group_localize.go @@ -0,0 +1,49 @@ +package dto + +// 用户侧接口在输出前调用 Localized:把分组的名称与描述换成请求首选语言 +// (Accept-Language)的版本,并去掉译文表。管理端接口不调用,保留原字段与译文表, +// 分组编辑表单依赖原字段回写。 + +// Localized 就地本地化分组并返回自身。 +func (g *Group) Localized(acceptLanguage string) *Group { + if g == nil { + return nil + } + g.Name, g.Description = g.I18n.Localize(acceptLanguage, g.Name, g.Description) + g.I18n = nil + return g +} + +// Localized 本地化 API Key 所属分组并返回自身。 +func (k *APIKey) Localized(acceptLanguage string) *APIKey { + if k != nil { + k.Group.Localized(acceptLanguage) + } + return k +} + +// Localized 本地化兑换码关联分组并返回自身。 +func (r *RedeemCode) Localized(acceptLanguage string) *RedeemCode { + if r != nil { + r.Group.Localized(acceptLanguage) + } + return r +} + +// Localized 本地化使用记录关联的分组(含其 API Key 与订阅各自携带的分组)并返回自身。 +func (l *UsageLog) Localized(acceptLanguage string) *UsageLog { + if l != nil { + l.Group.Localized(acceptLanguage) + l.APIKey.Localized(acceptLanguage) + l.Subscription.Localized(acceptLanguage) + } + return l +} + +// Localized 本地化订阅关联分组并返回自身。 +func (s *UserSubscription) Localized(acceptLanguage string) *UserSubscription { + if s != nil { + s.Group.Localized(acceptLanguage) + } + return s +} diff --git a/backend/internal/handler/dto/group_localize_test.go b/backend/internal/handler/dto/group_localize_test.go new file mode 100644 index 000000000000..e1b44e50f380 --- /dev/null +++ b/backend/internal/handler/dto/group_localize_test.go @@ -0,0 +1,71 @@ +package dto + +import ( + "encoding/json" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/domain" + "github.com/stretchr/testify/require" +) + +func localizableGroup() *Group { + return &Group{ + ID: 1, Name: "标准", Description: "号池", + I18n: domain.GroupI18n{"en": {Name: "Standard", Description: "Pro + Max pool"}}, + } +} + +func TestGroupLocalized_ReplacesTextAndDropsTranslations(t *testing.T) { + g := localizableGroup().Localized("en") + + require.Equal(t, "Standard", g.Name) + require.Equal(t, "Pro + Max pool", g.Description) + + raw, err := json.Marshal(g) + require.NoError(t, err) + var decoded map[string]any + require.NoError(t, json.Unmarshal(raw, &decoded)) + require.NotContains(t, decoded, "i18n", "user-facing output must not carry the translation table") +} + +func TestGroupLocalized_KeepsOriginalWhenLanguageHasNoTranslation(t *testing.T) { + g := localizableGroup().Localized("zh") + + require.Equal(t, "标准", g.Name) + require.Equal(t, "号池", g.Description) + require.Nil(t, g.I18n) +} + +func TestLocalized_NilSafe(t *testing.T) { + require.Nil(t, (*Group)(nil).Localized("en")) + require.Nil(t, (*APIKey)(nil).Localized("en")) + require.Nil(t, (*RedeemCode)(nil).Localized("en")) + require.Nil(t, (*UsageLog)(nil).Localized("en")) + require.Nil(t, (*UserSubscription)(nil).Localized("en")) + + // 未携带分组的记录原样返回 + require.Nil(t, (&APIKey{}).Localized("en").Group) +} + +func TestLocalized_NestedGroups(t *testing.T) { + key := (&APIKey{Group: localizableGroup()}).Localized("en") + require.Equal(t, "Standard", key.Group.Name) + + code := (&RedeemCode{Group: localizableGroup()}).Localized("en") + require.Equal(t, "Standard", code.Group.Name) + + sub := (&UserSubscription{Group: localizableGroup()}).Localized("en") + require.Equal(t, "Standard", sub.Group.Name) + + // 使用记录除自身分组外,还带着 API Key 与订阅各自的分组 + log := (&UsageLog{ + Group: localizableGroup(), + APIKey: &APIKey{Group: localizableGroup()}, + Subscription: &UserSubscription{Group: localizableGroup()}, + }).Localized("en") + require.Equal(t, "Standard", log.Group.Name) + require.Equal(t, "Standard", log.APIKey.Group.Name) + require.Equal(t, "Standard", log.Subscription.Group.Name) + require.Nil(t, log.APIKey.Group.I18n) + require.Nil(t, log.Subscription.Group.I18n) +} diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 89f8819ed46c..435e29d751f1 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -182,6 +182,7 @@ func groupFromServiceBase(g *service.Group) Group { ID: g.ID, Name: g.Name, Description: g.Description, + I18n: g.I18n, Platform: g.Platform, RateMultiplier: g.RateMultiplier, IsExclusive: g.IsExclusive, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index fc18d690af19..3b40697a22fa 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -99,6 +99,10 @@ type Group struct { IsExclusive bool `json:"is_exclusive"` Status string `json:"status"` + // I18n 名称与描述的各语言译文。只有管理端输出;用户侧接口经 Localized 把 + // name/description 换成请求语言的版本后清空本字段。 + I18n domain.GroupI18n `json:"i18n,omitempty"` + SubscriptionType string `json:"subscription_type"` DailyLimitUSD *float64 `json:"daily_limit_usd"` WeeklyLimitUSD *float64 `json:"weekly_limit_usd"` diff --git a/backend/internal/handler/endpoint.go b/backend/internal/handler/endpoint.go index 82d8279e9046..11ddc065c4b9 100644 --- a/backend/internal/handler/endpoint.go +++ b/backend/internal/handler/endpoint.go @@ -32,6 +32,7 @@ const ( EndpointVideos = "/v1/videos" EndpointSeedanceTasks = "/api/v3/contents/generations/tasks" EndpointGeminiModels = "/v1beta/models" + EndpointGeminiCachedContents = "/v1beta/cachedContents" ) const EndpointAntigravityGenerateContent = "/v1internal:streamGenerateContent" @@ -53,6 +54,7 @@ const ( // "/v1/chat/completions" → "/v1/chat/completions" // "/openai/v1/responses/foo" → "/v1/responses" // "/v1beta/models/gemini:gen" → "/v1beta/models" +// "/v1beta/cachedContents/abc" → "/v1beta/cachedContents" // // The OpenAI Responses API is also exposed via a few bare/alias // routes that do not carry a "/v1/" prefix (top-level bare route and @@ -117,6 +119,8 @@ func NormalizeInboundEndpoint(path string) string { return EndpointResponses case strings.Contains(path, EndpointGeminiModels): return EndpointGeminiModels + case strings.Contains(path, EndpointGeminiCachedContents): + return EndpointGeminiCachedContents default: return path } @@ -194,7 +198,7 @@ func isBareOrSubpathOf(path, root string) bool { // retain their paths. Grok raw Chat requests override this through the // forwarding result consumed by resolveOpenAIUpstreamEndpoint. // - Anthropic → /v1/messages -// - Gemini → /v1beta/models +// - Gemini → /v1beta/models(显式缓存管理接口保留 /v1beta/cachedContents) // - Antigravity → /v1/messages (Claude) or gemini (Gemini) // - Antigravity routes may target either Claude or Gemini, so the // inbound endpoint is used to distinguish. @@ -230,6 +234,9 @@ func DeriveUpstreamEndpoint(inbound, rawRequestPath, platform string) string { return EndpointSystemOne case service.PlatformGemini: + if inbound == EndpointGeminiCachedContents { + return EndpointGeminiCachedContents + } return EndpointGeminiModels case service.PlatformAntigravity: diff --git a/backend/internal/handler/endpoint_test.go b/backend/internal/handler/endpoint_test.go index 6941e5637411..7fbb4aae10b0 100644 --- a/backend/internal/handler/endpoint_test.go +++ b/backend/internal/handler/endpoint_test.go @@ -37,6 +37,8 @@ func TestNormalizeInboundEndpoint(t *testing.T) { {"/v1/videos/generations", EndpointVideosGenerations}, {"/v1/videos/req_123", EndpointVideos}, {"/v1beta/models", EndpointGeminiModels}, + {"/v1beta/cachedContents", EndpointGeminiCachedContents}, + {"/v1beta/cachedContents/gfo2i2haszm3wbwiyivyok5cyddubfwsr77efg7h", EndpointGeminiCachedContents}, // Prefixed paths (antigravity, openai) — root Responses. {"/antigravity/v1/messages", EndpointMessages}, @@ -102,6 +104,7 @@ func TestDeriveUpstreamEndpoint(t *testing.T) { // Gemini. {"gemini models", EndpointGeminiModels, "/v1beta/models/gemini:gen", service.PlatformGemini, EndpointGeminiModels}, + {"gemini cached contents", EndpointGeminiCachedContents, "/v1beta/cachedContents/abc", service.PlatformGemini, EndpointGeminiCachedContents}, // OpenAI — root Responses. {"openai responses root", EndpointResponses, "/v1/responses", service.PlatformOpenAI, EndpointResponses}, diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 2f3ba2f6170b..3862c4369319 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -52,12 +52,14 @@ type GatewayHandler struct { errorPassthroughService *service.ErrorPassthroughService contentModerationService *service.ContentModerationService securityAuditCoordinator *securityaudit.Coordinator - concurrencyHelper *ConcurrencyHelper - userMsgQueueHelper *UserMsgQueueHelper - maxAccountSwitches int - maxAccountSwitchesGemini int - cfg *config.Config - settingService *service.SettingService + // geminiCachedContentService 管理 Gemini 显式缓存;为 nil 时相关接口不可用。 + geminiCachedContentService *service.GeminiCachedContentService + concurrencyHelper *ConcurrencyHelper + userMsgQueueHelper *UserMsgQueueHelper + maxAccountSwitches int + maxAccountSwitchesGemini int + cfg *config.Config + settingService *service.SettingService } // NewGatewayHandler creates a new GatewayHandler diff --git a/backend/internal/handler/gemini_cached_contents_billing_test.go b/backend/internal/handler/gemini_cached_contents_billing_test.go new file mode 100644 index 000000000000..c933421e9b9c --- /dev/null +++ b/backend/internal/handler/gemini_cached_contents_billing_test.go @@ -0,0 +1,955 @@ +//go:build unit + +package handler + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" + middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +const ( + cachedBillingInputPrice = 0.00000125 + cachedBillingStoragePrice = 0.0000045 + cachedBillingAliasStoragePrice = 0.000009 + cachedBillingTokens = 1_000_000 +) + +type cachedBillingRepo struct { + mu sync.Mutex + records map[string]*service.GeminiCachedContent + // mutations 按记录 id 保存修改意图。 + mutations map[int64]cachedBillingMutation + failCommit bool + nextID int64 + deleted []int64 +} + +type cachedBillingMutation struct { + token string + leaseUntil time.Time +} + +func (r *cachedBillingRepo) Create(_ context.Context, record *service.GeminiCachedContent) error { + r.mu.Lock() + defer r.mu.Unlock() + r.nextID++ + record.ID = r.nextID + cp := *record + r.records[record.PublicID] = &cp + return nil +} + +func (r *cachedBillingRepo) GetForOwner(_ context.Context, apiKeyID int64, publicID string) (*service.GeminiCachedContent, error) { + r.mu.Lock() + defer r.mu.Unlock() + record, ok := r.records[publicID] + if !ok || record.APIKeyID != apiKeyID || r.liveLocked(record.ID) == nil { + return nil, service.ErrGeminiCachedContentNotFound + } + cp := *record + return &cp, nil +} + +func (r *cachedBillingRepo) ListForOwner(context.Context, int64, int64, time.Time, int64, int) ([]*service.GeminiCachedContent, error) { + return nil, nil +} + +// liveLocked 返回未删除的记录;调用方须持有 mu。 +func (r *cachedBillingRepo) liveLocked(id int64) *service.GeminiCachedContent { + for _, deleted := range r.deleted { + if deleted == id { + return nil + } + } + for _, record := range r.records { + if record.ID == id { + return record + } + } + return nil +} + +func (r *cachedBillingRepo) BeginMutation(_ context.Context, id int64, baseline time.Time, token string, lease time.Duration) (bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + record := r.liveLocked(id) + if record == nil { + return false, service.ErrGeminiCachedContentNotFound + } + if _, pending := r.mutations[id]; pending || !record.ExpireTime.Equal(baseline) { + return false, nil + } + r.mutations[id] = cachedBillingMutation{token: token, leaseUntil: time.Now().Add(lease)} + return true, nil +} + +func (r *cachedBillingRepo) TakeOverStaleMutation(_ context.Context, id int64, token string, lease time.Duration) (*service.GeminiCachedContent, error) { + r.mu.Lock() + defer r.mu.Unlock() + record := r.liveLocked(id) + mutation, pending := r.mutations[id] + if record == nil || !pending || mutation.leaseUntil.After(time.Now()) { + return nil, nil + } + r.mutations[id] = cachedBillingMutation{token: token, leaseUntil: time.Now().Add(lease)} + cp := *record + return &cp, nil +} + +func (r *cachedBillingRepo) CommitMutation(_ context.Context, id int64, token string, expireTime time.Time) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.failCommit { + return errors.New("db unavailable") + } + record := r.liveLocked(id) + mutation, pending := r.mutations[id] + if record == nil || !pending || mutation.token != token { + return service.ErrGeminiCachedContentMutationConflict + } + record.ExpireTime = expireTime + delete(r.mutations, id) + return nil +} + +func (r *cachedBillingRepo) ReleaseMutation(_ context.Context, id int64, token string) error { + r.mu.Lock() + defer r.mu.Unlock() + if mutation, pending := r.mutations[id]; pending && mutation.token == token { + delete(r.mutations, id) + } + return nil +} + +func (r *cachedBillingRepo) ListStaleMutations(_ context.Context, limit int) ([]int64, error) { + r.mu.Lock() + defer r.mu.Unlock() + var ids []int64 + for id, mutation := range r.mutations { + if len(ids) < limit && mutation.leaseUntil.Before(time.Now()) && r.liveLocked(id) != nil { + ids = append(ids, id) + } + } + return ids, nil +} + +func (r *cachedBillingRepo) SoftDelete(_ context.Context, id int64) error { + r.mu.Lock() + defer r.mu.Unlock() + r.deleted = append(r.deleted, id) + return nil +} + +// pendingMutation 返回记录是否有未清除的修改意图。 +func (r *cachedBillingRepo) pendingMutation(id int64) bool { + r.mu.Lock() + defer r.mu.Unlock() + _, pending := r.mutations[id] + return pending +} + +// expireLease 让记录上的修改意图租约立即过期。 +func (r *cachedBillingRepo) expireLease(id int64) { + r.mu.Lock() + defer r.mu.Unlock() + mutation := r.mutations[id] + mutation.leaseUntil = time.Now().Add(-time.Second) + r.mutations[id] = mutation +} + +type cachedBillingAccountRepo struct { + service.AccountRepository + accounts map[int64]*service.Account +} + +func (r *cachedBillingAccountRepo) GetByID(_ context.Context, id int64) (*service.Account, error) { + account, ok := r.accounts[id] + if !ok { + return nil, service.ErrAccountNotFound + } + cp := *account + return &cp, nil +} + +type cachedBillingUpstream struct { + service.HTTPUpstream + mu sync.Mutex + createBody string + calls []string + // patchTTLs 按顺序记录 PATCH 请求体中的 ttl。 + patchTTLs []string + beforePost func(n int) + // failures 按请求方法返回上游错误响应;transportErrors 按请求方法返回传输层错误(上游结果未知)。 + failures map[string]cachedBillingUpstreamFailure + transportErrors map[string]error +} + +type cachedBillingUpstreamFailure struct { + status int + body string +} + +func (u *cachedBillingUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + var patch struct { + TTL string `json:"ttl"` + } + if req.Method == http.MethodPatch { + _ = json.NewDecoder(req.Body).Decode(&patch) + } + u.mu.Lock() + u.calls = append(u.calls, req.Method+" "+req.URL.Path) + n := len(u.calls) + if req.Method == http.MethodPatch { + u.patchTTLs = append(u.patchTTLs, patch.TTL) + } + failure, failed := u.failures[req.Method] + transportErr := u.transportErrors[req.Method] + u.mu.Unlock() + if transportErr != nil { + return nil, transportErr + } + if failed { + return &http.Response{StatusCode: failure.status, Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(failure.body))}, nil + } + body := "{}" + switch req.Method { + case http.MethodPost: + body = u.createBody + case http.MethodPatch: + if u.beforePost != nil { + u.beforePost(n) + } + seconds, _ := strconv.ParseInt(strings.TrimSuffix(patch.TTL, "s"), 10, 64) + body = fmt.Sprintf(`{"name":"cachedContents/up1","expireTime":%q}`, time.Now().Add(time.Duration(seconds)*time.Second).UTC().Format(time.RFC3339Nano)) + } + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body))}, nil +} + +func (u *cachedBillingUpstream) methodCalls(method string) int { + u.mu.Lock() + defer u.mu.Unlock() + n := 0 + for _, call := range u.calls { + if strings.HasPrefix(call, method+" ") { + n++ + } + } + return n +} + +type cachedBillingUserRepo struct { + service.UserRepository + balance float64 +} + +func (r *cachedBillingUserRepo) GetByID(_ context.Context, id int64) (*service.User, error) { + return &service.User{ID: id, Balance: r.balance}, nil +} + +func (r *cachedBillingUserRepo) DeductBalance(context.Context, int64, float64) error { return nil } + +type cachedBillingUsageRepo struct { + service.UsageLogRepository + mu sync.Mutex + logs []*service.UsageLog +} + +func (r *cachedBillingUsageRepo) Create(_ context.Context, log *service.UsageLog) (bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.logs = append(r.logs, log) + return true, nil +} + +func (r *cachedBillingUsageRepo) first() *service.UsageLog { + r.mu.Lock() + defer r.mu.Unlock() + return r.logs[0] +} + +func (r *cachedBillingUsageRepo) totals() []float64 { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]float64, 0, len(r.logs)) + for _, log := range r.logs { + out = append(out, log.TotalCost) + } + return out +} + +type cachedBillingEnv struct { + h *GatewayHandler + repo *cachedBillingRepo + upstream *cachedBillingUpstream + usage *cachedBillingUsageRepo + apiKey *service.APIKey + group *service.Group + // accounts 是调度快照桩直接返回的账号对象,测试可改其凭据。 + accounts map[int64]*service.Account +} + +func newCachedBillingEnv(t *testing.T, balance float64, accountIDs ...int64) *cachedBillingEnv { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "model_pricing.json"), []byte(fmt.Sprintf(`{"gemini-2.5-pro":{ + "litellm_provider":"gemini","mode":"chat","input_cost_per_token":%g,"output_cost_per_token":0.00001, + "cache_storage_cost_per_token_per_hour":%g},"gemini-alias":{ + "litellm_provider":"gemini","mode":"chat","input_cost_per_token":%g,"output_cost_per_token":0.00001, + "cache_storage_cost_per_token_per_hour":%g}}`, + cachedBillingInputPrice, cachedBillingStoragePrice, cachedBillingInputPrice, cachedBillingAliasStoragePrice)), 0o644)) + cfg := &config.Config{} + cfg.Pricing.DataDir = dir + cfg.Pricing.UpdateIntervalHours = 100000 + cfg.Default.RateMultiplier = 1 + pricing := service.NewPricingService(cfg, nil) + require.NoError(t, pricing.Initialize()) + billing := service.NewBillingService(cfg, pricing) + + groupID := int64(42) + group := &service.Group{ID: groupID, Hydrated: true, Platform: service.PlatformGemini, Status: service.StatusActive, RateMultiplier: 1} + accounts := map[int64]*service.Account{} + var scheduled []*service.Account + for _, id := range accountIDs { + account := &service.Account{ID: id, Platform: service.PlatformGemini, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "k"}, Status: service.StatusActive, Schedulable: true, + Concurrency: 5, Priority: 1, AccountGroups: []service.AccountGroup{{AccountID: id, GroupID: groupID}}} + accounts[id] = account + scheduled = append(scheduled, account) + } + userRepo := &cachedBillingUserRepo{balance: balance} + usage := &cachedBillingUsageRepo{} + bcs := service.NewBillingCacheService(nil, userRepo, nil, nil, nil, nil, cfg, nil) + t.Cleanup(bcs.Stop) + snapshot := service.NewSchedulerSnapshotService(&fakeSchedulerCache{accounts: scheduled}, nil, nil, nil, nil) + gw := service.NewGatewayService(nil, &fakeGroupRepo{group: group}, usage, nil, userRepo, nil, nil, nil, cfg, snapshot, nil, + billing, nil, &service.BillingCacheService{}, nil, nil, &service.DeferredService{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + upstream := &cachedBillingUpstream{} + compat := service.NewGeminiMessagesCompatService(&cachedBillingAccountRepo{accounts: accounts}, nil, nil, nil, nil, nil, upstream, nil, cfg) + repo := &cachedBillingRepo{records: map[string]*service.GeminiCachedContent{}, mutations: map[int64]cachedBillingMutation{}} + h := &GatewayHandler{ + gatewayService: gw, + billingCacheService: bcs, + concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(&fakeConcurrencyCache{}), SSEPingFormatNone, 0), + geminiCachedContentService: service.NewGeminiCachedContentService(repo, compat, cfg), + } + apiKey := &service.APIKey{ID: 7, UserID: 3, GroupID: &groupID, Group: group, Status: service.StatusActive, + User: &service.User{ID: 3, Concurrency: 10, Balance: balance}} + return &cachedBillingEnv{h: h, repo: repo, upstream: upstream, usage: usage, apiKey: apiKey, group: group, accounts: accounts} +} + +// TestGeminiExplicitCacheExclusions_FiltersUnsupportedAccountsFromSnapshot 候选过滤直接按快照里的凭据判定: +// 第三方地址且未声明上游显式缓存的 Key 账号在选号前就被排除,官方地址与声明了上游显式缓存的账号保留。 +func TestGeminiExplicitCacheExclusions_FiltersUnsupportedAccountsFromSnapshot(t *testing.T) { + env := newCachedBillingEnv(t, 500, 900, 901, 902) + env.accounts[901].Credentials["base_url"] = "https://third.example.com" + env.accounts[902].Credentials["base_url"] = "https://gw.example.com" + env.accounts[902].Credentials[service.GeminiExplicitCacheUpstreamCredentialKey] = true + + groupID := env.group.ID + excluded, err := env.h.gatewayService.GeminiExplicitCacheExclusions(context.Background(), &groupID, 0) + require.NoError(t, err) + require.Equal(t, map[int64]struct{}{901: {}}, excluded) + + excluded, err = env.h.gatewayService.GeminiExplicitCacheExclusions(context.Background(), &groupID, 902) + require.NoError(t, err) + require.Equal(t, map[int64]struct{}{900: {}, 901: {}}, excluded, "绑定缓存的请求只保留持有账号") +} + +func (e *cachedBillingEnv) serve(method, target, body string, params gin.Params, call func(*gin.Context)) *httptest.ResponseRecorder { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + req := httptest.NewRequest(method, target, strings.NewReader(body)) + c.Request = req.WithContext(context.WithValue(req.Context(), ctxkey.Group, e.group)) + c.Params = params + c.Set(string(middleware.ContextKeyAPIKey), e.apiKey) + c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: e.apiKey.UserID, Concurrency: 10}) + call(c) + return rec +} + +// waitForUsage 等待异步计费任务落库(无 worker 池时同步执行,直接返回)。 +func (e *cachedBillingEnv) waitForUsage(t *testing.T, n int) []float64 { + t.Helper() + require.Eventually(t, func() bool { return len(e.usage.totals()) >= n }, time.Second, 10*time.Millisecond) + return e.usage.totals() +} + +func (e *cachedBillingEnv) create() *httptest.ResponseRecorder { + return e.serve(http.MethodPost, "/v1beta/cachedContents", + `{"model":"models/gemini-2.5-pro","ttl":"86400s","contents":[{"role":"user","parts":[{"text":"doc"}]}]}`, + nil, e.h.GeminiCachedContentsCreate) +} + +func (e *cachedBillingEnv) seed(expire time.Time) { + e.repo.records["abc"] = &service.GeminiCachedContent{ + ID: 100, PublicID: "abc", UserID: 3, APIKeyID: 7, GroupID: 42, AccountID: 900, + UpstreamName: "cachedContents/up1", Model: "gemini-2.5-pro", UpstreamModel: "models/gemini-2.5-pro", + TotalTokenCount: cachedBillingTokens, ExpireTime: expire, + } +} + +func (e *cachedBillingEnv) patch() *httptest.ResponseRecorder { + return e.serve(http.MethodPatch, "/v1beta/cachedContents/abc?updateMask=ttl", `{"ttl":"86400s"}`, + gin.Params{{Key: "cacheID", Value: "abc"}}, e.h.GeminiCachedContentsPatch) +} + +// TestGeminiCachedContentsPatch_UsesChannelUsageRecordedAtCreate 延长有效期沿用创建时的渠道计费口径: +// 计费模型来源为请求模型时按别名定价,用量记录带渠道 ID。 +func TestGeminiCachedContentsPatch_UsesChannelUsageRecordedAtCreate(t *testing.T) { + env := newCachedBillingEnv(t, 500, 900) + env.seed(time.Now().Add(time.Hour)) + env.repo.records["abc"].ChannelUsage = service.ChannelUsageFields{ + ChannelID: 5, OriginalModel: "gemini-alias", ChannelMappedModel: "gemini-2.5-pro", + BillingModelSource: service.BillingModelSourceRequested, + } + + rec := env.patch() + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + totals := env.waitForUsage(t, 1) + require.Len(t, totals, 1) + require.InDelta(t, cachedBillingTokens*cachedBillingAliasStoragePrice*23, totals[0], 0.01, "按创建时的计费模型(请求模型别名)计存储费") + usageLog := env.usage.first() + require.NotNil(t, usageLog.ChannelID) + require.Equal(t, int64(5), *usageLog.ChannelID) + require.True(t, strings.HasPrefix(usageLog.RequestID, "gcache:patch:abc:"), usageLog.RequestID) +} + +func cachedBillingCreateBody(expire time.Time) string { + return fmt.Sprintf(`{"name":"cachedContents/up1","usageMetadata":{"totalTokenCount":%d},"expireTime":%q}`, + cachedBillingTokens, expire.UTC().Format(time.RFC3339Nano)) +} + +func TestGeminiCachedContentsCreate_ChargesInputAndStorage(t *testing.T) { + env := newCachedBillingEnv(t, 500, 900) + env.upstream.createBody = cachedBillingCreateBody(time.Now().Add(100 * time.Hour)) + + rec := env.create() + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + totals := env.waitForUsage(t, 1) + require.Len(t, totals, 1) + require.InDelta(t, cachedBillingTokens*cachedBillingInputPrice+cachedBillingTokens*cachedBillingStoragePrice*24, totals[0], 1e-9) + require.Len(t, env.repo.records, 1) + for _, record := range env.repo.records { + require.False(t, record.ExpireTime.After(time.Now().Add(24*time.Hour)), "本地到期时间不超过请求的有效期") + require.Equal(t, "gcache:create:"+record.PublicID, env.usage.first().RequestID, "费用行按缓存 ID 可检索") + } +} + +func TestGeminiCachedContentsCreate_UnaffordableStorageDeletesUpstreamAndBillsInputOnly(t *testing.T) { + env := newCachedBillingEnv(t, 1, 900) + env.upstream.createBody = cachedBillingCreateBody(time.Now().Add(24 * time.Hour)) + + rec := env.create() + require.NotEqual(t, http.StatusOK, rec.Code) + require.Contains(t, rec.Body.String(), "error") + require.Equal(t, 1, env.upstream.methodCalls(http.MethodDelete), "删除已创建的上游缓存,避免产生存储费") + require.Empty(t, env.repo.records, "不登记缓存") + totals := env.waitForUsage(t, 1) + require.Len(t, totals, 1) + require.InDelta(t, cachedBillingTokens*cachedBillingInputPrice, totals[0], 1e-9, "只收已发生的缓存 token 输入费") +} + +func TestGeminiCachedContentsCreate_UnusableUpstreamResponseCleansUpWithoutRetry(t *testing.T) { + env := newCachedBillingEnv(t, 500, 900, 901) + env.upstream.createBody = `{"name":"cachedContents/up1","expireTime":"2099-01-01T00:00:00Z"}` + + rec := env.create() + require.Equal(t, http.StatusBadGateway, rec.Code) + require.Equal(t, 1, env.upstream.methodCalls(http.MethodPost), "不换号重建") + require.Equal(t, 1, env.upstream.methodCalls(http.MethodDelete), "删除已创建的上游缓存") + require.Empty(t, env.repo.records) + require.Empty(t, env.usage.totals()) +} + +func (e *cachedBillingEnv) patchTTL(id, ttl string) *httptest.ResponseRecorder { + return e.serve(http.MethodPatch, "/v1beta/cachedContents/"+id+"?updateMask=ttl", fmt.Sprintf(`{"ttl":%q}`, ttl), + gin.Params{{Key: "cacheID", Value: id}}, e.h.GeminiCachedContentsPatch) +} + +// requireTTLNear 断言发往上游的 ttl("Ns")与 want 相差不超过 5 秒。 +func requireTTLNear(t *testing.T, ttl string, want time.Duration) { + t.Helper() + seconds, err := strconv.ParseInt(strings.TrimSuffix(ttl, "s"), 10, 64) + require.NoError(t, err, ttl) + require.InDelta(t, want.Seconds(), float64(seconds), 5, ttl) +} + +// 同一缓存的修改串行执行:第一个 PATCH 的上游调用返回前,第二个 PATCH 直接得到 409 且不调用上游, +// 本地到期时间与上游最后生效的修改一致,只按第一个 PATCH 实际推进的时长收费。 +func TestGeminiCachedContentsPatch_ConcurrentUpdateConflictsAndKeepsUpstreamInSync(t *testing.T) { + env := newCachedBillingEnv(t, 1000, 900) + env.seed(time.Now().Add(time.Hour)) + env.upstream.beforePost = func(n int) { + if n == 1 { + rec := env.patchTTL("abc", "86400s") + require.Equal(t, http.StatusConflict, rec.Code, rec.Body.String()) + require.Contains(t, rec.Body.String(), `"ABORTED"`) + } + } + + rec := env.patchTTL("abc", "7200s") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + require.Equal(t, 1, env.upstream.methodCalls(http.MethodPatch), "冲突的修改不调用上游") + require.WithinDuration(t, time.Now().Add(2*time.Hour), env.repo.records["abc"].ExpireTime, 5*time.Second, "本地到期时间与上游一致") + require.False(t, env.repo.pendingMutation(100)) + totals := env.waitForUsage(t, 1) + require.Len(t, totals, 1) + require.InDelta(t, cachedBillingTokens*cachedBillingStoragePrice, totals[0], cachedBillingTokens*cachedBillingStoragePrice*time.Minute.Hours(), "只收推进的 1 小时") +} + +// 上游已修改但本地写回失败:不计费并保留修改意图;租约内的修改返回 409, +// 租约过期后后台恢复把上游有效期回滚到修改前的到期时间并清除意图。 +func TestGeminiCachedContentsPatch_CommitFailureRollsBackUpstreamWithoutCharge(t *testing.T) { + env := newCachedBillingEnv(t, 1000, 900) + baseline := time.Now().Add(time.Hour) + env.seed(baseline) + env.repo.failCommit = true + + require.Equal(t, http.StatusInternalServerError, env.patch().Code) + require.Equal(t, 1, env.upstream.methodCalls(http.MethodPatch)) + require.True(t, env.repo.pendingMutation(100), "上游可能已修改:保留修改意图") + require.Empty(t, env.usage.totals(), "未提交的修改不收费") + + env.repo.failCommit = false + require.Equal(t, http.StatusConflict, env.patch().Code, "租约内的修改被拒绝") + require.Equal(t, 1, env.upstream.methodCalls(http.MethodPatch)) + + env.repo.expireLease(100) + recovered, err := env.h.geminiCachedContentService.RecoverStaleMutations(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, recovered) + require.Equal(t, 2, env.upstream.methodCalls(http.MethodPatch)) + requireTTLNear(t, env.upstream.patchTTLs[1], time.Until(baseline)) + require.False(t, env.repo.pendingMutation(100)) + require.True(t, env.repo.records["abc"].ExpireTime.Equal(baseline), "本地到期时间保持修改前的值") + require.Empty(t, env.usage.totals()) +} + +// 租约过期的遗留修改意图在下一次修改前先回滚,随后的修改按修改前的到期时间计费。 +func TestGeminiCachedContentsPatch_StaleMutationRolledBackBeforeNextUpdate(t *testing.T) { + env := newCachedBillingEnv(t, 1000, 900) + baseline := time.Now().Add(time.Hour) + env.seed(baseline) + env.repo.failCommit = true + require.Equal(t, http.StatusInternalServerError, env.patch().Code) + env.repo.failCommit = false + env.repo.expireLease(100) + + rec := env.patch() + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + require.Equal(t, 3, env.upstream.methodCalls(http.MethodPatch), "失败的修改、回滚、本次修改") + requireTTLNear(t, env.upstream.patchTTLs[1], time.Until(baseline)) + require.Equal(t, "86400s", env.upstream.patchTTLs[2]) + totals := env.waitForUsage(t, 1) + require.Len(t, totals, 1) + require.InDelta(t, cachedBillingTokens*cachedBillingStoragePrice*23, totals[0], 0.01) +} + +// 上游结果未知(传输层错误)时保留修改意图等待回滚;上游明确拒绝时清除修改意图。两种情况都不收费。 +func TestGeminiCachedContentsPatch_MutationKeptOnlyWhenUpstreamOutcomeUnknown(t *testing.T) { + t.Run("transport error", func(t *testing.T) { + env := newCachedBillingEnv(t, 1000, 900) + env.seed(time.Now().Add(time.Hour)) + env.upstream.transportErrors = map[string]error{http.MethodPatch: errors.New("connection reset")} + + require.Equal(t, http.StatusBadGateway, env.patch().Code) + require.True(t, env.repo.pendingMutation(100)) + require.Empty(t, env.usage.totals()) + }) + t.Run("upstream rejected", func(t *testing.T) { + env := newCachedBillingEnv(t, 1000, 900) + env.seed(time.Now().Add(time.Hour)) + env.upstream.failures = map[string]cachedBillingUpstreamFailure{http.MethodPatch: {status: http.StatusBadRequest, + body: `{"error":{"code":400,"message":"invalid ttl","status":"INVALID_ARGUMENT"}}`}} + + require.Equal(t, http.StatusBadRequest, env.patch().Code) + require.False(t, env.repo.pendingMutation(100)) + require.Empty(t, env.usage.totals()) + }) +} + +// 修改前的到期时间已过:恢复时删除上游缓存与本地记录。 +func TestGeminiCachedContentMutationRecovery_DeletesUpstreamWhenBaselineExpired(t *testing.T) { + env := newCachedBillingEnv(t, 1000, 900) + env.seed(time.Now().Add(-time.Minute)) + env.repo.mutations[100] = cachedBillingMutation{token: "stale", leaseUntil: time.Now().Add(-time.Second)} + + recovered, err := env.h.geminiCachedContentService.RecoverStaleMutations(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, recovered) + require.Equal(t, 1, env.upstream.methodCalls(http.MethodDelete)) + require.Zero(t, env.upstream.methodCalls(http.MethodPatch)) + require.Equal(t, []int64{100}, env.repo.deleted) +} + +// cachedBillingReservationCache 是启用在途预留时的计费缓存桩:余额与订阅用量取固定值,各维度预留按 key 记账。 +type cachedBillingReservationCache struct { + service.BillingCache + mu sync.Mutex + balance float64 + sub *service.SubscriptionCacheData + reserved map[string]map[string]float64 +} + +func (c *cachedBillingReservationCache) GetUserBalance(context.Context, int64) (float64, error) { + return c.balance, nil +} + +func (c *cachedBillingReservationCache) GetSubscriptionCache(context.Context, int64, int64) (*service.SubscriptionCacheData, error) { + return c.sub, nil +} + +func (c *cachedBillingReservationCache) reserveLocked(key, requestID string, amount float64) float64 { + if c.reserved == nil { + c.reserved = map[string]map[string]float64{} + } + if c.reserved[key] == nil { + c.reserved[key] = map[string]float64{} + } + others := 0.0 + for _, v := range c.reserved[key] { + others += v + } + c.reserved[key][requestID] = amount + return others +} + +func (c *cachedBillingReservationCache) ReserveInflightBalance(_ context.Context, userID int64, requestID string, amount, balance float64, _ time.Duration) (bool, float64, error) { + c.mu.Lock() + defer c.mu.Unlock() + key := fmt.Sprintf("user:%d", userID) + others := c.reserveLocked(key, requestID, amount) + if len(c.reserved[key]) > 1 && balance-others < amount { + delete(c.reserved[key], requestID) + return false, others, nil + } + return true, others, nil +} + +func (c *cachedBillingReservationCache) ReleaseInflightBalance(_ context.Context, userID int64, requestID string) error { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.reserved[fmt.Sprintf("user:%d", userID)], requestID) + return nil +} + +func (c *cachedBillingReservationCache) ReserveInflightScope(_ context.Context, scope, requestID string, amount float64, _ time.Duration) (float64, error) { + c.mu.Lock() + defer c.mu.Unlock() + return c.reserveLocked(scope, requestID, amount), nil +} + +func (c *cachedBillingReservationCache) ReleaseInflightScope(_ context.Context, scope, requestID string) error { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.reserved[scope], requestID) + return nil +} + +func enableCachedBillingReservations(t *testing.T, env *cachedBillingEnv, cache *cachedBillingReservationCache) { + t.Helper() + cfg := &config.Config{} + cfg.Billing.InflightReservation.Enabled = true + bcs := service.NewBillingCacheService(cache, nil, nil, nil, nil, nil, cfg, nil) + t.Cleanup(bcs.Stop) + env.h.billingCacheService = bcs +} + +// 确定金额的续期费按 API Key 与余额维度预留:第一笔续期(约 $103.5)尚未入账时, +// 第二个缓存的续期超出 Key 剩余额度或余额,在调用上游前被拒绝。 +func TestGeminiCachedContentsPatch_UnsettledChargeHoldsKeyQuotaAndBalance(t *testing.T) { + for _, tc := range []struct { + name string + balance, quota float64 + }{ + {name: "api key quota", balance: 1000, quota: 110}, + {name: "balance", balance: 110}, + } { + t.Run(tc.name, func(t *testing.T) { + env := newCachedBillingEnv(t, tc.balance, 900) + env.seed(time.Now().Add(time.Hour)) + second := *env.repo.records["abc"] + second.ID, second.PublicID, second.UpstreamName = 101, "def", "cachedContents/up2" + env.repo.records["def"] = &second + env.apiKey.Quota = tc.quota + enableCachedBillingReservations(t, env, &cachedBillingReservationCache{balance: tc.balance}) + env.upstream.beforePost = func(n int) { + if n == 1 { + rec := env.patchTTL("def", "86400s") + require.NotEqual(t, http.StatusOK, rec.Code, rec.Body.String()) + } + } + + require.Equal(t, http.StatusOK, env.patch().Code) + require.Equal(t, 1, env.upstream.methodCalls(http.MethodPatch), "第二笔续期没有到达上游") + }) + } +} + +// 订阅模式的确定金额费用按订阅维度预留:第一笔未入账时第二笔超出日剩余额度被拒绝,释放后放行。 +func TestReserveGeminiCachedContentCharge_UnsettledChargeHoldsSubscriptionHeadroom(t *testing.T) { + env := newCachedBillingEnv(t, 1000, 900) + limit := 110.0 + env.group.SubscriptionType = service.SubscriptionTypeSubscription + env.group.DailyLimitUSD = &limit + enableCachedBillingReservations(t, env, &cachedBillingReservationCache{balance: 1000, + sub: &service.SubscriptionCacheData{Status: service.SubscriptionStatusActive, ExpiresAt: time.Now().Add(time.Hour)}}) + sub := &service.UserSubscription{ID: 1, UserID: env.apiKey.UserID, GroupID: env.group.ID, Status: service.SubscriptionStatusActive, + ExpiresAt: time.Now().Add(time.Hour)} + reserve := func() (func(), error) { + var done func() + var err error + env.serve(http.MethodPatch, "/", `{}`, nil, func(c *gin.Context) { + done, err = env.h.reserveGeminiCachedContentCharge(c, env.apiKey, sub, 103.5) + }) + return done, err + } + + firstDone, err := reserve() + require.NoError(t, err) + _, err = reserve() + require.ErrorIs(t, err, service.ErrDailyLimitExceeded) + firstDone() + secondDone, err := reserve() + require.NoError(t, err) + secondDone() +} + +func TestGeminiCachedContentsPatch_UnaffordableExtensionRejectedBeforeUpstream(t *testing.T) { + env := newCachedBillingEnv(t, 1, 900) + env.seed(time.Now().Add(time.Hour)) + + rec := env.patch() + require.NotEqual(t, http.StatusOK, rec.Code) + require.Zero(t, env.upstream.methodCalls(http.MethodPatch), "余额不足以支付延长部分时不调用上游") + require.Empty(t, env.usage.totals()) +} + +// 上游对缓存不存在的响应:AI Studio 403;Vertex 缓存资源接口 404,文案含上游缓存数字 ID。 +var cachedBillingUpstreamNotFoundCases = []struct { + name string + failure cachedBillingUpstreamFailure +}{ + {name: "ai studio 403", failure: cachedBillingUpstreamFailure{status: http.StatusForbidden, + body: `{"error":{"code":403,"message":"CachedContent not found (or permission denied)","status":"PERMISSION_DENIED"}}`}}, + {name: "vertex 404", failure: cachedBillingUpstreamFailure{status: http.StatusNotFound, + body: `{"error":{"code":404,"message":"Cached content 8079767076522164224 is not found.","status":"NOT_FOUND"}}`}}, +} + +func TestGeminiCachedContentsPatch_UpstreamNotFoundForgetsAndReturnsStandardForbidden(t *testing.T) { + for _, tc := range cachedBillingUpstreamNotFoundCases { + t.Run(tc.name, func(t *testing.T) { + env := newCachedBillingEnv(t, 1000, 900) + env.seed(time.Now().Add(time.Hour)) + env.upstream.failures = map[string]cachedBillingUpstreamFailure{http.MethodPatch: tc.failure} + + rec := env.patch() + require.Equal(t, http.StatusForbidden, rec.Code) + require.Equal(t, service.GeminiCachedContentNotFoundResponse, rec.Body.String()) + require.NotContains(t, rec.Body.String(), "8079767076522164224", "不应暴露上游缓存 ID") + require.Equal(t, []int64{100}, env.repo.deleted, "上游已不存在的缓存应从本地移除") + require.Empty(t, env.usage.totals(), "上游未延长不收费") + }) + } +} + +func TestGeminiCachedContentsDelete_UpstreamNotFoundStillForgets(t *testing.T) { + for _, tc := range cachedBillingUpstreamNotFoundCases { + t.Run(tc.name, func(t *testing.T) { + env := newCachedBillingEnv(t, 1000, 900) + env.seed(time.Now().Add(time.Hour)) + env.upstream.failures = map[string]cachedBillingUpstreamFailure{http.MethodDelete: tc.failure} + + rec := env.serve(http.MethodDelete, "/v1beta/cachedContents/abc", "", + gin.Params{{Key: "cacheID", Value: "abc"}}, env.h.GeminiCachedContentsDelete) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + require.JSONEq(t, `{}`, rec.Body.String()) + require.Equal(t, []int64{100}, env.repo.deleted) + }) + } +} + +// 持有账号暂时不支持显式缓存(能力被关闭)时,恢复流程不能把修改意图当作已回滚而清除; +// 能力恢复后再回滚上游并清除意图。 +func TestGeminiCachedContentMutationRecovery_KeepsIntentWhileAccountCannotCache(t *testing.T) { + env := newCachedBillingEnv(t, 1000, 900) + env.accounts[900].Credentials["base_url"] = "https://gw.example.com" + env.accounts[900].Credentials[service.GeminiExplicitCacheUpstreamCredentialKey] = true + baseline := time.Now().Add(time.Hour) + env.seed(baseline) + env.repo.failCommit = true + require.Equal(t, http.StatusInternalServerError, env.patch().Code) + require.True(t, env.repo.pendingMutation(100)) + require.Equal(t, 1, env.upstream.methodCalls(http.MethodPatch)) + env.repo.failCommit = false + env.repo.expireLease(100) + + env.accounts[900].Credentials[service.GeminiExplicitCacheUpstreamCredentialKey] = false + recovered, err := env.h.geminiCachedContentService.RecoverStaleMutations(context.Background()) + require.Error(t, err, "账号暂不支持缓存时恢复失败") + require.Zero(t, recovered) + require.True(t, env.repo.pendingMutation(100), "未回滚上游前保留修改意图") + require.Equal(t, 1, env.upstream.methodCalls(http.MethodPatch), "不改动上游") + require.Empty(t, env.repo.deleted) + + env.accounts[900].Credentials[service.GeminiExplicitCacheUpstreamCredentialKey] = true + env.repo.expireLease(100) + recovered, err = env.h.geminiCachedContentService.RecoverStaleMutations(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, recovered) + require.Equal(t, 2, env.upstream.methodCalls(http.MethodPatch)) + requireTTLNear(t, env.upstream.patchTTLs[1], time.Until(baseline)) + require.False(t, env.repo.pendingMutation(100)) + require.True(t, env.repo.records["abc"].ExpireTime.Equal(baseline)) + require.Empty(t, env.usage.totals()) +} + +// 持有账号长期不支持显式缓存,且修改前到期时间加上单次最长有效期都已过去:上游缓存必已自行过期, +// 恢复流程不再保留意图,直接移除本地记录。 +func TestGeminiCachedContentMutationRecovery_AbandonsIntentOnceUpstreamMustHaveExpired(t *testing.T) { + env := newCachedBillingEnv(t, 1000, 900) + env.accounts[900].Credentials["base_url"] = "https://gw.example.com" + env.accounts[900].Credentials[service.GeminiExplicitCacheUpstreamCredentialKey] = true + env.seed(time.Now().Add(time.Hour)) + env.repo.failCommit = true + require.Equal(t, http.StatusInternalServerError, env.patch().Code) + env.repo.failCommit = false + env.repo.expireLease(100) + env.accounts[900].Credentials[service.GeminiExplicitCacheUpstreamCredentialKey] = false + + // 修改前到期时间 + 24h 上限 + 1h 容忍仍未过去:保留意图。 + env.repo.records["abc"].ExpireTime = time.Now().Add(-20 * time.Hour) + recovered, err := env.h.geminiCachedContentService.RecoverStaleMutations(context.Background()) + require.Error(t, err) + require.Zero(t, recovered) + require.True(t, env.repo.pendingMutation(100)) + require.Empty(t, env.repo.deleted) + + env.repo.expireLease(100) + env.repo.records["abc"].ExpireTime = time.Now().Add(-26 * time.Hour) + recovered, err = env.h.geminiCachedContentService.RecoverStaleMutations(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, recovered) + require.Equal(t, 1, env.upstream.methodCalls(http.MethodPatch), "不改动上游") + require.Zero(t, env.upstream.methodCalls(http.MethodDelete)) + require.Equal(t, []int64{100}, env.repo.deleted) + require.Empty(t, env.usage.totals()) + + recovered, err = env.h.geminiCachedContentService.RecoverStaleMutations(context.Background()) + require.NoError(t, err) + require.Zero(t, recovered, "作废的记录不再被选中") +} + +// 作废判定的边界:默认上限 24h 加 1h 容忍;配置上限低于 24h 时仍按 24h 兜底。 +func TestGeminiCachedContentMutationRecovery_AbandonBoundary(t *testing.T) { + disableCache := func(env *cachedBillingEnv) { + env.accounts[900].Credentials["base_url"] = "https://gw.example.com" + env.accounts[900].Credentials[service.GeminiExplicitCacheUpstreamCredentialKey] = false + } + t.Run("within grace keeps intent", func(t *testing.T) { + env := newCachedBillingEnv(t, 1000, 900) + env.seed(time.Now().Add(-25*time.Hour + time.Minute)) + env.repo.mutations[100] = cachedBillingMutation{token: "stale", leaseUntil: time.Now().Add(-time.Second)} + disableCache(env) + + recovered, err := env.h.geminiCachedContentService.RecoverStaleMutations(context.Background()) + require.Error(t, err) + require.Zero(t, recovered) + require.True(t, env.repo.pendingMutation(100)) + require.Empty(t, env.repo.deleted) + }) + t.Run("configured max ttl below default still waits a day", func(t *testing.T) { + env := newCachedBillingEnv(t, 1000, 900) + env.seed(time.Now().Add(-12 * time.Hour)) + env.repo.mutations[100] = cachedBillingMutation{token: "stale", leaseUntil: time.Now().Add(-time.Second)} + disableCache(env) + cfg := &config.Config{} + cfg.Gateway.GeminiCachedContentMaxTTLSeconds = 3600 + compat := service.NewGeminiMessagesCompatService(&cachedBillingAccountRepo{accounts: env.accounts}, nil, nil, nil, nil, nil, env.upstream, nil, cfg) + svc := service.NewGeminiCachedContentService(env.repo, compat, cfg) + + recovered, err := svc.RecoverStaleMutations(context.Background()) + require.Error(t, err) + require.Zero(t, recovered) + require.True(t, env.repo.pendingMutation(100)) + + env.repo.expireLease(100) + env.repo.records["abc"].ExpireTime = time.Now().Add(-26 * time.Hour) + recovered, err = svc.RecoverStaleMutations(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, recovered) + require.Equal(t, []int64{100}, env.repo.deleted) + }) +} + +// 持有账号可用但上游回滚持续失败(如 Key 已失效):上游缓存必已自行过期后不再重试,移除本地记录。 +func TestGeminiCachedContentMutationRecovery_AbandonsIntentWhenRollbackKeepsFailing(t *testing.T) { + env := newCachedBillingEnv(t, 1000, 900) + env.seed(time.Now().Add(-time.Minute)) + env.repo.mutations[100] = cachedBillingMutation{token: "stale", leaseUntil: time.Now().Add(-time.Second)} + env.upstream.failures = map[string]cachedBillingUpstreamFailure{http.MethodDelete: {status: http.StatusUnauthorized, + body: `{"error":{"code":401,"message":"API key not valid","status":"UNAUTHENTICATED"}}`}} + + recovered, err := env.h.geminiCachedContentService.RecoverStaleMutations(context.Background()) + require.Error(t, err, "上游缓存可能仍存活:保留意图等待重试") + require.Zero(t, recovered) + require.Equal(t, 1, env.upstream.methodCalls(http.MethodDelete)) + require.True(t, env.repo.pendingMutation(100)) + require.Empty(t, env.repo.deleted) + + env.repo.expireLease(100) + env.repo.records["abc"].ExpireTime = time.Now().Add(-26 * time.Hour) + recovered, err = env.h.geminiCachedContentService.RecoverStaleMutations(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, recovered) + require.Equal(t, 2, env.upstream.methodCalls(http.MethodDelete), "账号可用时仍先尝试回滚") + require.Equal(t, []int64{100}, env.repo.deleted) + require.Empty(t, env.usage.totals()) +} + +// 持有账号已删除时没有凭据可回滚上游:恢复流程移除本地记录并清除意图,不再重试。 +func TestGeminiCachedContentMutationRecovery_ForgetsRecordWhenAccountDeleted(t *testing.T) { + env := newCachedBillingEnv(t, 1000, 900) + env.seed(time.Now().Add(time.Hour)) + env.repo.failCommit = true + require.Equal(t, http.StatusInternalServerError, env.patch().Code) + require.True(t, env.repo.pendingMutation(100)) + env.repo.failCommit = false + env.repo.expireLease(100) + + delete(env.accounts, 900) + recovered, err := env.h.geminiCachedContentService.RecoverStaleMutations(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, recovered) + require.Equal(t, 1, env.upstream.methodCalls(http.MethodPatch), "无凭据,不改动上游") + require.Zero(t, env.upstream.methodCalls(http.MethodDelete)) + require.Equal(t, []int64{100}, env.repo.deleted, "本地记录作废") + require.Empty(t, env.usage.totals()) + require.Equal(t, http.StatusForbidden, env.serve(http.MethodGet, "/v1beta/cachedContents/abc", "", gin.Params{{Key: "cacheID", Value: "abc"}}, env.h.GeminiCachedContentsGet).Code, "作废的记录对客户端不可见") + + recovered, err = env.h.geminiCachedContentService.RecoverStaleMutations(context.Background()) + require.NoError(t, err) + require.Zero(t, recovered, "不再重复处理") +} diff --git a/backend/internal/handler/gemini_cached_contents_handler.go b/backend/internal/handler/gemini_cached_contents_handler.go new file mode 100644 index 000000000000..2071c1612936 --- /dev/null +++ b/backend/internal/handler/gemini_cached_contents_handler.go @@ -0,0 +1,848 @@ +package handler + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// geminiCachedContentCreateMaxAttempts 限制创建缓存时实际发往上游的账号数。 +const geminiCachedContentCreateMaxAttempts = 3 + +// geminiCachedContentPersistTimeout 是上游操作完成后落库的超时;落库与客户端连接脱钩。 +const geminiCachedContentPersistTimeout = 15 * time.Second + +const geminiCachedContentUnavailableMessage = "The account holding this cached content is temporarily unavailable" + +// geminiCachedContentsPreamble 校验显式缓存管理接口的公共前置条件:gemini 分组、非 composite 分组、功能已装配。 +func (h *GatewayHandler) geminiCachedContentsPreamble(c *gin.Context, component string) (*service.APIKey, middleware.AuthSubject, *zap.Logger, bool) { + apiKey, ok := middleware.GetAPIKeyFromContext(c) + if !ok || apiKey == nil { + googleError(c, http.StatusUnauthorized, "Invalid API key") + return nil, middleware.AuthSubject{}, nil, false + } + authSubject, ok := middleware.GetAuthSubjectFromContext(c) + if !ok { + googleError(c, http.StatusInternalServerError, "User context not found") + return nil, middleware.AuthSubject{}, nil, false + } + if h.geminiCachedContentService == nil { + googlePlatformError(c, http.StatusNotFound, "Explicit context caching is not enabled") + return nil, middleware.AuthSubject{}, nil, false + } + if apiKey.Group == nil || apiKey.GroupID == nil || apiKey.Group.Platform == service.PlatformComposite { + googleError(c, http.StatusBadRequest, "Explicit context caching is not supported for this API key group") + return nil, middleware.AuthSubject{}, nil, false + } + if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformGemini { + googleError(c, http.StatusBadRequest, "API key group platform is not gemini") + return nil, middleware.AuthSubject{}, nil, false + } + reqLog := requestLogger( + c, + component, + zap.Int64("user_id", authSubject.UserID), + zap.Int64("api_key_id", apiKey.ID), + zap.Any("group_id", apiKey.GroupID), + ) + return apiKey, authSubject, reqLog, true +} + +func readGeminiCachedContentBody(c *gin.Context) ([]byte, bool) { + body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) + if err != nil { + if maxErr, ok := extractMaxBytesError(err); ok { + googleError(c, http.StatusRequestEntityTooLarge, buildBodyTooLargeMessage(maxErr.Limit)) + return nil, false + } + googleError(c, http.StatusBadRequest, "Failed to read request body") + return nil, false + } + if len(body) == 0 { + googleError(c, http.StatusBadRequest, "Request body is empty") + return nil, false + } + return body, true +} + +func (h *GatewayHandler) checkGeminiCachedContentBilling(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, subscription *service.UserSubscription) bool { + if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil { + reqLog.Info("gemini.cached_content.billing_eligibility_check_failed", zap.Error(err)) + status, code, message, retryAfter := billingErrorDetails(err) + if retryAfter > 0 { + c.Header("Retry-After", strconv.Itoa(retryAfter)) + } + googleErrorWithType(c, status, code, "", message) + return false + } + return true +} + +// GeminiCachedContentsCreate POST /v1beta/cachedContents +// 创建请求内一次性收取缓存 token 的输入费与整个有效期的存储费。 +func (h *GatewayHandler) GeminiCachedContentsCreate(c *gin.Context) { + apiKey, authSubject, reqLog, ok := h.geminiCachedContentsPreamble(c, "handler.gemini_v1beta.cached_contents.create") + if !ok { + return + } + svc := h.geminiCachedContentService + + body, ok := readGeminiCachedContentBody(c) + if !ok { + return + } + req, err := service.ParseGeminiCachedContentCreateRequest(body, svc.Now(), svc.MaxTTL()) + if err != nil { + googleError(c, http.StatusBadRequest, err.Error()) + return + } + modelName := req.Model + reqLog = reqLog.With(zap.String("model", modelName)) + + setOpsRequestContext(c, modelName, false) + setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(false, false))) + pricingCtx, pricingAt := service.WithGatewayTokenRequestPricing(c.Request.Context()) + c.Request = c.Request.WithContext(pricingCtx) + + if decision := h.checkSecurityAudit(c, reqLog, apiKey, authSubject, service.ContentModerationProtocolGemini, modelName, body); decision != nil && !decision.AllowNextStage { + googleSecurityAuditError(c, decision) + return + } + + channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, modelName) + reqModel := modelName + if channelMapping.Mapped { + modelName = channelMapping.MappedModel + } + + subscription, _ := middleware.GetSubscriptionFromContext(c) + geminiConcurrency := NewConcurrencyHelper(h.concurrencyHelper.concurrencyService, SSEPingFormatNone, 0) + streamStarted := false + userReleaseFunc, err := geminiConcurrency.AcquireUserSlotWithWait(c, authSubject.UserID, authSubject.Concurrency, false, &streamStarted) + if err != nil { + reqLog.Warn("gemini.cached_content.user_slot_acquire_failed", zap.Error(err)) + googleConcurrencyError(c, err, "user") + return + } + userReleaseFunc = wrapReleaseOnDone(c.Request.Context(), userReleaseFunc) + if userReleaseFunc != nil { + defer userReleaseFunc() + } + + if !h.checkGeminiCachedContentBilling(c, reqLog, apiKey, subscription) { + return + } + inflightRelease, err := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(modelName, body)) + if err != nil { + reqLog.Info("gemini.cached_content.inflight_reservation_rejected", zap.Error(err)) + status, code, message, retryAfter := billingErrorDetails(err) + if retryAfter > 0 { + c.Header("Retry-After", strconv.Itoa(retryAfter)) + } + googleErrorWithType(c, status, code, "", message) + return + } + defer inflightRelease() + + excluded, err := h.gatewayService.GeminiExplicitCacheExclusions(c.Request.Context(), apiKey.GroupID, 0) + if err != nil { + reqLog.Warn("gemini.cached_content.list_accounts_failed", zap.Error(err)) + googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts support explicit context caching") + return + } + + var lastUpstreamErr *service.GeminiCachedContentUpstreamError + var ttlRejectedLimit time.Duration + for attempts := 0; attempts < geminiCachedContentCreateMaxAttempts; { + selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, "", modelName, excluded, "", int64(0)) + if err != nil { + if failoverClientGone(c) { + return + } + reqLog.Info("gemini.cached_content.account_select_exhausted", zap.Error(err)) + break + } + account := selection.Account + if !service.IsGeminiExplicitCacheAccount(account) { + if selection.Acquired && selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + excluded[account.ID] = struct{}{} + continue + } + ttl, ttlAllowed := req.TTLForAccount(account) + if !ttlAllowed { + if selection.Acquired && selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + excluded[account.ID] = struct{}{} + ttlRejectedLimit = max(ttlRejectedLimit, account.GeminiExplicitCacheUpstreamMaxTTL()) + continue + } + accountReleaseFunc, ok := h.acquireGeminiCachedContentAccountSlot(c, reqLog, geminiConcurrency, selection) + if !ok { + return + } + admissionCtx := service.ContextWithSelectionProfitGate(c.Request.Context(), selection) + latest, vetoed, reason := h.gatewayService.GatewayProfitControlVetoLatest(admissionCtx, account) + if vetoed { + accountReleaseFunc() + reqLog.Debug("gemini.cached_content.account_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason)) + excluded[account.ID] = struct{}{} + continue + } + account = latest + attempts++ + setOpsSelectedAccount(c, account.ID, account.Platform) + + mappedModel := account.GetMappedModel(modelName) + usageFields := clientRequestedUsageFields(c, channelMapping, reqModel, mappedModel) + if !h.gatewayService.CacheStoragePriced(c.Request.Context(), &service.RecordUsageInput{ + Result: &service.ForwardResult{Model: modelName, UpstreamModel: mappedModel, CacheStorageTokenHours: 1}, + APIKey: apiKey, + ChannelUsageFields: usageFields, + }) { + accountReleaseFunc() + googlePlatformError(c, http.StatusBadRequest, fmt.Sprintf("Explicit context caching is not available for model %s: storage pricing is not configured", modelName)) + return + } + upstreamModel, err := service.GeminiCachedContentUpstreamModelResource(account, mappedModel) + if err != nil { + accountReleaseFunc() + reqLog.Warn("gemini.cached_content.upstream_model_invalid", zap.Int64("account_id", account.ID), zap.Error(err)) + excluded[account.ID] = struct{}{} + continue + } + publicID, err := service.NewGeminiCachedContentPublicID() + if err != nil { + accountReleaseFunc() + googleError(c, http.StatusInternalServerError, "Failed to allocate cached content id") + return + } + + upstream, err := svc.CreateUpstream(c.Request.Context(), account, req, mappedModel, publicID, ttl) + accountReleaseFunc() + if err != nil { + var upErr *service.GeminiCachedContentUpstreamError + if errors.As(err, &upErr) { + reqLog.Info("gemini.cached_content.upstream_create_failed", zap.Int64("account_id", account.ID), zap.Int("status", upErr.StatusCode)) + if upErr.RetryableOnOtherAccount() { + lastUpstreamErr = upErr + excluded[account.ID] = struct{}{} + continue + } + h.writeGeminiCachedContentUpstreamError(c, upErr) + return + } + // 上游已返回成功但响应不可用:不换号重建,能识别出资源名时删除上游缓存。 + var malformed *service.GeminiCachedContentMalformedResponseError + if errors.As(err, &malformed) { + reqLog.Error("gemini.cached_content.upstream_create_malformed", zap.Int64("account_id", account.ID), zap.Error(err)) + if malformed.Name != "" { + h.cleanupGeminiCachedContentUpstream(reqLog, account, malformed.Name) + } + googleError(c, http.StatusBadGateway, "Upstream returned an invalid cached content response") + return + } + if failoverClientGone(c) { + return + } + reqLog.Warn("gemini.cached_content.upstream_create_error", zap.Int64("account_id", account.ID), zap.Error(err)) + excluded[account.ID] = struct{}{} + continue + } + + // 缓存 token 数在上游创建后才确定:按确定金额校验余额 / 额度并预留,不足时删除上游缓存, + // 只收取已发生的缓存 token 输入费。 + tokens := upstream.TotalTokenCount + storageTokenHours := service.GeminiCachedContentStorageTokenHours(tokens, ttl) + amount := h.gatewayService.QuoteUsageCost(c.Request.Context(), &service.RecordUsageInput{ + Result: &service.ForwardResult{ + Model: modelName, UpstreamModel: mappedModel, + Usage: service.ClaudeUsage{InputTokens: int(tokens)}, CacheStorageTokenHours: storageTokenHours, + }, + APIKey: apiKey, User: apiKey.User, Subscription: subscription, PricingAt: pricingAt, ChannelUsageFields: usageFields, + }) + inflightRelease() + chargeDone, err := h.reserveGeminiCachedContentCharge(c, apiKey, subscription, amount) + if err != nil { + reqLog.Info("gemini.cached_content.storage_charge_rejected", zap.Float64("amount", amount), zap.Error(err)) + h.cleanupGeminiCachedContentUpstream(reqLog, account, upstream.Name) + h.submitGeminiCachedContentUsage(c, apiKey, account, subscription, pricingAt, + service.GeminiCachedContentCreateUsageRequestID(publicID), modelName, mappedModel, int(tokens), 0, usageFields, body) + writeGeminiCachedContentBillingError(c, err) + return + } + defer chargeDone() + + expireTime := service.ClampGeminiCachedContentExpire(upstream.ExpireTime, svc.Now().Add(ttl)) + record := &service.GeminiCachedContent{ + PublicID: publicID, + UserID: authSubject.UserID, + APIKeyID: apiKey.ID, + GroupID: *apiKey.GroupID, + AccountID: account.ID, + UpstreamName: upstream.Name, + Model: modelName, + UpstreamModel: upstreamModel, + DisplayName: req.DisplayName, + TotalTokenCount: tokens, + ExpireTime: expireTime, + ChannelUsage: usageFields, + } + persistCtx, cancelPersist := geminiCachedContentPersistContext(c) + err = svc.Save(persistCtx, record) + cancelPersist() + if err != nil { + reqLog.Error("gemini.cached_content.save_failed", zap.Int64("account_id", account.ID), zap.Error(err)) + h.cleanupGeminiCachedContentUpstream(reqLog, account, record.UpstreamName) + googleError(c, http.StatusInternalServerError, "Failed to persist cached content") + return + } + + h.submitGeminiCachedContentUsage(c, apiKey, account, subscription, pricingAt, + service.GeminiCachedContentCreateUsageRequestID(publicID), modelName, mappedModel, int(tokens), storageTokenHours, usageFields, body) + c.JSON(http.StatusOK, service.GeminiCachedContentView(record)) + return + } + + if lastUpstreamErr != nil { + h.writeGeminiCachedContentUpstreamError(c, lastUpstreamErr) + return + } + if ttlRejectedLimit > 0 { + googleError(c, http.StatusBadRequest, fmt.Sprintf("ttl exceeds the maximum allowed %ds by the upstream accounts", int64(ttlRejectedLimit.Seconds()))) + return + } + markOpsRoutingCapacityLimited(c) + googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts support explicit context caching") +} + +// GeminiCachedContentsList GET /v1beta/cachedContents +// 只返回当前 API Key 在当前分组下仍有效的缓存,不透传上游 list(同一账号上其他租户的缓存不可见)。 +func (h *GatewayHandler) GeminiCachedContentsList(c *gin.Context) { + apiKey, _, reqLog, ok := h.geminiCachedContentsPreamble(c, "handler.gemini_v1beta.cached_contents.list") + if !ok { + return + } + pageSize := service.GeminiCachedContentListDefaultPageSize + if raw := strings.TrimSpace(c.Query("pageSize")); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n < 0 { + googleError(c, http.StatusBadRequest, "pageSize must be a non-negative integer") + return + } + if n > 0 { + pageSize = min(n, service.GeminiCachedContentListMaxPageSize) + } + } + beforeID := int64(0) + if raw := strings.TrimSpace(c.Query("pageToken")); raw != "" { + id, ok := decodeGeminiCachedContentPageToken(raw) + if !ok { + googleError(c, http.StatusBadRequest, "Invalid pageToken") + return + } + beforeID = id + } + records, err := h.geminiCachedContentService.List(c.Request.Context(), apiKey.ID, *apiKey.GroupID, beforeID, pageSize+1) + if err != nil { + reqLog.Error("gemini.cached_content.list_failed", zap.Error(err)) + googleError(c, http.StatusInternalServerError, "Failed to list cached contents") + return + } + resp := gin.H{} + if len(records) > pageSize { + records = records[:pageSize] + resp["nextPageToken"] = encodeGeminiCachedContentPageToken(records[len(records)-1].ID) + } + if len(records) > 0 { + views := make([]map[string]any, 0, len(records)) + for _, record := range records { + views = append(views, service.GeminiCachedContentView(record)) + } + resp["cachedContents"] = views + } + c.JSON(http.StatusOK, resp) +} + +// GeminiCachedContentsGet GET /v1beta/cachedContents/:cacheID +func (h *GatewayHandler) GeminiCachedContentsGet(c *gin.Context) { + apiKey, _, reqLog, ok := h.geminiCachedContentsPreamble(c, "handler.gemini_v1beta.cached_contents.get") + if !ok { + return + } + record, ok := h.resolveGeminiCachedContentOrWrite(c, reqLog, apiKey, c.Param("cacheID")) + if !ok { + return + } + c.JSON(http.StatusOK, service.GeminiCachedContentView(record)) +} + +// GeminiCachedContentsPatch PATCH /v1beta/cachedContents/:cacheID +// 只允许修改有效期;延长部分在本次请求内按增量时长收取存储费,缩短不退费。 +// 同一缓存的修改串行执行:上游修改、本地写回与计费都在修改意图的租约内完成,并发的修改返回 409。 +// 上游结果未知或本地写回失败的修改不计费,租约过期后由恢复流程把上游有效期回滚到修改前的到期时间。 +func (h *GatewayHandler) GeminiCachedContentsPatch(c *gin.Context) { + apiKey, _, reqLog, ok := h.geminiCachedContentsPreamble(c, "handler.gemini_v1beta.cached_contents.patch") + if !ok { + return + } + svc := h.geminiCachedContentService + body, ok := readGeminiCachedContentBody(c) + if !ok { + return + } + record, ok := h.resolveGeminiCachedContentOrWrite(c, reqLog, apiKey, c.Param("cacheID")) + if !ok { + return + } + account, err := svc.BoundAccount(c.Request.Context(), record) + if err != nil { + reqLog.Error("gemini.cached_content.load_account_failed", zap.Int64("account_id", record.AccountID), zap.Error(err)) + googleError(c, http.StatusInternalServerError, "Failed to load cached content account") + return + } + if account == nil { + googleError(c, http.StatusServiceUnavailable, geminiCachedContentUnavailableMessage) + return + } + ttl, err := service.ParseGeminiCachedContentPatchRequest(body, c.Query("updateMask"), svc.Now(), svc.MaxTTLForAccount(account)) + if err != nil { + googleError(c, http.StatusBadRequest, err.Error()) + return + } + setOpsRequestContext(c, record.Model, false) + pricingCtx, pricingAt := service.WithGatewayTokenRequestPricing(c.Request.Context()) + c.Request = c.Request.WithContext(pricingCtx) + + subscription, _ := middleware.GetSubscriptionFromContext(c) + if extension := svc.Now().Add(ttl).Sub(record.ExpireTime); extension > 0 { + usage := &service.RecordUsageInput{ + Result: &service.ForwardResult{ + Model: record.Model, UpstreamModel: record.UpstreamModelID(), + CacheStorageTokenHours: service.GeminiCachedContentStorageTokenHours(record.TotalTokenCount, extension), + }, + APIKey: apiKey, User: apiKey.User, Subscription: subscription, PricingAt: pricingAt, + ChannelUsageFields: record.ChannelUsage, + } + if !h.gatewayService.CacheStoragePriced(c.Request.Context(), usage) { + googlePlatformError(c, http.StatusBadRequest, fmt.Sprintf("Explicit context caching is not available for model %s: storage pricing is not configured", record.Model)) + return + } + if !h.checkGeminiCachedContentBilling(c, reqLog, apiKey, subscription) { + return + } + chargeDone, err := h.reserveGeminiCachedContentCharge(c, apiKey, subscription, h.gatewayService.QuoteUsageCost(c.Request.Context(), usage)) + if err != nil { + reqLog.Info("gemini.cached_content.storage_charge_rejected", zap.Error(err)) + writeGeminiCachedContentBillingError(c, err) + return + } + defer chargeDone() + } + + setOpsSelectedAccount(c, account.ID, account.Platform) + mutation, err := svc.BeginMutation(c.Request.Context(), record) + if err != nil { + switch { + case errors.Is(err, service.ErrGeminiCachedContentNotFound): + googleError(c, http.StatusForbidden, service.GeminiCachedContentNotFoundMessage) + case errors.Is(err, service.ErrGeminiCachedContentMutationConflict): + reqLog.Info("gemini.cached_content.mutation_conflict", zap.Int64("account_id", account.ID), zap.Error(err)) + writeGeminiCachedContentMutationConflict(c) + default: + reqLog.Error("gemini.cached_content.begin_mutation_failed", zap.Int64("account_id", account.ID), zap.Error(err)) + googleError(c, http.StatusInternalServerError, "Failed to update cached content") + } + return + } + upstream, err := svc.PatchUpstream(c.Request.Context(), account, record, ttl) + if err != nil { + var upErr *service.GeminiCachedContentUpstreamError + upstreamRejected := errors.As(err, &upErr) + if upstreamRejected || errors.Is(err, service.ErrGeminiCachedContentRequestNotSent) { + // 上游有效期未变:清除修改意图。 + persistCtx, cancelPersist := geminiCachedContentPersistContext(c) + if releaseErr := svc.ReleaseMutation(persistCtx, mutation); releaseErr != nil { + reqLog.Warn("gemini.cached_content.release_mutation_failed", zap.Error(releaseErr)) + } + cancelPersist() + } + if upstreamRejected { + if upErr.NotFound() { + if forgetErr := svc.Forget(c.Request.Context(), record); forgetErr != nil { + reqLog.Warn("gemini.cached_content.forget_failed", zap.Error(forgetErr)) + } + } + h.writeGeminiCachedContentUpstreamError(c, upErr) + return + } + reqLog.Warn("gemini.cached_content.upstream_patch_error", zap.Int64("account_id", account.ID), zap.Error(err)) + googleError(c, http.StatusBadGateway, "Upstream request failed") + return + } + expireTime := service.ClampGeminiCachedContentExpire(upstream.ExpireTime, svc.Now().Add(ttl)) + persistCtx, cancelPersist := geminiCachedContentPersistContext(c) + err = svc.CommitMutation(persistCtx, mutation, expireTime) + cancelPersist() + if err != nil { + reqLog.Error("gemini.cached_content.commit_mutation_failed", zap.Int64("account_id", account.ID), zap.Error(err)) + if errors.Is(err, service.ErrGeminiCachedContentMutationConflict) { + writeGeminiCachedContentMutationConflict(c) + return + } + googleError(c, http.StatusInternalServerError, "Failed to persist cached content expiration") + return + } + // 提交前库中的到期时间一直是修改前的值:按本次实际推进的时长收费。 + if extended := expireTime.Sub(mutation.Baseline()); extended > 0 { + h.submitGeminiCachedContentUsage(c, apiKey, account, subscription, pricingAt, + service.GeminiCachedContentPatchUsageRequestID(record.PublicID), record.Model, record.UpstreamModelID(), 0, + service.GeminiCachedContentStorageTokenHours(record.TotalTokenCount, extended), record.ChannelUsage, body) + } + c.JSON(http.StatusOK, service.GeminiCachedContentView(record)) +} + +// GeminiCachedContentsDelete DELETE /v1beta/cachedContents/:cacheID +// 删除不退还已收取的存储费。 +func (h *GatewayHandler) GeminiCachedContentsDelete(c *gin.Context) { + apiKey, _, reqLog, ok := h.geminiCachedContentsPreamble(c, "handler.gemini_v1beta.cached_contents.delete") + if !ok { + return + } + svc := h.geminiCachedContentService + record, ok := h.resolveGeminiCachedContentOrWrite(c, reqLog, apiKey, c.Param("cacheID")) + if !ok { + return + } + account, err := svc.BoundAccount(c.Request.Context(), record) + if err != nil { + reqLog.Error("gemini.cached_content.load_account_failed", zap.Int64("account_id", record.AccountID), zap.Error(err)) + googleError(c, http.StatusInternalServerError, "Failed to load cached content account") + return + } + if account != nil { + setOpsSelectedAccount(c, account.ID, account.Platform) + if err := svc.DeleteUpstream(c.Request.Context(), account, record); err != nil { + var upErr *service.GeminiCachedContentUpstreamError + if !errors.As(err, &upErr) { + reqLog.Warn("gemini.cached_content.upstream_delete_error", zap.Int64("account_id", account.ID), zap.Error(err)) + googleError(c, http.StatusBadGateway, "Upstream request failed") + return + } + if !upErr.NotFound() { + h.writeGeminiCachedContentUpstreamError(c, upErr) + return + } + } + } + persistCtx, cancelPersist := geminiCachedContentPersistContext(c) + err = svc.Forget(persistCtx, record) + cancelPersist() + if err != nil { + reqLog.Error("gemini.cached_content.forget_failed", zap.Error(err)) + googleError(c, http.StatusInternalServerError, "Failed to delete cached content") + return + } + c.JSON(http.StatusOK, gin.H{}) +} + +func (h *GatewayHandler) resolveGeminiCachedContentOrWrite(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, cacheID string) (*service.GeminiCachedContent, bool) { + record, err := h.geminiCachedContentService.Resolve(c.Request.Context(), apiKey.ID, apiKey.GroupID, cacheID) + if err != nil { + if errors.Is(err, service.ErrGeminiCachedContentNotFound) { + googleError(c, http.StatusForbidden, service.GeminiCachedContentNotFoundMessage) + return nil, false + } + reqLog.Error("gemini.cached_content.lookup_failed", zap.Error(err)) + googleError(c, http.StatusInternalServerError, "Failed to load cached content") + return nil, false + } + return record, true +} + +// resolveGeminiCachedContentBinding 处理 generateContent / countTokens 请求对显式缓存的引用: +// 校验归属与模型后把引用改写为上游资源名,返回绑定记录;请求未引用缓存时返回 nil。 +// 返回 false 表示已写出错误响应。 +func (h *GatewayHandler) resolveGeminiCachedContentBinding(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, modelName string, body []byte) (*service.GeminiCachedContent, []byte, bool) { + ref, err := service.FindGeminiCachedContentReference(body) + if err != nil { + googleError(c, http.StatusBadRequest, err.Error()) + return nil, nil, false + } + if ref == nil { + return nil, body, true + } + if middleware.HasForcePlatform(c) || h.geminiCachedContentService == nil || + apiKey.Group == nil || apiKey.Group.Platform == service.PlatformComposite { + googleError(c, http.StatusBadRequest, "cachedContent is not supported on this route") + return nil, nil, false + } + record, err := h.geminiCachedContentService.Resolve(c.Request.Context(), apiKey.ID, apiKey.GroupID, ref.Name) + if err != nil { + if errors.Is(err, service.ErrGeminiCachedContentNotFound) { + googleError(c, http.StatusForbidden, service.GeminiCachedContentNotFoundMessage) + return nil, nil, false + } + reqLog.Error("gemini.cached_content.lookup_failed", zap.Error(err)) + googleError(c, http.StatusInternalServerError, "Failed to load cached content") + return nil, nil, false + } + if record.Model != modelName { + googleError(c, http.StatusBadRequest, fmt.Sprintf( + "Model used by GenerateContent request (models/%s) and CachedContent (models/%s) has to be the same.", modelName, record.Model)) + return nil, nil, false + } + rewritten, err := service.RewriteGeminiCachedContentReference(body, ref, record.UpstreamName) + if err != nil { + googleError(c, http.StatusBadRequest, "Invalid cachedContent reference") + return nil, nil, false + } + return record, rewritten, true +} + +// acquireGeminiCachedContentAccountSlot 获取选中账号的并发槽(含等待计划);返回 false 表示已写出错误响应。 +func (h *GatewayHandler) acquireGeminiCachedContentAccountSlot(c *gin.Context, reqLog *zap.Logger, helper *ConcurrencyHelper, selection *service.AccountSelectionResult) (func(), bool) { + if selection.Acquired { + release := selection.ReleaseFunc + if release == nil { + release = func() {} + } + return wrapReleaseOnDone(c.Request.Context(), release), true + } + account := selection.Account + if selection.WaitPlan == nil { + markOpsRoutingCapacityLimited(c) + googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts") + return nil, false + } + canWait, err := helper.IncrementAccountWaitCount(c.Request.Context(), account.ID, selection.WaitPlan.MaxWaiting) + if err != nil { + reqLog.Warn("gemini.cached_content.account_wait_counter_increment_failed", zap.Int64("account_id", account.ID), zap.Error(err)) + } else if !canWait { + googleError(c, http.StatusTooManyRequests, "Too many pending requests, please retry later") + return nil, false + } + streamStarted := false + release, acquireErr := helper.AcquireAccountSlotWithWaitTimeout(c, account.ID, selection.WaitPlan.MaxConcurrency, selection.WaitPlan.Timeout, false, &streamStarted) + if err == nil { + helper.DecrementAccountWaitCount(c.Request.Context(), account.ID) + } + if acquireErr != nil { + reqLog.Warn("gemini.cached_content.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(acquireErr)) + googleConcurrencyError(c, acquireErr, "account") + return nil, false + } + if release == nil { + release = func() {} + } + return wrapReleaseOnDone(c.Request.Context(), release), true +} + +// submitGeminiCachedContentUsage 记录缓存创建 / 延长的计费:inputTokens 按输入价计,storageTokenHours 按计费模型的存储单价计; +// requestID 是带固定前缀的费用行标识(见 GeminiCachedContentUsageRequestIDPrefix),落库时不被请求 id 覆盖。 +func (h *GatewayHandler) submitGeminiCachedContentUsage( + c *gin.Context, + apiKey *service.APIKey, + account *service.Account, + subscription *service.UserSubscription, + pricingAt time.Time, + requestID string, + model string, + upstreamModel string, + inputTokens int, + storageTokenHours float64, + usageFields service.ChannelUsageFields, + payload []byte, +) { + if inputTokens <= 0 && storageTokenHours <= 0 { + return + } + if upstreamModel == model { + upstreamModel = "" + } + result := &service.ForwardResult{ + RequestID: requestID, + Model: model, + UpstreamModel: upstreamModel, + Usage: service.ClaudeUsage{InputTokens: inputTokens}, + CacheStorageTokenHours: storageTokenHours, + } + inboundEndpoint := GetInboundEndpoint(c) + upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) + userAgent := c.GetHeader("User-Agent") + clientIP := ip.GetClientIP(c) + requestPayloadHash := service.HashUsageRequestPayload(payload) + quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) + h.submitMandatoryUsageRecordTask(c.Request.Context(), func(ctx context.Context) { + if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ + Result: result, + APIKey: apiKey, + User: apiKey.User, + Account: account, + Subscription: subscription, + PricingAt: pricingAt, + InboundEndpoint: inboundEndpoint, + UpstreamEndpoint: upstreamEndpoint, + UserAgent: userAgent, + IPAddress: clientIP, + RequestPayloadHash: requestPayloadHash, + APIKeyService: h.apiKeyService, + QuotaPlatform: quotaPlatform, + ChannelUsageFields: usageFields, + }); err != nil { + logger.L().With( + zap.String("component", "handler.gemini_v1beta.cached_contents"), + zap.Int64("api_key_id", apiKey.ID), + zap.Int64("account_id", account.ID), + zap.String("request_id", result.RequestID), + ).Error("gemini.cached_content.record_usage_failed", zap.Error(err)) + } + }) +} + +// geminiCachedContentPersistContext 返回与客户端连接脱钩的落库 context:上游操作已生效时,客户端断开不应让本地记录缺失。 +func geminiCachedContentPersistContext(c *gin.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(context.WithoutCancel(c.Request.Context()), geminiCachedContentPersistTimeout) +} + +// cleanupGeminiCachedContentUpstream 尽力删除已在上游创建、但网关不会登记的缓存,避免其持续产生存储费。 +func (h *GatewayHandler) cleanupGeminiCachedContentUpstream(reqLog *zap.Logger, account *service.Account, upstreamName string) { + ctx, cancel := context.WithTimeout(context.Background(), geminiCachedContentPersistTimeout) + defer cancel() + if err := h.geminiCachedContentService.DeleteUpstream(ctx, account, &service.GeminiCachedContent{UpstreamName: upstreamName}); err != nil { + reqLog.Error("gemini.cached_content.orphan_cleanup_failed", zap.Int64("account_id", account.ID), zap.String("upstream_name", upstreamName), zap.Error(err)) + } +} + +// reserveGeminiCachedContentCharge 校验余额 / 订阅额度 / API Key 额度足以支付确定金额 amount(倍率后), +// 并按该金额在用户、订阅、API Key 维度登记在途预留(不受预留上限封顶,见 ReserveFixedCharge)。 +// 返回的 done 须在 handler 结束时调用;预留随计费任务交接,落库后释放。 +func (h *GatewayHandler) reserveGeminiCachedContentCharge(c *gin.Context, apiKey *service.APIKey, subscription *service.UserSubscription, amount float64) (func(), error) { + ctx := c.Request.Context() + res, err := h.billingCacheService.ReserveFixedCharge(ctx, apiKey.User, apiKey, apiKey.Group, subscription, amount) + if err != nil { + return inflightNoop, err + } + if res == nil { + return inflightNoop, nil + } + c.Request = c.Request.WithContext(service.WithInflightReservation(ctx, res)) + return res.HandlerDone, nil +} + +// writeGeminiCachedContentMutationConflict 回写同一缓存的修改冲突(Google 并发冲突语义 ABORTED),客户端可稍后重试。 +func writeGeminiCachedContentMutationConflict(c *gin.Context) { + c.JSON(http.StatusConflict, gin.H{ + "error": gin.H{ + "code": http.StatusConflict, + "message": "Another update to this cached content is in progress, please retry later", + "status": "ABORTED", + }, + }) +} + +func writeGeminiCachedContentBillingError(c *gin.Context, err error) { + status, code, message, retryAfter := billingErrorDetails(err) + if retryAfter > 0 { + c.Header("Retry-After", strconv.Itoa(retryAfter)) + } + googleErrorWithType(c, status, code, "", message) +} + +// writeGeminiCachedContentUpstreamError 回写上游错误:缓存不存在统一返回标准 403(各上游形态不同且可能含上游资源 ID); +// 账号凭据或权限类错误不是客户端问题,统一改为 502;限流 / 不可用按普通 Gemini 路径处理(透传规则、通用映射), +// 只有上游网关账号的这类响应反映持有缓存的上游账号状态,原样回写。 +func (h *GatewayHandler) writeGeminiCachedContentUpstreamError(c *gin.Context, upErr *service.GeminiCachedContentUpstreamError) { + status := upErr.StatusCode + message := strings.TrimSpace(extractGoogleErrorMessage(upErr.Body)) + service.SetOpsUpstreamError(c, status, message, "") + if upErr.NotFound() { + service.MarkOpsRequestScopedError(c, "permission_error") + c.Data(http.StatusForbidden, "application/json", []byte(service.GeminiCachedContentNotFoundResponse)) + return + } + if (status == http.StatusTooManyRequests || status >= http.StatusInternalServerError) && !upErr.UpstreamGateway { + h.handleGeminiFailoverExhausted(c, &service.UpstreamFailoverError{StatusCode: status, ResponseBody: upErr.Body}) + return + } + if status == http.StatusUnauthorized || status == http.StatusForbidden { + googleError(c, http.StatusBadGateway, "Upstream account rejected the cached content request") + return + } + if len(upErr.Body) == 0 || !json.Valid(upErr.Body) { + if message == "" { + message = http.StatusText(status) + } + googleError(c, status, message) + return + } + c.Data(status, "application/json", upErr.Body) +} + +func extractGoogleErrorMessage(body []byte) string { + var envelope struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return "" + } + return envelope.Error.Message +} + +func encodeGeminiCachedContentPageToken(id int64) string { + return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10))) +} + +func decodeGeminiCachedContentPageToken(raw string) (int64, bool) { + decoded, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return 0, false + } + id, err := strconv.ParseInt(string(decoded), 10, 64) + if err != nil || id <= 0 { + return 0, false + } + return id, true +} + +// mergeAccountIDSets 返回两个账号 ID 集合的并集(不修改入参)。 +func mergeAccountIDSets(a, b map[int64]struct{}) map[int64]struct{} { + out := make(map[int64]struct{}, len(a)+len(b)) + for id := range a { + out[id] = struct{}{} + } + for id := range b { + out[id] = struct{}{} + } + return out +} + +// handleGeminiCachedContentFailoverExhausted 处理绑定缓存的请求在持有账号上重试耗尽:没有其他账号可换。 +// 上游网关账号的限流 / 不可用响应反映持有缓存的上游账号状态,原样返回供客户端判断; +// 其余(含官方账号)仍按普通 Gemini 路径处理:透传规则、通用映射。 +func (h *GatewayHandler) handleGeminiCachedContentFailoverExhausted(c *gin.Context, failoverErr *service.UpstreamFailoverError) { + if failoverErr == nil || !failoverErr.BoundUpstreamPassthrough || len(failoverErr.ResponseBody) == 0 || !json.Valid(failoverErr.ResponseBody) { + h.handleGeminiFailoverExhausted(c, failoverErr) + return + } + service.SetOpsUpstreamError(c, failoverErr.StatusCode, service.ExtractUpstreamErrorMessage(failoverErr.ResponseBody), "") + c.Data(failoverErr.StatusCode, "application/json", failoverErr.ResponseBody) +} diff --git a/backend/internal/handler/gemini_cached_contents_handler_test.go b/backend/internal/handler/gemini_cached_contents_handler_test.go new file mode 100644 index 000000000000..f9a1d2b4932b --- /dev/null +++ b/backend/internal/handler/gemini_cached_contents_handler_test.go @@ -0,0 +1,264 @@ +//go:build unit + +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +type geminiCachedContentHandlerRepoStub struct { + records []*service.GeminiCachedContent +} + +func (r *geminiCachedContentHandlerRepoStub) Create(_ context.Context, record *service.GeminiCachedContent) error { + record.ID = int64(len(r.records) + 1) + r.records = append(r.records, record) + return nil +} + +func (r *geminiCachedContentHandlerRepoStub) GetForOwner(_ context.Context, apiKeyID int64, publicID string) (*service.GeminiCachedContent, error) { + for _, record := range r.records { + if record.PublicID == publicID && record.APIKeyID == apiKeyID { + return record, nil + } + } + return nil, service.ErrGeminiCachedContentNotFound +} + +func (r *geminiCachedContentHandlerRepoStub) ListForOwner(_ context.Context, apiKeyID, groupID int64, activeAt time.Time, beforeID int64, limit int) ([]*service.GeminiCachedContent, error) { + var out []*service.GeminiCachedContent + for i := len(r.records) - 1; i >= 0; i-- { + record := r.records[i] + if record.APIKeyID == apiKeyID && record.GroupID == groupID && record.ExpireTime.After(activeAt) && (beforeID <= 0 || record.ID < beforeID) { + out = append(out, record) + } + if len(out) == limit { + break + } + } + return out, nil +} + +func (r *geminiCachedContentHandlerRepoStub) BeginMutation(context.Context, int64, time.Time, string, time.Duration) (bool, error) { + return true, nil +} + +func (r *geminiCachedContentHandlerRepoStub) TakeOverStaleMutation(context.Context, int64, string, time.Duration) (*service.GeminiCachedContent, error) { + return nil, nil +} + +func (r *geminiCachedContentHandlerRepoStub) CommitMutation(context.Context, int64, string, time.Time) error { + return nil +} + +func (r *geminiCachedContentHandlerRepoStub) ReleaseMutation(context.Context, int64, string) error { + return nil +} + +func (r *geminiCachedContentHandlerRepoStub) ListStaleMutations(context.Context, int) ([]int64, error) { + return nil, nil +} + +func (r *geminiCachedContentHandlerRepoStub) SoftDelete(context.Context, int64) error { + return nil +} + +func newGeminiCachedContentHandlerForTest(repo *geminiCachedContentHandlerRepoStub) *GatewayHandler { + return &GatewayHandler{geminiCachedContentService: service.NewGeminiCachedContentService(repo, nil, &config.Config{})} +} + +func geminiCachedContentTestAPIKey(platform string) *service.APIKey { + groupID := int64(42) + return &service.APIKey{ID: 7, UserID: 3, GroupID: &groupID, Group: &service.Group{ID: groupID, Platform: platform}} +} + +func newGeminiCachedContentTestContext(method, target string, apiKey *service.APIKey) (*gin.Context, *httptest.ResponseRecorder) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(method, target, nil) + c.Set(string(middleware.ContextKeyAPIKey), apiKey) + c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.UserID, Concurrency: 10}) + return c, rec +} + +func seedGeminiCachedContent(repo *geminiCachedContentHandlerRepoStub, publicID string, apiKeyID int64, expire time.Time) *service.GeminiCachedContent { + record := &service.GeminiCachedContent{ + PublicID: publicID, + UserID: 3, + APIKeyID: apiKeyID, + GroupID: 42, + AccountID: 900, + UpstreamName: "cachedContents/upstream" + publicID, + Model: "gemini-3.8-flash", + UpstreamModel: "models/gemini-3.8-flash", + TotalTokenCount: 6273, + ExpireTime: expire, + CreatedAt: time.Date(2026, 10, 9, 8, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 10, 9, 8, 0, 0, 0, time.UTC), + } + _ = repo.Create(context.Background(), record) + return record +} + +func TestResolveGeminiCachedContentBinding(t *testing.T) { + repo := &geminiCachedContentHandlerRepoStub{} + seedGeminiCachedContent(repo, "abc", 7, time.Now().Add(time.Hour)) + seedGeminiCachedContent(repo, "foreign", 8, time.Now().Add(time.Hour)) + h := newGeminiCachedContentHandlerForTest(repo) + apiKey := geminiCachedContentTestAPIKey(service.PlatformGemini) + + t.Run("request without cache passes through", func(t *testing.T) { + c, _ := newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:generateContent", apiKey) + body := []byte(`{"contents":[]}`) + record, out, ok := h.resolveGeminiCachedContentBinding(c, zap.NewNop(), apiKey, "gemini-3.8-flash", body) + require.True(t, ok) + require.Nil(t, record) + require.Equal(t, body, out) + }) + + t.Run("owned cache rewrites reference and binds account", func(t *testing.T) { + c, _ := newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:generateContent", apiKey) + record, out, ok := h.resolveGeminiCachedContentBinding(c, zap.NewNop(), apiKey, "gemini-3.8-flash", + []byte(`{"cachedContent":"cachedContents/abc","contents":[]}`)) + require.True(t, ok) + require.Equal(t, int64(900), record.AccountID) + require.JSONEq(t, `{"cachedContent":"cachedContents/upstreamabc","contents":[]}`, string(out)) + }) + + t.Run("cache owned by another key looks missing", func(t *testing.T) { + c, rec := newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:generateContent", apiKey) + _, _, ok := h.resolveGeminiCachedContentBinding(c, zap.NewNop(), apiKey, "gemini-3.8-flash", + []byte(`{"cachedContent":"cachedContents/foreign","contents":[]}`)) + require.False(t, ok) + require.Equal(t, http.StatusForbidden, rec.Code) + require.Contains(t, rec.Body.String(), service.GeminiCachedContentNotFoundMessage) + }) + + t.Run("model must match the cache literally", func(t *testing.T) { + c, rec := newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-flash-latest:generateContent", apiKey) + _, _, ok := h.resolveGeminiCachedContentBinding(c, zap.NewNop(), apiKey, "gemini-flash-latest", + []byte(`{"cachedContent":"cachedContents/abc","contents":[]}`)) + require.False(t, ok) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), "has to be the same") + }) + + t.Run("composite groups are rejected", func(t *testing.T) { + composite := geminiCachedContentTestAPIKey(service.PlatformComposite) + c, rec := newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:generateContent", composite) + _, _, ok := h.resolveGeminiCachedContentBinding(c, zap.NewNop(), composite, "gemini-3.8-flash", + []byte(`{"cachedContent":"cachedContents/abc","contents":[]}`)) + require.False(t, ok) + require.Equal(t, http.StatusBadRequest, rec.Code) + }) +} + +func TestGeminiCachedContentsListAndGet(t *testing.T) { + repo := &geminiCachedContentHandlerRepoStub{} + seedGeminiCachedContent(repo, "first", 7, time.Now().Add(time.Hour)) + seedGeminiCachedContent(repo, "second", 7, time.Now().Add(time.Hour)) + seedGeminiCachedContent(repo, "expired", 7, time.Now().Add(-time.Minute)) + seedGeminiCachedContent(repo, "foreign", 8, time.Now().Add(time.Hour)) + h := newGeminiCachedContentHandlerForTest(repo) + apiKey := geminiCachedContentTestAPIKey(service.PlatformGemini) + + c, rec := newGeminiCachedContentTestContext(http.MethodGet, "/v1beta/cachedContents?pageSize=1", apiKey) + h.GeminiCachedContentsList(c) + require.Equal(t, http.StatusOK, rec.Code) + var page struct { + CachedContents []map[string]any `json:"cachedContents"` + NextPageToken string `json:"nextPageToken"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &page)) + require.Len(t, page.CachedContents, 1) + require.Equal(t, "cachedContents/second", page.CachedContents[0]["name"]) + require.NotEmpty(t, page.NextPageToken) + + c, rec = newGeminiCachedContentTestContext(http.MethodGet, "/v1beta/cachedContents?pageSize=1&pageToken="+page.NextPageToken, apiKey) + h.GeminiCachedContentsList(c) + require.Equal(t, http.StatusOK, rec.Code) + page.NextPageToken = "" + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &page)) + require.Len(t, page.CachedContents, 1) + require.Equal(t, "cachedContents/first", page.CachedContents[0]["name"]) + require.Empty(t, page.NextPageToken, "过期与其他 Key 的缓存不出现在列表里") + + c, rec = newGeminiCachedContentTestContext(http.MethodGet, "/v1beta/cachedContents/first", apiKey) + c.Params = gin.Params{{Key: "cacheID", Value: "first"}} + h.GeminiCachedContentsGet(c) + require.Equal(t, http.StatusOK, rec.Code) + var view map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &view)) + require.Equal(t, "cachedContents/first", view["name"]) + require.Equal(t, "models/gemini-3.8-flash", view["model"]) + require.NotContains(t, rec.Body.String(), "upstreamfirst", "上游资源名不暴露给客户端") + + c, rec = newGeminiCachedContentTestContext(http.MethodGet, "/v1beta/cachedContents/foreign", apiKey) + c.Params = gin.Params{{Key: "cacheID", Value: "foreign"}} + h.GeminiCachedContentsGet(c) + require.Equal(t, http.StatusForbidden, rec.Code) + require.True(t, strings.Contains(rec.Body.String(), service.GeminiCachedContentNotFoundMessage)) +} + +func TestHandleGeminiCachedContentFailoverExhausted(t *testing.T) { + h := &GatewayHandler{} + apiKey := geminiCachedContentTestAPIKey(service.PlatformGemini) + unavailable := `{"error":{"code":503,"message":"The account holding this cached content is temporarily unavailable","status":"UNAVAILABLE"}}` + + c, rec := newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:generateContent", apiKey) + h.handleGeminiCachedContentFailoverExhausted(c, &service.UpstreamFailoverError{StatusCode: http.StatusServiceUnavailable, ResponseBody: []byte(unavailable), BoundUpstreamPassthrough: true}) + require.Equal(t, http.StatusServiceUnavailable, rec.Code, "绑定缓存的请求无账号可换,上游网关的 503 原样返回") + require.JSONEq(t, unavailable, rec.Body.String()) + + c, rec = newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:generateContent", apiKey) + h.handleGeminiCachedContentFailoverExhausted(c, &service.UpstreamFailoverError{StatusCode: http.StatusTooManyRequests, ResponseBody: []byte(`{"error":{"code":429,"message":"Too many pending requests, please retry later","status":"RESOURCE_EXHAUSTED"}}`), BoundUpstreamPassthrough: true}) + require.Equal(t, http.StatusTooManyRequests, rec.Code) + require.Contains(t, rec.Body.String(), "Too many pending requests") + + officialQuota := `{"error":{"code":429,"message":"You exceeded your current quota","status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.QuotaFailure","violations":[{"quotaId":"GenerateRequestsPerDayPerProjectPerModel-FreeTier"}]}]}}` + c, rec = newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:generateContent", apiKey) + h.handleGeminiCachedContentFailoverExhausted(c, &service.UpstreamFailoverError{StatusCode: http.StatusTooManyRequests, ResponseBody: []byte(officialQuota)}) + require.Equal(t, http.StatusTooManyRequests, rec.Code, "官方账号的限流按普通 Gemini 路径映射") + require.NotContains(t, rec.Body.String(), "FreeTier", "不向客户端暴露上游账号的配额明细") + require.Contains(t, rec.Body.String(), "Upstream rate limit exceeded") + + c, rec = newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:generateContent", apiKey) + h.handleGeminiCachedContentFailoverExhausted(c, &service.UpstreamFailoverError{StatusCode: http.StatusUnauthorized, ResponseBody: []byte(`{"error":{"code":401,"message":"bad key"}}`)}) + require.Equal(t, http.StatusBadGateway, rec.Code, "账号凭据类错误仍按网关映射,不透传给客户端") + require.NotContains(t, rec.Body.String(), "bad key") +} + +func TestWriteGeminiCachedContentUpstreamError_RateLimitMappedUnlessUpstreamGateway(t *testing.T) { + h := &GatewayHandler{} + apiKey := geminiCachedContentTestAPIKey(service.PlatformGemini) + quota := `{"error":{"code":429,"message":"You exceeded your current quota","status":"RESOURCE_EXHAUSTED","details":[{"quotaId":"GenerateRequestsPerDayPerProjectPerModel-FreeTier"}]}}` + + c, rec := newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/cachedContents", apiKey) + h.writeGeminiCachedContentUpstreamError(c, &service.GeminiCachedContentUpstreamError{StatusCode: http.StatusTooManyRequests, Body: []byte(quota)}) + require.Equal(t, http.StatusTooManyRequests, rec.Code) + require.NotContains(t, rec.Body.String(), "FreeTier", "官方账号的限流不透传配额明细") + + c, rec = newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/cachedContents", apiKey) + h.writeGeminiCachedContentUpstreamError(c, &service.GeminiCachedContentUpstreamError{StatusCode: http.StatusTooManyRequests, Body: []byte(quota), UpstreamGateway: true}) + require.Equal(t, http.StatusTooManyRequests, rec.Code) + require.JSONEq(t, quota, rec.Body.String(), "上游网关账号的限流反映持有缓存的上游账号状态,原样回写") + + c, rec = newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/cachedContents", apiKey) + h.writeGeminiCachedContentUpstreamError(c, &service.GeminiCachedContentUpstreamError{StatusCode: http.StatusBadRequest, Body: []byte(`{"error":{"code":400,"message":"Cached content is too small","status":"INVALID_ARGUMENT"}}`)}) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), "too small", "请求校验类错误仍原样回写") +} diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index 4e56c37232a4..8627e0d1395a 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -74,7 +74,7 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) { return } - account, err := h.geminiCompatService.SelectAccountForAIStudioEndpoints(c.Request.Context(), apiKey.GroupID) + account, err := h.geminiCompatService.SelectAccountForModelsEndpoints(c.Request.Context(), apiKey.GroupID) if err != nil { if len(agModels) > 0 { c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(agModels)}) @@ -85,7 +85,7 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) { return } - res, err := h.geminiCompatService.ForwardAIStudioGET(c.Request.Context(), account, "/v1beta/models") + res, err := h.geminiCompatService.ForwardListModels(c.Request.Context(), account) if err != nil { googleError(c, http.StatusBadGateway, err.Error()) return @@ -251,7 +251,7 @@ func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) { return } - account, err := h.geminiCompatService.SelectAccountForAIStudioEndpoints(c.Request.Context(), apiKey.GroupID) + account, err := h.geminiCompatService.SelectAccountForModelsEndpoints(c.Request.Context(), apiKey.GroupID) if err != nil { // 没有 gemini 账户,检查是否有 antigravity 账户可用 hasAntigravity, _ := h.geminiCompatService.HasAntigravityAccounts(c.Request.Context(), apiKey.GroupID) @@ -265,7 +265,7 @@ func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) { return } - res, err := h.geminiCompatService.ForwardAIStudioGET(c.Request.Context(), account, "/v1beta/models/"+modelName) + res, err := h.geminiCompatService.ForwardGetModel(c.Request.Context(), account, modelName) if err != nil { googleError(c, http.StatusBadGateway, err.Error()) return @@ -356,6 +356,12 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { modelName = channelMapping.MappedModel } + // 引用显式缓存的请求只能发往持有该缓存的账号,且不参与会话粘性与换号。 + cachedContentBinding, body, ok := h.resolveGeminiCachedContentBinding(c, reqLog, apiKey, modelName, body) + if !ok { + return + } + // Get subscription (may be nil) subscription, _ := middleware.GetSubscriptionFromContext(c) @@ -382,11 +388,11 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { // 2) billing eligibility check (after wait) if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil { reqLog.Info("gemini.billing_eligibility_check_failed", zap.Error(err)) - status, _, message, retryAfter := billingErrorDetails(err) + status, code, message, retryAfter := billingErrorDetails(err) if retryAfter > 0 { c.Header("Retry-After", strconv.Itoa(retryAfter)) } - googleError(c, status, message) + googleErrorWithType(c, status, code, "", message) return } @@ -394,19 +400,22 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { inflightRelease, err := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(modelName, body)) if err != nil { reqLog.Info("gemini.inflight_reservation_rejected", zap.Error(err)) - status, _, message, retryAfter := billingErrorDetails(err) + status, code, message, retryAfter := billingErrorDetails(err) if retryAfter > 0 { c.Header("Retry-After", strconv.Itoa(retryAfter)) } - googleError(c, status, message) + googleErrorWithType(c, status, code, "", message) return } defer inflightRelease() // 3) select account (sticky session based on request body) // 优先使用 Gemini CLI 的会话标识(privileged-user-id + tmp 目录哈希) - sessionHash := extractGeminiCLISessionHash(c, body) - if sessionHash == "" { + sessionHash := "" + if cachedContentBinding == nil { + sessionHash = extractGeminiCLISessionHash(c, body) + } + if sessionHash == "" && cachedContentBinding == nil { // Fallback: 使用通用的会话哈希生成逻辑(适用于其他客户端) parsedReq, _ := service.ParseGatewayRequest(service.NewRequestBodyRef(body), domain.PlatformGemini) if parsedReq != nil { @@ -443,7 +452,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { var geminiPrefixHash string var geminiSessionUUID string var matchedDigestChain string - useDigestFallback := sessionBoundAccountID == 0 + useDigestFallback := sessionBoundAccountID == 0 && cachedContentBinding == nil if useDigestFallback { // 解析 Gemini 请求体 @@ -507,7 +516,21 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { hasBoundSession := sessionKey != "" && sessionBoundAccountID > 0 cleanedForUnknownBinding := false - fs := NewFailoverState(h.maxAccountSwitchesGemini, hasBoundSession) + maxAccountSwitches := h.maxAccountSwitchesGemini + var cachedContentExclusions map[int64]struct{} + if cachedContentBinding != nil { + // 缓存只存在于绑定账号:排除分组内其余账号,失败不换号。 + maxAccountSwitches = 0 + sessionBoundAccountID = cachedContentBinding.AccountID + c.Request = c.Request.WithContext(service.WithGeminiCachedContentBound(c.Request.Context())) + cachedContentExclusions, err = h.gatewayService.GeminiExplicitCacheExclusions(c.Request.Context(), apiKey.GroupID, cachedContentBinding.AccountID) + if err != nil { + reqLog.Warn("gemini.cached_content.list_accounts_failed", zap.Error(err)) + googleError(c, http.StatusServiceUnavailable, geminiCachedContentUnavailableMessage) + return + } + } + fs := NewFailoverState(maxAccountSwitches, hasBoundSession) // 单账号分组提前设置 SingleAccountRetry 标记,让 Service 层首次 503 就不设模型限流标记。 // 避免单账号分组收到 503 (MODEL_CAPACITY_EXHAUSTED) 时设 29s 限流,导致后续请求连续快速失败。 @@ -517,12 +540,32 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { } for { - selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, modelName, fs.FailedAccountIDs, "", int64(0)) // Gemini 不使用会话限制 + excludedAccountIDs := fs.FailedAccountIDs + if cachedContentExclusions != nil { + excludedAccountIDs = mergeAccountIDSets(cachedContentExclusions, fs.FailedAccountIDs) + } + selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, modelName, excludedAccountIDs, "", int64(0)) // Gemini 不使用会话限制 + if err == nil && cachedContentBinding != nil && selection.Account.ID != cachedContentBinding.AccountID { + if selection.Acquired && selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + err = service.ErrNoAvailableAccounts + } if err != nil { if failoverClientGone(c) { reqLog.Info("gemini.account_select_aborted_client_disconnected", zap.Error(err)) return } + if cachedContentBinding != nil { + if fs.LastFailoverErr != nil { + h.handleGeminiCachedContentFailoverExhausted(c, fs.LastFailoverErr) + return + } + reqLog.Info("gemini.cached_content.bound_account_unavailable", zap.Int64("account_id", cachedContentBinding.AccountID), zap.Error(err)) + markOpsRoutingCapacityLimited(c) + googleError(c, http.StatusServiceUnavailable, geminiCachedContentUnavailableMessage) + return + } if len(fs.FailedAccountIDs) == 0 { cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, modelName, modelName, service.PlatformGemini) if !cls.ModelNotFound { @@ -631,6 +674,11 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { accountReleaseFunc() } reqLog.Debug("gemini.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason)) + if cachedContentBinding != nil { + markOpsRoutingCapacityLimited(c) + googleError(c, http.StatusServiceUnavailable, geminiCachedContentUnavailableMessage) + return + } if fs.RecordProfitVeto(account.ID) == FailoverExhausted { reqLog.Warn("gemini.profit_veto_attempts_exhausted", zap.Int("profit_veto_count", fs.ProfitVetoCount())) markOpsRoutingCapacityLimited(c) @@ -684,6 +732,10 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { case FailoverContinue: continue case FailoverExhausted: + if cachedContentBinding != nil { + h.handleGeminiCachedContentFailoverExhausted(c, fs.LastFailoverErr) + return + } h.handleGeminiFailoverExhausted(c, fs.LastFailoverErr) return case FailoverCanceled: @@ -809,7 +861,8 @@ func (h *GatewayHandler) handleGeminiFailoverExhausted(c *gin.Context, failoverE c.Set(service.OpsSkipPassthroughKey, true) } - googleError(c, respCode, msg) + // 按透传规则改写的是上游错误,不登记本地错误类型。 + googleErrorWithType(c, respCode, "", "", msg) return } } @@ -844,7 +897,21 @@ type pathParseError struct{ msg string } func (e *pathParseError) Error() string { return e.msg } +// googleError 以 Google 错误格式回写网关产生的错误,并按状态码向 ops 登记错误类型: +// 4xx 为客户端请求问题,其余状态码不登记(按平台错误统计)。 func googleError(c *gin.Context, status int, message string) { + googleErrorWithType(c, status, googleClientErrorType(status), "", message) +} + +// googlePlatformError 以 Google 错误格式回写由平台侧原因(如管理员未完成配置)导致的错误,ops 按平台错误统计。 +func googlePlatformError(c *gin.Context, status int, message string) { + googleErrorWithType(c, status, "", "", message) +} + +// googleErrorWithType 以 Google 错误格式回写错误,并向 ops 登记错误类型与错误码(Google 错误体没有 type 字段), +// 与 Claude / OpenAI 格式错误同口径分类;errType 为空时不登记。 +func googleErrorWithType(c *gin.Context, status int, errType, code, message string) { + service.SetOpsLocalErrorType(c, errType, code) c.JSON(status, gin.H{ "error": gin.H{ "code": status, @@ -854,11 +921,27 @@ func googleError(c *gin.Context, status int, message string) { }) } +// googleClientErrorType 返回客户端请求类 4xx 状态码对应的 ops 错误类型;其余状态码返回空串。 +func googleClientErrorType(status int) string { + switch status { + case http.StatusBadRequest, http.StatusRequestEntityTooLarge: + return "invalid_request_error" + case http.StatusUnauthorized: + return "authentication_error" + case http.StatusForbidden: + return "permission_error" + case http.StatusNotFound: + return "not_found_error" + default: + return "" + } +} + // googleConcurrencyError 以 Google 错误格式回写并发槽获取失败,状态码与文案 // 沿用 concurrencyErrorResponse 的统一映射(客户端断开为 499)。 func googleConcurrencyError(c *gin.Context, err error, slotType string) { - status, _, _, message := concurrencyErrorResponse(err, slotType) - googleError(c, status, message) + status, errType, code, message := concurrencyErrorResponse(err, slotType) + googleErrorWithType(c, status, errType, code, message) } func writeUpstreamResponse(c *gin.Context, res *service.UpstreamHTTPResult) { diff --git a/backend/internal/handler/gemini_v1beta_handler_test.go b/backend/internal/handler/gemini_v1beta_handler_test.go index 8604b07b8816..44d66d5c9731 100644 --- a/backend/internal/handler/gemini_v1beta_handler_test.go +++ b/backend/internal/handler/gemini_v1beta_handler_test.go @@ -109,7 +109,7 @@ func TestGeminiV1BetaHandler_ListModelsAntigravityFallback(t *testing.T) { expectedBehavior string }{ { - name: "有Gemini账户-调用ForwardAIStudioGET", + name: "有Gemini账户-调用ForwardListModels", hasGeminiAccount: true, hasAntigravity: false, expectedBehavior: "forward_to_upstream", @@ -155,7 +155,7 @@ func TestGeminiV1BetaHandler_GetModelAntigravityFallback(t *testing.T) { expectedBehavior string }{ { - name: "有Gemini账户-调用ForwardAIStudioGET", + name: "有Gemini账户-调用ForwardGetModel", hasGeminiAccount: true, hasAntigravity: false, expectedBehavior: "forward_to_upstream", diff --git a/backend/internal/handler/gemini_vertex_models_test.go b/backend/internal/handler/gemini_vertex_models_test.go new file mode 100644 index 000000000000..eaadf6e03fe7 --- /dev/null +++ b/backend/internal/handler/gemini_vertex_models_test.go @@ -0,0 +1,83 @@ +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type geminiVertexModelsTokenCache struct{} + +func (geminiVertexModelsTokenCache) GetAccessToken(context.Context, string) (string, error) { + return "ya29.vertex-token", nil +} + +func (geminiVertexModelsTokenCache) SetAccessToken(context.Context, string, string, time.Duration) error { + return nil +} + +func (geminiVertexModelsTokenCache) DeleteAccessToken(context.Context, string) error { + return nil +} + +func (geminiVertexModelsTokenCache) AcquireRefreshLock(context.Context, string, time.Duration) (bool, error) { + return false, nil +} + +func (geminiVertexModelsTokenCache) ReleaseRefreshLock(context.Context, string) error { + return nil +} + +// 分组里只有 Agent Platform (Vertex) 服务账号时,模型列表与模型详情接口返回 Vertex 上的 Gemini 模型。 +func TestGeminiV1BetaModelsEndpoints_VertexServiceAccountOnlyGroup(t *testing.T) { + groupID := int64(8) + repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{groupID: {{ + ID: 18, + Platform: service.PlatformGemini, + Type: service.AccountTypeServiceAccount, + Credentials: map[string]any{ + "location": "global", + "service_account_json": map[string]any{ + "type": "service_account", + "project_id": "proj", + "private_key": "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n", + "client_email": "svc@proj.iam.gserviceaccount.com", + }, + }, + }}}}} + upstream := &geminiMixedModelsUpstream{status: http.StatusOK, body: `{"publisherModels":[{"name":"publishers/google/models/gemini-3.8-flash"}]}`} + tokenProvider := service.NewGeminiTokenProvider(nil, geminiVertexModelsTokenCache{}, nil) + h := &GatewayHandler{geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, tokenProvider, nil, upstream, nil, &config.Config{})} + apiKey := &service.APIKey{GroupID: &groupID, Group: &service.Group{ID: groupID, Platform: service.PlatformGemini}} + + t.Run("list", func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil) + c.Set(string(middleware.ContextKeyAPIKey), apiKey) + + h.GeminiV1BetaListModels(c) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + require.JSONEq(t, `{"models":[{"name":"models/gemini-3.8-flash","supportedGenerationMethods":["generateContent","streamGenerateContent"]}]}`, rec.Body.String()) + }) + + t.Run("get", func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models/gemini-3.8-flash", nil) + c.Params = gin.Params{{Key: "model", Value: "gemini-3.8-flash"}} + c.Set(string(middleware.ContextKeyAPIKey), apiKey) + + h.GeminiV1BetaGetModel(c) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + require.JSONEq(t, `{"name":"models/gemini-3.8-flash","supportedGenerationMethods":["generateContent","streamGenerateContent"]}`, rec.Body.String()) + }) +} diff --git a/backend/internal/handler/group_localize_test.go b/backend/internal/handler/group_localize_test.go new file mode 100644 index 000000000000..502c5a516471 --- /dev/null +++ b/backend/internal/handler/group_localize_test.go @@ -0,0 +1,92 @@ +package handler + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestToLocalizedModelPlazaGroups(t *testing.T) { + visible := []service.PlazaGroup{ + { + ID: 1, Name: "标准", Description: "号池", Platform: "anthropic", + I18n: service.GroupI18n{"en": {Name: "Standard", Description: "Pro + Max pool"}}, + }, + {ID: 2, Name: "无译文", Description: "原描述", Platform: "openai"}, + } + + english := toLocalizedModelPlazaGroups(visible, nil, "en") + require.Equal(t, "Standard", english[0].Name) + require.Equal(t, "Pro + Max pool", english[0].Description) + require.Equal(t, "无译文", english[1].Name) + require.Equal(t, "原描述", english[1].Description) + + chinese := toLocalizedModelPlazaGroups(visible, nil, "zh") + require.Equal(t, "标准", chinese[0].Name) + require.Equal(t, "号池", chinese[0].Description) + + // service 层对象不被改写,同一批分组可继续按别的语言输出 + require.Equal(t, "标准", visible[0].Name) + + raw, err := json.Marshal(english[0]) + require.NoError(t, err) + var decoded map[string]any + require.NoError(t, json.Unmarshal(raw, &decoded)) + require.NotContains(t, decoded, "i18n") +} + +func TestLocalizeUserAvailableGroups(t *testing.T) { + refs := []service.AvailableGroupRef{ + {ID: 1, Name: "标准", Platform: "anthropic", I18n: service.GroupI18n{"en": {Name: "Standard"}}}, + {ID: 2, Name: "无译文", Platform: "anthropic"}, + } + allowed := map[int64]struct{}{1: {}, 2: {}} + + english := filterUserVisibleGroups(refs, allowed) + localizeUserAvailableGroups(english, "en") + require.Equal(t, "Standard", english[0].Name) + require.Equal(t, "无译文", english[1].Name) + + chinese := filterUserVisibleGroups(refs, allowed) + localizeUserAvailableGroups(chinese, "zh") + require.Equal(t, "标准", chinese[0].Name) + + raw, err := json.Marshal(english[0]) + require.NoError(t, err) + var decoded map[string]any + require.NoError(t, json.Unmarshal(raw, &decoded)) + require.NotContains(t, decoded, "i18n") +} + +// 用户侧 handler 输出带分组的 DTO 时必须经过 Localized,否则分组名称与描述会 +// 停留在原字段,并把译文表原样带给用户。管理端(handler/admin)不在此列。 +func TestUserFacingGroupDTOsAreLocalized(t *testing.T) { + mapperCall := regexp.MustCompile(`dto\.(APIKey|UserSubscription|RedeemCode|UsageLog|Group)FromService[A-Za-z]*\(`) + + files, err := filepath.Glob("*.go") + require.NoError(t, err) + + checked := 0 + for _, file := range files { + if strings.HasSuffix(file, "_test.go") { + continue + } + source, err := os.ReadFile(file) + require.NoError(t, err) + for i, line := range strings.Split(string(source), "\n") { + if !mapperCall.MatchString(line) { + continue + } + checked++ + require.Containsf(t, line, ".Localized(", + "%s:%d outputs a group DTO without Localized(Accept-Language)", file, i+1) + } + } + require.NotZero(t, checked, "expected to find user-facing group DTO call sites") +} diff --git a/backend/internal/handler/model_plaza_handler.go b/backend/internal/handler/model_plaza_handler.go index e613080a7470..6f3a905d816c 100644 --- a/backend/internal/handler/model_plaza_handler.go +++ b/backend/internal/handler/model_plaza_handler.go @@ -151,10 +151,10 @@ func (h *ModelPlazaHandler) Get(c *gin.Context) { visible := filterPlazaVisibleGroups(groups, allowedGroups, restrictPublicGroups) - out := make([]modelPlazaGroup, 0, len(visible)) - for i := range visible { - out = append(out, toModelPlazaGroupDTO(&visible[i], userRates)) - } + // 广场可匿名访问,分组文案随请求语言变化,提示中间缓存按语言区分。 + // 追加而非覆盖:CORS 中间件已为具体来源写入 Vary: Origin。 + c.Writer.Header().Add("Vary", "Accept-Language") + out := toLocalizedModelPlazaGroups(visible, userRates, c.GetHeader("Accept-Language")) response.Success(c, modelPlazaResponse{ Description: rt.Description, Groups: out, @@ -185,6 +185,17 @@ func filterPlazaVisibleGroups( return visible } +// toLocalizedModelPlazaGroups 转换可见分组,并把名称与描述换成请求首选语言的版本。 +func toLocalizedModelPlazaGroups(visible []service.PlazaGroup, userRates map[int64]float64, acceptLanguage string) []modelPlazaGroup { + out := make([]modelPlazaGroup, 0, len(visible)) + for i := range visible { + group := toModelPlazaGroupDTO(&visible[i], userRates) + group.Name, group.Description = visible[i].I18n.Localize(acceptLanguage, group.Name, group.Description) + out = append(out, group) + } + return out +} + // toModelPlazaGroupDTO 将 service 层广场分组映射为白名单 DTO,并合并用户专属倍率。 func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) modelPlazaGroup { models := make([]modelPlazaModel, 0, len(g.Models)) diff --git a/backend/internal/handler/model_plaza_handler_test.go b/backend/internal/handler/model_plaza_handler_test.go index f356d68b5ef3..b29c85ede27f 100644 --- a/backend/internal/handler/model_plaza_handler_test.go +++ b/backend/internal/handler/model_plaza_handler_test.go @@ -3,11 +3,14 @@ package handler import ( + "context" "encoding/json" "net/http" "net/http/httptest" "testing" + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -262,3 +265,43 @@ func TestFilterPlazaVisibleGroups_SubscribedExclusiveGroup(t *testing.T) { require.Equal(t, int64(42), visible[0].ID) } } + +type plazaVarySettingRepo struct{ service.SettingRepository } + +func (plazaVarySettingRepo) GetMultiple(context.Context, []string) (map[string]string, error) { + return map[string]string{service.SettingKeyModelPlazaEnabled: "true"}, nil +} + +type plazaVaryChannelRepo struct{ service.ChannelRepository } + +func (plazaVaryChannelRepo) ListAll(context.Context) ([]service.Channel, error) { return nil, nil } + +type plazaVaryGroupRepo struct{ service.GroupRepository } + +func (plazaVaryGroupRepo) ListActive(context.Context) ([]service.Group, error) { return nil, nil } + +func TestModelPlazaHandler_VaryKeepsCORSOrigin(t *testing.T) { + gin.SetMode(gin.TestMode) + h := NewModelPlazaHandler( + service.NewModelPlazaService(plazaVaryChannelRepo{}, plazaVaryGroupRepo{}, nil, nil, nil), + nil, + service.NewSettingService(plazaVarySettingRepo{}, &config.Config{}), + ) + r := gin.New() + r.Use(middleware.CORS(config.CORSConfig{AllowedOrigins: []string{"https://a.example", "https://b.example"}})) + r.GET("/api/v1/model-plaza", h.Get) + + for _, origin := range []string{"https://a.example", "https://b.example"} { + req := httptest.NewRequest(http.MethodGet, "/api/v1/model-plaza", nil) + req.Header.Set("Origin", origin) + req.Header.Set("Accept-Language", "en") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.Equal(t, origin, w.Header().Get("Access-Control-Allow-Origin")) + // 共享缓存需同时按来源与语言区分,否则 A 来源的响应可能被复用给 B。 + require.Equal(t, []string{"Origin", "Accept-Language"}, w.Header().Values("Vary")) + } +} diff --git a/backend/internal/handler/ops_error_logger.go b/backend/internal/handler/ops_error_logger.go index 4202f020e2df..f8b6116a4f3c 100644 --- a/backend/internal/handler/ops_error_logger.go +++ b/backend/internal/handler/ops_error_logger.go @@ -1234,6 +1234,14 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { } } + // Google 格式错误体没有 type 字段:按网关回写时登记的错误类型补齐。 + if errType, code := service.OpsLocalErrorType(c); errType != "" && !parsed.StreamFailure && + (parsed.ErrorType == "" || parsed.ErrorType == "api_error") { + parsed.ErrorType = errType + if code != "" { + parsed.Code = code + } + } normalizedType := normalizeOpsErrorType(parsed.ErrorType, parsed.Code) phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, normalizedType, parsed.Message, parsed.Code, status) @@ -2333,6 +2341,11 @@ func classifyOpsErrorLog(c *gin.Context, errType, message, code string, status i return "routing", false, "platform", "gateway" } phase = classifyOpsPhase(errType, message, code) + if service.IsOpsRequestScopedError(c) { + // 失败由请求内容决定:只按错误类型分类,上游错误上下文不参与归因。 + isBusinessLimited = classifyOpsIsBusinessLimited(errType, phase, code, status, message) + return phase, isBusinessLimited, classifyOpsErrorOwner(phase, message), classifyOpsErrorSource(phase, message) + } routingCapacityLimited := isOpsRoutingCapacityLimited(c) clientBusinessLimited := service.HasOpsClientBusinessLimited(c) localModelConfiguration := clientBusinessLimited && service.OpsClientBusinessLimitedReason(c) == service.OpsClientBusinessLimitedReasonLocalModelConfiguration diff --git a/backend/internal/handler/ops_error_logger_google_test.go b/backend/internal/handler/ops_error_logger_google_test.go new file mode 100644 index 000000000000..392421dffd45 --- /dev/null +++ b/backend/internal/handler/ops_error_logger_google_test.go @@ -0,0 +1,135 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// recordGoogleFormatOpsError 让 handle 以 Google 错误格式回写错误,返回 ops 中间件记录的错误日志。 +func recordGoogleFormatOpsError(t *testing.T, handle gin.HandlerFunc) *service.OpsInsertErrorLogInput { + t.Helper() + setupOpsErrorLogTestQueue(t, 1) + gin.SetMode(gin.TestMode) + ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router := gin.New() + router.Use(OpsErrorLoggerMiddleware(ops)) + router.POST("/v1beta/cachedContents", handle) + + router.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/v1beta/cachedContents", nil)) + + require.Equal(t, int64(1), OpsErrorLogQueueLength()) + return (<-opsErrorLogQueue).entry +} + +func TestOpsErrorLoggerMiddleware_GoogleClientErrorsClassifiedAsClient(t *testing.T) { + for _, tc := range []struct { + status int + errType string + phase string + }{ + {status: http.StatusBadRequest, errType: "invalid_request_error", phase: "request"}, + {status: http.StatusRequestEntityTooLarge, errType: "invalid_request_error", phase: "request"}, + {status: http.StatusUnauthorized, errType: "authentication_error", phase: "auth"}, + {status: http.StatusForbidden, errType: "permission_error", phase: "request"}, + {status: http.StatusNotFound, errType: "not_found_error", phase: "request"}, + } { + t.Run(http.StatusText(tc.status), func(t *testing.T) { + entry := recordGoogleFormatOpsError(t, func(c *gin.Context) { + googleError(c, tc.status, "client request problem") + }) + require.Equal(t, tc.status, entry.StatusCode) + require.Equal(t, tc.errType, entry.ErrorType) + require.Equal(t, tc.phase, entry.ErrorPhase) + require.Equal(t, "client", entry.ErrorOwner) + require.Equal(t, "client_request", entry.ErrorSource) + require.Equal(t, "P3", entry.Severity) + }) + } +} + +func TestOpsErrorLoggerMiddleware_GooglePlatformErrorStaysPlatform(t *testing.T) { + entry := recordGoogleFormatOpsError(t, func(c *gin.Context) { + googlePlatformError(c, http.StatusBadRequest, "Explicit context caching is not available for model m: storage pricing is not configured") + }) + require.Equal(t, "api_error", entry.ErrorType) + require.Equal(t, "internal", entry.ErrorPhase) + require.Equal(t, "platform", entry.ErrorOwner) + require.Equal(t, "gateway", entry.ErrorSource) +} + +func TestOpsErrorLoggerMiddleware_GoogleServerErrorsNotTypedByStatus(t *testing.T) { + entry := recordGoogleFormatOpsError(t, func(c *gin.Context) { + googleError(c, http.StatusInternalServerError, "Failed to persist cached content") + }) + require.Equal(t, "api_error", entry.ErrorType) + require.Equal(t, "platform", entry.ErrorOwner) +} + +func TestOpsErrorLoggerMiddleware_GoogleBillingErrorMatchesClaudeFormat(t *testing.T) { + entry := recordGoogleFormatOpsError(t, func(c *gin.Context) { + status, code, message, _ := billingErrorDetails(service.ErrInsufficientBalance) + googleErrorWithType(c, status, code, "", message) + }) + require.Equal(t, "billing_error", entry.ErrorType) + require.Equal(t, "request", entry.ErrorPhase) + require.Equal(t, "client", entry.ErrorOwner) + require.True(t, entry.IsBusinessLimited) +} + +func TestOpsErrorLoggerMiddleware_GoogleConcurrencyErrorClassifiedAsRequest(t *testing.T) { + entry := recordGoogleFormatOpsError(t, func(c *gin.Context) { + googleConcurrencyError(c, &ConcurrencyError{SlotType: "user"}, "user") + }) + require.Equal(t, http.StatusTooManyRequests, entry.StatusCode) + require.Equal(t, "rate_limit_error", entry.ErrorType) + require.Equal(t, "request", entry.ErrorPhase) + require.Equal(t, "client", entry.ErrorOwner) +} + +func TestOpsErrorLoggerMiddleware_GoogleErrorAfterUpstreamFailureKeepsUpstreamAttribution(t *testing.T) { + entry := recordGoogleFormatOpsError(t, func(c *gin.Context) { + service.SetOpsUpstreamError(c, http.StatusBadRequest, "upstream rejected", "") + googleError(c, http.StatusBadRequest, "upstream rejected") + }) + require.Equal(t, "upstream", entry.ErrorPhase) + require.Equal(t, "provider", entry.ErrorOwner) + require.Equal(t, "upstream_http", entry.ErrorSource) +} + +func TestOpsErrorLoggerMiddleware_RequestScopedUpstreamErrorClassifiedAsClient(t *testing.T) { + entry := recordGoogleFormatOpsError(t, func(c *gin.Context) { + service.SetOpsUpstreamError(c, http.StatusBadRequest, "Invalid resource state for cache content 8079767076522164224.", "") + c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{ + Platform: service.PlatformGemini, AccountID: 173, UpstreamStatusCode: http.StatusBadRequest, Kind: "http_error", + Message: "Invalid resource state for cache content 8079767076522164224.", + }}) + service.MarkOpsRequestScopedError(c, "permission_error") + c.Data(http.StatusForbidden, "application/json", []byte(service.GeminiCachedContentNotFoundResponse)) + }) + require.Equal(t, http.StatusForbidden, entry.StatusCode) + require.Equal(t, "permission_error", entry.ErrorType) + require.Equal(t, "request", entry.ErrorPhase) + require.Equal(t, "client", entry.ErrorOwner) + require.Equal(t, "client_request", entry.ErrorSource) + require.False(t, entry.IsBusinessLimited) + require.NotNil(t, entry.UpstreamStatusCode, "上游原始错误保留为明细") + require.Equal(t, http.StatusBadRequest, *entry.UpstreamStatusCode) +} + +func TestSetOpsLocalErrorTypeClearsRequestScopedMark(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + service.MarkOpsRequestScopedError(c, "permission_error") + require.True(t, service.IsOpsRequestScopedError(c)) + + service.SetOpsLocalErrorType(c, "", "") + require.False(t, service.IsOpsRequestScopedError(c)) + errType, code := service.OpsLocalErrorType(c) + require.Empty(t, errType) + require.Empty(t, code) +} diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index 24e318cc5a04..ca60e2fcf559 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -78,7 +78,7 @@ func (h *PaymentHandler) GetPlans(c *gin.Context) { gi := groupInfo[p.GroupID] result = append(result, planWithPlatform{ ID: int64(p.ID), GroupID: p.GroupID, - GroupPlatform: gi.Platform, GroupName: gi.Name, + GroupPlatform: gi.Platform, GroupName: gi.I18n.LocalizeName(c.GetHeader("Accept-Language"), gi.Name), RateMultiplier: gi.RateMultiplier, PeakRateEnabled: gi.PeakRateEnabled, PeakStart: gi.PeakStart, PeakEnd: gi.PeakEnd, PeakRateMultiplier: gi.PeakRateMultiplier, Name: p.Name, Description: p.Description, Price: p.Price, OriginalPrice: p.OriginalPrice, @@ -126,7 +126,7 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) { gi := groupInfo[p.GroupID] planList = append(planList, checkoutPlan{ ID: int64(p.ID), GroupID: p.GroupID, - GroupPlatform: gi.Platform, GroupName: gi.Name, + GroupPlatform: gi.Platform, GroupName: gi.I18n.LocalizeName(c.GetHeader("Accept-Language"), gi.Name), RateMultiplier: gi.RateMultiplier, PeakRateEnabled: gi.PeakRateEnabled, PeakStart: gi.PeakStart, PeakEnd: gi.PeakEnd, PeakRateMultiplier: gi.PeakRateMultiplier, diff --git a/backend/internal/handler/redeem_handler.go b/backend/internal/handler/redeem_handler.go index 5d919cdc4eb5..f80f833b7e57 100644 --- a/backend/internal/handler/redeem_handler.go +++ b/backend/internal/handler/redeem_handler.go @@ -59,7 +59,7 @@ func (h *RedeemHandler) Redeem(c *gin.Context) { return } - response.Success(c, dto.RedeemCodeFromService(result)) + response.Success(c, dto.RedeemCodeFromService(result).Localized(c.GetHeader("Accept-Language"))) } // GetHistory returns the user's redemption history @@ -98,7 +98,7 @@ func (h *RedeemHandler) GetHistory(c *gin.Context) { } out := make([]dto.RedeemCode, 0, len(codes)) for i := range codes { - out = append(out, *dto.RedeemCodeFromService(&codes[i])) + out = append(out, *dto.RedeemCodeFromService(&codes[i]).Localized(c.GetHeader("Accept-Language"))) } response.Paginated(c, out, result.Total, page, pageSize) return @@ -115,7 +115,7 @@ func (h *RedeemHandler) GetHistory(c *gin.Context) { out := make([]dto.RedeemCode, 0, len(codes)) for i := range codes { - out = append(out, *dto.RedeemCodeFromService(&codes[i])) + out = append(out, *dto.RedeemCodeFromService(&codes[i]).Localized(c.GetHeader("Accept-Language"))) } response.Success(c, out) } diff --git a/backend/internal/handler/security_audit_errors.go b/backend/internal/handler/security_audit_errors.go index b6f42c7111f4..7c55e9bfde39 100644 --- a/backend/internal/handler/security_audit_errors.go +++ b/backend/internal/handler/security_audit_errors.go @@ -100,9 +100,14 @@ func googleSecurityAuditError(c *gin.Context, decision *securityaudit.Decision) return } if decision.Legacy != nil && decision.Legacy.Blocked { - googleError(c, securityAuditStatus(decision), securityAuditMessage(decision)) + googleErrorWithType(c, securityAuditStatus(decision), securityAuditErrorCode(decision), "", securityAuditMessage(decision)) return } + errType := "api_error" + if decision.Kind == securityaudit.DecisionBlock { + errType = "permission_error" + } + service.SetOpsLocalErrorType(c, errType, securityAuditErrorCode(decision)) status := securityAuditStatus(decision) googleStatus := googleapi.HTTPStatusToGoogleStatus(status) if status == http.StatusServiceUnavailable { diff --git a/backend/internal/handler/security_audit_order_test.go b/backend/internal/handler/security_audit_order_test.go index e9cb4e1f919a..2e0182930748 100644 --- a/backend/internal/handler/security_audit_order_test.go +++ b/backend/internal/handler/security_audit_order_test.go @@ -24,6 +24,7 @@ func TestPromptAuditGatePrecedesAccountBillingAndUpstreamSideEffects(t *testing. {file: "gateway_handler_chat_completions.go", function: "ChatCompletions", auditToken: "checkSecurityAudit"}, {file: "gateway_handler_responses.go", function: "Responses", auditToken: "checkSecurityAudit"}, {file: "gemini_v1beta_handler.go", function: "GeminiV1BetaModels", auditToken: "checkSecurityAudit"}, + {file: "gemini_cached_contents_handler.go", function: "GeminiCachedContentsCreate", auditToken: "checkSecurityAudit"}, {file: "openai_gateway_handler.go", function: "Responses", auditToken: "checkSecurityAudit"}, {file: "openai_gateway_handler.go", function: "Messages", auditToken: "checkSecurityAudit"}, {file: "openai_chat_completions.go", function: "ChatCompletions", auditToken: "checkSecurityAudit"}, diff --git a/backend/internal/handler/subscription_handler.go b/backend/internal/handler/subscription_handler.go index b40df8333656..282207f835dc 100644 --- a/backend/internal/handler/subscription_handler.go +++ b/backend/internal/handler/subscription_handler.go @@ -59,7 +59,7 @@ func (h *SubscriptionHandler) List(c *gin.Context) { out := make([]dto.UserSubscription, 0, len(subscriptions)) for i := range subscriptions { - out = append(out, *dto.UserSubscriptionFromService(&subscriptions[i])) + out = append(out, *dto.UserSubscriptionFromService(&subscriptions[i]).Localized(c.GetHeader("Accept-Language"))) } response.Success(c, out) } @@ -81,7 +81,7 @@ func (h *SubscriptionHandler) GetActive(c *gin.Context) { out := make([]dto.UserSubscription, 0, len(subscriptions)) for i := range subscriptions { - out = append(out, *dto.UserSubscriptionFromService(&subscriptions[i])) + out = append(out, *dto.UserSubscriptionFromService(&subscriptions[i]).Localized(c.GetHeader("Accept-Language"))) } response.Success(c, out) } @@ -110,8 +110,11 @@ func (h *SubscriptionHandler) GetProgress(c *gin.Context) { // Skip subscriptions with errors continue } + if sub.Group != nil { + progress.GroupName = sub.Group.I18n.LocalizeName(c.GetHeader("Accept-Language"), progress.GroupName) + } result = append(result, SubscriptionProgressInfo{ - Subscription: dto.UserSubscriptionFromService(sub), + Subscription: dto.UserSubscriptionFromService(sub).Localized(c.GetHeader("Accept-Language")), Progress: progress, }) } @@ -150,7 +153,7 @@ func (h *SubscriptionHandler) GetSummary(c *gin.Context) { // Add group info if preloaded if sub.Group != nil { - item.GroupName = sub.Group.Name + item.GroupName = sub.Group.I18n.LocalizeName(c.GetHeader("Accept-Language"), sub.Group.Name) if sub.Group.DailyLimitUSD != nil { item.DailyLimitUSD = *sub.Group.DailyLimitUSD } diff --git a/backend/internal/handler/usage_handler.go b/backend/internal/handler/usage_handler.go index 9a4284450bc4..987092ca46d3 100644 --- a/backend/internal/handler/usage_handler.go +++ b/backend/internal/handler/usage_handler.go @@ -254,7 +254,7 @@ func (h *UsageHandler) List(c *gin.Context) { out := make([]dto.UsageLog, 0, len(records)) for i := range records { - out = append(out, *dto.UsageLogFromService(&records[i])) + out = append(out, *dto.UsageLogFromService(&records[i]).Localized(c.GetHeader("Accept-Language"))) } response.Paginated(c, out, result.Total, page, pageSize) } @@ -400,7 +400,7 @@ func (h *UsageHandler) GetByID(c *gin.Context) { return } - response.Success(c, dto.UsageLogFromService(record)) + response.Success(c, dto.UsageLogFromService(record).Localized(c.GetHeader("Accept-Language"))) } // Stats handles getting usage statistics diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index cbe4e7e5a931..d21fcd75055c 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -121,11 +121,13 @@ func ProvideGatewayHandler( cfg *config.Config, settingService *service.SettingService, coordinator *securityaudit.Coordinator, + geminiCachedContentService *service.GeminiCachedContentService, ) *GatewayHandler { h := NewGatewayHandler(gatewayService, openAIGatewayService, geminiCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMsgQueueService, cfg, settingService) h.securityAuditCoordinator = coordinator + h.geminiCachedContentService = geminiCachedContentService return h } diff --git a/backend/internal/pkg/apicompat/anthropic_responses_test.go b/backend/internal/pkg/apicompat/anthropic_responses_test.go index bbfd9d282b4c..59e85d4691f5 100644 --- a/backend/internal/pkg/apicompat/anthropic_responses_test.go +++ b/backend/internal/pkg/apicompat/anthropic_responses_test.go @@ -2009,6 +2009,72 @@ func TestResponsesToAnthropicReasoningEffortByModelFamily(t *testing.T) { require.Equal(t, "medium", out.OutputConfig.Effort) } +func TestHaiku55ResponsesThinkingAndSampling(t *testing.T) { + for _, effort := range []string{"", "low", "medium", "high", "xhigh", "max", "none"} { + req := &ResponsesRequest{Model: "claude-haiku-5-5", Input: json.RawMessage(`"hello"`), Reasoning: &ResponsesReasoning{Effort: effort}} + out, err := ResponsesToAnthropicRequest(req) + require.NoError(t, err, effort) + require.Zero(t, out.Thinking.BudgetTokens) + if effort == "none" { + require.Equal(t, "disabled", out.Thinking.Type) + require.Equal(t, "low", out.OutputConfig.Effort) + } else { + require.Equal(t, "adaptive", out.Thinking.Type) + if effort == "" { + effort = "medium" + } + require.Equal(t, effort, out.OutputConfig.Effort) + } + } + // Haiku 5.5 accepts forced tool_choice, unlike opus/sonnet 5.5. + for _, choice := range []string{`"required"`, `{"type":"function","name":"lookup"}`} { + out, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-haiku-5-5", Input: json.RawMessage(`"hello"`), ToolChoice: json.RawMessage(choice)}) + require.NoError(t, err, choice) + require.NotEmpty(t, out.ToolChoice) + } + temperature := 0.7 + _, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-haiku-5-5", Input: json.RawMessage(`"hello"`), Temperature: &temperature}) + require.ErrorContains(t, err, "temperature") + for _, topP := range []float64{0.5, 0.995, 1} { + _, err = ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-haiku-5-5", Input: json.RawMessage(`"hello"`), TopP: &topP}) + require.ErrorContains(t, err, "top_p", "top_p=%v", topP) + } + _, err = ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-haiku-5-5", Input: json.RawMessage(`"hello"`), Reasoning: &ResponsesReasoning{Effort: "minimal"}}) + require.ErrorContains(t, err, "reasoning effort") + + temperature = 1 + topP := 0.99 + _, err = ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-haiku-5-5", Input: json.RawMessage(`"hello"`), Temperature: &temperature}) + require.NoError(t, err) + _, err = ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-haiku-5-5", Input: json.RawMessage(`"hello"`), TopP: &topP}) + require.NoError(t, err) + _, err = ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-haiku-5-5", Input: json.RawMessage(`"hello"`), Temperature: &temperature, TopP: &topP}) + require.ErrorContains(t, err, "together") +} + +func TestHaiku55SignedThinkingResponsesRoundTrip(t *testing.T) { + block := AnthropicContentBlock{Type: "thinking", Thinking: "", Signature: "signed-haiku-block"} + response := AnthropicToResponsesResponse(&AnthropicResponse{Model: "claude-haiku-5-5", Content: []AnthropicContentBlock{block, {Type: "text", Text: "progress"}, {Type: "tool_use", ID: "toolu_1", Name: "lookup", Input: json.RawMessage(`{}`)}}}) + require.Len(t, response.Output, 3) + require.NotEmpty(t, response.Output[0].EncryptedContent) + require.Equal(t, "message", response.Output[1].Type) + raw, err := json.Marshal(response.Output) + require.NoError(t, err) + var items []ResponsesInputItem + require.NoError(t, json.Unmarshal(raw, &items)) + items = append(items, ResponsesInputItem{Type: "function_call_output", CallID: response.Output[2].CallID, Output: "ok"}) + raw, err = json.Marshal(items) + require.NoError(t, err) + converted, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-haiku-5-5", Input: raw}) + require.NoError(t, err) + require.Len(t, converted.Messages, 2) + var blocks []AnthropicContentBlock + require.NoError(t, json.Unmarshal(converted.Messages[0].Content, &blocks)) + require.Equal(t, block, blocks[0]) + require.Equal(t, "text", blocks[1].Type) + require.Equal(t, "tool_use", blocks[2].Type) +} + func TestGPT6ChatSamplingAndCacheFields(t *testing.T) { temperature := 0.7 for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} { diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses_response.go b/backend/internal/pkg/apicompat/anthropic_to_responses_response.go index 186ee3baa3bd..2e7c85d3c94d 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses_response.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses_response.go @@ -53,7 +53,7 @@ func AnthropicToResponsesResponse(resp *AnthropicResponse) *ResponsesResponse { for _, block := range resp.Content { switch block.Type { case "thinking", "redacted_thinking": - if (claude.IsOpus55(resp.Model) || claude.IsSonnet55(resp.Model)) && (block.Signature != "" || block.Data != "") { + if (claude.IsOpus55(resp.Model) || claude.IsSonnet55(resp.Model) || claude.IsHaiku55(resp.Model)) && (block.Signature != "" || block.Data != "") { item := ResponsesOutput{Type: "reasoning", ID: generateItemID(), EncryptedContent: encodeAnthropicThinking(block)} if block.Thinking != "" { item.Summary = []ResponsesSummary{{Type: "summary_text", Text: block.Thinking}} @@ -72,7 +72,7 @@ func AnthropicToResponsesResponse(resp *AnthropicResponse) *ResponsesResponse { }) } case "text": - if (claude.IsOpus55(resp.Model) || claude.IsSonnet55(resp.Model)) && block.Text != "" { + if (claude.IsOpus55(resp.Model) || claude.IsSonnet55(resp.Model) || claude.IsHaiku55(resp.Model)) && block.Text != "" { outputs = append(outputs, ResponsesOutput{Type: "message", ID: generateItemID(), Role: "assistant", Status: "completed", Content: []ResponsesContentPart{{Type: "output_text", Text: block.Text}}}) continue } @@ -285,7 +285,7 @@ func ResponsesEventToSSE(evt ResponsesStreamEvent) (string, error) { func anthToResHandleMessageStart(evt *AnthropicStreamEvent, state *AnthropicEventToResponsesState) []ResponsesStreamEvent { if evt.Message != nil { state.ResponseID = evt.Message.ID - state.PreserveThinkingSignatures = state.PreserveThinkingSignatures || claude.IsOpus55(evt.Message.Model) || claude.IsSonnet55(evt.Message.Model) + state.PreserveThinkingSignatures = state.PreserveThinkingSignatures || claude.IsOpus55(evt.Message.Model) || claude.IsSonnet55(evt.Message.Model) || claude.IsHaiku55(evt.Message.Model) if state.Model == "" { state.Model = evt.Message.Model } diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go index 8da8cc6dc727..e00b9304e30f 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go @@ -16,7 +16,8 @@ import ( func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, error) { isOpus55 := claude.IsOpus55(req.Model) isSonnet55 := claude.IsSonnet55(req.Model) - system, messages, err := convertResponsesInputToAnthropic(req.Instructions, req.Input, isOpus55 || isSonnet55) + isHaiku55 := claude.IsHaiku55(req.Model) + system, messages, err := convertResponsesInputToAnthropic(req.Instructions, req.Input, isOpus55 || isSonnet55 || isHaiku55) if err != nil { return nil, err } @@ -56,11 +57,13 @@ func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, erro out.ToolChoice = tc } - // The 5.5 models reject manual thinking and forced tool use. Sonnet 5.5 - // additionally supports between_tools to disable up-front thinking. + // The 5.5 models reject manual thinking. Opus/Sonnet 5.5 additionally reject + // forced tool use; Haiku 5.5 accepts it (starts with the tool call, no thinking + // block). Sonnet 5.5 supports between_tools to disable up-front thinking; + // Haiku 5.5 uses disabled (at high effort or below) instead. // Resolve the upstream model before conversion: client aliases need not // identify a Claude model. - if isOpus55 || isSonnet55 { + if isOpus55 || isSonnet55 || isHaiku55 { var choice struct { Type string `json:"type"` } @@ -69,7 +72,7 @@ func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, erro return nil, fmt.Errorf("invalid tool_choice: %w", err) } } - if choice.Type == "any" || choice.Type == "tool" { + if (choice.Type == "any" || choice.Type == "tool") && !isHaiku55 { return nil, fmt.Errorf("%s does not support forced tool_choice; use auto or none", req.Model) } effort := "medium" @@ -82,6 +85,17 @@ func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, erro return nil, fmt.Errorf("claude-sonnet-5-5 does not support non-default top_p") } } + if isHaiku55 { + if req.Temperature != nil && *req.Temperature != 1 { + return nil, fmt.Errorf("claude-haiku-5-5 does not support non-default temperature") + } + if req.TopP != nil && *req.TopP != 0.99 { + return nil, fmt.Errorf("claude-haiku-5-5 does not support non-default top_p") + } + if req.Temperature != nil && req.TopP != nil { + return nil, fmt.Errorf("claude-haiku-5-5 does not allow temperature and top_p together; omit both") + } + } if req.Reasoning != nil && req.Reasoning.Effort != "" { effort = req.Reasoning.Effort } @@ -96,6 +110,16 @@ func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, erro out.OutputConfig.Effort = "low" return out, nil } + if isHaiku55 && effort == "none" { + // Haiku 5.5's lowest thinking mode is disabled (valid at high + // effort or below); low keeps token spend minimal like Sonnet above. + out.Thinking = &AnthropicThinking{Type: "disabled"} + if out.OutputConfig == nil { + out.OutputConfig = &AnthropicOutputConfig{} + } + out.OutputConfig.Effort = "low" + return out, nil + } switch effort { case "low", "medium", "high", "xhigh", "max": default: diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index 3578cedc9cf2..fac25987e978 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -223,6 +223,12 @@ var DefaultModels = []Model{ DisplayName: "Claude Haiku 4.5", CreatedAt: "2025-10-01T00:00:00Z", }, + { + ID: "claude-haiku-5-5", + Type: "model", + DisplayName: "Claude Haiku 5.5", + CreatedAt: "2026-10-07T00:00:00Z", + }, } // DefaultModelIDs 返回默认模型的 ID 列表 diff --git a/backend/internal/pkg/claude/constants_model_test.go b/backend/internal/pkg/claude/constants_model_test.go index 4a58d540d8e6..0a15c8d21489 100644 --- a/backend/internal/pkg/claude/constants_model_test.go +++ b/backend/internal/pkg/claude/constants_model_test.go @@ -43,3 +43,16 @@ func TestDefaultModelsContainsSonnet55(t *testing.T) { } t.Fatal("claude-sonnet-5-5 missing from DefaultModels") } + +func TestDefaultModelsContainsHaiku55(t *testing.T) { + t.Parallel() + + for _, model := range DefaultModels { + if model.ID == "claude-haiku-5-5" { + require.Equal(t, "Claude Haiku 5.5", model.DisplayName) + require.Equal(t, "2026-10-07T00:00:00Z", model.CreatedAt) + return + } + } + t.Fatal("claude-haiku-5-5 missing from DefaultModels") +} diff --git a/backend/internal/pkg/claude/effort_catalog.go b/backend/internal/pkg/claude/effort_catalog.go index c8b13539a79f..928af46d7d87 100644 --- a/backend/internal/pkg/claude/effort_catalog.go +++ b/backend/internal/pkg/claude/effort_catalog.go @@ -34,6 +34,7 @@ var effortFamilies = []effortFamily{ {family: "claude-opus-4-5", levels: effortLowMediumHigh}, {family: "claude-opus-5-5", levels: effortLowMediumHighXHighMax, adaptive: true}, {family: "claude-opus-5", levels: effortLowMediumHighXHighMax, adaptive: true}, + {family: "claude-haiku-5-5", levels: effortLowMediumHighXHighMax, adaptive: true}, } func findEffortFamily(model string) *effortFamily { @@ -93,8 +94,8 @@ func DefaultEffortForModel(model string) string { switch { case entry == nil: return "" - case entry.family == "claude-opus-5-5": - // Opus 5.5 defaults one level below every other effort family. + case entry.family == "claude-opus-5-5" || entry.family == "claude-haiku-5-5": + // Opus and Haiku 5.5 default to medium effort. return "medium" default: return "high" @@ -111,6 +112,11 @@ func IsSonnet55(model string) bool { return normalizeEffortModelID(model) == "claude-sonnet-5-5" } +// IsHaiku55 identifies the fixed Haiku 5.5 ID after provider/local suffix normalization. +func IsHaiku55(model string) bool { + return normalizeEffortModelID(model) == "claude-haiku-5-5" +} + func normalizeEffortModelID(model string) string { id := strings.ToLower(strings.TrimSpace(model)) id = strings.TrimPrefix(id, "models/") diff --git a/backend/internal/pkg/claude/effort_catalog_test.go b/backend/internal/pkg/claude/effort_catalog_test.go index 2bf55a414f90..c52e648c85ca 100644 --- a/backend/internal/pkg/claude/effort_catalog_test.go +++ b/backend/internal/pkg/claude/effort_catalog_test.go @@ -18,6 +18,9 @@ func TestEffortLevelsForModel(t *testing.T) { {model: "claude-opus-5", want: []string{"low", "medium", "high", "xhigh", "max"}}, {model: "anthropic/claude-opus-5.5", want: []string{"low", "medium", "high", "xhigh", "max"}}, {model: "claude-sonnet-5-5", want: []string{"low", "medium", "high", "xhigh", "max"}}, + {model: "claude-haiku-5-5", want: []string{"low", "medium", "high", "xhigh", "max"}}, + {model: "anthropic/claude-haiku-5.5", want: []string{"low", "medium", "high", "xhigh", "max"}}, + {model: "us.anthropic.claude-haiku-5-5", want: []string{"low", "medium", "high", "xhigh", "max"}}, {model: "us.anthropic.claude-sonnet-5-5", want: []string{"low", "medium", "high", "xhigh", "max"}}, {model: "claude-opus-4-5-20251101", want: []string{"low", "medium", "high"}}, {model: "claude-opus-4-5@20251101", want: []string{"low", "medium", "high"}}, @@ -35,6 +38,8 @@ func TestEffortLevelsForModel(t *testing.T) { func TestEffortThinkingModeAndDefault(t *testing.T) { t.Parallel() require.True(t, EffortUsesAdaptiveThinking("claude-opus-4-7")) + require.True(t, EffortUsesAdaptiveThinking("anthropic/claude-haiku-5.5")) + require.Equal(t, "medium", DefaultEffortForModel("claude-haiku-5-5")) require.False(t, EffortUsesAdaptiveThinking("claude-opus-4-5-20251101"), "Opus 4.5 thinks only with budget_tokens") require.Equal(t, "medium", DefaultEffortForModel("claude-opus-5-5")) require.Equal(t, "high", DefaultEffortForModel("claude-opus-4-8")) @@ -67,3 +72,19 @@ func TestIsSonnet55(t *testing.T) { require.False(t, IsSonnet55(model), model) } } + +func TestIsHaiku55(t *testing.T) { + t.Parallel() + for _, model := range []string{ + "claude-haiku-5-5", + "anthropic/claude-haiku-5.5", + "anthropic.claude-haiku-5-5", + "us.anthropic.claude-haiku-5-5", + "global.anthropic.claude-haiku-5-5-thinking", + } { + require.True(t, IsHaiku55(model), model) + } + for _, model := range []string{"claude-haiku-5", "claude-haiku-4-5", "claude-haiku-4-5-20251001", "claude-sonnet-5-5", "claude-opus-5-5"} { + require.False(t, IsHaiku55(model), model) + } +} diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 9060aa65fd32..51ebf1ae00e1 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -867,7 +867,7 @@ func (r *apiKeyRepository) ResetRateLimitWindows(ctx context.Context, id int64) // GetRateLimitData returns the current rate limit usage and window start times for an API key. func (r *apiKeyRepository) GetRateLimitData(ctx context.Context, id int64) (result *service.APIKeyRateLimitData, err error) { rows, err := r.sql.QueryContext(ctx, ` - SELECT usage_5h, usage_1d, usage_7d, window_5h_start, window_1d_start, window_7d_start + SELECT quota_used, usage_5h, usage_1d, usage_7d, window_5h_start, window_1d_start, window_7d_start FROM api_keys WHERE id = $1 AND deleted_at IS NULL`, id) @@ -883,7 +883,7 @@ func (r *apiKeyRepository) GetRateLimitData(ctx context.Context, id int64) (resu return nil, service.ErrAPIKeyNotFound } data := &service.APIKeyRateLimitData{} - if err := rows.Scan(&data.Usage5h, &data.Usage1d, &data.Usage7d, &data.Window5hStart, &data.Window1dStart, &data.Window7dStart); err != nil { + if err := rows.Scan(&data.QuotaUsed, &data.Usage5h, &data.Usage1d, &data.Usage7d, &data.Window5hStart, &data.Window1dStart, &data.Window7dStart); err != nil { return nil, err } return data, rows.Err() @@ -989,6 +989,7 @@ func groupEntityToService(g *dbent.Group) *service.Group { ID: g.ID, Name: g.Name, Description: derefString(g.Description), + I18n: g.I18n, Platform: g.Platform, RateMultiplier: g.RateMultiplier, IsExclusive: g.IsExclusive, diff --git a/backend/internal/repository/api_key_repo_integration_test.go b/backend/internal/repository/api_key_repo_integration_test.go index ada0a201b3c8..9d41f269b637 100644 --- a/backend/internal/repository/api_key_repo_integration_test.go +++ b/backend/internal/repository/api_key_repo_integration_test.go @@ -464,6 +464,10 @@ func (s *APIKeyRepoSuite) TestIncrementQuotaUsed_Basic() { newQuota, err = s.repo.IncrementQuotaUsed(s.ctx, key.ID, 2.5) s.Require().NoError(err, "IncrementQuotaUsed second") s.Require().Equal(4.0, newQuota, "第二次递增后应为 4.0") + + data, err := s.repo.GetRateLimitData(s.ctx, key.ID) + s.Require().NoError(err, "GetRateLimitData") + s.Require().Equal(4.0, data.QuotaUsed, "用量读取带回总额度已用量") } func (s *APIKeyRepoSuite) TestIncrementQuotaUsed_NotFound() { diff --git a/backend/internal/repository/billing_inflight_cache.go b/backend/internal/repository/billing_inflight_cache.go index 898cf3b52a20..8f83717ce606 100644 --- a/backend/internal/repository/billing_inflight_cache.go +++ b/backend/internal/repository/billing_inflight_cache.go @@ -25,6 +25,20 @@ func billingInflightKeys(userID int64) (string, string) { fmt.Sprintf("%s{%d}", billingInflightAmtKeyPrefix, userID) } +// 订阅 / API Key 维度在途预留(确定金额费用): +// - billing:inflight_scope:{scope} ZSET member=requestID score=过期时间(ms) +// - billing:inflight_scope_amt:{scope} HASH field=requestID value=预留金额(USD) +// +// 过期清理与释放语义与余额维度相同,不续期。 +const ( + billingInflightScopeKeyPrefix = "billing:inflight_scope:" + billingInflightScopeAmtKeyPrefix = "billing:inflight_scope_amt:" +) + +func billingInflightScopeKeys(scope string) (string, string) { + return billingInflightScopeKeyPrefix + "{" + scope + "}", billingInflightScopeAmtKeyPrefix + "{" + scope + "}" +} + var ( // KEYS: [1]=zset [2]=hash // ARGV: [1]=now_ms [2]=expire_at_ms [3]=member [4]=amount [5]=balance [6]=key_ttl_ms @@ -85,6 +99,28 @@ var ( redis.call('HDEL', KEYS[2], ARGV[1]) return 1 `) + + // KEYS: [1]=zset [2]=hash + // ARGV: [1]=now_ms [2]=expire_at_ms [3]=member [4]=amount [5]=key_ttl_ms + // 先清理已过期成员,再登记本次预留;返回登记前其他在途预留的合计(string),是否放行由调用方按权威用量判定。 + reserveInflightScopeScript = redis.NewScript(` + local now = tonumber(ARGV[1]) + local expired = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', now) + if #expired > 0 then + redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now) + redis.call('HDEL', KEYS[2], unpack(expired)) + end + local vals = redis.call('HVALS', KEYS[2]) + local sum = 0 + for i = 1, #vals do + sum = sum + (tonumber(vals[i]) or 0) + end + redis.call('ZADD', KEYS[1], tonumber(ARGV[2]), ARGV[3]) + redis.call('HSET', KEYS[2], ARGV[3], ARGV[4]) + redis.call('PEXPIRE', KEYS[1], ARGV[5]) + redis.call('PEXPIRE', KEYS[2], ARGV[5]) + return tostring(sum) + `) ) // ReserveInflightBalance 实现 service.InflightBalanceReservationCache。 @@ -138,7 +174,35 @@ func (c *billingCache) RenewInflightBalance(ctx context.Context, userID int64, r return n == 1, nil } +// ReserveInflightScope 实现 service.InflightScopeReservationCache。 +func (c *billingCache) ReserveInflightScope(ctx context.Context, scope, requestID string, amount float64, ttl time.Duration) (float64, error) { + zkey, hkey := billingInflightScopeKeys(scope) + now := time.Now().UnixMilli() + ttlMs := ttl.Milliseconds() + if ttlMs <= 0 { + ttlMs = 1 + } + res, err := reserveInflightScopeScript.Run(ctx, c.rdb, []string{zkey, hkey}, + now, + now+ttlMs, + requestID, + strconv.FormatFloat(amount, 'f', -1, 64), + ttlMs, + ).Text() + if err != nil { + return 0, err + } + return strconv.ParseFloat(res, 64) +} + +// ReleaseInflightScope 实现 service.InflightScopeReservationCache。 +func (c *billingCache) ReleaseInflightScope(ctx context.Context, scope, requestID string) error { + zkey, hkey := billingInflightScopeKeys(scope) + return releaseInflightBalanceScript.Run(ctx, c.rdb, []string{zkey, hkey}, requestID).Err() +} + var ( _ service.InflightBalanceReservationCache = (*billingCache)(nil) _ service.InflightBalanceReservationRenewer = (*billingCache)(nil) + _ service.InflightScopeReservationCache = (*billingCache)(nil) ) diff --git a/backend/internal/repository/billing_inflight_cache_test.go b/backend/internal/repository/billing_inflight_cache_test.go index ddab7c4de058..2fd642fe389a 100644 --- a/backend/internal/repository/billing_inflight_cache_test.go +++ b/backend/internal/repository/billing_inflight_cache_test.go @@ -285,3 +285,72 @@ func TestInflightReservation_RenewDoesNotResurrectExpiredMember(t *testing.T) { require.NoError(t, err) require.False(t, exists) } + +func TestInflightScopeReservation_ReturnsOthersAndReleases(t *testing.T) { + mr, cache, _ := newInflightTestEnv(t, true, 60) + ctx := context.Background() + + others, err := cache.ReserveInflightScope(ctx, "apikey:7", "r1", 3, time.Minute) + require.NoError(t, err) + require.Zero(t, others) + others, err = cache.ReserveInflightScope(ctx, "apikey:7", "r2", 2, time.Minute) + require.NoError(t, err) + require.InDelta(t, 3, others, 1e-9, "返回登记前其他在途预留的合计") + others, err = cache.ReserveInflightScope(ctx, "sub:7", "r3", 5, time.Minute) + require.NoError(t, err) + require.Zero(t, others, "不同维度互不影响") + + require.NoError(t, cache.ReleaseInflightScope(ctx, "apikey:7", "r1")) + require.NoError(t, cache.ReleaseInflightScope(ctx, "apikey:7", "r1")) + others, err = cache.ReserveInflightScope(ctx, "apikey:7", "r4", 1, time.Minute) + require.NoError(t, err) + require.InDelta(t, 2, others, 1e-9) + + mr.FastForward(2 * time.Minute) + others, err = cache.ReserveInflightScope(ctx, "apikey:7", "r5", 1, time.Minute) + require.NoError(t, err) + require.Zero(t, others, "过期预留不再计入") +} + +func TestReserveFixedCharge_ConcurrentChargesAdmitOnlyWhatAPIKeyQuotaCovers(t *testing.T) { + _, cache, svc := newInflightTestEnv(t, true, 60) + ctx := context.Background() + user := &service.User{ID: 42} + apiKey := &service.APIKey{ID: 9, UserID: user.ID, Quota: 1.0} + require.NoError(t, cache.SetUserBalance(ctx, user.ID, 100)) + + const n = 20 + var admitted atomic.Int32 + reservations := make(chan *service.InflightReservation, n) + var wg sync.WaitGroup + start := make(chan struct{}) + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + res, err := svc.ReserveFixedCharge(ctx, user, apiKey, nil, nil, 0.3) + if err != nil { + if !errors.Is(err, service.ErrAPIKeyQuotaExhausted) { + t.Errorf("unexpected error: %v", err) + } + return + } + admitted.Add(1) + reservations <- res + }() + } + close(start) + wg.Wait() + close(reservations) + + require.Equal(t, int32(3), admitted.Load(), "Key 额度 1.0、每笔 0.3:同时在途的不超过 3 笔") + for res := range reservations { + res.Release() + } + zkey, _ := billingInflightScopeKeys("apikey:9") + remaining, err := cache.rdb.ZCard(ctx, zkey).Result() + require.NoError(t, err) + require.Zero(t, remaining) + require.Equal(t, int64(0), inflightCount(t, cache, user.ID)) +} diff --git a/backend/internal/repository/gemini_cached_content_repo.go b/backend/internal/repository/gemini_cached_content_repo.go new file mode 100644 index 000000000000..42775448d7ea --- /dev/null +++ b/backend/internal/repository/gemini_cached_content_repo.go @@ -0,0 +1,205 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +type geminiCachedContentSQLExecutor interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row +} + +type geminiCachedContentRepository struct { + sql geminiCachedContentSQLExecutor +} + +func NewGeminiCachedContentRepository(db *sql.DB) service.GeminiCachedContentRepository { + return &geminiCachedContentRepository{sql: db} +} + +const geminiCachedContentColumns = `id, public_id, user_id, api_key_id, group_id, account_id, upstream_name, model, + upstream_model, display_name, total_token_count, expire_time, channel_usage, created_at, updated_at` + +func (r *geminiCachedContentRepository) Create(ctx context.Context, record *service.GeminiCachedContent) error { + channelUsage, err := json.Marshal(record.ChannelUsage) + if err != nil { + return err + } + row := r.sql.QueryRowContext(ctx, ` +INSERT INTO gemini_cached_contents (public_id, user_id, api_key_id, group_id, account_id, upstream_name, model, + upstream_model, display_name, total_token_count, expire_time, channel_usage) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) +RETURNING id, created_at, updated_at`, + record.PublicID, record.UserID, record.APIKeyID, record.GroupID, record.AccountID, record.UpstreamName, + record.Model, record.UpstreamModel, record.DisplayName, record.TotalTokenCount, record.ExpireTime, channelUsage) + if err := row.Scan(&record.ID, &record.CreatedAt, &record.UpdatedAt); err != nil { + return translatePersistenceError(err, nil, service.ErrGeminiCachedContentExists) + } + return nil +} + +func (r *geminiCachedContentRepository) GetForOwner(ctx context.Context, apiKeyID int64, publicID string) (*service.GeminiCachedContent, error) { + record, err := scanGeminiCachedContent(r.sql.QueryRowContext(ctx, `SELECT `+geminiCachedContentColumns+` + FROM gemini_cached_contents + WHERE public_id = $1 AND api_key_id = $2 AND deleted_at IS NULL`, publicID, apiKeyID)) + if err != nil { + return nil, translatePersistenceError(err, service.ErrGeminiCachedContentNotFound, nil) + } + return record, nil +} + +func (r *geminiCachedContentRepository) ListForOwner(ctx context.Context, apiKeyID, groupID int64, activeAt time.Time, beforeID int64, limit int) ([]*service.GeminiCachedContent, error) { + if limit <= 0 { + limit = service.GeminiCachedContentListDefaultPageSize + } + rows, err := r.sql.QueryContext(ctx, `SELECT `+geminiCachedContentColumns+` + FROM gemini_cached_contents + WHERE api_key_id = $1 AND group_id = $2 AND deleted_at IS NULL AND expire_time > $3 AND ($4 <= 0 OR id < $4) + ORDER BY id DESC + LIMIT $5`, apiKeyID, groupID, activeAt, beforeID, limit) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + var out []*service.GeminiCachedContent + for rows.Next() { + record, err := scanGeminiCachedContent(rows) + if err != nil { + return nil, err + } + out = append(out, record) + } + return out, rows.Err() +} + +func (r *geminiCachedContentRepository) BeginMutation(ctx context.Context, id int64, baseline time.Time, token string, lease time.Duration) (bool, error) { + result, err := r.sql.ExecContext(ctx, `UPDATE gemini_cached_contents + SET mutation_token = $2, mutation_lease_until = NOW() + make_interval(secs => $3::double precision) + WHERE id = $1 AND deleted_at IS NULL AND mutation_token IS NULL AND expire_time = $4`, + id, token, lease.Seconds(), baseline) + if err != nil { + return false, err + } + affected, err := result.RowsAffected() + if err != nil { + return false, err + } + if affected > 0 { + return true, nil + } + var exists bool + if err := r.sql.QueryRowContext(ctx, `SELECT EXISTS ( + SELECT 1 FROM gemini_cached_contents WHERE id = $1 AND deleted_at IS NULL)`, id).Scan(&exists); err != nil { + return false, err + } + if !exists { + return false, service.ErrGeminiCachedContentNotFound + } + return false, nil +} + +func (r *geminiCachedContentRepository) TakeOverStaleMutation(ctx context.Context, id int64, token string, lease time.Duration) (*service.GeminiCachedContent, error) { + record, err := scanGeminiCachedContent(r.sql.QueryRowContext(ctx, `UPDATE gemini_cached_contents + SET mutation_token = $2, mutation_lease_until = NOW() + make_interval(secs => $3::double precision) + WHERE id = $1 AND deleted_at IS NULL AND mutation_token IS NOT NULL AND mutation_lease_until < NOW() + RETURNING `+geminiCachedContentColumns, id, token, lease.Seconds())) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return record, nil +} + +func (r *geminiCachedContentRepository) CommitMutation(ctx context.Context, id int64, token string, expireTime time.Time) error { + result, err := r.sql.ExecContext(ctx, `UPDATE gemini_cached_contents + SET expire_time = $3, updated_at = NOW(), mutation_token = NULL, mutation_lease_until = NULL + WHERE id = $1 AND deleted_at IS NULL AND mutation_token = $2`, id, token, expireTime) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return service.ErrGeminiCachedContentMutationConflict + } + return nil +} + +func (r *geminiCachedContentRepository) ReleaseMutation(ctx context.Context, id int64, token string) error { + _, err := r.sql.ExecContext(ctx, `UPDATE gemini_cached_contents + SET mutation_token = NULL, mutation_lease_until = NULL + WHERE id = $1 AND mutation_token = $2`, id, token) + return err +} + +func (r *geminiCachedContentRepository) ListStaleMutations(ctx context.Context, limit int) ([]int64, error) { + rows, err := r.sql.QueryContext(ctx, `SELECT id FROM gemini_cached_contents + WHERE mutation_token IS NOT NULL AND deleted_at IS NULL AND mutation_lease_until < NOW() + ORDER BY mutation_lease_until + LIMIT $1`, limit) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + var ids []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +func (r *geminiCachedContentRepository) SoftDelete(ctx context.Context, id int64) error { + _, err := r.sql.ExecContext(ctx, `UPDATE gemini_cached_contents + SET deleted_at = NOW(), updated_at = NOW() + WHERE id = $1 AND deleted_at IS NULL`, id) + return err +} + +type geminiCachedContentScanner interface { + Scan(dest ...any) error +} + +func scanGeminiCachedContent(row geminiCachedContentScanner) (*service.GeminiCachedContent, error) { + var record service.GeminiCachedContent + var channelUsage []byte + if err := row.Scan( + &record.ID, + &record.PublicID, + &record.UserID, + &record.APIKeyID, + &record.GroupID, + &record.AccountID, + &record.UpstreamName, + &record.Model, + &record.UpstreamModel, + &record.DisplayName, + &record.TotalTokenCount, + &record.ExpireTime, + &channelUsage, + &record.CreatedAt, + &record.UpdatedAt, + ); err != nil { + return nil, err + } + if len(channelUsage) > 0 { + if err := json.Unmarshal(channelUsage, &record.ChannelUsage); err != nil { + return nil, err + } + } + return &record, nil +} diff --git a/backend/internal/repository/gemini_cached_content_repo_integration_test.go b/backend/internal/repository/gemini_cached_content_repo_integration_test.go new file mode 100644 index 000000000000..89399552c242 --- /dev/null +++ b/backend/internal/repository/gemini_cached_content_repo_integration_test.go @@ -0,0 +1,120 @@ +//go:build integration + +package repository + +import ( + "context" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestGeminiCachedContentRepository_Lifecycle(t *testing.T) { + ctx := context.Background() + tx := testTx(t) + repo := &geminiCachedContentRepository{sql: tx} + now := time.Now().UTC().Truncate(time.Microsecond) + suffix := time.Now().Format("150405.000000") + apiKeyID := int64(910001) + + newRecord := func(publicID string, keyID, groupID int64, expire time.Time) *service.GeminiCachedContent { + return &service.GeminiCachedContent{ + PublicID: publicID + suffix, UserID: 1, APIKeyID: keyID, GroupID: groupID, AccountID: 99, + UpstreamName: "cachedContents/up" + publicID, Model: "gemini-3.8-flash", UpstreamModel: "models/gemini-3.8-flash", + DisplayName: "label", TotalTokenCount: 2730, ExpireTime: expire, + ChannelUsage: service.ChannelUsageFields{ChannelID: 7, OriginalModel: "gemini-pro", ChannelMappedModel: "gemini-3.8-flash", BillingModelSource: "requested"}, + } + } + first := newRecord("a", apiKeyID, 42, now.Add(time.Hour)) + require.NoError(t, repo.Create(ctx, first)) + require.Positive(t, first.ID) + require.False(t, first.CreatedAt.IsZero()) + _, err := tx.ExecContext(ctx, `SAVEPOINT gemini_cached_content_duplicate`) + require.NoError(t, err) + require.ErrorIs(t, repo.Create(ctx, newRecord("a", apiKeyID, 42, now.Add(time.Hour))), service.ErrGeminiCachedContentExists) + _, err = tx.ExecContext(ctx, `ROLLBACK TO SAVEPOINT gemini_cached_content_duplicate`) + require.NoError(t, err) + + second := newRecord("b", apiKeyID, 42, now.Add(time.Hour)) + require.NoError(t, repo.Create(ctx, second)) + require.NoError(t, repo.Create(ctx, newRecord("expired", apiKeyID, 42, now.Add(-time.Minute)))) + require.NoError(t, repo.Create(ctx, newRecord("othergroup", apiKeyID, 43, now.Add(time.Hour)))) + require.NoError(t, repo.Create(ctx, newRecord("otherkey", apiKeyID+1, 42, now.Add(time.Hour)))) + + got, err := repo.GetForOwner(ctx, apiKeyID, first.PublicID) + require.NoError(t, err) + require.Equal(t, "cachedContents/upa", got.UpstreamName) + require.Equal(t, int64(2730), got.TotalTokenCount) + require.True(t, got.ExpireTime.Equal(first.ExpireTime)) + require.Equal(t, first.ChannelUsage, got.ChannelUsage, "创建时的渠道计费口径随记录持久化") + _, err = repo.GetForOwner(ctx, apiKeyID+1, first.PublicID) + require.ErrorIs(t, err, service.ErrGeminiCachedContentNotFound) + + list, err := repo.ListForOwner(ctx, apiKeyID, 42, now, 0, 10) + require.NoError(t, err) + require.Len(t, list, 2) + require.Equal(t, second.PublicID, list[0].PublicID) + page, err := repo.ListForOwner(ctx, apiKeyID, 42, now, list[0].ID, 10) + require.NoError(t, err) + require.Len(t, page, 1) + require.Equal(t, first.PublicID, page[0].PublicID) + + lease := 2 * time.Minute + acquired, err := repo.BeginMutation(ctx, first.ID, first.ExpireTime, "t1", lease) + require.NoError(t, err) + require.True(t, acquired) + acquired, err = repo.BeginMutation(ctx, first.ID, first.ExpireTime, "t2", lease) + require.NoError(t, err) + require.False(t, acquired, "有进行中的修改时不能再登记") + taken, err := repo.TakeOverStaleMutation(ctx, first.ID, "t2", lease) + require.NoError(t, err) + require.Nil(t, taken, "租约未过期不能接管") + require.ErrorIs(t, repo.CommitMutation(ctx, first.ID, "t2", now), service.ErrGeminiCachedContentMutationConflict) + + newExpire := now.Add(3 * time.Hour) + require.NoError(t, repo.CommitMutation(ctx, first.ID, "t1", newExpire)) + got, err = repo.GetForOwner(ctx, apiKeyID, first.PublicID) + require.NoError(t, err) + require.True(t, got.ExpireTime.Equal(newExpire)) + require.ErrorIs(t, repo.CommitMutation(ctx, first.ID, "t1", now), service.ErrGeminiCachedContentMutationConflict, "提交后修改意图已清除") + + acquired, err = repo.BeginMutation(ctx, first.ID, first.ExpireTime, "t3", lease) + require.NoError(t, err) + require.False(t, acquired, "到期时间已被其他修改改变") + acquired, err = repo.BeginMutation(ctx, first.ID, newExpire, "t3", lease) + require.NoError(t, err) + require.True(t, acquired) + stale, err := repo.ListStaleMutations(ctx, 10) + require.NoError(t, err) + require.NotContains(t, stale, first.ID) + + _, err = tx.ExecContext(ctx, `UPDATE gemini_cached_contents SET mutation_lease_until = NOW() - INTERVAL '1 second' WHERE id = $1`, first.ID) + require.NoError(t, err) + stale, err = repo.ListStaleMutations(ctx, 10) + require.NoError(t, err) + require.Contains(t, stale, first.ID) + taken, err = repo.TakeOverStaleMutation(ctx, first.ID, "t4", lease) + require.NoError(t, err) + require.NotNil(t, taken) + require.True(t, taken.ExpireTime.Equal(newExpire), "接管返回修改前的到期时间") + require.ErrorIs(t, repo.CommitMutation(ctx, first.ID, "t3", now), service.ErrGeminiCachedContentMutationConflict, "被接管后原持有者不能提交") + require.NoError(t, repo.ReleaseMutation(ctx, first.ID, "t3")) + acquired, err = repo.BeginMutation(ctx, first.ID, newExpire, "t5", lease) + require.NoError(t, err) + require.False(t, acquired, "原持有者释放不影响接管者") + require.NoError(t, repo.ReleaseMutation(ctx, first.ID, "t4")) + stale, err = repo.ListStaleMutations(ctx, 10) + require.NoError(t, err) + require.NotContains(t, stale, first.ID) + + require.NoError(t, repo.SoftDelete(ctx, first.ID)) + _, err = repo.GetForOwner(ctx, apiKeyID, first.PublicID) + require.ErrorIs(t, err, service.ErrGeminiCachedContentNotFound) + _, err = repo.BeginMutation(ctx, first.ID, newExpire, "t6", lease) + require.ErrorIs(t, err, service.ErrGeminiCachedContentNotFound) + list, err = repo.ListForOwner(ctx, apiKeyID, 42, now, 0, 10) + require.NoError(t, err) + require.Len(t, list, 1) +} diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index 06f0b47259aa..15e137686c73 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -167,6 +167,10 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi // 设置支持的模型系列(始终设置,空数组表示不限制) builder = builder.SetSupportedModelScopes(groupIn.SupportedModelScopes) + if groupIn.I18n != nil { + builder = builder.SetI18n(groupIn.I18n) + } + created, err := builder.Save(ctx) if err != nil { return translatePersistenceError(err, nil, service.ErrGroupExists) @@ -431,6 +435,12 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er // 处理 SupportedModelScopes(始终设置,空数组表示不限制) builder = builder.SetSupportedModelScopes(groupIn.SupportedModelScopes) + i18n := groupIn.I18n + if i18n == nil { + i18n = service.GroupI18n{} + } + builder = builder.SetI18n(i18n) + updated, err := builder.Save(ctx) if err != nil { return translatePersistenceError(err, service.ErrGroupNotFound, service.ErrGroupExists) @@ -957,6 +967,29 @@ func (r *groupRepository) deleteCascade(ctx context.Context, id int64, requireEm if _, err := txClient.Group.Delete().Where(group.IDEQ(id)).Exec(ctx); err != nil { return nil, err } + // Lock and validate the text value before using JSONB operators. A legacy + // malformed setting must not prevent deletion or be silently replaced. + var moderationValue string + err = scanSingleRow(ctx, exec, `SELECT value FROM settings WHERE key = $1 FOR UPDATE`, + []any{service.SettingKeyContentModerationConfig}, &moderationValue) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + // Keep the moderation scope in sync in the same transaction. Updating the + // setting row in SQL also serializes concurrent group deletions across nodes. + if err == nil && json.Valid([]byte(moderationValue)) { + if _, err := exec.ExecContext(ctx, `UPDATE settings + SET value = jsonb_set(value::jsonb, '{group_ids}', + (SELECT COALESCE(jsonb_agg(group_id ORDER BY ord), '[]'::jsonb) + FROM jsonb_array_elements(value::jsonb->'group_ids') WITH ORDINALITY AS ids(group_id, ord) + WHERE group_id <> to_jsonb($2::bigint)))::text, + updated_at = NOW() + WHERE key = $1 AND jsonb_typeof(value::jsonb->'group_ids') = 'array' + AND value::jsonb->'group_ids' @> jsonb_build_array($2::bigint)`, + service.SettingKeyContentModerationConfig, id); err != nil { + return nil, err + } + } if tx != nil { if err := tx.Commit(); err != nil { diff --git a/backend/internal/repository/group_repo_integration_test.go b/backend/internal/repository/group_repo_integration_test.go index 177fbe7f1770..ab7562172161 100644 --- a/backend/internal/repository/group_repo_integration_test.go +++ b/backend/internal/repository/group_repo_integration_test.go @@ -5,7 +5,9 @@ package repository import ( "context" "database/sql" + "encoding/json" "errors" + "fmt" "strings" "testing" "time" @@ -49,6 +51,55 @@ func TestGroupRepoSuite(t *testing.T) { suite.Run(t, new(GroupRepoSuite)) } +func TestModerationConfigWriteRejectsGroupDeletedAfterValidation(t *testing.T) { + ctx := context.Background() + client := testEntClient(t) + repo := newGroupRepositoryWithSQL(client, integrationDB) + settings := &settingRepository{client: client} + group := &service.Group{Name: "moderation-concurrent-delete", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard} + require.NoError(t, repo.Create(ctx, group)) + t.Cleanup(func() { _, _ = integrationDB.ExecContext(ctx, "DELETE FROM groups WHERE id=$1", group.ID) }) + key := service.SettingKeyContentModerationConfig + previous, previousErr := settings.GetValue(ctx, key) + t.Cleanup(func() { + if previousErr == nil { + _ = settings.Set(ctx, key, previous) + } else { + _ = settings.Delete(ctx, key) + } + }) + require.NoError(t, settings.Set(ctx, key, `{"all_groups":false,"group_ids":[]}`)) + + // Hold the deleter's group lock while the config writer starts with an ID + // that was valid before deletion. It must recheck after this transaction commits. + tx, err := integrationDB.BeginTx(ctx, nil) + require.NoError(t, err) + defer func() { _ = tx.Rollback() }() + var id int64 + require.NoError(t, tx.QueryRowContext(ctx, "SELECT id FROM groups WHERE id=$1 FOR UPDATE", group.ID).Scan(&id)) + finished := make(chan error, 1) + go func() { + finished <- settings.SetContentModerationConfig(ctx, `{"all_groups":false,"group_ids":[`+fmt.Sprint(group.ID)+`]}`, []int64{group.ID}) + }() + select { + case err := <-finished: + t.Fatalf("config write passed a locked group before deletion: %v", err) + case <-time.After(100 * time.Millisecond): + } + _, err = tx.ExecContext(ctx, "UPDATE groups SET deleted_at=NOW() WHERE id=$1", group.ID) + require.NoError(t, err) + require.NoError(t, tx.Commit()) + select { + case err := <-finished: + require.ErrorIs(t, err, service.ErrGroupNotFound) + case <-time.After(5 * time.Second): + t.Fatal("config write did not finish after deletion") + } + stored, err := settings.GetValue(ctx, key) + require.NoError(t, err) + require.JSONEq(t, `{"all_groups":false,"group_ids":[]}`, stored) +} + // --- Create / GetByID / Update / Delete --- func (s *GroupRepoSuite) TestCreate() { @@ -201,6 +252,48 @@ func (s *GroupRepoSuite) TestUpdate() { s.Require().Equal("updated", got.Name) } +func (s *GroupRepoSuite) TestI18n_RoundTrip() { + group := &service.Group{ + Name: "i18n-roundtrip", + Platform: service.PlatformAnthropic, + RateMultiplier: 1.0, + Status: service.StatusActive, + SubscriptionType: service.SubscriptionTypeStandard, + I18n: service.GroupI18n{ + "en": {Name: "Standard", Description: "Pro + Max pool"}, + }, + } + s.Require().NoError(s.repo.Create(s.ctx, group)) + + got, err := s.repo.GetByID(s.ctx, group.ID) + s.Require().NoError(err) + s.Require().Equal(group.I18n, got.I18n) + + got.I18n = service.GroupI18n{"zh": {Description: "号池"}} + s.Require().NoError(s.repo.Update(s.ctx, got)) + got, err = s.repo.GetByID(s.ctx, group.ID) + s.Require().NoError(err) + s.Require().Equal(service.GroupI18n{"zh": {Description: "号池"}}, got.I18n) + + // 未携带译文的写入落库为空对象,而不是 JSON null。 + got.I18n = nil + s.Require().NoError(s.repo.Update(s.ctx, got)) + var stored string + s.Require().NoError(scanSingleRow(s.ctx, s.tx, "SELECT i18n::text FROM groups WHERE id = $1", []any{group.ID}, &stored)) + s.Require().Equal("{}", stored) + + plain := &service.Group{ + Name: "i18n-default", + Platform: service.PlatformAnthropic, + RateMultiplier: 1.0, + Status: service.StatusActive, + SubscriptionType: service.SubscriptionTypeStandard, + } + s.Require().NoError(s.repo.Create(s.ctx, plain)) + s.Require().NoError(scanSingleRow(s.ctx, s.tx, "SELECT i18n::text FROM groups WHERE id = $1", []any{plain.ID}, &stored)) + s.Require().Equal("{}", stored) +} + func (s *GroupRepoSuite) TestGetByID_PreservesMessagesDispatchModelConfig() { group := &service.Group{ Name: "openai-dispatch", @@ -257,8 +350,14 @@ func (s *GroupRepoSuite) TestDeleteCascadeIfEmptyRejectsGroupWithNonDeletedAccou _, err := s.tx.ExecContext(s.ctx, "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, 1, NOW())", accountID, group.ID) s.Require().NoError(err) + config, marshalErr := json.Marshal(map[string]any{"group_ids": []int64{group.ID}, "all_groups": false}) + s.Require().NoError(marshalErr) + s.Require().NoError(NewSettingRepository(s.tx.Client()).Set(s.ctx, service.SettingKeyContentModerationConfig, string(config))) _, err = s.repo.DeleteCascadeIfEmpty(s.ctx, group.ID) s.Require().ErrorIs(err, service.ErrGroupNotEmpty) + stored, readErr := NewSettingRepository(s.tx.Client()).GetValue(s.ctx, service.SettingKeyContentModerationConfig) + s.Require().NoError(readErr) + s.Require().JSONEq(string(config), stored) _, err = s.repo.GetByID(s.ctx, group.ID) s.Require().NoError(err) var bindings int @@ -276,6 +375,76 @@ func (s *GroupRepoSuite) TestDeleteCascadeIfEmptyDeletesEmptyGroup() { s.Require().ErrorIs(err, service.ErrGroupNotFound) } +func (s *GroupRepoSuite) TestDeleteCascadeRemovesOnlySelectedModerationGroup() { + for _, guarded := range []bool{false, true} { + name := map[bool]string{false: "normal", true: "guarded"}[guarded] + s.Run(name, func() { + deleted := &service.Group{Name: "moderation-deleted-" + name, Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard} + kept := &service.Group{Name: "moderation-kept-" + name, Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard} + s.Require().NoError(s.repo.Create(s.ctx, deleted)) + s.Require().NoError(s.repo.Create(s.ctx, kept)) + config := map[string]any{"group_ids": []int64{kept.ID, deleted.ID}, "all_groups": false, "mode": "observe"} + raw, err := json.Marshal(config) + s.Require().NoError(err) + s.Require().NoError(NewSettingRepository(s.tx.Client()).Set(s.ctx, service.SettingKeyContentModerationConfig, string(raw))) + if guarded { + _, err = s.repo.DeleteCascadeIfEmpty(s.ctx, deleted.ID) + } else { + _, err = s.repo.DeleteCascade(s.ctx, deleted.ID) + } + s.Require().NoError(err) + stored, err := NewSettingRepository(s.tx.Client()).GetValue(s.ctx, service.SettingKeyContentModerationConfig) + s.Require().NoError(err) + var got struct { + GroupIDs []int64 `json:"group_ids"` + Mode string `json:"mode"` + } + s.Require().NoError(json.Unmarshal([]byte(stored), &got)) + s.Require().Equal([]int64{kept.ID}, got.GroupIDs) + s.Require().Equal("observe", got.Mode) + }) + } +} + +func (s *GroupRepoSuite) TestDeleteCascadeUnknownGroupPreservesModerationConfig() { + const original = `{"all_groups":false,"group_ids":[123,456],"mode":"observe"}` + s.Require().NoError(NewSettingRepository(s.tx.Client()).Set(s.ctx, service.SettingKeyContentModerationConfig, original)) + for _, guarded := range []bool{false, true} { + var err error + if guarded { + _, err = s.repo.DeleteCascadeIfEmpty(s.ctx, -1) + } else { + _, err = s.repo.DeleteCascade(s.ctx, -1) + } + s.Require().ErrorIs(err, service.ErrGroupNotFound) + stored, readErr := NewSettingRepository(s.tx.Client()).GetValue(s.ctx, service.SettingKeyContentModerationConfig) + s.Require().NoError(readErr) + s.Require().JSONEq(original, stored) + } +} + +func (s *GroupRepoSuite) TestDeleteCascadePreservesMalformedModerationConfig() { + for _, guarded := range []bool{false, true} { + name := map[bool]string{false: "normal", true: "guarded"}[guarded] + s.Run(name, func() { + group := &service.Group{Name: "malformed-moderation-" + name, Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard} + s.Require().NoError(s.repo.Create(s.ctx, group)) + const malformed = `{"group_ids":[oops]}` + s.Require().NoError(NewSettingRepository(s.tx.Client()).Set(s.ctx, service.SettingKeyContentModerationConfig, malformed)) + var err error + if guarded { + _, err = s.repo.DeleteCascadeIfEmpty(s.ctx, group.ID) + } else { + _, err = s.repo.DeleteCascade(s.ctx, group.ID) + } + s.Require().NoError(err) + stored, err := NewSettingRepository(s.tx.Client()).GetValue(s.ctx, service.SettingKeyContentModerationConfig) + s.Require().NoError(err) + s.Require().Equal(malformed, stored) + }) + } +} + func (s *GroupRepoSuite) TestDeleteCascadeIfEmptyIgnoresBindingsToSoftDeletedAccounts() { group := &service.Group{Name: "guarded-soft-deleted-account", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard} s.Require().NoError(s.repo.Create(s.ctx, group)) diff --git a/backend/internal/repository/scheduler_cache.go b/backend/internal/repository/scheduler_cache.go index b3e24da66a5b..808bd1a5088d 100644 --- a/backend/internal/repository/scheduler_cache.go +++ b/backend/internal/repository/scheduler_cache.go @@ -1007,7 +1007,7 @@ func filterSchedulerCredentials(credentials map[string]any) map[string]any { } // Candidate-list admission evaluates the account override before hydrating // the full account. Preserve routing and capability fields used by provider gates. - keys := []string{"model_mapping", "compact_model_mapping", "api_key", "project_id", "oauth_type", "plan_type", "account_scheduling_threshold", "base_url", "openai_capabilities"} + keys := []string{"model_mapping", "compact_model_mapping", "api_key", "project_id", "oauth_type", "plan_type", "account_scheduling_threshold", "base_url", "openai_capabilities", "auth_mode", service.GeminiExplicitCacheUpstreamCredentialKey, service.GeminiExplicitCacheUpstreamMaxTTLCredentialKey} filtered := make(map[string]any) for _, key := range keys { if value, ok := credentials[key]; ok && value != nil { diff --git a/backend/internal/repository/scheduler_cache_test.go b/backend/internal/repository/scheduler_cache_test.go index d12e77f74cd8..0542842fdf52 100644 --- a/backend/internal/repository/scheduler_cache_test.go +++ b/backend/internal/repository/scheduler_cache_test.go @@ -20,6 +20,23 @@ func TestFilterSchedulerCredentialsKeepsSubscriptionPlanType(t *testing.T) { require.NotContains(t, filtered, "refresh_token") } +func TestFilterSchedulerCredentialsKeepsGeminiExplicitCacheKeys(t *testing.T) { + filtered := filterSchedulerCredentials(map[string]any{ + "api_key": "k", + "base_url": "https://gw.example.com", + "auth_mode": "apikey", + "service_account_json": "secret", + service.GeminiExplicitCacheUpstreamCredentialKey: true, + service.GeminiExplicitCacheUpstreamMaxTTLCredentialKey: 3600, + }) + + require.Equal(t, "https://gw.example.com", filtered["base_url"]) + require.Equal(t, "apikey", filtered["auth_mode"]) + require.Equal(t, true, filtered[service.GeminiExplicitCacheUpstreamCredentialKey]) + require.Equal(t, 3600, filtered[service.GeminiExplicitCacheUpstreamMaxTTLCredentialKey]) + require.NotContains(t, filtered, "service_account_json") +} + func TestSchedulerMetadataAccountKeepsOpenAISubscriptionIdentity(t *testing.T) { account := service.Account{ ID: 24, diff --git a/backend/internal/repository/setting_repo.go b/backend/internal/repository/setting_repo.go index a4550e602069..815daa54be7a 100644 --- a/backend/internal/repository/setting_repo.go +++ b/backend/internal/repository/setting_repo.go @@ -2,6 +2,7 @@ package repository import ( "context" + "errors" "time" "github.com/Wei-Shaw/sub2api/ent" @@ -9,6 +10,34 @@ import ( "github.com/Wei-Shaw/sub2api/internal/service" ) +// SetContentModerationConfig holds live group rows until the setting is written. +// Deletion takes FOR UPDATE on the group before cleaning this setting, so both +// operations follow the same lock order: groups, then settings. +func (r *settingRepository) SetContentModerationConfig(ctx context.Context, value string, groupIDs []int64) error { + tx, err := r.client.Tx(ctx) + if err != nil && !errors.Is(err, ent.ErrTxStarted) { + return err + } + client := r.client + if err == nil { + defer func() { _ = tx.Rollback() }() + client = tx.Client() + } + if err := lockLiveGroups(ctx, client, groupIDs); err != nil { + return err + } + now := time.Now() + if err := client.Setting.Create().SetKey(service.SettingKeyContentModerationConfig). + SetValue(value).SetUpdatedAt(now).OnConflictColumns(setting.FieldKey). + UpdateNewValues().Exec(ctx); err != nil { + return err + } + if tx != nil { + return tx.Commit() + } + return nil +} + type settingRepository struct { client *ent.Client } diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 6e3aee88e638..9bef5ef19d10 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -84,6 +84,7 @@ var ProviderSet = wire.NewSet( NewUsageLogRepository, NewUsageBillingRepository, NewBatchImageRepository, + NewGeminiCachedContentRepository, NewIdempotencyRepository, NewUsageCleanupRepository, NewDashboardAggregationRepository, diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index 787a88f1ef62..282a83bbda00 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -370,6 +370,12 @@ func RegisterGatewayRoutes( gemini.GET("/models/:model", h.Gateway.GeminiV1BetaGetModel) // Gin treats ":" as a param marker, but Gemini uses "{model}:{action}" in the same segment. gemini.POST("/models/*modelAction", h.Gateway.GeminiV1BetaModels) + // 显式上下文缓存:网关自管资源 ID 与账号绑定,list/get 只读本地记录。 + gemini.POST("/cachedContents", h.Gateway.GeminiCachedContentsCreate) + gemini.GET("/cachedContents", h.Gateway.GeminiCachedContentsList) + gemini.GET("/cachedContents/:cacheID", h.Gateway.GeminiCachedContentsGet) + gemini.PATCH("/cachedContents/:cacheID", h.Gateway.GeminiCachedContentsPatch) + gemini.DELETE("/cachedContents/:cacheID", h.Gateway.GeminiCachedContentsDelete) } // OpenAI Responses API(不带v1前缀的别名)— auto-route based on group platform diff --git a/backend/internal/server/routes/prompt_audit_route_coverage_test.go b/backend/internal/server/routes/prompt_audit_route_coverage_test.go index 2d82f7c1daec..64b33d92b9ef 100644 --- a/backend/internal/server/routes/prompt_audit_route_coverage_test.go +++ b/backend/internal/server/routes/prompt_audit_route_coverage_test.go @@ -47,6 +47,7 @@ func TestEveryGatewayPOSTRouteIsClassifiedForPromptAuditCoverage(t *testing.T) { "/videos/edits": {"grok_media.go"}, "/videos/extensions": {"grok_media.go"}, "/models/*modelAction": {"gemini_v1beta_handler.go"}, + "/cachedContents": {"gemini_cached_contents_handler.go"}, "/tts": {"grok_audio.go"}, "/web_search": {"gateway_web_search.go"}, "/x_search": {"gateway_web_search.go"}, diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index a1cc0a0ff224..e1327cc3ffd7 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -158,11 +158,20 @@ func isOpenAIPersonalAccessTokenAuthMode(value string) bool { } } +// TempUnschedulableRule.Scope 的取值:命中后隔离的粒度。 +const ( + TempUnschedScopeAccount = "account" + TempUnschedScopeModel = "model" +) + type TempUnschedulableRule struct { ErrorCode int `json:"error_code"` Keywords []string `json:"keywords"` DurationMinutes int `json:"duration_minutes"` Description string `json:"description"` + // Scope 决定命中后的隔离粒度:account 暂停整个账号,model 只冷却本次请求的模型。 + // 为空时模型已知且非 401 走模型级,否则账号级。 + Scope string `json:"scope,omitempty"` } func (a *Account) IsActive() bool { @@ -371,6 +380,13 @@ func (a *Account) IsGeminiGoogleOne() bool { return a.Platform == PlatformGemini && a.Type == AccountTypeOAuth && a.GeminiOAuthType() == "google_one" } +// IsGeminiThirdPartyAPIKey reports whether a Gemini API Key account points at a +// non-Google base URL. Such upstreams carry no AI Studio tier or daily quota. +func (a *Account) IsGeminiThirdPartyAPIKey() bool { + return a != nil && a.Platform == PlatformGemini && a.Type == AccountTypeAPIKey && + !isOfficialGeminiAPIBaseURL(a.GetCredential("base_url")) +} + func (a *Account) CanGetUsage() bool { return a.Type == AccountTypeOAuth } @@ -492,6 +508,7 @@ func (a *Account) GetTempUnschedulableRules() []TempUnschedulableRule { Keywords: parseTempUnschedStrings(entry["keywords"]), DurationMinutes: parseTempUnschedInt(entry["duration_minutes"]), Description: parseTempUnschedString(entry["description"]), + Scope: normalizeTempUnschedScope(entry["scope"]), } if rule.ErrorCode <= 0 || rule.DurationMinutes <= 0 || len(rule.Keywords) == 0 { @@ -504,6 +521,18 @@ func (a *Account) GetTempUnschedulableRules() []TempUnschedulableRule { return rules } +// normalizeTempUnschedScope 只认 account / model 两个值,其余一律视为未设置。 +func normalizeTempUnschedScope(raw any) string { + switch strings.ToLower(strings.TrimSpace(parseTempUnschedString(raw))) { + case TempUnschedScopeAccount: + return TempUnschedScopeAccount + case TempUnschedScopeModel: + return TempUnschedScopeModel + default: + return "" + } +} + func parseTempUnschedString(value any) string { s, ok := value.(string) if !ok { @@ -1607,7 +1636,7 @@ func (a *Account) GetCodingPlanProvider() string { } baseURL := strings.ToLower(a.GetOpenAIBaseURL()) switch { - case strings.Contains(baseURL, "api.kimi.com/coding"): + case isKimiCodingBaseURL(a.GetOpenAIBaseURL()): return PlatformKimi case strings.Contains(baseURL, "bigmodel.cn"), strings.Contains(baseURL, "api.z.ai"): return PlatformZhipu @@ -1620,6 +1649,24 @@ func (a *Account) GetCodingPlanProvider() string { } } +func isKimiCodingBaseURL(baseURL string) bool { + parsed, err := url.Parse(baseURL) + if err != nil || parsed.Scheme != "https" || parsed.User != nil || parsed.Opaque != "" || + parsed.RawQuery != "" || parsed.ForceQuery || strings.Contains(baseURL, "#") { + return false + } + host := strings.ToLower(parsed.Host) + if host != "api.kimi.com" && host != "api.kimi.ai" && host != "api.kimi.com:443" && host != "api.kimi.ai:443" { + return false + } + switch parsed.EscapedPath() { + case "/coding", "/coding/", "/coding/v1", "/coding/v1/": + return true + default: + return false + } +} + func (a *Account) GetOpenAIAccessToken() string { if !a.IsOpenAI() { return "" diff --git a/backend/internal/service/account_kimi_coding_test.go b/backend/internal/service/account_kimi_coding_test.go new file mode 100644 index 000000000000..d2d79e1e0ee2 --- /dev/null +++ b/backend/internal/service/account_kimi_coding_test.go @@ -0,0 +1,83 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetCodingPlanProvider_KimiOfficialURLs(t *testing.T) { + for _, host := range []string{"api.kimi.com", "api.kimi.ai", "API.KIMI.AI", "api.kimi.ai:443"} { + for _, path := range []string{"/coding", "/coding/", "/coding/v1", "/coding/v1/"} { + t.Run(host+path, func(t *testing.T) { + account := &Account{Platform: PlatformKimi, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "account_mode": AccountModeCoding, + "base_url": "https://" + host + path, + }} + require.Equal(t, PlatformKimi, account.GetCodingPlanProvider()) + require.Equal(t, "https://"+host+"/coding/v1/usages", kimiQuotaURL(account.GetOpenAIBaseURL())) + account.Credentials["account_mode"] = AccountModePayG + require.Empty(t, account.GetCodingPlanProvider()) + }) + } + } +} + +func TestGetCodingPlanProvider_KimiRejectsUnsafeURLs(t *testing.T) { + for _, baseURL := range []string{ + "https://api.kimi.ai.attacker.example/coding/v1", + "https://api.kimi.com.attacker.example/coding/v1", + "https://fake-api.kimi.ai/coding/v1", + "https://relay.attacker.example/api.kimi.com/coding", + "https://relay.attacker.example/?target=api.kimi.ai/coding/v1", + "https://relay.attacker.example/#api.kimi.com/coding", + "https://api.kimi.ai@relay.attacker.example/coding/v1", + "https://user@api.kimi.ai/coding/v1", + "https://api.kimi.ai/coding-other", + "https://api.kimi.com/coding/v10", + "https://api.kimi.ai/coding/v1/usages", + "https://api.kimi.ai/v1?target=/coding/v1", + "https://api.kimi.ai/coding/v1?target=attacker", + "https://api.kimi.ai/coding/v1?", + "https://api.kimi.ai/coding/v1#attacker", + "https://api.kimi.ai/coding/v1#", + "https://api.kimi.ai/%63oding/v1", + "https://api.kimi.ai/coding%2fv1", + "https://api.kimi.ai/coding/../coding/v1", + "https://api.kimi.ai/CODING/v1", + "https://api.kimi.ai:8443/coding/v1", + "https://api.kimi.ai:/coding/v1", + "http://api.kimi.ai/coding/v1", + "//api.kimi.ai/coding/v1", + "https://api.kimi.ai/%zz", + } { + t.Run(baseURL, func(t *testing.T) { + account := &Account{Platform: PlatformKimi, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "account_mode": AccountModeCoding, + "base_url": baseURL, + }} + require.Empty(t, account.GetCodingPlanProvider()) + }) + } +} + +func TestGetCodingPlanProvider_UnrelatedProvidersUnchanged(t *testing.T) { + for _, testCase := range []struct { + platform string + baseURL string + }{ + {PlatformZhipu, "https://open.bigmodel.cn/api/coding/paas/v4"}, + {PlatformZhipu, "https://api.z.ai/api/coding/paas/v4"}, + {PlatformMiniMax, "https://api.minimax.io/v1"}, + {PlatformMiniMax, "https://api.minimaxi.com/v1"}, + {PlatformMiniMax, "https://api.minimax.com/v1"}, + } { + t.Run(testCase.baseURL, func(t *testing.T) { + account := &Account{Platform: testCase.platform, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "account_mode": AccountModeCoding, + "base_url": testCase.baseURL, + }} + require.Equal(t, testCase.platform, account.GetCodingPlanProvider()) + }) + } +} diff --git a/backend/internal/service/account_stats_pricing_test.go b/backend/internal/service/account_stats_pricing_test.go index 26eb645935ed..0f5d236ca17a 100644 --- a/backend/internal/service/account_stats_pricing_test.go +++ b/backend/internal/service/account_stats_pricing_test.go @@ -933,7 +933,7 @@ func TestResolveAccountStatsCost_Gemini36FlashTierUsesFallbackPricing(t *testing UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000}, 1, 0, "", time.Time{}, true, ) require.NotNil(t, result) - require.InDelta(t, 9.15, *result, 1e-12) + require.InDelta(t, 4.575, *result, 1e-12) } func TestResolveAccountStatsCost_AllMiss_ReturnsNil(t *testing.T) { diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 052563c4fa52..6ce29c2da17d 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -2770,14 +2770,7 @@ func (s *AccountTestService) buildGeminiOAuthRequest(ctx context.Context, accoun } func (s *AccountTestService) buildGeminiServiceAccountRequest(ctx context.Context, account *Account, modelID string, payload []byte) (*http.Request, error) { - if s.geminiTokenProvider == nil { - return nil, fmt.Errorf("gemini token provider not configured") - } - accessToken, err := s.geminiTokenProvider.GetAccessToken(ctx, account) - if err != nil { - return nil, fmt.Errorf("failed to get service account access token: %w", err) - } - fullURL, err := buildVertexGeminiURL(account.VertexProjectID(), account.VertexLocation(modelID), modelID, "streamGenerateContent", true) + fullURL, err := buildVertexGeminiAccountURL(account, modelID, "streamGenerateContent", true) if err != nil { return nil, err } @@ -2786,7 +2779,9 @@ func (s *AccountTestService) buildGeminiServiceAccountRequest(ctx context.Contex return nil, err } req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+accessToken) + if err := setVertexGeminiAuth(ctx, req, account, s.geminiTokenProvider); err != nil { + return nil, fmt.Errorf("failed to authenticate vertex request: %w", err) + } return req, nil } diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index 41d16bb6c9c7..96c8092a8ec0 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -372,7 +372,7 @@ func normalizeCreateGroupInputForSimpleMode(input *CreateGroupInput) { return } *input = CreateGroupInput{ - Name: input.Name, Description: input.Description, Platform: input.Platform, + Name: input.Name, Description: input.Description, I18n: input.I18n, Platform: input.Platform, RateMultiplier: 1, SubscriptionType: SubscriptionTypeStandard, } } @@ -381,7 +381,7 @@ func normalizeUpdateGroupInputForSimpleMode(input *UpdateGroupInput) { if input == nil { return } - *input = UpdateGroupInput{Name: input.Name, Description: input.Description} + *input = UpdateGroupInput{Name: input.Name, Description: input.Description, I18n: input.I18n} } func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) { @@ -394,6 +394,10 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn if input.RateMultiplier <= 0 { return nil, errors.New("rate_multiplier must be > 0") } + i18n, err := normalizeGroupI18n(input.I18n) + if err != nil { + return nil, err + } platform := NormalizeGroupPlatform(input.Platform) // 固定账号 manifest 配置:账号绑定发生在创建之后,创建时无法校验成员关系, @@ -565,6 +569,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn group := &Group{ Name: input.Name, Description: input.Description, + I18n: i18n, Platform: platform, RateMultiplier: input.RateMultiplier, IsExclusive: input.IsExclusive, @@ -776,6 +781,13 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if input.Description != nil { group.Description = *input.Description } + if input.I18n != nil { + i18n, err := normalizeGroupI18n(*input.I18n) + if err != nil { + return nil, err + } + group.I18n = i18n + } if input.Platform != "" { group.Platform = input.Platform } diff --git a/backend/internal/service/admin_group_duplicate.go b/backend/internal/service/admin_group_duplicate.go index 859d19d6f280..b31a4ab33733 100644 --- a/backend/internal/service/admin_group_duplicate.go +++ b/backend/internal/service/admin_group_duplicate.go @@ -97,6 +97,7 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group { return &Group{ Name: duplicateGroupName(source.Name, 1), Description: source.Description, + I18n: cloneGroupI18nForDuplicate(source.I18n), Platform: source.Platform, RateMultiplier: source.RateMultiplier, PeakRateEnabled: source.PeakRateEnabled, diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index d52e1468713b..532df156e8da 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -235,6 +235,7 @@ type AdminBoundAuthIdentityChannel struct { type CreateGroupInput struct { Name string Description string + I18n GroupI18n // 名称与描述的界面语言译文 Platform string RateMultiplier float64 IsExclusive bool @@ -315,6 +316,7 @@ type CreateGroupInput struct { type UpdateGroupInput struct { Name string Description *string + I18n *GroupI18n // nil 表示不修改;非 nil 整体替换 Platform string RateMultiplier *float64 // 使用指针以支持设置为0 IsExclusive *bool diff --git a/backend/internal/service/admin_service_group_i18n_test.go b/backend/internal/service/admin_service_group_i18n_test.go new file mode 100644 index 000000000000..c7361bbc63de --- /dev/null +++ b/backend/internal/service/admin_service_group_i18n_test.go @@ -0,0 +1,150 @@ +//go:build unit + +package service + +import ( + "context" + "net/http" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/stretchr/testify/require" +) + +func TestAdminService_CreateGroup_NormalizesI18n(t *testing.T) { + repo := &groupRepoStubForAdmin{} + svc := &adminServiceImpl{groupRepo: repo} + + _, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ + Name: "Claude Code - 标准", + Platform: PlatformAnthropic, + RateMultiplier: 1.0, + I18n: GroupI18n{ + "en": {Name: " Claude Code - Standard ", Description: "Pro + Max pool"}, + "zh": {}, + }, + }) + + require.NoError(t, err) + require.NotNil(t, repo.created) + require.Equal(t, GroupI18n{ + "en": {Name: "Claude Code - Standard", Description: "Pro + Max pool"}, + }, repo.created.I18n) +} + +func TestAdminService_CreateGroup_RejectsInvalidI18nLocale(t *testing.T) { + repo := &groupRepoStubForAdmin{} + svc := &adminServiceImpl{groupRepo: repo} + + _, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ + Name: "g", + Platform: PlatformAnthropic, + RateMultiplier: 1.0, + I18n: GroupI18n{"en_US": {Name: "x"}}, + }) + + require.Error(t, err) + appErr := infraerrors.FromError(err) + require.Equal(t, int32(http.StatusBadRequest), appErr.Code) + require.Equal(t, "INVALID_GROUP_I18N", appErr.Reason) + require.Nil(t, repo.created) +} + +func TestAdminService_CreateGroup_RejectsCaseCollidingI18nLocales(t *testing.T) { + repo := &groupRepoStubForAdmin{} + svc := &adminServiceImpl{groupRepo: repo} + + _, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ + Name: "默认", + Platform: PlatformAnthropic, + RateMultiplier: 1.0, + I18n: GroupI18n{"en": {Name: "English A"}, "EN": {Name: "English B"}}, + }) + + require.Error(t, err) + appErr := infraerrors.FromError(err) + require.Equal(t, int32(http.StatusBadRequest), appErr.Code) + require.Equal(t, "INVALID_GROUP_I18N", appErr.Reason) + require.Nil(t, repo.created) +} + +func TestAdminService_UpdateGroup_I18n(t *testing.T) { + newExisting := func() *Group { + return &Group{ + ID: 1, Name: "标准", Platform: PlatformAnthropic, Status: StatusActive, RateMultiplier: 1, + I18n: GroupI18n{"en": {Name: "Standard"}}, + } + } + + t.Run("omitted i18n keeps stored translations", func(t *testing.T) { + repo := &groupRepoStubForAdmin{getByID: newExisting()} + svc := &adminServiceImpl{groupRepo: repo} + + _, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{Name: "标准版"}) + + require.NoError(t, err) + require.Equal(t, "标准版", repo.updated.Name) + require.Equal(t, GroupI18n{"en": {Name: "Standard"}}, repo.updated.I18n) + }) + + t.Run("provided i18n replaces stored translations as a whole", func(t *testing.T) { + repo := &groupRepoStubForAdmin{getByID: newExisting()} + svc := &adminServiceImpl{groupRepo: repo} + next := GroupI18n{"zh": {Description: " 号池 "}} + + _, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{I18n: &next}) + + require.NoError(t, err) + require.Equal(t, GroupI18n{"zh": {Description: "号池"}}, repo.updated.I18n) + }) + + t.Run("empty i18n clears stored translations", func(t *testing.T) { + repo := &groupRepoStubForAdmin{getByID: newExisting()} + svc := &adminServiceImpl{groupRepo: repo} + next := GroupI18n{} + + _, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{I18n: &next}) + + require.NoError(t, err) + require.NotNil(t, repo.updated.I18n) + require.Empty(t, repo.updated.I18n) + }) + + t.Run("invalid i18n is rejected before persisting", func(t *testing.T) { + repo := &groupRepoStubForAdmin{getByID: newExisting()} + svc := &adminServiceImpl{groupRepo: repo} + next := GroupI18n{"not a locale": {Name: "x"}} + + _, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{I18n: &next}) + + require.Error(t, err) + require.Equal(t, "INVALID_GROUP_I18N", infraerrors.FromError(err).Reason) + require.Nil(t, repo.updated) + }) + + // 简易模式只放行名称与描述,译文属于同一类展示文案,同样放行。 + t.Run("simple mode keeps i18n editable", func(t *testing.T) { + repo := &groupRepoStubForAdmin{getByID: newExisting()} + svc := &adminServiceImpl{cfg: &config.Config{RunMode: config.RunModeSimple}, groupRepo: repo} + next := GroupI18n{"en": {Name: "Standard v2"}} + rate := 9.0 + + _, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{I18n: &next, RateMultiplier: &rate}) + + require.NoError(t, err) + require.Equal(t, GroupI18n{"en": {Name: "Standard v2"}}, repo.updated.I18n) + require.Equal(t, 1.0, repo.updated.RateMultiplier) + }) +} + +func TestCloneGroupForDuplicate_DropsNameTranslations(t *testing.T) { + source := &Group{ + ID: 7, Name: "标准", Platform: PlatformAnthropic, + I18n: GroupI18n{"en": {Name: "Standard", Description: "Pro + Max pool"}}, + } + + clone := cloneGroupForDuplicate(source, "op-1") + + require.Equal(t, GroupI18n{"en": {Description: "Pro + Max pool"}}, clone.I18n) +} diff --git a/backend/internal/service/antigravity_rate_limit_test.go b/backend/internal/service/antigravity_rate_limit_test.go index d632f14bda74..2721e54a7f70 100644 --- a/backend/internal/service/antigravity_rate_limit_test.go +++ b/backend/internal/service/antigravity_rate_limit_test.go @@ -844,6 +844,28 @@ func TestSetAntigravityModelRateLimits_GeminiWritesFamilyScope(t *testing.T) { require.Equal(t, antigravityGeminiModelRateLimitKey, repo.modelRateLimitCalls[1].modelKey) } +func TestSetAntigravityModelRateLimits_ImageDoesNotWriteFamilyScope(t *testing.T) { + repo := &stubAntigravityAccountRepo{} + svc := &AntigravityGatewayService{} + account := &Account{ID: 789, Platform: PlatformAntigravity} + resetAt := time.Now().Add(30 * time.Second) + + success := svc.setAntigravityModelRateLimits( + context.Background(), + repo, + account, + "gemini-3.1-flash-image-preview", + "[test]", + 429, + resetAt, + false, + ) + + require.True(t, success) + require.Len(t, repo.modelRateLimitCalls, 1) + require.Equal(t, "gemini-3.1-flash-image-preview", repo.modelRateLimitCalls[0].modelKey) +} + func TestSetAntigravityModelRateLimits_DoesNotDoubleMapCustomChain(t *testing.T) { repo := &stubAntigravityAccountRepo{} svc := &AntigravityGatewayService{} diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go index b00b03d43fbb..e20a2697eb7e 100644 --- a/backend/internal/service/api_key_service.go +++ b/backend/internal/service/api_key_service.go @@ -130,6 +130,8 @@ type apiKeyAllByUserIDLister interface { // APIKeyRateLimitData holds rate limit usage and window state for an API key. type APIKeyRateLimitData struct { + // QuotaUsed 是总额度已用量(quota_used)。 + QuotaUsed float64 Usage5h float64 Usage1d float64 Usage7d float64 diff --git a/backend/internal/service/billing_cache_service.go b/backend/internal/service/billing_cache_service.go index 691d2558f738..ea22efe2eb6f 100644 --- a/backend/internal/service/billing_cache_service.go +++ b/backend/internal/service/billing_cache_service.go @@ -3,6 +3,7 @@ package service import ( "context" "fmt" + "math" "strconv" "sync" "sync/atomic" @@ -573,6 +574,11 @@ func (s *BillingCacheService) InvalidateAPIKeyRateLimit(ctx context.Context, key // resets expired windows in-memory and triggers async DB reset, // and returns an error if any window limit is exceeded. func (s *BillingCacheService) checkAPIKeyRateLimits(ctx context.Context, apiKey *APIKey) error { + return s.checkAPIKeyRateLimitsWithPending(ctx, apiKey, 0) +} + +// checkAPIKeyRateLimitsWithPending 在窗口用量上叠加 pending(即将扣除的金额)后检查限速窗口。 +func (s *BillingCacheService) checkAPIKeyRateLimitsWithPending(ctx context.Context, apiKey *APIKey, pending float64) error { if s.cache == nil { // No cache: fall back to reading from DB directly if s.apiKeyRateLimitLoader == nil { @@ -583,7 +589,7 @@ func (s *BillingCacheService) checkAPIKeyRateLimits(ctx context.Context, apiKey return nil // Don't block requests on DB errors } return s.evaluateRateLimits(ctx, apiKey, data.Usage5h, data.Usage1d, data.Usage7d, - data.Window5hStart, data.Window1dStart, data.Window7dStart) + data.Window5hStart, data.Window1dStart, data.Window7dStart, pending) } cacheData, err := s.cache.GetAPIKeyRateLimit(ctx, apiKey.ID) @@ -628,11 +634,11 @@ func (s *BillingCacheService) checkAPIKeyRateLimits(ctx context.Context, apiKey t := time.Unix(cacheData.Window7d, 0) w7d = &t } - return s.evaluateRateLimits(ctx, apiKey, cacheData.Usage5h, cacheData.Usage1d, cacheData.Usage7d, w5h, w1d, w7d) + return s.evaluateRateLimits(ctx, apiKey, cacheData.Usage5h, cacheData.Usage1d, cacheData.Usage7d, w5h, w1d, w7d, pending) } -// evaluateRateLimits checks usage against limits, triggering async resets for expired windows. -func (s *BillingCacheService) evaluateRateLimits(ctx context.Context, apiKey *APIKey, usage5h, usage1d, usage7d float64, w5h, w1d, w7d *time.Time) error { +// evaluateRateLimits checks usage (plus pending) against limits, triggering async resets for expired windows. +func (s *BillingCacheService) evaluateRateLimits(ctx context.Context, apiKey *APIKey, usage5h, usage1d, usage7d float64, w5h, w1d, w7d *time.Time, pending float64) error { needsReset := false // Reset expired windows in-memory for check purposes @@ -675,13 +681,16 @@ func (s *BillingCacheService) evaluateRateLimits(ctx context.Context, apiKey *AP } // Check limits - if apiKey.RateLimit5h > 0 && usage5h >= apiKey.RateLimit5h { + exceeds := func(usage, limit float64) bool { + return limit > 0 && (usage >= limit || usage+pending > limit) + } + if exceeds(usage5h, apiKey.RateLimit5h) { return ErrAPIKeyRateLimit5hExceeded } - if apiKey.RateLimit1d > 0 && usage1d >= apiKey.RateLimit1d { + if exceeds(usage1d, apiKey.RateLimit1d) { return ErrAPIKeyRateLimit1dExceeded } - if apiKey.RateLimit7d > 0 && usage7d >= apiKey.RateLimit7d { + if exceeds(usage7d, apiKey.RateLimit7d) { return ErrAPIKeyRateLimit7dExceeded } return nil @@ -781,6 +790,110 @@ func (s *BillingCacheService) CheckBillingEligibility(ctx context.Context, user return nil } +// ReserveFixedCharge 为扣费前即可确定金额的费用(倍率后 USD,如显式缓存存储费)做准入并登记在途预留。 +// +// 余额模式要求缓存余额覆盖 amount 及用户的其他在途预留;订阅模式要求日 / 周 / 月剩余额度覆盖 amount; +// API Key 总额度与限速窗口同样叠加 amount。订阅与 API Key 的用量取库中权威值,并叠加同一订阅 / Key 上其他 +// 在途预留:先登记本次预留再读用量,并发的两笔费用中后读用量的一方必然看到另一方(预留或已落库的扣费)。 +// 返回的句柄可能为 nil(未登记任何预留);调用方须在 handler 结束时调用 HandlerDone,预留在计费任务落库后释放。 +// 须在 CheckBillingEligibility 通过后调用;简易模式不校验。 +func (s *BillingCacheService) ReserveFixedCharge(ctx context.Context, user *User, apiKey *APIKey, group *Group, subscription *UserSubscription, amount float64) (*InflightReservation, error) { + if user == nil || amount <= 0 || math.IsNaN(amount) || math.IsInf(amount, 0) { + return nil, nil + } + if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple { + return nil, nil + } + subscriptionBill := group != nil && group.IsSubscriptionType() && subscription != nil + if !subscriptionBill { + balance, err := s.GetUserBalance(ctx, user.ID) + if err != nil { + return nil, ErrBillingServiceUnavailable.WithCause(err) + } + if balance < amount { + return nil, ErrInsufficientBalance + } + } + res, err := s.reserveInflight(ctx, user, group, subscription, amount, false, false) + if err != nil { + return nil, err + } + var subscriptionPending, apiKeyPending float64 + if subscriptionBill && (group.HasDailyLimit() || group.HasWeeklyLimit() || group.HasMonthlyLimit()) { + res, subscriptionPending = s.reserveInflightScope(ctx, res, user.ID, inflightSubscriptionScope(subscription.ID), amount) + } + if apiKey != nil && (apiKey.Quota > 0 || apiKey.HasRateLimits()) { + res, apiKeyPending = s.reserveInflightScope(ctx, res, user.ID, inflightAPIKeyScope(apiKey.ID), amount) + } + if subscriptionBill { + err = s.checkFixedChargeSubscription(ctx, user.ID, group, amount+subscriptionPending) + } + if err == nil && apiKey != nil { + err = s.checkFixedChargeAPIKey(ctx, apiKey, amount+apiKeyPending) + } + if err != nil { + res.Release() + return nil, err + } + return res, nil +} + +// checkFixedChargeSubscription 按库中订阅用量校验日 / 周 / 月剩余额度覆盖 pending。 +func (s *BillingCacheService) checkFixedChargeSubscription(ctx context.Context, userID int64, group *Group, pending float64) error { + read := s.GetSubscriptionStatus + if s.subRepo != nil { + read = s.getSubscriptionFromDB + } + subData, err := read(ctx, userID, group.ID) + if err != nil { + return ErrBillingServiceUnavailable.WithCause(err) + } + if group.HasDailyLimit() && subData.DailyUsage+pending > *group.DailyLimitUSD { + return ErrDailyLimitExceeded + } + if group.HasWeeklyLimit() && subData.WeeklyUsage+pending > *group.WeeklyLimitUSD { + return ErrWeeklyLimitExceeded + } + if group.HasMonthlyLimit() && subData.MonthlyUsage+pending > *group.MonthlyLimitUSD { + return ErrMonthlyLimitExceeded + } + return nil +} + +// checkFixedChargeAPIKey 按库中 Key 用量校验总额度与限速窗口覆盖 pending;未接入仓储时使用请求上下文与限速缓存中的用量。 +func (s *BillingCacheService) checkFixedChargeAPIKey(ctx context.Context, apiKey *APIKey, pending float64) error { + if apiKey.Quota <= 0 && !apiKey.HasRateLimits() { + return nil + } + if s.apiKeyRateLimitLoader == nil { + if apiKey.Quota > 0 && apiKey.QuotaUsed+pending > apiKey.Quota { + return ErrAPIKeyQuotaExhausted + } + if apiKey.HasRateLimits() { + return s.checkAPIKeyRateLimitsWithPending(ctx, apiKey, pending) + } + return nil + } + data, err := s.apiKeyRateLimitLoader.GetRateLimitData(ctx, apiKey.ID) + if err != nil { + return ErrBillingServiceUnavailable.WithCause(err) + } + exceeds := func(usage, limit float64) bool { + return limit > 0 && usage+pending > limit + } + switch { + case exceeds(data.QuotaUsed, apiKey.Quota): + return ErrAPIKeyQuotaExhausted + case exceeds(data.EffectiveUsage5h(), apiKey.RateLimit5h): + return ErrAPIKeyRateLimit5hExceeded + case exceeds(data.EffectiveUsage1d(), apiKey.RateLimit1d): + return ErrAPIKeyRateLimit1dExceeded + case exceeds(data.EffectiveUsage7d(), apiKey.RateLimit7d): + return ErrAPIKeyRateLimit7dExceeded + } + return nil +} + // checkSimpleModeAPIKeyRateLimits is deliberately DB-authoritative. Redis // updates are asynchronous and can be dropped or missed after a committed // transaction, so using the cache here could let a limited simple-mode key diff --git a/backend/internal/service/billing_inflight_reservation.go b/backend/internal/service/billing_inflight_reservation.go index 7c6c7b45fc4d..e63c9db3390d 100644 --- a/backend/internal/service/billing_inflight_reservation.go +++ b/backend/internal/service/billing_inflight_reservation.go @@ -3,6 +3,7 @@ package service import ( "context" "math" + "strconv" "strings" "sync" "sync/atomic" @@ -25,6 +26,16 @@ type InflightBalanceReservationCache interface { ReleaseInflightBalance(ctx context.Context, userID int64, requestID string) error } +// InflightScopeReservationCache 订阅 / API Key 维度在途预留的缓存能力(可选),用于扣费前即可确定金额的费用; +// 未实现时这两个维度不登记预留(fail-open),准入仍按库中用量校验。 +type InflightScopeReservationCache interface { + // ReserveInflightScope 原子地清理 scope 下的过期预留并登记 requestID 的预留(ttl 后自动失效), + // 返回登记前该 scope 下其他在途预留的合计。 + ReserveInflightScope(ctx context.Context, scope, requestID string, amount float64, ttl time.Duration) (float64, error) + // ReleaseInflightScope 释放 scope 下 requestID 的预留(幂等)。 + ReleaseInflightScope(ctx context.Context, scope, requestID string) error +} + // InflightBalanceReservationRenewer 可选:续期仍存活的预留(流式长请求期间防止 TTL 过期)。 type InflightBalanceReservationRenewer interface { // RenewInflightBalance 若 requestID 仍存在则把其过期时间推迟到 now+ttl;返回是否仍存在。 @@ -54,11 +65,15 @@ const ( // 不再续期,所以最长持有时间受 TTL 约束(计费任务卡死时预留自动过期)。 // 所有方法对 nil 接收者安全。 type InflightReservation struct { + // cache 为 nil 表示未登记余额维度的预留。 cache InflightBalanceReservationCache userID int64 requestID string amount float64 ttl time.Duration + // scopes 是同一金额在订阅 / API Key 维度登记的预留(与余额维度共用 requestID),不续期。 + scopeCache InflightScopeReservationCache + scopes []string refs atomic.Int64 releaseOnce sync.Once @@ -116,8 +131,15 @@ func (r *InflightReservation) Release() { r.refs.Store(0) relCtx, relCancel := context.WithTimeout(context.Background(), inflightReservationReleaseTimeout) defer relCancel() - if err := r.cache.ReleaseInflightBalance(relCtx, r.userID, r.requestID); err != nil { - logger.LegacyPrintf("service.billing_cache", "Warning: inflight reservation release failed for user %d (expires by ttl): %v", r.userID, err) + if r.cache != nil { + if err := r.cache.ReleaseInflightBalance(relCtx, r.userID, r.requestID); err != nil { + logger.LegacyPrintf("service.billing_cache", "Warning: inflight reservation release failed for user %d (expires by ttl): %v", r.userID, err) + } + } + for _, scope := range r.scopes { + if err := r.scopeCache.ReleaseInflightScope(relCtx, scope, r.requestID); err != nil { + logger.LegacyPrintf("service.billing_cache", "Warning: inflight reservation release failed for %s (expires by ttl): %v", scope, err) + } } }) } @@ -213,7 +235,7 @@ func (s *BillingCacheService) InflightReservationFailClosedOnUnpriced() bool { // ReserveInflightBalance 简化封装:返回释放函数,不续期、不做计费交接(预留最长存活 TTL)。 func (s *BillingCacheService) ReserveInflightBalance(ctx context.Context, user *User, group *Group, subscription *UserSubscription, estimate float64) (func(), error) { - r, err := s.reserveInflight(ctx, user, group, subscription, estimate, false) + r, err := s.reserveInflight(ctx, user, group, subscription, estimate, false, true) if err != nil { return noopRelease, err } @@ -232,10 +254,50 @@ func (s *BillingCacheService) ReserveInflightBalance(ctx context.Context, user * // 开关关闭 / 简易模式 / 订阅模式 / estimate <= 0 / 缓存不支持 / 余额读取失败 / Redis 执行失败。 // 仅当 Redis 明确判定 缓存余额 - 在途合计 < estimate(且已有在途请求)时返回 ErrInsufficientBalance。 func (s *BillingCacheService) ReserveInflight(ctx context.Context, user *User, group *Group, subscription *UserSubscription, estimate float64) (*InflightReservation, error) { - return s.reserveInflight(ctx, user, group, subscription, estimate, true) + return s.reserveInflight(ctx, user, group, subscription, estimate, true, true) +} + +// inflightSubscriptionScope / inflightAPIKeyScope 是订阅 / API Key 维度在途预留的 scope 名。 +func inflightSubscriptionScope(subscriptionID int64) string { + return "sub:" + strconv.FormatInt(subscriptionID, 10) +} + +func inflightAPIKeyScope(apiKeyID int64) string { + return "apikey:" + strconv.FormatInt(apiKeyID, 10) +} + +// reserveInflightScope 在 scope 维度为 amount 登记在途预留并挂到 res 上(res 为 nil 时新建句柄), +// 返回句柄与登记前该 scope 下其他在途预留的合计。开关关闭 / 缓存不支持 / Redis 执行失败时不登记(fail-open),合计按 0。 +func (s *BillingCacheService) reserveInflightScope(ctx context.Context, res *InflightReservation, userID int64, scope string, amount float64) (*InflightReservation, float64) { + cfg, ok := s.inflightReservationConfig() + if !ok { + return res, 0 + } + sc, ok := s.cache.(InflightScopeReservationCache) + if !ok || sc == nil { + return res, 0 + } + if res == nil { + ttl := defaultInflightReservationTTL + if cfg.TTLSeconds > 0 { + ttl = time.Duration(cfg.TTLSeconds) * time.Second + } + res = &InflightReservation{userID: userID, requestID: uuid.NewString(), amount: amount, ttl: ttl} + res.refs.Store(1) + } + reserveCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), inflightReservationReserveTimeout) + pending, err := sc.ReserveInflightScope(reserveCtx, scope, res.requestID, amount, res.ttl) + cancel() + if err != nil { + logger.LegacyPrintf("service.billing_cache", "Warning: inflight reservation failed for %s (fail-open): %v", scope, err) + return res, 0 + } + res.scopeCache = sc + res.scopes = append(res.scopes, scope) + return res, pending } -func (s *BillingCacheService) reserveInflight(ctx context.Context, user *User, group *Group, subscription *UserSubscription, estimate float64, renew bool) (*InflightReservation, error) { +func (s *BillingCacheService) reserveInflight(ctx context.Context, user *User, group *Group, subscription *UserSubscription, estimate float64, renew, capped bool) (*InflightReservation, error) { cfg, ok := s.inflightReservationConfig() if !ok || user == nil { return nil, nil @@ -243,7 +305,7 @@ func (s *BillingCacheService) reserveInflight(ctx context.Context, user *User, g if group != nil && group.IsSubscriptionType() && subscription != nil { return nil, nil } - if cfg.MaxReservationUSD > 0 && estimate > cfg.MaxReservationUSD { + if capped && cfg.MaxReservationUSD > 0 && estimate > cfg.MaxReservationUSD { estimate = cfg.MaxReservationUSD } if estimate <= 0 || math.IsNaN(estimate) || math.IsInf(estimate, 0) { diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index c80094de8156..ee3a77272cd8 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -378,6 +378,20 @@ func (s *BillingService) initFallbackPricing() { CacheCreation1hPrice: 4e-6, SupportsCacheBreakdown: true, } + // Claude Haiku 5.5(2026-10-07,$0.10/$0.50,prompt 超 100k tokens 整单 5x)。 + // 长文本阶梯与目录 *_above_100k_tokens 绝对价同口径,缺目录时回退也不丢阶梯。 + s.fallbackPrices["claude-haiku-5-5"] = &ModelPricing{ + InputPricePerToken: 0.1e-6, + OutputPricePerToken: 0.5e-6, + CacheCreationPricePerToken: 0.125e-6, + CacheReadPricePerToken: 0.01e-6, + CacheCreation5mPrice: 0.125e-6, + CacheCreation1hPrice: 0.2e-6, + SupportsCacheBreakdown: true, + LongContextInputThreshold: 100000, + LongContextInputMultiplier: 5, + LongContextOutputMultiplier: 5, + } // Claude Fable 5.x uses the same input/output and cache-write prices, while // Fable 5.1 reduces cache reads from $1 to $0.25 per MTok. s.fallbackPrices["claude-fable-5"] = &ModelPricing{ @@ -408,14 +422,15 @@ func (s *BillingService) initFallbackPricing() { SupportsCacheBreakdown: false, } - // Gemini 3.6 Flash (Google AI pricing: $1.50 input / $7.50 output / - // $0.15 cached input per MTok). Antigravity's -high/-low/-medium/-tiered - // aliases are matched below so unavailable remote pricing never records - // token-bearing requests at $0. + // Gemini 3.6 Flash (Google AI pricing: $0.75 input / $3.75 output / + // $0.075 cached input per MTok, promotional through 2026-12-31; official + // rates double to $1.50/$7.50/$0.15 from 2027-01-01). Antigravity's + // -high/-low/-medium/-tiered aliases are matched below so unavailable + // remote pricing never records token-bearing requests at $0. s.fallbackPrices["gemini-3.6-flash"] = &ModelPricing{ - InputPricePerToken: 1.5e-6, - OutputPricePerToken: 7.5e-6, - CacheReadPricePerToken: 0.15e-6, + InputPricePerToken: 0.75e-6, + OutputPricePerToken: 3.75e-6, + CacheReadPricePerToken: 0.075e-6, SupportsCacheBreakdown: false, } @@ -929,6 +944,9 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { if claude.IsSonnet55(modelLower) { return s.fallbackPrices["claude-sonnet-5-5"] } + if claude.IsHaiku55(modelLower) { + return s.fallbackPrices["claude-haiku-5-5"] + } if strings.Contains(modelLower, "opus") { // "opus-5" 必须先判:不能用裸 "5" 匹配,否则 claude-opus-4-5 会被误判。 if strings.Contains(modelLower, "opus-5") || strings.Contains(modelLower, "opus5") { @@ -1262,6 +1280,18 @@ func (s *BillingService) GetModelPricing(model string) (*ModelPricing, error) { return nil, fmt.Errorf("%w for model: %s", ErrModelPricingUnavailable, model) } +// CacheStoragePricePerTokenHour 返回模型的显式上下文缓存存储单价(每 token 每小时,USD)。 +// 优先级:价格表(override_file 等)按确定性识别配置的值 > 内置型号价 > 内置 Pro/Flash 系列兜底价; +// 均未命中返回 0。 +func (s *BillingService) CacheStoragePricePerTokenHour(model string) float64 { + if s != nil && s.pricingService != nil { + if pricing := s.pricingService.GetIdentifiedModelPricing(model); pricing != nil && pricing.CacheStorageCostPerTokenPerHour > 0 { + return pricing.CacheStorageCostPerTokenPerHour + } + } + return builtinGeminiCacheStoragePrice(model) +} + // GetModelPricingWithChannel 获取模型定价,渠道配置的价格覆盖默认值。 // 与其他 token 字段一致,渠道留空的图片输入/输出价沿用目录价,见 applyChannelImagePriceOverrides。 func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing *ChannelModelPricing) (*ModelPricing, error) { @@ -1349,6 +1379,8 @@ type CostInput struct { Resolver *ModelPricingResolver // 定价解析器 Resolved *ResolvedPricing // 可选:预解析的定价结果(避免重复 Resolve 调用) LongContextBillingEnabled *bool + // DisableModelLongContextPricing 豁免模型价卡的长上下文附加费,不改变自定义区间价格。 + DisableModelLongContextPricing bool } // CalculateCostUnified 统一计费入口,支持三种计费模式。 @@ -1370,7 +1402,7 @@ func (s *BillingService) CalculateCostUnified(input CostInput) (*CostBreakdown, pricingAt = timezone.Now() } pricing = applyDeepseekTimePricing(pricing, pricingAt) - breakdown := s.computeTokenBreakdown(pricing, input.Tokens, input.RateMultiplier, input.ServiceTier, applyLongContextBilling) + breakdown := s.computeTokenBreakdown(pricing, input.Tokens, input.RateMultiplier, input.ServiceTier, applyLongContextBilling && !input.DisableModelLongContextPricing) applyCostBreakdownMultiplier(breakdown, reasoningEffortBillingMultiplier(input.ReasoningEffort, pricing.ReasoningEffortMultipliers)) return breakdown, nil } @@ -1444,7 +1476,7 @@ func (s *BillingService) calculateTokenCost(resolved *ResolvedPricing, input Cos } // 官方长上下文阶梯仅在无区间定价时应用(区间定价已包含上下文分层)。 - applyLongCtx := len(resolved.Intervals) == 0 && contextTierPricingEnabled + applyLongCtx := len(resolved.Intervals) == 0 && contextTierPricingEnabled && !input.DisableModelLongContextPricing breakdown := s.computeTokenBreakdown(pricing, input.Tokens, input.RateMultiplier, input.ServiceTier, applyLongCtx) applyCostBreakdownMultiplier(breakdown, resolvedChannelTimeMultiplier(resolved, input.PricingAt)) diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index b8ae7b3491d5..45544508ec1c 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -2055,6 +2055,33 @@ func TestNewModelPricingCatalogFallbackAndContext(t *testing.T) { require.False(t, cost.LongContextBillingApplied) }) } + // claude-haiku-5-5:prompt 超 100k tokens 整单 5x(官方定价), + // billing/catalog/fallback 三源口径一致(issue #7919)。 + for _, model := range []string{ + "claude-haiku-5-5", + "anthropic/claude-haiku-5.5", + } { + t.Run(source+"/"+model, func(t *testing.T) { + for _, n := range []int{99999, 100000, 100001} { + tokens := UsageTokens{ + InputTokens: n - 3000, OutputTokens: 500, + CacheReadTokens: 2000, CacheCreationTokens: 1000, + } + cost, err := svc.CalculateCost(model, tokens, 1) + require.NoError(t, err) + im, om := 1.0, 1.0 + if n > 100000 { + im, om = 5.0, 5.0 + } + require.InDelta(t, float64(tokens.InputTokens)*1e-7*im, cost.InputCost, 1e-10) + require.InDelta(t, 1000*1.25e-7*im, cost.CacheCreationCost, 1e-10) + require.InDelta(t, 2000*1e-8*im, cost.CacheReadCost, 1e-10) + require.InDelta(t, 500*5e-7*om, cost.OutputCost, 1e-10) + require.InDelta(t, cost.InputCost+cost.OutputCost+cost.CacheCreationCost+cost.CacheReadCost, cost.TotalCost, 1e-10) + require.Equal(t, n > 100000, cost.LongContextBillingApplied) + } + }) + } } } diff --git a/backend/internal/service/channel_available.go b/backend/internal/service/channel_available.go index 262a384821b4..3a6c5aba2fcb 100644 --- a/backend/internal/service/channel_available.go +++ b/backend/internal/service/channel_available.go @@ -16,6 +16,7 @@ import ( type AvailableGroupRef struct { ID int64 Name string + I18n GroupI18n Platform string SubscriptionType string RateMultiplier float64 @@ -66,6 +67,7 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel, groupByID[g.ID] = AvailableGroupRef{ ID: g.ID, Name: g.Name, + I18n: g.I18n, Platform: g.Platform, SubscriptionType: g.SubscriptionType, RateMultiplier: g.RateMultiplier, diff --git a/backend/internal/service/channel_monitor_checker.go b/backend/internal/service/channel_monitor_checker.go index ddb5e78317d5..c8b4fdf06940 100644 --- a/backend/internal/service/channel_monitor_checker.go +++ b/backend/internal/service/channel_monitor_checker.go @@ -636,6 +636,8 @@ var monitorAPIKeyPatterns = []struct { {regexp.MustCompile(`xai-[A-Za-z0-9_-]{6,}`), "xai-***REDACTED***"}, // Gemini / Google API Key:固定前缀 + 35 位 {regexp.MustCompile(`AIza[A-Za-z0-9_-]{35}`), "AIza***REDACTED***"}, + // Gemini auth key:AQ.Ab 前缀 + 48 位,长度放宽以覆盖格式变体 + {regexp.MustCompile(`AQ\.Ab[A-Za-z0-9_-]{40,}`), "AQ.***REDACTED***"}, // JWT 三段式(Bearer 后常出现):eyJxxx.eyJxxx.signature {regexp.MustCompile(`eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}`), "eyJ***REDACTED.JWT***"}, } @@ -643,7 +645,7 @@ var monitorAPIKeyPatterns = []struct { // sanitizeErrorMessage 擦除错误/响应文本中可能泄露的 API key。 // 处理两类来源: // 1. URL query 中的 ?key= / ?api_key= 等(Go *url.Error 会回填完整 URL) -// 2. 上游 HTTP body 文本里直接出现的 sk-* / xai-* / AIza* / JWT 等密钥碎片 +// 2. 上游 HTTP body 文本里直接出现的 sk-* / xai-* / AIza* / AQ.* / JWT 等密钥碎片 // // 注意:与 gemini_messages_compat_service.go 的 sanitizeUpstreamErrorMessage 关注点类似但参数集更广, // 监控模块独立维护,避免互相耦合。 diff --git a/backend/internal/service/channel_monitor_checker_body_test.go b/backend/internal/service/channel_monitor_checker_body_test.go index 922eb86732d9..5c347cae89ab 100644 --- a/backend/internal/service/channel_monitor_checker_body_test.go +++ b/backend/internal/service/channel_monitor_checker_body_test.go @@ -279,6 +279,17 @@ func TestRunCheckForModel_Grok_RedactsXAIKeyFromUpstreamBody(t *testing.T) { } } +func TestSanitizeErrorMessage_RedactsGeminiAuthKey(t *testing.T) { + authKey := "AQ." + "Ab" + strings.Repeat("Xy9_-z", 8) + got := sanitizeErrorMessage(`{"error":{"message":"API key not valid: ` + authKey + `"}}`) + if strings.Contains(got, authKey) { + t.Fatalf("Gemini auth key leaked: %q", got) + } + if !strings.Contains(got, "AQ.***REDACTED***") { + t.Fatalf("expected redaction marker, got %q", got) + } +} + func TestRunCheckForModel_OpenAIResponses_DefaultRequest(t *testing.T) { h := &openAICaptureHandler{} endpoint := setupFakeOpenAI(t, h) diff --git a/backend/internal/service/cn_provider_probe_url_test.go b/backend/internal/service/cn_provider_probe_url_test.go index f28508840053..fe0fe1da4152 100644 --- a/backend/internal/service/cn_provider_probe_url_test.go +++ b/backend/internal/service/cn_provider_probe_url_test.go @@ -6,7 +6,9 @@ package service import ( "context" + "io" "net/http" + "strings" "testing" "github.com/Wei-Shaw/sub2api/internal/config" @@ -72,15 +74,13 @@ func (r *fakeCNProbeAccountRepo) GetByID(ctx context.Context, id int64) (*Accoun return r.account, nil } -// kimi coding 账号的 base_url 指向中转(含 api.kimi.com/coding 路径段即可被识别 -// 为 kimi coding plan)→ 衍生额度端点落在中转主机上,白名单未列名必须拒绝。 func TestCNProviderQuotaService_RejectsURLBlockedByPolicy(t *testing.T) { repo := &fakeCNProbeAccountRepo{account: &Account{ ID: 1, Platform: PlatformKimi, Type: AccountTypeAPIKey, Status: StatusActive, Credentials: map[string]any{ "account_mode": "coding", "api_key": "sk-test", - "base_url": "https://relay.attacker.example/api.kimi.com/coding", + "base_url": "https://api.kimi.ai/coding/v1", }, }} upstream := &recordingHTTPUpstream{} @@ -92,6 +92,79 @@ func TestCNProviderQuotaService_RejectsURLBlockedByPolicy(t *testing.T) { require.Zero(t, upstream.calls, "probe must not issue any upstream request when URL policy rejects the target") } +func TestCNProviderQuotaService_RejectsSpoofedKimiURL(t *testing.T) { + repo := &fakeCNProbeAccountRepo{account: &Account{ + ID: 1, Platform: PlatformKimi, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "account_mode": AccountModeCoding, + "api_key": "test-only-placeholder", + "base_url": "https://relay.attacker.example/api.kimi.com/coding", + }, + }} + upstream := &recordingHTTPUpstream{} + svc := NewCNProviderQuotaService(repo, nil, upstream, nil) + _, err := svc.QueryUsage(context.Background(), 1) + require.ErrorContains(t, err, "CN_QUOTA_NOT_CODING_PLAN") + require.Zero(t, upstream.calls) +} + +type kimiQuotaTestRepo struct { + fakeCNProbeAccountRepo + updates map[string]any +} + +func (r *kimiQuotaTestRepo) UpdateExtra(ctx context.Context, id int64, updates map[string]any) error { + r.updates = updates + return nil +} + +type kimiQuotaTestUpstream struct { + recordingHTTPUpstream + request *http.Request +} + +func (u *kimiQuotaTestUpstream) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) { + u.calls++ + u.request = req + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"limits":[{"detail":{"limit":100,"remaining":75}}],"usage":{"limit":1000,"remaining":800}}`)), + }, nil +} + +func TestCNProviderQuotaService_KimiOfficialHosts(t *testing.T) { + for _, host := range []string{"api.kimi.com", "api.kimi.ai"} { + for _, path := range []string{"/coding", "/coding/", "/coding/v1", "/coding/v1/"} { + t.Run(host+path, func(t *testing.T) { + repo := &kimiQuotaTestRepo{fakeCNProbeAccountRepo: fakeCNProbeAccountRepo{account: &Account{ + ID: 1, Platform: PlatformKimi, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "account_mode": AccountModeCoding, + "api_key": "test-only-placeholder", + "base_url": "https://" + host + path, + }, + }}} + upstream := &kimiQuotaTestUpstream{} + svc := NewCNProviderQuotaService(repo, nil, upstream, cnProbeAllowlistConfig(host)) + result, err := svc.QueryUsage(context.Background(), 1) + require.NoError(t, err) + require.True(t, result.Success) + require.True(t, result.CredentialValid) + require.True(t, result.Persisted) + require.Equal(t, PlatformKimi, result.Provider) + require.Len(t, result.Tiers, 2) + require.Equal(t, float64(25), result.Tiers[0].UsedPercent) + require.Equal(t, float64(20), result.Tiers[1].UsedPercent) + require.Equal(t, float64(25), repo.updates["kimi_5h_used_percent"]) + require.Equal(t, 1, upstream.calls) + require.Equal(t, http.MethodGet, upstream.request.Method) + require.Equal(t, "https://"+host+"/coding/v1/usages", upstream.request.URL.String()) + require.Equal(t, "Bearer test-only-placeholder", upstream.request.Header.Get("Authorization")) + }) + } + } +} + // deepseek payg 账号自定义 base_url → 余额端点落在中转主机上,必须先过策略。 func TestCNProviderBalanceService_RejectsURLBlockedByPolicy(t *testing.T) { repo := &fakeCNProbeAccountRepo{account: &Account{ diff --git a/backend/internal/service/content_moderation.go b/backend/internal/service/content_moderation.go index 0e77b08b3ca3..95bbfdaf9c91 100644 --- a/backend/internal/service/content_moderation.go +++ b/backend/internal/service/content_moderation.go @@ -720,7 +720,18 @@ func (s *ContentModerationService) UpdateConfig(ctx context.Context, input Updat if err != nil { return nil, fmt.Errorf("marshal content moderation config: %w", err) } - if err := s.settingRepo.Set(ctx, SettingKeyContentModerationConfig, string(raw)); err != nil { + var saveErr error + if writer, ok := s.settingRepo.(interface { + SetContentModerationConfig(context.Context, string, []int64) error + }); ok { + saveErr = writer.SetContentModerationConfig(ctx, string(raw), cfg.GroupIDs) + if errors.Is(saveErr, ErrGroupNotFound) { + return nil, infraerrors.BadRequest("INVALID_CONTENT_MODERATION_GROUP", "审计分组不存在") + } + } else { + saveErr = s.settingRepo.Set(ctx, SettingKeyContentModerationConfig, string(raw)) + } + if err := saveErr; err != nil { return nil, fmt.Errorf("save content moderation config: %w", err) } s.replaceRuntimeConfig(cfg, raw) diff --git a/backend/internal/service/gateway_anthropic_passthrough.go b/backend/internal/service/gateway_anthropic_passthrough.go index 3102a2377e00..19fc487e09c0 100644 --- a/backend/internal/service/gateway_anthropic_passthrough.go +++ b/backend/internal/service/gateway_anthropic_passthrough.go @@ -183,7 +183,7 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput( logger.LegacyPrintf("service.gateway", "[Anthropic Passthrough] Upstream error (retry exhausted, failover): Account=%d(%s) Status=%d RequestID=%s Body=%s", account.ID, account.Name, resp.StatusCode, resp.Header.Get("x-request-id"), truncateString(string(respBody), 1000)) - s.handleRetryExhaustedSideEffects(ctx, resp, account) + s.handleRetryExhaustedSideEffects(ctx, resp, account, input.RequestModel) appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ ProxyID: opsUpstreamProxyID(account), ProxyName: opsUpstreamProxyName(account), @@ -208,7 +208,7 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput( RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), } } - return s.handleRetryExhaustedError(ctx, resp, c, account) + return s.handleRetryExhaustedError(ctx, resp, c, account, input.RequestModel) } if resp.StatusCode >= 400 && s.shouldFailoverUpstreamError(resp.StatusCode) { diff --git a/backend/internal/service/gateway_bedrock.go b/backend/internal/service/gateway_bedrock.go index 97ee44341dc0..60d6937fbb90 100644 --- a/backend/internal/service/gateway_bedrock.go +++ b/backend/internal/service/gateway_bedrock.go @@ -130,7 +130,9 @@ func (s *GatewayService) forwardBedrock( // 错误/failover 处理 if resp.StatusCode >= 400 { - return s.handleBedrockUpstreamErrors(ctx, resp, c, account) + // 账号状态按模型隔离时用调度器的模型键(账号映射后的模型名),不是发往上游的 Bedrock 模型 ID; + // 两者不一致会让模型级冷却写到调度器不会读取的键上。 + return s.handleBedrockUpstreamErrors(ctx, resp, c, account, account.GetMappedModel(reqModel)) } // Bedrock 分支绕过通用 Forward 成功路径,这里保持上游接受回调语义一致。 @@ -263,12 +265,14 @@ func (s *GatewayService) executeBedrockUpstream( return resp, nil } -// handleBedrockUpstreamErrors 处理 Bedrock 上游 4xx/5xx 错误(failover + 错误响应) +// handleBedrockUpstreamErrors 处理 Bedrock 上游 4xx/5xx 错误(failover + 错误响应); +// modelKey 是调度器按模型隔离账号状态时使用的模型键(account.GetMappedModel 的结果)。 func (s *GatewayService) handleBedrockUpstreamErrors( ctx context.Context, resp *http.Response, c *gin.Context, account *Account, + modelKey string, ) (*ForwardResult, error) { // retry exhausted + failover if s.shouldRetryUpstreamError(account, resp.StatusCode) { @@ -280,7 +284,7 @@ func (s *GatewayService) handleBedrockUpstreamErrors( logger.LegacyPrintf("service.gateway", "[Bedrock] Upstream error (retry exhausted, failover): Account=%d(%s) Status=%d Body=%s", account.ID, account.Name, resp.StatusCode, truncateString(string(respBody), 1000)) - s.handleRetryExhaustedSideEffects(ctx, resp, account) + s.handleRetryExhaustedSideEffects(ctx, resp, account, modelKey) appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ ProxyID: opsUpstreamProxyID(account), ProxyName: opsUpstreamProxyName(account), @@ -297,7 +301,7 @@ func (s *GatewayService) handleBedrockUpstreamErrors( RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), } } - return s.handleRetryExhaustedError(ctx, resp, c, account) + return s.handleRetryExhaustedError(ctx, resp, c, account, modelKey) } // non-retryable failover @@ -306,7 +310,7 @@ func (s *GatewayService) handleBedrockUpstreamErrors( _ = resp.Body.Close() resp.Body = io.NopCloser(bytes.NewReader(respBody)) - s.handleFailoverSideEffects(ctx, resp, account) + s.handleFailoverSideEffects(ctx, resp, account, modelKey) appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ ProxyID: opsUpstreamProxyID(account), ProxyName: opsUpstreamProxyName(account), @@ -325,7 +329,7 @@ func (s *GatewayService) handleBedrockUpstreamErrors( } // other errors - return s.handleErrorResponse(ctx, resp, c, account) + return s.handleErrorResponse(ctx, resp, c, account, modelKey) } // buildUpstreamRequestBedrock 构建 Bedrock 上游请求 diff --git a/backend/internal/service/gateway_claude_oauth_body.go b/backend/internal/service/gateway_claude_oauth_body.go index 085cfefbd92f..8a3eea412947 100644 --- a/backend/internal/service/gateway_claude_oauth_body.go +++ b/backend/internal/service/gateway_claude_oauth_body.go @@ -263,7 +263,11 @@ func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAu // temperature:真实 Claude Code CLI 总是发送 temperature(默认 1,客户端可覆盖)。 // 之前的实现直接 delete 会导致 payload 缺字段,与真实 CLI 字节级不一致。 // 策略:客户端传了什么就透传;没传则补默认 1。 - if !gjson.GetBytes(out, "temperature").Exists() && !claude.IsOpus55(modelID) { + // 例外:采样锁定的模型(sonnet-5-5/haiku-5-5)上 temperature+top_p 共存即 400, + // 客户端只传 top_p 时不得再补 temperature,否则网关自己制造 400(issue #7919)。 + samplingLocked := claude.IsSonnet55(modelID) || claude.IsHaiku55(modelID) + if !gjson.GetBytes(out, "temperature").Exists() && !claude.IsOpus55(modelID) && + (!samplingLocked || !gjson.GetBytes(out, "top_p").Exists()) { if next, ok := setJSONValueBytes(out, "temperature", 1); ok { out = next modified = true diff --git a/backend/internal/service/gateway_context_management_test.go b/backend/internal/service/gateway_context_management_test.go index 60dc89489c48..1e7a7d3eeecb 100644 --- a/backend/internal/service/gateway_context_management_test.go +++ b/backend/internal/service/gateway_context_management_test.go @@ -371,6 +371,25 @@ func TestNormalizeClaudeOAuthRequestBody_HaikuShortModelStillNormalizesToDatedID require.Equal(t, "claude-haiku-4-5-20251001", gjson.GetBytes(out, "model").String()) } +// issue #7919:采样锁定的模型上 temperature+top_p 共存即 400, +// 只传 top_p 时不得再补 temperature,否则网关自己制造 400。 +func TestNormalizeClaudeOAuthRequestBody_SkipsTemperatureTopUpWithTopPOnLockedModels(t *testing.T) { + for _, model := range []string{"claude-haiku-5-5", "claude-sonnet-5-5"} { + body := []byte(`{"model":"` + model + `","top_p":0.99,"messages":[]}`) + out, _ := normalizeClaudeOAuthRequestBody(body, model, claudeOAuthNormalizeOptions{}) + require.False(t, gjson.GetBytes(out, "temperature").Exists(), + "model %s with top_p must not gain temperature", model) + require.Equal(t, 0.99, gjson.GetBytes(out, "top_p").Float()) + } +} + +func TestNormalizeClaudeOAuthRequestBody_StillTopsUpTemperatureWithoutTopP(t *testing.T) { + body := []byte(`{"model":"claude-haiku-5-5","messages":[]}`) + out, _ := normalizeClaudeOAuthRequestBody(body, "claude-haiku-5-5", claudeOAuthNormalizeOptions{}) + require.Equal(t, int64(1), gjson.GetBytes(out, "temperature").Int(), + "无 top_p 时 temperature:1 补齐保持不变") +} + func TestApplyClaudeCodeOAuthMimicryToBody_HaikuRewritesSystem(t *testing.T) { account := &Account{ID: 405, Platform: PlatformAnthropic, Type: AccountTypeOAuth} body := []byte(`{"model":"claude-haiku-4-5","system":"Pi project instructions","messages":[{"role":"user","content":"hello"}]}`) diff --git a/backend/internal/service/gateway_count_tokens.go b/backend/internal/service/gateway_count_tokens.go index 2a5f4a04010c..ec3c2bad0e64 100644 --- a/backend/internal/service/gateway_count_tokens.go +++ b/backend/internal/service/gateway_count_tokens.go @@ -42,13 +42,15 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, if account != nil && account.IsAnthropicAPIKeyPassthroughEnabled() { passthroughBody := parsed.Body.Bytes() + passthroughModel := parsed.Model if reqModel := parsed.Model; reqModel != "" { if mappedModel := account.GetMappedModel(reqModel); mappedModel != reqModel { passthroughBody = s.replaceModelInBody(passthroughBody, mappedModel) + passthroughModel = mappedModel logger.LegacyPrintf("service.gateway", "CountTokens passthrough model mapping: %s -> %s (account: %s)", reqModel, mappedModel, account.Name) } } - return s.forwardCountTokensAnthropicAPIKeyPassthrough(ctx, c, account, passthroughBody) + return s.forwardCountTokensAnthropicAPIKeyPassthrough(ctx, c, account, passthroughBody, passthroughModel) } // Bedrock 不支持 count_tokens 端点 @@ -220,8 +222,8 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, // 处理错误响应 if resp.StatusCode >= 400 { - // 标记账号状态(429/529等) - s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) + // 标记账号状态(429/529等);传入实际发往上游的模型,临时不可调度规则才能按模型隔离 + s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, reqModel) upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) @@ -267,7 +269,9 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, return nil } -func (s *GatewayService) forwardCountTokensAnthropicAPIKeyPassthrough(ctx context.Context, c *gin.Context, account *Account, body []byte) error { +// forwardCountTokensAnthropicAPIKeyPassthrough 透传 count_tokens 请求;upstreamModel 是 body 中实际发往上游的模型, +// 仅用于上游错误处理按模型隔离。 +func (s *GatewayService) forwardCountTokensAnthropicAPIKeyPassthrough(ctx context.Context, c *gin.Context, account *Account, body []byte, upstreamModel string) error { token, tokenType, err := s.GetAccessToken(ctx, account) if err != nil { s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to get access token") @@ -322,7 +326,7 @@ func (s *GatewayService) forwardCountTokensAnthropicAPIKeyPassthrough(ctx contex if resp.StatusCode >= 400 { if s.rateLimitService != nil { - s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) + s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, upstreamModel) } upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index ea525548ddad..d2daec653632 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -712,7 +712,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A logger.LegacyPrintf("service.gateway", "[Forward] Upstream error (retry exhausted, failover): Account=%d(%s) Status=%d RequestID=%s Body=%s", account.ID, account.Name, resp.StatusCode, resp.Header.Get("x-request-id"), truncateString(string(respBody), 1000)) - s.handleRetryExhaustedSideEffects(ctx, resp, account) + s.handleRetryExhaustedSideEffects(ctx, resp, account, reqModel) appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ ProxyID: opsUpstreamProxyID(account), ProxyName: opsUpstreamProxyName(account), @@ -736,7 +736,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode), } } - return s.handleRetryExhaustedError(ctx, resp, c, account) + return s.handleRetryExhaustedError(ctx, resp, c, account, reqModel) } // 处理可切换账号的错误 diff --git a/backend/internal/service/gateway_image_reasoning_pricing_test.go b/backend/internal/service/gateway_image_reasoning_pricing_test.go index 39309153ba8a..322f3cb84921 100644 --- a/backend/internal/service/gateway_image_reasoning_pricing_test.go +++ b/backend/internal/service/gateway_image_reasoning_pricing_test.go @@ -131,7 +131,7 @@ func TestCalculateRecordUsageCost_MediaReasoningPricing(t *testing.T) { } var err error cost, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, - []string{model}, 0.5, 0.5, 0.5, 0.5, UsageTokens{}, "", nil, time.Time{}) + []string{model}, 0.5, 0.5, 0.5, 0.5, UsageTokens{}, "", nil, false, time.Time{}) require.NoError(t, err) } require.NotNil(t, cost) diff --git a/backend/internal/service/gateway_request.go b/backend/internal/service/gateway_request.go index 678d7d3d3bc0..b77fac3187e6 100644 --- a/backend/internal/service/gateway_request.go +++ b/backend/internal/service/gateway_request.go @@ -570,7 +570,7 @@ func StripEmptyTextBlocks(body []byte) []byte { // isClaude55SignedThinkingModel identifies models whose default thinking mode // requires signed history to survive protocol conversion and request filtering. func isClaude55SignedThinkingModel(model string) bool { - return claude.IsOpus55(model) || claude.IsSonnet55(model) + return claude.IsOpus55(model) || claude.IsSonnet55(model) || claude.IsHaiku55(model) } // validateClaude55Request rejects settings that the upstream cannot honor. @@ -580,14 +580,31 @@ func validateClaude55Request(body []byte, model string) error { return nil } isSonnet55 := claude.IsSonnet55(model) - switch gjson.GetBytes(body, "thinking.type").String() { + isHaiku55 := claude.IsHaiku55(model) + thinkingType := gjson.GetBytes(body, "thinking.type").String() + switch thinkingType { case "disabled", "enabled": + if isHaiku55 { + // haiku-5-5:enabled 一律 400(用 adaptive);disabled 仅 high 及以下 + // 可用,xhigh/max 上 400。见官方 migration guide。 + if thinkingType == "enabled" { + return fmt.Errorf("claude-haiku-5-5 requires adaptive thinking; omit thinking or use thinking.type=adaptive and output_config.effort") + } + switch gjson.GetBytes(body, "output_config.effort").String() { + case "xhigh", "max": + return fmt.Errorf("claude-haiku-5-5 thinking.type=disabled supports only low, medium or high effort") + } + break + } if isSonnet55 { return fmt.Errorf("claude-sonnet-5-5 requires adaptive thinking or thinking.type=between_tools; omit thinking or use one of those modes") } return fmt.Errorf("claude-opus-5-5 requires adaptive thinking; omit thinking or use thinking.type=adaptive and output_config.effort") case "between_tools": if !isSonnet55 { + if isHaiku55 { + return fmt.Errorf("claude-haiku-5-5 requires adaptive thinking; thinking.type=between_tools is unsupported (use thinking.type=disabled at high effort or below)") + } return fmt.Errorf("claude-opus-5-5 requires adaptive thinking; thinking.type=between_tools is unsupported") } effort := gjson.GetBytes(body, "output_config.effort").String() @@ -604,22 +621,34 @@ func validateClaude55Request(body []byte, model string) error { if isSonnet55 { modelName = "claude-sonnet-5-5" } - if gjson.GetBytes(body, "tool_choice").String() == "required" { - return fmt.Errorf("%s does not support forced tool_choice; use auto or none", modelName) + if isHaiku55 { + modelName = "claude-haiku-5-5" } - switch gjson.GetBytes(body, "tool_choice.type").String() { - case "any", "tool", "function", "custom", "namespace": - return fmt.Errorf("%s does not support forced tool_choice; use auto or none", modelName) + // haiku-5-5 接受强制 tool_choice(响应以 tool call 开头、无 thinking 块), + // 与 opus/sonnet-5-5 不同,不拦截。见官方 migration guide。 + if !isHaiku55 { + if gjson.GetBytes(body, "tool_choice").String() == "required" { + return fmt.Errorf("%s does not support forced tool_choice; use auto or none", modelName) + } + switch gjson.GetBytes(body, "tool_choice.type").String() { + case "any", "tool", "function", "custom", "namespace": + return fmt.Errorf("%s does not support forced tool_choice; use auto or none", modelName) + } } - if isSonnet55 { + if isSonnet55 || isHaiku55 { if temperature := gjson.GetBytes(body, "temperature"); temperature.Exists() && (temperature.Type != gjson.Number || temperature.Float() != 1) { - return fmt.Errorf("claude-sonnet-5-5 does not support non-default temperature") + return fmt.Errorf("%s does not support non-default temperature", modelName) } - if topP := gjson.GetBytes(body, "top_p"); topP.Exists() && (topP.Type != gjson.Number || topP.Float() < 0.99 || topP.Float() > 1) { - return fmt.Errorf("claude-sonnet-5-5 does not support non-default top_p") + // Haiku 5.5 只接受显式 top_p=0.99;保留 Sonnet 5.5 既有的区间策略。 + if topP := gjson.GetBytes(body, "top_p"); topP.Exists() && (topP.Type != gjson.Number || + (isHaiku55 && topP.Float() != 0.99) || (isSonnet55 && (topP.Float() < 0.99 || topP.Float() > 1))) { + return fmt.Errorf("%s does not support non-default top_p", modelName) } if gjson.GetBytes(body, "top_k").Exists() { - return fmt.Errorf("claude-sonnet-5-5 does not support top_k") + return fmt.Errorf("%s does not support top_k", modelName) + } + if isHaiku55 && gjson.GetBytes(body, "temperature").Exists() && gjson.GetBytes(body, "top_p").Exists() { + return fmt.Errorf("%s does not allow temperature and top_p together; omit both", modelName) } } return nil diff --git a/backend/internal/service/gateway_request_haiku55_test.go b/backend/internal/service/gateway_request_haiku55_test.go new file mode 100644 index 000000000000..c81309792c24 --- /dev/null +++ b/backend/internal/service/gateway_request_haiku55_test.go @@ -0,0 +1,55 @@ +//go:build unit + +package service + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// issue #7919:claude-haiku-5-5 请求校验(官方 migration guide 口径)。 +func TestValidateClaude55Request_Haiku55(t *testing.T) { + const model = "claude-haiku-5-5" + const prefix = `{"model":"claude-haiku-5-5","max_tokens":16000,"messages":[{"role":"user","content":"hi"}]` + cases := []struct { + name string + // extra 为追加进最小合法请求的 JSON 片段(含前导逗号) + extra string + wantErr string // 为空表示期望通过 + }{ + {name: "adaptive thinking passes", extra: `,"thinking":{"type":"adaptive"},"output_config":{"effort":"medium"}`}, + {name: "omitted thinking passes", extra: ``}, + {name: "enabled thinking rejected", extra: `,"thinking":{"type":"enabled","budget_tokens":8000}`, wantErr: "requires adaptive thinking"}, + {name: "拒绝 between_tools 并返回 Haiku 错误说明", extra: `,"thinking":{"type":"between_tools"}`, wantErr: "claude-haiku-5-5 requires adaptive thinking; thinking.type=between_tools is unsupported"}, + {name: "disabled thinking allowed at medium effort", extra: `,"thinking":{"type":"disabled"},"output_config":{"effort":"medium"}`}, + {name: "disabled thinking allowed when effort omitted", extra: `,"thinking":{"type":"disabled"}`}, + {name: "disabled thinking rejected at xhigh", extra: `,"thinking":{"type":"disabled"},"output_config":{"effort":"xhigh"}`, wantErr: "supports only low, medium or high effort"}, + {name: "disabled thinking rejected at max", extra: `,"thinking":{"type":"disabled"},"output_config":{"effort":"max"}`, wantErr: "supports only low, medium or high effort"}, + {name: "temperature 1 passes", extra: `,"temperature":1`}, + {name: "non-default temperature rejected", extra: `,"temperature":0.5`, wantErr: "non-default temperature"}, + {name: "top_p 0.99 passes", extra: `,"top_p":0.99`}, + {name: "拒绝 top_p=0.995", extra: `,"top_p":0.995`, wantErr: "non-default top_p"}, + {name: "拒绝 top_p=1", extra: `,"top_p":1`, wantErr: "non-default top_p"}, + {name: "拒绝字符串 top_p", extra: `,"top_p":"0.99"`, wantErr: "non-default top_p"}, + {name: "top_p 0.5 rejected", extra: `,"top_p":0.5`, wantErr: "non-default top_p"}, + {name: "top_k rejected", extra: `,"top_k":5`, wantErr: "does not support top_k"}, + {name: "temperature and top_p together rejected", extra: `,"temperature":1,"top_p":0.99`, wantErr: "does not allow temperature and top_p together"}, + {name: "forced tool_choice any allowed", extra: `,"tool_choice":{"type":"any"}`}, + {name: "forced tool_choice required allowed", extra: `,"tool_choice":"required"`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + body := []byte(prefix + tc.extra + "}") + err := validateClaude55Request(body, model) + if tc.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + require.True(t, strings.Contains(err.Error(), tc.wantErr), + "error %q should contain %q", err.Error(), tc.wantErr) + }) + } +} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 616019abc0a1..58ddbacfcaac 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -688,6 +688,9 @@ type ForwardResult struct { ImageSizeBreakdown map[string]int SearchCount int AudioUsage *AudioUsage + // CacheStorageTokenHours 显式上下文缓存的存储量(缓存 token 数 × 存储小时数),按计费模型的 + // 存储单价折算为费用后叠加在 token 费用之上。 + CacheStorageTokenHours float64 } // GatewayFailureStage identifies which request stage failed. The zero value is @@ -734,6 +737,7 @@ type UpstreamFailoverError struct { SameAccountRetryDeadline time.Time // 同账号重试截止时间;零值表示仅受 retryLimit 限制 SameAccountRetryMax int // 可选的错误级同账号重试上限,低于 handler 默认预算时优先采用 RequestScopedTransient bool // 故障因素与账号无关(如上游按客户端身份/模型容量降载):可同账号重试,但不得据此对账号做临时封禁 + BoundUpstreamPassthrough bool // A cache-bound upstream gateway 429/5xx is returned without error remapping. SafeToFailoverAfterWrite bool // 仅写出 SSE 注释等非语义字节时,仍可在同一客户端流中切换账号 Stage GatewayFailureStage Scope GatewayFailureScope diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go index 06ee09822ce1..cdf2319ebb94 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -419,11 +419,13 @@ func defaultAPIKeyBetaHeader(body []byte) string { // The legacy streaming beta is incompatible with the stable computer and // browser toolsets. The per-tool eager_input_streaming field remains available. +// Applies to Sonnet 5.5 and Haiku 5.5, which share the computer_toolset_20260801 +// / browser_toolset_20260801 toolsets (Haiku 5.5 migration guide). func filterSonnet55ToolsetBeta(header string, body []byte, modelID string) string { if at := strings.IndexByte(modelID, '@'); at >= 0 { modelID = modelID[:at] // Vertex's dated model ID } - if !claude.IsSonnet55(modelID) { + if !claude.IsSonnet55(modelID) && !claude.IsHaiku55(modelID) { return header } for _, tool := range gjson.GetBytes(body, "tools").Array() { diff --git a/backend/internal/service/gateway_upstream_response.go b/backend/internal/service/gateway_upstream_response.go index 0198897ae886..2ab9f881512a 100644 --- a/backend/internal/service/gateway_upstream_response.go +++ b/backend/internal/service/gateway_upstream_response.go @@ -529,13 +529,19 @@ func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Res return nil, fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, upstreamMsg) } -func (s *GatewayService) handleRetryExhaustedSideEffects(ctx context.Context, resp *http.Response, account *Account) { +// handleRetryExhaustedSideEffects 在重试耗尽后标记账号状态;requestedModel 是实际发往上游的模型, +// 临时不可调度规则据此按模型隔离。 +func (s *GatewayService) handleRetryExhaustedSideEffects(ctx context.Context, resp *http.Response, account *Account, requestedModel ...string) { body, _ := s.readUpstreamErrorBody(resp) statusCode := resp.StatusCode // OAuth/Setup Token 账号的 403:标记账号异常 if account.IsOAuth() && statusCode == 403 { - s.rateLimitService.HandleUpstreamError(ctx, account, statusCode, resp.Header, body) + if len(requestedModel) > 0 { + s.rateLimitService.HandleUpstreamError(ctx, account, statusCode, resp.Header, body, requestedModel[0]) + } else { + s.rateLimitService.HandleUpstreamError(ctx, account, statusCode, resp.Header, body) + } logger.LegacyPrintf("service.gateway", "Account %d: marked as error after %d retries for status %d", account.ID, maxRetryAttempts, statusCode) } else { // API Key 未配置错误码:不标记账号状态 @@ -555,14 +561,14 @@ func (s *GatewayService) handleFailoverSideEffects(ctx context.Context, resp *ht // handleRetryExhaustedError 处理重试耗尽后的错误 // OAuth 403:标记账号异常 // API Key 未配置错误码:仅返回错误,不标记账号 -func (s *GatewayService) handleRetryExhaustedError(ctx context.Context, resp *http.Response, c *gin.Context, account *Account) (*ForwardResult, error) { +func (s *GatewayService) handleRetryExhaustedError(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, requestedModel ...string) (*ForwardResult, error) { MarkResponseCommitted(c) // Capture upstream error body before side-effects consume the stream. respBody, _ := s.readUpstreamErrorBody(resp) _ = resp.Body.Close() resp.Body = io.NopCloser(bytes.NewReader(respBody)) - s.handleRetryExhaustedSideEffects(ctx, resp, account) + s.handleRetryExhaustedSideEffects(ctx, resp, account, requestedModel...) upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 1884f520d4c2..7929c3ecd989 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -244,7 +244,8 @@ func isForcedUsageBillingRequestID(requestID string) bool { return strings.HasPrefix(id, "web_search:") || strings.HasPrefix(id, "grok-video:") || strings.HasPrefix(id, "grok_audio:") || - strings.HasPrefix(id, "grok_realtime:") + strings.HasPrefix(id, "grok_realtime:") || + strings.HasPrefix(id, GeminiCachedContentUsageRequestIDPrefix) } // StableGrokAudioBillingRequestID is the durable usage_logs / dedup key for one @@ -825,42 +826,9 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage cacheTTLOverridden = (result.Usage.CacheCreation5mTokens + result.Usage.CacheCreation1hTokens) > 0 } - // 获取费率倍数(优先级:用户专属 > 分组默认 > 系统默认) - multiplier := 1.0 - if s.cfg != nil { - multiplier = s.cfg.Default.RateMultiplier - } - if apiKey.GroupID != nil && apiKey.Group != nil { - groupDefault := apiKey.Group.RateMultiplier - multiplier = s.ResolveUserGroupRateMultiplier(ctx, user.ID, *apiKey.GroupID, groupDefault) - } - // token 倍率叠加高峰因子(token 计费含图片 token,图片按次倍率不受影响)。高峰因子按请求时刻现算, - // 不并入上面的 getUserGroupRateMultiplier,以免污染 user:group 倍率缓存。 - pricingAt := input.PricingAt - if pricingAt.IsZero() { - pricingAt = timezone.Now() - } - multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, multiplier, pricingAt) - - // 确定计费模型 - concreteBillingModel := forwardResultBillingModel(result.Model, result.UpstreamModel) - billingModel := concreteBillingModel - if input.BillingModelSource == BillingModelSourceChannelMapped && input.ChannelMappedModel != "" { - billingModel = input.ChannelMappedModel - } - if input.BillingModelSource == BillingModelSourceRequested && input.OriginalModel != "" { - billingModel = input.OriginalModel - } - // composite 分组的公开别名(如 all/claude)会经 OriginalModel/ChannelMappedModel - // 进入上面的来源覆盖:任意别名查无价会静默落 $0,含家族词的别名则被价格表的 - // 家族模糊匹配错计(如 Opus 流量按 Sonnet 兜底价)。除非管理员为别名显式配置了 - // 渠道定价(OpenRouter 式自定价),composite 请求一律按实际转发的具体模型计费。 - if apiKey.Group != nil && apiKey.Group.Platform == PlatformComposite { - billingModel = s.compositeBillableModel(ctx, apiKey, billingModel, concreteBillingModel) - } - // 通用兜底(与 OpenAI 路径的 usageBillingModelCandidates 语义对齐): - // 选定模型查不到任何价格时回退到实际转发的具体模型。已定价流量不受影响。 - billingModel = s.billableModelWithFallback(ctx, apiKey, billingModel, result.UpstreamModel, result.Model) + multiplier, imageMultiplier, pricingAt := s.recordUsageMultipliers(ctx, apiKey, user, input.PricingAt) + billingModel := s.recordUsageBillingModel(ctx, apiKey, result, input.ChannelUsageFields) + cacheStorageCost := s.cacheStorageCost(result, billingModel) // 确定 RequestedModel(渠道映射前的原始模型) requestedModel := result.Model @@ -878,7 +846,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage input.BillingModelSource, result.UpstreamResponseModel, result.UpstreamResponseModelConflict, - result.ImageCount > 0 || result.AudioUsage != nil || result.SearchCount > 0, + result.ImageCount > 0 || result.AudioUsage != nil || result.SearchCount > 0 || result.CacheStorageTokenHours > 0, ); responseModel != "" && !strings.EqualFold(responseModel, strings.TrimSpace(billingModel)) { if identified, responseChannelPriced := s.hasIdentifiedResponseModelPricing(ctx, responseModel, apiKey); identified { responseCost := s.calculateRecordUsageCost(ctx, result, apiKey, responseModel, multiplier, imageMultiplier, pricingAt) @@ -904,8 +872,9 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage usageLog := s.buildRecordUsageLog(ctx, input, result, apiKey, user, account, subscription, requestedModel, multiplier, imageMultiplier, accountRateMultiplier, billingType, cacheTTLOverridden, cost) - // 计算账号统计定价费用(使用最终上游模型匹配自定义规则) - if apiKey.GroupID != nil { + // 计算账号统计定价费用(使用最终上游模型匹配自定义规则)。纯存储费不是一次模型请求, + // 账号统计成本回落 total_cost(官方存储价)。 + if apiKey.GroupID != nil && !isCacheStorageOnlyResult(result) { applyAccountStatsCost(ctx, usageLog, s.channelService, s.billingService, account.ID, *apiKey.GroupID, result.UpstreamModel, result.Model, // Anthropic's input_tokens excludes cache_read and cache_creation (billed separately); @@ -917,9 +886,10 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage CacheReadTokens: result.Usage.CacheReadInputTokens, ImageOutputTokens: result.Usage.ImageOutputTokens, }, - cost.TotalCost, pricingAt, + cost.TotalCost-cacheStorageCost, pricingAt, accountStatsLongContextPricingEnabled(nil), ) + addCacheStorageToAccountStatsCost(usageLog, cacheStorageCost) } simpleModeKeyRateLimitOnly := simpleModeKeyRateLimitBillingEnabled(s.cfg, apiKey) @@ -973,6 +943,112 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage return nil } +// recordUsageMultipliers 返回 token / 图片售价倍率(用户专属 > 分组默认 > 系统默认,token 倍率叠加高峰因子) +// 与计价时刻;pricingAt 为零值时取当前时刻。 +func (s *GatewayService) recordUsageMultipliers(ctx context.Context, apiKey *APIKey, user *User, pricingAt time.Time) (float64, float64, time.Time) { + multiplier := 1.0 + if s.cfg != nil { + multiplier = s.cfg.Default.RateMultiplier + } + if apiKey.GroupID != nil && apiKey.Group != nil { + multiplier = apiKey.Group.RateMultiplier + if user != nil { + multiplier = s.ResolveUserGroupRateMultiplier(ctx, user.ID, *apiKey.GroupID, multiplier) + } + } + // 高峰因子按请求时刻现算,不并入 getUserGroupRateMultiplier,以免污染 user:group 倍率缓存。 + if pricingAt.IsZero() { + pricingAt = timezone.Now() + } + multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, multiplier, pricingAt) + return multiplier, imageMultiplier, pricingAt +} + +// recordUsageBillingModel 按计费模型来源、composite 规则与无价兜底确定计费模型。 +func (s *GatewayService) recordUsageBillingModel(ctx context.Context, apiKey *APIKey, result *ForwardResult, fields ChannelUsageFields) string { + concreteBillingModel := forwardResultBillingModel(result.Model, result.UpstreamModel) + billingModel := concreteBillingModel + if fields.BillingModelSource == BillingModelSourceChannelMapped && fields.ChannelMappedModel != "" { + billingModel = fields.ChannelMappedModel + } + if fields.BillingModelSource == BillingModelSourceRequested && fields.OriginalModel != "" { + billingModel = fields.OriginalModel + } + // composite 分组的公开别名(如 all/claude)会经 OriginalModel/ChannelMappedModel + // 进入上面的来源覆盖:任意别名查无价会静默落 $0,含家族词的别名则被价格表的 + // 家族模糊匹配错计(如 Opus 流量按 Sonnet 兜底价)。除非管理员为别名显式配置了 + // 渠道定价(OpenRouter 式自定价),composite 请求一律按实际转发的具体模型计费。 + if apiKey.Group != nil && apiKey.Group.Platform == PlatformComposite { + billingModel = s.compositeBillableModel(ctx, apiKey, billingModel, concreteBillingModel) + } + // 通用兜底(与 OpenAI 路径的 usageBillingModelCandidates 语义对齐): + // 选定模型查不到任何价格时回退到实际转发的具体模型。已定价流量不受影响。 + return s.billableModelWithFallback(ctx, apiKey, billingModel, result.UpstreamModel, result.Model) +} + +// cacheStoragePrice 返回计费模型的显式缓存存储单价;计费模型未配置时依次回退到实际转发模型与请求模型。 +func (s *GatewayService) cacheStoragePrice(result *ForwardResult, billingModel string) float64 { + if s.billingService == nil { + return 0 + } + for _, model := range []string{billingModel, result.UpstreamModel, result.Model} { + if strings.TrimSpace(model) == "" { + continue + } + if price := s.billingService.CacheStoragePricePerTokenHour(model); price > 0 { + return price + } + } + return 0 +} + +// cacheStorageCost 返回 result 的显式缓存存储费(USD,倍率前)。 +func (s *GatewayService) cacheStorageCost(result *ForwardResult, billingModel string) float64 { + if result.CacheStorageTokenHours <= 0 { + return 0 + } + return result.CacheStorageTokenHours * s.cacheStoragePrice(result, billingModel) +} + +// isCacheStorageOnlyResult 判断 result 是否只有显式缓存存储费(延长有效期):不含 token 用量,不算一次模型请求。 +func isCacheStorageOnlyResult(result *ForwardResult) bool { + u := result.Usage + return result.CacheStorageTokenHours > 0 && u.InputTokens == 0 && u.OutputTokens == 0 && + u.CacheCreationInputTokens == 0 && u.CacheReadInputTokens == 0 && u.ImageOutputTokens == 0 && + result.ImageCount == 0 && result.AudioUsage == nil && result.SearchCount == 0 +} + +// CacheStoragePriced 判断 RecordUsage 对 input.Result 采用的计费模型(含回退候选)是否配置了显式缓存存储单价。 +func (s *GatewayService) CacheStoragePriced(ctx context.Context, input *RecordUsageInput) bool { + billingModel := s.recordUsageBillingModel(ctx, input.APIKey, input.Result, input.ChannelUsageFields) + return s.cacheStoragePrice(input.Result, billingModel) > 0 +} + +// QuoteUsageCost 按与 RecordUsage 相同的倍率、计费模型与定价计算 input.Result 的应付金额(倍率后 USD),不记录也不扣费。 +func (s *GatewayService) QuoteUsageCost(ctx context.Context, input *RecordUsageInput) float64 { + user := input.User + if user == nil { + user = input.APIKey.User + } + multiplier, imageMultiplier, pricingAt := s.recordUsageMultipliers(ctx, input.APIKey, user, input.PricingAt) + billingModel := s.recordUsageBillingModel(ctx, input.APIKey, input.Result, input.ChannelUsageFields) + cost := s.calculateRecordUsageCost(ctx, input.Result, input.APIKey, billingModel, multiplier, imageMultiplier, pricingAt) + if cost == nil { + return 0 + } + return cost.ActualCost +} + +// addCacheStorageToAccountStatsCost 把显式缓存存储费按官方价叠加到账号统计成本。 +// 账号统计定价只覆盖 token 部分;统计成本为空时成本统计回落 total_cost,其中已含存储费。 +func addCacheStorageToAccountStatsCost(usageLog *UsageLog, storageCost float64) { + if usageLog == nil || usageLog.AccountStatsCost == nil || storageCost <= 0 { + return + } + cost := *usageLog.AccountStatsCost + storageCost + usageLog.AccountStatsCost = &cost +} + // calculateRecordUsageCost 根据请求类型计算费用。 func (s *GatewayService) calculateRecordUsageCost( ctx context.Context, @@ -1010,8 +1086,13 @@ func (s *GatewayService) calculateRecordUsageCost( return s.billingService.CalculateAudioCost(result.AudioUsage.Mode, result.AudioUsage.DurationOrUnits, cfg, multiplier) } - // Token 计费;SearchCount 为叠加 surcharge(不替代 token)。 - tokenCost := s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt) + // Token 计费;SearchCount 为叠加 surcharge(不替代 token)。纯存储费不是一次模型请求,不计 token / 按次费用。 + var tokenCost *CostBreakdown + if isCacheStorageOnlyResult(result) { + tokenCost = &CostBreakdown{} + } else { + tokenCost = s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt) + } if result.SearchCount > 0 { price := groupSearchPricePer1kFromAPIKey(apiKey) if price != nil && *price == 0 { @@ -1026,6 +1107,14 @@ func (s *GatewayService) calculateRecordUsageCost( tokenCost.ActualCost += searchCost.ActualCost } } + // 显式缓存存储费同为叠加 surcharge,与 token 费用使用同一计费模型与倍率。 + if storageCost := s.cacheStorageCost(result, billingModel); storageCost > 0 { + if tokenCost == nil { + tokenCost = &CostBreakdown{} + } + tokenCost.TotalCost += storageCost + tokenCost.ActualCost += storageCost * multiplier + } return tokenCost } diff --git a/backend/internal/service/gateway_usage_billing_request_id_test.go b/backend/internal/service/gateway_usage_billing_request_id_test.go index 78a64cb3385d..fa13918b51c6 100644 --- a/backend/internal/service/gateway_usage_billing_request_id_test.go +++ b/backend/internal/service/gateway_usage_billing_request_id_test.go @@ -31,9 +31,27 @@ func TestIsForcedUsageBillingRequestID(t *testing.T) { require.True(t, isForcedUsageBillingRequestID("grok-video:task-1")) require.True(t, isForcedUsageBillingRequestID("grok_audio:up-1")) require.True(t, isForcedUsageBillingRequestID("grok_realtime:sess-1")) + require.True(t, isForcedUsageBillingRequestID(GeminiCachedContentCreateUsageRequestID("abc"))) require.False(t, isForcedUsageBillingRequestID("resp_abc")) } +func TestGeminiCachedContentUsageRequestIDs(t *testing.T) { + t.Parallel() + publicID, err := NewGeminiCachedContentPublicID() + require.NoError(t, err) + create := GeminiCachedContentCreateUsageRequestID(publicID) + patch := GeminiCachedContentPatchUsageRequestID(publicID) + require.Equal(t, "gcache:create:"+publicID, create) + require.True(t, strings.HasPrefix(patch, "gcache:patch:"+publicID+":")) + require.NotEqual(t, patch, GeminiCachedContentPatchUsageRequestID(publicID), "每次延长各自唯一") + require.LessOrEqual(t, len(create), 64, "usage_logs.request_id 列长 64") + require.LessOrEqual(t, len(patch), 64, "usage_logs.request_id 列长 64") + + ctx := context.WithValue(context.Background(), ctxkey.ClientRequestID, "client-shared-id") + require.Equal(t, create, resolveUsageBillingRequestID(ctx, create), "缓存费用行不被客户端请求 id 覆盖") + require.Equal(t, patch, resolveUsageBillingRequestID(ctx, patch)) +} + func TestStableGrokAudioBillingRequestID(t *testing.T) { t.Parallel() require.Equal(t, "grok_audio:up-1", StableGrokAudioBillingRequestID("up-1")) diff --git a/backend/internal/service/gemini_cache_storage_pricing.go b/backend/internal/service/gemini_cache_storage_pricing.go new file mode 100644 index 000000000000..c08ed2f683d3 --- /dev/null +++ b/backend/internal/service/gemini_cache_storage_pricing.go @@ -0,0 +1,72 @@ +package service + +import ( + "slices" + "strings" +) + +// Gemini 显式上下文缓存存储的内置官方单价(USD / token / 小时,Standard 档)。 +// 存储价与上下文长度无关(≤200K 与 >200K 同价);Priority 档的更高存储价不在此列。 +// 来源:https://ai.google.dev/gemini-api/docs/pricing +// 与 https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing +const ( + geminiProCacheStoragePrice = 4.5e-6 // $4.50 / 1M tokens / 小时 + geminiFlashCacheStoragePrice = 1e-6 // $1.00 / 1M tokens / 小时,Flash-Lite 同价 + + // 3.6/3.7/3.8 Flash 的优惠价 $0.50 / 1M tokens / 小时,有效期至 2026-12-31, + // 2027-01-01 起恢复为 geminiFlashCacheStoragePrice。AI Studio 价目页直接标注该优惠价; + // Vertex 价目页存储价标 $1.00,优惠以「按该模型净消费返还 50% 抵扣金」发放, + // 官方未明确存储费是否在返还范围内,两类账号统一按优惠价计。 + geminiFlashPromoCacheStoragePrice = 0.5e-6 +) + +// geminiCacheStorageModelPrices 是已发布型号的存储单价。模型名等于前缀,或以「前缀-」开头 +// (-preview、-customtools、-001 等变体)即命中;更具体的前缀排在前面。 +var geminiCacheStorageModelPrices = []struct { + prefix string + price float64 +}{ + {"gemini-3.8-flash-cyber", geminiFlashCacheStoragePrice}, + {"gemini-3.8-flash", geminiFlashPromoCacheStoragePrice}, + {"gemini-3.7-flash", geminiFlashPromoCacheStoragePrice}, + {"gemini-3.6-flash", geminiFlashPromoCacheStoragePrice}, + {"gemini-3.5-flash-lite", geminiFlashCacheStoragePrice}, + {"gemini-3.5-flash", geminiFlashCacheStoragePrice}, + {"gemini-3.1-flash-lite", geminiFlashCacheStoragePrice}, + {"gemini-3.1-pro", geminiProCacheStoragePrice}, + {"gemini-3-flash", geminiFlashCacheStoragePrice}, + {"gemini-3-pro", geminiProCacheStoragePrice}, + {"gemini-2.5-flash-lite", geminiFlashCacheStoragePrice}, + {"gemini-2.5-flash", geminiFlashCacheStoragePrice}, + {"gemini-2.5-pro", geminiProCacheStoragePrice}, + {"gemini-2.0-flash-lite", geminiFlashCacheStoragePrice}, + {"gemini-2.0-flash", geminiFlashCacheStoragePrice}, +} + +// builtinGeminiCacheStoragePrice 返回内置的显式缓存存储单价:先按已发布型号匹配,未命中时按系列兜底, +// Pro 系列取 geminiProCacheStoragePrice,Flash 与 Flash-Lite 系列取 geminiFlashCacheStoragePrice(标准价, +// 不套用优惠价)。其他 Gemini 模型与非 Gemini 模型返回 0。 +func builtinGeminiCacheStoragePrice(model string) float64 { + name := lastSegment(strings.ToLower(strings.TrimSpace(model))) + if !strings.HasPrefix(name, "gemini-") { + return 0 + } + parts := strings.Split(name, "-") + lite := slices.Contains(parts, "lite") + for _, entry := range geminiCacheStorageModelPrices { + // Flash-Lite 变体只匹配 Flash-Lite 条目:gemini-3.8-flash-lite 不能落到 3.8 Flash 的优惠价。 + if lite != strings.HasSuffix(entry.prefix, "-lite") { + continue + } + if name == entry.prefix || strings.HasPrefix(name, entry.prefix+"-") { + return entry.price + } + } + switch { + case slices.Contains(parts, "pro"): + return geminiProCacheStoragePrice + case slices.Contains(parts, "flash"): + return geminiFlashCacheStoragePrice + } + return 0 +} diff --git a/backend/internal/service/gemini_cache_storage_pricing_test.go b/backend/internal/service/gemini_cache_storage_pricing_test.go new file mode 100644 index 000000000000..846f7b56ec7e --- /dev/null +++ b/backend/internal/service/gemini_cache_storage_pricing_test.go @@ -0,0 +1,77 @@ +//go:build unit + +package service + +import ( + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func TestBuiltinGeminiCacheStoragePrice(t *testing.T) { + for _, tc := range []struct { + model string + want float64 + }{ + // 已发布型号 + {"gemini-3.8-flash", 0.5e-6}, + {"gemini-3.7-flash", 0.5e-6}, + {"gemini-3.6-flash", 0.5e-6}, + {"gemini-3.8-flash-cyber", 1e-6}, + {"gemini-3.5-flash", 1e-6}, + {"gemini-3.5-flash-lite", 1e-6}, + {"gemini-3.1-flash-lite", 1e-6}, + {"gemini-3.1-pro-preview", 4.5e-6}, + {"gemini-3.1-pro-preview-customtools", 4.5e-6}, + {"gemini-3-flash-preview", 1e-6}, + {"gemini-3-pro-preview", 4.5e-6}, + {"gemini-2.5-pro", 4.5e-6}, + {"gemini-2.5-flash", 1e-6}, + {"gemini-2.5-flash-lite", 1e-6}, + {"gemini-2.0-flash-001", 1e-6}, + {"gemini-2.0-flash-lite-001", 1e-6}, + // 资源名与大小写 + {"models/gemini-3.8-flash", 0.5e-6}, + {"projects/p/locations/global/publishers/google/models/gemini-2.5-pro", 4.5e-6}, + {" Gemini-3.8-Flash ", 0.5e-6}, + // 系列兜底:未发布型号与别名按标准价 + {"gemini-3.9-flash", 1e-6}, + {"gemini-3.9-flash-lite", 1e-6}, + {"gemini-3.8-flash-lite", 1e-6}, + {"gemini-3.9-pro-preview", 4.5e-6}, + {"gemini-flash-latest", 1e-6}, + {"gemini-flash-lite-latest", 1e-6}, + {"gemini-pro-latest", 4.5e-6}, + // 无存储价 + {"gemini-embedding-001", 0}, + {"gemini-unknown-model", 0}, + {"claude-sonnet-5-5", 0}, + {"", 0}, + } { + require.InDelta(t, tc.want, builtinGeminiCacheStoragePrice(tc.model), 1e-15, tc.model) + } +} + +func TestCacheStoragePricePerTokenHourPriority(t *testing.T) { + pricing := &PricingService{} + data, err := pricing.parsePricingData([]byte(`{ + "gemini-3.8-flash":{"litellm_provider":"gemini","input_cost_per_token":0.00000075,"output_cost_per_token":0.00000375, + "cache_storage_cost_per_token_per_hour":0.000002}, + "gemini-3.9-flash":{"litellm_provider":"gemini","input_cost_per_token":0.0000015,"output_cost_per_token":0.0000075, + "cache_storage_cost_per_token_per_hour":0.0000007}, + "gemini-2.5-pro":{"litellm_provider":"gemini","input_cost_per_token":0.00000125,"output_cost_per_token":0.00001}}`)) + require.NoError(t, err) + pricing.pricingData = data + billing := NewBillingService(&config.Config{}, pricing) + + require.InDelta(t, 2e-6, billing.CacheStoragePricePerTokenHour("gemini-3.8-flash"), 1e-15, "价格表配置值优先于内置型号价") + require.InDelta(t, 0.7e-6, billing.CacheStoragePricePerTokenHour("gemini-3.9-flash"), 1e-15, "价格表配置值优先于内置系列兜底价") + require.InDelta(t, 4.5e-6, billing.CacheStoragePricePerTokenHour("gemini-2.5-pro"), 1e-15, "价格表条目缺存储价时取内置型号价") + require.InDelta(t, 0.5e-6, billing.CacheStoragePricePerTokenHour("gemini-3.7-flash"), 1e-15, "价格表无条目时取内置型号价") + require.InDelta(t, 4.5e-6, billing.CacheStoragePricePerTokenHour("gemini-3.9-pro"), 1e-15, "内置型号未命中时取系列兜底价") + require.Zero(t, billing.CacheStoragePricePerTokenHour("customer-alias")) + + noCatalog := NewBillingService(&config.Config{}, nil) + require.InDelta(t, 0.5e-6, noCatalog.CacheStoragePricePerTokenHour("gemini-3.8-flash"), 1e-15, "无价格服务时取内置价") +} diff --git a/backend/internal/service/gemini_cached_content.go b/backend/internal/service/gemini_cached_content.go new file mode 100644 index 000000000000..bc1a3353dc03 --- /dev/null +++ b/backend/internal/service/gemini_cached_content.go @@ -0,0 +1,1226 @@ +package service + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base32" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net/http" + "net/url" + "regexp" + "slices" + "strconv" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/Wei-Shaw/sub2api/internal/config" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +const ( + // GeminiCachedContentNamePrefix 是缓存资源名前缀,网关与上游 AI Studio 一致。 + GeminiCachedContentNamePrefix = "cachedContents/" + // GeminiCachedContentNotFoundMessage 与上游对不存在、已过期或无权访问的缓存返回的文案一致。 + GeminiCachedContentNotFoundMessage = "CachedContent not found (or permission denied)" + // GeminiCachedContentNotFoundResponse 是网关对引用不存在缓存统一返回的 403 响应体(与 AI Studio 上游一致)。 + GeminiCachedContentNotFoundResponse = `{"error":{"code":403,"message":"CachedContent not found (or permission denied)","status":"PERMISSION_DENIED"}}` + + geminiCachedContentDefaultTTL = time.Hour + geminiCachedContentDefaultMaxTTL = 24 * time.Hour + geminiCachedContentDisplayNameMaxRunes = 128 + geminiCachedContentUpstreamTagPrefix = "s2a-" + geminiCachedContentResponseMaxBytes = 4 << 20 + geminiCachedContentUpstreamTimeout = 5 * time.Minute + geminiCachedContentOfficialHost = "generativelanguage.googleapis.com" + + // geminiCachedContentMutationUpstreamTimeout 是修改 / 删除上游缓存的单次调用超时。 + geminiCachedContentMutationUpstreamTimeout = 30 * time.Second + // geminiCachedContentMutationLease 是修改意图的租约时长,须覆盖持有者在租约内的全部操作 + // (一次上游修改与一次落库,或恢复流程的一次上游回滚与一次落库),租约过期后持有者不会再改动上游。 + geminiCachedContentMutationLease = 2 * time.Minute + // geminiCachedContentMutationRecoveryInterval / geminiCachedContentMutationRecoveryBatch 约束后台回滚恢复的节奏; + // geminiCachedContentMutationRecoveryTimeout 是恢复单条记录的落库超时,与一次上游回滚合计不超过租约。 + geminiCachedContentMutationRecoveryInterval = time.Minute + geminiCachedContentMutationRecoveryBatch = 50 + geminiCachedContentMutationRecoveryTimeout = time.Minute + // geminiCachedContentMutationAbandonGrace 是判定"上游缓存必已自行过期"时额外容忍的网关与上游时钟偏差。 + geminiCachedContentMutationAbandonGrace = time.Hour + + // GeminiCachedContentListDefaultPageSize / GeminiCachedContentListMaxPageSize 约束 list 分页。 + GeminiCachedContentListDefaultPageSize = 50 + GeminiCachedContentListMaxPageSize = 1000 +) + +var ( + ErrGeminiCachedContentNotFound = infraerrors.New(http.StatusNotFound, "GEMINI_CACHED_CONTENT_NOT_FOUND", "gemini cached content not found") + ErrGeminiCachedContentExists = infraerrors.New(http.StatusConflict, "GEMINI_CACHED_CONTENT_EXISTS", "gemini cached content already exists") + // ErrGeminiCachedContentMutationConflict 表示同一缓存有进行中的修改、到期时间已被其他修改改变,或修改意图已不属于调用方。 + ErrGeminiCachedContentMutationConflict = infraerrors.New(http.StatusConflict, "GEMINI_CACHED_CONTENT_MUTATION_CONFLICT", "another update to this cached content is in progress") + // ErrGeminiCachedContentRequestNotSent 表示上游缓存请求在发出前失败(地址、凭据等),上游状态未被改变。 + ErrGeminiCachedContentRequestNotSent = errors.New("gemini cached content request was not sent") + // errGeminiCachedContentAccountUnsupported 表示持有缓存的账号存在,但当前不支持显式缓存(能力被关闭或认证方式不支持)。 + errGeminiCachedContentAccountUnsupported = errors.New("gemini cached content account does not support explicit caching") + + geminiCachedContentPublicIDPattern = regexp.MustCompile(`^[a-z0-9]{1,64}$`) + geminiCachedContentAIStudioName = regexp.MustCompile(`^cachedContents/[A-Za-z0-9_-]+$`) + geminiCachedContentVertexName = regexp.MustCompile(`^projects/[A-Za-z0-9_.:-]+/locations/([a-z0-9-]+)/cachedContents/[A-Za-z0-9_-]+$`) + geminiCachedContentPublicIDEncoding = base32.StdEncoding.WithPadding(base32.NoPadding) +) + +// GeminiCachedContent 是网关对外暴露的显式缓存与上游资源、账号、归属的绑定。 +type GeminiCachedContent struct { + ID int64 + PublicID string + UserID int64 + APIKeyID int64 + GroupID int64 + // AccountID 是持有上游缓存的账号;引用缓存的请求只能发往该账号。 + AccountID int64 + UpstreamName string + // Model 是客户端视角的模型名(渠道映射后,不含 models/ 前缀),引用缓存的请求模型必须与之字面一致。 + Model string + // UpstreamModel 是创建时发给上游的模型资源名。 + UpstreamModel string + DisplayName string + TotalTokenCount int64 + ExpireTime time.Time + // ChannelUsage 是创建时的渠道计费口径(渠道 ID、请求模型、计费模型来源),延长有效期按同一口径计费。 + ChannelUsage ChannelUsageFields + CreatedAt time.Time + UpdatedAt time.Time +} + +// PublicName 返回客户端可见的缓存资源名。 +func (c *GeminiCachedContent) PublicName() string { + return GeminiCachedContentNamePrefix + c.PublicID +} + +// UpstreamModelID 返回上游模型资源名的末段(裸模型名),用于计费定价查找。 +func (c *GeminiCachedContent) UpstreamModelID() string { + model := c.UpstreamModel + if i := strings.LastIndex(model, "/"); i >= 0 { + model = model[i+1:] + } + return model +} + +// GeminiCachedContentRepository 持久化显式缓存绑定。所有读取都过滤已软删除的记录。 +type GeminiCachedContentRepository interface { + Create(ctx context.Context, record *GeminiCachedContent) error + GetForOwner(ctx context.Context, apiKeyID int64, publicID string) (*GeminiCachedContent, error) + // ListForOwner 按 id 倒序返回分组内 expire_time 晚于 activeAt 的记录;beforeID>0 时只返回 id 更小的记录。 + ListForOwner(ctx context.Context, apiKeyID, groupID int64, activeAt time.Time, beforeID int64, limit int) ([]*GeminiCachedContent, error) + // BeginMutation 在记录没有修改意图且 expire_time 仍为 baseline 时登记修改意图(token 与自数据库当前时间起的租约), + // 返回是否登记成功;记录不存在或已删除时返回 ErrGeminiCachedContentNotFound。 + BeginMutation(ctx context.Context, id int64, baseline time.Time, token string, lease time.Duration) (bool, error) + // TakeOverStaleMutation 把记录上租约已过期的修改意图换成 token 与新租约并返回记录;没有可接管的意图时返回 nil。 + TakeOverStaleMutation(ctx context.Context, id int64, token string, lease time.Duration) (*GeminiCachedContent, error) + // CommitMutation 由 token 的持有者写入新的到期时间并清除修改意图;token 已失效或记录已删除时返回 + // ErrGeminiCachedContentMutationConflict。 + CommitMutation(ctx context.Context, id int64, token string, expireTime time.Time) error + // ReleaseMutation 由 token 的持有者清除修改意图,不改变到期时间。 + ReleaseMutation(ctx context.Context, id int64, token string) error + // ListStaleMutations 返回修改意图租约已过期的未删除记录 id。 + ListStaleMutations(ctx context.Context, limit int) ([]int64, error) + SoftDelete(ctx context.Context, id int64) error +} + +// IsGeminiExplicitCacheAccountType 只按平台与账号类型判断是否可能支持显式缓存,适用于调度快照账号。 +func IsGeminiExplicitCacheAccountType(account *Account) bool { + if account == nil || account.Platform != PlatformGemini { + return false + } + return account.Type == AccountTypeAPIKey || account.Type == AccountTypeServiceAccount +} + +// IsGeminiExplicitCacheAccount 判断完整账号是否支持显式缓存:官方地址的 AI Studio API Key 账号、 +// 声明上游支持显式缓存的自定义地址 API Key 账号,或 Service Account JSON 认证的 Vertex 账号。 +// API Key 认证的 Vertex 账号不参与显式缓存:Vertex 缓存接口只接受 OAuth access token,不接受 API Key。 +func IsGeminiExplicitCacheAccount(account *Account) bool { + if !IsGeminiExplicitCacheAccountType(account) { + return false + } + if account.Type == AccountTypeServiceAccount { + return !account.IsVertexAPIKey() + } + if strings.TrimSpace(account.GetCredential("api_key")) == "" { + return false + } + return isOfficialGeminiAPIBaseURL(account.GetCredential("base_url")) || account.GeminiExplicitCacheUpstreamEnabled() +} + +// GeminiExplicitCacheUpstreamCredentialKey / GeminiExplicitCacheUpstreamMaxTTLCredentialKey 是 API Key 账号凭据里 +// 声明上游实现显式缓存接口及其有效期上限的键;调度快照投影须保留它们,候选过滤才能直接判定账号是否支持显式缓存。 +const ( + GeminiExplicitCacheUpstreamCredentialKey = "explicit_cache_upstream" + GeminiExplicitCacheUpstreamMaxTTLCredentialKey = "explicit_cache_upstream_max_ttl_seconds" +) + +// GeminiExplicitCacheUpstreamEnabled 判断 API Key 账号是否声明其上游实现了显式缓存接口, +// 并保证缓存只在创建它的上游账号上使用(本系统同版本网关)。 +func (a *Account) GeminiExplicitCacheUpstreamEnabled() bool { + if a == nil || a.Type != AccountTypeAPIKey || a.Credentials == nil { + return false + } + enabled, _ := a.Credentials[GeminiExplicitCacheUpstreamCredentialKey].(bool) + return enabled +} + +// GeminiExplicitCacheUpstreamMaxTTL 返回上游网关允许的缓存最长有效期;0 表示未配置。 +func (a *Account) GeminiExplicitCacheUpstreamMaxTTL() time.Duration { + if !a.GeminiExplicitCacheUpstreamEnabled() { + return 0 + } + var seconds float64 + switch v := a.Credentials[GeminiExplicitCacheUpstreamMaxTTLCredentialKey].(type) { + case float64: + seconds = v + case int: + seconds = float64(v) + case int64: + seconds = float64(v) + case json.Number: + seconds, _ = v.Float64() + case string: + seconds, _ = strconv.ParseFloat(strings.TrimSpace(v), 64) + } + if seconds <= 0 || math.IsNaN(seconds) || math.IsInf(seconds, 0) { + return 0 + } + return time.Duration(seconds) * time.Second +} + +// geminiCachedContentAPIBase 返回 API Key 账号缓存接口的上游根地址。 +func geminiCachedContentAPIBase(account *Account) string { + if isOfficialGeminiAPIBaseURL(account.GetCredential("base_url")) { + return geminicli.AIStudioBaseURL + } + return account.GetGeminiBaseURL(geminicli.AIStudioBaseURL) +} + +func isOfficialGeminiAPIBaseURL(raw string) bool { + raw = strings.TrimSpace(raw) + if raw == "" { + return true + } + u, err := url.Parse(raw) + if err != nil { + return false + } + path := strings.TrimRight(u.Path, "/") + return strings.EqualFold(u.Scheme, "https") && + strings.EqualFold(u.Hostname(), geminiCachedContentOfficialHost) && + u.Port() == "" && path == "" && u.RawQuery == "" +} + +// NewGeminiCachedContentPublicID 生成 40 位小写字母数字的缓存 ID。 +func NewGeminiCachedContentPublicID() (string, error) { + var b [25]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return strings.ToLower(geminiCachedContentPublicIDEncoding.EncodeToString(b[:])), nil +} + +// GeminiCachedContentUsageRequestIDPrefix 是显式缓存创建 / 延长费用在 usage_logs.request_id 中的固定前缀: +// 这类费用行是独立的扣费事件,不随客户端或网关请求 id 变化,按缓存 ID 即可检索一个缓存的全部费用行。 +// 两种格式都不超过 request_id 列的 64 字符。 +const GeminiCachedContentUsageRequestIDPrefix = "gcache:" + +// GeminiCachedContentCreateUsageRequestID 返回创建费用的 request_id("gcache:create:{id}");一个缓存只创建一次。 +func GeminiCachedContentCreateUsageRequestID(publicID string) string { + return GeminiCachedContentUsageRequestIDPrefix + "create:" + publicID +} + +// GeminiCachedContentPatchUsageRequestID 返回一次延长费用的 request_id("gcache:patch:{id}:{8 位随机}");每次延长各自唯一。 +func GeminiCachedContentPatchUsageRequestID(publicID string) string { + var b [5]byte + if _, err := rand.Read(b[:]); err != nil { + return GeminiCachedContentUsageRequestIDPrefix + "patch:" + publicID + ":" + strconv.FormatInt(time.Now().UnixNano()%(1<<40), 32) + } + return GeminiCachedContentUsageRequestIDPrefix + "patch:" + publicID + ":" + strings.ToLower(geminiCachedContentPublicIDEncoding.EncodeToString(b[:])) +} + +// ParseGeminiCachedContentPublicID 从 "cachedContents/{id}" 或裸 id 中解析缓存 ID。 +func ParseGeminiCachedContentPublicID(name string) (string, bool) { + id := strings.TrimPrefix(strings.TrimSpace(name), GeminiCachedContentNamePrefix) + if !geminiCachedContentPublicIDPattern.MatchString(id) { + return "", false + } + return id, true +} + +type geminiCachedContentBoundCtxKey struct{} + +// WithGeminiCachedContentBound 标记请求引用了显式缓存并已硬绑定到持有账号。 +func WithGeminiCachedContentBound(ctx context.Context) context.Context { + return context.WithValue(ctx, geminiCachedContentBoundCtxKey{}, true) +} + +func geminiCachedContentBoundFromContext(ctx context.Context) bool { + if ctx == nil { + return false + } + bound, _ := ctx.Value(geminiCachedContentBoundCtxKey{}).(bool) + return bound +} + +// isGeminiBoundCacheUpstreamPassthrough 判断绑定缓存的请求在上游网关账号上收到的错误是否只反映 +// 上游持有缓存的那个账号的状态(429 / 5xx),不应据此限流或重试本账号。 +func isGeminiBoundCacheUpstreamPassthrough(ctx context.Context, account *Account, statusCode int) bool { + if statusCode != http.StatusTooManyRequests && statusCode < http.StatusInternalServerError { + return false + } + return account.GeminiExplicitCacheUpstreamEnabled() && geminiCachedContentBoundFromContext(ctx) +} + +// GeminiCachedContentReference 是请求体中引用显式缓存的位置。 +type GeminiCachedContentReference struct { + Name string + Paths []string +} + +var geminiCachedContentReferencePaths = []string{"cachedContent", "generateContentRequest.cachedContent"} + +// FindGeminiCachedContentReference 返回 generateContent / countTokens 请求体中引用的缓存名;未引用时返回 nil。 +// 引用键重复、使用 snake_case 写法或多个位置引用不同缓存时返回错误,保证校验与改写的引用就是发往上游的引用。 +func FindGeminiCachedContentReference(body []byte) (*GeminiCachedContentReference, error) { + root := gjson.ParseBytes(body) + if !root.IsObject() { + return nil, nil + } + if err := checkGeminiCachedContentReferenceKeys(root, "cachedContent", "generateContentRequest"); err != nil { + return nil, err + } + if nested := root.Get("generateContentRequest"); nested.IsObject() { + if err := checkGeminiCachedContentReferenceKeys(nested, "cachedContent"); err != nil { + return nil, err + } + } + var ref *GeminiCachedContentReference + for _, path := range geminiCachedContentReferencePaths { + value := gjson.GetBytes(body, path) + if !value.Exists() || value.Type == gjson.Null { + continue + } + if value.Type != gjson.String { + return nil, errors.New("cachedContent must be a string") + } + name := strings.TrimSpace(value.String()) + if name == "" { + continue + } + if ref == nil { + ref = &GeminiCachedContentReference{Name: name} + } else if ref.Name != name { + return nil, errors.New("conflicting cachedContent references") + } + ref.Paths = append(ref.Paths, path) + } + return ref, nil +} + +// checkGeminiCachedContentReferenceKeys 拒绝对象中重复出现的 uniqueKeys 与 snake_case 写法的缓存引用键。 +func checkGeminiCachedContentReferenceKeys(obj gjson.Result, uniqueKeys ...string) error { + seen := make(map[string]bool, len(uniqueKeys)) + var err error + obj.ForEach(func(key, value gjson.Result) bool { + name := key.String() + switch { + case name == "cached_content": + err = errors.New("cached_content is not supported, use cachedContent") + case name == "generate_content_request" && (value.Get("cachedContent").Exists() || value.Get("cached_content").Exists()): + err = errors.New("generate_content_request is not supported with cachedContent, use generateContentRequest") + case slices.Contains(uniqueKeys, name): + if seen[name] { + err = fmt.Errorf("duplicate %s field", name) + } + seen[name] = true + } + return err == nil + }) + return err +} + +// RewriteGeminiCachedContentReference 把请求体中的缓存引用改写为上游资源名。 +func RewriteGeminiCachedContentReference(body []byte, ref *GeminiCachedContentReference, upstreamName string) ([]byte, error) { + if ref == nil { + return body, nil + } + out := body + for _, path := range ref.Paths { + next, err := sjson.SetBytes(out, path, upstreamName) + if err != nil { + return nil, err + } + out = next + } + return out, nil +} + +// GeminiCachedContentCreateRequest 是校验后的创建请求。 +type GeminiCachedContentCreateRequest struct { + // Model 为客户端请求的模型名(不含 models/ 前缀)。 + Model string + TTL time.Duration + DisplayName string + // ttlExplicit 表示客户端显式指定了 ttl 或 expireTime。 + ttlExplicit bool + payload map[string]any +} + +// TTLSeconds 返回发往上游的整秒 TTL。 +func (r *GeminiCachedContentCreateRequest) TTLSeconds() int64 { + return int64(math.Ceil(r.TTL.Seconds())) +} + +// TTLForAccount 返回在账号上创建缓存使用的整秒有效期。账号上限小于请求有效期时: +// 未显式指定有效期的请求按账号上限创建,显式指定的返回 false(该账号不可用)。 +func (r *GeminiCachedContentCreateRequest) TTLForAccount(account *Account) (time.Duration, bool) { + ttl := time.Duration(r.TTLSeconds()) * time.Second + limit := account.GeminiExplicitCacheUpstreamMaxTTL() + if limit <= 0 || ttl <= limit { + return ttl, true + } + if r.ttlExplicit { + return 0, false + } + return limit, true +} + +// ParseGeminiCachedContentCreateRequest 校验创建请求。未指定有效期时使用上游默认的 1 小时(不超过 maxTTL)。 +// 数字按原文保留,重新编码后发往上游的内容与请求一致。 +func ParseGeminiCachedContentCreateRequest(body []byte, now time.Time, maxTTL time.Duration) (*GeminiCachedContentCreateRequest, error) { + payload, err := decodeGeminiCachedContentPayload(body) + if err != nil { + return nil, err + } + rawModel, _ := payload["model"].(string) + model := strings.TrimPrefix(strings.TrimSpace(rawModel), "models/") + if model == "" { + return nil, errors.New("model is required") + } + if !IsSafeGeminiModelPathSegment(model) { + return nil, errors.New("model name must be in the format 'models/{model_name}'") + } + + ttl, err := parseGeminiCachedContentExpiration(payload, now) + if err != nil { + return nil, err + } + ttlExplicit := ttl > 0 + if ttl == 0 { + ttl = geminiCachedContentDefaultTTL + if maxTTL > 0 && ttl > maxTTL { + ttl = maxTTL + } + } + if maxTTL > 0 && ttl > maxTTL { + return nil, fmt.Errorf("ttl exceeds the maximum allowed %ds", int64(maxTTL.Seconds())) + } + + displayName := "" + if raw, ok := payload["displayName"]; ok && raw != nil { + s, ok := raw.(string) + if !ok { + return nil, errors.New("displayName must be a string") + } + if utf8.RuneCountInString(s) > geminiCachedContentDisplayNameMaxRunes { + return nil, fmt.Errorf("displayName must be at most %d characters", geminiCachedContentDisplayNameMaxRunes) + } + displayName = s + } + + for _, key := range []string{"name", "model", "displayName", "ttl", "expireTime", "createTime", "updateTime", "usageMetadata"} { + delete(payload, key) + } + return &GeminiCachedContentCreateRequest{Model: model, TTL: ttl, DisplayName: displayName, ttlExplicit: ttlExplicit, payload: payload}, nil +} + +// ParseGeminiCachedContentPatchRequest 校验更新请求,只允许修改有效期,返回新的 TTL(自 now 起算)。 +func ParseGeminiCachedContentPatchRequest(body []byte, updateMask string, now time.Time, maxTTL time.Duration) (time.Duration, error) { + payload, err := decodeGeminiCachedContentPayload(body) + if err != nil { + return 0, err + } + for key := range payload { + switch key { + case "ttl", "expireTime", "name": + default: + return 0, errors.New("can not update immutable fields (model or display_name)") + } + } + for _, field := range strings.Split(updateMask, ",") { + switch strings.TrimSpace(field) { + case "", "ttl", "expireTime", "expire_time": + default: + return 0, errors.New("can not update immutable fields (model or display_name)") + } + } + ttl, err := parseGeminiCachedContentExpiration(payload, now) + if err != nil { + return 0, err + } + if ttl == 0 { + return 0, errors.New("ttl or expireTime is required") + } + if maxTTL > 0 && ttl > maxTTL { + return 0, fmt.Errorf("ttl exceeds the maximum allowed %ds", int64(maxTTL.Seconds())) + } + return ttl, nil +} + +// geminiCachedContentFieldAliases 是缓存元数据字段的 snake_case 写法到 camelCase 的映射。 +var geminiCachedContentFieldAliases = map[string]string{ + "display_name": "displayName", + "expire_time": "expireTime", + "create_time": "createTime", + "update_time": "updateTime", + "usage_metadata": "usageMetadata", +} + +// decodeGeminiCachedContentPayload 解码缓存请求体(数字保留原文),并把 snake_case 写法的元数据字段改为 camelCase; +// 同一字段两种写法同时出现时报错。 +func decodeGeminiCachedContentPayload(body []byte) (map[string]any, error) { + dec := json.NewDecoder(bytes.NewReader(body)) + dec.UseNumber() + var payload map[string]any + if err := dec.Decode(&payload); err != nil || payload == nil { + return nil, errors.New("request body must be a JSON object") + } + if _, err := dec.Token(); !errors.Is(err, io.EOF) { + return nil, errors.New("request body must be a JSON object") + } + for alias, canonical := range geminiCachedContentFieldAliases { + value, ok := payload[alias] + if !ok { + continue + } + if _, dup := payload[canonical]; dup { + return nil, fmt.Errorf("only one of %s or %s may be set", canonical, alias) + } + delete(payload, alias) + payload[canonical] = value + } + return payload, nil +} + +// parseGeminiCachedContentExpiration 解析 ttl / expireTime(二选一),未设置时返回 0;结果向上取整到秒,与发往上游的 ttl 一致。 +func parseGeminiCachedContentExpiration(payload map[string]any, now time.Time) (time.Duration, error) { + rawTTL, hasTTL := payload["ttl"] + rawExpire, hasExpire := payload["expireTime"] + hasTTL = hasTTL && rawTTL != nil + hasExpire = hasExpire && rawExpire != nil + if hasTTL && hasExpire { + return 0, errors.New("only one of ttl or expireTime may be set") + } + if hasTTL { + s, ok := rawTTL.(string) + if !ok { + return 0, errors.New("ttl must be a duration string such as \"3600s\"") + } + return parseGoogleDurationSeconds(s) + } + if hasExpire { + s, ok := rawExpire.(string) + if !ok { + return 0, errors.New("expireTime must be an RFC 3339 timestamp") + } + expire, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(s)) + if err != nil { + return 0, errors.New("expireTime must be an RFC 3339 timestamp") + } + ttl := expire.Sub(now) + if ttl <= 0 { + return 0, errors.New("expireTime must be in the future") + } + return time.Duration(math.Ceil(ttl.Seconds())) * time.Second, nil + } + return 0, nil +} + +func parseGoogleDurationSeconds(raw string) (time.Duration, error) { + raw = strings.TrimSpace(raw) + if !strings.HasSuffix(raw, "s") { + return 0, errors.New("ttl must be a duration string such as \"3600s\"") + } + seconds, err := strconv.ParseFloat(strings.TrimSuffix(raw, "s"), 64) + if err != nil || math.IsNaN(seconds) || math.IsInf(seconds, 0) { + return 0, errors.New("ttl must be a duration string such as \"3600s\"") + } + if seconds <= 0 { + return 0, errors.New("ttl must be positive") + } + if seconds > float64(math.MaxInt64/int64(time.Second)) { + return 0, errors.New("ttl is too large") + } + return time.Duration(math.Ceil(seconds)) * time.Second, nil +} + +// GeminiCachedContentUpstream 是上游缓存资源的元数据。 +type GeminiCachedContentUpstream struct { + Name string + TotalTokenCount int64 + ExpireTime time.Time +} + +// GeminiCachedContentUpstreamError 是上游返回的非 2xx 响应。 +type GeminiCachedContentUpstreamError struct { + StatusCode int + Body []byte + // UpstreamGateway 表示响应来自声明实现显式缓存的上游网关账号:限流 / 不可用反映的是持有缓存的上游账号状态。 + UpstreamGateway bool +} + +func (e *GeminiCachedContentUpstreamError) Error() string { + return fmt.Sprintf("gemini cached content upstream error: %d", e.StatusCode) +} + +// NotFound 判断上游是否报告缓存不存在(含已过期、已删除)。 +func (e *GeminiCachedContentUpstreamError) NotFound() bool { + return e != nil && isGeminiCachedContentNotFound(e.StatusCode, e.Body) +} + +// GeminiCachedContentMalformedResponseError 表示上游返回 2xx 但响应体不可用;Name 非空时上游资源已经创建。 +type GeminiCachedContentMalformedResponseError struct { + Name string + Err error +} + +func (e *GeminiCachedContentMalformedResponseError) Error() string { + return "gemini cached content upstream returned an unusable response: " + e.Err.Error() +} + +func (e *GeminiCachedContentMalformedResponseError) Unwrap() error { + return e.Err +} + +// RetryableOnOtherAccount 判断创建失败是否值得换一个账号重试(账号凭据、配额或上游可用性问题)。 +func (e *GeminiCachedContentUpstreamError) RetryableOnOtherAccount() bool { + if e == nil { + return false + } + switch e.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden, http.StatusTooManyRequests: + return true + default: + return e.StatusCode >= 500 + } +} + +// GeminiCachedContentService 管理显式缓存的上游资源与网关绑定。 +type GeminiCachedContentService struct { + repo GeminiCachedContentRepository + compat *GeminiMessagesCompatService + cfg *config.Config + now func() time.Time + + startOnce sync.Once + stopOnce sync.Once + stopCh chan struct{} +} + +func NewGeminiCachedContentService(repo GeminiCachedContentRepository, compat *GeminiMessagesCompatService, cfg *config.Config) *GeminiCachedContentService { + return &GeminiCachedContentService{repo: repo, compat: compat, cfg: cfg, now: time.Now, stopCh: make(chan struct{})} +} + +// Start 启动后台回滚恢复:定期处理租约已过期仍未清除的修改意图。 +func (s *GeminiCachedContentService) Start() { + if s == nil || s.repo == nil { + return + } + s.startOnce.Do(func() { go s.runMutationRecovery() }) +} + +// Stop 停止后台回滚恢复。 +func (s *GeminiCachedContentService) Stop() { + if s == nil { + return + } + s.stopOnce.Do(func() { close(s.stopCh) }) +} + +func (s *GeminiCachedContentService) runMutationRecovery() { + ticker := time.NewTicker(geminiCachedContentMutationRecoveryInterval) + defer ticker.Stop() + for { + select { + case <-s.stopCh: + return + case <-ticker.C: + recovered, err := s.RecoverStaleMutations(context.Background()) + if err != nil { + logger.LegacyPrintf("service.gemini_cached_content", "Warning: gemini cached content mutation recovery failed (recovered=%d): %v", recovered, err) + } else if recovered > 0 { + logger.LegacyPrintf("service.gemini_cached_content", "gemini cached content mutation recovery rolled back %d stale mutations", recovered) + } + } + } +} + +// Now 返回服务时钟。 +func (s *GeminiCachedContentService) Now() time.Time { + return s.now() +} + +// MaxTTL 返回单次创建或延长允许的最长有效期。 +func (s *GeminiCachedContentService) MaxTTL() time.Duration { + if s.cfg != nil && s.cfg.Gateway.GeminiCachedContentMaxTTLSeconds > 0 { + return time.Duration(s.cfg.Gateway.GeminiCachedContentMaxTTLSeconds) * time.Second + } + return geminiCachedContentDefaultMaxTTL +} + +// MaxTTLForAccount 返回在账号上可设置的最长有效期:全局上限与上游网关上限取较小值。 +func (s *GeminiCachedContentService) MaxTTLForAccount(account *Account) time.Duration { + limit := s.MaxTTL() + if upstream := account.GeminiExplicitCacheUpstreamMaxTTL(); upstream > 0 && upstream < limit { + return upstream + } + return limit +} + +// GeminiCachedContentStorageTokenHours 返回 tokens 个缓存 token 存储 duration 的存储量(token·小时)。 +func GeminiCachedContentStorageTokenHours(tokens int64, duration time.Duration) float64 { + if tokens <= 0 || duration <= 0 { + return 0 + } + return float64(tokens) * duration.Hours() +} + +// ClampGeminiCachedContentExpire 返回本地记录与计费采用的到期时间:上游回报的到期时间不晚于 limit,缺失时取 limit。 +func ClampGeminiCachedContentExpire(upstream, limit time.Time) time.Time { + if upstream.IsZero() || upstream.After(limit) { + return limit + } + return upstream +} + +// Resolve 返回 API Key 名下仍有效的缓存;不存在、不在当前分组或已过期时返回 ErrGeminiCachedContentNotFound。 +func (s *GeminiCachedContentService) Resolve(ctx context.Context, apiKeyID int64, groupID *int64, name string) (*GeminiCachedContent, error) { + publicID, ok := ParseGeminiCachedContentPublicID(name) + if !ok { + return nil, ErrGeminiCachedContentNotFound + } + record, err := s.repo.GetForOwner(ctx, apiKeyID, publicID) + if err != nil { + return nil, err + } + if groupID == nil || record.GroupID != *groupID || !record.ExpireTime.After(s.now()) { + return nil, ErrGeminiCachedContentNotFound + } + return record, nil +} + +func (s *GeminiCachedContentService) Save(ctx context.Context, record *GeminiCachedContent) error { + return s.repo.Create(ctx, record) +} + +func (s *GeminiCachedContentService) List(ctx context.Context, apiKeyID, groupID int64, beforeID int64, limit int) ([]*GeminiCachedContent, error) { + return s.repo.ListForOwner(ctx, apiKeyID, groupID, s.now(), beforeID, limit) +} + +// GeminiCachedContentMutation 是一次已登记修改意图、尚未提交的有效期修改。持有期间同一缓存的其他修改被拒绝; +// 未提交的修改(上游结果未知、落库失败、进程退出)在租约过期后由恢复流程把上游有效期回滚到 Baseline,不计费。 +type GeminiCachedContentMutation struct { + record *GeminiCachedContent + baseline time.Time + token string +} + +// Baseline 返回修改前的到期时间:提交前它始终是库中的到期时间。 +func (m *GeminiCachedContentMutation) Baseline() time.Time { + return m.baseline +} + +// BeginMutation 登记修改 record 有效期的意图并取得租约。同一缓存有进行中的修改,或库中到期时间已不是 +// record.ExpireTime 时返回 ErrGeminiCachedContentMutationConflict;租约已过期的遗留意图先回滚恢复再登记。 +func (s *GeminiCachedContentService) BeginMutation(ctx context.Context, record *GeminiCachedContent) (*GeminiCachedContentMutation, error) { + for attempt := 0; ; attempt++ { + token, err := NewGeminiCachedContentPublicID() + if err != nil { + return nil, err + } + acquired, err := s.repo.BeginMutation(ctx, record.ID, record.ExpireTime, token, geminiCachedContentMutationLease) + if err != nil { + return nil, err + } + if acquired { + return &GeminiCachedContentMutation{record: record, baseline: record.ExpireTime, token: token}, nil + } + if attempt > 0 { + return nil, ErrGeminiCachedContentMutationConflict + } + recovered, err := s.recoverStaleMutation(ctx, record.ID) + if err != nil { + return nil, errors.Join(ErrGeminiCachedContentMutationConflict, err) + } + if !recovered { + return nil, ErrGeminiCachedContentMutationConflict + } + } +} + +// CommitMutation 写入新的到期时间并清除修改意图。修改意图已不属于 m(缓存已删除或租约已被接管)时返回 +// ErrGeminiCachedContentMutationConflict;其他失败时意图保留,由恢复流程回滚上游。 +func (s *GeminiCachedContentService) CommitMutation(ctx context.Context, m *GeminiCachedContentMutation, expireTime time.Time) error { + if err := s.repo.CommitMutation(ctx, m.record.ID, m.token, expireTime); err != nil { + return err + } + m.record.ExpireTime = expireTime + m.record.UpdatedAt = s.now() + return nil +} + +// ReleaseMutation 放弃修改并清除修改意图,用于上游明确未改变有效期的情形。 +func (s *GeminiCachedContentService) ReleaseMutation(ctx context.Context, m *GeminiCachedContentMutation) error { + return s.repo.ReleaseMutation(ctx, m.record.ID, m.token) +} + +// RecoverStaleMutations 回滚恢复一批租约已过期的修改意图,返回完成恢复的数量。 +func (s *GeminiCachedContentService) RecoverStaleMutations(ctx context.Context) (int, error) { + listCtx, cancel := context.WithTimeout(ctx, geminiCachedContentMutationRecoveryTimeout) + ids, err := s.repo.ListStaleMutations(listCtx, geminiCachedContentMutationRecoveryBatch) + cancel() + if err != nil { + return 0, err + } + recovered := 0 + var errs []error + for _, id := range ids { + select { + case <-s.stopCh: + return recovered, errors.Join(errs...) + default: + } + itemCtx, cancel := context.WithTimeout(ctx, geminiCachedContentMutationRecoveryTimeout) + ok, err := s.recoverStaleMutation(itemCtx, id) + cancel() + if err != nil { + errs = append(errs, fmt.Errorf("cached content %d: %w", id, err)) + continue + } + if ok { + recovered++ + } + } + return recovered, errors.Join(errs...) +} + +// recoverStaleMutation 接管 id 上租约已过期的修改意图,把上游有效期回滚到库中的到期时间后清除意图; +// 库中到期时间已到时删除上游缓存与本地记录。没有可接管的意图时返回 false;回滚失败时意图保留, +// 本次接管取得的新租约(geminiCachedContentMutationLease)过期后重试,期间同一缓存的修改返回 409。 +func (s *GeminiCachedContentService) recoverStaleMutation(ctx context.Context, id int64) (bool, error) { + token, err := NewGeminiCachedContentPublicID() + if err != nil { + return false, err + } + record, err := s.repo.TakeOverStaleMutation(ctx, id, token, geminiCachedContentMutationLease) + if err != nil || record == nil { + return false, err + } + forget, err := s.rollbackUpstream(ctx, record) + if err != nil { + return false, err + } + if forget { + return true, s.repo.SoftDelete(ctx, record.ID) + } + return true, s.repo.ReleaseMutation(ctx, record.ID, token) +} + +// rollbackUpstream 把上游有效期改回 record.ExpireTime,剩余不足 1 秒时删除上游缓存; +// 返回 true 表示上游缓存已不存在、或已没有凭据可触达(持有账号已删除),本地记录应移除。 +// 持有账号存在但当前不支持显式缓存时不改动上游,修改意图保留到账号恢复后重试;账号可用时总是先向上游回滚。 +// 两种情况下只要 mutationUpstreamMustHaveExpired 成立(上游缓存必已自行过期),本地记录直接作废,不再重试。 +func (s *GeminiCachedContentService) rollbackUpstream(ctx context.Context, record *GeminiCachedContent) (bool, error) { + account, err := s.boundAccount(ctx, record) + if errors.Is(err, errGeminiCachedContentAccountUnsupported) { + if s.mutationUpstreamMustHaveExpired(record) { + logger.LegacyPrintf("service.gemini_cached_content", "Warning: gemini cached content %d (upstream %s) has a pending mutation but account %d stopped supporting explicit caching and the upstream cache has expired since; dropping the local record", record.ID, record.UpstreamName, record.AccountID) + return true, nil + } + return false, fmt.Errorf("%w (account %d)", err, record.AccountID) + } + if err != nil { + return false, err + } + if account == nil { + logger.LegacyPrintf("service.gemini_cached_content", "Warning: gemini cached content %d (upstream %s) has a pending mutation but account %d no longer exists; dropping the local record without touching upstream", record.ID, record.UpstreamName, record.AccountID) + return true, nil + } + remaining := record.ExpireTime.Sub(s.now()) + if remaining < time.Second { + err = s.DeleteUpstream(ctx, account, record) + } else { + _, err = s.PatchUpstream(ctx, account, record, remaining) + } + var upErr *GeminiCachedContentUpstreamError + if errors.As(err, &upErr) && upErr.NotFound() { + return true, nil + } + if err != nil { + if s.mutationUpstreamMustHaveExpired(record) { + logger.LegacyPrintf("service.gemini_cached_content", "Warning: gemini cached content %d (upstream %s) rollback keeps failing on account %d but the upstream cache has expired since; dropping the local record: %v", record.ID, record.UpstreamName, record.AccountID, err) + return true, nil + } + return false, err + } + return remaining < time.Second, nil +} + +// mutationUpstreamMustHaveExpired 判断 record 上未完成修改所涉及的上游缓存是否必已自行过期: +// 修改发生时记录尚未到期(Resolve 拒绝已过期记录),且单次延长不超过 MaxTTL, +// 所以上游有效期最晚为 record.ExpireTime + MaxTTL;再过 geminiCachedContentMutationAbandonGrace 后判定为已过期。 +// MaxTTL 取判定时刻的配置并以默认上限兜底:配置被调低后判定可能早于上游真实过期,此时只影响运营方的上游存储费, +// 不涉及用户计费。 +func (s *GeminiCachedContentService) mutationUpstreamMustHaveExpired(record *GeminiCachedContent) bool { + maxTTL := s.MaxTTL() + if maxTTL < geminiCachedContentDefaultMaxTTL { + maxTTL = geminiCachedContentDefaultMaxTTL + } + return s.now().After(record.ExpireTime.Add(maxTTL + geminiCachedContentMutationAbandonGrace)) +} + +func (s *GeminiCachedContentService) Forget(ctx context.Context, record *GeminiCachedContent) error { + return s.repo.SoftDelete(ctx, record.ID) +} + +// BoundAccount 返回持有缓存的账号;账号已删除或不再支持显式缓存时返回 nil。 +func (s *GeminiCachedContentService) BoundAccount(ctx context.Context, record *GeminiCachedContent) (*Account, error) { + account, err := s.boundAccount(ctx, record) + if errors.Is(err, errGeminiCachedContentAccountUnsupported) { + return nil, nil + } + return account, err +} + +// boundAccount 返回持有缓存的账号:账号已删除返回 nil;账号存在但当前不支持显式缓存返回 +// errGeminiCachedContentAccountUnsupported,调用方据此区分"永久不可用"与"暂时不可用"。 +func (s *GeminiCachedContentService) boundAccount(ctx context.Context, record *GeminiCachedContent) (*Account, error) { + if s.compat == nil || s.compat.accountRepo == nil { + return nil, errors.New("account repository not configured") + } + account, err := s.compat.accountRepo.GetByID(ctx, record.AccountID) + if err != nil { + if errors.Is(err, ErrAccountNotFound) { + return nil, nil + } + return nil, err + } + if account == nil { + return nil, nil + } + if !IsGeminiExplicitCacheAccount(account) { + return nil, errGeminiCachedContentAccountUnsupported + } + return account, nil +} + +// GeminiCachedContentUpstreamModelResource 返回创建缓存时发往上游的模型资源名。 +func GeminiCachedContentUpstreamModelResource(account *Account, mappedModel string) (string, error) { + switch account.Type { + case AccountTypeAPIKey: + return "models/" + mappedModel, nil + case AccountTypeServiceAccount: + projectID := strings.TrimSpace(account.VertexProjectID()) + if projectID == "" { + return "", errors.New("vertex project_id is required") + } + location := strings.TrimSpace(account.VertexLocation(mappedModel)) + if location == "" { + location = vertexDefaultLocation + } + if !vertexLocationPattern.MatchString(location) { + return "", fmt.Errorf("invalid vertex location: %s", location) + } + return fmt.Sprintf("projects/%s/locations/%s/publishers/google/models/%s", projectID, location, mappedModel), nil + default: + return "", fmt.Errorf("account type %s does not support explicit context caching", account.Type) + } +} + +// CreateUpstream 在账号上创建上游缓存。 +func (s *GeminiCachedContentService) CreateUpstream(ctx context.Context, account *Account, req *GeminiCachedContentCreateRequest, mappedModel, publicID string, ttl time.Duration) (*GeminiCachedContentUpstream, error) { + upstreamModel, err := GeminiCachedContentUpstreamModelResource(account, mappedModel) + if err != nil { + return nil, err + } + payload := make(map[string]any, len(req.payload)+3) + for k, v := range req.payload { + payload[k] = v + } + payload["model"] = upstreamModel + payload["ttl"] = fmt.Sprintf("%ds", int64(math.Ceil(ttl.Seconds()))) + payload["displayName"] = geminiCachedContentUpstreamTagPrefix + publicID + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + base, err := s.apiBase(account) + if err != nil { + return nil, err + } + collectionURL, err := geminiCachedContentCollectionURL(account, base, mappedModel) + if err != nil { + return nil, err + } + respBody, err := s.doUpstream(ctx, account, http.MethodPost, collectionURL, body, geminiCachedContentUpstreamTimeout) + if err != nil { + return nil, err + } + upstream, err := parseGeminiCachedContentUpstream(respBody) + if err != nil { + return nil, err + } + if upstream.TotalTokenCount <= 0 { + return nil, &GeminiCachedContentMalformedResponseError{Name: upstream.Name, Err: errors.New("missing usageMetadata.totalTokenCount")} + } + return upstream, nil +} + +// PatchUpstream 把上游缓存的有效期改为自现在起 ttl。 +func (s *GeminiCachedContentService) PatchUpstream(ctx context.Context, account *Account, record *GeminiCachedContent, ttl time.Duration) (*GeminiCachedContentUpstream, error) { + resourceURL, err := s.upstreamResourceURL(account, record) + if err != nil { + return nil, err + } + seconds := int64(math.Ceil(ttl.Seconds())) + body, _ := json.Marshal(map[string]any{"ttl": fmt.Sprintf("%ds", seconds)}) + respBody, err := s.doUpstream(ctx, account, http.MethodPatch, resourceURL+"?updateMask=ttl", body, geminiCachedContentMutationUpstreamTimeout) + if err != nil { + return nil, err + } + out := &GeminiCachedContentUpstream{Name: record.UpstreamName} + if raw := gjson.GetBytes(respBody, "expireTime").String(); raw != "" { + if t, err := time.Parse(time.RFC3339Nano, raw); err == nil { + out.ExpireTime = t + } + } + return out, nil +} + +// DeleteUpstream 删除上游缓存。 +func (s *GeminiCachedContentService) DeleteUpstream(ctx context.Context, account *Account, record *GeminiCachedContent) error { + resourceURL, err := s.upstreamResourceURL(account, record) + if err != nil { + return err + } + _, err = s.doUpstream(ctx, account, http.MethodDelete, resourceURL, nil, geminiCachedContentMutationUpstreamTimeout) + return err +} + +// upstreamResourceURL 返回 record 对应上游缓存资源的地址;失败时请求不会发出。 +func (s *GeminiCachedContentService) upstreamResourceURL(account *Account, record *GeminiCachedContent) (string, error) { + base, err := s.apiBase(account) + if err != nil { + return "", geminiCachedContentNotSent(err) + } + resourceURL, err := geminiCachedContentResourceURL(account, base, record.UpstreamName) + if err != nil { + return "", geminiCachedContentNotSent(err) + } + return resourceURL, nil +} + +func geminiCachedContentNotSent(err error) error { + return fmt.Errorf("%w: %w", ErrGeminiCachedContentRequestNotSent, err) +} + +// doUpstream 调用上游缓存接口并返回 2xx 响应体,单次调用不超过 timeout。上游调用与客户端连接脱钩:客户端断开不会中途取消 +// 已发出的创建 / 修改,调用方据结果完成落库、计费或清理。请求发出前的失败包装为 ErrGeminiCachedContentRequestNotSent。 +func (s *GeminiCachedContentService) doUpstream(ctx context.Context, account *Account, method, target string, body []byte, timeout time.Duration) ([]byte, error) { + if s.compat == nil || s.compat.httpUpstream == nil { + return nil, geminiCachedContentNotSent(errors.New("gemini upstream client not configured")) + } + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout) + defer cancel() + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, target, reader) + if err != nil { + return nil, geminiCachedContentNotSent(err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + switch account.Type { + case AccountTypeAPIKey: + apiKey := strings.TrimSpace(account.GetCredential("api_key")) + if apiKey == "" { + return nil, geminiCachedContentNotSent(errors.New("gemini api_key not configured")) + } + req.Header.Set("x-goog-api-key", apiKey) + case AccountTypeServiceAccount: + if s.compat.tokenProvider == nil { + return nil, geminiCachedContentNotSent(errors.New("gemini token provider not configured")) + } + accessToken, err := s.compat.tokenProvider.GetAccessToken(ctx, account) + if err != nil { + return nil, geminiCachedContentNotSent(err) + } + req.Header.Set("Authorization", "Bearer "+accessToken) + default: + return nil, geminiCachedContentNotSent(fmt.Errorf("account type %s does not support explicit context caching", account.Type)) + } + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + resp, err := s.compat.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + respBody, err := io.ReadAll(io.LimitReader(resp.Body, geminiCachedContentResponseMaxBytes)) + success := resp.StatusCode >= 200 && resp.StatusCode < 300 + if err != nil { + if success { + return nil, &GeminiCachedContentMalformedResponseError{Err: err} + } + return nil, err + } + if !success { + return nil, &GeminiCachedContentUpstreamError{StatusCode: resp.StatusCode, Body: respBody, UpstreamGateway: account.GeminiExplicitCacheUpstreamEnabled()} + } + return respBody, nil +} + +// apiBase 返回 API Key 账号经地址校验后的上游根地址;Vertex 账号返回空串(地址由区域决定)。 +func (s *GeminiCachedContentService) apiBase(account *Account) (string, error) { + if account.Type != AccountTypeAPIKey { + return "", nil + } + if s.compat == nil { + return "", errors.New("gemini upstream client not configured") + } + return s.compat.validateUpstreamBaseURL(geminiCachedContentAPIBase(account)) +} + +func parseGeminiCachedContentUpstream(body []byte) (*GeminiCachedContentUpstream, error) { + name := strings.TrimSpace(gjson.GetBytes(body, "name").String()) + if name == "" { + return nil, &GeminiCachedContentMalformedResponseError{Err: errors.New("missing name")} + } + if !geminiCachedContentAIStudioName.MatchString(name) && !geminiCachedContentVertexName.MatchString(name) { + return nil, &GeminiCachedContentMalformedResponseError{Err: fmt.Errorf("unexpected name %q", name)} + } + out := &GeminiCachedContentUpstream{ + Name: name, + TotalTokenCount: gjson.GetBytes(body, "usageMetadata.totalTokenCount").Int(), + } + if raw := gjson.GetBytes(body, "expireTime").String(); raw != "" { + if t, err := time.Parse(time.RFC3339Nano, raw); err == nil { + out.ExpireTime = t + } + } + return out, nil +} + +func geminiCachedContentCollectionURL(account *Account, base, mappedModel string) (string, error) { + switch account.Type { + case AccountTypeAPIKey: + base = strings.TrimRight(strings.TrimSpace(base), "/") + if base == "" { + return "", errors.New("gemini base url is required") + } + return base + "/v1beta/cachedContents", nil + case AccountTypeServiceAccount: + projectID := strings.TrimSpace(account.VertexProjectID()) + if projectID == "" { + return "", errors.New("vertex project_id is required") + } + location := strings.TrimSpace(account.VertexLocation(mappedModel)) + if location == "" { + location = vertexDefaultLocation + } + if !vertexLocationPattern.MatchString(location) { + return "", fmt.Errorf("invalid vertex location: %s", location) + } + return fmt.Sprintf("https://%s/v1/projects/%s/locations/%s/cachedContents", + geminiCachedContentVertexHost(location), url.PathEscape(projectID), url.PathEscape(location)), nil + default: + return "", fmt.Errorf("account type %s does not support explicit context caching", account.Type) + } +} + +func geminiCachedContentResourceURL(account *Account, base, upstreamName string) (string, error) { + switch account.Type { + case AccountTypeAPIKey: + if !geminiCachedContentAIStudioName.MatchString(upstreamName) { + return "", fmt.Errorf("invalid upstream cached content name: %s", upstreamName) + } + base = strings.TrimRight(strings.TrimSpace(base), "/") + if base == "" { + return "", errors.New("gemini base url is required") + } + return base + "/v1beta/" + upstreamName, nil + case AccountTypeServiceAccount: + m := geminiCachedContentVertexName.FindStringSubmatch(upstreamName) + if m == nil { + return "", fmt.Errorf("invalid upstream cached content name: %s", upstreamName) + } + return fmt.Sprintf("https://%s/v1/%s", geminiCachedContentVertexHost(m[1]), upstreamName), nil + default: + return "", fmt.Errorf("account type %s does not support explicit context caching", account.Type) + } +} + +func geminiCachedContentVertexHost(location string) string { + if location == "global" { + return "aiplatform.googleapis.com" + } + return location + "-aiplatform.googleapis.com" +} + +// GeminiCachedContentView 返回客户端可见的缓存元数据。 +func GeminiCachedContentView(record *GeminiCachedContent) map[string]any { + view := map[string]any{ + "name": record.PublicName(), + "model": "models/" + record.Model, + "usageMetadata": map[string]any{"totalTokenCount": record.TotalTokenCount}, + "createTime": record.CreatedAt.UTC().Format(time.RFC3339Nano), + "updateTime": record.UpdatedAt.UTC().Format(time.RFC3339Nano), + "expireTime": record.ExpireTime.UTC().Format(time.RFC3339Nano), + } + if record.DisplayName != "" { + view["displayName"] = record.DisplayName + } + return view +} + +// GeminiExplicitCacheExclusions 返回显式缓存请求在 gemini 分组内需要排除的可调度账号。 +// boundAccountID > 0 时只保留该账号;否则排除不支持显式缓存的账号。判定所需的凭据键 +// (api_key、base_url、auth_mode、上游显式缓存声明)都在调度快照投影内,候选过滤一次算全; +// 选中后仍对完整账号复核。 +func (s *GatewayService) GeminiExplicitCacheExclusions(ctx context.Context, groupID *int64, boundAccountID int64) (map[int64]struct{}, error) { + accounts, _, err := s.listSchedulableAccounts(ctx, groupID, PlatformGemini, false) + if err != nil { + return nil, err + } + excluded := make(map[int64]struct{}, len(accounts)) + for i := range accounts { + account := &accounts[i] + keep := IsGeminiExplicitCacheAccount(account) + if boundAccountID > 0 { + keep = account.ID == boundAccountID + } + if !keep { + excluded[account.ID] = struct{}{} + } + } + return excluded, nil +} diff --git a/backend/internal/service/gemini_cached_content_hardening_test.go b/backend/internal/service/gemini_cached_content_hardening_test.go new file mode 100644 index 000000000000..f93b9424a058 --- /dev/null +++ b/backend/internal/service/gemini_cached_content_hardening_test.go @@ -0,0 +1,480 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +const geminiCacheHardeningPricing = `{ + "gemini-3.8-flash":{"litellm_provider":"gemini","input_cost_per_token":0.00000075,"output_cost_per_token":0.00000375, + "cache_storage_cost_per_token_per_hour":0.0000005}, + "gemini-2.5-pro":{"litellm_provider":"gemini","input_cost_per_token":0.00000125,"output_cost_per_token":0.00001, + "cache_storage_cost_per_token_per_hour":0.0000045}}` + +func newGeminiCacheBillingGatewayForTest(t *testing.T) (*GatewayService, *openAIRecordUsageLogRepoStub) { + t.Helper() + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newGatewayRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}) + pricing := &PricingService{} + data, err := pricing.parsePricingData([]byte(geminiCacheHardeningPricing)) + require.NoError(t, err) + pricing.pricingData = data + svc.billingService = NewBillingService(svc.cfg, pricing) + return svc, usageRepo +} + +func TestGeminiCachedContentStoragePriceFollowsBillingModel(t *testing.T) { + ctx := context.Background() + svc, usageRepo := newGeminiCacheBillingGatewayForTest(t) + groupID := int64(33) + apiKey := &APIKey{ID: 821, GroupID: &groupID, User: &User{ID: 621}, + Group: &Group{ID: groupID, Platform: PlatformGemini, RateMultiplier: 2, Hydrated: true}} + tokenHours := GeminiCachedContentStorageTokenHours(1000, 24*time.Hour) + + mapped := &RecordUsageInput{ + Result: &ForwardResult{RequestID: "cc-mapped", Model: "gemini-2.5-pro", UpstreamModel: "gemini-3.8-flash", + Usage: ClaudeUsage{InputTokens: 1000}, CacheStorageTokenHours: tokenHours}, + APIKey: apiKey, User: apiKey.User, Account: &Account{ID: 713, Platform: PlatformGemini}, + } + require.True(t, svc.CacheStoragePriced(ctx, mapped)) + want := (1000*0.00000125 + tokenHours*0.0000045) * 2 + require.InDelta(t, want, svc.QuoteUsageCost(ctx, mapped), 1e-12, "存储与 token 同按计费模型(请求模型)定价") + require.NoError(t, svc.RecordUsage(ctx, mapped)) + require.InDelta(t, want, usageRepo.lastLog.ActualCost, 1e-12, "报价与实际计费同口径") + + alias := &RecordUsageInput{ + Result: &ForwardResult{Model: "customer-alias", UpstreamModel: "gemini-3.8-flash", CacheStorageTokenHours: tokenHours}, + APIKey: apiKey, + } + require.True(t, svc.CacheStoragePriced(ctx, alias)) + require.InDelta(t, tokenHours*0.0000005*2, svc.QuoteUsageCost(ctx, alias), 1e-12, "计费模型无存储价时回退到实际转发模型") + + unpriced := &RecordUsageInput{ + Result: &ForwardResult{Model: "customer-alias", UpstreamModel: "unknown-upstream", CacheStorageTokenHours: tokenHours}, + APIKey: apiKey, + } + require.False(t, svc.CacheStoragePriced(ctx, unpriced)) +} + +func TestGeminiCachedContentStorageOnlyIsNotARequest(t *testing.T) { + ctx := context.Background() + svc, usageRepo := newGeminiCacheBillingGatewayForTest(t) + groupID := int64(34) + perRequest := 0.05 + pricing := []ChannelModelPricing{{Models: []string{"gemini-3.8-flash"}, BillingMode: BillingModePerRequest, PerRequestPrice: &perRequest}} + svc.channelService = newTestChannelServiceForStats(t, &Channel{ID: 1, Status: StatusActive, + AccountStatsPricingRules: []AccountStatsPricingRule{{GroupIDs: []int64{groupID}, Pricing: pricing}}}, groupID, PlatformGemini) + svc.resolver = NewModelPricingResolver(svc.channelService, svc.billingService) + apiKey := &APIKey{ID: 822, GroupID: &groupID, User: &User{ID: 622}, + Group: &Group{ID: groupID, Platform: PlatformGemini, RateMultiplier: 1, Hydrated: true, ModelPricing: pricing}} + tokenHours := 20000.0 + storage := tokenHours * 0.0000005 + + record := func(tokens int) *UsageLog { + require.NoError(t, svc.RecordUsage(ctx, &RecordUsageInput{ + Result: &ForwardResult{RequestID: fmt.Sprintf("cc-%d", tokens), Model: "gemini-3.8-flash", + Usage: ClaudeUsage{InputTokens: tokens}, CacheStorageTokenHours: tokenHours}, + APIKey: apiKey, User: apiKey.User, Account: &Account{ID: 712, Platform: PlatformGemini}, + })) + return usageRepo.lastLog + } + + created := record(10000) + require.InDelta(t, perRequest+storage, created.TotalCost, 1e-12, "创建是一次请求:按次价 + 存储费") + require.NotNil(t, created.AccountStatsCost) + require.InDelta(t, perRequest+storage, *created.AccountStatsCost, 1e-12) + + extended := record(0) + require.InDelta(t, storage, extended.TotalCost, 1e-12, "延长有效期不算一次请求,只收存储费") + require.Nil(t, extended.AccountStatsCost, "账号统计成本回落 total_cost,不套按次规则") +} + +func TestFindGeminiCachedContentReferenceRejectsAmbiguousKeys(t *testing.T) { + for name, body := range map[string]string{ + "duplicate top-level": `{"cachedContent":"cachedContents/a","contents":[],"cachedContent":"cachedContents/b"}`, + "snake top-level": `{"cached_content":"cachedContents/a"}`, + "snake beside camel": `{"cachedContent":"cachedContents/a","cached_content":"cachedContents/b"}`, + "duplicate nested": `{"generateContentRequest":{"cachedContent":"cachedContents/a","cachedContent":"cachedContents/b"}}`, + "snake nested": `{"generateContentRequest":{"cached_content":"cachedContents/a"}}`, + "snake parent with cache": `{"generate_content_request":{"cachedContent":"cachedContents/a"}}`, + "duplicate nested parent": `{"generateContentRequest":{},"generateContentRequest":{"cachedContent":"cachedContents/a"}}`, + "conflicting two locations": `{"cachedContent":"cachedContents/a","generateContentRequest":{"cachedContent":"cachedContents/b"}}`, + } { + t.Run(name, func(t *testing.T) { + _, err := FindGeminiCachedContentReference([]byte(body)) + require.Error(t, err) + }) + } + + for name, body := range map[string]string{ + "snake parent without cache": `{"generate_content_request":{"contents":[]}}`, + "key name inside content": `{"contents":[{"role":"user","cached_content":"x","parts":[{"text":"cached_content"}]}]}`, + "not an object": `[{"cachedContent":"cachedContents/a"}]`, + } { + t.Run("allows "+name, func(t *testing.T) { + ref, err := FindGeminiCachedContentReference([]byte(body)) + require.NoError(t, err) + require.Nil(t, ref) + }) + } + + ref, err := FindGeminiCachedContentReference([]byte(`{"cachedContent":"cachedContents/a","generateContentRequest":{"cachedContent":"cachedContents/a"}}`)) + require.NoError(t, err) + require.Equal(t, []string{"cachedContent", "generateContentRequest.cachedContent"}, ref.Paths) +} + +func TestGeminiCachedContentPayloadNormalization(t *testing.T) { + now := time.Date(2026, 10, 9, 8, 0, 0, 0, time.UTC) + maxTTL := 24 * time.Hour + + req, err := ParseGeminiCachedContentCreateRequest([]byte(`{"model":"gemini-3.8-flash","display_name":"docs","expire_time":"2026-10-09T10:00:00Z"}`), now, maxTTL) + require.NoError(t, err) + require.Equal(t, "docs", req.DisplayName) + require.Equal(t, 2*time.Hour, req.TTL) + for _, key := range []string{"display_name", "expire_time", "displayName", "expireTime"} { + require.NotContains(t, req.payload, key, "元数据字段不随内容发往上游") + } + + for name, body := range map[string]string{ + "both spellings": `{"model":"gemini-3.8-flash","displayName":"a","display_name":"b"}`, + "trailing data": `{"model":"gemini-3.8-flash"} {"model":"x"}`, + } { + t.Run("rejects "+name, func(t *testing.T) { + _, err := ParseGeminiCachedContentCreateRequest([]byte(body), now, maxTTL) + require.Error(t, err) + }) + } + + ttl, err := ParseGeminiCachedContentPatchRequest([]byte(`{"expire_time":"2026-10-09T09:00:00Z"}`), "", now, maxTTL) + require.NoError(t, err) + require.Equal(t, time.Hour, ttl) + _, err = ParseGeminiCachedContentPatchRequest([]byte(`{"display_name":"x"}`), "", now, maxTTL) + require.ErrorContains(t, err, "immutable") +} + +func TestGeminiCachedContentTTLRoundsUpToWholeSeconds(t *testing.T) { + now := time.Date(2026, 10, 9, 8, 0, 0, 0, time.UTC) + req, err := ParseGeminiCachedContentCreateRequest([]byte(`{"model":"gemini-3.8-flash","ttl":"1e-10s"}`), now, 24*time.Hour) + require.NoError(t, err) + require.Equal(t, time.Second, req.TTL, "极小的显式有效期按 1 秒处理,不回落到默认 1 小时") + require.True(t, req.ttlExplicit) + + ttl, err := ParseGeminiCachedContentPatchRequest([]byte(`{"ttl":"1e-10s"}`), "", now, 24*time.Hour) + require.NoError(t, err) + require.Equal(t, time.Second, ttl) + + req, err = ParseGeminiCachedContentCreateRequest([]byte(`{"model":"gemini-3.8-flash","expireTime":"2026-10-09T08:00:10.2Z"}`), now, 24*time.Hour) + require.NoError(t, err) + require.Equal(t, 11*time.Second, req.TTL, "expireTime 折算的有效期与发往上游的整秒 ttl 一致") +} + +func TestGeminiCachedContentCreateKeepsNumbersVerbatim(t *testing.T) { + svc, httpStub := newGeminiCachedContentServiceForTest(t, http.StatusOK, + `{"name":"cachedContents/up1","usageMetadata":{"totalTokenCount":2000}}`) + req, err := ParseGeminiCachedContentCreateRequest([]byte(`{"model":"models/gemini-3.8-flash","contents":[{"role":"user","parts":[ + {"functionResponse":{"name":"lookup","response":{"order_id":1790000000000000001,"ratio":0.10}}}]}]}`), time.Now(), 24*time.Hour) + require.NoError(t, err) + account := &Account{ID: 9, Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "secret"}} + _, err = svc.CreateUpstream(context.Background(), account, req, "gemini-3.8-flash", "pub1", time.Hour) + require.NoError(t, err) + raw, err := io.ReadAll(httpStub.lastReq.Body) + require.NoError(t, err) + require.Contains(t, string(raw), `"order_id":1790000000000000001`) + require.Contains(t, string(raw), `"ratio":0.10`) +} + +func TestGeminiCachedContentCreateUpstreamUnusableResponse(t *testing.T) { + account := &Account{ID: 9, Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "secret"}} + for name, tc := range map[string]struct { + body string + wantName string + }{ + "missing token count": {`{"name":"cachedContents/up9","expireTime":"2099-01-01T00:00:00Z"}`, "cachedContents/up9"}, + "zero token count": {`{"name":"cachedContents/up9","usageMetadata":{"totalTokenCount":0}}`, "cachedContents/up9"}, + "unexpected name": {`{"name":"cachedContents/a/../b","usageMetadata":{"totalTokenCount":5}}`, ""}, + "not json": {``, ""}, + } { + t.Run(name, func(t *testing.T) { + svc, _ := newGeminiCachedContentServiceForTest(t, http.StatusOK, tc.body) + req, err := ParseGeminiCachedContentCreateRequest([]byte(`{"model":"gemini-3.8-flash"}`), time.Now(), 24*time.Hour) + require.NoError(t, err) + _, err = svc.CreateUpstream(context.Background(), account, req, "gemini-3.8-flash", "pub1", time.Hour) + var malformed *GeminiCachedContentMalformedResponseError + require.ErrorAs(t, err, &malformed) + require.Equal(t, tc.wantName, malformed.Name) + }) + } +} + +type geminiCachedContentCtxProbeUpstream struct { + geminiCompatHTTPUpstreamStub + ctxErrAtSend error +} + +func (s *geminiCachedContentCtxProbeUpstream) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) { + s.ctxErrAtSend = req.Context().Err() + return s.geminiCompatHTTPUpstreamStub.Do(req, proxyURL, accountID, accountConcurrency) +} + +func TestGeminiCachedContentUpstreamCallIgnoresClientCancel(t *testing.T) { + up := &geminiCachedContentCtxProbeUpstream{geminiCompatHTTPUpstreamStub: geminiCompatHTTPUpstreamStub{response: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"name":"cachedContents/up1","usageMetadata":{"totalTokenCount":2000}}`)), + }}} + svc := NewGeminiCachedContentService(newGeminiCachedContentRepoStub(), + &GeminiMessagesCompatService{httpUpstream: up, cfg: &config.Config{}}, &config.Config{}) + req, err := ParseGeminiCachedContentCreateRequest([]byte(`{"model":"gemini-3.8-flash"}`), time.Now(), 24*time.Hour) + require.NoError(t, err) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + account := &Account{ID: 9, Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "secret"}} + _, err = svc.CreateUpstream(ctx, account, req, "gemini-3.8-flash", "pub1", time.Hour) + require.NoError(t, err) + require.NoError(t, up.ctxErrAtSend, "客户端断开不取消发往上游的创建") +} + +func TestClampGeminiCachedContentExpire(t *testing.T) { + limit := time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC) + require.Equal(t, limit, ClampGeminiCachedContentExpire(time.Time{}, limit)) + require.Equal(t, limit, ClampGeminiCachedContentExpire(limit.Add(100*time.Hour), limit), "上游回报的到期时间不超过请求的有效期") + earlier := limit.Add(-time.Second) + require.Equal(t, earlier, ClampGeminiCachedContentExpire(earlier, limit)) +} + +type geminiBoundCacheRateLimitRepo struct { + errorPolicyRepoStub + rateLimitedCalls int +} + +func (r *geminiBoundCacheRateLimitRepo) SetRateLimited(context.Context, int64, time.Time) error { + r.rateLimitedCalls++ + return nil +} + +func TestGeminiForwardNative_BoundCacheUpstreamGatewayErrorsSkipAccountPenalty(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, status := range []int{http.StatusTooManyRequests, http.StatusServiceUnavailable} { + t.Run(http.StatusText(status), func(t *testing.T) { + body := fmt.Sprintf(`{"error":{"code":%d,"message":"The account holding this cached content is temporarily unavailable","status":"UNAVAILABLE"}}`, status) + svc, httpStub, _ := newGeminiCachedContentTestService(status, body) + repo := &geminiBoundCacheRateLimitRepo{} + svc.accountRepo = repo + svc.rateLimitService = NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + account := &Account{ID: 711, Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "api_key": "k", "base_url": "https://gw.example.com", "explicit_cache_upstream": true, + }} + c, _ := newGeminiNativeTestContext(t) + + _, err := svc.ForwardNative(WithGeminiCachedContentBound(context.Background()), c, account, + "gemini-3.8-flash", "generateContent", false, []byte(geminiCachedContentRequestBody)) + + var failoverErr *UpstreamFailoverError + require.True(t, errors.As(err, &failoverErr), "交由绑定请求的耗尽处理透传") + require.Equal(t, status, failoverErr.StatusCode) + require.JSONEq(t, body, string(failoverErr.ResponseBody)) + require.True(t, failoverErr.BoundUpstreamPassthrough, "标记为上游网关账号的响应,handler 原样回写") + require.False(t, failoverErr.RetryableOnSameAccount) + require.Equal(t, 1, httpStub.calls, "不在同一账号上退避重试") + require.Zero(t, repo.rateLimitedCalls, "不限流整个网关账号") + require.Zero(t, repo.tempCalls) + require.Zero(t, repo.setErrCalls) + }) + } +} + +type fixedChargeCacheStub struct { + billingCacheWorkerStub + balance float64 + sub *SubscriptionCacheData + rate *APIKeyRateLimitCacheData + // reserved 按维度(user:{id} / scope 名)记录在途预留。 + reserved map[string]map[string]float64 +} + +func (s *fixedChargeCacheStub) GetUserBalance(context.Context, int64) (float64, error) { + return s.balance, nil +} + +func (s *fixedChargeCacheStub) GetSubscriptionCache(context.Context, int64, int64) (*SubscriptionCacheData, error) { + if s.sub == nil { + return nil, errors.New("cache miss") + } + return s.sub, nil +} + +func (s *fixedChargeCacheStub) GetAPIKeyRateLimit(context.Context, int64) (*APIKeyRateLimitCacheData, error) { + if s.rate == nil { + return nil, errors.New("cache miss") + } + return s.rate, nil +} + +func (s *fixedChargeCacheStub) reserve(key, requestID string, amount float64) float64 { + if s.reserved == nil { + s.reserved = map[string]map[string]float64{} + } + if s.reserved[key] == nil { + s.reserved[key] = map[string]float64{} + } + sum := 0.0 + for _, v := range s.reserved[key] { + sum += v + } + s.reserved[key][requestID] = amount + return sum +} + +func (s *fixedChargeCacheStub) ReserveInflightBalance(_ context.Context, userID int64, requestID string, amount, balance float64, _ time.Duration) (bool, float64, error) { + key := fmt.Sprintf("user:%d", userID) + others := s.reserve(key, requestID, amount) + if len(s.reserved[key]) > 1 && balance-others < amount { + delete(s.reserved[key], requestID) + return false, others, nil + } + return true, others, nil +} + +func (s *fixedChargeCacheStub) ReleaseInflightBalance(_ context.Context, userID int64, requestID string) error { + delete(s.reserved[fmt.Sprintf("user:%d", userID)], requestID) + return nil +} + +func (s *fixedChargeCacheStub) ReserveInflightScope(_ context.Context, scope, requestID string, amount float64, _ time.Duration) (float64, error) { + return s.reserve(scope, requestID, amount), nil +} + +func (s *fixedChargeCacheStub) ReleaseInflightScope(_ context.Context, scope, requestID string) error { + delete(s.reserved[scope], requestID) + return nil +} + +type fixedChargeAPIKeyRepoStub struct { + APIKeyRepository + data *APIKeyRateLimitData +} + +func (r *fixedChargeAPIKeyRepoStub) GetRateLimitData(context.Context, int64) (*APIKeyRateLimitData, error) { + return r.data, nil +} + +type fixedChargeSubscriptionRepoStub struct { + UserSubscriptionRepository + sub *UserSubscription +} + +func (r *fixedChargeSubscriptionRepoStub) GetActiveByUserIDAndGroupID(context.Context, int64, int64) (*UserSubscription, error) { + return r.sub, nil +} + +func TestReserveFixedCharge(t *testing.T) { + ctx := context.Background() + user := &User{ID: 1} + newSvc := func(cache *fixedChargeCacheStub) *BillingCacheService { + svc := NewBillingCacheService(cache, nil, nil, nil, nil, nil, &config.Config{}, nil) + t.Cleanup(svc.Stop) + return svc + } + reservingSvc := func(cache *fixedChargeCacheStub, subRepo UserSubscriptionRepository, apiKeyRepo APIKeyRepository) *BillingCacheService { + cfg := &config.Config{} + cfg.Billing.InflightReservation.Enabled = true + svc := NewBillingCacheService(cache, nil, subRepo, apiKeyRepo, nil, nil, cfg, nil) + t.Cleanup(svc.Stop) + return svc + } + reserve := func(svc *BillingCacheService, apiKey *APIKey, group *Group, sub *UserSubscription, amount float64) error { + res, err := svc.ReserveFixedCharge(ctx, user, apiKey, group, sub, amount) + res.Release() + return err + } + + t.Run("balance must cover the amount", func(t *testing.T) { + svc := newSvc(&fixedChargeCacheStub{balance: 10}) + require.NoError(t, reserve(svc, nil, nil, nil, 10)) + require.ErrorIs(t, reserve(svc, nil, nil, nil, 10.01), ErrInsufficientBalance) + require.NoError(t, reserve(svc, nil, nil, nil, 0)) + }) + + t.Run("subscription windows must cover the amount", func(t *testing.T) { + daily := 5.0 + group := &Group{ID: 2, SubscriptionType: SubscriptionTypeSubscription, DailyLimitUSD: &daily} + svc := newSvc(&fixedChargeCacheStub{sub: &SubscriptionCacheData{ + Status: SubscriptionStatusActive, ExpiresAt: time.Now().Add(time.Hour), DailyUsage: 3, + }}) + sub := &UserSubscription{ID: 1} + require.NoError(t, reserve(svc, nil, group, sub, 2)) + require.ErrorIs(t, reserve(svc, nil, group, sub, 2.5), ErrDailyLimitExceeded) + }) + + t.Run("api key quota and rate windows include the amount", func(t *testing.T) { + svc := newSvc(&fixedChargeCacheStub{balance: 100, rate: &APIKeyRateLimitCacheData{ + Usage1d: 8, Window1d: time.Now().Unix(), + }}) + quotaKey := &APIKey{ID: 5, Quota: 10, QuotaUsed: 9} + require.NoError(t, reserve(svc, quotaKey, nil, nil, 1)) + require.ErrorIs(t, reserve(svc, quotaKey, nil, nil, 1.5), ErrAPIKeyQuotaExhausted) + + windowKey := &APIKey{ID: 6, RateLimit1d: 10} + require.NoError(t, reserve(svc, windowKey, nil, nil, 2)) + require.ErrorIs(t, reserve(svc, windowKey, nil, nil, 2.5), ErrAPIKeyRateLimit1dExceeded) + }) + + t.Run("usage is read from the database instead of request snapshots", func(t *testing.T) { + now := time.Now() + apiKeyRepo := &fixedChargeAPIKeyRepoStub{data: &APIKeyRateLimitData{QuotaUsed: 9.5, Usage1d: 8, Window1dStart: &now}} + svc := reservingSvc(&fixedChargeCacheStub{balance: 100}, nil, apiKeyRepo) + require.ErrorIs(t, reserve(svc, &APIKey{ID: 5, Quota: 10}, nil, nil, 1), ErrAPIKeyQuotaExhausted, "请求上下文中的 QuotaUsed 可能滞后") + require.ErrorIs(t, reserve(svc, &APIKey{ID: 6, RateLimit1d: 10}, nil, nil, 2.5), ErrAPIKeyRateLimit1dExceeded) + + daily := 5.0 + group := &Group{ID: 2, SubscriptionType: SubscriptionTypeSubscription, DailyLimitUSD: &daily} + subRepo := &fixedChargeSubscriptionRepoStub{sub: &UserSubscription{Status: SubscriptionStatusActive, DailyUsageUSD: 4}} + svc = reservingSvc(&fixedChargeCacheStub{sub: &SubscriptionCacheData{Status: SubscriptionStatusActive}}, subRepo, nil) + require.ErrorIs(t, reserve(svc, nil, group, &UserSubscription{ID: 1}, 2), ErrDailyLimitExceeded, "订阅缓存用量可能滞后") + }) + + t.Run("unsettled charges hold subscription and api key headroom", func(t *testing.T) { + daily := 110.0 + group := &Group{ID: 2, SubscriptionType: SubscriptionTypeSubscription, DailyLimitUSD: &daily} + sub := &UserSubscription{ID: 1} + cache := &fixedChargeCacheStub{balance: 1000, sub: &SubscriptionCacheData{Status: SubscriptionStatusActive}} + svc := reservingSvc(cache, nil, nil) + first, err := svc.ReserveFixedCharge(ctx, user, nil, group, sub, 103.5) + require.NoError(t, err) + _, err = svc.ReserveFixedCharge(ctx, user, nil, group, sub, 103.5) + require.ErrorIs(t, err, ErrDailyLimitExceeded) + first.Release() + second, err := svc.ReserveFixedCharge(ctx, user, nil, group, sub, 103.5) + require.NoError(t, err) + second.Release() + + apiKey := &APIKey{ID: 5, Quota: 110} + first, err = svc.ReserveFixedCharge(ctx, user, apiKey, nil, nil, 103.5) + require.NoError(t, err) + _, err = svc.ReserveFixedCharge(ctx, user, apiKey, nil, nil, 103.5) + require.ErrorIs(t, err, ErrAPIKeyQuotaExhausted) + first.Release() + for key, reservations := range cache.reserved { + require.Empty(t, reservations, "拒绝与释放都不留下预留:%s", key) + } + }) + + t.Run("simple mode skips the check", func(t *testing.T) { + cfg := &config.Config{RunMode: config.RunModeSimple} + svc := NewBillingCacheService(&fixedChargeCacheStub{}, nil, nil, nil, nil, nil, cfg, nil) + t.Cleanup(svc.Stop) + require.NoError(t, reserve(svc, nil, nil, nil, 1000)) + }) +} diff --git a/backend/internal/service/gemini_cached_content_not_found_test.go b/backend/internal/service/gemini_cached_content_not_found_test.go new file mode 100644 index 000000000000..fc4daa1181d1 --- /dev/null +++ b/backend/internal/service/gemini_cached_content_not_found_test.go @@ -0,0 +1,186 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// 上游对不存在、已过期、已删除或不属于当前项目的显式缓存一律返回这个 403。 +const geminiCachedContentNotFoundBody = `{"error":{"code":403,"message":"CachedContent not found (or permission denied)","status":"PERMISSION_DENIED"}}` + +func newGeminiCachedContentTestService(status int, body string) (*GeminiMessagesCompatService, *geminiCompatHTTPUpstreamStub, *errorPolicyRepoStub) { + repo := &errorPolicyRepoStub{} + httpStub := &geminiCompatHTTPUpstreamStub{ + response: &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + }, + } + svc := &GeminiMessagesCompatService{ + httpUpstream: httpStub, + cfg: &config.Config{}, + rateLimitService: NewRateLimitService(repo, nil, &config.Config{}, nil, nil), + } + return svc, httpStub, repo +} + +func geminiDefaultPolicyAPIKeyAccount() *Account { + return &Account{ + ID: 710, + Platform: PlatformGemini, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "test-key", + }, + } +} + +const geminiCachedContentRequestBody = `{"cachedContent":"cachedContents/doesnotexist","contents":[{"role":"user","parts":[{"text":"hi"}]}]}` + +func TestGeminiForwardNative_CachedContentNotFoundPassthroughWithoutAccountPenalty(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tc := range []struct { + name string + action string + stream bool + }{ + {name: "generateContent", action: "generateContent"}, + {name: "streamGenerateContent", action: "streamGenerateContent", stream: true}, + {name: "countTokens", action: "countTokens"}, + } { + t.Run(tc.name, func(t *testing.T) { + svc, httpStub, repo := newGeminiCachedContentTestService(http.StatusForbidden, geminiCachedContentNotFoundBody) + c, rec := newGeminiNativeTestContext(t) + + result, err := svc.ForwardNative(context.Background(), c, geminiDefaultPolicyAPIKeyAccount(), + "gemini-3.8-flash", tc.action, tc.stream, []byte(geminiCachedContentRequestBody)) + + require.Nil(t, result) + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.False(t, errors.As(err, &failoverErr), "缓存不存在是请求问题,不应换号") + require.Equal(t, 0, repo.setErrCalls, "不应把账号标记为错误") + require.Equal(t, 0, repo.tempCalls, "不应临时停调度") + require.Equal(t, 1, httpStub.calls, "不应重试") + require.Equal(t, http.StatusForbidden, rec.Code) + require.Equal(t, GeminiCachedContentNotFoundResponse, rec.Body.String(), "应返回标准缓存不存在响应") + require.True(t, IsOpsRequestScopedError(c), "缓存不存在按客户端请求错误统计") + }) + } +} + +func TestGeminiForwardNative_CachedContentNotFoundBypassesTempUnschedulableRules(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, _, repo := newGeminiCachedContentTestService(http.StatusForbidden, geminiCachedContentNotFoundBody) + c, rec := newGeminiNativeTestContext(t) + account := geminiDefaultPolicyAPIKeyAccount() + account.Credentials["temp_unschedulable_enabled"] = true + account.Credentials["temp_unschedulable_rules"] = []any{ + map[string]any{ + "error_code": float64(403), + "keywords": []any{"permission denied"}, + "duration_minutes": float64(10), + "description": "403 permission denied", + }, + } + + _, err := svc.ForwardNative(context.Background(), c, account, + "gemini-3.8-flash", "generateContent", false, []byte(geminiCachedContentRequestBody)) + + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.False(t, errors.As(err, &failoverErr)) + require.Equal(t, 0, repo.tempCalls, "临时不可调度规则不应命中") + require.Equal(t, 0, repo.setErrCalls) + require.Equal(t, http.StatusForbidden, rec.Code) +} + +func TestGeminiForwardNative_OtherForbiddenStillPenalizesAccount(t *testing.T) { + gin.SetMode(gin.TestMode) + body := `{"error":{"code":403,"message":"Your project has been denied access. Please contact support.","status":"PERMISSION_DENIED"}}` + svc, _, repo := newGeminiCachedContentTestService(http.StatusForbidden, body) + c, _ := newGeminiNativeTestContext(t) + + _, err := svc.ForwardNative(context.Background(), c, geminiDefaultPolicyAPIKeyAccount(), + "gemini-3.8-flash", "generateContent", false, []byte(geminiCachedContentRequestBody)) + + var failoverErr *UpstreamFailoverError + require.True(t, errors.As(err, &failoverErr), "其他 403 仍按账号问题换号") + require.Equal(t, 1, repo.setErrCalls, "其他 403 仍标记账号错误") +} + +// Vertex 对已删除 / 已过期缓存的响应(2026-10 实测):生成请求 400,缓存资源接口 404,文案含上游缓存数字 ID。 +const ( + geminiVertexCachedContentInvalidStateBody = `{"error":{"code":400,"message":"Invalid resource state for cache content 8079767076522164224.","status":"FAILED_PRECONDITION"}}` + geminiVertexCachedContentNotFoundBody = `{"error":{"code":404,"message":"Cached content 8079767076522164224 is not found.","status":"NOT_FOUND"}}` +) + +func TestIsGeminiCachedContentNotFound(t *testing.T) { + require.True(t, isGeminiCachedContentNotFound(http.StatusForbidden, []byte(geminiCachedContentNotFoundBody))) + require.True(t, isGeminiCachedContentNotFound(http.StatusForbidden, + []byte(`[{"error":{"code":403,"message":"CachedContent not found (or permission denied)","status":"PERMISSION_DENIED"}}]`))) + require.True(t, isGeminiCachedContentNotFound(http.StatusBadRequest, []byte(geminiVertexCachedContentInvalidStateBody))) + require.True(t, isGeminiCachedContentNotFound(http.StatusNotFound, []byte(geminiVertexCachedContentNotFoundBody))) + + require.False(t, isGeminiCachedContentNotFound(http.StatusBadRequest, []byte(geminiCachedContentNotFoundBody))) + require.False(t, isGeminiCachedContentNotFound(http.StatusForbidden, + []byte(`{"error":{"code":403,"message":"Permission denied: Consumer has been suspended.","status":"PERMISSION_DENIED"}}`))) + require.False(t, isGeminiCachedContentNotFound(http.StatusNotFound, + []byte(`{"error":{"code":404,"message":"Publisher Model `+"`projects/p/locations/global/publishers/google/models/gemini-9`"+` was not found.","status":"NOT_FOUND"}}`))) + require.False(t, isGeminiCachedContentNotFound(http.StatusBadRequest, + []byte(`{"error":{"code":400,"message":"Tool config, tools and system instruction should not be set in the request when using cached content.","status":"INVALID_ARGUMENT"}}`))) + require.False(t, isGeminiCachedContentNotFound(http.StatusInternalServerError, []byte(geminiVertexCachedContentNotFoundBody))) +} + +func TestGeminiForwardNative_VertexCachedContentNotFoundReturnsStandardForbidden(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tc := range []struct { + name string + status int + body string + action string + stream bool + }{ + {name: "generate invalid resource state", status: http.StatusBadRequest, body: geminiVertexCachedContentInvalidStateBody, action: "generateContent"}, + {name: "stream invalid resource state", status: http.StatusBadRequest, body: geminiVertexCachedContentInvalidStateBody, action: "streamGenerateContent", stream: true}, + {name: "generate not found", status: http.StatusNotFound, body: geminiVertexCachedContentNotFoundBody, action: "generateContent"}, + } { + t.Run(tc.name, func(t *testing.T) { + svc, httpStub, repo := newGeminiCachedContentTestService(tc.status, tc.body) + svc.tokenProvider = NewGeminiTokenProvider(nil, &fakeGeminiTokenCache{token: "ya29.test-token"}, nil) + c, rec := newGeminiNativeTestContext(t) + account := vertexServiceAccount() + account.ID = 811 + + result, err := svc.ForwardNative(context.Background(), c, account, + "gemini-3.8-flash", tc.action, tc.stream, + []byte(`{"cachedContent":"projects/proj-1/locations/global/cachedContents/8079767076522164224","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)) + + require.Nil(t, result) + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.False(t, errors.As(err, &failoverErr), "缓存不存在是请求问题,不应换号") + require.Equal(t, 0, repo.setErrCalls) + require.Equal(t, 0, repo.tempCalls) + require.Empty(t, repo.modelRateLimitCalls, "404 不应按模型不存在记模型限流") + require.Equal(t, 1, httpStub.calls, "不应重试") + require.Equal(t, http.StatusForbidden, rec.Code) + require.Equal(t, GeminiCachedContentNotFoundResponse, rec.Body.String()) + require.NotContains(t, rec.Body.String(), "8079767076522164224", "不应暴露上游缓存 ID") + require.True(t, IsOpsRequestScopedError(c), "缓存不存在按客户端请求错误统计") + errType, _ := OpsLocalErrorType(c) + require.Equal(t, "permission_error", errType) + }) + } +} diff --git a/backend/internal/service/gemini_cached_content_test.go b/backend/internal/service/gemini_cached_content_test.go new file mode 100644 index 000000000000..e3afbdd5e641 --- /dev/null +++ b/backend/internal/service/gemini_cached_content_test.go @@ -0,0 +1,522 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +type geminiCachedContentRepoStub struct { + records map[string]*GeminiCachedContent + nextID int64 + deleted []int64 +} + +func newGeminiCachedContentRepoStub() *geminiCachedContentRepoStub { + return &geminiCachedContentRepoStub{records: map[string]*GeminiCachedContent{}} +} + +func (r *geminiCachedContentRepoStub) Create(_ context.Context, record *GeminiCachedContent) error { + r.nextID++ + record.ID = r.nextID + r.records[record.PublicID] = record + return nil +} + +func (r *geminiCachedContentRepoStub) GetForOwner(_ context.Context, apiKeyID int64, publicID string) (*GeminiCachedContent, error) { + record, ok := r.records[publicID] + if !ok || record.APIKeyID != apiKeyID { + return nil, ErrGeminiCachedContentNotFound + } + return record, nil +} + +func (r *geminiCachedContentRepoStub) ListForOwner(_ context.Context, apiKeyID, groupID int64, activeAt time.Time, beforeID int64, limit int) ([]*GeminiCachedContent, error) { + var out []*GeminiCachedContent + for _, record := range r.records { + if record.APIKeyID == apiKeyID && record.GroupID == groupID && record.ExpireTime.After(activeAt) && (beforeID <= 0 || record.ID < beforeID) { + out = append(out, record) + } + } + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func (r *geminiCachedContentRepoStub) BeginMutation(context.Context, int64, time.Time, string, time.Duration) (bool, error) { + return true, nil +} + +func (r *geminiCachedContentRepoStub) TakeOverStaleMutation(context.Context, int64, string, time.Duration) (*GeminiCachedContent, error) { + return nil, nil +} + +func (r *geminiCachedContentRepoStub) CommitMutation(_ context.Context, id int64, _ string, expireTime time.Time) error { + for _, record := range r.records { + if record.ID == id { + record.ExpireTime = expireTime + return nil + } + } + return ErrGeminiCachedContentMutationConflict +} + +func (r *geminiCachedContentRepoStub) ReleaseMutation(context.Context, int64, string) error { + return nil +} + +func (r *geminiCachedContentRepoStub) ListStaleMutations(context.Context, int) ([]int64, error) { + return nil, nil +} + +func (r *geminiCachedContentRepoStub) SoftDelete(_ context.Context, id int64) error { + r.deleted = append(r.deleted, id) + for key, record := range r.records { + if record.ID == id { + delete(r.records, key) + } + } + return nil +} + +func TestParseGeminiCachedContentCreateRequest(t *testing.T) { + now := time.Date(2026, 10, 9, 8, 0, 0, 0, time.UTC) + maxTTL := 24 * time.Hour + + t.Run("defaults to one hour and strips output-only fields", func(t *testing.T) { + req, err := ParseGeminiCachedContentCreateRequest([]byte(`{"model":"models/gemini-3.8-flash","displayName":"docs", + "contents":[{"role":"user","parts":[{"text":"x"}]}],"name":"cachedContents/forged","usageMetadata":{"totalTokenCount":1}}`), now, maxTTL) + require.NoError(t, err) + require.Equal(t, "gemini-3.8-flash", req.Model) + require.Equal(t, time.Hour, req.TTL) + require.Equal(t, int64(3600), req.TTLSeconds()) + require.Equal(t, "docs", req.DisplayName) + require.NotContains(t, req.payload, "name") + require.NotContains(t, req.payload, "usageMetadata") + require.Contains(t, req.payload, "contents") + }) + + t.Run("default is capped by a shorter max ttl", func(t *testing.T) { + req, err := ParseGeminiCachedContentCreateRequest([]byte(`{"model":"gemini-3.8-flash"}`), now, 30*time.Minute) + require.NoError(t, err) + require.Equal(t, 30*time.Minute, req.TTL) + }) + + t.Run("fractional ttl rounds up to whole seconds", func(t *testing.T) { + req, err := ParseGeminiCachedContentCreateRequest([]byte(`{"model":"gemini-3.8-flash","ttl":"3.5s"}`), now, maxTTL) + require.NoError(t, err) + require.Equal(t, int64(4), req.TTLSeconds()) + }) + + t.Run("expireTime converts to ttl", func(t *testing.T) { + req, err := ParseGeminiCachedContentCreateRequest([]byte(`{"model":"gemini-3.8-flash","expireTime":"2026-10-09T10:00:00Z"}`), now, maxTTL) + require.NoError(t, err) + require.Equal(t, 2*time.Hour, req.TTL) + }) + + for name, body := range map[string]string{ + "missing model": `{"ttl":"60s"}`, + "vertex style model": `{"model":"projects/p/locations/l/publishers/google/models/x"}`, + "ttl and expireTime": `{"model":"m","ttl":"60s","expireTime":"2026-10-09T10:00:00Z"}`, + "ttl over max": `{"model":"m","ttl":"86401s"}`, + "non positive ttl": `{"model":"m","ttl":"0s"}`, + "ttl without unit": `{"model":"m","ttl":"60"}`, + "expireTime in past": `{"model":"m","expireTime":"2026-10-09T07:00:00Z"}`, + "displayName too long": `{"model":"m","displayName":"` + strings.Repeat("字", 129) + `"}`, + "not an object": `[]`, + } { + t.Run("rejects "+name, func(t *testing.T) { + _, err := ParseGeminiCachedContentCreateRequest([]byte(body), now, maxTTL) + require.Error(t, err) + }) + } +} + +func TestParseGeminiCachedContentPatchRequest(t *testing.T) { + now := time.Date(2026, 10, 9, 8, 0, 0, 0, time.UTC) + ttl, err := ParseGeminiCachedContentPatchRequest([]byte(`{"ttl":"7200s"}`), "ttl", now, 24*time.Hour) + require.NoError(t, err) + require.Equal(t, 2*time.Hour, ttl) + + ttl, err = ParseGeminiCachedContentPatchRequest([]byte(`{"expireTime":"2026-10-09T09:00:00Z"}`), "", now, 24*time.Hour) + require.NoError(t, err) + require.Equal(t, time.Hour, ttl) + + _, err = ParseGeminiCachedContentPatchRequest([]byte(`{"displayName":"x"}`), "displayName", now, 24*time.Hour) + require.ErrorContains(t, err, "immutable") + _, err = ParseGeminiCachedContentPatchRequest([]byte(`{"ttl":"60s"}`), "ttl,displayName", now, 24*time.Hour) + require.ErrorContains(t, err, "immutable") + _, err = ParseGeminiCachedContentPatchRequest([]byte(`{}`), "", now, 24*time.Hour) + require.Error(t, err) + _, err = ParseGeminiCachedContentPatchRequest([]byte(`{"ttl":"90000s"}`), "", now, 24*time.Hour) + require.ErrorContains(t, err, "maximum") +} + +func TestGeminiCachedContentReferenceRewrite(t *testing.T) { + ref, err := FindGeminiCachedContentReference([]byte(`{"contents":[]}`)) + require.NoError(t, err) + require.Nil(t, ref) + + body := []byte(`{"cachedContent":"cachedContents/abc","contents":[]}`) + ref, err = FindGeminiCachedContentReference(body) + require.NoError(t, err) + require.Equal(t, "cachedContents/abc", ref.Name) + out, err := RewriteGeminiCachedContentReference(body, ref, "cachedContents/upstream1") + require.NoError(t, err) + require.JSONEq(t, `{"cachedContent":"cachedContents/upstream1","contents":[]}`, string(out)) + + countBody := []byte(`{"generateContentRequest":{"model":"models/m","cachedContent":"cachedContents/abc","contents":[]}}`) + ref, err = FindGeminiCachedContentReference(countBody) + require.NoError(t, err) + out, err = RewriteGeminiCachedContentReference(countBody, ref, "projects/1/locations/us-central1/cachedContents/9") + require.NoError(t, err) + require.JSONEq(t, `{"generateContentRequest":{"model":"models/m","cachedContent":"projects/1/locations/us-central1/cachedContents/9","contents":[]}}`, string(out)) + + _, err = FindGeminiCachedContentReference([]byte(`{"cachedContent":"cachedContents/a","generateContentRequest":{"cachedContent":"cachedContents/b"}}`)) + require.Error(t, err) + _, err = FindGeminiCachedContentReference([]byte(`{"cachedContent":1}`)) + require.Error(t, err) +} + +func TestIsGeminiExplicitCacheAccount(t *testing.T) { + cases := []struct { + name string + account *Account + want bool + }{ + {"official api key", &Account{Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "k"}}, true}, + {"explicit official base url", &Account{Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "k", "base_url": "https://generativelanguage.googleapis.com/"}}, true}, + {"reseller base url", &Account{Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "k", "base_url": "https://relay.example.com"}}, false}, + {"upstream gateway with explicit cache switch", &Account{Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "k", "base_url": "https://relay.example.com", "explicit_cache_upstream": true}}, true}, + {"official host with path prefix", &Account{Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "k", "base_url": "https://generativelanguage.googleapis.com/antigravity"}}, false}, + {"missing api key", &Account{Platform: PlatformGemini, Type: AccountTypeAPIKey}, false}, + {"vertex service account", &Account{Platform: PlatformGemini, Type: AccountTypeServiceAccount}, true}, + {"vertex api key", &Account{Platform: PlatformGemini, Type: AccountTypeServiceAccount, Credentials: map[string]any{"auth_mode": "apikey", "api_key": "k", "project_id": "my-project"}}, false}, + {"oauth", &Account{Platform: PlatformGemini, Type: AccountTypeOAuth}, false}, + {"antigravity api key", &Account{Platform: PlatformAntigravity, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "k"}}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, IsGeminiExplicitCacheAccount(tc.account)) + }) + } +} + +func TestGeminiCachedContentVertexURLs(t *testing.T) { + account := &Account{Platform: PlatformGemini, Type: AccountTypeServiceAccount, Credentials: map[string]any{ + "project_id": "proj-1", + "location": "us-central1", + "vertex_model_locations": map[string]any{"gemini-3.8-flash": "global"}, + }} + model, err := GeminiCachedContentUpstreamModelResource(account, "gemini-3.8-flash") + require.NoError(t, err) + require.Equal(t, "projects/proj-1/locations/global/publishers/google/models/gemini-3.8-flash", model) + + collection, err := geminiCachedContentCollectionURL(account, "", "gemini-3.8-flash") + require.NoError(t, err) + require.Equal(t, "https://aiplatform.googleapis.com/v1/projects/proj-1/locations/global/cachedContents", collection) + + collection, err = geminiCachedContentCollectionURL(account, "", "gemini-3.1-pro-preview") + require.NoError(t, err) + require.Equal(t, "https://us-central1-aiplatform.googleapis.com/v1/projects/proj-1/locations/us-central1/cachedContents", collection) + + resource, err := geminiCachedContentResourceURL(account, "", "projects/123/locations/europe-west4/cachedContents/456") + require.NoError(t, err) + require.Equal(t, "https://europe-west4-aiplatform.googleapis.com/v1/projects/123/locations/europe-west4/cachedContents/456", resource) + + _, err = geminiCachedContentResourceURL(account, "", "projects/123/locations/europe-west4/cachedContents/../x") + require.Error(t, err) + _, err = geminiCachedContentResourceURL(&Account{Type: AccountTypeAPIKey}, "https://generativelanguage.googleapis.com", "cachedContents/a/../b") + require.Error(t, err) +} + +func newGeminiCachedContentServiceForTest(t *testing.T, status int, respBody string) (*GeminiCachedContentService, *geminiCompatHTTPUpstreamStub) { + t.Helper() + httpStub := &geminiCompatHTTPUpstreamStub{response: &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(respBody)), + }} + compat := &GeminiMessagesCompatService{httpUpstream: httpStub, cfg: &config.Config{}} + return NewGeminiCachedContentService(newGeminiCachedContentRepoStub(), compat, &config.Config{}), httpStub +} + +func TestGeminiCachedContentCreateUpstreamAIStudio(t *testing.T) { + svc, httpStub := newGeminiCachedContentServiceForTest(t, http.StatusOK, + `{"name":"cachedContents/up123","model":"models/gemini-3.8-flash","usageMetadata":{"totalTokenCount":6273},"expireTime":"2026-10-09T09:50:26.591575799Z"}`) + account := &Account{ID: 9, Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "secret"}} + req, err := ParseGeminiCachedContentCreateRequest([]byte(`{"model":"models/gemini-3.8-flash","displayName":"user label", + "systemInstruction":{"parts":[{"text":"sys"}]},"contents":[{"role":"user","parts":[{"text":"doc"}]}]}`), time.Now(), 24*time.Hour) + require.NoError(t, err) + + upstream, err := svc.CreateUpstream(context.Background(), account, req, "gemini-3.8-flash", "pub1", req.TTL) + require.NoError(t, err) + require.Equal(t, "cachedContents/up123", upstream.Name) + require.Equal(t, int64(6273), upstream.TotalTokenCount) + require.Equal(t, 2026, upstream.ExpireTime.Year()) + + sent := httpStub.lastReq + require.Equal(t, http.MethodPost, sent.Method) + require.Equal(t, "https://generativelanguage.googleapis.com/v1beta/cachedContents", sent.URL.String()) + require.Equal(t, "secret", sent.Header.Get("x-goog-api-key")) + raw, err := io.ReadAll(sent.Body) + require.NoError(t, err) + var payload map[string]any + require.NoError(t, json.Unmarshal(raw, &payload)) + require.Equal(t, "models/gemini-3.8-flash", payload["model"]) + require.Equal(t, "3600s", payload["ttl"]) + require.Equal(t, "s2a-pub1", payload["displayName"], "上游 displayName 写网关标记,用户标签只存本地") + require.Contains(t, payload, "systemInstruction") + require.Contains(t, payload, "contents") +} + +func TestGeminiCachedContentUpstreamErrorClassification(t *testing.T) { + notFoundBody := `{"error":{"code":403,"message":"CachedContent not found (or permission denied)","status":"PERMISSION_DENIED"}}` + svc, _ := newGeminiCachedContentServiceForTest(t, http.StatusForbidden, notFoundBody) + account := &Account{ID: 9, Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "secret"}} + record := &GeminiCachedContent{UpstreamName: "cachedContents/up123"} + + err := svc.DeleteUpstream(context.Background(), account, record) + var upErr *GeminiCachedContentUpstreamError + require.ErrorAs(t, err, &upErr) + require.True(t, upErr.NotFound()) + require.True(t, upErr.RetryableOnOtherAccount()) + + quota := &GeminiCachedContentUpstreamError{StatusCode: http.StatusTooManyRequests} + require.True(t, quota.RetryableOnOtherAccount()) + badRequest := &GeminiCachedContentUpstreamError{StatusCode: http.StatusBadRequest} + require.False(t, badRequest.RetryableOnOtherAccount()) + require.False(t, badRequest.NotFound()) +} + +func TestGeminiCachedContentResolve(t *testing.T) { + repo := newGeminiCachedContentRepoStub() + now := time.Date(2026, 10, 9, 8, 0, 0, 0, time.UTC) + svc := NewGeminiCachedContentService(repo, nil, &config.Config{}) + svc.now = func() time.Time { return now } + groupID := int64(5) + require.NoError(t, repo.Create(context.Background(), &GeminiCachedContent{ + PublicID: "abc123", APIKeyID: 1, GroupID: groupID, ExpireTime: now.Add(time.Hour), + })) + require.NoError(t, repo.Create(context.Background(), &GeminiCachedContent{ + PublicID: "expired1", APIKeyID: 1, GroupID: groupID, ExpireTime: now.Add(-time.Second), + })) + + record, err := svc.Resolve(context.Background(), 1, &groupID, "cachedContents/abc123") + require.NoError(t, err) + require.Equal(t, "abc123", record.PublicID) + + otherGroup := int64(6) + for name, call := range map[string]func() error{ + "other api key": func() error { + _, err := svc.Resolve(context.Background(), 2, &groupID, "cachedContents/abc123") + return err + }, + "other group": func() error { + _, err := svc.Resolve(context.Background(), 1, &otherGroup, "cachedContents/abc123") + return err + }, + "expired": func() error { + _, err := svc.Resolve(context.Background(), 1, &groupID, "cachedContents/expired1") + return err + }, + "malformed": func() error { + _, err := svc.Resolve(context.Background(), 1, &groupID, "cachedContents/../x") + return err + }, + } { + t.Run(name, func(t *testing.T) { + require.ErrorIs(t, call(), ErrGeminiCachedContentNotFound) + }) + } +} + +func TestGeminiCachedContentPublicID(t *testing.T) { + id, err := NewGeminiCachedContentPublicID() + require.NoError(t, err) + require.Len(t, id, 40) + parsed, ok := ParseGeminiCachedContentPublicID(GeminiCachedContentNamePrefix + id) + require.True(t, ok) + require.Equal(t, id, parsed) +} + +func TestGeminiCachedContentStoragePricingAndSurcharge(t *testing.T) { + pricing := &PricingService{} + data, err := pricing.parsePricingData([]byte(`{"gemini-3.8-flash":{"litellm_provider":"gemini","input_cost_per_token":0.00000075, + "output_cost_per_token":0.00000375,"cache_read_input_token_cost":0.000000075,"cache_storage_cost_per_token_per_hour":0.0000005}}`)) + require.NoError(t, err) + pricing.pricingData = data + billing := NewBillingService(&config.Config{}, pricing) + + price := billing.CacheStoragePricePerTokenHour("models/gemini-3.8-flash") + require.InDelta(t, 0.0000005, price, 1e-15) + require.Zero(t, billing.CacheStoragePricePerTokenHour("gemini-unknown-model")) + + tokenHours := GeminiCachedContentStorageTokenHours(10000, 2*time.Hour) + require.InDelta(t, 20000, tokenHours, 1e-9) + storage := tokenHours * price + require.InDelta(t, 0.01, storage, 1e-12) + + gateway := &GatewayService{billingService: billing} + groupID := int64(3) + apiKey := &APIKey{GroupID: &groupID, Group: &Group{ID: groupID, Hydrated: true}} + cost := gateway.calculateRecordUsageCost(context.Background(), &ForwardResult{ + Model: "gemini-3.8-flash", + Usage: ClaudeUsage{InputTokens: 10000}, + CacheStorageTokenHours: tokenHours, + }, apiKey, "gemini-3.8-flash", 2, 2, time.Now()) + inputCost := 10000 * 0.00000075 + require.InDelta(t, inputCost+storage, cost.TotalCost, 1e-12) + require.InDelta(t, (inputCost+storage)*2, cost.ActualCost, 1e-12) + + storageOnly := gateway.calculateRecordUsageCost(context.Background(), &ForwardResult{ + Model: "gemini-3.8-flash", + CacheStorageTokenHours: tokenHours, + }, apiKey, "gemini-3.8-flash", 1.5, 1.5, time.Now()) + require.InDelta(t, storage, storageOnly.TotalCost, 1e-12) + require.InDelta(t, storage*1.5, storageOnly.ActualCost, 1e-12) +} + +func TestGeminiCachedContentStorageCostInAccountStats(t *testing.T) { + const ( + model = "gemini-3.8-flash" + groupID = int64(31) + inputTokens = 10000 + storage = 0.01 + inputPrice = 0.00000075 + rulePrice = 0.0000002 + ) + newPricing := func() *PricingService { + pricing := &PricingService{} + data, err := pricing.parsePricingData([]byte(`{"gemini-3.8-flash":{"litellm_provider":"gemini","input_cost_per_token":0.00000075, + "output_cost_per_token":0.00000375,"cache_storage_cost_per_token_per_hour":0.0000005}}`)) + require.NoError(t, err) + pricing.pricingData = data + return pricing + } + officialInput := inputTokens * inputPrice + + for _, tc := range []struct { + name string + channel *Channel + tokens int + wantStats float64 + }{ + {name: "no_channel", tokens: inputTokens, wantStats: officialInput + storage}, + {name: "model_file_pricing", channel: &Channel{ID: 1, Status: StatusActive}, tokens: inputTokens, wantStats: officialInput + storage}, + {name: "apply_pricing_to_account_stats", channel: &Channel{ID: 1, Status: StatusActive, ApplyPricingToAccountStats: true}, + tokens: inputTokens, wantStats: officialInput + storage}, + {name: "custom_rule", channel: &Channel{ID: 1, Status: StatusActive, AccountStatsPricingRules: []AccountStatsPricingRule{{ + GroupIDs: []int64{groupID}, + Pricing: []ChannelModelPricing{{Models: []string{model}, InputPrice: testPtrFloat64(rulePrice)}}, + }}}, tokens: inputTokens, wantStats: inputTokens*rulePrice + storage}, + {name: "storage_only_with_custom_rule", channel: &Channel{ID: 1, Status: StatusActive, AccountStatsPricingRules: []AccountStatsPricingRule{{ + GroupIDs: []int64{groupID}, + Pricing: []ChannelModelPricing{{Models: []string{model}, InputPrice: testPtrFloat64(rulePrice)}}, + }}}, wantStats: storage}, + } { + t.Run(tc.name, func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newGatewayRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}) + svc.billingService = NewBillingService(svc.cfg, newPricing()) + if tc.channel != nil { + svc.channelService = newTestChannelServiceForStats(t, tc.channel, groupID, PlatformGemini) + } + groupRate, accountRate := 1.5, 0.6 + gid := groupID + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: GeminiCachedContentCreateUsageRequestID(tc.name), + Model: model, + Usage: ClaudeUsage{InputTokens: tc.tokens}, + CacheStorageTokenHours: storage / 0.0000005, + }, + APIKey: &APIKey{ID: 811, GroupID: &gid, Group: &Group{ID: groupID, Platform: PlatformGemini, RateMultiplier: groupRate, Hydrated: true}}, + User: &User{ID: 611}, + Account: &Account{ID: 711, Platform: PlatformGemini, RateMultiplier: &accountRate}, + }) + require.NoError(t, err) + log := usageRepo.lastLog + require.NotNil(t, log) + + wantTotal := float64(tc.tokens)*inputPrice + storage + require.InDelta(t, wantTotal, log.TotalCost, 1e-12) + require.InDelta(t, wantTotal*groupRate, log.ActualCost, 1e-12, "售价 = 官方价 × 分组倍率") + + statsCost := log.TotalCost + if log.AccountStatsCost != nil { + statsCost = *log.AccountStatsCost + } + require.InDelta(t, tc.wantStats, statsCost, 1e-12, "账号统计成本须含存储费且只计一次") + require.NotNil(t, log.AccountRateMultiplier) + require.InDelta(t, tc.wantStats*accountRate, statsCost**log.AccountRateMultiplier, 1e-12, "成本 = 统计成本 × 账号倍率") + }) + } +} + +func TestGeminiExplicitCacheUpstreamTTL(t *testing.T) { + now := time.Date(2026, 10, 9, 8, 0, 0, 0, time.UTC) + upstream := &Account{Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "api_key": "k", "base_url": "https://relay.example.com", "explicit_cache_upstream": true, + "explicit_cache_upstream_max_ttl_seconds": float64(1800), + }} + official := &Account{Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "api_key": "k", "explicit_cache_upstream_max_ttl_seconds": float64(1800), + }} + require.Equal(t, 30*time.Minute, upstream.GeminiExplicitCacheUpstreamMaxTTL()) + require.Zero(t, official.GeminiExplicitCacheUpstreamMaxTTL(), "未开启开关时上限字段不生效") + + svc := NewGeminiCachedContentService(newGeminiCachedContentRepoStub(), nil, &config.Config{}) + require.Equal(t, 30*time.Minute, svc.MaxTTLForAccount(upstream)) + require.Equal(t, 24*time.Hour, svc.MaxTTLForAccount(official)) + + implicit, err := ParseGeminiCachedContentCreateRequest([]byte(`{"model":"gemini-3.8-flash"}`), now, 24*time.Hour) + require.NoError(t, err) + ttl, ok := implicit.TTLForAccount(upstream) + require.True(t, ok) + require.Equal(t, 30*time.Minute, ttl, "未显式指定有效期时按上游上限创建") + ttl, ok = implicit.TTLForAccount(official) + require.True(t, ok) + require.Equal(t, time.Hour, ttl) + + explicit, err := ParseGeminiCachedContentCreateRequest([]byte(`{"model":"gemini-3.8-flash","ttl":"3600s"}`), now, 24*time.Hour) + require.NoError(t, err) + _, ok = explicit.TTLForAccount(upstream) + require.False(t, ok, "显式有效期超过上游上限时该账号不可用") + ttl, ok = explicit.TTLForAccount(official) + require.True(t, ok) + require.Equal(t, time.Hour, ttl) +} + +func TestGeminiCachedContentCreateUpstreamGateway(t *testing.T) { + svc, httpStub := newGeminiCachedContentServiceForTest(t, http.StatusOK, + `{"name":"cachedContents/gatewayid0001","model":"models/gemini-3.8-flash","usageMetadata":{"totalTokenCount":2730},"expireTime":"2026-10-09T09:00:00Z"}`) + account := &Account{ID: 11, Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "api_key": "downstream-key", "base_url": "https://upstream.example.com/gw/", "explicit_cache_upstream": true, + }} + req, err := ParseGeminiCachedContentCreateRequest([]byte(`{"model":"gemini-3.8-flash","contents":[{"role":"user","parts":[{"text":"doc"}]}]}`), time.Now(), 24*time.Hour) + require.NoError(t, err) + + upstream, err := svc.CreateUpstream(context.Background(), account, req, "gemini-3.8-flash", "pub2", 30*time.Minute) + require.NoError(t, err) + require.Equal(t, "cachedContents/gatewayid0001", upstream.Name) + require.Equal(t, "https://upstream.example.com/gw/v1beta/cachedContents", httpStub.lastReq.URL.String()) + require.Equal(t, "downstream-key", httpStub.lastReq.Header.Get("x-goog-api-key")) + raw, err := io.ReadAll(httpStub.lastReq.Body) + require.NoError(t, err) + require.Contains(t, string(raw), `"ttl":"1800s"`) + + resource, err := geminiCachedContentResourceURL(account, geminiCachedContentAPIBase(account), upstream.Name) + require.NoError(t, err) + require.Equal(t, "https://upstream.example.com/gw/v1beta/cachedContents/gatewayid0001", resource) +} diff --git a/backend/internal/service/gemini_chat_completions_compat_service.go b/backend/internal/service/gemini_chat_completions_compat_service.go index d1ca7ba9ae83..7800bff72584 100644 --- a/backend/internal/service/gemini_chat_completions_compat_service.go +++ b/backend/internal/service/gemini_chat_completions_compat_service.go @@ -400,19 +400,11 @@ func (s *GeminiMessagesCompatService) buildGeminiChatCompletionsUpstreamRequestF case AccountTypeServiceAccount: return func(ctx context.Context) (*http.Request, string, error) { - if s.tokenProvider == nil { - return nil, "", errors.New("gemini token provider not configured") - } - accessToken, err := s.tokenProvider.GetAccessToken(ctx, account) - if err != nil { - return nil, "", err - } - action := "generateContent" if clientStream { action = "streamGenerateContent" } - fullURL, err := buildVertexGeminiURL(account.VertexProjectID(), account.VertexLocation(mappedModel), mappedModel, action, clientStream) + fullURL, err := buildVertexGeminiAccountURL(account, mappedModel, action, clientStream) if err != nil { return nil, "", err } @@ -423,7 +415,9 @@ func (s *GeminiMessagesCompatService) buildGeminiChatCompletionsUpstreamRequestF return nil, "", err } upstreamReq.Header.Set("Content-Type", "application/json") - upstreamReq.Header.Set("Authorization", "Bearer "+accessToken) + if err := setVertexGeminiAuth(ctx, upstreamReq, account, s.tokenProvider); err != nil { + return nil, "", err + } return upstreamReq, "x-request-id", nil }, "x-request-id" diff --git a/backend/internal/service/gemini_error_policy_test.go b/backend/internal/service/gemini_error_policy_test.go index c868cf641727..2f119ff5e42e 100644 --- a/backend/internal/service/gemini_error_policy_test.go +++ b/backend/internal/service/gemini_error_policy_test.go @@ -677,6 +677,72 @@ func TestHandleGeminiUpstreamError_APIKeyFallbackStillPSTMidnight(t *testing.T) require.WithinDuration(t, expected, repo.lastRateLimitReset, 5*time.Second) } +// 第三方上游 API Key 的 429 不套用 AI Studio 每日配额语义:只认显式重试时长, +// 否则走全局 429 兜底冷却(未配置时默认 5 秒)。 +func TestHandleGeminiUpstreamError_ThirdPartyAPIKey429(t *testing.T) { + const relayBaseURL = "https://relay.example.com" + tests := []struct { + name string + baseURL string + body string + expectWait time.Duration + pstNight bool + }{ + { + name: "no_reset_time_uses_global_fallback", + baseURL: relayBaseURL, + body: `{"error":{"code":429,"message":"You have exhausted your capacity on this model. Your quota will reset after 6h53m10s."}}`, + expectWait: defaultRateLimit429CooldownSeconds * time.Second, + }, + { + name: "daily_quota_message_not_treated_as_pst_midnight", + baseURL: relayBaseURL, + body: `{"error":{"code":429,"message":"Quota exceeded for metric: generate_content_free_tier_requests, limit: 50 per day"}}`, + expectWait: defaultRateLimit429CooldownSeconds * time.Second, + }, + { + name: "explicit_quota_reset_delay_respected", + baseURL: relayBaseURL, + body: `{"error":{"code":429,"message":"rate limit","details":[{"metadata":{"quotaResetDelay":"42s"}}]}}`, + expectWait: 42 * time.Second, + }, + { + name: "official_base_url_keeps_pst_midnight", + baseURL: "https://generativelanguage.googleapis.com/", + body: `{"error":{"code":429,"message":"rate limit"}}`, + pstNight: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &rateLimit429AccountRepoStub{} + svc := &GeminiMessagesCompatService{ + accountRepo: repo, + rateLimitService: NewRateLimitService(repo, nil, &config.Config{}, nil, nil), + } + account := &Account{ + ID: 700, + Platform: PlatformGemini, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": tt.baseURL}, + } + + before := time.Now() + svc.handleGeminiUpstreamError(context.Background(), account, http.StatusTooManyRequests, http.Header{}, []byte(tt.body)) + + require.Equal(t, 1, repo.rateLimitCalls) + require.Equal(t, int64(700), repo.lastRateLimitID) + if tt.pstNight { + expected := time.Unix(*nextGeminiDailyResetUnix(), 0) + require.WithinDuration(t, expected, repo.lastRateLimitReset, 5*time.Second) + return + } + require.WithinDuration(t, before.Add(tt.expectWait), repo.lastRateLimitReset, 2*time.Second) + }) + } +} + type geminiErrorPolicyRepo struct { mockAccountRepoForGemini setErrorCalls int diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index daf7bb08cc91..623d6193c2ea 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -530,94 +530,6 @@ func (s *GeminiMessagesCompatService) HasAntigravityAccounts(ctx context.Context return len(accounts) > 0, nil } -// SelectAccountForAIStudioEndpoints selects an account that is likely to succeed against -// generativelanguage.googleapis.com (e.g. GET /v1beta/models). -// -// Preference order: -// 1) API key accounts (AI Studio) -// 2) OAuth accounts without project_id (AI Studio OAuth) -// 3) OAuth accounts explicitly marked as ai_studio -// 4) Any remaining Gemini accounts (fallback) -func (s *GeminiMessagesCompatService) SelectAccountForAIStudioEndpoints(ctx context.Context, groupID *int64) (*Account, error) { - accounts, err := s.listSchedulableAccountsOnce(ctx, groupID, PlatformGemini, true) - if err != nil { - return nil, fmt.Errorf("query accounts failed: %w", err) - } - if len(accounts) == 0 { - return nil, errors.New("no available Gemini accounts") - } - - rank := func(a *Account) int { - if a == nil { - return 999 - } - switch a.Type { - case AccountTypeAPIKey: - if strings.TrimSpace(a.GetCredential("api_key")) != "" { - return 0 - } - return 9 - case AccountTypeOAuth: - if strings.TrimSpace(a.GetCredential("project_id")) == "" { - return 1 - } - if strings.TrimSpace(a.GetCredential("oauth_type")) == "ai_studio" { - return 2 - } - // Code Assist OAuth tokens often lack AI Studio scopes for models listing. - return 3 - case AccountTypeServiceAccount: - // Vertex service accounts use aiplatform.googleapis.com, not the AI Studio - // endpoint (generativelanguage.googleapis.com), so they cannot serve these requests. - return 999 - default: - return 10 - } - } - - var selected *Account - for i := range accounts { - acc := &accounts[i] - if selected == nil { - selected = acc - continue - } - - r1, r2 := rank(acc), rank(selected) - if r1 < r2 { - selected = acc - continue - } - if r1 > r2 { - continue - } - - if acc.Priority < selected.Priority { - selected = acc - } else if acc.Priority == selected.Priority { - switch { - case acc.LastUsedAt == nil && selected.LastUsedAt != nil: - selected = acc - case acc.LastUsedAt != nil && selected.LastUsedAt == nil: - // keep selected - case acc.LastUsedAt == nil && selected.LastUsedAt == nil: - if acc.Type == AccountTypeOAuth && selected.Type != AccountTypeOAuth { - selected = acc - } - default: - if acc.LastUsedAt.Before(*selected.LastUsedAt) { - selected = acc - } - } - } - } - - if selected == nil { - return nil, errors.New("no available Gemini accounts") - } - return s.hydrateSelectedAccount(ctx, selected) -} - func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) { beginUpstreamResponseModelObservation(c) beginGeminiImageOutputObservation(c) @@ -772,19 +684,11 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex case AccountTypeServiceAccount: buildReq = func(ctx context.Context) (*http.Request, string, error) { - if s.tokenProvider == nil { - return nil, "", errors.New("gemini token provider not configured") - } - accessToken, err := s.tokenProvider.GetAccessToken(ctx, account) - if err != nil { - return nil, "", err - } - action := "generateContent" if req.Stream { action = "streamGenerateContent" } - fullURL, err := buildVertexGeminiURL(account.VertexProjectID(), account.VertexLocation(mappedModel), mappedModel, action, req.Stream) + fullURL, err := buildVertexGeminiAccountURL(account, mappedModel, action, req.Stream) if err != nil { return nil, "", err } @@ -795,7 +699,9 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex return nil, "", err } upstreamReq.Header.Set("Content-Type", "application/json") - upstreamReq.Header.Set("Authorization", "Bearer "+accessToken) + if err := setVertexGeminiAuth(ctx, upstreamReq, account, s.tokenProvider); err != nil { + return nil, "", err + } return upstreamReq, "x-request-id", nil } requestIDHeader = "x-request-id" @@ -1316,15 +1222,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. case AccountTypeServiceAccount: buildReq = func(ctx context.Context) (*http.Request, string, error) { - if s.tokenProvider == nil { - return nil, "", errors.New("gemini token provider not configured") - } - accessToken, err := s.tokenProvider.GetAccessToken(ctx, account) - if err != nil { - return nil, "", err - } - - fullURL, err := buildVertexGeminiURL(account.VertexProjectID(), account.VertexLocation(mappedModel), mappedModel, upstreamAction, useUpstreamStream) + fullURL, err := buildVertexGeminiAccountURL(account, mappedModel, upstreamAction, useUpstreamStream) if err != nil { return nil, "", err } @@ -1334,7 +1232,9 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. return nil, "", err } upstreamReq.Header.Set("Content-Type", "application/json") - upstreamReq.Header.Set("Authorization", "Bearer "+accessToken) + if err := setVertexGeminiAuth(ctx, upstreamReq, account, s.tokenProvider); err != nil { + return nil, "", err + } return upstreamReq, "x-request-id", nil } requestIDHeader = "x-request-id" @@ -1379,6 +1279,15 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. return nil, transportErr } + // 引用缓存不存在、上游网关持有缓存的账号不可用先于错误策略识别:错误策略会写临时不可调度, + // 重试与换号也无法恢复。 + if passthrough, rebuilt := s.cachedContentPassthroughInLoop(ctx, account, resp); passthrough { + resp = rebuilt + break + } else { + resp = rebuilt + } + // 错误策略优先:匹配则跳过重试直接处理。 if matched, rebuilt := s.checkErrorPolicyInLoop(ctx, account, resp, mappedModel); matched { resp = rebuilt @@ -1472,6 +1381,14 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. if resp.StatusCode >= 400 { respBody := s.readUpstreamErrorBody(resp) + // 引用缓存不存在是请求问题:统一返回 403,不改账号状态、不换号。 + if isGeminiCachedContentNotFound(resp.StatusCode, respBody) { + return nil, s.writeGeminiCachedContentNotFound(c, account, resp, respBody, requestID, isOAuth) + } + // 上游网关的限流 / 不可用只针对其持有缓存的账号:不改本账号状态,交由绑定请求的耗尽处理透传。 + if isGeminiBoundCacheUpstreamPassthrough(ctx, account, resp.StatusCode) { + return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody, BoundUpstreamPassthrough: true} + } // Best-effort fallback for OAuth tokens missing AI Studio scopes when calling countTokens. // This avoids Gemini SDKs failing hard during preflight token counting. // Checked before error policy so it always works regardless of custom error codes. @@ -1681,6 +1598,27 @@ func (s *GeminiMessagesCompatService) checkErrorPolicyInLoop( return policy != ErrorPolicyNone, rebuilt } +// cachedContentPassthroughInLoop 判断上游响应是否跳过同号重试与错误策略直接处理:引用缓存不存在, +// 或绑定缓存的请求在上游网关账号上收到的 429 / 5xx。读取过的响应体以内存副本重建。 +func (s *GeminiMessagesCompatService) cachedContentPassthroughInLoop(ctx context.Context, account *Account, resp *http.Response) (passthrough bool, rebuilt *http.Response) { + if isGeminiBoundCacheUpstreamPassthrough(ctx, account, resp.StatusCode) { + return true, resp + } + switch resp.StatusCode { + case http.StatusBadRequest, http.StatusForbidden, http.StatusNotFound: + default: + return false, resp + } + body := s.readUpstreamErrorBody(resp) + _ = resp.Body.Close() + rebuilt = &http.Response{ + StatusCode: resp.StatusCode, + Header: resp.Header.Clone(), + Body: io.NopCloser(bytes.NewReader(body)), + } + return isGeminiCachedContentNotFound(resp.StatusCode, body), rebuilt +} + func (s *GeminiMessagesCompatService) shouldRetryGeminiUpstreamError(account *Account, statusCode int) bool { switch statusCode { case 429, 500, 502, 503, 504, 529: @@ -1786,36 +1724,53 @@ func (s *GeminiMessagesCompatService) writeGeminiCustomCodeSkippedError(c *gin.C // 并记录 ops 错误事件。状态码保真:下游据此区分请求级错误与可重试的链路故障。 func (s *GeminiMessagesCompatService) writeGeminiNativeUpstreamError(c *gin.Context, account *Account, resp *http.Response, respBody []byte, requestID string, isOAuth bool) error { respBody = unwrapIfNeeded(isOAuth, respBody) + upstreamMsg := s.recordGeminiNativeUpstreamError(c, account, resp.StatusCode, respBody, requestID) + + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/json" + } + MarkResponseCommitted(c) + c.Data(resp.StatusCode, contentType, respBody) + if upstreamMsg == "" { + return fmt.Errorf("gemini upstream error: %d", resp.StatusCode) + } + return fmt.Errorf("gemini upstream error: %d message=%s", resp.StatusCode, upstreamMsg) +} + +// writeGeminiCachedContentNotFound 记录上游原始错误,并向客户端统一返回缓存不存在的 403: +// 各上游的响应形态不同,且 Vertex 的文案含上游缓存资源 ID。失败由请求引用的缓存决定,ops 按客户端请求错误统计。 +func (s *GeminiMessagesCompatService) writeGeminiCachedContentNotFound(c *gin.Context, account *Account, resp *http.Response, respBody []byte, requestID string, isOAuth bool) error { + respBody = unwrapIfNeeded(isOAuth, respBody) + upstreamMsg := s.recordGeminiNativeUpstreamError(c, account, resp.StatusCode, respBody, requestID) + MarkOpsRequestScopedError(c, "permission_error") + MarkResponseCommitted(c) + c.Data(http.StatusForbidden, "application/json", []byte(GeminiCachedContentNotFoundResponse)) + return fmt.Errorf("gemini upstream error: %d message=%s", resp.StatusCode, upstreamMsg) +} + +// recordGeminiNativeUpstreamError 把上游错误响应登记到 ops 上下文,返回脱敏后的上游错误文案。 +func (s *GeminiMessagesCompatService) recordGeminiNativeUpstreamError(c *gin.Context, account *Account, statusCode int, respBody []byte, requestID string) string { upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) upstreamDetail := s.upstreamErrorDetail(respBody) if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { - logger.LegacyPrintf("service.gemini_messages_compat", "[Gemini] native upstream error %d: %s", resp.StatusCode, truncateForLog(respBody, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)) + logger.LegacyPrintf("service.gemini_messages_compat", "[Gemini] native upstream error %d: %s", statusCode, truncateForLog(respBody, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)) } - setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) + setOpsUpstreamError(c, statusCode, upstreamMsg, upstreamDetail) appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ ProxyID: opsUpstreamProxyID(account), ProxyName: opsUpstreamProxyName(account), Platform: account.Platform, AccountID: account.ID, AccountName: account.Name, - UpstreamStatusCode: resp.StatusCode, + UpstreamStatusCode: statusCode, UpstreamRequestID: requestID, Kind: "http_error", Message: upstreamMsg, Detail: upstreamDetail, }) - - contentType := resp.Header.Get("Content-Type") - if contentType == "" { - contentType = "application/json" - } - MarkResponseCommitted(c) - c.Data(resp.StatusCode, contentType, respBody) - if upstreamMsg == "" { - return fmt.Errorf("gemini upstream error: %d", resp.StatusCode) - } - return fmt.Errorf("gemini upstream error: %d message=%s", resp.StatusCode, upstreamMsg) + return upstreamMsg } func sleepGeminiBackoff(attempt int) { @@ -2640,6 +2595,25 @@ func isGeminiInsufficientScope(headers http.Header, body []byte) bool { return strings.Contains(lower, "insufficient authentication scopes") || strings.Contains(lower, "access_token_scope_insufficient") } +// isGeminiCachedContentNotFound 识别请求引用的显式缓存不存在(含已过期、已删除、不属于当前项目)时上游的响应, +// 这类响应由请求的 cachedContent 决定,与账号凭据状态无关: +// - AI Studio 及本系统网关:一律 403 "CachedContent not found (or permission denied)"; +// - Vertex 生成请求:400 "Invalid resource state for cache content {id}."; +// - Vertex 缓存资源 GET / PATCH / DELETE:404 "Cached content {id} is not found."。 +func isGeminiCachedContentNotFound(statusCode int, body []byte) bool { + lower := strings.ToLower(string(body)) + switch statusCode { + case http.StatusForbidden: + return strings.Contains(lower, "cachedcontent not found") + case http.StatusBadRequest: + return strings.Contains(lower, "invalid resource state for cache content") + case http.StatusNotFound: + return strings.Contains(lower, "cached content") && strings.Contains(lower, "not found") + default: + return false + } +} + func estimateGeminiCountTokens(reqBody []byte) int { total := 0 @@ -2859,78 +2833,6 @@ func (s *GeminiMessagesCompatService) handleNativeStreamingResponse(c *gin.Conte return &geminiNativeStreamResult{usage: usage, firstTokenMs: firstTokenMs}, nil } -// ForwardAIStudioGET forwards a GET request to AI Studio (generativelanguage.googleapis.com) for -// endpoints like /v1beta/models and /v1beta/models/{model}. -// -// This is used to support Gemini SDKs that call models listing endpoints before generation. -func (s *GeminiMessagesCompatService) ForwardAIStudioGET(ctx context.Context, account *Account, path string) (*UpstreamHTTPResult, error) { - if account == nil { - return nil, errors.New("account is nil") - } - // path 会被直接拼到上游 base URL 后面,因此按路径护栏逐片段校验, - // 见 upstream_path_guard.go。 - sanitizedPath, ok := sanitizedUpstreamPathSuffix(path) - if !ok || sanitizedPath == "" { - return nil, errors.New("invalid path") - } - path = sanitizedPath - - baseURL := account.GetGeminiBaseURL(geminicli.AIStudioBaseURL) - normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL) - if err != nil { - return nil, err - } - fullURL := strings.TrimRight(normalizedBaseURL, "/") + path - - var proxyURL string - if account.ProxyID != nil && account.Proxy != nil { - proxyURL = account.Proxy.URL() - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) - if err != nil { - return nil, err - } - - switch account.Type { - case AccountTypeAPIKey: - apiKey := strings.TrimSpace(account.GetCredential("api_key")) - if apiKey == "" { - return nil, errors.New("gemini api_key not configured") - } - req.Header.Set("x-goog-api-key", apiKey) - case AccountTypeOAuth: - if s.tokenProvider == nil { - return nil, errors.New("gemini token provider not configured") - } - accessToken, err := s.tokenProvider.GetAccessToken(ctx, account) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+accessToken) - default: - return nil, fmt.Errorf("unsupported account type: %s", account.Type) - } - - resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) - if err != nil { - return nil, err - } - defer func() { _ = resp.Body.Close() }() - - body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) - wwwAuthenticate := resp.Header.Get("Www-Authenticate") - filteredHeaders := responseheaders.FilterHeaders(resp.Header, s.responseHeaderFilter) - if wwwAuthenticate != "" { - filteredHeaders.Set("Www-Authenticate", wwwAuthenticate) - } - return &UpstreamHTTPResult{ - StatusCode: resp.StatusCode, - Headers: filteredHeaders, - Body: body, - }, nil -} - // unwrapGeminiResponse 解包 Gemini OAuth 响应中的 response 字段 // 使用 gjson 零拷贝提取,避免完整 Unmarshal+Marshal func unwrapGeminiResponse(raw []byte) ([]byte, error) { @@ -3105,6 +3007,10 @@ func (s *GeminiMessagesCompatService) handleGeminiUpstreamError(ctx context.Cont if account.IsPoolMode() && !account.IsCustomErrorCodesEnabled() { return } + if account.IsGeminiThirdPartyAPIKey() { + s.handleGeminiThirdParty429(ctx, account, body) + return + } oauthType := account.GeminiOAuthType() tierID := account.GeminiTierID() @@ -3153,16 +3059,36 @@ func (s *GeminiMessagesCompatService) handleGeminiUpstreamError(ctx context.Cont account.ID, resetTime, oauthType, tierID) } +// handleGeminiThirdParty429 处理第三方上游的 429:不套用 AI Studio 每日配额语义, +// 只认响应里显式给出的重试时间,否则走全局 429 兜底冷却。 +func (s *GeminiMessagesCompatService) handleGeminiThirdParty429(ctx context.Context, account *Account, body []byte) { + if resetAt := parseGeminiRetryDelayResetTime(body); resetAt != nil { + resetTime := time.Unix(*resetAt, 0) + _ = s.accountRepo.SetRateLimited(ctx, account.ID, resetTime) + logger.LegacyPrintf("service.gemini_messages_compat", "[Gemini 429] Account %d (third-party API Key) rate limited until %v", account.ID, resetTime) + return + } + if s.rateLimitService != nil { + s.rateLimitService.apply429FallbackRateLimit(ctx, account, "gemini_third_party_no_reset_time") + return + } + _ = s.accountRepo.SetRateLimited(ctx, account.ID, time.Now().Add(defaultRateLimit429CooldownSeconds*time.Second)) +} + // ParseGeminiRateLimitResetTime 解析 Gemini 格式的 429 响应,返回重置时间的 Unix 时间戳 func ParseGeminiRateLimitResetTime(body []byte) *int64 { - // 第一阶段:gjson 结构化提取 errMsg := gjson.GetBytes(body, "error.message").String() if looksLikeGeminiDailyQuota(errMsg) { if ts := nextGeminiDailyResetUnix(); ts != nil { return ts } } + return parseGeminiRetryDelayResetTime(body) +} +// parseGeminiRetryDelayResetTime 只解析响应里显式给出的重试时长,返回重置时间的 Unix 时间戳。 +func parseGeminiRetryDelayResetTime(body []byte) *int64 { + // 第一阶段:gjson 结构化提取 // 遍历 error.details 查找 quotaResetDelay(AI Studio)或 RetryInfo.retryDelay(Vertex) var found *int64 gjson.GetBytes(body, "error.details").ForEach(func(_, detail gjson.Result) bool { @@ -3448,6 +3374,7 @@ func convertClaudeMessagesToGeminiContents(messages any, toolUseIDToName map[str case []any: // 如果只有一个 block,不过滤空白(让上游 API 报错) singleBlock := len(content) == 1 + var toolImageParts []any for _, block := range content { bm, ok := block.(map[string]any) @@ -3488,14 +3415,20 @@ func convertClaudeMessagesToGeminiContents(messages any, toolUseIDToName map[str if name == "" { name = "tool" } + text := extractClaudeContentText(bm["content"]) + images := extractClaudeToolResultImageParts(bm["content"]) + if text == "" && len(images) > 0 { + text = "[image attached below]" + } parts = append(parts, map[string]any{ "functionResponse": map[string]any{ "name": name, "response": map[string]any{ - "content": extractClaudeContentText(bm["content"]), + "content": text, }, }, }) + toolImageParts = append(toolImageParts, images...) case "image": if src, ok := bm["source"].(map[string]any); ok { if srcType, _ := src["type"].(string); srcType == "base64" { @@ -3518,6 +3451,7 @@ func convertClaudeMessagesToGeminiContents(messages any, toolUseIDToName map[str } } } + parts = append(parts, toolImageParts...) default: // ignore } @@ -3554,6 +3488,40 @@ func extractClaudeContentText(v any) string { } } +func extractClaudeToolResultImageParts(v any) []any { + arr, ok := v.([]any) + if !ok { + return nil + } + + parts := make([]any, 0) + for _, part := range arr { + pm, ok := part.(map[string]any) + if !ok || pm["type"] != "image" { + continue + } + source, ok := pm["source"].(map[string]any) + if !ok { + continue + } + if sourceType, _ := source["type"].(string); sourceType != "base64" { + continue + } + mediaType, _ := source["media_type"].(string) + data, _ := source["data"].(string) + if mediaType == "" || data == "" { + continue + } + parts = append(parts, map[string]any{ + "inlineData": map[string]any{ + "mimeType": mediaType, + "data": data, + }, + }) + } + return parts +} + func convertClaudeToolsToGeminiTools(tools any) []any { arr, ok := tools.([]any) if !ok || len(arr) == 0 { diff --git a/backend/internal/service/gemini_messages_compat_service_test.go b/backend/internal/service/gemini_messages_compat_service_test.go index a431ffd6a26f..d29e2c7d8b21 100644 --- a/backend/internal/service/gemini_messages_compat_service_test.go +++ b/backend/internal/service/gemini_messages_compat_service_test.go @@ -704,6 +704,105 @@ func TestConvertClaudeMessagesToGeminiGenerateContent_AddsThoughtSignatureForToo } } +func TestConvertClaudeMessagesToGeminiGenerateContent_ExtractsToolResultImages(t *testing.T) { + claudeReq := map[string]any{ + "messages": []any{ + map[string]any{ + "role": "assistant", + "content": []any{ + map[string]any{ + "type": "tool_use", + "id": "toolu_123", + "name": "inspect", + "input": map[string]any{ + "path": "image.png", + }, + }, + }, + }, + map[string]any{ + "role": "user", + "content": []any{ + map[string]any{ + "type": "tool_result", + "tool_use_id": "toolu_123", + "content": []any{ + map[string]any{"type": "text", "text": "found it"}, + map[string]any{ + "type": "image", + "source": map[string]any{ + "type": "base64", + "media_type": "image/png", + "data": "aGVsbG8=", + }, + }, + }, + }, + map[string]any{ + "type": "tool_result", + "tool_use_id": "toolu_123", + "content": []any{ + map[string]any{ + "type": "image", + "source": map[string]any{ + "type": "base64", + "media_type": "image/jpeg", + "data": "d29ybGQ=", + }, + }, + }, + }, + }, + }, + }, + } + body, err := json.Marshal(claudeReq) + require.NoError(t, err) + + converted, err := convertClaudeMessagesToGeminiGenerateContent(body) + require.NoError(t, err) + + var geminiReq map[string]any + require.NoError(t, json.Unmarshal(converted, &geminiReq)) + contents, ok := geminiReq["contents"].([]any) + require.True(t, ok) + content, ok := contents[1].(map[string]any) + require.True(t, ok) + parts, ok := content["parts"].([]any) + require.True(t, ok) + require.Len(t, parts, 4) + + firstPart, ok := parts[0].(map[string]any) + require.True(t, ok) + firstResponse, ok := firstPart["functionResponse"].(map[string]any) + require.True(t, ok) + require.Equal(t, "inspect", firstResponse["name"]) + firstResponseBody, ok := firstResponse["response"].(map[string]any) + require.True(t, ok) + require.Equal(t, "found it", firstResponseBody["content"]) + + secondPart, ok := parts[1].(map[string]any) + require.True(t, ok) + secondResponse, ok := secondPart["functionResponse"].(map[string]any) + require.True(t, ok) + secondResponseBody, ok := secondResponse["response"].(map[string]any) + require.True(t, ok) + require.Equal(t, "[image attached below]", secondResponseBody["content"]) + + firstImagePart, ok := parts[2].(map[string]any) + require.True(t, ok) + firstImage, ok := firstImagePart["inlineData"].(map[string]any) + require.True(t, ok) + require.Equal(t, "image/png", firstImage["mimeType"]) + require.Equal(t, "aGVsbG8=", firstImage["data"]) + secondImagePart, ok := parts[3].(map[string]any) + require.True(t, ok) + secondImage, ok := secondImagePart["inlineData"].(map[string]any) + require.True(t, ok) + require.Equal(t, "image/jpeg", secondImage["mimeType"]) + require.Equal(t, "d29ybGQ=", secondImage["data"]) +} + func TestEnsureGeminiFunctionCallThoughtSignatures_InsertsWhenMissing(t *testing.T) { geminiReq := map[string]any{ "contents": []any{ diff --git a/backend/internal/service/gemini_models_endpoints.go b/backend/internal/service/gemini_models_endpoints.go new file mode 100644 index 000000000000..02f3b985a171 --- /dev/null +++ b/backend/internal/service/gemini_models_endpoints.go @@ -0,0 +1,302 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "slices" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/gemini" + "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" + "github.com/Wei-Shaw/sub2api/internal/pkg/googleapi" + "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" +) + +// Gemini 分组对外提供 AI Studio 兼容协议(GET /v1beta/models、/v1beta/models/{model}), +// 分组内的账号可以是 AI Studio 兼容上游(API Key / OAuth),也可以是 Agent Platform (Vertex)。 +// 本文件把不同账号类型的模型接口统一成 AI Studio 格式的响应。 + +// errGeminiModelsUpstreamStatus 在 Vertex 分页读取遇到非 2xx 时中止分页,原始响应由调用方另行保存。 +var errGeminiModelsUpstreamStatus = errors.New("upstream model list returned non-2xx status") + +// SelectAccountForModelsEndpoints 为模型列表与模型详情接口选择账号。 +// +// 优先级: +// 1. 配置了 api_key 的 API Key 账号 +// 2. 不带 project_id 的 OAuth 账号(AI Studio OAuth) +// 3. 显式标记为 ai_studio 的 OAuth 账号 +// 4. Code Assist OAuth 账号(令牌常缺 AI Studio 权限范围,多半退回内置列表) +// 5. 其余类型的账号 +// 6. Vertex 服务账号(服务账号 JSON 认证,读取 Google 发布方模型列表) +// 7. Vertex API Key 账号(列表接口不接受 API Key,只能返回内置列表) +// +// Vertex 账号排在所有 AI Studio 兼容账号之后,混合分组始终由 AI Studio 兼容账号应答。 +// 同级账号按 priority、最近使用时间挑选。 +func (s *GeminiMessagesCompatService) SelectAccountForModelsEndpoints(ctx context.Context, groupID *int64) (*Account, error) { + accounts, err := s.listSchedulableAccountsOnce(ctx, groupID, PlatformGemini, true) + if err != nil { + return nil, fmt.Errorf("query accounts failed: %w", err) + } + if len(accounts) == 0 { + return nil, errors.New("no available Gemini accounts") + } + + var selected *Account + for i := range accounts { + acc := &accounts[i] + if selected == nil { + selected = acc + continue + } + + r1, r2 := modelsEndpointAccountRank(acc), modelsEndpointAccountRank(selected) + if r1 < r2 { + selected = acc + continue + } + if r1 > r2 { + continue + } + + if acc.Priority < selected.Priority { + selected = acc + } else if acc.Priority == selected.Priority { + switch { + case acc.LastUsedAt == nil && selected.LastUsedAt != nil: + selected = acc + case acc.LastUsedAt != nil && selected.LastUsedAt == nil: + // keep selected + case acc.LastUsedAt == nil && selected.LastUsedAt == nil: + if acc.Type == AccountTypeOAuth && selected.Type != AccountTypeOAuth { + selected = acc + } + default: + if acc.LastUsedAt.Before(*selected.LastUsedAt) { + selected = acc + } + } + } + } + + if selected == nil { + return nil, errors.New("no available Gemini accounts") + } + return s.hydrateSelectedAccount(ctx, selected) +} + +// modelsEndpointAccountRank 返回账号在模型接口选号中的优先级,数值越小越优先,顺序见 SelectAccountForModelsEndpoints。 +func modelsEndpointAccountRank(a *Account) int { + if a == nil { + return 999 + } + switch a.Type { + case AccountTypeAPIKey: + if strings.TrimSpace(a.GetCredential("api_key")) != "" { + return 0 + } + return 9 + case AccountTypeOAuth: + if strings.TrimSpace(a.GetCredential("project_id")) == "" { + return 1 + } + if strings.TrimSpace(a.GetCredential("oauth_type")) == "ai_studio" { + return 2 + } + return 3 + case AccountTypeServiceAccount: + if a.IsVertexAPIKey() { + return 12 + } + return 11 + default: + return 10 + } +} + +// ForwardListModels 读取账号上游的模型列表,返回 AI Studio 格式的响应(GET /v1beta/models)。 +// AI Studio 兼容账号原样返回上游响应;Vertex 服务账号读取 Google 发布方模型列表后转换格式, +// 上游非 2xx 时原样返回该响应;Vertex API Key 账号返回内置列表。 +func (s *GeminiMessagesCompatService) ForwardListModels(ctx context.Context, account *Account) (*UpstreamHTTPResult, error) { + if account == nil { + return nil, errors.New("account is nil") + } + if !account.IsVertexServiceAccount() { + return s.forwardGeminiModelsGET(ctx, account, "/v1beta/models") + } + if account.IsVertexAPIKey() { + return geminiModelsJSONResult(http.StatusOK, gemini.FallbackModelsList()) + } + + modelIDs, failure, err := s.listVertexGeminiModels(ctx, account, account.VertexLocation("")) + if err != nil { + return nil, err + } + if failure != nil { + return failure, nil + } + models := make([]gemini.Model, 0, len(modelIDs)) + for _, modelID := range modelIDs { + models = append(models, gemini.FallbackModel(modelID)) + } + return geminiModelsJSONResult(http.StatusOK, gemini.ModelsListResponse{Models: models}) +} + +// ForwardGetModel 读取账号上游的单个模型信息,返回 AI Studio 格式的响应(GET /v1beta/models/{model})。 +// Vertex 服务账号在该模型生成请求所用区域的模型列表中查找,找不到时返回 404;Vertex API Key 账号返回内置信息。 +func (s *GeminiMessagesCompatService) ForwardGetModel(ctx context.Context, account *Account, model string) (*UpstreamHTTPResult, error) { + if account == nil { + return nil, errors.New("account is nil") + } + if !account.IsVertexServiceAccount() { + return s.forwardGeminiModelsGET(ctx, account, "/v1beta/models/"+model) + } + if account.IsVertexAPIKey() { + return geminiModelsJSONResult(http.StatusOK, gemini.FallbackModel(model)) + } + + modelIDs, failure, err := s.listVertexGeminiModels(ctx, account, account.VertexLocation(model)) + if err != nil { + return nil, err + } + if failure != nil { + return failure, nil + } + if slices.Contains(modelIDs, model) { + return geminiModelsJSONResult(http.StatusOK, gemini.FallbackModel(model)) + } + return geminiModelsJSONResult(http.StatusNotFound, map[string]any{ + "error": map[string]any{ + "code": http.StatusNotFound, + "message": fmt.Sprintf("models/%s is not found", model), + "status": googleapi.HTTPStatusToGoogleStatus(http.StatusNotFound), + }, + }) +} + +// forwardGeminiModelsGET 向 AI Studio 兼容上游转发模型接口的 GET 请求。 +// path 会被直接拼到上游 base URL 后面,因此按路径护栏逐片段校验,见 upstream_path_guard.go。 +func (s *GeminiMessagesCompatService) forwardGeminiModelsGET(ctx context.Context, account *Account, path string) (*UpstreamHTTPResult, error) { + sanitizedPath, ok := sanitizedUpstreamPathSuffix(path) + if !ok || sanitizedPath == "" { + return nil, errors.New("invalid path") + } + + baseURL := account.GetGeminiBaseURL(geminicli.AIStudioBaseURL) + normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL) + if err != nil { + return nil, err + } + fullURL := strings.TrimRight(normalizedBaseURL, "/") + sanitizedPath + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + return nil, err + } + + switch account.Type { + case AccountTypeAPIKey: + apiKey := strings.TrimSpace(account.GetCredential("api_key")) + if apiKey == "" { + return nil, errors.New("gemini api_key not configured") + } + req.Header.Set("x-goog-api-key", apiKey) + case AccountTypeOAuth: + if s.tokenProvider == nil { + return nil, errors.New("gemini token provider not configured") + } + accessToken, err := s.tokenProvider.GetAccessToken(ctx, account) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + default: + return nil, fmt.Errorf("unsupported account type: %s", account.Type) + } + + return s.doGeminiModelsGET(req, account) +} + +// listVertexGeminiModels 读取 Vertex 服务账号在指定区域可用的 Gemini 模型。 +// 上游返回非 2xx 时 failure 为该页的原始响应,modelIDs 与 err 均为空。 +func (s *GeminiMessagesCompatService) listVertexGeminiModels(ctx context.Context, account *Account, location string) (modelIDs []string, failure *UpstreamHTTPResult, err error) { + if s.tokenProvider == nil { + return nil, nil, errors.New("gemini token provider not configured") + } + accessToken, err := s.tokenProvider.GetAccessToken(ctx, account) + if err != nil { + return nil, nil, err + } + + modelIDs, err = listVertexGeminiPublisherModels(location, func(_ int, listURL string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + + res, err := s.doGeminiModelsGET(req, account) + if err != nil { + return nil, err + } + if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices { + failure = res + return nil, errGeminiModelsUpstreamStatus + } + return res.Body, nil + }) + if failure != nil { + return nil, failure, nil + } + if err != nil { + return nil, nil, err + } + return modelIDs, nil, nil +} + +// doGeminiModelsGET 发送模型接口请求并读取响应。响应体上限取 gateway.models_list_read_max_bytes,超出时报错;响应头按配置过滤。 +func (s *GeminiMessagesCompatService) doGeminiModelsGET(req *http.Request, account *Account) (*UpstreamHTTPResult, error) { + var proxyURL string + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + + resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + bodyLimit := resolveModelsListReadLimit(s.cfg) + body, _ := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1)) + if int64(len(body)) > bodyLimit { + return nil, fmt.Errorf("upstream model list response exceeds %d bytes", bodyLimit) + } + wwwAuthenticate := resp.Header.Get("Www-Authenticate") + filteredHeaders := responseheaders.FilterHeaders(resp.Header, s.responseHeaderFilter) + if wwwAuthenticate != "" { + filteredHeaders.Set("Www-Authenticate", wwwAuthenticate) + } + return &UpstreamHTTPResult{ + StatusCode: resp.StatusCode, + Headers: filteredHeaders, + Body: body, + }, nil +} + +// geminiModelsJSONResult 把网关生成的模型接口响应包装成 UpstreamHTTPResult,交给 handler 统一写出。 +func geminiModelsJSONResult(status int, payload any) (*UpstreamHTTPResult, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return &UpstreamHTTPResult{ + StatusCode: status, + Headers: http.Header{"Content-Type": []string{"application/json; charset=utf-8"}}, + Body: body, + }, nil +} diff --git a/backend/internal/service/gemini_models_endpoints_test.go b/backend/internal/service/gemini_models_endpoints_test.go new file mode 100644 index 000000000000..e33b85d8090f --- /dev/null +++ b/backend/internal/service/gemini_models_endpoints_test.go @@ -0,0 +1,208 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/gemini" + "github.com/stretchr/testify/require" +) + +func newGeminiModelsEndpointsTestService(accounts []Account, upstream HTTPUpstream) *GeminiMessagesCompatService { + repo := &mockAccountRepoForGemini{listByGroupFunc: func(context.Context, int64, []string) ([]Account, error) { + return accounts, nil + }} + return &GeminiMessagesCompatService{ + accountRepo: repo, + httpUpstream: upstream, + cfg: &config.Config{}, + tokenProvider: NewGeminiTokenProvider(nil, &vertexModelsTokenCacheStub{token: "ya29.vertex-token"}, nil), + } +} + +func decodeGeminiModelNames(t *testing.T, body []byte) []string { + t.Helper() + var got gemini.ModelsListResponse + require.NoError(t, json.Unmarshal(body, &got)) + names := make([]string, 0, len(got.Models)) + for _, model := range got.Models { + require.Contains(t, model.SupportedGenerationMethods, "generateContent") + names = append(names, model.Name) + } + return names +} + +func TestSelectAccountForModelsEndpoints_VertexServiceAccountOnlyGroup(t *testing.T) { + groupID := int64(8) + vertex := *vertexModelSyncTestAccount(nil) + vertex.ID = 18 + svc := newGeminiModelsEndpointsTestService([]Account{vertex}, nil) + + account, err := svc.SelectAccountForModelsEndpoints(context.Background(), &groupID) + require.NoError(t, err) + require.Equal(t, int64(18), account.ID) +} + +func TestModelsEndpointAccountRank_Order(t *testing.T) { + ordered := []*Account{ + {Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "k"}}, + {Platform: PlatformGemini, Type: AccountTypeOAuth, Credentials: map[string]any{}}, + {Platform: PlatformGemini, Type: AccountTypeOAuth, Credentials: map[string]any{"project_id": "p", "oauth_type": "ai_studio"}}, + {Platform: PlatformGemini, Type: AccountTypeOAuth, Credentials: map[string]any{"project_id": "p", "oauth_type": "code_assist"}}, + {Platform: PlatformGemini, Type: AccountTypeUpstream, Credentials: map[string]any{"api_key": "k"}}, + vertexModelSyncTestAccount(nil), + {Platform: PlatformGemini, Type: AccountTypeServiceAccount, Credentials: map[string]any{"auth_mode": "apikey", "api_key": "AQ.k"}}, + } + for i := 1; i < len(ordered); i++ { + require.Less(t, modelsEndpointAccountRank(ordered[i-1]), modelsEndpointAccountRank(ordered[i]), "position %d", i) + } +} + +func TestSelectAccountForModelsEndpoints_PrefersAPIKeyOverVertex(t *testing.T) { + groupID := int64(8) + vertex := *vertexModelSyncTestAccount(nil) + vertex.ID = 18 + apiKey := Account{ID: 19, Platform: PlatformGemini, Type: AccountTypeAPIKey, Priority: 9, Credentials: map[string]any{"api_key": "k"}} + svc := newGeminiModelsEndpointsTestService([]Account{vertex, apiKey}, nil) + + account, err := svc.SelectAccountForModelsEndpoints(context.Background(), &groupID) + require.NoError(t, err) + require.Equal(t, int64(19), account.ID) +} + +func TestForwardListModels_VertexServiceAccountReturnsAIStudioFormat(t *testing.T) { + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + vertexModelsJSONResponse(http.StatusOK, `{"publisherModels":[ + {"name":"publishers/google/models/gemini-3.8-flash"}, + {"name":"publishers/google/models/gemma4"} + ],"nextPageToken":"page-2"}`), + vertexModelsJSONResponse(http.StatusOK, `{"publisherModels":[{"name":"publishers/google/models/gemini-2.5-flash"}]}`), + }} + svc := newGeminiModelsEndpointsTestService(nil, upstream) + + res, err := svc.ForwardListModels(context.Background(), vertexModelSyncTestAccount(map[string]any{"location": "europe-west4"})) + require.NoError(t, err) + require.Equal(t, http.StatusOK, res.StatusCode) + require.Equal(t, []string{"models/gemini-2.5-flash", "models/gemini-3.8-flash"}, decodeGeminiModelNames(t, res.Body)) + + require.Len(t, upstream.requests, 2) + require.Equal(t, "https://europe-west4-aiplatform.googleapis.com/v1beta1/publishers/google/models?pageSize=300", upstream.requests[0].URL.String()) + require.Equal(t, "https://europe-west4-aiplatform.googleapis.com/v1beta1/publishers/google/models?pageSize=300&pageToken=page-2", upstream.requests[1].URL.String()) + for _, req := range upstream.requests { + require.Equal(t, "Bearer ya29.vertex-token", req.Header.Get("Authorization")) + } +} + +func TestForwardListModels_VertexUpstreamErrorPassesThrough(t *testing.T) { + body := `{"error":{"code":403,"status":"PERMISSION_DENIED"}}` + upstream := &httpUpstreamRecorder{resp: vertexModelsJSONResponse(http.StatusForbidden, body)} + svc := newGeminiModelsEndpointsTestService(nil, upstream) + + res, err := svc.ForwardListModels(context.Background(), vertexModelSyncTestAccount(map[string]any{"location": "global"})) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, res.StatusCode) + require.JSONEq(t, body, string(res.Body)) +} + +func TestForwardListModels_VertexAPIKeyReturnsBuiltinList(t *testing.T) { + upstream := &httpUpstreamRecorder{} + svc := newGeminiModelsEndpointsTestService(nil, upstream) + account := &Account{ID: 174, Platform: PlatformGemini, Type: AccountTypeServiceAccount, Credentials: map[string]any{ + "auth_mode": "apikey", + "api_key": "AQ.test-key", + }} + + res, err := svc.ForwardListModels(context.Background(), account) + require.NoError(t, err) + require.Equal(t, http.StatusOK, res.StatusCode) + expected, err := json.Marshal(gemini.FallbackModelsList()) + require.NoError(t, err) + require.JSONEq(t, string(expected), string(res.Body)) + require.Empty(t, upstream.requests) +} + +func TestForwardListModels_APIKeyForwardsToBaseURL(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: vertexModelsJSONResponse(http.StatusOK, `{"models":[{"name":"models/gemini-native"}]}`)} + svc := newGeminiModelsEndpointsTestService(nil, upstream) + account := &Account{ID: 261, Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "api_key": "sk-pool", + "base_url": "https://relay.example.com", + }} + + res, err := svc.ForwardListModels(context.Background(), account) + require.NoError(t, err) + require.Equal(t, http.StatusOK, res.StatusCode) + require.JSONEq(t, `{"models":[{"name":"models/gemini-native"}]}`, string(res.Body)) + require.Equal(t, "https://relay.example.com/v1beta/models", upstream.lastReq.URL.String()) + require.Equal(t, "sk-pool", upstream.lastReq.Header.Get("x-goog-api-key")) +} + +func TestForwardGetModel_VertexServiceAccountLooksUpModelList(t *testing.T) { + listBody := `{"publisherModels":[{"name":"publishers/google/models/gemini-3.8-flash"}]}` + + t.Run("listed model", func(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: vertexModelsJSONResponse(http.StatusOK, listBody)} + svc := newGeminiModelsEndpointsTestService(nil, upstream) + + res, err := svc.ForwardGetModel(context.Background(), vertexModelSyncTestAccount(nil), "gemini-3.8-flash") + require.NoError(t, err) + require.Equal(t, http.StatusOK, res.StatusCode) + var model gemini.Model + require.NoError(t, json.Unmarshal(res.Body, &model)) + require.Equal(t, "models/gemini-3.8-flash", model.Name) + }) + + t.Run("unlisted model", func(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: vertexModelsJSONResponse(http.StatusOK, listBody)} + svc := newGeminiModelsEndpointsTestService(nil, upstream) + + res, err := svc.ForwardGetModel(context.Background(), vertexModelSyncTestAccount(nil), "gemini-unknown") + require.NoError(t, err) + require.Equal(t, http.StatusNotFound, res.StatusCode) + require.JSONEq(t, `{"error":{"code":404,"message":"models/gemini-unknown is not found","status":"NOT_FOUND"}}`, string(res.Body)) + }) +} + +func TestForwardGetModel_VertexUsesModelLocation(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: vertexModelsJSONResponse(http.StatusOK, `{"publisherModels":[{"name":"publishers/google/models/gemini-3.8-flash"}]}`)} + svc := newGeminiModelsEndpointsTestService(nil, upstream) + account := vertexModelSyncTestAccount(map[string]any{ + "location": "europe-west4", + "vertex_model_locations": map[string]any{"gemini-3.8-flash": "global"}, + }) + + res, err := svc.ForwardGetModel(context.Background(), account, "gemini-3.8-flash") + require.NoError(t, err) + require.Equal(t, http.StatusOK, res.StatusCode) + require.Equal(t, "https://aiplatform.googleapis.com/v1beta1/publishers/google/models?pageSize=300", upstream.lastReq.URL.String()) +} + +func TestForwardListModels_VertexMidPaginationErrorPassesThrough(t *testing.T) { + body := `{"error":{"code":503,"status":"UNAVAILABLE"}}` + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + vertexModelsJSONResponse(http.StatusOK, `{"publisherModels":[{"name":"publishers/google/models/gemini-3.8-flash"}],"nextPageToken":"page-2"}`), + vertexModelsJSONResponse(http.StatusServiceUnavailable, body), + }} + svc := newGeminiModelsEndpointsTestService(nil, upstream) + + res, err := svc.ForwardListModels(context.Background(), vertexModelSyncTestAccount(nil)) + require.NoError(t, err) + require.Equal(t, http.StatusServiceUnavailable, res.StatusCode) + require.JSONEq(t, body, string(res.Body)) + require.Len(t, upstream.requests, 2) +} + +func TestForwardListModels_ResponseOverReadLimitFails(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: vertexModelsJSONResponse(http.StatusOK, `{"models":[{"name":"models/gemini-native"}]}`)} + svc := newGeminiModelsEndpointsTestService(nil, upstream) + svc.cfg.Gateway.ModelsListReadMaxBytes = 16 + account := &Account{ID: 261, Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "sk-pool"}} + + _, err := svc.ForwardListModels(context.Background(), account) + require.ErrorContains(t, err, "exceeds 16 bytes") +} diff --git a/backend/internal/service/gemini_quota.go b/backend/internal/service/gemini_quota.go index 3a70232c87f2..be927754cf2d 100644 --- a/backend/internal/service/gemini_quota.go +++ b/backend/internal/service/gemini_quota.go @@ -373,6 +373,10 @@ func geminiQuotaTierKeyForAccount(account *Account) string { if account == nil || account.Platform != PlatformGemini { return "" } + // 本地模拟配额只适用于按 Google 账号等级限额的通道;第三方上游与 Vertex 按量付费不套用。 + if account.IsGeminiThirdPartyAPIKey() || account.IsVertexServiceAccount() { + return "" + } // Note: GeminiOAuthType() already defaults legacy (project_id present) to code_assist. oauthType := strings.ToLower(strings.TrimSpace(account.GeminiOAuthType())) diff --git a/backend/internal/service/gemini_quota_test.go b/backend/internal/service/gemini_quota_test.go new file mode 100644 index 000000000000..36e60f291ece --- /dev/null +++ b/backend/internal/service/gemini_quota_test.go @@ -0,0 +1,80 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func TestAccountIsGeminiThirdPartyAPIKey(t *testing.T) { + tests := []struct { + name string + account *Account + expected bool + }{ + {"nil_account", nil, false}, + {"empty_base_url_is_official", geminiQuotaAPIKeyAccount(""), false}, + {"official_base_url", geminiQuotaAPIKeyAccount("https://generativelanguage.googleapis.com"), false}, + {"official_base_url_trailing_slash", geminiQuotaAPIKeyAccount("https://generativelanguage.googleapis.com/"), false}, + {"official_host_over_http", geminiQuotaAPIKeyAccount("http://generativelanguage.googleapis.com"), true}, + {"official_host_with_path_prefix", geminiQuotaAPIKeyAccount("https://generativelanguage.googleapis.com/proxy"), true}, + {"relay_base_url", geminiQuotaAPIKeyAccount("https://relay.example.com"), true}, + {"oauth_account", &Account{Platform: PlatformGemini, Type: AccountTypeOAuth, Credentials: map[string]any{"base_url": "https://relay.example.com"}}, false}, + {"vertex_service_account", &Account{Platform: PlatformGemini, Type: AccountTypeServiceAccount}, false}, + {"non_gemini_platform", &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://relay.example.com"}}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, tt.account.IsGeminiThirdPartyAPIKey()) + }) + } +} + +func TestGeminiQuotaService_QuotaForAccountSkipsThirdPartyAndVertex(t *testing.T) { + svc := NewGeminiQuotaService(&config.Config{}, nil) + ctx := context.Background() + + quota, ok := svc.QuotaForAccount(ctx, geminiQuotaAPIKeyAccount("")) + require.True(t, ok) + require.Equal(t, int64(50), quota.ProRPD) + + paid := geminiQuotaAPIKeyAccount("https://generativelanguage.googleapis.com") + paid.Credentials["tier_id"] = GeminiTierAIStudioPaid + quota, ok = svc.QuotaForAccount(ctx, paid) + require.True(t, ok) + require.Equal(t, int64(-1), quota.ProRPD) + + relay := geminiQuotaAPIKeyAccount("https://relay.example.com") + relay.Credentials["tier_id"] = GeminiTierAIStudioFree + _, ok = svc.QuotaForAccount(ctx, relay) + require.False(t, ok, "第三方上游不套用 AI Studio 模拟配额") + + vertex := &Account{ + Platform: PlatformGemini, + Type: AccountTypeServiceAccount, + Credentials: map[string]any{"project_id": "my-project", "tier_id": "vertex"}, + } + _, ok = svc.QuotaForAccount(ctx, vertex) + require.False(t, ok, "Vertex 按量付费不套用模拟配额") + + googleOne := &Account{ + Platform: PlatformGemini, + Type: AccountTypeOAuth, + Credentials: map[string]any{"oauth_type": "google_one", "tier_id": GeminiTierGoogleOneFree}, + } + quota, ok = svc.QuotaForAccount(ctx, googleOne) + require.True(t, ok) + require.Positive(t, quota.SharedRPD) +} + +func geminiQuotaAPIKeyAccount(baseURL string) *Account { + credentials := map[string]any{} + if baseURL != "" { + credentials["base_url"] = baseURL + } + return &Account{Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: credentials} +} diff --git a/backend/internal/service/gemini_vertex_api_key_forward_test.go b/backend/internal/service/gemini_vertex_api_key_forward_test.go new file mode 100644 index 000000000000..bf8be6c1c0fc --- /dev/null +++ b/backend/internal/service/gemini_vertex_api_key_forward_test.go @@ -0,0 +1,101 @@ +//go:build unit + +package service + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +const geminiVertexAPIKeyTestResponse = `{"candidates":[{"content":{"parts":[{"text":"hello"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}` + +func newGeminiVertexAPIKeyTestService() (*GeminiMessagesCompatService, *geminiCompatHTTPUpstreamStub) { + httpStub := &geminiCompatHTTPUpstreamStub{ + response: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "X-Request-Id": []string{"vertex-req-1"}}, + Body: io.NopCloser(strings.NewReader(geminiVertexAPIKeyTestResponse)), + }, + } + // tokenProvider 留空:API Key 认证不依赖 access token。 + return &GeminiMessagesCompatService{httpUpstream: httpStub, cfg: &config.Config{}}, httpStub +} + +func geminiVertexAPIKeyTestAccount(projectID string) *Account { + credentials := map[string]any{ + "auth_mode": "apikey", + "api_key": "vertex-test-key", + "location": "us-central1", + "tier_id": "vertex", + } + if projectID != "" { + credentials["project_id"] = projectID + } + return &Account{ + ID: 811, + Platform: PlatformGemini, + Type: AccountTypeServiceAccount, + Credentials: credentials, + Concurrency: 1, + } +} + +func requireVertexAPIKeyAuth(t *testing.T, req *http.Request) { + t.Helper() + require.NotNil(t, req) + require.Equal(t, "vertex-test-key", req.Header.Get("x-goog-api-key")) + require.Empty(t, req.Header.Get("Authorization")) +} + +func TestGeminiForwardNative_VertexAPIKeyWithoutProjectUsesExpressEndpoint(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, httpStub := newGeminiVertexAPIKeyTestService() + c, _ := newGeminiNativeTestContext(t) + + result, err := svc.ForwardNative(context.Background(), c, geminiVertexAPIKeyTestAccount(""), + "gemini-3-flash-preview", "generateContent", false, []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 1, httpStub.calls) + require.Equal(t, "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3-flash-preview:generateContent", httpStub.lastReq.URL.String()) + requireVertexAPIKeyAuth(t, httpStub.lastReq) +} + +func TestGeminiMessagesCompatServiceForward_VertexAPIKeyWithProjectUsesProjectEndpoint(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, httpStub := newGeminiVertexAPIKeyTestService() + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + body := []byte(`{"model":"gemini-3-flash-preview","max_tokens":16,"messages":[{"role":"user","content":"hello"}]}`) + + result, err := svc.Forward(context.Background(), c, geminiVertexAPIKeyTestAccount("my-project"), body) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini-3-flash-preview:generateContent", httpStub.lastReq.URL.String()) + requireVertexAPIKeyAuth(t, httpStub.lastReq) +} + +func TestGeminiForwardAsChatCompletions_VertexAPIKeyUsesAPIKeyHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, httpStub := newGeminiVertexAPIKeyTestService() + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gemini-3-flash-preview","messages":[{"role":"user","content":"hi"}]}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, geminiVertexAPIKeyTestAccount(""), body) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3-flash-preview:generateContent", httpStub.lastReq.URL.String()) + requireVertexAPIKeyAuth(t, httpStub.lastReq) +} diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index bb6804a82307..0489fe35ccc9 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -19,6 +19,7 @@ type Group struct { ID int64 Name string Description string + I18n GroupI18n // 名称与描述的界面语言译文;缺失的语言沿用 Name/Description Platform string RateMultiplier float64 // 高峰时段倍率:peak_rate_enabled 为 true 且当前时刻处于 [PeakStart, PeakEnd) 时, diff --git a/backend/internal/service/group_i18n.go b/backend/internal/service/group_i18n.go new file mode 100644 index 000000000000..31bd88d29f25 --- /dev/null +++ b/backend/internal/service/group_i18n.go @@ -0,0 +1,64 @@ +package service + +import ( + "fmt" + "maps" + "regexp" + "slices" + "strings" + "unicode/utf8" + + "github.com/Wei-Shaw/sub2api/internal/domain" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +type GroupI18n = domain.GroupI18n +type GroupLocaleText = domain.GroupLocaleText + +// groupI18nNameMaxRunes 与 groups.name 的长度上限一致。 +const groupI18nNameMaxRunes = 100 + +// 语言代码只校验 BCP 47 形态(en、zh、zh-TW);具体提供哪些语言由前端语言包决定。 +var groupI18nLocalePattern = regexp.MustCompile(`^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$`) + +// normalizeGroupI18n 把语言代码统一为 domain.CanonicalLocale 的写法,去除译文首尾空白, +// 并丢弃名称与描述都为空的语言。统一写法后重复的语言代码(如 en 与 EN)直接拒绝, +// 不在其中任选一份。返回值始终非 nil,落库为 JSON 对象。 +func normalizeGroupI18n(in GroupI18n) (GroupI18n, error) { + out := make(GroupI18n, len(in)) + seen := make(map[string]struct{}, len(in)) + for _, raw := range slices.Sorted(maps.Keys(in)) { + if !groupI18nLocalePattern.MatchString(raw) { + return nil, infraerrors.BadRequest("INVALID_GROUP_I18N", fmt.Sprintf("invalid locale %q in i18n", raw)) + } + locale := domain.CanonicalLocale(raw) + if _, dup := seen[locale]; dup { + return nil, infraerrors.BadRequest("INVALID_GROUP_I18N", fmt.Sprintf("duplicate locale %q in i18n", locale)) + } + seen[locale] = struct{}{} + text := in[raw] + name := strings.TrimSpace(text.Name) + description := strings.TrimSpace(text.Description) + if utf8.RuneCountInString(name) > groupI18nNameMaxRunes { + return nil, infraerrors.BadRequest("INVALID_GROUP_I18N", fmt.Sprintf("i18n name for locale %q must be at most %d characters", locale, groupI18nNameMaxRunes)) + } + if name == "" && description == "" { + continue + } + out[locale] = GroupLocaleText{Name: name, Description: description} + } + return out, nil +} + +// cloneGroupI18nForDuplicate 复制译文中的描述。名称译文不随复制带走: +// 副本的 name 已改写,沿用源分组的名称译文会让两个分组在该语言下同名。 +func cloneGroupI18nForDuplicate(in GroupI18n) GroupI18n { + out := make(GroupI18n, len(in)) + for locale, text := range in { + if text.Description == "" { + continue + } + out[locale] = GroupLocaleText{Description: text.Description} + } + return out +} diff --git a/backend/internal/service/group_i18n_test.go b/backend/internal/service/group_i18n_test.go new file mode 100644 index 000000000000..c3fd84eaf798 --- /dev/null +++ b/backend/internal/service/group_i18n_test.go @@ -0,0 +1,83 @@ +package service + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeGroupI18n(t *testing.T) { + t.Run("nil input yields an empty non-nil map", func(t *testing.T) { + got, err := normalizeGroupI18n(nil) + require.NoError(t, err) + require.NotNil(t, got) + require.Empty(t, got) + }) + + t.Run("trims text and drops locales with nothing left", func(t *testing.T) { + got, err := normalizeGroupI18n(GroupI18n{ + "en": {Name: " Standard ", Description: "\tPro + Max pool\n"}, + "zh": {Name: " ", Description: ""}, + "zh-TW": {Description: "僅描述"}, + }) + require.NoError(t, err) + require.Equal(t, GroupI18n{ + "en": {Name: "Standard", Description: "Pro + Max pool"}, + "zh-TW": {Description: "僅描述"}, + }, got) + }) + + t.Run("locale codes are stored in canonical case", func(t *testing.T) { + got, err := normalizeGroupI18n(GroupI18n{ + "EN": {Name: "Standard"}, + "zh-tw": {Name: "標準"}, + }) + require.NoError(t, err) + require.Equal(t, GroupI18n{ + "en": {Name: "Standard"}, + "zh-TW": {Name: "標準"}, + }, got) + }) + + t.Run("rejects locales that collide after canonicalization", func(t *testing.T) { + for _, in := range []GroupI18n{ + {"en": {Name: "English A"}, "EN": {Name: "English B"}}, + {"zh-TW": {Name: "標準"}, "zh-tw": {}}, + } { + _, err := normalizeGroupI18n(in) + require.Errorf(t, err, "input %v should be rejected", in) + } + }) + + t.Run("rejects malformed locale codes", func(t *testing.T) { + for _, locale := range []string{"", "e", "en_US", "en-", "中文", "en us"} { + _, err := normalizeGroupI18n(GroupI18n{locale: {Name: "x"}}) + require.Errorf(t, err, "locale %q should be rejected", locale) + } + }) + + t.Run("name length is capped in characters like groups.name", func(t *testing.T) { + atLimit := strings.Repeat("名", groupI18nNameMaxRunes) + got, err := normalizeGroupI18n(GroupI18n{"zh": {Name: atLimit}}) + require.NoError(t, err) + require.Equal(t, atLimit, got["zh"].Name) + + _, err = normalizeGroupI18n(GroupI18n{"zh": {Name: atLimit + "名"}}) + require.Error(t, err) + }) +} + +func TestCloneGroupI18nForDuplicate(t *testing.T) { + source := GroupI18n{ + "en": {Name: "Standard", Description: "Pro + Max pool"}, + "zh": {Name: "标准"}, + } + + got := cloneGroupI18nForDuplicate(source) + + // 副本改了 name;名称译文若照搬,副本与源分组在该语言下会同名。 + require.Equal(t, GroupI18n{"en": {Description: "Pro + Max pool"}}, got) + require.Equal(t, "Standard", source["en"].Name, "source must not be mutated") + require.NotNil(t, cloneGroupI18nForDuplicate(nil)) +} diff --git a/backend/internal/service/model_context_capacity_reference_test.go b/backend/internal/service/model_context_capacity_reference_test.go index 91dc83676888..b84f566e970c 100644 --- a/backend/internal/service/model_context_capacity_reference_test.go +++ b/backend/internal/service/model_context_capacity_reference_test.go @@ -30,6 +30,8 @@ func TestModelContextCapacitySelectedGPTReferenceAndExactVariants(t *testing.T) {"gpt-5.2", 400000, "gpt-5.2"}, {"vendor/gpt-5.3-codex", 400000, "gpt-5.3-codex"}, {"vendor/claude-sonnet-4-6", 1000000, "claude-sonnet-4-6"}, + {"claude-haiku-5-5", 1000000, "claude-haiku-5-5"}, + {"anthropic/claude-haiku-5.5", 1000000, "claude-haiku-5-5"}, {"anthropic/claude-opus-4.6", 1000000, "claude-opus-4-6"}, {"anthropic/claude-opus-4-6:free", 1000000, "claude-opus-4-6"}, {"vendor/gpt-5.4-2099-01-01", 0, ""}, diff --git a/backend/internal/service/model_context_official_catalog_data.go b/backend/internal/service/model_context_official_catalog_data.go index 05c9979b1a7f..3f906cf89e52 100644 --- a/backend/internal/service/model_context_official_catalog_data.go +++ b/backend/internal/service/model_context_official_catalog_data.go @@ -246,6 +246,14 @@ var officialModelContextCapacityCatalog = []OfficialModelContextCapacity{ VerifiedAt: claude55CapacityVerifiedAt, OriginalText: "1M; 128K", NormalizationBasis: claudeUnitsBasis, }, + { + ModelID: "claude-haiku-5-5", Provider: "anthropic", Product: "messages_api", + ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxOutputTokens: 128000, CapacityBasis: "total_context"}, + SourceURL: "https://platform.claude.com/docs/en/models/haiku-5-5/overview", + SourceURLs: []string{claudeContextSource, claudeIntegerSource, claudeOutputSource}, + VerifiedAt: "2026-10-13", OriginalText: "1M; 128K", + NormalizationBasis: claudeUnitsBasis, + }, { ModelID: "claude-fable-5-1", Provider: "anthropic", Product: "messages_api", ModelContextCapacity: ModelContextCapacity{ContextWindow: 1000000, MaxOutputTokens: 128000, CapacityBasis: "total_context"}, diff --git a/backend/internal/service/model_plaza_service.go b/backend/internal/service/model_plaza_service.go index 0f24a13f1e65..37ef1e303d56 100644 --- a/backend/internal/service/model_plaza_service.go +++ b/backend/internal/service/model_plaza_service.go @@ -40,6 +40,7 @@ type PlazaGroup struct { ID int64 Name string Description string + I18n GroupI18n Platform string SubscriptionType string RateMultiplier float64 @@ -125,6 +126,7 @@ func (s *ModelPlazaService) ListGroups(ctx context.Context) ([]PlazaGroup, error ID: g.ID, Name: g.Name, Description: g.Description, + I18n: g.I18n, Platform: g.Platform, SubscriptionType: g.SubscriptionType, RateMultiplier: g.RateMultiplier, diff --git a/backend/internal/service/model_rate_limit.go b/backend/internal/service/model_rate_limit.go index 153e60939052..8245362255be 100644 --- a/backend/internal/service/model_rate_limit.go +++ b/backend/internal/service/model_rate_limit.go @@ -88,7 +88,7 @@ func (a *Account) modelRateLimitKeysForRequest(ctx context.Context, requestedMod keys := []string{modelKey} switch a.Platform { case PlatformAntigravity: - if isAntigravityGeminiModel(modelKey) && modelKey != antigravityGeminiModelRateLimitKey { + if isAntigravityGeminiModel(modelKey) && !isAntigravityGeminiImageModel(modelKey) && modelKey != antigravityGeminiModelRateLimitKey { keys = append(keys, antigravityGeminiModelRateLimitKey) } case PlatformOpenAI: @@ -173,12 +173,17 @@ func antigravityModelRateLimitKeys(model string) []string { return nil } keys := []string{model} - if isAntigravityGeminiModel(model) && model != antigravityGeminiModelRateLimitKey { + if isAntigravityGeminiModel(model) && !isAntigravityGeminiImageModel(model) && model != antigravityGeminiModelRateLimitKey { keys = append(keys, antigravityGeminiModelRateLimitKey) } return keys } +func isAntigravityGeminiImageModel(model string) bool { + normalized := normalizeAntigravityModelName(model) + return strings.HasPrefix(normalized, "gemini-") && strings.Contains(normalized, "-image") +} + func (a *Account) modelRateLimitResetAt(scope string) *time.Time { if a == nil || a.Extra == nil || scope == "" { return nil diff --git a/backend/internal/service/model_rate_limit_test.go b/backend/internal/service/model_rate_limit_test.go index 430d91baf67e..9ac283c12aa0 100644 --- a/backend/internal/service/model_rate_limit_test.go +++ b/backend/internal/service/model_rate_limit_test.go @@ -255,6 +255,31 @@ func TestIsModelRateLimited_OpenAIImageGenerationIntentBlocksTextModelImageTool( require.True(t, account.isModelRateLimitedWithContext(WithOpenAIImageGenerationIntent(context.Background()), "gpt-5.4")) } +func TestIsModelRateLimited_AntigravityImageDoesNotInheritGeminiFamilyLimit(t *testing.T) { + future := time.Now().Add(10 * time.Minute).Format(time.RFC3339) + account := &Account{ + Platform: PlatformAntigravity, + Extra: map[string]any{ + modelRateLimitsKey: map[string]any{ + antigravityGeminiModelRateLimitKey: map[string]any{ + "rate_limit_reset_at": future, + }, + }, + }, + } + + for _, model := range []string{"gemini-3.1-flash-image", "gemini-3.1-flash-image-preview", "gemini-3-pro-image"} { + require.False(t, account.isModelRateLimitedWithContext(context.Background(), model), model) + } + require.True(t, account.isModelRateLimitedWithContext(context.Background(), "gemini-3-flash")) + rawLimits, ok := account.Extra[modelRateLimitsKey].(map[string]any) + require.True(t, ok) + rawLimits["gemini-3.1-flash-image"] = map[string]any{ + "rate_limit_reset_at": future, + } + require.True(t, account.isModelRateLimitedWithContext(context.Background(), "gemini-3-pro-image-preview")) +} + func TestIsModelRateLimited_Antigravity_ThinkingAffectsModelKey(t *testing.T) { now := time.Now() future := now.Add(10 * time.Minute).Format(time.RFC3339) diff --git a/backend/internal/service/openai_account_runtime_block_fastpath.go b/backend/internal/service/openai_account_runtime_block_fastpath.go index 795542162877..c3040ebf9ca2 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath.go @@ -495,6 +495,21 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont } return true } + // An explicit model-scoped 401 rule also applies to count/probe entrypoints + // outside the ordinary HTTP marker. Evaluate it before their legacy auth + // fast path; absent/account-scoped rules retain that existing auth behavior. + if s != nil && s.rateLimitService != nil && statusCode == http.StatusUnauthorized && + !IsOpenAIOfficialHTTPFailover(ctx, account) && len(canonicalModel) > 0 && + strings.TrimSpace(canonicalModel[0]) != "" && tempUnschedRulesApply(account, statusCode, responseBody, canonicalModel[0]) { + stateCtx, cancel := openAIAccountStateContext(ctx) + defer cancel() + if s.rateLimitService.HandleTempUnschedulable(stateCtx, account, statusCode, responseBody, canonicalModel[0]) { + if isOpenAIAccount(account) && account.Type == AccountTypeAPIKey { + s.recordOpenAILastUpstreamError(ctx, account, statusCode, responseBody) + } + return true + } + } if isOpenAIAccount(account) && account.Type == AccountTypeAPIKey && !IsOpenAIOfficialHTTPFailover(ctx, account) { switch statusCode { case http.StatusUnauthorized, http.StatusForbidden, http.StatusTooManyRequests: @@ -550,7 +565,8 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont // Isolate a custom temporary-unschedulable match to the known upstream // model before entering the generic account error path. This keeps the // account available to other models and avoids the account runtime blocker. - if s.rateLimitService != nil && statusCode != http.StatusUnauthorized && len(canonicalModel) > 0 && strings.TrimSpace(canonicalModel[0]) != "" && + if s.rateLimitService != nil && len(canonicalModel) > 0 && strings.TrimSpace(canonicalModel[0]) != "" && + tempUnschedRulesApply(account, statusCode, responseBody, canonicalModel[0]) && s.rateLimitService.HandleTempUnschedulable(stateCtx, account, statusCode, responseBody, canonicalModel[0]) { return true } @@ -569,7 +585,8 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont } beforeGeneration, _ := s.openaiAccountRuntimeBlockGeneration.Load(account.ID) shouldDisable := s.rateLimitService.HandleUpstreamError(stateCtx, account, statusCode, headers, responseBody) - modelTempMatched := statusCode != http.StatusUnauthorized && tempUnschedulableModel(stateCtx, nil) != "" && + tempModel := tempUnschedulableModel(stateCtx, nil) + modelTempMatched := tempModel != "" && tempUnschedRulesApply(account, statusCode, responseBody, tempModel) && len(matchTempUnschedulableRules(account, statusCode, responseBody)) > 0 if shouldDisable && !modelTempMatched { // A finite DB-cooldown notification already supplies the bridge for this diff --git a/backend/internal/service/openai_alpha_search_billing_test.go b/backend/internal/service/openai_alpha_search_billing_test.go index b8c6236d9dba..3674efba09f6 100644 --- a/backend/internal/service/openai_alpha_search_billing_test.go +++ b/backend/internal/service/openai_alpha_search_billing_test.go @@ -51,7 +51,7 @@ func TestCalculateOpenAIRecordUsageCostWebSearchPerCall(t *testing.T) { // 即使 token 倍率(含高峰,3.0)更高也不采用。 apiKey := &APIKey{ID: 1, GroupID: &groupID, Group: &Group{ID: groupID, Platform: PlatformOpenAI}} result := &OpenAIForwardResult{Model: "gpt-5.6-sol", UpstreamModel: "gpt-5.6-sol", WebSearchCalls: 1} - cost, err := svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 3.0, 1.0, 1.0, 2.0, UsageTokens{}, "", boolPtr(false), time.Time{}) + cost, err := svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 3.0, 1.0, 1.0, 2.0, UsageTokens{}, "", boolPtr(false), false, time.Time{}) require.NoError(t, err) require.Equal(t, string(BillingModePerRequest), cost.BillingMode) require.InDelta(t, 0.01, cost.TotalCost, 1e-12) @@ -59,7 +59,7 @@ func TestCalculateOpenAIRecordUsageCostWebSearchPerCall(t *testing.T) { // 分组配置单价 0.005 apiKey.Group.WebSearchPricePerCall = float64Ptr(0.005) - cost, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{}, "", boolPtr(false), time.Time{}) + cost, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{}, "", boolPtr(false), false, time.Time{}) require.NoError(t, err) require.InDelta(t, 0.005, cost.TotalCost, 1e-12) require.InDelta(t, 0.005, cost.ActualCost, 1e-12) @@ -67,7 +67,7 @@ func TestCalculateOpenAIRecordUsageCostWebSearchPerCall(t *testing.T) { // WebSearchCalls = 0 时不得走按次分支(无定价数据会返回 pricing 错误, // 证明回落到了 token 路径而不是被按次分支吞掉)。 result.WebSearchCalls = 0 - _, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{InputTokens: 10}, "", boolPtr(false), time.Time{}) + _, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{InputTokens: 10}, "", boolPtr(false), false, time.Time{}) require.Error(t, err) } diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index c1288e35ad80..d392e472b883 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -470,10 +470,6 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr } if isClaudeCodexModel(modelID) { - if claude.IsOpus55(modelID) || claude.IsSonnet55(modelID) { - descriptor.ContextWindow = 1_000_000 - descriptor.MaxContextWindow = 1_000_000 - } descriptor.DisplayName = claudeCodexDisplayName(modelID) descriptor.Description = "Claude coding and reasoning model routed through Sub2API." descriptor.SupportsParallelToolCalls = true diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 3352ff0752e0..773eca79128a 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -324,8 +324,8 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(claudeOpus5.SupportedReasoningLevels)) claudeSonnet55 := newConfiguredCodexModelDescriptor("anthropic/claude-sonnet-5-5") require.Equal(t, "Claude Sonnet 5.5", claudeSonnet55.DisplayName) - require.Equal(t, int64(1_000_000), claudeSonnet55.ContextWindow) - require.Equal(t, int64(1_000_000), claudeSonnet55.MaxContextWindow) + require.Zero(t, claudeSonnet55.ContextWindow) + require.Zero(t, claudeSonnet55.MaxContextWindow) require.Equal(t, "high", *claudeSonnet55.DefaultReasoningLevel) require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(claudeSonnet55.SupportedReasoningLevels)) @@ -339,6 +339,16 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t require.Equal(t, "none", *claudeHaiku.DefaultReasoningLevel) require.Equal(t, []string{"none"}, effortsFromConfiguredCodexLevels(claudeHaiku.SupportedReasoningLevels)) + claudeHaiku55 := newConfiguredCodexModelDescriptor("claude-haiku-5-5") + require.Equal(t, "Claude Haiku 5.5", claudeHaiku55.DisplayName) + // The provider's official API capacity is resolved from the evidence catalog, + // rather than written as an offline descriptor default. + require.Zero(t, claudeHaiku55.ContextWindow) + require.Zero(t, claudeHaiku55.MaxContextWindow) + require.NotNil(t, claudeHaiku55.DefaultReasoningLevel) + require.Equal(t, "medium", *claudeHaiku55.DefaultReasoningLevel) + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(claudeHaiku55.SupportedReasoningLevels)) + gpt56 := newConfiguredCodexModelDescriptor("gpt-5.6-sol") require.Equal(t, "GPT-5.6 Sol", gpt56.DisplayName) require.Equal(t, "OpenAI GPT coding model routed through Sub2API.", gpt56.Description) @@ -2343,7 +2353,7 @@ func TestBuildOpenAIModelsCacheKeyKeepsCapacityOverridesLocal(t *testing.T) { } func TestFetchCodexModelsManifestOAuthPreservesResponsesLite(t *testing.T) { - const manifestBody = ` {"models":[{"slug":"gpt-5.6-sol","use_responses_lite":true}]} ` + const manifestBody = ` {"models":[{"slug":"gpt-5.6-sol","use_responses_lite":true},{"slug":"gpt-6.1-sol","context_window":500000,"max_context_window":1000000,"use_responses_lite":true}]} ` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(manifestBody)) })) @@ -4280,3 +4290,29 @@ func TestGPT61SolAPIKeyCatalogUsesFullResponses(t *testing.T) { require.Equal(t, false, catalog.Models[0]["use_responses_lite"]) require.Equal(t, "low", catalog.Models[0]["default_reasoning_level"]) } + +func TestGPT61SolAPIKeyCatalogContextWindow(t *testing.T) { + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://api.openai.com"}} + for _, tc := range []struct { + name string + body string + want int64 + }{ + {"missing remains unknown until capacity resolution", `{"models":[{"slug":"gpt-6.1-sol"}]}`, 0}, + {"null remains unknown until capacity resolution", `{"models":[{"slug":"gpt-6.1-sol","context_window":null}]}`, 0}, + {"lower upstream window is preserved", `{"models":[{"slug":"gpt-6.1-sol","context_window":272000}]}`, 272_000}, + {"higher upstream window is preserved", `{"models":[{"slug":"gpt-6.1-sol","context_window":1000000,"max_context_window":1000000}]}`, 1_000_000}, + } { + t.Run(tc.name, func(t *testing.T) { + body, err := completeAPIKeyCodexModelsManifestMetadata([]byte(tc.body), true, account) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + if tc.want == 0 { + require.Nil(t, models[0]["context_window"]) + } else { + require.EqualValues(t, tc.want, models[0]["context_window"]) + } + }) + } +} diff --git a/backend/internal/service/openai_gateway_cc_pipeline.go b/backend/internal/service/openai_gateway_cc_pipeline.go index 533d67190a55..453dc46d1bac 100644 --- a/backend/internal/service/openai_gateway_cc_pipeline.go +++ b/backend/internal/service/openai_gateway_cc_pipeline.go @@ -70,11 +70,23 @@ type rewoundOpenAIUpstreamErrorBody struct { func (b *rewoundOpenAIUpstreamErrorBody) Close() error { return nil } -// readOpenAIUpstreamError keeps the exact wire bytes available to the account -// error classifiers while every other inspector and downstream reader sees the -// body with the client's own instructions echo. +// readOpenAIUpstreamError restores only the client's prompt echo for display; +// account classifiers retain the original wire body through the rewound reader. func (s *OpenAIGatewayService) readOpenAIUpstreamError(resp *http.Response, c *gin.Context) ([]byte, string) { - rawBody := s.readUpstreamErrorBody(resp) + respBody, upstreamMsg, _ := s.readOpenAIUpstreamErrorWithCompleteness(resp, c) + return respBody, upstreamMsg +} + +func (s *OpenAIGatewayService) readOpenAIUpstreamErrorWithCompleteness(resp *http.Response, c *gin.Context) ([]byte, string, bool) { + limit := openAIUpstreamErrorBodyReadLimitForConfig(nil) + if s != nil { + limit = openAIUpstreamErrorBodyReadLimitForConfig(s.cfg) + } + rawBody, readErr := io.ReadAll(io.LimitReader(resp.Body, limit+1)) + complete := readErr == nil && int64(len(rawBody)) <= limit + if int64(len(rawBody)) > limit { + rawBody = rawBody[:limit] + } respBody := restoreSystemPromptEcho(c, rawBody) _ = resp.Body.Close() resp.Body = &rewoundOpenAIUpstreamErrorBody{ @@ -84,7 +96,7 @@ func (s *OpenAIGatewayService) readOpenAIUpstreamError(resp *http.Response, c *g upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - return respBody, upstreamMsg + return respBody, upstreamMsg, complete } // failoverOpenAIUpstreamHTTPError 对 >=400 的上游响应做 failover 判定:命中时 diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 667f7e81dbcd..9f840f561168 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -7,6 +7,8 @@ import ( "errors" "fmt" "net/http" + "regexp" + "strconv" "strings" "sync/atomic" "time" @@ -402,7 +404,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( _ = resp.Body.Close() } if resp.StatusCode >= 400 { - respBody, upstreamMsg := s.readOpenAIUpstreamError(resp, c) + respBody, upstreamMsg, completeErrorBody := s.readOpenAIUpstreamErrorWithCompleteness(resp, c) defer closeUpstreamResponse() if !agentIdentityTaskRecoveryWasTried(ctx) && s.isAgentIdentityAccount(ctx, account) && isAgentIdentityTaskInvalidHTTPResponse(resp.StatusCode, respBody) { @@ -420,7 +422,24 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( zap.Int("upstream_status", resp.StatusCode), zap.String("upstream_message", upstreamMsg), ) - return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) + return s.forwardAsRawChatCompletionsWithStartTime(ctx, c, account, body, defaultMappedModel, startTime) + } + responsesMode, _ := account.Extra[openai_compat.ExtraKeyResponsesMode].(string) + if account.IsOpenAIApiKey() && + openai_compat.NormalizeResponsesSupportMode(responsesMode) == openai_compat.ResponsesSupportModeAuto && + openai_compat.ResolveResponsesSupport(account.Extra) == openai_compat.ResponsesSupportYes && + !isResponsesShape && + (ctx == nil || ctx.Err() == nil) && + (c.Request == nil || c.Request.Context().Err() == nil) && + !IsResponseCommitted(c) && + !c.Writer.Written() && + completeErrorBody && + isConvertedResponsesInputStringRejection(resp.StatusCode, respBody) { + logger.L().Info("openai chat_completions: structured Responses input rejected, falling back to raw chat completions", + zap.Int64("account_id", account.ID), + zap.Int("upstream_status", resp.StatusCode), + ) + return s.forwardAsRawChatCompletionsWithStartTime(ctx, c, account, body, defaultMappedModel, startTime) } if foErr := s.failoverOpenAIUpstreamHTTPError(ctx, c, account, resp, respBody, upstreamModel); foErr != nil { return nil, foErr @@ -468,6 +487,155 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( return result, handleErr } +var responsesInputStringValidationError = regexp.MustCompile(`^['"]loc['"]\s*:\s*\(\s*['"]body['"]\s*,\s*['"]input['"]\s*,\s*['"]str['"]\s*\)$`) +var responsesStringValidationMessage = regexp.MustCompile(`^['"]msg['"]\s*:\s*['"]Input should be a valid string['"]$`) +var responsesStringValidationType = regexp.MustCompile(`^['"]type['"]\s*:\s*['"]string_type['"]$`) +var responsesValidationErrors = regexp.MustCompile(`(?s)^([1-9][0-9]*) validation errors?(?: for [^\r\n:]+)?:\s*(\[.*\])$`) +var responsesInvalidInputTypeMessage = regexp.MustCompile(`(?i)^(?:Invalid type for ['"]input['"]:\s*)?Expected a string(?: for input)?, but got an (?:array|object) instead\.?$`) + +func isResponsesInputStringValidationMessage(message string) bool { + match := responsesValidationErrors.FindStringSubmatch(strings.TrimSpace(message)) + if match == nil { + return false + } + entries, ok := splitResponsesValidationFields(match[2][1 : len(match[2])-1]) + count, err := strconv.Atoi(match[1]) + if !ok || err != nil || count != len(entries) { + return false + } + matched := false + for _, entry := range entries { + if !strings.HasPrefix(entry, "{") || !strings.HasSuffix(entry, "}") { + return false + } + fields, ok := splitResponsesValidationFields(entry[1 : len(entry)-1]) + if !ok { + return false + } + location, message, stringType := false, false, false + keys := make(map[string]bool, len(fields)) + for _, field := range fields { + key, value, found := strings.Cut(field, ":") + key = strings.TrimSpace(key) + if !found || len(key) < 3 || (key[0] != '\'' && key[0] != '"') || key[len(key)-1] != key[0] || strings.TrimSpace(value) == "" || keys[key[1:len(key)-1]] { + return false + } + keys[key[1:len(key)-1]] = true + location = location || responsesInputStringValidationError.MatchString(field) + message = message || responsesStringValidationMessage.MatchString(field) + stringType = stringType || responsesStringValidationType.MatchString(field) + } + matched = matched || (location && message && stringType) + } + return matched +} + +func splitResponsesValidationFields(value string) ([]string, bool) { + var fields []string + var brackets []byte + var quote byte + escaped := false + start := 0 + for index := 0; index < len(value); index++ { + character := value[index] + if quote != 0 { + if escaped { + escaped = false + } else if character == '\\' { + escaped = true + } else if character == quote { + quote = 0 + } + continue + } + switch character { + case '\'', '"': + quote = character + case '{', '[', '(': + brackets = append(brackets, character) + case '}', ']', ')': + if len(brackets) == 0 { + return nil, false + } + opening := brackets[len(brackets)-1] + if (character == '}' && opening != '{') || (character == ']' && opening != '[') || (character == ')' && opening != '(') { + return nil, false + } + brackets = brackets[:len(brackets)-1] + case ',': + if len(brackets) == 0 { + field := strings.TrimSpace(value[start:index]) + if field == "" { + return nil, false + } + fields = append(fields, field) + start = index + 1 + } + } + } + if quote != 0 || len(brackets) != 0 { + return nil, false + } + if field := strings.TrimSpace(value[start:]); field != "" { + fields = append(fields, field) + } + return fields, len(fields) > 0 +} + +func isConvertedResponsesInputStringRejection(status int, responseBody []byte) bool { + if status != http.StatusBadRequest || !json.Valid(responseBody) { + return false + } + envelope := gjson.ParseBytes(responseBody) + for _, object := range []gjson.Result{envelope, envelope.Get("error")} { + if !object.IsObject() { + return false + } + keys := make(map[string]bool) + unambiguous := true + object.ForEach(func(key, value gjson.Result) bool { + name := key.String() + if keys[name] { + unambiguous = false + return false + } + keys[name] = true + switch name { + case "usage", "output", "output_text", "choices", "tool_calls", "response", "status", "incomplete_details": + unambiguous = false + case "code": + code := strings.ToLower(strings.TrimSpace(value.String())) + unambiguous = code == "" || code == "invalid_type" || code == "invalid_request_error" + case "param": + param := strings.ToLower(strings.TrimSpace(value.String())) + unambiguous = param == "" || param == "input" + case "type": + errorType := strings.ToLower(strings.TrimSpace(value.String())) + unambiguous = errorType == "" || errorType == "invalid_type" || errorType == "invalid_request_error" + } + return unambiguous + }) + if !unambiguous { + return false + } + } + message := gjson.GetBytes(responseBody, "error.message").String() + if isResponsesInputStringValidationMessage(message) { + return true + } + code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(responseBody, "error.code").String())) + typ := strings.ToLower(strings.TrimSpace(gjson.GetBytes(responseBody, "error.type").String())) + if code != "invalid_type" && typ != "invalid_type" { + return false + } + param := strings.ToLower(strings.TrimSpace(gjson.GetBytes(responseBody, "error.param").String())) + if param != "input" { + return false + } + message = strings.ToLower(strings.TrimSpace(message)) + return responsesInvalidInputTypeMessage.MatchString(message) +} + func normalizeResponsesRequestServiceTier(req *apicompat.ResponsesRequest) { if req == nil { return diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index c56885754299..31d2c4a5cfa0 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -61,7 +61,17 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( body []byte, defaultMappedModel string, ) (*OpenAIForwardResult, error) { - startTime := time.Now() + return s.forwardAsRawChatCompletionsWithStartTime(ctx, c, account, body, defaultMappedModel, time.Now()) +} + +func (s *OpenAIGatewayService) forwardAsRawChatCompletionsWithStartTime( + ctx context.Context, + c *gin.Context, + account *Account, + body []byte, + defaultMappedModel string, + startTime time.Time, +) (*OpenAIForwardResult, error) { if account != nil && account.IsOpenAI() { requestedModel := gjson.GetBytes(body, "model").String() mappedModel := resolveOpenAIForwardModel(account, requestedModel, defaultMappedModel) diff --git a/backend/internal/service/openai_gateway_chat_completions_test.go b/backend/internal/service/openai_gateway_chat_completions_test.go index 629662c14390..9f8c7d98c19c 100644 --- a/backend/internal/service/openai_gateway_chat_completions_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_test.go @@ -3,7 +3,9 @@ package service import ( "bytes" "context" + "encoding/json" "errors" + "fmt" "io" "net/http" "net/http/httptest" @@ -13,6 +15,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" @@ -32,6 +35,555 @@ func (w *openAIChatFailingWriter) Write(p []byte) (int, error) { return w.ResponseWriter.Write(p) } +func TestForwardAsChatCompletions_ResponsesSupportedFallsBackWhenStructuredInputRequiresString(t *testing.T) { + for name, rejection := range map[string]string{ + "OpenAI invalid type": `{"error":{"type":"invalid_request_error","code":"invalid_type","param":"input","message":"Invalid type for 'input': expected a string, but got an array instead."}}`, + "vLLM validation": `{"error":{"message":"1 validation error: [{'type':'string_type', 'loc':('body','input','str'), 'msg':'Input should be a valid string', 'input':[{'role':'user','content':'hi'}, {'role':'assistant','content':[{'type':'output_text'}]}, ResponseFunctionToolCall(type='function_call'), {'type':'function_call_output'}]}]"}}`, + } { + t.Run(name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"},{"role":"assistant","content":[{"type":"text","text":"checking"},{"type":"thinking","thinking":"think"}],"tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{}"}}]},{"role":"tool","tool_call_id":"call_1","content":"done"},{"role":"user","content":"what now"}],"stream":false}`) + originalBody := append([]byte(nil), body...) + logs, releaseLogs := captureStructuredLog(t) + defer releaseLogs() + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + { + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + rejection, + )), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_raw_fallback"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"chatcmpl_fallback","object":"chat.completion","model":"gpt-5.4","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`, + )), + }, + }} + svc := &OpenAIGatewayService{ + cfg: structuredInputRetryTestConfig(), + httpUpstream: upstream, + } + account := structuredInputRetryTestAccount() + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.requests, 2) + require.Equal(t, "/v1/responses", upstream.requests[0].URL.Path) + require.True(t, gjson.GetBytes(upstream.bodies[0], "input").IsArray()) + require.Equal(t, "/v1/chat/completions", upstream.requests[1].URL.Path) + require.JSONEq(t, string(body), string(upstream.bodies[1])) + require.Equal(t, originalBody, body) + require.Equal(t, "/v1/chat/completions", result.UpstreamEndpoint) + require.Equal(t, "/v1/chat/completions", GetActualOpenAIUpstreamEndpoint(c)) + require.Equal(t, "ok", gjson.Get(rec.Body.String(), "choices.0.message.content").String()) + require.True(t, logs.ContainsMessage("structured Responses input rejected")) + logs.mu.Lock() + defer logs.mu.Unlock() + for _, event := range logs.events { + require.NotContains(t, event.Fields, "upstream_message") + require.NotContains(t, event.Fields, "upstream_body") + require.NotContains(t, event.Message, rejection) + } + }) + } +} + +type openAIChatFallbackBoundaryReader struct { + io.Reader + beforeRead func() +} + +func (reader *openAIChatFallbackBoundaryReader) Read(buffer []byte) (int, error) { + if reader.beforeRead != nil { + reader.beforeRead() + reader.beforeRead = nil + } + return reader.Reader.Read(buffer) +} + +func TestForwardAsChatCompletions_StructuredInputRetrySafetyBoundaries(t *testing.T) { + rejection := `{"error":{"code":"invalid_type","param":"input","message":"Expected a string, but got an array instead."}}` + validation := `"message":"1 validation error: [{'type':'string_type','loc':('body','input','str'),'msg':'Input should be a valid string'}]"` + for _, test := range []struct { + name string + body string + }{ + {name: "duplicate error", body: strings.TrimSuffix(rejection, "}") + `,"error":{"code":"rate_limit_exceeded"}}`}, + {name: "duplicate code", body: strings.Replace(rejection, `"code":"invalid_type"`, `"code":"invalid_type","code":"rate_limit_exceeded"`, 1)}, + {name: "duplicate escaped param", body: strings.Replace(rejection, `"param":"input"`, `"param":"input","p\u0061ram":"tools"`, 1)}, + {name: "duplicate message", body: strings.Replace(rejection, `"message":"Expected a string, but got an array instead."`, `"message":"Expected a string, but got an array instead.","message":"Input is too long."`, 1)}, + {name: "contradictory error type", body: strings.Replace(rejection, `"error":{`, `"error":{"type":"authentication_error",`, 1)}, + {name: "contradictory envelope type", body: strings.TrimSuffix(rejection, "}") + `,"type":"rate_limit_error"}`}, + {name: "validation contradictory type", body: `{"error":{"type":"rate_limit_error",` + validation + `}}`}, + {name: "validation contradictory envelope type", body: `{"type":"authentication_error","error":{` + validation + `}}`}, + {name: "validation contradictory code", body: `{"error":{"code":"rate_limit_exceeded",` + validation + `}}`}, + {name: "validation contradictory param", body: `{"error":{"param":"tools",` + validation + `}}`}, + {name: "contradictory envelope code", body: strings.TrimSuffix(rejection, "}") + `,"code":"rate_limit_exceeded"}`}, + {name: "contradictory envelope param", body: strings.TrimSuffix(rejection, "}") + `,"param":"tools"}`}, + {name: "type cannot override code", body: strings.Replace(rejection, `"code":"invalid_type"`, `"code":"rate_limit_exceeded","type":"invalid_type"`, 1)}, + {name: "usage", body: strings.TrimSuffix(rejection, "}") + `,"usage":{"input_tokens":12,"output_tokens":3}}`}, + {name: "error usage", body: strings.Replace(rejection, `"error":{`, `"error":{"usage":{"input_tokens":12},`, 1)}, + {name: "output", body: strings.TrimSuffix(rejection, "}") + `,"output":[{"type":"message","content":[{"type":"output_text","text":"partial"}]}]}`}, + {name: "output text", body: strings.TrimSuffix(rejection, "}") + `,"output_text":"partial"}`}, + {name: "tool calls", body: strings.TrimSuffix(rejection, "}") + `,"tool_calls":[{"id":"call_partial","type":"function","function":{"name":"lookup","arguments":"{}"}}]}`}, + {name: "choices", body: strings.TrimSuffix(rejection, "}") + `,"choices":[{"delta":{"content":"partial"}}]}`}, + {name: "nested response", body: strings.TrimSuffix(rejection, "}") + `,"response":{"status":"incomplete","usage":{"input_tokens":12},"output":[]}}`}, + {name: "partial status", body: strings.TrimSuffix(rejection, "}") + `,"status":"in_progress"}`}, + {name: "incomplete details", body: strings.TrimSuffix(rejection, "}") + `,"incomplete_details":{"reason":"max_output_tokens"}}`}, + {name: "partial event", body: strings.TrimSuffix(rejection, "}") + `,"type":"response.output_text.delta","delta":"partial"}`}, + {name: "caller canceled"}, + {name: "request canceled"}, + {name: "response committed"}, + {name: "writer committed"}, + } { + t.Run(test.name, func(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + requestCtx, cancelRequest := context.WithCancel(context.Background()) + defer cancelRequest() + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)).WithContext(requestCtx) + errorBody := test.body + if errorBody == "" { + errorBody = rejection + } + reader := &openAIChatFallbackBoundaryReader{Reader: strings.NewReader(errorBody), beforeRead: func() { + switch test.name { + case "caller canceled": + cancel() + case "request canceled": + cancelRequest() + case "response committed": + MarkResponseCommitted(c) + case "writer committed": + c.Writer.WriteHeaderNow() + } + }} + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(reader), + }} + svc := &OpenAIGatewayService{cfg: structuredInputRetryTestConfig(), httpUpstream: upstream} + result, err := svc.ForwardAsChatCompletions(ctx, c, structuredInputRetryTestAccount(), body, "", "") + require.Error(t, err) + require.Nil(t, result) + require.Len(t, upstream.requests, 1) + require.Equal(t, "/v1/responses", upstream.requests[0].URL.Path) + }) + } +} + +func TestConvertedResponsesInputStringRejectionIsNarrow(t *testing.T) { + matching := []byte(`{"error":{"code":"invalid_type","param":"input","message":"Expected a string for input, but got an object instead."}}`) + require.True(t, isConvertedResponsesInputStringRejection(http.StatusBadRequest, matching)) + require.True(t, isConvertedResponsesInputStringRejection(http.StatusBadRequest, []byte(`{"error":{"type":"invalid_type","param":"input","message":"Invalid type for 'input': expected a string, but got an array instead."}}`))) + + for _, tc := range []struct { + name string + status int + body string + }{ + {name: "nested content validation", status: http.StatusBadRequest, body: `{"error":{"message":"'loc':('body','input',0,'content','str'), 'msg':'Input should be a valid string'"}}`}, + {name: "other field validation", status: http.StatusBadRequest, body: `{"error":{"message":"'loc':('body','model','str'), 'msg':'Input should be a valid string'"}}`}, + {name: "generic string validation", status: http.StatusBadRequest, body: `{"error":{"message":"Input should be a valid string"}}`}, + {name: "wrong status", status: http.StatusUnprocessableEntity, body: string(matching)}, + {name: "authentication", status: http.StatusUnauthorized, body: string(matching)}, + {name: "authorization", status: http.StatusForbidden, body: string(matching)}, + {name: "rate limit", status: http.StatusTooManyRequests, body: string(matching)}, + {name: "server error", status: http.StatusInternalServerError, body: string(matching)}, + {name: "wrong parameter", status: http.StatusBadRequest, body: `{"error":{"code":"invalid_type","param":"tools","message":"Expected a string, but got an array instead."}}`}, + {name: "wrong code", status: http.StatusBadRequest, body: `{"error":{"code":"invalid_request_error","param":"input","message":"Expected a string, but got an array instead."}}`}, + {name: "unrelated input error", status: http.StatusBadRequest, body: `{"error":{"code":"invalid_type","param":"input","message":"Input is too long."}}`}, + } { + t.Run(tc.name, func(t *testing.T) { + require.False(t, isConvertedResponsesInputStringRejection(tc.status, []byte(tc.body))) + }) + } +} + +func TestConvertedResponsesInputStringRejectionValidationBoundaries(t *testing.T) { + validation := `{'type': 'string_type', 'loc': ('body', 'input', 'str'), 'msg': 'Input should be a valid string', 'input': [{'role': 'user', 'content': 'hello'}]}` + for _, test := range []struct { + name string + message string + want bool + }{ + {name: "complete Pydantic error", message: "1 validation error: [" + validation + "]", want: true}, + {name: "multiple Pydantic errors", message: "2 validation errors for Request: [" + strings.Replace(validation, "'input', 'str'", "'model', 'str'", 1) + ", " + validation + "]", want: true}, + {name: "double quoted keys", message: "1 validation error: [" + strings.ReplaceAll(validation, "'", `"`) + "]", want: true}, + {name: "surrounding whitespace", message: " \n1 validation error: [" + validation + "]\n ", want: true}, + {name: "escaped quote in history", message: `1 validation error: [{'type':'string_type','loc':('body','input','str'),'msg':'Input should be a valid string','input':[{'content':'can\'t [close] this }'}]}]`, want: true}, + {name: "bare loc and msg", message: `'loc':('body','input','str'), 'msg':'Input should be a valid string'`}, + {name: "echoed dictionary in unrelated error", message: "Invalid history: " + validation}, + {name: "echoed fake loc and msg in input", message: `1 validation error: [{'type':'string_type','loc':('body','model','str'),'msg':'Input should be a valid string','input':{'loc':('body','input','str'),'msg':'Input should be a valid string'}}]`}, + {name: "echoed fake loc and msg in quoted history", message: `1 validation error: [{'type':'string_type','loc':('body','input',0,'content','str'),'msg':'Input should be a valid string','input':"'loc':('body','input','str'), 'msg':'Input should be a valid string'"}]`}, + {name: "echoed complete error in quoted history", message: `1 validation error: [{'type':'string_type','loc':('body','model','str'),'msg':'Input should be a valid string','input':"1 validation error: [{'type':'string_type','loc':('body','input','str'),'msg':'Input should be a valid string'}]"}]`}, + {name: "truncated error list", message: "1 validation error: [" + validation}, + {name: "truncated quoted input", message: `1 validation error: [{'type':'string_type','loc':('body','input','str'),'msg':'Input should be a valid string','input':'history}]`}, + {name: "mismatched closing bracket", message: "1 validation error: [" + strings.TrimSuffix(validation, "}") + ")]"}, + {name: "elided validation list", message: "240 validation errors: ... " + validation}, + {name: "incorrect error count", message: "2 validation errors: [" + validation + "]"}, + {name: "overflowed error count", message: "999999999999999999999999 validation errors: [" + validation + "]"}, + {name: "duplicate location", message: "1 validation error: [" + strings.Replace(validation, "'input':", "'loc': ('body', 'model', 'str'), 'input':", 1) + "]"}, + {name: "wrong validation type", message: "1 validation error: [" + strings.Replace(validation, "string_type", "value_error", 1) + "]"}, + {name: "missing validation type", message: "1 validation error: [" + strings.Replace(validation, "'type': 'string_type', ", "", 1) + "]"}, + {name: "location and message in different errors", message: "2 validation errors: [" + strings.Replace(validation, "Input should be a valid string", "Invalid history", 1) + ", " + strings.Replace(validation, "'input', 'str'", "'model', 'str'", 1) + "]"}, + {name: "trailing echoed text", message: "1 validation error: [" + validation + "] echoed history"}, + {name: "structured string array", message: "Expected a string, but got an array instead.", want: true}, + {name: "structured object", message: "Invalid type for 'input': expected a string, but got an object instead.", want: true}, + {name: "structured echoed history", message: "Invalid history containing 'expected a string' and 'got an array'"}, + {name: "structured truncated message", message: "Expected a string, but got an array"}, + } { + t.Run(test.name, func(t *testing.T) { + body, err := json.Marshal(map[string]any{"error": map[string]string{"message": test.message, "code": "invalid_type", "param": "input"}}) + require.NoError(t, err) + require.Equal(t, test.want, isConvertedResponsesInputStringRejection(http.StatusBadRequest, body)) + if test.want { + require.False(t, isConvertedResponsesInputStringRejection(http.StatusBadRequest, body[:len(body)-1])) + require.False(t, isConvertedResponsesInputStringRejection(http.StatusBadRequest, append(body, []byte(" trailing")...))) + } + }) + } +} + +func TestForwardAsChatCompletions_StructuredInputRetryRequiresCompleteErrorBody(t *testing.T) { + rejection := `{"error":{"code":"invalid_type","param":"input","message":"Expected a string, but got an array instead."}}` + limit := int(openAIUpstreamErrorBodyReadLimitForConfig(structuredInputRetryTestConfig())) + for _, test := range []struct { + name string + payload string + readErr error + unknown bool + want bool + }{ + {name: "complete JSON followed by read failure", payload: rejection, readErr: io.ErrUnexpectedEOF}, + {name: "truncated JSON", payload: rejection[:len(rejection)-1]}, + {name: "exact read limit", payload: rejection + strings.Repeat(" ", limit-len(rejection)), want: true}, + {name: "one byte beyond read limit", payload: rejection + strings.Repeat(" ", limit-len(rejection)+1)}, + {name: "valid JSON prefix before discarded suffix", payload: rejection + strings.Repeat(" ", limit-len(rejection)) + "trailing"}, + {name: "unknown capability still retries after read failure", payload: rejection, readErr: io.ErrUnexpectedEOF, unknown: true, want: true}, + {name: "unknown capability still retries beyond read limit", payload: rejection + strings.Repeat(" ", limit-len(rejection)+1), unknown: true, want: true}, + } { + t.Run(test.name, func(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}]}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + errorBody := io.NopCloser(strings.NewReader(test.payload)) + if test.readErr != nil { + errorBody = &openAIChatStreamReadErrorCloser{payload: []byte(test.payload), err: test.readErr} + } + account := structuredInputRetryTestAccount() + status := http.StatusBadRequest + if test.unknown { + delete(account.Extra, openai_compat.ExtraKeyResponsesSupported) + status = http.StatusNotFound + } + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + {StatusCode: status, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: errorBody}, + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"id":"chatcmpl_retry","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`))}, + }} + svc := &OpenAIGatewayService{cfg: structuredInputRetryTestConfig(), httpUpstream: upstream} + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + if test.want { + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.requests, 2) + } else { + require.Error(t, err) + require.Nil(t, result) + require.Len(t, upstream.requests, 1) + } + }) + } +} + +type openAIChatFallbackDelayReader struct { + io.Reader + elapsed time.Duration +} + +func (reader *openAIChatFallbackDelayReader) Read(buffer []byte) (int, error) { + if reader.elapsed == 0 { + started := time.Now() + time.Sleep(30 * time.Millisecond) + reader.elapsed = time.Since(started) + } + return reader.Reader.Read(buffer) +} + +func TestForwardAsChatCompletions_RawFallbackPreservesResponsesTiming(t *testing.T) { + for _, unknown := range []bool{false, true} { + for _, stream := range []bool{false, true} { + t.Run(fmt.Sprintf("unknown=%t/stream=%t", unknown, stream), func(t *testing.T) { + body := []byte(fmt.Sprintf(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":%t}`, stream)) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + account := structuredInputRetryTestAccount() + status := http.StatusBadRequest + if unknown { + delete(account.Extra, openai_compat.ExtraKeyResponsesSupported) + status = http.StatusNotFound + } + initialBody := &openAIChatFallbackDelayReader{Reader: strings.NewReader(`{"error":{"code":"invalid_type","param":"input","message":"Expected a string, but got an array instead."}}`)} + responseBody := `{"id":"chatcmpl_retry","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}` + contentType := "application/json" + if stream { + responseBody = "data: {\"id\":\"chatcmpl_retry\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: {\"choices\":[],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}\n\ndata: [DONE]\n\n" + contentType = "text/event-stream" + } + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + {StatusCode: status, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(initialBody)}, + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{contentType}}, Body: io.NopCloser(strings.NewReader(responseBody))}, + }} + svc := &OpenAIGatewayService{cfg: structuredInputRetryTestConfig(), httpUpstream: upstream} + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.requests, 2) + require.Equal(t, "/v1/responses", upstream.requests[0].URL.Path) + require.Equal(t, "/v1/chat/completions", upstream.requests[1].URL.Path) + require.GreaterOrEqual(t, result.Duration, initialBody.elapsed) + if stream { + require.NotNil(t, result.FirstTokenMs) + require.GreaterOrEqual(t, *result.FirstTokenMs, int(initialBody.elapsed.Milliseconds())) + } + }) + } + } +} + +func TestForwardAsChatCompletions_StructuredInputRejectionDoesNotRetryIneligibleRequests(t *testing.T) { + rejection := `{"error":{"code":"invalid_type","param":"input","message":"Expected a string, but got an array instead."}}` + for _, test := range []struct { + name string + mode string + unknown bool + platform string + protocol string + endpoint string + accountType string + responsesBody bool + nativeIngress bool + status int + rejection string + }{ + {name: "forced Responses", mode: "force_responses"}, + {name: "forced Responses without probe", mode: "force_responses", unknown: true}, + {name: "unknown support", unknown: true}, + {name: "Responses shaped Chat ingress", responsesBody: true}, + {name: "native Responses ingress", responsesBody: true, nativeIngress: true}, + {name: "native CN Responses protocol", platform: PlatformDeepseek}, + {name: "adaptive Chat ingress stays raw", platform: PlatformKimi, protocol: APIProtocolAdaptive, endpoint: "/chat/completions"}, + {name: "adaptive Responses shaped ingress stays Responses", platform: PlatformKimi, protocol: APIProtocolAdaptive, responsesBody: true}, + {name: "adaptive Responses shaped ingress converts to Chat", platform: PlatformZhipu, protocol: APIProtocolAdaptive, responsesBody: true, endpoint: "/chat/completions"}, + {name: "model routed Responses is not OpenAI retry eligible", platform: PlatformCommandCode, protocol: APIProtocolResponses}, + {name: "OAuth", accountType: AccountTypeOAuth}, + {name: "authentication", status: http.StatusUnauthorized}, + {name: "authorization", status: http.StatusForbidden}, + {name: "rate limit", status: http.StatusTooManyRequests}, + {name: "unrelated validation", rejection: `{"error":{"code":"invalid_type","param":"tools","message":"Expected a string, but got an array instead."}}`}, + {name: "nested validation", rejection: `{"error":{"message":"'loc':('body','input',0,'content','str'), 'msg':'Input should be a valid string'"}}`}, + {name: "echoed validation", rejection: `{"error":{"message":"1 validation error: [{'type':'string_type','loc':('body','model','str'),'msg':'Input should be a valid string','input':{'loc':('body','input','str'),'msg':'Input should be a valid string'}}]"}}`}, + {name: "echoed structured error", rejection: `{"error":{"code":"invalid_type","param":"input","message":"Invalid history containing expected a string but got an array"}}`}, + } { + t.Run(test.name, func(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`) + if test.responsesBody { + body = []byte(`{"model":"gpt-5.4","input":[{"role":"user","content":"hello"}],"stream":false}`) + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + account := structuredInputRetryTestAccount() + account.Extra = map[string]any{openai_compat.ExtraKeyResponsesSupported: true, openai_compat.ExtraKeyResponsesMode: test.mode} + if test.unknown { + delete(account.Extra, openai_compat.ExtraKeyResponsesSupported) + } + if test.platform != "" { + account.Platform = test.platform + account.Credentials["api_protocol"] = APIProtocolResponses + } + if test.protocol != "" { + account.Credentials["api_protocol"] = test.protocol + } + if test.accountType != "" { + account.Type = test.accountType + account.Credentials["access_token"] = "test-token" + } + status := test.status + if status == 0 { + status = http.StatusBadRequest + } + errorBody := rejection + if test.rejection != "" { + errorBody = test.rejection + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(errorBody)), + }} + svc := &OpenAIGatewayService{cfg: structuredInputRetryTestConfig(), httpUpstream: upstream} + var result *OpenAIForwardResult + var err error + if test.nativeIngress { + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + result, err = svc.Forward(context.Background(), c, account, body) + } else { + result, err = svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + } + require.Error(t, err) + require.Nil(t, result) + require.Len(t, upstream.requests, 1) + endpoint := test.endpoint + if endpoint == "" { + endpoint = "/responses" + } + require.True(t, strings.HasSuffix(upstream.requests[0].URL.Path, endpoint)) + var failover *UpstreamFailoverError + if status == http.StatusUnauthorized || status == http.StatusForbidden || status == http.StatusTooManyRequests { + require.ErrorAs(t, err, &failover) + require.Equal(t, status, failover.StatusCode) + } else if test.nativeIngress { + // Downstream native ingress returns request-terminal metadata for + // its handler to render. The error's shared type never permits retry. + require.ErrorAs(t, err, &failover) + require.Equal(t, GatewayFailureScopeRequest, failover.Scope) + require.Equal(t, NextAccountStop, failover.NextAccountAction) + require.False(t, failover.ShouldRetryNextAccount()) + require.False(t, failover.ShouldReportAccountScheduleFailure()) + require.Equal(t, http.StatusBadRequest, failover.ClientStatusCode) + } else { + require.False(t, errors.As(err, &failover)) + require.True(t, c.Writer.Written()) + } + }) + } +} + +func TestForwardAsChatCompletions_StructuredInputRetryPreservesStreamingAndMapping(t *testing.T) { + for _, useAccountMapping := range []bool{false, true} { + t.Run(fmt.Sprintf("account_mapping=%t", useAccountMapping), func(t *testing.T) { + body := []byte(`{"model":"client-model","messages":[{"role":"user","content":"hello"}],"stream":true,"stream_options":{"include_usage":false},"vendor_option":"preserve"}`) + originalBody := append([]byte(nil), body...) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + account := structuredInputRetryTestAccount() + account.Extra = map[string]any{openai_compat.ExtraKeyResponsesSupported: true, openai_compat.ExtraKeyResponsesMode: "auto"} + mappedModel := "gpt-5.4" + if useAccountMapping { + account.Credentials["model_mapping"] = map[string]any{"client-model": mappedModel} + } + stream := "data: {\"id\":\"chatcmpl_retry\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-5.4\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: {\"choices\":[],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":4,\"total_tokens\":13}}\n\ndata: [DONE]\n\n" + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + {StatusCode: http.StatusBadRequest, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"error":{"code":"invalid_type","param":"input","message":"Expected a string, but got an array instead."}}`))}, + {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(stream))}, + }} + svc := &OpenAIGatewayService{cfg: structuredInputRetryTestConfig(), httpUpstream: upstream} + defaultMappedModel := mappedModel + if useAccountMapping { + defaultMappedModel = "" + } + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", defaultMappedModel) + require.NoError(t, err) + require.Len(t, upstream.requests, 2) + for _, outbound := range upstream.bodies { + require.Equal(t, mappedModel, gjson.GetBytes(outbound, "model").String()) + require.True(t, gjson.GetBytes(outbound, "stream").Bool()) + } + require.JSONEq(t, gjson.GetBytes(body, "messages").Raw, gjson.GetBytes(upstream.bodies[1], "messages").Raw) + require.Equal(t, "preserve", gjson.GetBytes(upstream.bodies[1], "vendor_option").String()) + require.True(t, gjson.GetBytes(upstream.bodies[1], "stream_options.include_usage").Bool()) + require.Equal(t, originalBody, body) + require.True(t, result.Stream) + require.Equal(t, mappedModel, result.BillingModel) + require.Equal(t, mappedModel, result.UpstreamModel) + require.Equal(t, 9, result.Usage.InputTokens) + require.Equal(t, 4, result.Usage.OutputTokens) + require.Equal(t, "/v1/chat/completions", result.UpstreamEndpoint) + require.Contains(t, rec.Header().Get("Content-Type"), "text/event-stream") + require.Contains(t, rec.Body.String(), "data: [DONE]") + }) + } +} + +func TestForwardAsChatCompletions_StructuredInputRetryIsBoundedAndPreservesRawErrors(t *testing.T) { + for _, status := range []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusTooManyRequests} { + t.Run(fmt.Sprintf("status=%d", status), func(t *testing.T) { + rejection := `{"error":{"code":"invalid_type","param":"input","message":"Expected a string, but got an array instead."}}` + response := func(status int) *http.Response { + return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(rejection))} + } + body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`) + account := structuredInputRetryTestAccount() + account.Extra = map[string]any{openai_compat.ExtraKeyResponsesSupported: true} + forward := func(retry bool) (*httptest.ResponseRecorder, error) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + upstream := &httpUpstreamRecorder{responses: []*http.Response{response(status)}} + if retry { + upstream.responses = append([]*http.Response{response(http.StatusBadRequest)}, upstream.responses...) + } + svc := &OpenAIGatewayService{cfg: structuredInputRetryTestConfig(), httpUpstream: upstream} + var result *OpenAIForwardResult + var err error + if retry { + result, err = svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + require.Len(t, upstream.requests, 2) + require.Equal(t, "/v1/chat/completions", upstream.requests[1].URL.Path) + } else { + result, err = svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "") + require.Len(t, upstream.requests, 1) + } + require.Nil(t, result) + require.Error(t, err) + return rec, err + } + baseline, baselineErr := forward(false) + retried, retryErr := forward(true) + require.Equal(t, baseline.Code, retried.Code) + require.Equal(t, baseline.Body.String(), retried.Body.String()) + require.Equal(t, baselineErr.Error(), retryErr.Error()) + require.IsType(t, baselineErr, retryErr) + }) + } +} + +func structuredInputRetryTestConfig() *config.Config { + return &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{ + Enabled: false, + AllowInsecureHTTP: true, + }}} +} + +func structuredInputRetryTestAccount() *Account { + return &Account{ + ID: 101, + Name: "raw-openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test", "base_url": "http://upstream.example"}, + Extra: map[string]any{openai_compat.ExtraKeyResponsesSupported: true}, + } +} + type openAIChatStreamReadErrorCloser struct { payload []byte err error @@ -1326,7 +1878,7 @@ func TestGPT61SolOnlyChatFallbackRejectsToolsAndDisabledReasoning(t *testing.T) svc := &OpenAIGatewayService{cfg: &config.Config{}} var err error if responses { - _, err = svc.forwardResponsesViaRawChatCompletions(context.Background(), c, account, []byte(body)) + _, err = svc.forwardResponsesViaRawChatCompletions(context.Background(), c, account, []byte(body), time.Now()) } else { _, err = svc.forwardAnthropicViaRawChatCompletions(context.Background(), c, account, []byte(body), "") } diff --git a/backend/internal/service/openai_gateway_cn_fixes_test.go b/backend/internal/service/openai_gateway_cn_fixes_test.go index da532536b179..1ab002af8995 100644 --- a/backend/internal/service/openai_gateway_cn_fixes_test.go +++ b/backend/internal/service/openai_gateway_cn_fixes_test.go @@ -74,7 +74,7 @@ func TestCalculateOpenAIRecordUsageCost_EmptyCandidatesIsPricingUnavailable(t *t _, err := svc.calculateOpenAIRecordUsageCost( context.Background(), nil, apiKey, nil, - 1.0, 1.0, 1.0, 1.0, UsageTokens{InputTokens: 100}, "", nil, time.Time{}, + 1.0, 1.0, 1.0, 1.0, UsageTokens{InputTokens: 100}, "", nil, false, time.Time{}, ) require.Error(t, err) require.True(t, isUsagePricingUnavailableError(err), diff --git a/backend/internal/service/openai_gateway_count_tokens.go b/backend/internal/service/openai_gateway_count_tokens.go index 0c89b4662ad8..f10d3aa66db3 100644 --- a/backend/internal/service/openai_gateway_count_tokens.go +++ b/backend/internal/service/openai_gateway_count_tokens.go @@ -104,7 +104,7 @@ func (s *OpenAIGatewayService) ForwardResponsesInputTokens( return nil } if s.rateLimitService != nil { - s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) + s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, prepared.UpstreamModel) } upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, "") @@ -343,7 +343,7 @@ func (s *OpenAIGatewayService) ForwardCountTokensAsAnthropic( if isOpenAIInputTokensUnsupported(resp.StatusCode, respBody) { if s.rateLimitService != nil { - s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) + s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, prepared.UpstreamModel) } writeAnthropicCountTokensError(c, http.StatusNotFound, "not_found_error", "Token counting is not supported by upstream") return nil @@ -362,7 +362,7 @@ func (s *OpenAIGatewayService) ForwardCountTokensAsAnthropic( } } if s.rateLimitService != nil { - s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) + s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, prepared.UpstreamModel) } upstreamDetail := "" diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index b024bc63cd09..d59017f2fdd9 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -236,7 +236,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco // 账号映射未命中时以去除首尾空白的请求模型兜底,计费名与上游模型名一致。 return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, reqModel) case APIProtocolChatCompletions: - return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body) + return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body, startTime) } if usesOfficialOpenAIResponsesInputContract(account) { SetActualOpenAIUpstreamEndpoint(c, openAIResponsesUpstreamEndpoint) diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go index 20fa07509670..12ea0d0d2a07 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go @@ -29,9 +29,8 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( c *gin.Context, account *Account, body []byte, + startTime time.Time, ) (*OpenAIForwardResult, error) { - startTime := time.Now() - var responsesReq apicompat.ResponsesRequest if err := json.Unmarshal(body, &responsesReq); err != nil { writeOpenAIResponsesFallbackError(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body") diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback_test.go b/backend/internal/service/openai_gateway_responses_chat_fallback_test.go index 812e5c7b411b..64694fd3137c 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback_test.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback_test.go @@ -5,6 +5,7 @@ package service import ( "bytes" "context" + "fmt" "io" "net/http" "net/http/httptest" @@ -19,6 +20,34 @@ import ( "github.com/tidwall/gjson" ) +func TestForwardResponses_ChatFallbackPreservesProvidedStartTime(t *testing.T) { + for _, stream := range []bool{false, true} { + t.Run(fmt.Sprintf("stream=%t", stream), func(t *testing.T) { + body := []byte(fmt.Sprintf(`{"model":"gpt-5.4","input":"hello","stream":%t}`, stream)) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + responseBody := `{"id":"chatcmpl_timing","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}` + contentType := "application/json" + if stream { + responseBody = "data: {\"id\":\"chatcmpl_timing\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: {\"choices\":[],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}\n\ndata: [DONE]\n\n" + contentType = "text/event-stream" + } + upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{contentType}}, Body: io.NopCloser(strings.NewReader(responseBody))}} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + startTime := time.Now().Add(-time.Minute) + result, err := svc.forwardResponsesViaRawChatCompletions(context.Background(), c, forceChatResponsesFallbackAccount(), body, startTime) + require.NoError(t, err) + require.NotNil(t, result) + require.GreaterOrEqual(t, result.Duration, time.Minute) + if stream { + require.NotNil(t, result.FirstTokenMs) + require.GreaterOrEqual(t, *result.FirstTokenMs, int(time.Minute.Milliseconds())) + } + }) + } +} + func TestForwardResponses_ForceChatCompletionsRoutesNonStreamingToChatCompletions(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_gateway_search_surcharge_test.go b/backend/internal/service/openai_gateway_search_surcharge_test.go index d2d069ff5c26..ed9aed830cbf 100644 --- a/backend/internal/service/openai_gateway_search_surcharge_test.go +++ b/backend/internal/service/openai_gateway_search_surcharge_test.go @@ -38,6 +38,7 @@ func TestCalculateOpenAIRecordUsageCost_SearchIsAdditiveToTokens(t *testing.T) { UsageTokens{InputTokens: 1000, OutputTokens: 500}, "", boolPtr(false), + false, time.Time{}, ) require.NoError(t, err) @@ -69,6 +70,7 @@ func TestCalculateOpenAIRecordUsageCost_SearchOnlyWhenNoTokenPricing(t *testing. UsageTokens{}, "", boolPtr(false), + false, time.Time{}, ) require.NoError(t, err) @@ -115,6 +117,7 @@ func TestCalculateOpenAIRecordUsageCost_TokenPricingErrorNotSwallowedBySearch(t UsageTokens{InputTokens: 1000, OutputTokens: 500}, "", boolPtr(false), + false, time.Time{}, ) require.Error(t, err) diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index fc6ec5607ce2..e7648e07880d 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -637,7 +637,14 @@ func (s *OpenAIGatewayService) ResolveChannelMappingAndRestrict(ctx context.Cont return s.channelService.ResolveChannelMappingAndRestrict(ctx, groupID, model) } +// isCodexImageGenerationBridgeEnabled 决定是否为 Codex 客户端自动注入 hosted +// image_generation 工具。该工具是 OpenAI Responses 专属能力,Kimi / DeepSeek / +// MiniMax 等经 OpenAI 网关转发的原生 Responses 上游不识别,注入后会被 400 拒绝, +// 因此非 OpenAI 平台账号一律不桥接,后续再按账号 → 渠道 → 全局的优先级取开关。 func (s *OpenAIGatewayService) isCodexImageGenerationBridgeEnabled(ctx context.Context, account *Account, apiKey *APIKey) bool { + if account == nil || account.Platform != PlatformOpenAI { + return false + } if override := account.CodexImageGenerationBridgeOverride(); override != nil { return *override } diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index a9ddac975b33..631ed45dc035 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -14,6 +14,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "go.uber.org/zap" ) @@ -239,6 +240,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec serviceTier = strings.TrimSpace(*result.ServiceTier) } longContextBillingGate := openAILongContextBillingGate(billingAccount) + modelLongContextExempt := openAISubscriptionLongContextExempt(billingAccount, result) cost, err = s.calculateOpenAIRecordUsageCost( ctx, result, @@ -251,6 +253,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec tokens, serviceTier, longContextBillingGate, + modelLongContextExempt, pricingAt, ) if err != nil { @@ -284,7 +287,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec responseModels := s.filterCNProviderBillingModelCandidates(ctx, account, apiKey, usageBillingModelCandidates(responseModel)) responseCost, responseErr := s.calculateOpenAIRecordUsageCost( ctx, result, apiKey, responseModels, multiplier, imageMultiplier, - videoMultiplier, baseMultiplier, tokens, serviceTier, longContextBillingGate, pricingAt, + videoMultiplier, baseMultiplier, tokens, serviceTier, longContextBillingGate, modelLongContextExempt, pricingAt, ) // 基线定价源以 baselineBillingModel 为准:它正是 calculateOpenAIRecordUsageCost // 内部做渠道定价判断时使用的模型,且"首候选有渠道价"必然意味着首候选就是实际 @@ -315,6 +318,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec tokens, "", longContextBillingGate, + modelLongContextExempt, pricingAt, ) if standardErr != nil && !isUsagePricingUnavailableError(standardErr) { @@ -487,7 +491,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec applyAccountStatsCost(ctx, usageLog, s.channelService, s.billingService, account.ID, *apiKey.GroupID, result.UpstreamModel, result.Model, tokens, cost.TotalCost, pricingAt, - accountStatsLongContextPricingEnabled(longContextBillingGate), + accountStatsLongContextPricingEnabled(longContextBillingGate) && !modelLongContextExempt, ) } @@ -573,6 +577,20 @@ func openAILongContextBillingGate(account *Account) *bool { return &enabled } +// openAISubscriptionLongContextExempt 按实际凭据和最终发送模型判断订阅豁免。 +// GPT-6.1 Sol 的订阅请求不再收取模型长上下文附加费;API Key 仍使用 API 价卡。 +// 使用发送模型,避免客户别名、渠道计费模型或响应模型计费切换改变上游订阅规则。 +func openAISubscriptionLongContextExempt(account *Account, result *OpenAIForwardResult) bool { + if !account.IsOpenAIOAuthLike() || result == nil { + return false + } + model := strings.TrimSpace(result.UpstreamModel) + if model == "" { + model = result.Model + } + return openai.IsGPT61SolModelSpelling(model) +} + func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( ctx context.Context, result *OpenAIForwardResult, @@ -585,6 +603,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( tokens UsageTokens, serviceTier string, longContextBillingGate *bool, + modelLongContextExempt bool, pricingAt time.Time, ) (*CostBreakdown, error) { billingModel := firstUsageBillingModel(billingModels) @@ -641,6 +660,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( serviceTier, optionalStringValue(result.ReasoningEffort), longContextBillingGate, + modelLongContextExempt, ) if err == nil { tokenCost = cost @@ -735,6 +755,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost( serviceTier string, reasoningEffort string, longContextBillingGate *bool, + modelLongContextExempt bool, ) (*CostBreakdown, error) { if s.resolver != nil && apiKey.Group != nil { gid := apiKey.Group.ID @@ -742,12 +763,13 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost( Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, PricingAt: pricingAt, ServiceTier: serviceTier, ReasoningEffort: reasoningEffort, Resolver: s.resolver, - LongContextBillingEnabled: longContextBillingGate, + LongContextBillingEnabled: longContextBillingGate, + DisableModelLongContextPricing: modelLongContextExempt, }) } return s.billingService.calculateCostInternalWithPolicy( billingModel, tokens, multiplier, serviceTier, nil, - longContextBillingGate == nil || *longContextBillingGate, pricingAt, + (longContextBillingGate == nil || *longContextBillingGate) && !modelLongContextExempt, pricingAt, ) } diff --git a/backend/internal/service/openai_image_generation_controls_test.go b/backend/internal/service/openai_image_generation_controls_test.go index 300bea37639d..782c9f01cd29 100644 --- a/backend/internal/service/openai_image_generation_controls_test.go +++ b/backend/internal/service/openai_image_generation_controls_test.go @@ -424,6 +424,76 @@ func TestOpenAIGatewayServiceForward_CodexBridgePreservesExistingToolChoice(t *t require.Equal(t, "image_generation", gjson.GetBytes(upstream.lastBody, "tool_choice.type").String()) } +// 国产原生 Responses 账号(Kimi / DeepSeek / MiniMax)在桥接由渠道级开关或全局开关 +// 单独打开时,发往上游的请求体都不能带 hosted image_generation 工具 / tool_choice / 桥接指令。 +func TestOpenAIGatewayServiceForward_CodexBridgeSkipsNativeCNResponsesAccounts(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := int64(4242) + triggers := []struct { + name string + global bool + channel *Channel + }{ + { + name: "channel_openai_true_global_off", + global: false, + channel: &Channel{ID: 1, Status: StatusActive, FeaturesConfig: map[string]any{ + featureKeyCodexImageGenerationBridge: map[string]any{PlatformOpenAI: true}, + }}, + }, + { + name: "global_on_no_channel", + global: true, + }, + } + for _, platform := range []string{PlatformKimi, PlatformDeepseek, PlatformMiniMax} { + for _, trig := range triggers { + t.Run(platform+"/"+trig.name, func(t *testing.T) { + upstream := &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"resp_cn_codex","model":"cn-model","usage":{"input_tokens":1,"output_tokens":1}}`)), + }, + } + svc := newOpenAIImageGenerationControlTestService(upstream) + svc.cfg.Gateway.CodexImageGenerationBridgeEnabled = trig.global + if trig.channel != nil { + svc.channelService = newOpenAIImageGenerationControlChannelService(groupID, trig.channel) + } + c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1") + account := &Account{ + ID: 6100, + Name: platform + "-native-responses", + Platform: platform, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-cn-test", + "api_protocol": APIProtocolResponses, + }, + } + // 前置条件:账号确实走原生 Responses 转发,而不是 Chat Completions 转换链。 + require.True(t, account.UsesNativeCNResponses()) + require.False(t, shouldForwardOpenAIResponsesViaRawChatCompletions(account)) + + result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"cn-model","input":"hello","stream":false,"tools":[{"type":"function","name":"shell","parameters":{"type":"object"}}]}`)) + + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + require.Falsef(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="image_generation")`).Exists(), "image_generation tool injected into %s upstream body", platform) + require.Falsef(t, gjson.GetBytes(upstream.lastBody, "tool_choice").Exists(), "tool_choice injected into %s upstream body", platform) + require.NotContains(t, gjson.GetBytes(upstream.lastBody, "instructions").String(), codexImageGenerationBridgeMarker) + require.Equal(t, "shell", gjson.GetBytes(upstream.lastBody, "tools.0.name").String()) + }) + } + } +} + func TestOpenAIGatewayServiceForward_CodexBridgeSkipsCompactRequests(t *testing.T) { gin.SetMode(gin.TestMode) @@ -524,6 +594,42 @@ func TestOpenAIGatewayService_CodexImageGenerationBridgeOverridePrecedence(t *te }, want: false, }, + { + name: "kimi account ignores channel openai true", + global: false, + channel: &Channel{ID: 1, Status: StatusActive, FeaturesConfig: map[string]any{ + featureKeyCodexImageGenerationBridge: map[string]any{PlatformOpenAI: true}, + }}, + account: &Account{Platform: PlatformKimi, Type: AccountTypeAPIKey}, + want: false, + }, + { + name: "kimi account ignores enabled global", + global: true, + account: &Account{Platform: PlatformKimi, Type: AccountTypeAPIKey}, + want: false, + }, + { + name: "deepseek account ignores enabled global", + global: true, + account: &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey}, + want: false, + }, + { + name: "minimax account ignores channel openai true", + global: false, + channel: &Channel{ID: 1, Status: StatusActive, FeaturesConfig: map[string]any{ + featureKeyCodexImageGenerationBridge: map[string]any{PlatformOpenAI: true}, + }}, + account: &Account{Platform: PlatformMiniMax, Type: AccountTypeAPIKey}, + want: false, + }, + { + name: "nil account never bridges", + global: true, + account: nil, + want: false, + }, } for _, tt := range tests { diff --git a/backend/internal/service/openai_subscription_long_context_test.go b/backend/internal/service/openai_subscription_long_context_test.go new file mode 100644 index 000000000000..bdf5b9549b58 --- /dev/null +++ b/backend/internal/service/openai_subscription_long_context_test.go @@ -0,0 +1,238 @@ +//go:build unit + +package service + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestOpenAISubscriptionLongContextExempt(t *testing.T) { + oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + for _, tt := range []struct { + name string + account *Account + result *OpenAIForwardResult + want bool + }{ + {name: "空账号", result: &OpenAIForwardResult{Model: "gpt-6.1-sol"}}, + {name: "空结果", account: oauth}, + {name: "订阅", account: oauth, result: &OpenAIForwardResult{Model: "gpt-6.1-sol"}, want: true}, + {name: "别名", account: oauth, result: &OpenAIForwardResult{Model: "openai/GPT_6.1_SOL-max"}, want: true}, + {name: "压缩", account: oauth, result: &OpenAIForwardResult{Model: "gpt-6.1-sol-openai-compact"}, want: true}, + {name: "映射到目标", account: oauth, result: &OpenAIForwardResult{Model: "custom", UpstreamModel: "gpt-6.1-sol"}, want: true}, + {name: "映射离开目标", account: oauth, result: &OpenAIForwardResult{Model: "gpt-6.1-sol", UpstreamModel: "gpt-6-sol"}}, + {name: "仅计费标签相同", account: oauth, result: &OpenAIForwardResult{Model: "gpt-6-sol", BillingModel: "gpt-6.1-sol"}}, + {name: "仅响应标签相同", account: oauth, result: &OpenAIForwardResult{Model: "gpt-6-sol", UpstreamResponseModel: "gpt-6.1-sol"}}, + {name: "其他模型", account: oauth, result: &OpenAIForwardResult{Model: "gpt-6-astra"}}, + {name: "相似名称", account: oauth, result: &OpenAIForwardResult{Model: "gpt-6.1-sol-preview"}}, + {name: "API账号", account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, result: &OpenAIForwardResult{Model: "gpt-6.1-sol"}}, + {name: "其他平台", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth}, result: &OpenAIForwardResult{Model: "gpt-6.1-sol"}}, + {name: "订阅访问令牌", account: &Account{Platform: PlatformOpenAI, Type: AccountTypeSetupToken}, result: &OpenAIForwardResult{Model: "gpt-6.1-sol"}, want: true}, + } { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, openAISubscriptionLongContextExempt(tt.account, tt.result)) + }) + } +} + +// 同时覆盖远程目录更新、静态兜底、旧计费入口和分组入口,校验真实扣款与账号成本。 +func TestOpenAIGatewayServiceRecordUsage_GPT61SolSubscriptionLongContext(t *testing.T) { + for _, catalog := range []bool{false, true} { + for _, groupMode := range []string{"无分组", "分组关闭", "分组开启"} { + for _, accountEnabled := range []bool{false, true} { + for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken, AccountTypeAPIKey} { + for _, tier := range []string{"", "priority"} { + name := fmt.Sprintf("目录=%t/%s/账号开关=%t/%s/%s", catalog, groupMode, accountEnabled, accountType, tier) + t.Run(name, func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil) + if catalog { + svc.billingService = NewBillingService(svc.cfg, newStubPricingServiceFromJSON(t, `{ + "gpt-6.1-sol": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 2e-6, "output_cost_per_token": 10e-6, + "cache_read_input_token_cost": 0.1e-6, "cache_creation_input_token_cost": 2.5e-6, + "input_cost_per_token_above_272k_tokens": 4e-6, + "output_cost_per_token_above_272k_tokens": 15e-6} + }`)) + } + apiKey := &APIKey{ID: 1} + if groupMode != "无分组" { + apiKey = openAIRecordUsageAPIKeyWithGroup(svc, 1, groupMode == "分组开启") + apiKey.GroupID = i64p(1) + apiKey.Group.RateMultiplier = 1.1 + svc.channelService = newTestChannelServiceForStats(t, &Channel{ID: 1, Status: StatusActive}, 1, PlatformOpenAI) + } + account := &Account{ID: 3, Platform: PlatformOpenAI, Type: accountType, + Extra: map[string]any{openAILongContextBillingEnabledKey: accountEnabled}} + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: name, Model: "gpt-6.1-sol", ServiceTier: &tier, Duration: time.Second, + // 普通输入只有 200k,由缓存使总上下文越过 272k。 + Usage: OpenAIUsage{InputTokens: 300000, OutputTokens: 2000, CacheReadInputTokens: 80000, CacheCreationInputTokens: 20000}, + }, + APIKey: apiKey, User: &User{ID: 2}, Account: account, + }) + require.NoError(t, err) + wantLong := accountType == AccountTypeAPIKey && (accountEnabled || groupMode == "分组开启") + inputFactor, outputFactor, tierFactor := 1.0, 1.0, 1.0 + if wantLong { + inputFactor, outputFactor = 2, 1.5 + } + if tier == "priority" { + tierFactor = 2 + } + log := usageRepo.lastLog + require.NotNil(t, log) + require.Equal(t, wantLong, log.LongContextBillingApplied) + require.InDelta(t, 200000*2e-6*inputFactor*tierFactor, log.InputCost, 1e-12) + require.InDelta(t, 80000*0.1e-6*inputFactor*tierFactor, log.CacheReadCost, 1e-12) + require.InDelta(t, 20000*2.5e-6*inputFactor*tierFactor, log.CacheCreationCost, 1e-12) + require.InDelta(t, 2000*10e-6*outputFactor*tierFactor, log.OutputCost, 1e-12) + wantTotal := (0.458*inputFactor + 0.02*outputFactor) * tierFactor + require.InDelta(t, wantTotal, log.TotalCost, 1e-12) + require.InDelta(t, wantTotal*1.1, log.ActualCost, 1e-12) + require.Equal(t, 1, userRepo.deductCalls) + require.InDelta(t, wantTotal*1.1, userRepo.lastAmount, 1e-12) + if groupMode != "无分组" { + wantStats := 0.478 * tierFactor + if accountType == AccountTypeAPIKey && accountEnabled { + wantStats = (0.458*2 + 0.02*1.5) * tierFactor + } + require.NotNil(t, log.AccountStatsCost) + require.InDelta(t, wantStats, *log.AccountStatsCost, 1e-12) + } + }) + } + } + } + } + } +} + +func TestOpenAIGatewayServiceRecordUsage_GPT61SolUsesCredentialAndUpstreamModel(t *testing.T) { + for _, tt := range []struct { + name string + model string + upstream string + parentType string + wantTotal float64 + wantLong bool + }{ + {name: "客户别名映射到目标", model: "custom", upstream: "gpt-6.1-sol", parentType: AccountTypeOAuth, wantTotal: 0.62}, + {name: "请求目标映射到旧模型", model: "gpt-6.1-sol", upstream: "gpt-6-sol", parentType: AccountTypeOAuth, wantTotal: 1.23, wantLong: true}, + {name: "其他订阅模型", model: "gpt-6-sol", parentType: AccountTypeOAuth, wantTotal: 1.23, wantLong: true}, + {name: "订阅母账号", model: "gpt-6.1-sol", parentType: AccountTypeOAuth, wantTotal: 0.62}, + } { + t.Run(tt.name, func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + parentID := int64(4) + svc.accountRepo = &openAIRecordUsageAccountRepoStub{account: &Account{ + ID: parentID, Platform: PlatformOpenAI, Type: tt.parentType, + Extra: map[string]any{openAILongContextBillingEnabledKey: true}, + }} + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{RequestID: tt.name, Model: tt.model, UpstreamModel: tt.upstream, + Usage: OpenAIUsage{InputTokens: 300000, OutputTokens: 2000}, Duration: time.Second}, + APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1, true), User: &User{ID: 2}, + Account: &Account{ID: 3, Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &parentID, QuotaDimension: QuotaDimensionSpark}, + }) + require.NoError(t, err) + require.Equal(t, tt.wantLong, usageRepo.lastLog.LongContextBillingApplied) + require.InDelta(t, tt.wantTotal, usageRepo.lastLog.TotalCost, 1e-12) + }) + } +} + +func TestCalculateCostUnified_ModelLongContextExemptionPreservesCustomIntervals(t *testing.T) { + service := NewBillingService(nil, nil) + for _, withResolver := range []bool{false, true} { + input := CostInput{Model: "gpt-6.1-sol", Tokens: UsageTokens{InputTokens: 300000}, RateMultiplier: 1, + LongContextBillingEnabled: boolPtr(true), DisableModelLongContextPricing: true} + if withResolver { + input.Ctx = context.Background() + input.Resolver = NewModelPricingResolver(nil, service) + } + cost, err := service.CalculateCostUnified(input) + require.NoError(t, err) + require.InDelta(t, 0.6, cost.TotalCost, 1e-12) + require.False(t, cost.LongContextBillingApplied) + if withResolver { + input.Resolved = &ResolvedPricing{ + BasePricing: &ModelPricing{InputPricePerToken: 2e-6}, + Intervals: []PricingInterval{{MinTokens: 272000, InputMultiplier: pricingMultiplier(3)}}, + longContextPricingEnabled: true, + } + cost, err = service.CalculateCostUnified(input) + require.NoError(t, err) + require.InDelta(t, 1.8, cost.TotalCost, 1e-12, "管理员显式区间价格应保留") + } + } +} + +func TestOpenAIGatewayServiceRecordUsage_GPT61SolLongContextBoundary(t *testing.T) { + for _, totalInput := range []int{271999, 272000, 272001} { + for _, accountType := range []string{AccountTypeOAuth, AccountTypeAPIKey} { + t.Run(fmt.Sprintf("%s/%d", accountType, totalInput), func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{RequestID: "boundary", Model: "gpt-6.1-sol", Duration: time.Second, + Usage: OpenAIUsage{InputTokens: totalInput, OutputTokens: 1000}}, + APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1, true), User: &User{ID: 2}, + Account: &Account{ID: 3, Platform: PlatformOpenAI, Type: accountType, + Extra: map[string]any{openAILongContextBillingEnabledKey: true}}, + }) + require.NoError(t, err) + wantLong := accountType == AccountTypeAPIKey && totalInput > 272000 + wantInput, wantOutput := float64(totalInput)*2e-6, 0.01 + if wantLong { + wantInput *= 2 + wantOutput *= 1.5 + } + require.Equal(t, wantLong, usageRepo.lastLog.LongContextBillingApplied) + require.InDelta(t, wantInput, usageRepo.lastLog.InputCost, 1e-12) + require.InDelta(t, wantOutput, usageRepo.lastLog.OutputCost, 1e-12) + }) + } + } +} + +func TestOpenAIGatewayServiceRecordUsage_GPT61SolExemptionSurvivesRepricing(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil) + apiKey := openAIRecordUsageAPIKeyWithGroup(svc, 1, true) + apiKey.GroupID = i64p(1) + apiKey.Group.Platform = PlatformOpenAI + apiKey.Group.RateMultiplier = 1.1 + apiKey.Group.FreeOpenAIFast = true + svc.channelService = newTestChannelServiceForStats(t, &Channel{ID: 1, Status: StatusActive}, 1, PlatformOpenAI) + tier := "priority" + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "repricing", Model: "gpt-6-sol", UpstreamModel: "gpt-6.1-sol", BillingModel: "gpt-6-sol", + UpstreamResponseModel: "gpt-6.1-sol", ServiceTier: &tier, Duration: time.Second, + Usage: OpenAIUsage{InputTokens: 300000, OutputTokens: 2000, CacheReadInputTokens: 80000}, + }, + APIKey: apiKey, User: &User{ID: 2}, + Account: &Account{ID: 3, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Extra: map[string]any{openAILongContextBillingEnabledKey: true}}, + ChannelUsageFields: ChannelUsageFields{BillingModelSource: BillingModelSourceResponse}, + }) + require.NoError(t, err) + // 响应模型改用较低的缓存价;免费 Fast 再按标准档扣款,两次重算均须保留豁免。 + const standardCost = 220000*2e-6 + 80000*0.1e-6 + 2000*10e-6 + require.InDelta(t, standardCost*2, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, standardCost*1.1, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, standardCost*1.1, userRepo.lastAmount, 1e-12) + require.False(t, usageRepo.lastLog.LongContextBillingApplied) + require.NotNil(t, usageRepo.lastLog.AccountStatsCost) + require.InDelta(t, standardCost*2, *usageRepo.lastLog.AccountStatsCost, 1e-12) +} diff --git a/backend/internal/service/ops_upstream_context.go b/backend/internal/service/ops_upstream_context.go index 673f1e1f7818..8ab3dfc4f614 100644 --- a/backend/internal/service/ops_upstream_context.go +++ b/backend/internal/service/ops_upstream_context.go @@ -52,6 +52,14 @@ const ( // ensureForwardErrorResponse 检查此 key,为 true 时跳过兜底写入,避免在已完成的 JSON 后追加 SSE。 ResponseCommittedKey = "response_committed" + // OpsLocalErrorTypeKey / OpsLocalErrorCodeKey 记录网关以 Google 错误格式回写的错误类型与错误码: + // Google 错误体没有 type 字段,ops_error_logger 据此按与 Claude / OpenAI 格式错误相同的口径分类。 + OpsLocalErrorTypeKey = "ops_local_error_type" + OpsLocalErrorCodeKey = "ops_local_error_code" + // OpsRequestScopedErrorKey 标记失败由请求内容决定(如引用的显式缓存不存在):只按错误类型分类, + // 上游错误上下文保留为明细但不参与归因。 + OpsRequestScopedErrorKey = "ops_request_scoped_error" + OpsClientBusinessLimitedKey = "ops_client_business_limited" OpsClientBusinessLimitedReasonKey = "ops_client_business_limited_reason" OpsClientBusinessLimitedReasonIPRestriction = "api_key_ip_restriction" @@ -134,6 +142,42 @@ func OpsClientBusinessLimitedReason(c *gin.Context) string { return strings.TrimSpace(reason) } +// SetOpsLocalErrorType 登记响应错误体缺少的错误类型与错误码,并清除请求级失败标记;errType 为空表示不登记。 +func SetOpsLocalErrorType(c *gin.Context, errType, code string) { + if c == nil { + return + } + c.Set(OpsLocalErrorTypeKey, strings.TrimSpace(errType)) + c.Set(OpsLocalErrorCodeKey, strings.TrimSpace(code)) + c.Set(OpsRequestScopedErrorKey, false) +} + +// OpsLocalErrorType 返回 SetOpsLocalErrorType 登记的错误类型与错误码;未登记时返回空串。 +func OpsLocalErrorType(c *gin.Context) (errType, code string) { + if c == nil { + return "", "" + } + errType = c.GetString(OpsLocalErrorTypeKey) + if errType == "" { + return "", "" + } + return errType, c.GetString(OpsLocalErrorCodeKey) +} + +// MarkOpsRequestScopedError 登记错误类型,并标记失败由请求内容决定:上游错误上下文不参与 ops 归因。 +func MarkOpsRequestScopedError(c *gin.Context, errType string) { + if c == nil { + return + } + SetOpsLocalErrorType(c, errType, "") + c.Set(OpsRequestScopedErrorKey, true) +} + +// IsOpsRequestScopedError 判断 MarkOpsRequestScopedError 是否标记了本次请求的失败。 +func IsOpsRequestScopedError(c *gin.Context) bool { + return c != nil && c.GetBool(OpsRequestScopedErrorKey) +} + // OpsStreamError 描述承载在 2xx 响应上的带内错误:网关在响应状态已固化为 200 之后 // 就地以 SSE error 帧返回的错误(并发限流回退、Wait 后二次计费校验失败、流开始后才无 // 可用账号等),以及上游 2xx 正文或事件里携带的错误结果(NonStream 标记非流式正文)。 diff --git a/backend/internal/service/payment_config_plans.go b/backend/internal/service/payment_config_plans.go index 095730c1e8a4..6ddfb4bfc980 100644 --- a/backend/internal/service/payment_config_plans.go +++ b/backend/internal/service/payment_config_plans.go @@ -86,6 +86,9 @@ type PlanGroupInfo struct { WeeklyLimitUSD *float64 `json:"weekly_limit_usd"` MonthlyLimitUSD *float64 `json:"monthly_limit_usd"` ModelScopes []string `json:"supported_model_scopes"` + + // 不对外输出;handler 按请求语言用它换出 Name 的译文。 + I18n GroupI18n `json:"-"` } // GetGroupInfoMap returns a map of group_id → PlanGroupInfo for the given plans. @@ -119,6 +122,7 @@ func (s *PaymentConfigService) GetGroupInfoMap(ctx context.Context, plans []*dbe WeeklyLimitUSD: g.WeeklyLimitUsd, MonthlyLimitUSD: g.MonthlyLimitUsd, ModelScopes: g.SupportedModelScopes, + I18n: g.I18n, } } return m diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index 2d6ade2a4e23..b4dab399da16 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -139,6 +139,14 @@ var ( CacheReadInputTokenCost: 0.2e-6, LiteLLMProvider: "anthropic", Mode: "chat", SupportsPromptCaching: true, } + claudeHaiku55FallbackPricing = &LiteLLMModelPricing{ + InputCostPerToken: 1e-7, OutputCostPerToken: 5e-7, + CacheCreationInputTokenCost: 1.25e-7, CacheCreationInputTokenCostAbove1hr: 2e-7, + CacheReadInputTokenCost: 1e-8, + LongContextInputTokenThreshold: 100000, + LongContextInputCostMultiplier: 5, LongContextOutputCostMultiplier: 5, + LiteLLMProvider: "anthropic", Mode: "chat", SupportsPromptCaching: true, + } openAIGPT56SolFallbackPricing = &LiteLLMModelPricing{ InputCostPerToken: 5e-06, InputCostPerTokenPriority: 1e-05, @@ -224,6 +232,9 @@ type LiteLLMModelPricing struct { OutputCostPerImageToken float64 `json:"output_cost_per_image_token"` // 图片输出 token 价格 InputCostPerImageToken float64 `json:"input_cost_per_image_token"` // 图片输入 token 价格(如 gpt-image-2 图片编辑) CacheReadInputImageTokenCost float64 `json:"cache_read_input_image_token_cost"` + // CacheStorageCostPerTokenPerHour 显式上下文缓存存储单价(每 token 每小时,USD),一般由定价覆盖文件提供, + // 配置后优先于内置价(见 BillingService.CacheStoragePricePerTokenHour)。 + CacheStorageCostPerTokenPerHour float64 `json:"cache_storage_cost_per_token_per_hour"` // TokenPricingAbsent 表示源数据中 input/output token 价格均缺失(仅有图片价)。 // 此类条目只可用于图片计费,token 计费必须回退到 fallback 或 fail-closed, @@ -259,6 +270,7 @@ type LiteLLMRawEntry struct { OutputCostPerImageToken *float64 `json:"output_cost_per_image_token"` InputCostPerImageToken *float64 `json:"input_cost_per_image_token"` CacheReadInputImageTokenCost *float64 `json:"cache_read_input_image_token_cost"` + CacheStorageCostPerTokenPerHour *float64 `json:"cache_storage_cost_per_token_per_hour"` } // PricingService 动态价格服务 @@ -731,6 +743,9 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel if entry.CacheReadInputImageTokenCost != nil { pricing.CacheReadInputImageTokenCost = *entry.CacheReadInputImageTokenCost } + if entry.CacheStorageCostPerTokenPerHour != nil { + pricing.CacheStorageCostPerTokenPerHour = *entry.CacheStorageCostPerTokenPerHour + } hasExplicitLongContext := entry.LongContextInputTokenThreshold != nil || entry.LongContextInputCostMultiplier != nil || @@ -1417,6 +1432,12 @@ func (s *PricingService) matchByModelFamily(model string) *LiteLLMModelPricing { } return claudeSonnet55FallbackPricing } + if claude.IsHaiku55(model) { + if pricing, ok := s.pricingData["claude-haiku-5-5"]; ok { + return pricing + } + return claudeHaiku55FallbackPricing + } // modelFamily 定义一个模型系列的匹配和定价查找规则。 type modelFamily struct { name string // 系列名称 diff --git a/backend/internal/service/pricing_service_test.go b/backend/internal/service/pricing_service_test.go index 0dce3e435cf0..3d9f0cb09e79 100644 --- a/backend/internal/service/pricing_service_test.go +++ b/backend/internal/service/pricing_service_test.go @@ -613,10 +613,10 @@ func TestBillingService_Gemini36FlashThinkingTierFallbacksAreBillable(t *testing t.Run(model, func(t *testing.T) { cost, err := svc.CalculateCost(model, tokens, 1) require.NoError(t, err) - require.InDelta(t, 1.5, cost.InputCost, 1e-12) - require.InDelta(t, 7.5, cost.OutputCost, 1e-12) - require.InDelta(t, 0.15, cost.CacheReadCost, 1e-12) - require.InDelta(t, 9.15, cost.TotalCost, 1e-12) + require.InDelta(t, 0.75, cost.InputCost, 1e-12) + require.InDelta(t, 3.75, cost.OutputCost, 1e-12) + require.InDelta(t, 0.075, cost.CacheReadCost, 1e-12) + require.InDelta(t, 4.575, cost.TotalCost, 1e-12) }) } } @@ -725,9 +725,9 @@ func TestDefaultPricingIncludesGemini36FlashRates(t *testing.T) { t.Run(model, func(t *testing.T) { pricing, err := billingSvc.GetModelPricing(model) require.NoError(t, err) - require.InDelta(t, 1.5e-6, pricing.InputPricePerToken, 1e-12) - require.InDelta(t, 7.5e-6, pricing.OutputPricePerToken, 1e-12) - require.InDelta(t, 0.15e-6, pricing.CacheReadPricePerToken, 1e-12) + require.InDelta(t, 0.75e-6, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 3.75e-6, pricing.OutputPricePerToken, 1e-12) + require.InDelta(t, 0.075e-6, pricing.CacheReadPricePerToken, 1e-12) }) } } diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index c4a32feefdd0..d22c453434fa 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -334,8 +334,9 @@ func (s *RateLimitService) CheckErrorPolicy(ctx context.Context, account *Accoun } if account.IsPoolMode() { // 池模式只跳过默认账号状态处理;管理员显式配置的临时不可调度规则仍应生效。 - // 401 保留现有认证错误语义,避免改变重复 401 的升级行为。 - if statusCode != http.StatusUnauthorized && s.tryTempUnschedulable(ctx, account, statusCode, responseBody) { + // 401 只在非凭据级且命中显式模型级规则时由规则处理,其余保留现有认证错误语义。 + if tempUnschedRulesApply(account, statusCode, responseBody, tempUnschedulableModel(ctx, nil)) && + s.tryTempUnschedulable(ctx, account, statusCode, responseBody) { return ErrorPolicyTempUnscheduled } return ErrorPolicySkipped @@ -378,9 +379,10 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc } // 池模式默认不标记本地账号状态;但管理员显式配置的临时不可调度规则优先。 - // 401 保留现有认证错误语义,不在这里改变池模式的认证处理。 + // 401 只在非凭据级且命中显式模型级规则时由规则处理,其余保留池模式现有的认证处理。 if account.IsPoolMode() && !customErrorCodesEnabled { - if statusCode != http.StatusUnauthorized && s.tryTempUnschedulable(ctx, account, statusCode, responseBody) { + if tempUnschedRulesApply(account, statusCode, responseBody, tempUnschedulableModel(ctx, nil)) && + s.tryTempUnschedulable(ctx, account, statusCode, responseBody) { return true } slog.Info("pool_mode_error_skipped", "account_id", account.ID, "status_code", statusCode) @@ -425,9 +427,9 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc } } - // 先尝试临时不可调度规则(401除外) + // 先尝试临时不可调度规则(401 仅限非凭据级且命中显式模型级规则) // 如果匹配成功,直接返回,不执行后续禁用逻辑 - if statusCode != 401 { + if tempUnschedRulesApply(account, statusCode, responseBody, tempUnschedulableModel(ctx, requestedModel)) { if s.tryTempUnschedulable(ctx, account, statusCode, responseBody, firstRequestedModel(requestedModel)) { return true } @@ -2672,6 +2674,62 @@ func firstRequestedModel(requestedModel []string) string { return strings.TrimSpace(requestedModel[0]) } +// tempUnschedRulesApply 决定本次错误是否交给临时不可调度规则处理。 +// 401 默认留给认证错误处理;只有首条命中规则按模型级生效时,才由规则只冷却该模型。 +func tempUnschedRulesApply(account *Account, statusCode int, responseBody []byte, modelKey string) bool { + if statusCode != http.StatusUnauthorized { + return true + } + matches := matchTempUnschedulableRules(account, statusCode, responseBody) + return len(matches) > 0 && tempUnschedHitUsesModelScope(account, matches[0].rule, modelKey, statusCode, responseBody) +} + +// tempUnschedHitUsesModelScope 决定一次命中是否按模型级冷却:凭据级 401 始终按账号处理, +// 其余由规则作用范围决定。 +func tempUnschedHitUsesModelScope(account *Account, rule TempUnschedulableRule, modelKey string, statusCode int, responseBody []byte) bool { + if statusCode == http.StatusUnauthorized && unauthorizedRequiresCredentialHandling(account, responseBody) { + return false + } + return tempUnschedRuleUsesModelScope(rule, modelKey, statusCode) +} + +// unauthorizedRequiresCredentialHandling 识别凭据级 401:OAuth 账号与 Spark 影子 +// (失效缓存、刷新窗口、缺 refresh_token 停用),以及 OpenAI 明确的凭据失效 +// (token_invalidated / token_revoked、{"detail":"Unauthorized"})。 +func unauthorizedRequiresCredentialHandling(account *Account, responseBody []byte) bool { + if account == nil { + return false + } + if account.Type == AccountTypeOAuth || account.IsShadow() { + return true + } + if account.Platform != PlatformOpenAI { + return false + } + switch extractUpstreamErrorCode(responseBody) { + case "token_invalidated", "token_revoked": + return true + } + return gjson.GetBytes(responseBody, "detail").String() == "Unauthorized" +} + +// tempUnschedRuleUsesModelScope 决定一次命中按模型级冷却还是账号级暂停。 +// 显式 scope 完全由规则决定;scope 为空时模型已知且非 401 走模型级。 +// 模型未知时无法隔离,一律账号级。 +func tempUnschedRuleUsesModelScope(rule TempUnschedulableRule, modelKey string, statusCode int) bool { + if strings.TrimSpace(modelKey) == "" { + return false + } + switch rule.Scope { + case TempUnschedScopeAccount: + return false + case TempUnschedScopeModel: + return true + default: + return statusCode != http.StatusUnauthorized + } +} + type tempUnschedulableModelContextKey struct{} func withTempUnschedulableModel(ctx context.Context, requestedModel []string) context.Context { @@ -2740,10 +2798,17 @@ func (s *RateLimitService) tryTempUnschedulable(ctx context.Context, account *Ac if !account.IsTempUnschedulableEnabled() { return false } + matches := matchTempUnschedulableRules(account, statusCode, responseBody) + if len(matches) == 0 { + return false + } + modelKey := tempUnschedulableModel(ctx, requestedModel) // 401 首次命中可临时不可调度(给 token 刷新窗口); // 若历史上已因 401 进入过临时不可调度,则本次应升级为 error(返回 false 交由默认错误逻辑处理)。 // Antigravity 跳过:其 401 由 applyErrorPolicy 的 temp_unschedulable_rules 自行控制,无需升级逻辑。 - if statusCode == http.StatusUnauthorized && account.Platform != PlatformAntigravity { + // 模型级命中只冷却模型、不写账号级状态,不参与升级。 + if statusCode == http.StatusUnauthorized && account.Platform != PlatformAntigravity && + !tempUnschedHitUsesModelScope(account, matches[0].rule, modelKey, statusCode, responseBody) { reason := account.TempUnschedulableReason // 缓存可能没有 reason,从 DB 回退读取 if reason == "" { @@ -2757,8 +2822,8 @@ func (s *RateLimitService) tryTempUnschedulable(ctx context.Context, account *Ac return false } } - for _, match := range matchTempUnschedulableRules(account, statusCode, responseBody) { - if s.triggerTempUnschedulable(ctx, account, match.rule, match.ruleIndex, statusCode, match.matchedKeyword, responseBody, tempUnschedulableModel(ctx, requestedModel)) { + for _, match := range matches { + if s.triggerTempUnschedulable(ctx, account, match.rule, match.ruleIndex, statusCode, match.matchedKeyword, responseBody, modelKey) { return true } } @@ -2808,6 +2873,8 @@ func (s *RateLimitService) triggerTempUnschedulable(ctx context.Context, account now := time.Now() until := now.Add(time.Duration(rule.DurationMinutes) * time.Minute) + modelKey := firstRequestedModel(requestedModel) + modelScoped := tempUnschedHitUsesModelScope(account, rule, modelKey, statusCode, responseBody) state := &TempUnschedState{ UntilUnix: until.Unix(), @@ -2816,6 +2883,11 @@ func (s *RateLimitService) triggerTempUnschedulable(ctx context.Context, account MatchedKeyword: matchedKeyword, RuleIndex: ruleIndex, ErrorMessage: truncateTempUnschedMessage(responseBody, tempUnschedMessageMaxBytes), + Scope: TempUnschedScopeAccount, + } + if modelScoped { + state.Scope = TempUnschedScopeModel + state.Model = modelKey } reason := "" @@ -2826,18 +2898,16 @@ func (s *RateLimitService) triggerTempUnschedulable(ctx context.Context, account reason = strings.TrimSpace(state.ErrorMessage) } - // Persist known-model failures under the model key so the scheduler excludes - // only this (account, model) pair. Authentication and model-unknown failures - // retain the legacy account-wide temporary-unschedulable behavior below. - modelKey := firstRequestedModel(requestedModel) - if modelKey != "" && statusCode != http.StatusUnauthorized { + // Persist model-scoped hits under the model key so the scheduler excludes + // only this (account, model) pair; every other hit pauses the whole account below. + if modelScoped { if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, modelKey, until, reason); err != nil { slog.Warn("temp_unsched_model_rate_limit_set_failed", "account_id", account.ID, "model", modelKey, "error", err) // The rule matched, so fail over the current request even if persistence // failed; never widen a model-scoped failure into an account-wide block. return true } - slog.Info("account_model_temp_unschedulable", "account_id", account.ID, "model", modelKey, "until", until, "rule_index", ruleIndex, "status_code", statusCode) + slog.Info("account_model_temp_unschedulable", "account_id", account.ID, "model", modelKey, "until", until, "rule_index", ruleIndex, "rule_scope", rule.Scope, "status_code", statusCode) return true } @@ -2853,7 +2923,7 @@ func (s *RateLimitService) triggerTempUnschedulable(ctx context.Context, account } } - slog.Info("account_temp_unschedulable", "account_id", account.ID, "until", until, "rule_index", ruleIndex, "status_code", statusCode) + slog.Info("account_temp_unschedulable", "account_id", account.ID, "until", until, "rule_index", ruleIndex, "rule_scope", rule.Scope, "status_code", statusCode) return true } diff --git a/backend/internal/service/temp_unsched.go b/backend/internal/service/temp_unsched.go index debf069c8f11..002961c1668d 100644 --- a/backend/internal/service/temp_unsched.go +++ b/backend/internal/service/temp_unsched.go @@ -16,6 +16,8 @@ type TempUnschedState struct { TriggerCount int64 `json:"trigger_count,omitempty"` // 本次触发累计命中次数 TriggerThreshold int `json:"trigger_threshold,omitempty"` // 触发阈值 TriggerWindowMinutes int `json:"trigger_window_minutes,omitempty"` // 计数窗口(分钟) + Scope string `json:"scope,omitempty"` // 隔离粒度:account / model + Model string `json:"model,omitempty"` // 模型级时被冷却的模型键 } // TempUnschedCache 临时不可调度缓存接口 diff --git a/backend/internal/service/temp_unsched_scope_test.go b/backend/internal/service/temp_unsched_scope_test.go new file mode 100644 index 000000000000..ca510b6da25e --- /dev/null +++ b/backend/internal/service/temp_unsched_scope_test.go @@ -0,0 +1,495 @@ +//go:build unit + +package service + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +type tempUnschedScopeRepoStub struct { + modelNotFoundAccountRepoStub + tempReasons []string + errorCalls int +} + +func (r *tempUnschedScopeRepoStub) SetError(context.Context, int64, string) error { + r.errorCalls++ + return nil +} + +func (r *tempUnschedScopeRepoStub) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, reason string) error { + r.tempCalls++ + r.tempReasons = append(r.tempReasons, reason) + return nil +} + +func newTempUnschedScopeAccount(scope string, extra map[string]any) *Account { + rule := map[string]any{ + "error_code": float64(http.StatusServiceUnavailable), + "keywords": []any{"unavailable"}, + "duration_minutes": float64(10), + } + if scope != "" { + rule["scope"] = scope + } + for k, v := range extra { + rule[k] = v + } + return &Account{ + ID: 202, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Credentials: map[string]any{ + "temp_unschedulable_enabled": true, + "temp_unschedulable_rules": []any{rule}, + }, + } +} + +var tempUnschedScopeBody = []byte(`{"error":{"message":"upstream unavailable"}}`) + +var ( + tempUnschedScope401Body = []byte(`{"error":{"message":"unauthorized"}}`) + tempUnschedScopeRevoked401Body = []byte(`{"error":{"code":"token_revoked","message":"unauthorized"}}`) + tempUnschedScopeDetail401Body = []byte(`{"detail":"Unauthorized"}`) + tempUnschedScopePrevious401Mark = `{"status_code":401,"until_unix":1735689600}` +) + +func tempUnschedScope401Rule(scope string) map[string]any { + rule := map[string]any{ + "error_code": float64(http.StatusUnauthorized), + "keywords": []any{"unauthorized"}, + "duration_minutes": float64(10), + } + if scope != "" { + rule["scope"] = scope + } + return rule +} + +func newTempUnschedScope401Account(platform string, scopes ...string) *Account { + account := newTempUnschedScopeAccount("", nil) + account.Platform = platform + rules := make([]any, 0, len(scopes)) + for _, scope := range scopes { + rules = append(rules, tempUnschedScope401Rule(scope)) + } + account.Credentials["temp_unschedulable_rules"] = rules + return account +} + +func TestGetTempUnschedulableRules_ParsesScope(t *testing.T) { + mk := func(keyword string, scope any) map[string]any { + rule := map[string]any{ + "error_code": float64(http.StatusServiceUnavailable), + "keywords": []any{keyword}, + "duration_minutes": float64(1), + } + if scope != nil { + rule["scope"] = scope + } + return rule + } + account := &Account{Credentials: map[string]any{ + "temp_unschedulable_rules": []any{ + mk("a", "account"), + mk("b", " Model "), + mk("c", "whatever"), + mk("d", nil), + }, + }} + + rules := account.GetTempUnschedulableRules() + + require.Len(t, rules, 4) + require.Equal(t, TempUnschedScopeAccount, rules[0].Scope) + require.Equal(t, TempUnschedScopeModel, rules[1].Scope, "大小写与首尾空白应被归一化") + require.Empty(t, rules[2].Scope, "未知取值视为未设置") + require.Empty(t, rules[3].Scope) +} + +func TestTempUnschedRuleUsesModelScope(t *testing.T) { + cases := []struct { + name string + scope string + model string + status int + want bool + }{ + {"缺省 模型已知 非401 走模型级", "", "gpt-5.4", http.StatusServiceUnavailable, true}, + {"缺省 401 走账号级", "", "gpt-5.4", http.StatusUnauthorized, false}, + {"缺省 模型未知 走账号级", "", "", http.StatusServiceUnavailable, false}, + {"显式账号级 模型已知 仍账号级", TempUnschedScopeAccount, "gpt-5.4", http.StatusServiceUnavailable, false}, + {"显式模型级 401 仍模型级", TempUnschedScopeModel, "gpt-5.4", http.StatusUnauthorized, true}, + {"显式模型级 模型未知 兜底账号级", TempUnschedScopeModel, " ", http.StatusServiceUnavailable, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := tempUnschedRuleUsesModelScope(TempUnschedulableRule{Scope: tc.scope}, tc.model, tc.status) + require.Equal(t, tc.want, got) + }) + } +} + +func TestHandleTempUnschedulable_ExplicitAccountScopePausesWholeAccountWithKnownModel(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := newTempUnschedScopeAccount(TempUnschedScopeAccount, nil) + + handled := svc.HandleTempUnschedulable(context.Background(), account, http.StatusServiceUnavailable, tempUnschedScopeBody, "gpt-5.4") + + require.True(t, handled) + require.Equal(t, 1, repo.tempCalls) + require.Empty(t, repo.modelRateLimitCalls) + + var state TempUnschedState + require.NoError(t, json.Unmarshal([]byte(repo.tempReasons[0]), &state)) + require.Equal(t, TempUnschedScopeAccount, state.Scope) + require.Empty(t, state.Model) +} + +func TestCheckErrorPolicy_ExplicitModelScopeKeeps401ModelScoped(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := newTempUnschedScopeAccount(TempUnschedScopeModel, map[string]any{ + "error_code": float64(http.StatusUnauthorized), + "keywords": []any{"unauthorized"}, + }) + + result := svc.CheckErrorPolicy(context.Background(), account, http.StatusUnauthorized, + []byte(`{"error":{"message":"unauthorized"}}`), "gpt-5.4") + + require.Equal(t, ErrorPolicyTempUnscheduled, result) + require.Zero(t, repo.tempCalls, "运营人员显式选了模型级,401 也不应连累整个账号") + require.Len(t, repo.modelRateLimitCalls, 1) + require.Equal(t, "gpt-5.4", repo.modelRateLimitCalls[0].scope) + + var state TempUnschedState + require.NoError(t, json.Unmarshal([]byte(repo.modelRateLimitCalls[0].reason), &state)) + require.Equal(t, TempUnschedScopeModel, state.Scope) + require.Equal(t, "gpt-5.4", state.Model) + require.Equal(t, http.StatusUnauthorized, state.StatusCode) +} + +func TestHandleTempUnschedulable_ExplicitModelScopeFallsBackToAccountWhenModelUnknown(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := newTempUnschedScopeAccount(TempUnschedScopeModel, nil) + + handled := svc.HandleTempUnschedulable(context.Background(), account, http.StatusServiceUnavailable, tempUnschedScopeBody) + + require.True(t, handled) + require.Equal(t, 1, repo.tempCalls) + require.Empty(t, repo.modelRateLimitCalls) +} + +func TestOpenAIFailoverSideEffects_401FollowsTempUnschedScope(t *testing.T) { + cases := []struct { + name string + scopes []string + body []byte + poolMode bool + oauth bool + wantModel bool + wantTemp bool + wantSetError bool + wantBlocked bool + }{ + {name: "显式模型级 只冷却模型", scopes: []string{TempUnschedScopeModel}, wantModel: true}, + {name: "显式模型级 池模式 只冷却模型", scopes: []string{TempUnschedScopeModel}, poolMode: true, wantModel: true}, + {name: "缺省 走认证错误处理", scopes: []string{""}, wantSetError: true, wantBlocked: true}, + {name: "显式账号级 走认证错误处理", scopes: []string{TempUnschedScopeAccount}, wantSetError: true, wantBlocked: true}, + {name: "首条命中为账号级 走认证错误处理", scopes: []string{TempUnschedScopeAccount, TempUnschedScopeModel}, wantSetError: true, wantBlocked: true}, + {name: "缺省 池模式 保持跳过", scopes: []string{""}, poolMode: true}, + {name: "显式模型级 token_revoked 走认证错误处理", scopes: []string{TempUnschedScopeModel}, body: tempUnschedScopeRevoked401Body, wantSetError: true, wantBlocked: true}, + {name: "显式模型级 detail Unauthorized 走认证错误处理", scopes: []string{TempUnschedScopeModel}, body: tempUnschedScopeDetail401Body, wantSetError: true, wantBlocked: true}, + {name: "显式模型级 token_revoked 池模式 保持跳过", scopes: []string{TempUnschedScopeModel}, body: tempUnschedScopeRevoked401Body, poolMode: true}, + {name: "显式模型级 OAuth 走刷新窗口", scopes: []string{TempUnschedScopeModel}, oauth: true, wantTemp: true, wantBlocked: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + gateway := &OpenAIGatewayService{rateLimitService: &RateLimitService{accountRepo: repo, cfg: &config.Config{}}} + account := newTempUnschedScope401Account(PlatformOpenAI, tc.scopes...) + if tc.poolMode { + account.Credentials["pool_mode"] = true + } + if tc.oauth { + account.Type = AccountTypeOAuth + account.Credentials["refresh_token"] = "rt" + } + body := tc.body + if body == nil { + body = tempUnschedScope401Body + } + + require.True(t, gateway.shouldFailoverOpenAIUpstreamResponse(account, http.StatusUnauthorized, "unauthorized", body)) + // Match the ordinary production HTTP handler's policy marker; the + // unmarked count/probe path retains its downstream authentication policy. + gateway.handleFailoverSideEffects(WithOpenAIOfficialHTTPFailover(context.Background()), &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: http.Header{}, + }, account, body, "gpt-5.4") + + require.Equal(t, tc.wantTemp, repo.tempCalls == 1, "SetTempUnschedulable=%d", repo.tempCalls) + require.Equal(t, tc.wantSetError, repo.errorCalls == 1, "SetError=%d", repo.errorCalls) + require.Equal(t, tc.wantBlocked, gateway.isOpenAIAccountRuntimeBlocked(account)) + if tc.wantModel { + require.Len(t, repo.modelRateLimitCalls, 1) + require.Equal(t, "gpt-5.4", repo.modelRateLimitCalls[0].scope) + } else { + require.Empty(t, repo.modelRateLimitCalls) + } + }) + } +} + +func TestHandleUpstreamError_ExplicitModelScope401(t *testing.T) { + t.Run("模型已知 只冷却模型", func(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := newTempUnschedScope401Account(PlatformAnthropic, TempUnschedScopeModel) + + shouldDisable := svc.HandleUpstreamError(context.Background(), account, http.StatusUnauthorized, http.Header{}, tempUnschedScope401Body, "claude-sonnet-4-5") + + require.True(t, shouldDisable) + require.Zero(t, repo.errorCalls) + require.Zero(t, repo.tempCalls) + require.Len(t, repo.modelRateLimitCalls, 1) + require.Equal(t, "claude-sonnet-4-5", repo.modelRateLimitCalls[0].scope) + }) + + t.Run("模型未知 按账号级规则走认证错误处理", func(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := newTempUnschedScope401Account(PlatformAnthropic, TempUnschedScopeModel) + + shouldDisable := svc.HandleUpstreamError(context.Background(), account, http.StatusUnauthorized, http.Header{}, tempUnschedScope401Body) + + require.True(t, shouldDisable) + require.Equal(t, 1, repo.errorCalls) + require.Empty(t, repo.modelRateLimitCalls) + }) +} + +func TestCheckErrorPolicy_401FollowsTempUnschedScope(t *testing.T) { + cases := []struct { + name string + scope string + body []byte + oauth bool + previous401 bool + want ErrorPolicyResult + wantModel bool + wantTemp bool + }{ + {name: "显式模型级 不参与重复 401 升级", scope: TempUnschedScopeModel, previous401: true, want: ErrorPolicyTempUnscheduled, wantModel: true}, + {name: "缺省 第二次 401 升级", previous401: true, want: ErrorPolicyNone}, + {name: "显式模型级 token_revoked 按账号暂停", scope: TempUnschedScopeModel, body: tempUnschedScopeRevoked401Body, want: ErrorPolicyTempUnscheduled, wantTemp: true}, + {name: "显式模型级 detail Unauthorized 按账号暂停", scope: TempUnschedScopeModel, body: tempUnschedScopeDetail401Body, want: ErrorPolicyTempUnscheduled, wantTemp: true}, + {name: "显式模型级 OAuth 按账号暂停", scope: TempUnschedScopeModel, oauth: true, want: ErrorPolicyTempUnscheduled, wantTemp: true}, + {name: "显式模型级 凭据级第二次 401 升级", scope: TempUnschedScopeModel, body: tempUnschedScopeDetail401Body, previous401: true, want: ErrorPolicyNone}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := newTempUnschedScope401Account(PlatformOpenAI, tc.scope) + if tc.oauth { + account.Type = AccountTypeOAuth + } + if tc.previous401 { + account.TempUnschedulableReason = tempUnschedScopePrevious401Mark + } + body := tc.body + if body == nil { + body = tempUnschedScope401Body + } + + result := svc.CheckErrorPolicy(context.Background(), account, http.StatusUnauthorized, body, "gpt-5.4") + + require.Equal(t, tc.want, result) + require.Equal(t, tc.wantTemp, repo.tempCalls == 1, "SetTempUnschedulable=%d", repo.tempCalls) + require.Equal(t, tc.wantModel, len(repo.modelRateLimitCalls) == 1) + }) + } +} + +// 计数入口把实际发往上游的模型交给错误处理:显式模型级 401 规则只冷却该模型,不把整号置为 error。 +func TestCountTokensEntries_ExplicitModelScope401OnlyCoolsModel(t *testing.T) { + gin.SetMode(gin.TestMode) + unauthorized := func() *http.Response { + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"message":"unauthorized"}}`)), + } + } + cfg := &config.Config{ + Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}, + Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false, AllowInsecureHTTP: true}}, + } + newContext := func(path string, body []byte) (*gin.Context, *httptest.ResponseRecorder) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body)) + return c, rec + } + + t.Run("openai count_tokens as anthropic", func(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + upstream := &httpUpstreamRecorder{resp: unauthorized()} + svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream, rateLimitService: &RateLimitService{accountRepo: repo, cfg: cfg}} + account := newTempUnschedScope401Account(PlatformOpenAI, TempUnschedScopeModel) + account.Credentials["api_key"] = "sk-test" + account.Credentials["base_url"] = "http://upstream.example" + account.Concurrency = 1 + body := []byte(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hello"}]}`) + c, _ := newContext("/v1/messages/count_tokens", body) + + require.Error(t, svc.ForwardCountTokensAsAnthropic(context.Background(), c, account, body, "gpt-5.4")) + + require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Zero(t, repo.errorCalls) + require.Zero(t, repo.tempCalls) + require.Len(t, repo.modelRateLimitCalls, 1) + require.Equal(t, "gpt-5.4", repo.modelRateLimitCalls[0].scope) + }) + + t.Run("openai responses input_tokens", func(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + upstream := &httpUpstreamRecorder{resp: unauthorized()} + svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream, rateLimitService: &RateLimitService{accountRepo: repo, cfg: cfg}} + account := newTempUnschedScope401Account(PlatformOpenAI, TempUnschedScopeModel) + account.Credentials["api_key"] = "sk-test" + account.Credentials["model_mapping"] = map[string]any{"gpt-5.4": "gpt-5.4-upstream"} + account.Concurrency = 1 + body := []byte(`{"model":"gpt-5.4","input":"hello"}`) + c, _ := newContext("/v1/responses/input_tokens", body) + + require.Error(t, svc.ForwardResponsesInputTokens(context.Background(), c, account, body)) + + require.NotNil(t, upstream.lastReq) + require.Equal(t, "gpt-5.4-upstream", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Zero(t, repo.errorCalls) + require.Zero(t, repo.tempCalls) + require.Len(t, repo.modelRateLimitCalls, 1) + require.Equal(t, "gpt-5.4-upstream", repo.modelRateLimitCalls[0].scope) + }) + + for _, passthrough := range []bool{false, true} { + name := "anthropic count_tokens" + if passthrough { + name = "anthropic count_tokens api key passthrough" + } + t.Run(name, func(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + upstream := &anthropicHTTPUpstreamRecorder{resp: unauthorized()} + svc := &GatewayService{ + cfg: cfg, + responseHeaderFilter: compileResponseHeaderFilter(cfg), + httpUpstream: upstream, + rateLimitService: &RateLimitService{accountRepo: repo, cfg: cfg}, + } + account := newTempUnschedScope401Account(PlatformAnthropic, TempUnschedScopeModel) + account.Credentials["api_key"] = "upstream-anthropic-key" + account.Credentials["base_url"] = "https://api.anthropic.com" + account.Credentials["model_mapping"] = map[string]any{"claude-sonnet-4-5": "claude-sonnet-4-5-upstream"} + account.Concurrency = 1 + if passthrough { + account.Extra = map[string]any{"anthropic_passthrough": true} + } + body := []byte(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hello"}]}`) + c, _ := newContext("/v1/messages/count_tokens", body) + parsed := &ParsedRequest{Body: NewRequestBodyRef(body), Model: "claude-sonnet-4-5"} + + require.Error(t, svc.ForwardCountTokens(context.Background(), c, account, parsed)) + + require.NotNil(t, upstream.lastReq) + require.Equal(t, "claude-sonnet-4-5-upstream", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Zero(t, repo.errorCalls) + require.Zero(t, repo.tempCalls) + require.Len(t, repo.modelRateLimitCalls, 1) + require.Equal(t, "claude-sonnet-4-5-upstream", repo.modelRateLimitCalls[0].scope) + }) + } +} + +// Bedrock 故障转移入口与重试耗尽入口把实际发往上游的模型交给错误处理:显式模型级规则只冷却该模型。 +func TestAnthropicFailoverEntries_ExplicitModelScopeOnlyCoolsModel(t *testing.T) { + gin.SetMode(gin.TestMode) + cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}} + newResponse := func(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + } + } + + t.Run("bedrock failover 401", func(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + upstream := &anthropicHTTPUpstreamRecorder{resp: newResponse(http.StatusUnauthorized, `{"message":"unauthorized"}`)} + svc := &GatewayService{cfg: cfg, httpUpstream: upstream, rateLimitService: &RateLimitService{accountRepo: repo, cfg: cfg}} + account := newTempUnschedScope401Account(PlatformAnthropic, TempUnschedScopeModel) + account.Type = AccountTypeBedrock + account.Status = StatusActive + account.Schedulable = true + account.Credentials["auth_mode"] = "apikey" + account.Credentials["api_key"] = "bedrock-key" + body := []byte(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hello"}]}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + parsed := &ParsedRequest{Body: NewRequestBodyRef(body), Model: "claude-sonnet-4-5"} + + _, err := svc.forwardBedrock(context.Background(), c, account, parsed, time.Now()) + + var failover *UpstreamFailoverError + require.ErrorAs(t, err, &failover) + require.NotNil(t, upstream.lastReq) + require.Contains(t, upstream.lastReq.URL.Path, "anthropic.claude-sonnet-4-5-", "上游收到 Bedrock 模型 ID") + require.Zero(t, repo.errorCalls) + require.Zero(t, repo.tempCalls) + require.Len(t, repo.modelRateLimitCalls, 1) + // 冷却键必须是调度器查找的键(账号映射后的模型名),而不是 Bedrock 模型 ID。 + scope := repo.modelRateLimitCalls[0].scope + require.Equal(t, "claude-sonnet-4-5", scope) + require.True(t, account.IsSchedulableForModelWithContext(context.Background(), "claude-sonnet-4-5")) + setAccountModelRateLimitSnapshot(account, scope, time.Now().Add(10*time.Minute), "temp_unschedulable", time.Now()) + require.False(t, account.IsSchedulableForModelWithContext(context.Background(), "claude-sonnet-4-5"), "调度器按同一个键排除该模型") + }) + + t.Run("oauth retry exhausted 403", func(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + svc := &GatewayService{cfg: cfg, rateLimitService: &RateLimitService{accountRepo: repo, cfg: cfg}} + account := newTempUnschedScopeAccount(TempUnschedScopeModel, map[string]any{ + "error_code": float64(http.StatusForbidden), + "keywords": []any{"forbidden"}, + }) + account.Platform = PlatformAnthropic + account.Type = AccountTypeOAuth + + svc.handleRetryExhaustedSideEffects(context.Background(), newResponse(http.StatusForbidden, `{"error":{"message":"forbidden"}}`), account, "claude-sonnet-4-5") + + require.Zero(t, repo.errorCalls) + require.Zero(t, repo.tempCalls) + require.Len(t, repo.modelRateLimitCalls, 1) + require.Equal(t, "claude-sonnet-4-5", repo.modelRateLimitCalls[0].scope) + }) +} diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index c4d865226ade..ee563e6bc14d 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -11,6 +11,7 @@ import ( "net/url" "slices" "sort" + "strconv" "strings" "time" @@ -26,6 +27,10 @@ const ( UpstreamModelMetadataExtraKey = "upstream_model_metadata" UpstreamModelMetadataIncompleteCode = "upstream_model_metadata_incomplete" UpstreamModelMetadataPartialCode = "upstream_model_metadata_partial" + + vertexPublisherModelsPageSize = 300 + vertexPublisherModelsMaxPages = 20 + vertexGooglePublisherModelPrefix = "publishers/google/models/" ) // UpstreamModelMetadata is one model of an account's upstream catalog. The @@ -951,6 +956,11 @@ func (s *AccountTestService) fetchUpstreamModelList(ctx context.Context, account return nil, nil, newUpstreamModelSyncConfigError("Upstream HTTP client is not configured", nil) } + if account.IsGemini() && account.IsVertexServiceAccount() { + models, err := s.fetchVertexGeminiUpstreamModels(ctx, account) + return models, nil, err + } + req, err := s.buildUpstreamModelsRequest(ctx, account) if err != nil { return nil, nil, err @@ -1093,6 +1103,106 @@ func (s *AccountTestService) readUpstreamModelsPage(req *http.Request, proxyURL return body, nil } +// readUpstreamModelsResponse shares bounded status/body handling with the +// existing paginated account catalog reader. +func (s *AccountTestService) readUpstreamModelsResponse(req *http.Request, account *Account) ([]byte, error) { + return s.readUpstreamModelsPage(req, upstreamModelsProxyURL(account), account, resolveModelsListReadLimit(s.cfg)) +} + +// fetchVertexGeminiUpstreamModels 按账号默认区域读取 Vertex 上可用的 Gemini 模型。列表接口不接受 API Key。 +func (s *AccountTestService) fetchVertexGeminiUpstreamModels(ctx context.Context, account *Account) ([]string, error) { + if account.IsVertexAPIKey() { + return nil, newUpstreamModelSyncUnsupportedError("Vertex API key accounts do not support upstream model listing", nil) + } + if _, err := parseVertexServiceAccountKey(account); err != nil { + return nil, newUpstreamModelSyncConfigError("Invalid Vertex service account credentials", err) + } + location := account.VertexLocation("") + if _, err := buildVertexPublisherModelsURL(location, ""); err != nil { + return nil, newUpstreamModelSyncConfigError("Invalid Vertex location", err) + } + if s.geminiTokenProvider == nil { + return nil, newUpstreamModelSyncConfigError("Gemini token provider is not configured", nil) + } + accessToken, err := s.geminiTokenProvider.GetAccessToken(ctx, account) + if err != nil { + return nil, newUpstreamModelSyncUpstreamError("Failed to get Vertex access token", err) + } + accessToken = strings.TrimSpace(accessToken) + if accessToken == "" { + return nil, newUpstreamModelSyncConfigError("No Vertex access token is available", nil) + } + + return listVertexGeminiPublisherModels(location, func(page int, listURL string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil) + if err != nil { + return nil, newUpstreamModelSyncConfigError("Invalid Vertex model list URL", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + + body, err := s.readUpstreamModelsResponse(req, account) + if err != nil { + // 只有首页的 404/405 表示列表端点不可用;翻页途中的失败不触发配置模型回退。 + var syncErr *UpstreamModelSyncError + if page > 0 && errors.As(err, &syncErr) { + syncErr.StatusCode = 0 + } + return nil, err + } + return body, nil + }) +} + +// listVertexGeminiPublisherModels 按区域分页读取 Google 发布方模型列表,只保留 gemini- 前缀的模型(去重排序)。 +// 区域端点还会列出 Gemma、Veo、AutoML 等非 Gemini 的模型花园条目;global 端点只含 Gemini。 +// fetchPage 负责发送请求并处理非 2xx 响应,返回该页的响应体;它返回的错误原样透出。 +func listVertexGeminiPublisherModels(location string, fetchPage func(page int, listURL string) ([]byte, error)) ([]string, error) { + models := make([]string, 0) + pageToken := "" + for page := 0; page < vertexPublisherModelsMaxPages; page++ { + listURL, err := buildVertexPublisherModelsURL(location, pageToken) + if err != nil { + return nil, newUpstreamModelSyncConfigError("Invalid Vertex location", err) + } + body, err := fetchPage(page, listURL) + if err != nil { + return nil, err + } + var response struct { + PublisherModels []struct { + Name string `json:"name"` + } `json:"publisherModels"` + NextPageToken string `json:"nextPageToken"` + } + if err := json.Unmarshal(body, &response); err != nil { + return nil, newUpstreamModelSyncUpstreamError("Upstream model list response was not valid JSON", err) + } + for _, entry := range response.PublisherModels { + name := strings.TrimSpace(entry.Name) + if !strings.HasPrefix(name, vertexGooglePublisherModelPrefix) { + continue + } + modelID := strings.TrimPrefix(name, vertexGooglePublisherModelPrefix) + if strings.HasPrefix(modelID, "gemini-") && len(modelID) > len("gemini-") { + models = append(models, modelID) + } + } + + pageToken = strings.TrimSpace(response.NextPageToken) + if pageToken == "" { + if len(models) == 0 { + return nil, newUpstreamModelSyncUpstreamError("Upstream returned no supported models", nil) + } + return dedupeAndSortModelIDs(models), nil + } + } + return nil, newUpstreamModelSyncUpstreamError( + "Upstream model list pagination did not finish", + fmt.Errorf("more than %d pages", vertexPublisherModelsMaxPages), + ) +} + func (s *AccountTestService) buildUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) { switch { case account.Platform == PlatformAntigravity: @@ -1525,6 +1635,26 @@ func buildOpenAIModelsURL(base string) string { return buildOpenAIEndpointURL(base, "/v1/models") } +// buildVertexPublisherModelsURL 构造 Google 发布方模型列表地址。该接口只有 v1beta1 版本,pageSize 上限 300。 +func buildVertexPublisherModelsURL(location, pageToken string) (string, error) { + location = strings.TrimSpace(location) + if location == "" { + location = vertexDefaultLocation + } + if !vertexLocationPattern.MatchString(location) { + return "", fmt.Errorf("invalid vertex location: %s", location) + } + host := location + "-aiplatform.googleapis.com" + if location == "global" { + host = vertexGlobalHost + } + query := url.Values{"pageSize": []string{strconv.Itoa(vertexPublisherModelsPageSize)}} + if pageToken != "" { + query.Set("pageToken", pageToken) + } + return fmt.Sprintf("https://%s/v1beta1/publishers/google/models?%s", host, query.Encode()), nil +} + func buildGeminiModelsURL(base string) string { normalized := strings.TrimRight(strings.TrimSpace(base), "/") if strings.HasSuffix(normalized, "/v1beta/models") { diff --git a/backend/internal/service/upstream_models_vertex_test.go b/backend/internal/service/upstream_models_vertex_test.go new file mode 100644 index 000000000000..bd4678614ed2 --- /dev/null +++ b/backend/internal/service/upstream_models_vertex_test.go @@ -0,0 +1,276 @@ +package service + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type vertexModelsTokenCacheStub struct { + token string + calls int +} + +func (c *vertexModelsTokenCacheStub) GetAccessToken(context.Context, string) (string, error) { + c.calls++ + if c.token == "" { + return "", errors.New("missing token") + } + return c.token, nil +} + +func (c *vertexModelsTokenCacheStub) SetAccessToken(context.Context, string, string, time.Duration) error { + return nil +} + +func (c *vertexModelsTokenCacheStub) DeleteAccessToken(context.Context, string) error { + return nil +} + +func (c *vertexModelsTokenCacheStub) AcquireRefreshLock(context.Context, string, time.Duration) (bool, error) { + return false, nil +} + +func (c *vertexModelsTokenCacheStub) ReleaseRefreshLock(context.Context, string) error { + return nil +} + +func vertexModelSyncTestAccount(credentials map[string]any) *Account { + merged := map[string]any{ + "service_account_json": map[string]any{ + "type": "service_account", + "project_id": "proj", + "private_key": "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n", + "client_email": "svc@proj.iam.gserviceaccount.com", + }, + } + for key, value := range credentials { + merged[key] = value + } + return &Account{ + ID: 173, + Platform: PlatformGemini, + Type: AccountTypeServiceAccount, + Credentials: merged, + } +} + +func vertexModelSyncTestService(upstream *httpUpstreamRecorder) *AccountTestService { + return &AccountTestService{ + httpUpstream: upstream, + cfg: upstreamModelSyncTestConfig(), + geminiTokenProvider: NewGeminiTokenProvider(nil, &vertexModelsTokenCacheStub{token: "ya29.vertex-token"}, nil), + } +} + +func vertexModelsJSONResponse(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func TestBuildVertexPublisherModelsURL(t *testing.T) { + t.Parallel() + + u, err := buildVertexPublisherModelsURL("global", "") + require.NoError(t, err) + require.Equal(t, "https://aiplatform.googleapis.com/v1beta1/publishers/google/models?pageSize=300", u) + + u, err = buildVertexPublisherModelsURL("us-central1", "next/page+token") + require.NoError(t, err) + require.Equal(t, "https://us-central1-aiplatform.googleapis.com/v1beta1/publishers/google/models?pageSize=300&pageToken=next%2Fpage%2Btoken", u) + + u, err = buildVertexPublisherModelsURL("", "") + require.NoError(t, err) + require.Equal(t, "https://us-central1-aiplatform.googleapis.com/v1beta1/publishers/google/models?pageSize=300", u) + + _, err = buildVertexPublisherModelsURL("evil.com/x", "") + require.Error(t, err) +} + +// Scenario: Vertex SA 账号按账号区域分页读取发布方模型列表,只保留 gemini- 前缀模型。 +func TestFetchUpstreamSupportedModelsVertexServiceAccountPaginatesAndKeepsGemini(t *testing.T) { + t.Parallel() + + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + vertexModelsJSONResponse(http.StatusOK, `{"publisherModels":[ + {"name":"publishers/google/models/gemini-3.8-flash"}, + {"name":"publishers/google/models/gemma4"}, + {"name":"publishers/google/models/veo-3.1-generate-001"} + ],"nextPageToken":"page-2"}`), + vertexModelsJSONResponse(http.StatusOK, `{"publisherModels":[ + {"name":" publishers/google/models/gemini-2.5-flash "}, + {"name":"publishers/google/models/gemini-3.8-flash"}, + {"name":"gemini-without-publisher-prefix"}, + {"name":"publishers/anthropic/models/gemini-other-publisher"}, + {"name":"publishers/google/models/gemini-"} + ]}`), + }} + svc := vertexModelSyncTestService(upstream) + + models, err := svc.FetchUpstreamSupportedModels(context.Background(), vertexModelSyncTestAccount(map[string]any{ + "location": "europe-west4", + })) + require.NoError(t, err) + require.Equal(t, []string{"gemini-2.5-flash", "gemini-3.8-flash"}, models) + + require.Len(t, upstream.requests, 2) + require.Equal(t, "https://europe-west4-aiplatform.googleapis.com/v1beta1/publishers/google/models?pageSize=300", upstream.requests[0].URL.String()) + require.Equal(t, "https://europe-west4-aiplatform.googleapis.com/v1beta1/publishers/google/models?pageSize=300&pageToken=page-2", upstream.requests[1].URL.String()) + for _, req := range upstream.requests { + require.Equal(t, http.MethodGet, req.Method) + require.Equal(t, "Bearer ya29.vertex-token", req.Header.Get("Authorization")) + } +} + +func TestFetchUpstreamSupportedModelsVertexUpstreamErrorKeepsStatus(t *testing.T) { + t.Parallel() + + upstream := &httpUpstreamRecorder{resp: vertexModelsJSONResponse(http.StatusForbidden, `{"error":{"code":403,"status":"PERMISSION_DENIED"}}`)} + svc := vertexModelSyncTestService(upstream) + + _, err := svc.FetchUpstreamSupportedModels(context.Background(), vertexModelSyncTestAccount(map[string]any{"location": "global"})) + var syncErr *UpstreamModelSyncError + require.ErrorAs(t, err, &syncErr) + require.Equal(t, UpstreamModelSyncErrorUpstream, syncErr.Kind) + require.Equal(t, http.StatusForbidden, syncErr.StatusCode) + require.Equal(t, "https://aiplatform.googleapis.com/v1beta1/publishers/google/models?pageSize=300", upstream.lastReq.URL.String()) +} + +func TestFetchUpstreamSupportedModelsVertexWithoutGeminiModelsFails(t *testing.T) { + t.Parallel() + + upstream := &httpUpstreamRecorder{resp: vertexModelsJSONResponse(http.StatusOK, `{"publisherModels":[{"name":"publishers/google/models/gemma4"}]}`)} + svc := vertexModelSyncTestService(upstream) + + _, err := svc.FetchUpstreamSupportedModels(context.Background(), vertexModelSyncTestAccount(nil)) + var syncErr *UpstreamModelSyncError + require.ErrorAs(t, err, &syncErr) + require.Equal(t, UpstreamModelSyncErrorUpstream, syncErr.Kind) + require.Equal(t, "Upstream returned no supported models", syncErr.SafeMessage()) +} + +func TestFetchUpstreamSupportedModelsVertexStopsEndlessPagination(t *testing.T) { + t.Parallel() + + responses := make([]*http.Response, 0, vertexPublisherModelsMaxPages) + for i := 0; i < vertexPublisherModelsMaxPages; i++ { + responses = append(responses, vertexModelsJSONResponse(http.StatusOK, `{"publisherModels":[{"name":"publishers/google/models/gemini-3.8-flash"}],"nextPageToken":"again"}`)) + } + upstream := &httpUpstreamRecorder{responses: responses} + svc := vertexModelSyncTestService(upstream) + + _, err := svc.FetchUpstreamSupportedModels(context.Background(), vertexModelSyncTestAccount(nil)) + var syncErr *UpstreamModelSyncError + require.ErrorAs(t, err, &syncErr) + require.Equal(t, "Upstream model list pagination did not finish", syncErr.SafeMessage()) + require.Len(t, upstream.requests, vertexPublisherModelsMaxPages) +} + +func TestFetchUpstreamSupportedModelsVertexAPIKeyUnsupported(t *testing.T) { + t.Parallel() + + upstream := &httpUpstreamRecorder{} + svc := vertexModelSyncTestService(upstream) + account := &Account{ + ID: 174, + Platform: PlatformGemini, + Type: AccountTypeServiceAccount, + Credentials: map[string]any{ + "auth_mode": "apikey", + "api_key": "AQ.test-key", + }, + } + + _, err := svc.FetchUpstreamSupportedModels(context.Background(), account) + var syncErr *UpstreamModelSyncError + require.ErrorAs(t, err, &syncErr) + require.Equal(t, UpstreamModelSyncErrorUnsupported, syncErr.Kind) + require.Empty(t, upstream.requests) +} + +func TestFetchUpstreamSupportedModelsVertexInvalidCredentialsIsConfigError(t *testing.T) { + t.Parallel() + + cache := &vertexModelsTokenCacheStub{token: "ya29.vertex-token"} + upstream := &httpUpstreamRecorder{} + svc := &AccountTestService{ + httpUpstream: upstream, + cfg: upstreamModelSyncTestConfig(), + geminiTokenProvider: NewGeminiTokenProvider(nil, cache, nil), + } + account := &Account{ + ID: 175, + Platform: PlatformGemini, + Type: AccountTypeServiceAccount, + Credentials: map[string]any{"api_key": "not-a-service-account"}, + } + + _, err := svc.FetchUpstreamSupportedModels(context.Background(), account) + var syncErr *UpstreamModelSyncError + require.ErrorAs(t, err, &syncErr) + require.Equal(t, UpstreamModelSyncErrorConfiguration, syncErr.Kind) + require.Equal(t, "Invalid Vertex service account credentials", syncErr.SafeMessage()) + require.Zero(t, cache.calls) + require.Empty(t, upstream.requests) +} + +func TestFetchUpstreamSupportedModelsVertexInvalidLocationSkipsTokenExchange(t *testing.T) { + t.Parallel() + + cache := &vertexModelsTokenCacheStub{token: "ya29.vertex-token"} + upstream := &httpUpstreamRecorder{} + svc := &AccountTestService{ + httpUpstream: upstream, + cfg: upstreamModelSyncTestConfig(), + geminiTokenProvider: NewGeminiTokenProvider(nil, cache, nil), + } + + _, err := svc.FetchUpstreamSupportedModels(context.Background(), vertexModelSyncTestAccount(map[string]any{ + "location": "us-central1.evil.com", + })) + var syncErr *UpstreamModelSyncError + require.ErrorAs(t, err, &syncErr) + require.Equal(t, UpstreamModelSyncErrorConfiguration, syncErr.Kind) + require.Equal(t, "Invalid Vertex location", syncErr.SafeMessage()) + require.Zero(t, cache.calls) + require.Empty(t, upstream.requests) +} + +// Scenario: 翻页途中的 404 不能被当成列表端点不可用而回退到配置模型。 +func TestSyncUpstreamModelCatalogVertexMidPagination404DoesNotFallback(t *testing.T) { + t.Parallel() + + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + vertexModelsJSONResponse(http.StatusOK, `{"publisherModels":[{"name":"publishers/google/models/gemini-a"}],"nextPageToken":"page-2"}`), + vertexModelsJSONResponse(http.StatusNotFound, `{"error":{"code":404}}`), + }} + svc := vertexModelSyncTestService(upstream) + + _, err := svc.SyncUpstreamModelCatalog(context.Background(), vertexModelSyncTestAccount(map[string]any{ + "model_mapping": map[string]any{"x": "gemini-mapped"}, + })) + var syncErr *UpstreamModelSyncError + require.ErrorAs(t, err, &syncErr) + require.Equal(t, UpstreamModelSyncErrorUpstream, syncErr.Kind) + require.Zero(t, syncErr.StatusCode) + require.Len(t, upstream.requests, 2) +} + +func TestFetchUpstreamSupportedModelsVertexFirstPage404KeepsStatus(t *testing.T) { + t.Parallel() + + upstream := &httpUpstreamRecorder{resp: vertexModelsJSONResponse(http.StatusNotFound, `{"error":{"code":404}}`)} + svc := vertexModelSyncTestService(upstream) + + _, err := svc.FetchUpstreamSupportedModels(context.Background(), vertexModelSyncTestAccount(nil)) + require.True(t, upstreamModelListEndpointUnsupported(err)) +} diff --git a/backend/internal/service/vertex_service_account.go b/backend/internal/service/vertex_service_account.go index 7ccbeee43ce7..2a19a50e21b1 100644 --- a/backend/internal/service/vertex_service_account.go +++ b/backend/internal/service/vertex_service_account.go @@ -29,6 +29,8 @@ const ( vertexServiceAccountCacheSkew = 5 * time.Minute vertexLockWaitTime = 200 * time.Millisecond vertexAnthropicVersion = "vertex-2023-10-16" + vertexGlobalHost = "aiplatform.googleapis.com" + vertexAuthModeAPIKey = "apikey" ) var ( @@ -58,6 +60,13 @@ func (a *Account) IsVertexServiceAccount() bool { return a != nil && a.Type == AccountTypeServiceAccount } +// IsVertexAPIKey 判断 Gemini 平台的 Vertex 账号是否使用 API Key 认证(credentials.auth_mode=apikey)。 +// Anthropic 平台的 Vertex 账号只支持 Service Account JSON。 +func (a *Account) IsVertexAPIKey() bool { + return a.IsVertexServiceAccount() && a.Platform == PlatformGemini && + strings.TrimSpace(a.GetCredential("auth_mode")) == vertexAuthModeAPIKey +} + func (a *Account) VertexProjectID() string { if a == nil { return "" @@ -283,6 +292,63 @@ func exchangeVertexServiceAccountToken(ctx context.Context, key *vertexServiceAc return parsed.AccessToken, ttl, nil } +// buildVertexGeminiAccountURL 按账号构造 Vertex Gemini 请求地址:未配置 project_id 的 API Key 账号 +// 走 Express mode 全局端点,其余账号走项目级端点。 +func buildVertexGeminiAccountURL(account *Account, model, action string, stream bool) (string, error) { + if account.IsVertexAPIKey() && account.VertexProjectID() == "" { + return buildVertexGeminiExpressURL(model, action, stream) + } + return buildVertexGeminiURL(account.VertexProjectID(), account.VertexLocation(model), model, action, stream) +} + +// buildVertexGeminiExpressURL 构造不带项目与区域的全局端点地址,仅 API Key 认证可用。 +func buildVertexGeminiExpressURL(model, action string, stream bool) (string, error) { + model = strings.TrimSpace(model) + action = strings.TrimSpace(action) + if model == "" { + return "", errors.New("vertex model is required") + } + if err := validateVertexGeminiAction(action); err != nil { + return "", err + } + u := fmt.Sprintf("https://%s/v1/publishers/google/models/%s:%s", vertexGlobalHost, url.PathEscape(model), action) + if stream { + u += "?alt=sse" + } + return u, nil +} + +func validateVertexGeminiAction(action string) error { + switch action { + case "generateContent", "streamGenerateContent", "countTokens": + return nil + default: + return fmt.Errorf("unsupported vertex gemini action: %s", action) + } +} + +// setVertexGeminiAuth 给 Vertex Gemini 请求设置认证头:API Key 账号用 x-goog-api-key, +// Service Account JSON 账号用换取的 Bearer access token。 +func setVertexGeminiAuth(ctx context.Context, req *http.Request, account *Account, tokenProvider *GeminiTokenProvider) error { + if account.IsVertexAPIKey() { + apiKey := strings.TrimSpace(account.GetCredential("api_key")) + if apiKey == "" { + return errors.New("vertex api_key not configured") + } + req.Header.Set("x-goog-api-key", apiKey) + return nil + } + if tokenProvider == nil { + return errors.New("gemini token provider not configured") + } + accessToken, err := tokenProvider.GetAccessToken(ctx, account) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + return nil +} + func buildVertexGeminiURL(projectID, location, model, action string, stream bool) (string, error) { projectID = strings.TrimSpace(projectID) location = strings.TrimSpace(location) @@ -300,14 +366,12 @@ func buildVertexGeminiURL(projectID, location, model, action string, stream bool if model == "" { return "", errors.New("vertex model is required") } - switch action { - case "generateContent", "streamGenerateContent", "countTokens": - default: - return "", fmt.Errorf("unsupported vertex gemini action: %s", action) + if err := validateVertexGeminiAction(action); err != nil { + return "", err } host := fmt.Sprintf("%s-aiplatform.googleapis.com", location) if location == "global" { - host = "aiplatform.googleapis.com" + host = vertexGlobalHost } u := fmt.Sprintf( "https://%s/v1/projects/%s/locations/%s/publishers/google/models/%s:%s", diff --git a/backend/internal/service/vertex_service_account_test.go b/backend/internal/service/vertex_service_account_test.go index 68c756eaa2f5..77cb6617c79e 100644 --- a/backend/internal/service/vertex_service_account_test.go +++ b/backend/internal/service/vertex_service_account_test.go @@ -30,6 +30,62 @@ func TestBuildVertexGeminiURLUsesGlobalEndpointHost(t *testing.T) { require.Equal(t, "https://aiplatform.googleapis.com/v1/projects/my-project/locations/global/publishers/google/models/gemini-3-flash-preview:streamGenerateContent?alt=sse", got) } +func TestIsVertexAPIKey(t *testing.T) { + apiKeyCreds := map[string]any{"auth_mode": "apikey", "api_key": "k"} + require.True(t, (&Account{Platform: PlatformGemini, Type: AccountTypeServiceAccount, Credentials: apiKeyCreds}).IsVertexAPIKey()) + require.False(t, (&Account{Platform: PlatformGemini, Type: AccountTypeServiceAccount, Credentials: map[string]any{"service_account_json": "{}"}}).IsVertexAPIKey()) + require.False(t, (&Account{Platform: PlatformAnthropic, Type: AccountTypeServiceAccount, Credentials: apiKeyCreds}).IsVertexAPIKey()) + require.False(t, (&Account{Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: apiKeyCreds}).IsVertexAPIKey()) +} + +func TestBuildVertexGeminiAccountURL(t *testing.T) { + cases := []struct { + name string + credentials map[string]any + want string + }{ + { + name: "api key without project uses express global endpoint", + credentials: map[string]any{"auth_mode": "apikey", "api_key": "k", "location": "us-central1"}, + want: "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3-flash-preview:streamGenerateContent?alt=sse", + }, + { + name: "api key with project uses project endpoint", + credentials: map[string]any{"auth_mode": "apikey", "api_key": "k", "project_id": "my-project", "location": "us-central1"}, + want: "https://us-central1-aiplatform.googleapis.com/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini-3-flash-preview:streamGenerateContent?alt=sse", + }, + { + name: "service account uses project endpoint", + credentials: map[string]any{"project_id": "sa-project", "location": "global"}, + want: "https://aiplatform.googleapis.com/v1/projects/sa-project/locations/global/publishers/google/models/gemini-3-flash-preview:streamGenerateContent?alt=sse", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + account := &Account{Platform: PlatformGemini, Type: AccountTypeServiceAccount, Credentials: tc.credentials} + got, err := buildVertexGeminiAccountURL(account, "gemini-3-flash-preview", "streamGenerateContent", true) + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } + + account := &Account{Platform: PlatformGemini, Type: AccountTypeServiceAccount, Credentials: map[string]any{"auth_mode": "apikey", "api_key": "k"}} + _, err := buildVertexGeminiAccountURL(account, "gemini-3-flash-preview", "rawPredict", false) + require.ErrorContains(t, err, "unsupported vertex gemini action") +} + +func TestSetVertexGeminiAuthUsesAPIKeyHeader(t *testing.T) { + account := &Account{Platform: PlatformGemini, Type: AccountTypeServiceAccount, Credentials: map[string]any{"auth_mode": "apikey", "api_key": " vertex-key "}} + req := httptest.NewRequest(http.MethodPost, "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3-flash-preview:generateContent", nil) + + require.NoError(t, setVertexGeminiAuth(context.Background(), req, account, nil)) + require.Equal(t, "vertex-key", req.Header.Get("x-goog-api-key")) + require.Empty(t, req.Header.Get("Authorization")) + + account.Credentials["api_key"] = "" + require.ErrorContains(t, setVertexGeminiAuth(context.Background(), req, account, nil), "vertex api_key not configured") +} + func TestBuildVertexAnthropicURL(t *testing.T) { got, err := buildVertexAnthropicURL("my-project", "us-east5", "claude-sonnet-4-5@20250929", false) require.NoError(t, err) diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index a33942243c85..433034fb9293 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -765,6 +765,13 @@ func ProvideIdempotencyCleanupService(repo IdempotencyRepository, cfg *config.Co return svc } +// ProvideGeminiCachedContentService creates GeminiCachedContentService and starts its mutation recovery loop. +func ProvideGeminiCachedContentService(repo GeminiCachedContentRepository, compat *GeminiMessagesCompatService, cfg *config.Config) *GeminiCachedContentService { + svc := NewGeminiCachedContentService(repo, compat, cfg) + svc.Start() + return svc +} + // ProvideScheduledTestService creates ScheduledTestService. func ProvideScheduledTestService( planRepo ScheduledTestPlanRepository, @@ -1036,6 +1043,7 @@ var ProviderSet = wire.NewSet( ProvideGrokOAuthService, wire.Bind(new(GrokOAuthTokenService), new(*GrokOAuthService)), NewGeminiOAuthService, + ProvideGeminiCachedContentService, NewGeminiQuotaService, NewCompositeTokenCacheInvalidator, wire.Bind(new(TokenCacheInvalidator), new(*CompositeTokenCacheInvalidator)), diff --git a/backend/internal/util/logredact/redact.go b/backend/internal/util/logredact/redact.go index 9249b761c791..8be6448a1809 100644 --- a/backend/internal/util/logredact/redact.go +++ b/backend/internal/util/logredact/redact.go @@ -42,6 +42,8 @@ type textRedactPatterns struct { var ( reGOCSPX = regexp.MustCompile(`GOCSPX-[0-9A-Za-z_-]{24,}`) reAIza = regexp.MustCompile(`AIza[0-9A-Za-z_-]{35}`) + // Gemini auth key:AQ.Ab 前缀 + 48 位,长度放宽以覆盖格式变体。 + reGeminiAuthKey = regexp.MustCompile(`AQ\.Ab[0-9A-Za-z_-]{40,}`) defaultTextRedactPatterns = compileTextRedactPatterns(nil) extraTextPatternCache sync.Map // map[string]*textRedactPatterns @@ -99,6 +101,7 @@ func RedactText(input string, extraKeys ...string) string { out := input out = reGOCSPX.ReplaceAllString(out, "GOCSPX-***") out = reAIza.ReplaceAllString(out, "AIza***") + out = reGeminiAuthKey.ReplaceAllString(out, "AQ.***") out = patterns.reJSONLike.ReplaceAllString(out, `$1***$3`) out = patterns.reQueryLike.ReplaceAllString(out, `$1=***`) out = patterns.rePlain.ReplaceAllString(out, `$1$2***`) diff --git a/backend/internal/util/logredact/redact_test.go b/backend/internal/util/logredact/redact_test.go index 266db69dbd78..e615f33e0e7f 100644 --- a/backend/internal/util/logredact/redact_test.go +++ b/backend/internal/util/logredact/redact_test.go @@ -38,6 +38,28 @@ func TestRedactText_GOCSPX(t *testing.T) { } } +func TestRedactText_GeminiAPIKeys(t *testing.T) { + legacyKey := "AI" + "za" + strings.Repeat("Ab1_-", 7) + authKey := "AQ." + "Ab" + strings.Repeat("Xy9_-z", 8) + in := "upstream rejected " + legacyKey + " and " + authKey + " for model gemini-3.1-pro" + out := RedactText(in) + if strings.Contains(out, legacyKey) || strings.Contains(out, authKey) { + t.Fatalf("expected Gemini keys redacted, got %q", out) + } + for _, want := range []string{"AIza***", "AQ.***", "for model gemini-3.1-pro"} { + if !strings.Contains(out, want) { + t.Fatalf("expected %q in %q", want, out) + } + } +} + +func TestRedactText_GeminiAuthKeyPrefixAloneUnchanged(t *testing.T) { + in := "key format AQ.Ab... is documented" + if out := RedactText(in); out != in { + t.Fatalf("expected short AQ. prefix unchanged, got %q", out) + } +} + func TestRedactText_ExtraKeyCacheUsesNormalizedSortedKey(t *testing.T) { clearExtraTextPatternCache() diff --git a/backend/migrations/242_group_i18n.sql b/backend/migrations/242_group_i18n.sql new file mode 100644 index 000000000000..2e0ab2c63ae5 --- /dev/null +++ b/backend/migrations/242_group_i18n.sql @@ -0,0 +1,5 @@ +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS i18n JSONB NOT NULL DEFAULT '{}'::jsonb; + +COMMENT ON COLUMN groups.i18n IS + 'Per-locale display text for the group: {"":{"name":string,"description":string}}; locales or fields that are absent fall back to groups.name / groups.description'; diff --git a/backend/migrations/243_gemini_cached_contents.sql b/backend/migrations/243_gemini_cached_contents.sql new file mode 100644 index 000000000000..d3cbda27dfd2 --- /dev/null +++ b/backend/migrations/243_gemini_cached_contents.sql @@ -0,0 +1,30 @@ +-- Gemini 显式缓存(cachedContents)的网关资源绑定。 +-- public_id 是网关对客户端暴露的缓存 ID;upstream_name 是上游资源名, +-- 引用缓存的请求只能路由到 account_id 对应的账号。 +CREATE TABLE IF NOT EXISTS gemini_cached_contents ( + id BIGSERIAL PRIMARY KEY, + public_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + api_key_id BIGINT NOT NULL, + group_id BIGINT NOT NULL, + account_id BIGINT NOT NULL, + upstream_name VARCHAR(512) NOT NULL, + model VARCHAR(255) NOT NULL, + upstream_model VARCHAR(512) NOT NULL, + display_name VARCHAR(512) NOT NULL DEFAULT '', + total_token_count BIGINT NOT NULL DEFAULT 0, + expire_time TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_gemini_cached_contents_public_id + ON gemini_cached_contents (public_id); + +CREATE INDEX IF NOT EXISTS idx_gemini_cached_contents_api_key_active + ON gemini_cached_contents (api_key_id, id DESC) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_gemini_cached_contents_account + ON gemini_cached_contents (account_id); diff --git a/backend/migrations/244_gemini_cached_contents_channel_usage.sql b/backend/migrations/244_gemini_cached_contents_channel_usage.sql new file mode 100644 index 000000000000..c72f097a7303 --- /dev/null +++ b/backend/migrations/244_gemini_cached_contents_channel_usage.sql @@ -0,0 +1,4 @@ +-- Gemini 显式缓存创建时的渠道计费口径(渠道 ID / 请求模型 / 计费模型来源), +-- 延长有效期按同一口径计费。 +ALTER TABLE gemini_cached_contents + ADD COLUMN IF NOT EXISTS channel_usage JSONB NOT NULL DEFAULT '{}'::jsonb; diff --git a/backend/migrations/245_gemini_cached_contents_mutation_lease.sql b/backend/migrations/245_gemini_cached_contents_mutation_lease.sql new file mode 100644 index 000000000000..e0970efebf75 --- /dev/null +++ b/backend/migrations/245_gemini_cached_contents_mutation_lease.sql @@ -0,0 +1,10 @@ +-- Gemini 显式缓存修改有效期的进行中操作(修改意图):mutation_token 标识持有者, +-- mutation_lease_until 之前同一缓存的其他修改被拒绝;expire_time 在提交前保持修改前的值, +-- 租约过期仍未清除的意图由恢复流程把上游有效期回滚到 expire_time。 +ALTER TABLE gemini_cached_contents + ADD COLUMN IF NOT EXISTS mutation_token VARCHAR(64) NULL, + ADD COLUMN IF NOT EXISTS mutation_lease_until TIMESTAMPTZ NULL; + +CREATE INDEX IF NOT EXISTS idx_gemini_cached_contents_pending_mutation + ON gemini_cached_contents (mutation_lease_until) + WHERE mutation_token IS NOT NULL AND deleted_at IS NULL; diff --git a/backend/resources/model-pricing/model_prices_and_context_window.json b/backend/resources/model-pricing/model_prices_and_context_window.json index 3329344bd601..4f83413767e0 100644 --- a/backend/resources/model-pricing/model_prices_and_context_window.json +++ b/backend/resources/model-pricing/model_prices_and_context_window.json @@ -381,6 +381,40 @@ "supports_tool_choice": true, "supports_vision": true }, + "claude-haiku-5-5": { + "cache_creation_input_token_cost": 1.25e-07, + "cache_creation_input_token_cost_above_1hr": 2e-07, + "cache_creation_input_token_cost_above_100k_tokens": 6.25e-07, + "cache_creation_input_token_cost_above_1hr_above_100k_tokens": 1e-06, + "cache_read_input_token_cost": 1e-08, + "cache_read_input_token_cost_above_100k_tokens": 5e-08, + "input_cost_per_token": 1e-07, + "input_cost_per_token_above_100k_tokens": 5e-07, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-07, + "output_cost_per_token_above_100k_tokens": 2.5e-06, + "source": "https://platform.claude.com/docs/en/models/haiku-5-5/overview", + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "input_cost_per_token_batches": 5e-08, + "output_cost_per_token_batches": 2.5e-07 + }, "claude-opus-4-1": { "cache_creation_input_token_cost": 1.875e-05, "cache_creation_input_token_cost_above_1hr": 3e-05, @@ -2608,17 +2642,17 @@ "web_search_billing_unit": "per_query" }, "gemini-3.6-flash": { - "cache_read_input_token_cost": 1.5e-07, - "cache_read_input_token_cost_priority": 2.7e-07, - "input_cost_per_token": 1.5e-06, - "input_cost_per_token_priority": 2.7e-06, + "cache_read_input_token_cost": 7.5e-08, + "cache_read_input_token_cost_priority": 1.35e-07, + "input_cost_per_token": 7.5e-07, + "input_cost_per_token_priority": 1.35e-06, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 1048576, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", - "output_cost_per_token": 7.5e-06, - "output_cost_per_token_priority": 1.35e-05, + "output_cost_per_token": 3.75e-06, + "output_cost_per_token_priority": 6.75e-06, "source": "https://ai.google.dev/gemini-api/docs/latest-model", "supports_prompt_caching": true, "supports_reasoning": true, diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 181a2d9c1970..6963d9587050 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -256,6 +256,9 @@ gateway: # Enable Gemini upstream response header debug logs (default: false) # 是否开启 Gemini 上游响应头调试日志(默认 false) gemini_debug_response_headers: false + # Maximum TTL (seconds) a Gemini explicit context cache may be created or extended to (default: 86400) + # Gemini 显式上下文缓存单次可设置的最长有效期(秒,默认 86400);存储费在创建/延长请求中一次性收取 + gemini_cached_content_max_ttl_seconds: 86400 # Connection pool isolation strategy: # 连接池隔离策略: # - proxy: Isolate by proxy, same proxy shares connection pool (suitable for few proxies, many accounts) @@ -1116,6 +1119,14 @@ pricing: # 覆盖补丁文件(可选,优先级最高):条目按字段浅合并覆盖目录/回退数据, # 字段值为 null 表示删除该字段。模型名须与目录键完全一致。 # 修改后在下一次哈希检查时按内容哈希自动重载,无需重启。 + # Gemini explicit context caching storage price (USD per token per hour) comes + # from the field cache_storage_cost_per_token_per_hour here, e.g. + # {"gemini-3.8-flash": {"cache_storage_cost_per_token_per_hour": 0.0000005}}; + # without it, built-in official prices apply (released models first, then the + # Pro / Flash series rate). Models with no price at all reject POST /v1beta/cachedContents. + # Gemini 显式缓存存储单价(每 token 每小时,USD)优先取这里的 cache_storage_cost_per_token_per_hour 字段, + # 未配置时使用内置官方价(先按已发布型号,再按 Pro / Flash 系列兜底);两者都没有的模型拒绝创建缓存。 + # 目录中不存在的模型条目还须同时提供 input_cost_per_token / output_cost_per_token。 # override_file: "./data/model_pricing_overrides.json" # Update interval in hours # 更新间隔(小时) diff --git a/frontend/src/components/account/AccountGroupsCell.vue b/frontend/src/components/account/AccountGroupsCell.vue index 01c395fb16b3..ab730d26b8c9 100644 --- a/frontend/src/components/account/AccountGroupsCell.vue +++ b/frontend/src/components/account/AccountGroupsCell.vue @@ -1,7 +1,7 @@