diff --git a/.downstream/upstream-base b/.downstream/upstream-base index 57645dd8fcb2..400feeb94ad4 100644 --- a/.downstream/upstream-base +++ b/.downstream/upstream-base @@ -1 +1 @@ -v0.2.5 \ No newline at end of file +v0.2.6 diff --git a/.downstream/upstream-review-v0.2.6.md b/.downstream/upstream-review-v0.2.6.md new file mode 100644 index 000000000000..45ac119e5e41 --- /dev/null +++ b/.downstream/upstream-review-v0.2.6.md @@ -0,0 +1,39 @@ +# Recovered upstream v0.2.6 integration review + +## Source and provenance + +- Downstream base: `v0.2.5-codexrip.14`, commit `a2f13d34a41bb89225e5d6864d466b4a35d6448f`. +- Recovered upstream commit: `49a39b6dc1abed30fd227611e8af1108bc427610`. +- Upstream parents: `efe9aab1e4ec89a42ba45e8dac20e882c5409a6a`, `3c2f05c957b4b93866318ec8695fc5a28fff70eb`. +- Upstream tree: `61de86700f217d58c337998f3c51a89969493267`. +- The public `v0.2.6` tag/Release is withdrawn. This review uses the exact commit object; it does not use a mutable tag or `latest` image. +- Machine-readable evidence: `.downstream/upstream-risk.json`. It records the exact commit, tree, parents, tag-unavailable state, overlap counts and the original Git conflict list. + +## Integration scope + +The complete recovered merge tree is integrated. This includes the upstream changes after the official v0.2.5 baseline and the six Codex ticket patches: ticket harvesting, live management settings, ticket injection, export/edit redaction, compact-model gating and scheduler-neutral ticket writes. + +Ticket runtime defaults remain safe: `enabled=false`, empty harvest proxy, `fail_closed=true`, 292-character state, one-hour TTL, ten-minute refresh window, and the upstream target models. No harvest proxy is written during this release and the management switch stays off. + +## Conflict decisions + +- Account DTOs retain downstream fields for provider identity, folders, tags, Cindy state, quotas, shadow accounts and parent metadata; upstream `codex_turn_tickets` status is added without returning private state. +- Locked account Extra merging retains downstream model-context capacity/override/metadata values and upstream current ticket entries. Non-object historical Extra values degrade ticket preservation without blocking account edits. +- Account Extra updates preserve downstream reasoning/fingerprint/quota protections and reject administrator-supplied ticket material. +- Wire generation retains downstream account jobs, Cindy health/balance, system prompts, remote skills, Image Studio, traffic telemetry and identity backfill while wiring the upstream SettingService, PluginManager and ticket harvester lifecycle. +- OpenAI OAuth/Setup Token scheduling combines official outbound-model/compact ticket gating with downstream runtime-breaker, probe-ownership, cooldown and continuation/sticky protections. Cindy/API-key paths do not enter the ticket predicate. +- Response affinity uses the downstream cancellation-independent Redis context and upstream owner-binding diagnostics. +- Frontend settings and account views retain downstream Cindy and permission behavior while adding ticket status, masked proxy settings and live-toggle coverage. +- Public model management remains retired; Image Studio and Responses image bridge remain off by default. + +## Validation status + +- Passed: Wire regeneration, focused backend ticket/account/DTO tests, focused frontend settings/account/dialog/manifest tests, frontend production build and `git diff --check`. +- Passed: full backend compile (`go test -run '^$' ./...`), `go vet ./...`, `go mod verify`, focused runtime/continuation/Cindy tests and risk/automation contract tests. +- Passed: US `resource-preflight` baseline; evidence `artifacts/evidence/us-runtime-audit-2026-09-18_140357_769694.log` records the `.14` image, digest, healthy container, resource profile, guard hashes and unchanged companion services. +- Pending: required PR checks, Release and production deployment. +- Not run: real upstream model calls, harvest-proxy calls, management-page activation, canary, long observation and production deployment. + +## Release boundary + +The candidate release is `v0.2.6-codexrip.1`. Production deployment uses the existing immutable-digest `deploy-preserve` path and changes only the `sub2api` container. The release does not claim that ticket quality improvements have been model-validated while the feature remains disabled. diff --git a/.downstream/upstream-risk.json b/.downstream/upstream-risk.json index cf61c6d7790d..365ed78e0235 100644 --- a/.downstream/upstream-risk.json +++ b/.downstream/upstream-risk.json @@ -1,75 +1,47 @@ { - "critical_file_count": 66, + "critical_file_count": 38, "critical_files": [ - "backend/ent/channelmonitor/channelmonitor.go", - "backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go", - "backend/ent/client.go", - "backend/ent/migrate/schema.go", - "backend/ent/mutation.go", - "backend/ent/proxy.go", - "backend/ent/proxy/proxy.go", - "backend/ent/proxy/where.go", - "backend/ent/proxy_create.go", - "backend/ent/proxy_query.go", - "backend/ent/proxy_update.go", - "backend/ent/schema/channel_monitor.go", - "backend/ent/schema/channel_monitor_request_template.go", - "backend/ent/schema/proxy.go", - "backend/ent/schema/user_platform_quota.go", "backend/go.mod", "backend/go.sum", - "backend/internal/repository/account_repo_ollama_cloud_usage.go", - "backend/internal/repository/account_repo_ollama_cloud_usage_test.go", - "backend/internal/repository/channel_cache.go", - "backend/internal/repository/channel_cache_test.go", - "backend/internal/repository/concurrency_cache.go", + "backend/internal/repository/account_repo.go", + "backend/internal/repository/account_repo_codex_ticket_test.go", + "backend/internal/repository/account_repo_integration_test.go", + "backend/internal/repository/account_repo_temp_unsched_test.go", + "backend/internal/repository/account_repo_upstream_billing_probe_update_test.go", + "backend/internal/repository/custom_group_usage_rollup_repo.go", "backend/internal/repository/http_upstream.go", - "backend/internal/repository/http_upstream_http2_keepalive_test.go", "backend/internal/repository/http_upstream_test.go", - "backend/internal/repository/proxy_backup_reference_integration_test.go", - "backend/internal/repository/proxy_repeated_expiry_integration_test.go", - "backend/internal/repository/proxy_repo.go", - "backend/internal/repository/proxy_repo_upstream_billing_probe_test.go", - "backend/internal/repository/user_msg_queue_cache.go", - "backend/internal/repository/user_platform_quota_repo_integration_test.go", - "backend/internal/repository/wire.go", - "backend/internal/service/billing_token_cost_request_test.go", + "backend/internal/repository/redeem_code_repo.go", + "backend/internal/repository/redeem_code_repo_sort_integration_test.go", + "backend/internal/repository/usage_log_repo_group_summary_test.go", "backend/internal/service/openai_account_runtime_block_fastpath.go", "backend/internal/service/openai_account_runtime_block_fastpath_test.go", "backend/internal/service/openai_account_runtime_transient_test.go", + "backend/internal/service/openai_account_scheduler.go", + "backend/internal/service/openai_chat_roles.go", + "backend/internal/service/openai_chat_roles_test.go", "backend/internal/service/openai_codex_models_service.go", "backend/internal/service/openai_codex_models_service_test.go", - "backend/internal/service/openai_codex_transform.go", - "backend/internal/service/openai_gateway_chat_completions.go", - "backend/internal/service/openai_gateway_chat_completions_cancellation_test.go", + "backend/internal/service/openai_codex_ticket.go", + "backend/internal/service/openai_codex_ticket_lifecycle_test.go", + "backend/internal/service/openai_codex_ticket_test.go", + "backend/internal/service/openai_codex_turn_state.go", "backend/internal/service/openai_gateway_chat_completions_raw.go", - "backend/internal/service/openai_gateway_cn_fixes_test.go", - "backend/internal/service/openai_gateway_grok.go", + "backend/internal/service/openai_gateway_forward.go", + "backend/internal/service/openai_gateway_messages.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_scheduling.go", - "backend/internal/service/openai_images.go", - "backend/internal/service/openai_images_model_test.go", - "backend/internal/service/openai_images_responses.go", - "backend/internal/service/openai_responses_tool_schema_test.go", - "backend/internal/service/openai_upstream_transport_error.go", - "backend/internal/service/openai_ws_forwarder_client_cancel_test.go", - "backend/internal/service/openai_ws_forwarder_v2.go", - "backend/internal/service/pricing_service.go", - "backend/internal/service/ratelimit_service.go", - "backend/internal/service/ratelimit_service_openai_test.go", - "backend/migrations/237_add_minimax_platform.sql", - "backend/migrations/minimax_platform_migration_test.go", - "deploy/.env.example", - "deploy/APPLE_CONTAINER.md", - "deploy/apple-container.sh", - "deploy/docker-compose.dev.yml", - "deploy/docker-compose.local.yml", - "deploy/docker-compose.standalone.yml", - "deploy/docker-compose.yml", - "deploy/tests/apple-container-test.sh", - "deploy/tests/fixtures/bin/container" + "backend/internal/service/openai_gateway_service.go", + "backend/internal/service/openai_gateway_service_test.go", + "backend/internal/service/openai_guardian_affinity_test.go", + "backend/internal/service/openai_ws_forwarder_payload.go", + "backend/internal/service/openai_ws_forwarder_support.go", + "deploy/config.example.yaml" ], - "downstream_commit": "851e6868087a258a915484c3963d97f2b55710d2", - "downstream_file_count": 1679, + "downstream_commit": "a2f13d34a41bb89225e5d6864d466b4a35d6448f", + "downstream_file_count": 1918, "downstream_files": [ "\"backend/internal/service/remote_skill_seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\\344\\277\\241\\346\\201\\257\\346\\224\\266\\351\\233\\206.md\"", "\"backend/internal/service/remote_skill_seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\\345\\205\\215\\346\\235\\200\\344\\270\\216\\350\\247\\204\\351\\201\\277.md\"", @@ -121,9 +93,11 @@ ".dockerignore", ".downstream/README.md", ".downstream/cindy-catalog-refresh-20260905.json", + ".downstream/cindy-catalog-refresh-20260911.json", ".downstream/upstream-0.2.0-overlap-review.md", ".downstream/upstream-base", ".downstream/upstream-review-v0.2.1.md", + ".downstream/upstream-review-v0.2.5.md", ".downstream/upstream-risk.json", ".gitattributes", ".github/scripts/test_agent_protocol.sh", @@ -163,6 +137,7 @@ "backend/cmd/cleanup-ingress-reject-logs/main.go", "backend/cmd/profit-preview/main.go", "backend/cmd/profit-preview/main_test.go", + "backend/cmd/server/VERSION", "backend/cmd/server/main.go", "backend/cmd/server/main_test.go", "backend/cmd/server/wire.go", @@ -252,12 +227,10 @@ "backend/internal/config/config_test.go", "backend/internal/config/image_studio_feature_flags.go", "backend/internal/domain/constants.go", - "backend/internal/domain/managed_model_routes.go", - "backend/internal/domain/managed_model_routes_test.go", - "backend/internal/handler/admin/account_capability_catalog_handler.go", - "backend/internal/handler/admin/account_capability_jobs_handler.go", - "backend/internal/handler/admin/account_capability_jobs_handler_test.go", - "backend/internal/handler/admin/account_capability_publication.go", + "backend/internal/handler/admin/account_api_key_visibility.go", + "backend/internal/handler/admin/account_api_key_visibility_test.go", + "backend/internal/handler/admin/account_batch_test_test.go", + "backend/internal/handler/admin/account_batch_testing.go", "backend/internal/handler/admin/account_cindy_balance.go", "backend/internal/handler/admin/account_cindy_balance_test.go", "backend/internal/handler/admin/account_cindy_duplicates.go", @@ -270,6 +243,7 @@ "backend/internal/handler/admin/account_data_import_job.go", "backend/internal/handler/admin/account_data_import_preview_test.go", "backend/internal/handler/admin/account_data_import_v2.go", + "backend/internal/handler/admin/account_data_proxy_compat_test.go", "backend/internal/handler/admin/account_data_v2_handler_test.go", "backend/internal/handler/admin/account_duplicate_jobs.go", "backend/internal/handler/admin/account_handler.go", @@ -280,6 +254,7 @@ "backend/internal/handler/admin/account_handler_long_context_billing_test.go", "backend/internal/handler/admin/account_handler_mixed_channel_test.go", "backend/internal/handler/admin/account_handler_model_context_test.go", + "backend/internal/handler/admin/account_handler_traffic_telemetry_test.go", "backend/internal/handler/admin/account_job_execution.go", "backend/internal/handler/admin/account_job_execution_test.go", "backend/internal/handler/admin/account_job_handler.go", @@ -306,7 +281,6 @@ "backend/internal/handler/admin/provider_identity_validation_test.go", "backend/internal/handler/admin/setting_handler.go", "backend/internal/handler/admin/setting_handler_audit.go", - "backend/internal/handler/admin/setting_handler_internal_rate_conversion_test.go", "backend/internal/handler/admin/setting_handler_partial_payload_test.go", "backend/internal/handler/admin/setting_handler_refusal_recovery_test.go", "backend/internal/handler/admin/setting_handler_update.go", @@ -318,9 +292,12 @@ "backend/internal/handler/cindy_balance_terminal_test.go", "backend/internal/handler/cindy_catalog_rollback_test.go", "backend/internal/handler/cindy_rollout_testmain_test.go", + "backend/internal/handler/continuation_repair_live_support_test.go", + "backend/internal/handler/continuation_repair_live_test.go", "backend/internal/handler/dto/account_mapper_cindy_probe_test.go", "backend/internal/handler/dto/account_mapper_redact_test.go", - "backend/internal/handler/dto/group_mapper_managed_routes_test.go", + "backend/internal/handler/dto/credentials_redact.go", + "backend/internal/handler/dto/credentials_redact_test.go", "backend/internal/handler/dto/group_mapper_profit_visibility_test.go", "backend/internal/handler/dto/mappers.go", "backend/internal/handler/dto/provider_identity_mapper_test.go", @@ -341,18 +318,12 @@ "backend/internal/handler/image_studio_executor_test.go", "backend/internal/handler/image_studio_job_handler.go", "backend/internal/handler/image_studio_job_handler_test.go", - "backend/internal/handler/managed_model_catalog_isolation_test.go", - "backend/internal/handler/managed_model_routes.go", - "backend/internal/handler/managed_model_routes_test.go", - "backend/internal/handler/managed_model_ws.go", - "backend/internal/handler/managed_model_ws_client.go", - "backend/internal/handler/managed_model_ws_client_test.go", - "backend/internal/handler/managed_model_ws_test.go", "backend/internal/handler/no_account_error.go", "backend/internal/handler/no_account_error_test.go", "backend/internal/handler/openai_alpha_search.go", "backend/internal/handler/openai_alpha_search_bridge_error_test.go", "backend/internal/handler/openai_alpha_search_cindy_gate_test.go", + "backend/internal/handler/openai_automation_bootstrap_test.go", "backend/internal/handler/openai_body_limit_failover_test.go", "backend/internal/handler/openai_chat_completions.go", "backend/internal/handler/openai_cindy_http_wsv2_sticky_test.go", @@ -378,8 +349,13 @@ "backend/internal/handler/openai_legacy_cindy_continuation.go", "backend/internal/handler/openai_legacy_cindy_continuation_test.go", "backend/internal/handler/openai_management_isolation_test.go", + "backend/internal/handler/openai_models_handler.go", "backend/internal/handler/openai_models_handler_test.go", + "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_profit_veto_budget_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", @@ -388,6 +364,7 @@ "backend/internal/handler/openai_ws_failover_policy.go", "backend/internal/handler/openai_ws_failover_policy_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", "backend/internal/handler/remote_skill_handler.go", "backend/internal/handler/remote_skill_handler_test.go", @@ -427,21 +404,17 @@ "backend/internal/pkg/apicompat/responses_to_chatcompletions.go", "backend/internal/pkg/apicompat/service_tier_passthrough_test.go", "backend/internal/pkg/apicompat/types.go", + "backend/internal/pkg/httpclient/pool.go", "backend/internal/pkg/httputil/body.go", "backend/internal/pkg/httputil/body_lenient_json_test.go", "backend/internal/pkg/httputil/body_test.go", "backend/internal/pkg/openai/constants.go", + "backend/internal/pkg/openai_compat/upstream_capability.go", + "backend/internal/pkg/openai_compat/upstream_capability_test.go", "backend/internal/pkg/proxyurl/parse_test.go", "backend/internal/pkg/tlsfingerprint/dialer.go", "backend/internal/pkg/tlsfingerprint/dialer_deadline_test.go", "backend/internal/pkg/tlsfingerprint/dialer_test.go", - "backend/internal/repository/account_capability_jobs_evidence.go", - "backend/internal/repository/account_capability_jobs_integration_test.go", - "backend/internal/repository/account_capability_jobs_repo.go", - "backend/internal/repository/account_capability_jobs_repo_test.go", - "backend/internal/repository/account_capability_publication.go", - "backend/internal/repository/account_capability_publication_integration_test.go", - "backend/internal/repository/account_capability_publication_test.go", "backend/internal/repository/account_credential_identity_repo.go", "backend/internal/repository/account_credential_identity_repo_test.go", "backend/internal/repository/account_job_cindy_mutation.go", @@ -456,8 +429,6 @@ "backend/internal/repository/account_repo_cindy_group_identity_test.go", "backend/internal/repository/account_repo_cindy_identity_lifecycle_integration_test.go", "backend/internal/repository/account_repo_codex_fingerprint_seed_test.go", - "backend/internal/repository/account_repo_codex_overdraft.go", - "backend/internal/repository/account_repo_codex_overdraft_test.go", "backend/internal/repository/account_repo_integration_test.go", "backend/internal/repository/account_repo_model_availability_test.go", "backend/internal/repository/account_repo_model_context_test.go", @@ -469,6 +440,7 @@ "backend/internal/repository/account_repo_temp_unsched_test.go", "backend/internal/repository/account_repo_upstream_billing_probe_update_test.go", "backend/internal/repository/account_taxonomy_integration_test.go", + "backend/internal/repository/account_traffic_observe_cache.go", "backend/internal/repository/affiliate_repo.go", "backend/internal/repository/api_key_repo.go", "backend/internal/repository/api_key_repo_cindy_group_materialization_test.go", @@ -501,6 +473,7 @@ "backend/internal/repository/cindy_health_repo.go", "backend/internal/repository/cindy_health_repo_test.go", "backend/internal/repository/codex_fingerprint_seed_migration_integration_test.go", + "backend/internal/repository/concurrency_cache_live_test.go", "backend/internal/repository/dashboard_aggregation_repo.go", "backend/internal/repository/db_pool.go", "backend/internal/repository/db_pool_test.go", @@ -518,12 +491,14 @@ "backend/internal/repository/group_repo_cindy_split.go", "backend/internal/repository/group_repo_cindy_split_integration_test.go", "backend/internal/repository/group_repo_cindy_split_test.go", - "backend/internal/repository/group_repo_managed_routes_test.go", "backend/internal/repository/group_usage_rollup_trigger_integration_test.go", "backend/internal/repository/http_upstream.go", + "backend/internal/repository/http_upstream_compression_test.go", "backend/internal/repository/image_studio_job_repo.go", "backend/internal/repository/image_studio_job_repo_test.go", "backend/internal/repository/integration_harness_test.go", + "backend/internal/repository/minimax_cindy_quota_migration_integration_test.go", + "backend/internal/repository/openai_oauth_service.go", "backend/internal/repository/ops_repo.go", "backend/internal/repository/ops_write_pressure_integration_test.go", "backend/internal/repository/passkey_repo.go", @@ -532,13 +507,15 @@ "backend/internal/repository/postgres_batch_insert_test.go", "backend/internal/repository/postgres_driver.go", "backend/internal/repository/postgres_driver_test.go", + "backend/internal/repository/public_model_retirement_migration_integration_test.go", "backend/internal/repository/remote_skill_registry_repository.go", "backend/internal/repository/remote_skill_registry_repository_test.go", "backend/internal/repository/remote_skill_registry_revision_bus.go", "backend/internal/repository/scheduler_cache.go", - "backend/internal/repository/scheduler_cache_managed_identity_unit_test.go", "backend/internal/repository/scheduler_cache_reasoning_policy_test.go", "backend/internal/repository/scheduler_cache_unit_test.go", + "backend/internal/repository/temp_unsched_cache.go", + "backend/internal/repository/temp_unsched_cache_health_test.go", "backend/internal/repository/usage_billing_owner_test.go", "backend/internal/repository/usage_billing_repo.go", "backend/internal/repository/usage_log_repo_stats.go", @@ -556,13 +533,8 @@ "backend/internal/server/middleware/group_model_allowlist.go", "backend/internal/server/middleware/group_model_allowlist_body_compat_test.go", "backend/internal/server/middleware/group_model_allowlist_test.go", - "backend/internal/server/middleware/internal_managed_models.go", - "backend/internal/server/middleware/internal_managed_models_test.go", - "backend/internal/server/middleware/managed_model_routes.go", - "backend/internal/server/middleware/managed_model_routes_test.go", "backend/internal/server/middleware/openai_fast_policy_forwarding_test.go", "backend/internal/server/router.go", - "backend/internal/server/routes/account_capability.go", "backend/internal/server/routes/account_job_routes_test.go", "backend/internal/server/routes/admin.go", "backend/internal/server/routes/composite_platform_test.go", @@ -576,43 +548,26 @@ "backend/internal/server/routes/user.go", "backend/internal/service/account.go", "backend/internal/service/account_available_models_test.go", + "backend/internal/service/account_base_url.go", + "backend/internal/service/account_base_url_input_test.go", "backend/internal/service/account_business_errors.go", "backend/internal/service/account_business_errors_test.go", - "backend/internal/service/account_capability.go", - "backend/internal/service/account_capability_catalog.go", - "backend/internal/service/account_capability_catalog_test.go", - "backend/internal/service/account_capability_discovery.go", - "backend/internal/service/account_capability_discovery_test.go", - "backend/internal/service/account_capability_jobs.go", - "backend/internal/service/account_capability_jobs_test.go", - "backend/internal/service/account_capability_probe.go", - "backend/internal/service/account_capability_probe_errors.go", - "backend/internal/service/account_capability_probe_metadata.go", - "backend/internal/service/account_capability_probe_metadata_test.go", - "backend/internal/service/account_capability_probe_parse.go", - "backend/internal/service/account_capability_probe_request.go", - "backend/internal/service/account_capability_probe_test.go", - "backend/internal/service/account_capability_probe_tools_test.go", - "backend/internal/service/account_capability_probe_websocket.go", - "backend/internal/service/account_capability_probe_websocket_test.go", - "backend/internal/service/account_capability_publication.go", - "backend/internal/service/account_capability_publication_pricing_test.go", - "backend/internal/service/account_capability_publication_test.go", - "backend/internal/service/account_capability_runtime.go", "backend/internal/service/account_credential_identity.go", "backend/internal/service/account_credential_identity_test.go", "backend/internal/service/account_job.go", + "backend/internal/service/account_job_batch_test_runtime.go", "backend/internal/service/account_job_test.go", "backend/internal/service/account_quota_schedulable_test.go", "backend/internal/service/account_reasoning_policy_test.go", - "backend/internal/service/account_scheduler_metadata.go", - "backend/internal/service/account_scheduler_metadata_test.go", "backend/internal/service/account_service.go", - "backend/internal/service/account_test_codex_overdraft.go", "backend/internal/service/account_test_models_test.go", "backend/internal/service/account_test_service.go", "backend/internal/service/account_test_service_cn_adaptive.go", + "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_traffic_observe.go", "backend/internal/service/account_usage_service.go", "backend/internal/service/account_usage_service_test.go", "backend/internal/service/account_usage_session_window_test.go", @@ -645,17 +600,14 @@ "backend/internal/service/api_key.go", "backend/internal/service/api_key_auth_cache.go", "backend/internal/service/api_key_auth_cache_impl.go", - "backend/internal/service/api_key_auth_cache_managed_routes_test.go", "backend/internal/service/api_key_auth_cache_profit_test.go", "backend/internal/service/api_key_auth_cache_version_test.go", "backend/internal/service/api_key_service.go", "backend/internal/service/auth_service_platform_quota_test.go", - "backend/internal/service/batch_image_mvp_smoke_test.go", - "backend/internal/service/batch_image_public.go", - "backend/internal/service/batch_image_public_test.go", - "backend/internal/service/batch_image_settlement.go", "backend/internal/service/billing_cache_service_user_platform_quota_test.go", + "backend/internal/service/billing_context_schedule.go", "backend/internal/service/billing_service.go", + "backend/internal/service/billing_token_cost_request.go", "backend/internal/service/business_system_prompt_bundle.go", "backend/internal/service/business_system_prompt_bundle_test.go", "backend/internal/service/business_system_prompt_cache_identity_test.go", @@ -706,24 +658,21 @@ "backend/internal/service/cindy_v4_catalog.go", "backend/internal/service/cindy_v4_catalog_contract_test.go", "backend/internal/service/cindy_v4_catalog_fixture_test.go", + "backend/internal/service/cn_provider_balance_check_service.go", "backend/internal/service/cn_provider_balance_check_service_test.go", "backend/internal/service/composite_platform_test.go", "backend/internal/service/content_moderation_runtime_cache_test.go", + "backend/internal/service/deepseek_pricing.go", + "backend/internal/service/deepseek_pricing_test.go", "backend/internal/service/domain_constants.go", "backend/internal/service/domain_constants_test.go", "backend/internal/service/email_service_smtp_test.go", "backend/internal/service/gateway_anthropic_apikey_passthrough_test.go", "backend/internal/service/gateway_anthropic_passthrough.go", "backend/internal/service/gateway_count_tokens.go", - "backend/internal/service/gateway_forward.go", - "backend/internal/service/gateway_forward_as_chat_completions.go", - "backend/internal/service/gateway_forward_as_chat_completions_test.go", - "backend/internal/service/gateway_forward_as_responses.go", - "backend/internal/service/gateway_forward_as_responses_test.go", "backend/internal/service/gateway_model_availability.go", "backend/internal/service/gateway_model_availability_test.go", "backend/internal/service/gateway_multiplatform_test.go", - "backend/internal/service/gateway_record_usage_test.go", "backend/internal/service/gateway_scheduling.go", "backend/internal/service/gateway_service.go", "backend/internal/service/gateway_service_bedrock_beta_test.go", @@ -734,6 +683,7 @@ "backend/internal/service/gemini_oauth_service_test.go", "backend/internal/service/gpt56_prompt_source.go", "backend/internal/service/gpt56_prompt_source_test.go", + "backend/internal/service/grok_credential_failure.go", "backend/internal/service/grok_free_quota_gate_test.go", "backend/internal/service/grok_quota_service_test.go", "backend/internal/service/grok_upstream_errors_test.go", @@ -752,26 +702,15 @@ "backend/internal/service/image_studio_job_test.go", "backend/internal/service/image_studio_runtime.go", "backend/internal/service/image_studio_runtime_test.go", - "backend/internal/service/internal_rate_conversion.go", - "backend/internal/service/internal_rate_conversion_batch_test.go", - "backend/internal/service/internal_rate_conversion_test.go", - "backend/internal/service/managed_messages_bridge_defaults.go", - "backend/internal/service/managed_messages_bridge_defaults_forward_test.go", - "backend/internal/service/managed_messages_bridge_defaults_test.go", - "backend/internal/service/managed_model_catalog.go", - "backend/internal/service/managed_model_catalog_isolation.go", - "backend/internal/service/managed_model_catalog_isolation_test.go", - "backend/internal/service/managed_model_catalog_test.go", - "backend/internal/service/managed_model_routes.go", - "backend/internal/service/managed_model_routes_test.go", - "backend/internal/service/managed_model_selectors.go", - "backend/internal/service/managed_model_selectors_test.go", + "backend/internal/service/kimi_anthropic_usage_test.go", "backend/internal/service/model_context_capacity.go", "backend/internal/service/model_context_capacity_catalog.go", "backend/internal/service/model_context_capacity_catalog_test.go", "backend/internal/service/model_context_capacity_parser.go", "backend/internal/service/model_context_capacity_reference_test.go", "backend/internal/service/model_context_capacity_test.go", + "backend/internal/service/model_context_identity.go", + "backend/internal/service/model_context_identity_test.go", "backend/internal/service/model_pricing_resolver.go", "backend/internal/service/model_pricing_resolver_test.go", "backend/internal/service/model_rate_limit.go", @@ -779,6 +718,7 @@ "backend/internal/service/namespace_roundtrip_diagnostic_support_test.go", "backend/internal/service/namespace_roundtrip_diagnostic_test.go", "backend/internal/service/namespace_roundtrip_live_test.go", + "backend/internal/service/oauth_service.go", "backend/internal/service/oauth_service_test.go", "backend/internal/service/openai_account_model_transient.go", "backend/internal/service/openai_account_runtime_block_fastpath.go", @@ -786,6 +726,7 @@ "backend/internal/service/openai_account_runtime_transient_test.go", "backend/internal/service/openai_account_scheduler.go", "backend/internal/service/openai_account_scheduler_test.go", + "backend/internal/service/openai_agent_identity_compat_test.go", "backend/internal/service/openai_alpha_search.go", "backend/internal/service/openai_alpha_search_billing_test.go", "backend/internal/service/openai_alpha_search_cindy_native_first_test.go", @@ -809,24 +750,28 @@ "backend/internal/service/openai_cindy_http_wsv2_test.go", "backend/internal/service/openai_cindy_opaque_binding.go", "backend/internal/service/openai_cindy_opaque_binding_test.go", + "backend/internal/service/openai_codex_account_identity.go", + "backend/internal/service/openai_codex_account_identity_test.go", + "backend/internal/service/openai_codex_client_identity.go", + "backend/internal/service/openai_codex_client_identity_test.go", "backend/internal/service/openai_codex_context_contract.go", "backend/internal/service/openai_codex_context_contract_test.go", + "backend/internal/service/openai_codex_context_window_test.go", + "backend/internal/service/openai_codex_fingerprint.go", "backend/internal/service/openai_codex_fingerprint_test.go", + "backend/internal/service/openai_codex_identity.go", + "backend/internal/service/openai_codex_identity_snapshot.go", "backend/internal/service/openai_codex_model_metadata_test.go", "backend/internal/service/openai_codex_models_pinned.go", "backend/internal/service/openai_codex_models_service.go", "backend/internal/service/openai_codex_models_service_test.go", - "backend/internal/service/openai_codex_quota_overdraft.go", - "backend/internal/service/openai_codex_quota_overdraft_hooks.go", - "backend/internal/service/openai_codex_quota_overdraft_integration.go", - "backend/internal/service/openai_codex_quota_overdraft_probe.go", - "backend/internal/service/openai_codex_quota_overdraft_probe_test.go", - "backend/internal/service/openai_codex_quota_overdraft_test.go", + "backend/internal/service/openai_codex_request_compression.go", + "backend/internal/service/openai_codex_request_compression_test.go", "backend/internal/service/openai_codex_transform.go", + "backend/internal/service/openai_codex_transform_additions_test.go", "backend/internal/service/openai_codex_transform_test.go", "backend/internal/service/openai_codex_turn_state.go", "backend/internal/service/openai_codex_turn_state_test.go", - "backend/internal/service/openai_compact_fallback.go", "backend/internal/service/openai_compact_fallback_test.go", "backend/internal/service/openai_compact_model_mapping_test.go", "backend/internal/service/openai_compact_stream_bridge.go", @@ -850,12 +795,14 @@ "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_cancellation_test.go", "backend/internal/service/openai_gateway_chat_completions_raw.go", "backend/internal/service/openai_gateway_chat_completions_raw_test.go", "backend/internal/service/openai_gateway_cn_fixes_test.go", "backend/internal/service/openai_gateway_count_tokens.go", "backend/internal/service/openai_gateway_count_tokens_test.go", "backend/internal/service/openai_gateway_forward.go", + "backend/internal/service/openai_gateway_grok.go", "backend/internal/service/openai_gateway_grok_405_test.go", "backend/internal/service/openai_gateway_grok_chat_bridge.go", "backend/internal/service/openai_gateway_grok_test.go", @@ -889,7 +836,6 @@ "backend/internal/service/openai_gateway_upstream_errors.go", "backend/internal/service/openai_gateway_upstream_errors_test.go", "backend/internal/service/openai_gateway_usage.go", - "backend/internal/service/openai_gpt56_max_test.go", "backend/internal/service/openai_http_terminal_boundary_test.go", "backend/internal/service/openai_image_generation_controls_test.go", "backend/internal/service/openai_images.go", @@ -904,13 +850,19 @@ "backend/internal/service/openai_model_not_supported_test.go", "backend/internal/service/openai_model_reasoning_alias_test.go", "backend/internal/service/openai_models_list.go", + "backend/internal/service/openai_named_output_namespace_test.go", "backend/internal/service/openai_namespace_roundtrip_test.go", "backend/internal/service/openai_native_fidelity_test.go", "backend/internal/service/openai_oauth_429_classifier.go", "backend/internal/service/openai_oauth_429_classifier_test.go", "backend/internal/service/openai_oauth_passthrough_test.go", + "backend/internal/service/openai_oauth_service.go", + "backend/internal/service/openai_official_http_failover.go", + "backend/internal/service/openai_official_http_failover_test.go", + "backend/internal/service/openai_official_http_state_test.go", "backend/internal/service/openai_ollama_continuation_test.go", "backend/internal/service/openai_passthrough_normalization_test.go", + "backend/internal/service/openai_plugin_transport.go", "backend/internal/service/openai_previous_response_id.go", "backend/internal/service/openai_previous_response_id_test.go", "backend/internal/service/openai_raw_stream_truncation.go", @@ -919,9 +871,11 @@ "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_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", "backend/internal/service/openai_refusal_recovery_pipeline_test.go", @@ -933,16 +887,18 @@ "backend/internal/service/openai_refusal_recovery_ws.go", "backend/internal/service/openai_refusal_recovery_ws_test.go", "backend/internal/service/openai_request_capabilities.go", + "backend/internal/service/openai_request_integrity.go", + "backend/internal/service/openai_request_integrity_semantics.go", "backend/internal/service/openai_responses_ingress_compat.go", "backend/internal/service/openai_responses_ingress_compat_test.go", "backend/internal/service/openai_responses_input_compat.go", "backend/internal/service/openai_responses_input_compat_test.go", "backend/internal/service/openai_responses_namespace.go", "backend/internal/service/openai_responses_namespace_forward_test.go", - "backend/internal/service/openai_responses_namespace_test.go", "backend/internal/service/openai_responses_rejected_field_retry_test.go", "backend/internal/service/openai_responses_tool_schema_test.go", "backend/internal/service/openai_satellite_policy_test.go", + "backend/internal/service/openai_setup_token_compat_test.go", "backend/internal/service/openai_sse_data.go", "backend/internal/service/openai_sticky_compat.go", "backend/internal/service/openai_sticky_compat_test.go", @@ -953,8 +909,10 @@ "backend/internal/service/openai_upstream_transport_error_handle_test.go", "backend/internal/service/openai_v183_compat.go", "backend/internal/service/openai_ws_account_sticky_test.go", + "backend/internal/service/openai_ws_client.go", "backend/internal/service/openai_ws_fallback_test.go", "backend/internal/service/openai_ws_forwarder.go", + "backend/internal/service/openai_ws_forwarder_client_cancel_test.go", "backend/internal/service/openai_ws_forwarder_ingress.go", "backend/internal/service/openai_ws_forwarder_ingress_session_test.go", "backend/internal/service/openai_ws_forwarder_ingress_test.go", @@ -971,7 +929,6 @@ "backend/internal/service/openai_ws_protocol_forward_test.go", "backend/internal/service/openai_ws_ratelimit_signal_test.go", "backend/internal/service/openai_ws_reasoning_mode_model_test.go", - "backend/internal/service/openai_ws_session_preemption_test.go", "backend/internal/service/openai_ws_state_fidelity_test.go", "backend/internal/service/openai_ws_state_store.go", "backend/internal/service/openai_ws_state_store_test.go", @@ -990,8 +947,8 @@ "backend/internal/service/ops_upstream_context.go", "backend/internal/service/ops_user_error_test.go", "backend/internal/service/payment_order_result_test.go", - "backend/internal/service/plugin_package.go", - "backend/internal/service/plugin_package_test.go", + "backend/internal/service/pricing_named_family_test.go", + "backend/internal/service/pricing_service.go", "backend/internal/service/profit_preview.go", "backend/internal/service/profit_preview_test.go", "backend/internal/service/prompts/codexrip_reverse_skill_system_prompt.txt", @@ -1440,25 +1397,26 @@ "backend/internal/service/remote_skill_seed/tree/skills/sec-web-api/references/api-deep/_api-security-workflow.md", "backend/internal/service/remote_skill_seed/tree/skills/sec-web-api/references/api-deep/jwt-oauth-testing.md", "backend/internal/service/remote_skill_seed/tree/skills/sec-web-api/references/api-deep/rest-graphql-testing.md", - "backend/internal/service/scheduler_cache.go", "backend/internal/service/scheduler_snapshot_bulk_event_test.go", - "backend/internal/service/scheduler_snapshot_full_rebuild_lifecycle_test.go", "backend/internal/service/scheduler_snapshot_group_lifecycle_test.go", - "backend/internal/service/scheduler_snapshot_overdraft_context_test.go", - "backend/internal/service/scheduler_snapshot_retirement_test.go", "backend/internal/service/scheduler_snapshot_service.go", "backend/internal/service/setting_gateway_runtime.go", "backend/internal/service/setting_gateway_runtime_refusal_test.go", "backend/internal/service/setting_parse.go", "backend/internal/service/setting_public.go", "backend/internal/service/setting_service.go", + "backend/internal/service/setting_service_platform_quota_test.go", "backend/internal/service/setting_update.go", "backend/internal/service/settings_view.go", + "backend/internal/service/temp_unsched.go", "backend/internal/service/testdata/account_available_models_contract.json", "backend/internal/service/testdata/cindy_free_catalog_2026-08-29.json", - "backend/internal/service/testdata/cindy_free_catalog_2026-09-05.json", + "backend/internal/service/testdata/cindy_free_catalog_2026-09-11.json", "backend/internal/service/testdata/codexrip_reverse_skill_system_prompt_legacy_dual_marker.txt", "backend/internal/service/timing_wheel_service_test.go", + "backend/internal/service/token_refresh_service.go", + "backend/internal/service/token_refresh_service_candidates_test.go", + "backend/internal/service/token_refresh_service_test.go", "backend/internal/service/update_service.go", "backend/internal/service/update_service_test.go", "backend/internal/service/upstream_models.go", @@ -1467,7 +1425,6 @@ "backend/internal/service/usage_billing.go", "backend/internal/service/wire.go", "backend/internal/service/wire_test.go", - "backend/internal/setup/setup.go", "backend/internal/testutil/stubs.go", "backend/internal/web/agent_route_ownership_test.go", "backend/internal/web/embed_on.go", @@ -1502,14 +1459,18 @@ "backend/migrations/234_fix_cindy_platform_projection_round_trip.sql", "backend/migrations/235_preserve_mixed_openai_cindy_groups.sql", "backend/migrations/236_bind_strict_cindy_groups_to_catalog_channel.sql", + "backend/migrations/237_add_minimax_platform.sql", "backend/migrations/237_cindy_terminal_health_and_cleanup.sql", "backend/migrations/237_user_platform_quotas_add_cindy.sql", "backend/migrations/238_cindy_account_stats_reset_at.sql", + "backend/migrations/238_opencode_go_platform.sql", "backend/migrations/239_codexrip_release_acceptance_api_key_leases.sql", "backend/migrations/240_admin_account_capabilities.sql", + "backend/migrations/241_minimax_cindy_platform_quota_compat.sql", + "backend/migrations/242_retire_public_model_manager.sql", + "backend/migrations/243_codex_fingerprint_device_default.sql", "backend/migrations/account_credential_identity_migration_test.go", "backend/migrations/account_jobs_slim_migration_test.go", - "backend/migrations/admin_account_capabilities_migration_test.go", "backend/migrations/business_system_prompt_codex_skill_hybrid_migration_test.go", "backend/migrations/business_system_prompt_managed_source_migration_test.go", "backend/migrations/business_system_prompt_remote_skill_migration_test.go", @@ -1533,10 +1494,13 @@ "backend/migrations/cindy_prompt_cache_hash_migration_integration_test.go", "backend/migrations/cindy_prompt_cache_hash_migration_test.go", "backend/migrations/image_studio_jobs_migration_test.go", + "backend/migrations/minimax_platform_migration_test.go", + "backend/migrations/opencode_go_platform_migration_test.go", "backend/migrations/release_acceptance_api_key_lease_migration_test.go", "backend/migrations/remote_skill_registry_migration_test.go", "backend/migrations/remote_skill_sources_migration_integration_test.go", "backend/migrations/user_platform_quota_cindy_migration_test.go", + "backend/resources/model-pricing/model_prices_and_context_window.json", "deploy/.env.example", "deploy/Dockerfile", "deploy/README.md", @@ -1551,23 +1515,24 @@ "deploy/tests/docker-runtime-resources-test.sh", "deploy/tests/production-deploy-workflow-test.sh", "deploy/tests/test_verify_business_system_prompt_bundle.py", - "docs/ACCOUNT_CAPABILITIES.md", "docs/channel-monitor-v2-safe-defaults.md", + "frontend/index.html", "frontend/package.json", "frontend/pnpm-lock.yaml", + "frontend/public/fonts/misans/MiSans-Font-Intellectual-Property-License.pdf", + "frontend/public/fonts/misans/NOTICE.md", "frontend/src/App.vue", "frontend/src/__tests__/integration/data-import.spec.ts", "frontend/src/api/__tests__/admin.accounts.contextCapacity.spec.ts", "frontend/src/api/__tests__/admin.accounts.jobs.spec.ts", "frontend/src/api/__tests__/admin.groups.cindy-split.spec.ts", "frontend/src/api/__tests__/cindyBalanceProbe.spec.ts", + "frontend/src/api/__tests__/client.spec.ts", "frontend/src/api/__tests__/downstreamStatus.spec.ts", "frontend/src/api/__tests__/imageStudio.spec.ts", "frontend/src/api/__tests__/settings.authSourceDefaults.spec.ts", - "frontend/src/api/admin/__tests__/accountCapabilities.spec.ts", "frontend/src/api/admin/__tests__/accountJobs.spec.ts", "frontend/src/api/admin/__tests__/systemPrompts.spec.ts", - "frontend/src/api/admin/accountCapabilities.ts", "frontend/src/api/admin/accountJobs.ts", "frontend/src/api/admin/accounts.ts", "frontend/src/api/admin/cindyBalanceProbe.ts", @@ -1577,21 +1542,52 @@ "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/downstreamStatus.ts", "frontend/src/api/imageStudio.ts", + "frontend/src/assets/fonts/misans/MiSans-Bold.woff2", + "frontend/src/assets/fonts/misans/MiSans-Medium.woff2", + "frontend/src/assets/fonts/misans/MiSans-Regular.woff2", + "frontend/src/assets/fonts/misans/MiSans-Semibold.woff2", + "frontend/src/components/AliyunCaptchaWidget.vue", + "frontend/src/components/TurnstileWidget.vue", "frontend/src/components/account/AccountCapacityCell.vue", + "frontend/src/components/account/AccountGroupsCell.vue", + "frontend/src/components/account/AccountIdentityBadges.vue", + "frontend/src/components/account/AccountQuotaInfo.vue", + "frontend/src/components/account/AccountSelectionCheckbox.vue", + "frontend/src/components/account/AccountStatsModal.vue", "frontend/src/components/account/AccountStatusIndicator.vue", "frontend/src/components/account/AccountTestModal.vue", + "frontend/src/components/account/AccountTodayStatsCell.vue", "frontend/src/components/account/AccountUsageCell.vue", "frontend/src/components/account/BulkEditAccountModal.vue", - "frontend/src/components/account/CodexOverdraftStats.vue", - "frontend/src/components/account/CodexOverdraftStatus.vue", + "frontend/src/components/account/CNProviderBalanceCell.vue", + "frontend/src/components/account/CNProviderQuotaCell.vue", + "frontend/src/components/account/CapacityBadge.vue", + "frontend/src/components/account/CnBaseUrlPresets.vue", "frontend/src/components/account/CreateAccountModal.vue", "frontend/src/components/account/EditAccountModal.vue", + "frontend/src/components/account/GrokBaseUrlPresets.vue", + "frontend/src/components/account/GrokQuotaProbeCell.vue", + "frontend/src/components/account/HeaderOverrideEditor.vue", + "frontend/src/components/account/HeaderOverrideJsonTools.vue", "frontend/src/components/account/ModelContextCapacityField.vue", "frontend/src/components/account/ModelWhitelistSelector.vue", + "frontend/src/components/account/OAuthAuthorizationFlow.vue", "frontend/src/components/account/OllamaCloudUsageCell.vue", + "frontend/src/components/account/OllamaCloudUsageSettings.vue", + "frontend/src/components/account/OpenAIQuotaResetCell.vue", "frontend/src/components/account/OpenAIReasoningPolicyFields.vue", + "frontend/src/components/account/OpenCodeGoProtocolRulesEditor.vue", + "frontend/src/components/account/QuotaBadge.vue", + "frontend/src/components/account/QuotaLimitCard.vue", + "frontend/src/components/account/QuotaNotifyToggle.vue", + "frontend/src/components/account/ReAuthAccountModal.vue", + "frontend/src/components/account/SyncFromCrsModal.vue", + "frontend/src/components/account/TempUnschedStatusModal.vue", + "frontend/src/components/account/UpstreamBillingRateCell.vue", + "frontend/src/components/account/UpstreamRequestIdHeaderField.vue", "frontend/src/components/account/UsageProgressBar.vue", "frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts", "frontend/src/components/account/__tests__/AccountTestModal.spec.ts", @@ -1599,11 +1595,17 @@ "frontend/src/components/account/__tests__/AccountUsageCell.spec.ts", "frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts", "frontend/src/components/account/__tests__/CreateAccountModal.spec.ts", + "frontend/src/components/account/__tests__/EditAccountModal.grokMediaEligibility.spec.ts", "frontend/src/components/account/__tests__/EditAccountModal.spec.ts", "frontend/src/components/account/__tests__/ModelContextCapacityField.spec.ts", "frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts", "frontend/src/components/account/__tests__/OpenAIReasoningPolicyFields.spec.ts", "frontend/src/components/account/__tests__/UsageProgressBar.spec.ts", + "frontend/src/components/account/__tests__/credentialsBuilder.spec.ts", + "frontend/src/components/account/credentialsBuilder.ts", + "frontend/src/components/admin/AdminComplianceDialog.vue", + "frontend/src/components/admin/ErrorPassthroughRulesModal.vue", + "frontend/src/components/admin/TLSFingerprintProfilesModal.vue", "frontend/src/components/admin/account-jobs/AccountTaskDrawer.vue", "frontend/src/components/admin/account-jobs/__tests__/AccountTaskDrawer.spec.ts", "frontend/src/components/admin/account/AccountActionMenu.vue", @@ -1615,11 +1617,16 @@ "frontend/src/components/admin/account/AccountDetailsDrawer.vue", "frontend/src/components/admin/account/AccountFolderBar.vue", "frontend/src/components/admin/account/AccountImportSettingsEditor.vue", + "frontend/src/components/admin/account/AccountStatsModal.vue", "frontend/src/components/admin/account/AccountTableFilters.vue", "frontend/src/components/admin/account/AccountTaxonomyManager.vue", "frontend/src/components/admin/account/AccountTestModal.vue", + "frontend/src/components/admin/account/AccountTestModelSelect.vue", "frontend/src/components/admin/account/AccountViewModeSwitcher.vue", + "frontend/src/components/admin/account/BatchTestAccountModal.vue", "frontend/src/components/admin/account/ImportDataModal.vue", + "frontend/src/components/admin/account/ReAuthAccountModal.vue", + "frontend/src/components/admin/account/ScheduledTestsPanel.vue", "frontend/src/components/admin/account/__tests__/AccountActionMenu.spark_shadow.spec.ts", "frontend/src/components/admin/account/__tests__/AccountBulkActionsBar.spec.ts", "frontend/src/components/admin/account/__tests__/AccountBulkTaxonomyModal.spec.ts", @@ -1630,35 +1637,159 @@ "frontend/src/components/admin/account/__tests__/AccountTestModal.displayContract.spec.ts", "frontend/src/components/admin/account/__tests__/AccountTestModal.spec.ts", "frontend/src/components/admin/account/__tests__/AccountUsageViews.spec.ts", + "frontend/src/components/admin/account/__tests__/BatchTestAccountModal.spec.ts", "frontend/src/components/admin/account/__tests__/ImportDataModal.spec.ts", + "frontend/src/components/admin/announcements/AnnouncementTargetingEditor.vue", + "frontend/src/components/admin/channel/IntervalRow.vue", + "frontend/src/components/admin/channel/ModelTagInput.vue", + "frontend/src/components/admin/channel/PricingEntryCard.vue", + "frontend/src/components/admin/channel/TimePricingSection.vue", "frontend/src/components/admin/group/CodexManifestAccountsField.vue", "frontend/src/components/admin/group/GroupRPMOverridesModal.vue", "frontend/src/components/admin/group/GroupRateMultipliersModal.vue", + "frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue", "frontend/src/components/admin/group/__tests__/CodexManifestAccountsField.spec.ts", + "frontend/src/components/admin/monitor/MonitorActionsCell.vue", + "frontend/src/components/admin/monitor/MonitorAdvancedRequestConfig.vue", + "frontend/src/components/admin/monitor/MonitorFiltersBar.vue", + "frontend/src/components/admin/monitor/MonitorFormDialog.vue", + "frontend/src/components/admin/monitor/MonitorKeyPickerDialog.vue", + "frontend/src/components/admin/monitor/MonitorPrimaryModelCell.vue", + "frontend/src/components/admin/monitor/MonitorRunResultDialog.vue", + "frontend/src/components/admin/monitor/MonitorTemplateApplyPickerDialog.vue", + "frontend/src/components/admin/monitor/MonitorTemplateManagerDialog.vue", + "frontend/src/components/admin/payment/AdminOrderDetail.vue", + "frontend/src/components/admin/payment/AdminOrderTable.vue", + "frontend/src/components/admin/payment/AdminRefundDialog.vue", + "frontend/src/components/admin/payment/OrderStatsCards.vue", + "frontend/src/components/admin/payment/TopUsersLeaderboard.vue", + "frontend/src/components/admin/proxy/ImportDataModal.vue", + "frontend/src/components/admin/subscription/BulkSubscriptionActionDialog.vue", "frontend/src/components/admin/systemPrompt/SystemPromptAdvancedDrawer.vue", + "frontend/src/components/admin/usage/UsageCleanupDialog.vue", + "frontend/src/components/admin/usage/UsageFilters.vue", + "frontend/src/components/admin/usage/UsageStatsCards.vue", + "frontend/src/components/admin/usage/UsageTable.vue", + "frontend/src/components/admin/usage/UserTokenRanking.vue", + "frontend/src/components/admin/user/GroupReplaceModal.vue", + "frontend/src/components/admin/user/UserAllowedGroupsModal.vue", + "frontend/src/components/admin/user/UserApiKeysModal.vue", + "frontend/src/components/admin/user/UserBalanceHistoryModal.vue", + "frontend/src/components/admin/user/UserBalanceModal.vue", + "frontend/src/components/admin/user/UserEditModal.vue", "frontend/src/components/admin/user/UserPlatformQuotaModal.vue", "frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts", + "frontend/src/components/auth/LoginAgreementPrompt.vue", + "frontend/src/components/auth/TotpLoginModal.vue", + "frontend/src/components/auth/TotpStepUpDialog.vue", + "frontend/src/components/channels/AvailableChannelsTable.vue", + "frontend/src/components/channels/SupportedModelChip.vue", + "frontend/src/components/charts/EndpointDistributionChart.vue", + "frontend/src/components/charts/GroupDistributionChart.vue", + "frontend/src/components/charts/ModelDistributionChart.vue", + "frontend/src/components/charts/TokenUsageTrend.vue", + "frontend/src/components/charts/UserBreakdownSubTable.vue", + "frontend/src/components/common/AnnouncementBell.vue", + "frontend/src/components/common/AnnouncementPopup.vue", + "frontend/src/components/common/AutoRefreshButton.vue", + "frontend/src/components/common/BaseDialog.vue", + "frontend/src/components/common/ConfirmDialog.vue", + "frontend/src/components/common/DataTable.vue", + "frontend/src/components/common/DateRangePicker.vue", + "frontend/src/components/common/EmptyState.vue", + "frontend/src/components/common/ExportProgressDialog.vue", + "frontend/src/components/common/GroupBadge.vue", + "frontend/src/components/common/GroupCapacityBadge.vue", + "frontend/src/components/common/GroupOptionItem.vue", + "frontend/src/components/common/GroupSelector.vue", + "frontend/src/components/common/HelpTooltip.vue", + "frontend/src/components/common/ImageUpload.vue", + "frontend/src/components/common/Input.vue", + "frontend/src/components/common/IpGeoBatchToolbar.vue", + "frontend/src/components/common/IpGeoCell.vue", + "frontend/src/components/common/LoadingSpinner.vue", + "frontend/src/components/common/LocaleSwitcher.vue", + "frontend/src/components/common/ModelIcon.vue", + "frontend/src/components/common/MonitorQuotaView.vue", + "frontend/src/components/common/NavigationProgress.vue", + "frontend/src/components/common/Pagination.vue", "frontend/src/components/common/PlatformIcon.vue", "frontend/src/components/common/PlatformTypeBadge.vue", + "frontend/src/components/common/ProxySelector.vue", + "frontend/src/components/common/Select.vue", + "frontend/src/components/common/Skeleton.vue", + "frontend/src/components/common/SubscriptionProgressMini.vue", + "frontend/src/components/common/Toast.vue", + "frontend/src/components/common/Toggle.vue", "frontend/src/components/common/VersionBadge.vue", + "frontend/src/components/common/__tests__/BaseDialog.spec.ts", + "frontend/src/components/common/__tests__/DataTable.spec.ts", "frontend/src/components/common/__tests__/PlatformIcon.spec.ts", "frontend/src/components/common/__tests__/PlatformTypeBadge.grok.spec.ts", "frontend/src/components/common/__tests__/VersionBadge.downstream.spec.ts", + "frontend/src/components/common/dialogStack.ts", + "frontend/src/components/keys/EndpointPopover.vue", "frontend/src/components/keys/UseKeyModal.vue", + "frontend/src/components/keys/__tests__/UseKeyModal.spec.ts", "frontend/src/components/layout/AppHeader.vue", "frontend/src/components/layout/AppLayout.vue", "frontend/src/components/layout/AppSidebar.vue", + "frontend/src/components/layout/AuthLayout.vue", "frontend/src/components/layout/TablePageLayout.vue", "frontend/src/components/layout/__tests__/AppSidebar.simple-mode.spec.ts", "frontend/src/components/layout/__tests__/AppSidebar.spec.ts", + "frontend/src/components/modelPlaza/ModelPlazaContent.vue", + "frontend/src/components/modelPlaza/PlazaFilterBar.vue", + "frontend/src/components/modelPlaza/PlazaGroupSection.vue", + "frontend/src/components/modelPlaza/PlazaModelPricingTable.vue", + "frontend/src/components/modelPlaza/PlazaNavBar.vue", + "frontend/src/components/payment/AmountInput.vue", + "frontend/src/components/payment/OrderStatusBadge.vue", + "frontend/src/components/payment/PaymentMethodSelector.vue", + "frontend/src/components/payment/PaymentProviderDialog.vue", + "frontend/src/components/payment/PaymentProviderList.vue", + "frontend/src/components/payment/PaymentQRDialog.vue", + "frontend/src/components/payment/PaymentStatusPanel.vue", + "frontend/src/components/payment/ProviderCard.vue", + "frontend/src/components/payment/StripePaymentInline.vue", + "frontend/src/components/payment/SubscriptionPlanCard.vue", + "frontend/src/components/payment/ToggleSwitch.vue", + "frontend/src/components/user/MonitorDetailDialog.vue", + "frontend/src/components/user/PlatformCostCell.vue", + "frontend/src/components/user/PlatformUsageBreakdown.vue", + "frontend/src/components/user/UserAttributeForm.vue", + "frontend/src/components/user/UserAttributesConfigModal.vue", + "frontend/src/components/user/UserConcurrencyCell.vue", + "frontend/src/components/user/UserErrorDetailModal.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/UserDashboardRecentUsage.vue", "frontend/src/components/user/dashboard/UserDashboardStats.vue", + "frontend/src/components/user/monitor/MonitorCard.vue", + "frontend/src/components/user/monitor/MonitorCardGrid.vue", + "frontend/src/components/user/monitor/MonitorHero.vue", + "frontend/src/components/user/monitor/MonitorMetricPair.vue", + "frontend/src/components/user/monitor/MonitorTimeline.vue", + "frontend/src/components/user/profile/ProfileAvatarCard.vue", + "frontend/src/components/user/profile/ProfileBalanceNotifyCard.vue", + "frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue", + "frontend/src/components/user/profile/ProfileInfoCard.vue", + "frontend/src/components/user/profile/ProfilePasskeyCard.vue", + "frontend/src/components/user/profile/TotpDisableDialog.vue", + "frontend/src/components/user/profile/TotpSetupModal.vue", "frontend/src/composables/__tests__/useModelContextCapacities.spec.ts", "frontend/src/composables/useModelContextCapacities.ts", "frontend/src/composables/useModelWhitelist.ts", "frontend/src/constants/__tests__/platforms.spec.ts", "frontend/src/constants/platforms.ts", "frontend/src/features/account-jobs/__tests__/integrationSurface.spec.ts", + "frontend/src/features/channel-monitor-v2/FilterMultiSelect.vue", + "frontend/src/features/channel-monitor-v2/MetricCell.vue", + "frontend/src/features/channel-monitor-v2/MonitorSettingsPanel.vue", + "frontend/src/features/channel-monitor-v2/MonitorTrendChart.vue", + "frontend/src/features/channel-monitor-v2/RelayPulseMatrix.vue", "frontend/src/features/cindy-balance-probe/CindyBalanceProbePanel.vue", "frontend/src/features/cindy-balance-probe/CindyBalanceProbeSummary.vue", "frontend/src/features/cindy-balance-probe/__tests__/CindyBalanceProbePanel.spec.ts", @@ -1669,9 +1800,15 @@ "frontend/src/features/cindy-group-split/__tests__/CindyGroupAuditDialog.spec.ts", "frontend/src/features/image-studio/history.spec.ts", "frontend/src/features/image-studio/history.ts", + "frontend/src/features/prompt-audit/PromptAuditView.vue", + "frontend/src/features/prompt-audit/components/EndpointPool.vue", + "frontend/src/features/prompt-audit/components/EventDetailDialog.vue", + "frontend/src/features/prompt-audit/components/EventWorkspace.vue", + "frontend/src/features/prompt-audit/components/FilterDeleteDialog.vue", + "frontend/src/features/prompt-audit/components/PolicyPanel.vue", + "frontend/src/features/prompt-audit/components/RuntimeOverview.vue", "frontend/src/i18n/__tests__/accountConsoleLocales.spec.ts", "frontend/src/i18n/__tests__/cindyProbeLocales.spec.ts", - "frontend/src/i18n/locales/en/admin/accountCapabilities.ts", "frontend/src/i18n/locales/en/admin/accountTasks.ts", "frontend/src/i18n/locales/en/admin/accounts.ts", "frontend/src/i18n/locales/en/admin/index.ts", @@ -1679,11 +1816,9 @@ "frontend/src/i18n/locales/en/admin/settings.ts", "frontend/src/i18n/locales/en/admin/systemPrompts.ts", "frontend/src/i18n/locales/en/common.ts", - "frontend/src/i18n/locales/en/dashboard.ts", "frontend/src/i18n/locales/en/imageStudio.ts", "frontend/src/i18n/locales/en/index.ts", "frontend/src/i18n/locales/en/misc.ts", - "frontend/src/i18n/locales/zh/admin/accountCapabilities.ts", "frontend/src/i18n/locales/zh/admin/accountTasks.ts", "frontend/src/i18n/locales/zh/admin/accounts.ts", "frontend/src/i18n/locales/zh/admin/index.ts", @@ -1691,7 +1826,6 @@ "frontend/src/i18n/locales/zh/admin/settings.ts", "frontend/src/i18n/locales/zh/admin/systemPrompts.ts", "frontend/src/i18n/locales/zh/common.ts", - "frontend/src/i18n/locales/zh/dashboard.ts", "frontend/src/i18n/locales/zh/imageStudio.ts", "frontend/src/i18n/locales/zh/index.ts", "frontend/src/i18n/locales/zh/misc.ts", @@ -1703,24 +1837,47 @@ "frontend/src/stores/accountJobs.ts", "frontend/src/stores/app.ts", "frontend/src/stores/index.ts", + "frontend/src/style.css", + "frontend/src/styles/announcement-markdown.css", + "frontend/src/styles/onboarding.css", + "frontend/src/styles/theme.css", "frontend/src/types/index.ts", "frontend/src/utils/__tests__/cindyOpenAIDefaults.spec.ts", "frontend/src/utils/__tests__/modelContextCapacity.spec.ts", "frontend/src/utils/__tests__/openaiReasoningPolicy.spec.ts", + "frontend/src/utils/accountImportParser.ts", + "frontend/src/utils/accountImportWorker.ts", + "frontend/src/utils/accountPresentation.ts", + "frontend/src/utils/accountTestModels.ts", "frontend/src/utils/cindyOpenAIDefaults.ts", "frontend/src/utils/featureFlags.ts", + "frontend/src/utils/keyGroupProviders.ts", "frontend/src/utils/modelContextCapacity.ts", "frontend/src/utils/openaiReasoningPolicy.ts", "frontend/src/utils/platformColors.ts", - "frontend/src/views/admin/AccountCapabilitiesView.vue", + "frontend/src/views/HomeView.vue", + "frontend/src/views/KeyUsageView.vue", + "frontend/src/views/NotFoundView.vue", "frontend/src/views/admin/AccountTasksView.vue", "frontend/src/views/admin/AccountsView.vue", + "frontend/src/views/admin/AnnouncementsView.vue", + "frontend/src/views/admin/AuditLogView.vue", + "frontend/src/views/admin/BackupView.vue", + "frontend/src/views/admin/ChannelMonitorView.vue", "frontend/src/views/admin/ChannelsView.vue", "frontend/src/views/admin/CindyAccountsView.vue", + "frontend/src/views/admin/DashboardView.vue", "frontend/src/views/admin/GroupsView.vue", + "frontend/src/views/admin/PluginsView.vue", + "frontend/src/views/admin/PromoCodesView.vue", + "frontend/src/views/admin/ProxiesView.vue", + "frontend/src/views/admin/RedeemView.vue", + "frontend/src/views/admin/RiskControlView.vue", "frontend/src/views/admin/SettingsView.vue", + "frontend/src/views/admin/SubscriptionsView.vue", "frontend/src/views/admin/SystemPromptsView.vue", - "frontend/src/views/admin/__tests__/AccountCapabilitiesView.spec.ts", + "frontend/src/views/admin/UsageView.vue", + "frontend/src/views/admin/UsersView.vue", "frontend/src/views/admin/__tests__/AccountTasksView.spec.ts", "frontend/src/views/admin/__tests__/AccountsView.bulkEdit.spec.ts", "frontend/src/views/admin/__tests__/AccountsView.console.spec.ts", @@ -1736,419 +1893,325 @@ "frontend/src/views/admin/__tests__/GroupsView.duplicate.spec.ts", "frontend/src/views/admin/__tests__/SettingsView.spec.ts", "frontend/src/views/admin/__tests__/SystemPromptsView.spec.ts", - "frontend/src/views/admin/__tests__/accountCapabilitiesHelpers.spec.ts", - "frontend/src/views/admin/__tests__/accountCapabilitiesLabels.spec.ts", - "frontend/src/views/admin/__tests__/accountCapabilitiesPublicationTier.spec.ts", "frontend/src/views/admin/__tests__/channelPlatformOptions.spec.ts", "frontend/src/views/admin/__tests__/groupModelAllowlist.spec.ts", "frontend/src/views/admin/__tests__/groupsProfitControl.spec.ts", "frontend/src/views/admin/__tests__/systemPromptsIntegrationSurface.spec.ts", - "frontend/src/views/admin/accountCapabilitiesHelpers.ts", + "frontend/src/views/admin/affiliates/AdminAffiliateRecordsTable.vue", "frontend/src/views/admin/groupModelAllowlist.ts", "frontend/src/views/admin/groupsProfitControl.ts", + "frontend/src/views/admin/ops/OpsDashboard.vue", + "frontend/src/views/admin/ops/components/OpsAlertEventsCard.vue", + "frontend/src/views/admin/ops/components/OpsAlertRulesCard.vue", + "frontend/src/views/admin/ops/components/OpsConcurrencyCard.vue", + "frontend/src/views/admin/ops/components/OpsDashboardHeader.vue", + "frontend/src/views/admin/ops/components/OpsDashboardSkeleton.vue", + "frontend/src/views/admin/ops/components/OpsEmailNotificationCard.vue", + "frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue", + "frontend/src/views/admin/ops/components/OpsErrorDetailsModal.vue", + "frontend/src/views/admin/ops/components/OpsErrorDistributionChart.vue", + "frontend/src/views/admin/ops/components/OpsErrorLogTable.vue", + "frontend/src/views/admin/ops/components/OpsErrorTrendChart.vue", + "frontend/src/views/admin/ops/components/OpsLatencyChart.vue", + "frontend/src/views/admin/ops/components/OpsOpenAITokenStatsCard.vue", + "frontend/src/views/admin/ops/components/OpsRequestDetailsModal.vue", + "frontend/src/views/admin/ops/components/OpsRuntimeSettingsCard.vue", + "frontend/src/views/admin/ops/components/OpsSettingsDialog.vue", + "frontend/src/views/admin/ops/components/OpsSwitchRateTrendChart.vue", + "frontend/src/views/admin/ops/components/OpsSystemLogTable.vue", + "frontend/src/views/admin/ops/components/OpsThroughputTrendChart.vue", + "frontend/src/views/admin/orders/AdminOrdersView.vue", + "frontend/src/views/admin/orders/AdminPaymentDashboardView.vue", + "frontend/src/views/admin/orders/AdminPaymentPlansView.vue", + "frontend/src/views/admin/orders/PlanEditDialog.vue", + "frontend/src/views/admin/settings/EmailTemplateEditor.vue", + "frontend/src/views/admin/settings/OpenAIFastPolicyUserSelector.vue", + "frontend/src/views/auth/DingTalkCallbackView.vue", + "frontend/src/views/auth/EmailVerifyView.vue", + "frontend/src/views/auth/ForgotPasswordView.vue", + "frontend/src/views/auth/LinuxDoCallbackView.vue", + "frontend/src/views/auth/LoginView.vue", + "frontend/src/views/auth/OidcCallbackView.vue", + "frontend/src/views/auth/RegisterView.vue", + "frontend/src/views/auth/ResetPasswordView.vue", + "frontend/src/views/auth/WechatCallbackView.vue", + "frontend/src/views/auth/WechatPaymentCallbackView.vue", + "frontend/src/views/public/LegalDocumentView.vue", + "frontend/src/views/setup/SetupWizardView.vue", + "frontend/src/views/user/AffiliateView.vue", + "frontend/src/views/user/AvailableChannelsView.vue", + "frontend/src/views/user/BatchImageGuideView.vue", + "frontend/src/views/user/ChannelStatusV2View.vue", + "frontend/src/views/user/CustomPageView.vue", "frontend/src/views/user/ImageStudioView.vue", + "frontend/src/views/user/KeysView.vue", + "frontend/src/views/user/PaymentQRCodeView.vue", + "frontend/src/views/user/PaymentResultView.vue", + "frontend/src/views/user/PaymentView.vue", + "frontend/src/views/user/ProfileView.vue", + "frontend/src/views/user/RedeemView.vue", + "frontend/src/views/user/StripePaymentView.vue", + "frontend/src/views/user/StripePopupView.vue", + "frontend/src/views/user/SubscriptionsView.vue", + "frontend/src/views/user/UsageView.vue", + "frontend/src/views/user/UserOrdersView.vue", "frontend/src/views/user/__tests__/ImageStudioView.spec.ts", + "frontend/src/workers/accountImport.worker.ts", + "frontend/tailwind.config.js", + "frontend/vite.config.ts", "tools/sync_modelgang_remote_skill_seed.ps1", "tools/verify_business_system_prompt_bundle.py" ], + "initial_conflict_files": [ + "backend/cmd/server/wire_gen.go", + "backend/go.mod", + "backend/internal/handler/admin/account_handler.go", + "backend/internal/handler/dto/types.go", + "backend/internal/handler/wire.go", + "backend/internal/repository/account_repo.go", + "backend/internal/repository/account_repo_upstream_billing_probe_update_test.go", + "backend/internal/service/admin_account.go", + "backend/internal/service/openai_account_runtime_block_fastpath.go", + "backend/internal/service/openai_account_runtime_block_fastpath_test.go", + "backend/internal/service/openai_account_scheduler.go", + "backend/internal/service/openai_codex_turn_state.go", + "backend/internal/service/openai_gateway_response_handling.go", + "backend/internal/service/openai_gateway_scheduling.go", + "backend/internal/service/openai_ws_forwarder_support.go", + "frontend/src/components/account/AccountUsageCell.vue", + "frontend/src/components/common/BaseDialog.vue", + "frontend/src/views/admin/__tests__/GroupsView.codexManifest.spec.ts", + "frontend/src/views/admin/__tests__/SettingsView.spec.ts" + ], + "integration_branch": "codex/v0.2.6-integration", "merge_conflicts": [], - "overlap_file_count": 123, + "overlap_file_count": 76, "overlap_files": [ - "README.md", - "README_CN.md", - "README_JA.md", + ".gitignore", + "backend/cmd/server/VERSION", + "backend/cmd/server/wire.go", "backend/cmd/server/wire_gen.go", - "backend/ent/client.go", - "backend/ent/migrate/schema.go", - "backend/ent/mutation.go", - "backend/ent/schema/user_platform_quota.go", "backend/go.mod", "backend/go.sum", "backend/internal/config/config.go", - "backend/internal/config/config_test.go", - "backend/internal/domain/constants.go", "backend/internal/handler/admin/account_data.go", - "backend/internal/handler/admin/channel_handler_test.go", - "backend/internal/handler/admin/group_handler.go", - "backend/internal/handler/admin/group_handler_platform_test.go", + "backend/internal/handler/admin/account_data_handler_test.go", + "backend/internal/handler/admin/account_handler.go", "backend/internal/handler/admin/setting_handler.go", + "backend/internal/handler/admin/setting_handler_audit.go", "backend/internal/handler/admin/setting_handler_update.go", + "backend/internal/handler/dto/account_mapper_redact_test.go", + "backend/internal/handler/dto/mappers.go", "backend/internal/handler/dto/settings.go", - "backend/internal/handler/gateway_handler.go", - "backend/internal/handler/gateway_models_test.go", - "backend/internal/handler/openai_gateway_handler.go", - "backend/internal/handler/openai_gateway_handler_test.go", - "backend/internal/handler/setting_handler.go", - "backend/internal/pkg/openai/constants.go", - "backend/internal/repository/account_repo_ollama_cloud_usage.go", - "backend/internal/repository/account_repo_ollama_cloud_usage_test.go", + "backend/internal/handler/dto/types.go", + "backend/internal/handler/wire.go", + "backend/internal/repository/account_repo.go", + "backend/internal/repository/account_repo_integration_test.go", + "backend/internal/repository/account_repo_temp_unsched_test.go", + "backend/internal/repository/account_repo_upstream_billing_probe_update_test.go", "backend/internal/repository/http_upstream.go", - "backend/internal/repository/user_platform_quota_repo_integration_test.go", - "backend/internal/repository/wire.go", "backend/internal/server/api_contract_test.go", - "backend/internal/server/routes/admin.go", - "backend/internal/server/routes/gateway.go", - "backend/internal/service/account.go", - "backend/internal/service/account_service.go", - "backend/internal/service/account_test_models_test.go", - "backend/internal/service/account_test_service.go", - "backend/internal/service/admin_group.go", - "backend/internal/service/admin_service.go", - "backend/internal/service/admin_service_composite_group_test.go", - "backend/internal/service/api_key_service.go", - "backend/internal/service/channel_service.go", - "backend/internal/service/cn_provider_balance_check_service_test.go", - "backend/internal/service/composite_platform_test.go", - "backend/internal/service/content_moderation_runtime_cache_test.go", + "backend/internal/service/admin_account.go", "backend/internal/service/domain_constants.go", - "backend/internal/service/model_pricing_resolver_test.go", "backend/internal/service/openai_account_runtime_block_fastpath.go", "backend/internal/service/openai_account_runtime_block_fastpath_test.go", "backend/internal/service/openai_account_runtime_transient_test.go", + "backend/internal/service/openai_account_scheduler.go", "backend/internal/service/openai_codex_models_service.go", "backend/internal/service/openai_codex_models_service_test.go", - "backend/internal/service/openai_codex_transform.go", - "backend/internal/service/openai_gateway_chat_completions.go", + "backend/internal/service/openai_codex_turn_state.go", "backend/internal/service/openai_gateway_chat_completions_raw.go", - "backend/internal/service/openai_gateway_cn_fixes_test.go", + "backend/internal/service/openai_gateway_forward.go", + "backend/internal/service/openai_gateway_messages.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_scheduling.go", - "backend/internal/service/openai_images.go", - "backend/internal/service/openai_images_responses.go", - "backend/internal/service/openai_responses_tool_schema_test.go", - "backend/internal/service/openai_upstream_transport_error.go", - "backend/internal/service/openai_ws_forwarder_v2.go", - "backend/internal/service/ops_log_runtime_test.go", - "backend/internal/service/ops_service.go", - "backend/internal/service/plugin_package.go", - "backend/internal/service/plugin_package_test.go", - "backend/internal/service/ratelimit_service.go", - "backend/internal/service/ratelimit_service_openai_test.go", - "backend/internal/service/scheduler_snapshot_bulk_event_test.go", - "backend/internal/service/scheduler_snapshot_full_rebuild_lifecycle_test.go", - "backend/internal/service/scheduler_snapshot_group_lifecycle_test.go", - "backend/internal/service/scheduler_snapshot_retirement_test.go", - "backend/internal/service/scheduler_snapshot_service.go", + "backend/internal/service/openai_gateway_service.go", + "backend/internal/service/openai_gateway_service_test.go", + "backend/internal/service/openai_ws_forwarder_payload.go", + "backend/internal/service/openai_ws_forwarder_support.go", + "backend/internal/service/setting_gateway_runtime.go", "backend/internal/service/setting_parse.go", - "backend/internal/service/setting_public.go", + "backend/internal/service/setting_service.go", "backend/internal/service/setting_update.go", "backend/internal/service/settings_view.go", - "deploy/.env.example", - "deploy/docker-compose.dev.yml", - "deploy/docker-compose.local.yml", - "deploy/docker-compose.standalone.yml", - "deploy/docker-compose.yml", - "frontend/src/api/admin/accounts.ts", + "backend/internal/service/token_refresh_service_candidates_test.go", + "deploy/config.example.yaml", "frontend/src/api/admin/settings.ts", "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/UsageProgressBar.vue", "frontend/src/components/account/__tests__/AccountUsageCell.spec.ts", - "frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts", - "frontend/src/components/account/__tests__/CreateAccountModal.spec.ts", - "frontend/src/components/account/__tests__/EditAccountModal.spec.ts", - "frontend/src/components/admin/account/AccountActionMenu.vue", - "frontend/src/components/admin/account/__tests__/AccountActionMenu.spark_shadow.spec.ts", - "frontend/src/components/common/PlatformIcon.vue", - "frontend/src/components/common/PlatformTypeBadge.vue", - "frontend/src/components/common/__tests__/PlatformTypeBadge.grok.spec.ts", - "frontend/src/components/keys/UseKeyModal.vue", - "frontend/src/components/user/dashboard/UserDashboardStats.vue", - "frontend/src/composables/useModelWhitelist.ts", - "frontend/src/constants/__tests__/platforms.spec.ts", - "frontend/src/constants/platforms.ts", + "frontend/src/components/admin/channel/ModelTagInput.vue", + "frontend/src/components/admin/payment/AdminRefundDialog.vue", + "frontend/src/components/admin/user/UserPlatformQuotaModal.vue", + "frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts", + "frontend/src/components/common/BaseDialog.vue", + "frontend/src/components/common/Pagination.vue", + "frontend/src/components/common/ProxySelector.vue", + "frontend/src/components/payment/AmountInput.vue", + "frontend/src/components/user/profile/TotpDisableDialog.vue", + "frontend/src/components/user/profile/TotpSetupModal.vue", "frontend/src/i18n/locales/en/admin/accounts.ts", - "frontend/src/i18n/locales/en/admin/overview.ts", "frontend/src/i18n/locales/en/admin/settings.ts", - "frontend/src/i18n/locales/en/dashboard.ts", "frontend/src/i18n/locales/zh/admin/accounts.ts", - "frontend/src/i18n/locales/zh/admin/overview.ts", "frontend/src/i18n/locales/zh/admin/settings.ts", - "frontend/src/i18n/locales/zh/dashboard.ts", "frontend/src/types/index.ts", - "frontend/src/utils/featureFlags.ts", - "frontend/src/utils/platformColors.ts", - "frontend/src/views/admin/AccountsView.vue", - "frontend/src/views/admin/ChannelsView.vue", - "frontend/src/views/admin/GroupsView.vue", "frontend/src/views/admin/SettingsView.vue", - "frontend/src/views/admin/__tests__/AccountsView.lite.spec.ts", - "frontend/src/views/admin/__tests__/AccountsView.selectAllResults.spec.ts", - "frontend/src/views/admin/__tests__/GroupsView.compositePlatforms.spec.ts", - "frontend/src/views/admin/__tests__/channelPlatformOptions.spec.ts" + "frontend/src/views/admin/__tests__/GroupsView.codexManifest.spec.ts", + "frontend/src/views/admin/__tests__/SettingsView.spec.ts", + "frontend/src/views/auth/RegisterView.vue", + "frontend/src/views/user/RedeemView.vue", + "frontend/src/views/user/UserOrdersView.vue" ], + "review_status": "resolutions_implemented_validation_pending", "risk_class": "review_required", "schema": 1, - "upstream_base": "v0.2.3", - "upstream_file_count": 266, + "temporary_tag_created": false, + "upstream_base": "v0.2.5", + "upstream_commit": "49a39b6dc1abed30fd227611e8af1108bc427610", + "upstream_file_count": 132, "upstream_files": [ - "README.md", - "README_CN.md", - "README_JA.md", - "assets/partners/logos/cola-proxy.jpg", - "assets/partners/logos/ppdog.png", + ".gitignore", "backend/cmd/server/VERSION", + "backend/cmd/server/wire.go", "backend/cmd/server/wire_gen.go", - "backend/ent/channelmonitor/channelmonitor.go", - "backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go", - "backend/ent/client.go", - "backend/ent/migrate/schema.go", - "backend/ent/mutation.go", - "backend/ent/proxy.go", - "backend/ent/proxy/proxy.go", - "backend/ent/proxy/where.go", - "backend/ent/proxy_create.go", - "backend/ent/proxy_query.go", - "backend/ent/proxy_update.go", - "backend/ent/schema/channel_monitor.go", - "backend/ent/schema/channel_monitor_request_template.go", - "backend/ent/schema/proxy.go", - "backend/ent/schema/user_platform_quota.go", "backend/go.mod", "backend/go.sum", "backend/internal/config/config.go", - "backend/internal/config/config_test.go", - "backend/internal/domain/constants.go", + "backend/internal/handler/admin/account_codex_ticket_test.go", "backend/internal/handler/admin/account_data.go", - "backend/internal/handler/admin/channel_handler.go", - "backend/internal/handler/admin/channel_handler_test.go", - "backend/internal/handler/admin/channel_monitor_handler.go", - "backend/internal/handler/admin/grok_media_eligibility_handler.go", - "backend/internal/handler/admin/grok_media_eligibility_handler_test.go", - "backend/internal/handler/admin/group_handler.go", - "backend/internal/handler/admin/group_handler_platform_test.go", - "backend/internal/handler/admin/proxy_data.go", - "backend/internal/handler/admin/proxy_handler.go", - "backend/internal/handler/admin/proxy_partial_update_test.go", + "backend/internal/handler/admin/account_data_handler_test.go", + "backend/internal/handler/admin/account_handler.go", "backend/internal/handler/admin/setting_handler.go", + "backend/internal/handler/admin/setting_handler_audit.go", + "backend/internal/handler/admin/setting_handler_codex_ticket_test.go", "backend/internal/handler/admin/setting_handler_update.go", - "backend/internal/handler/composite_platform_test.go", + "backend/internal/handler/dto/account_mapper_redact_test.go", + "backend/internal/handler/dto/mappers.go", "backend/internal/handler/dto/settings.go", - "backend/internal/handler/gateway_handler.go", - "backend/internal/handler/gateway_helper.go", - "backend/internal/handler/gateway_helper_hotpath_test.go", - "backend/internal/handler/gateway_models_test.go", - "backend/internal/handler/model_plaza_handler.go", - "backend/internal/handler/model_plaza_handler_test.go", - "backend/internal/handler/openai_client_disconnect_test.go", - "backend/internal/handler/openai_gateway_cn_dispatch_test.go", - "backend/internal/handler/openai_gateway_handler.go", - "backend/internal/handler/openai_gateway_handler_test.go", - "backend/internal/handler/setting_handler.go", - "backend/internal/model/error_passthrough_rule.go", - "backend/internal/model/error_passthrough_rule_test.go", - "backend/internal/pkg/openai/constants.go", - "backend/internal/pkg/openai/constants_test.go", - "backend/internal/repository/account_repo_ollama_cloud_usage.go", - "backend/internal/repository/account_repo_ollama_cloud_usage_test.go", - "backend/internal/repository/channel_cache.go", - "backend/internal/repository/channel_cache_test.go", - "backend/internal/repository/concurrency_cache.go", + "backend/internal/handler/dto/types.go", + "backend/internal/handler/gateway_model_allowlist_listing_test.go", + "backend/internal/handler/gemini_mixed_models_test.go", + "backend/internal/handler/gemini_v1beta_handler.go", + "backend/internal/handler/gemini_v1beta_handler_test.go", + "backend/internal/handler/redeem_handler.go", + "backend/internal/handler/redeem_handler_test.go", + "backend/internal/handler/wire.go", + "backend/internal/pkg/antigravity/attribution_test.go", + "backend/internal/pkg/antigravity/request_transformer.go", + "backend/internal/pkg/apicompat/responses_tool_output_media.go", + "backend/internal/pkg/apicompat/responses_tool_output_media_test.go", + "backend/internal/repository/account_repo.go", + "backend/internal/repository/account_repo_codex_ticket_test.go", + "backend/internal/repository/account_repo_integration_test.go", + "backend/internal/repository/account_repo_temp_unsched_test.go", + "backend/internal/repository/account_repo_upstream_billing_probe_update_test.go", + "backend/internal/repository/custom_group_usage_rollup_repo.go", "backend/internal/repository/http_upstream.go", - "backend/internal/repository/http_upstream_http2_keepalive_test.go", "backend/internal/repository/http_upstream_test.go", - "backend/internal/repository/proxy_backup_reference_integration_test.go", - "backend/internal/repository/proxy_repeated_expiry_integration_test.go", - "backend/internal/repository/proxy_repo.go", - "backend/internal/repository/proxy_repo_upstream_billing_probe_test.go", - "backend/internal/repository/user_msg_queue_cache.go", - "backend/internal/repository/user_platform_quota_repo_integration_test.go", - "backend/internal/repository/wire.go", + "backend/internal/repository/redeem_code_repo.go", + "backend/internal/repository/redeem_code_repo_sort_integration_test.go", + "backend/internal/repository/usage_log_repo_group_summary_test.go", "backend/internal/server/api_contract_test.go", - "backend/internal/server/routes/admin.go", - "backend/internal/server/routes/gateway.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_models_test.go", - "backend/internal/service/account_test_service.go", - "backend/internal/service/account_test_service_openai_image_test.go", - "backend/internal/service/admin_group.go", - "backend/internal/service/admin_proxy.go", - "backend/internal/service/admin_proxy_date_validation_test.go", - "backend/internal/service/admin_proxy_partial_update_test.go", - "backend/internal/service/admin_service.go", - "backend/internal/service/admin_service_composite_group_test.go", - "backend/internal/service/announcement_service.go", - "backend/internal/service/antigravity_oauth_service.go", - "backend/internal/service/antigravity_oauth_service_test.go", - "backend/internal/service/api_key_group_visibility_test.go", - "backend/internal/service/api_key_service.go", - "backend/internal/service/billing_token_cost_request_test.go", - "backend/internal/service/channel_available_test.go", - "backend/internal/service/channel_monitor_checker.go", - "backend/internal/service/channel_monitor_const.go", - "backend/internal/service/channel_monitor_quota_fetcher.go", - "backend/internal/service/channel_monitor_quota_fetcher_test.go", - "backend/internal/service/channel_monitor_quota_mode_test.go", - "backend/internal/service/channel_monitor_v2.go", - "backend/internal/service/channel_monitor_v2_test.go", - "backend/internal/service/channel_monitor_validate.go", - "backend/internal/service/channel_service.go", - "backend/internal/service/channel_service_test.go", - "backend/internal/service/claude_code_validator.go", - "backend/internal/service/claude_code_validator_test.go", - "backend/internal/service/cn_provider_balance_check_service.go", - "backend/internal/service/cn_provider_balance_check_service_test.go", - "backend/internal/service/cn_provider_foraccount_test.go", - "backend/internal/service/cn_provider_quota_service.go", + "backend/internal/service/admin_account.go", + "backend/internal/service/admin_account_codex_ticket_test.go", "backend/internal/service/cn_providers_test.go", - "backend/internal/service/composite_platform.go", - "backend/internal/service/composite_platform_test.go", - "backend/internal/service/content_moderation_runtime_cache_test.go", "backend/internal/service/domain_constants.go", + "backend/internal/service/gemini_messages_compat_service.go", "backend/internal/service/http_upstream_profile.go", "backend/internal/service/http_upstream_profile_test.go", - "backend/internal/service/json_time.go", - "backend/internal/service/json_time_validation_test.go", - "backend/internal/service/model_plaza_service_test.go", - "backend/internal/service/model_pricing_resolver_test.go", - "backend/internal/service/ollama_cloud_usage.go", - "backend/internal/service/ollama_cloud_usage_test.go", "backend/internal/service/openai_account_runtime_block_fastpath.go", "backend/internal/service/openai_account_runtime_block_fastpath_test.go", "backend/internal/service/openai_account_runtime_transient_test.go", + "backend/internal/service/openai_account_scheduler.go", + "backend/internal/service/openai_chat_roles.go", + "backend/internal/service/openai_chat_roles_test.go", "backend/internal/service/openai_codex_models_service.go", "backend/internal/service/openai_codex_models_service_test.go", - "backend/internal/service/openai_codex_transform.go", - "backend/internal/service/openai_gateway_chat_completions.go", - "backend/internal/service/openai_gateway_chat_completions_cancellation_test.go", + "backend/internal/service/openai_codex_ticket.go", + "backend/internal/service/openai_codex_ticket_lifecycle_test.go", + "backend/internal/service/openai_codex_ticket_test.go", + "backend/internal/service/openai_codex_turn_state.go", "backend/internal/service/openai_gateway_chat_completions_raw.go", - "backend/internal/service/openai_gateway_cn_fixes_test.go", - "backend/internal/service/openai_gateway_grok.go", + "backend/internal/service/openai_gateway_forward.go", + "backend/internal/service/openai_gateway_messages.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_scheduling.go", - "backend/internal/service/openai_images.go", - "backend/internal/service/openai_images_model_test.go", - "backend/internal/service/openai_images_responses.go", - "backend/internal/service/openai_responses_tool_schema_test.go", - "backend/internal/service/openai_upstream_transport_error.go", - "backend/internal/service/openai_ws_forwarder_client_cancel_test.go", - "backend/internal/service/openai_ws_forwarder_v2.go", - "backend/internal/service/ops_cleanup_overlay_test.go", - "backend/internal/service/ops_cleanup_service.go", - "backend/internal/service/ops_log_runtime.go", - "backend/internal/service/ops_log_runtime_test.go", - "backend/internal/service/ops_runtime_snapshot_test.go", - "backend/internal/service/ops_service.go", - "backend/internal/service/ops_settings.go", - "backend/internal/service/ops_settings_advanced_test.go", - "backend/internal/service/ops_settings_models.go", - "backend/internal/service/ops_system_log_sink.go", - "backend/internal/service/ops_system_log_sink_test.go", - "backend/internal/service/plugin_package.go", - "backend/internal/service/plugin_package_test.go", - "backend/internal/service/pricing_service.go", - "backend/internal/service/proxy_update_probe_invalidation_test.go", - "backend/internal/service/ratelimit_service.go", - "backend/internal/service/ratelimit_service_openai_test.go", - "backend/internal/service/scheduler_snapshot_bulk_event_test.go", - "backend/internal/service/scheduler_snapshot_full_rebuild_lifecycle_test.go", - "backend/internal/service/scheduler_snapshot_group_lifecycle_test.go", - "backend/internal/service/scheduler_snapshot_retirement_test.go", - "backend/internal/service/scheduler_snapshot_service.go", + "backend/internal/service/openai_gateway_service.go", + "backend/internal/service/openai_gateway_service_test.go", + "backend/internal/service/openai_guardian_affinity_test.go", + "backend/internal/service/openai_ws_forwarder_payload.go", + "backend/internal/service/openai_ws_forwarder_support.go", + "backend/internal/service/redeem_service.go", + "backend/internal/service/setting_codex_ticket_test.go", + "backend/internal/service/setting_gateway_runtime.go", "backend/internal/service/setting_parse.go", - "backend/internal/service/setting_public.go", - "backend/internal/service/setting_service_public_test.go", + "backend/internal/service/setting_service.go", "backend/internal/service/setting_update.go", "backend/internal/service/settings_view.go", - "backend/internal/service/upstream_billing_probe.go", - "backend/internal/service/upstream_billing_probe_multiplatform_test.go", - "backend/migrations/237_add_minimax_platform.sql", - "backend/migrations/minimax_platform_migration_test.go", - "backend/resources/model-pricing/model_prices_and_context_window.json", - "deploy/.env.example", - "deploy/APPLE_CONTAINER.md", - "deploy/apple-container.sh", - "deploy/docker-compose.dev.yml", - "deploy/docker-compose.local.yml", - "deploy/docker-compose.standalone.yml", - "deploy/docker-compose.yml", - "deploy/tests/apple-container-test.sh", - "deploy/tests/fixtures/bin/container", - "docs/COMPOSITE_GROUPS.md", - "frontend/src/api/__tests__/admin.proxies.spec.ts", - "frontend/src/api/admin/accounts.ts", - "frontend/src/api/admin/antigravity.ts", - "frontend/src/api/admin/channelMonitor.ts", - "frontend/src/api/admin/ops.ts", - "frontend/src/api/admin/proxies.ts", + "backend/internal/service/token_refresh_service_candidates_test.go", + "deploy/config.example.yaml", + "docs/ANTIGRAVITY_ATTRIBUTION_429.md", + "frontend/src/api/__tests__/redeem.spec.ts", "frontend/src/api/admin/settings.ts", + "frontend/src/api/redeem.ts", "frontend/src/components/account/AccountUsageCell.vue", - "frontend/src/components/account/CnBaseUrlPresets.vue", - "frontend/src/components/account/CreateAccountModal.vue", "frontend/src/components/account/EditAccountModal.vue", - "frontend/src/components/account/ModelWhitelistSelector.vue", - "frontend/src/components/account/UsageProgressBar.vue", "frontend/src/components/account/__tests__/AccountUsageCell.spec.ts", - "frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts", - "frontend/src/components/account/__tests__/CreateAccountModal.spec.ts", - "frontend/src/components/account/__tests__/EditAccountModal.grokMediaEligibility.spec.ts", - "frontend/src/components/account/__tests__/EditAccountModal.spec.ts", - "frontend/src/components/account/__tests__/credentialsBuilder.cnAdaptive.spec.ts", - "frontend/src/components/account/__tests__/credentialsBuilder.spec.ts", - "frontend/src/components/account/credentialsBuilder.ts", - "frontend/src/components/admin/account/AccountActionMenu.vue", - "frontend/src/components/admin/account/__tests__/AccountActionMenu.position.spec.ts", - "frontend/src/components/admin/account/__tests__/AccountActionMenu.spark_shadow.spec.ts", - "frontend/src/components/admin/monitor/MonitorFiltersBar.vue", - "frontend/src/components/admin/monitor/MonitorFormDialog.vue", - "frontend/src/components/admin/monitor/MonitorTemplateManagerDialog.vue", - "frontend/src/components/common/GroupBadge.vue", - "frontend/src/components/common/GroupSelector.vue", - "frontend/src/components/common/PlatformIcon.vue", - "frontend/src/components/common/PlatformTypeBadge.vue", - "frontend/src/components/common/__tests__/PlatformTypeBadge.grok.spec.ts", - "frontend/src/components/keys/UseKeyModal.vue", - "frontend/src/components/keys/__tests__/UseKeyModal.spec.ts", - "frontend/src/components/user/PlatformUsageBreakdown.vue", - "frontend/src/components/user/dashboard/UserDashboardStats.vue", - "frontend/src/components/user/monitor/ProviderIcon.vue", - "frontend/src/composables/__tests__/useAntigravityOAuth.spec.ts", - "frontend/src/composables/useAntigravityOAuth.ts", - "frontend/src/composables/useChannelMonitorFormat.ts", - "frontend/src/composables/useModelWhitelist.ts", - "frontend/src/constants/__tests__/platforms.spec.ts", - "frontend/src/constants/channelMonitor.ts", - "frontend/src/constants/platforms.ts", - "frontend/src/features/channel-monitor-v2/MetricCell.vue", - "frontend/src/features/channel-monitor-v2/MonitorSettingsPanel.vue", - "frontend/src/features/channel-monitor-v2/__tests__/MetricCell.spec.ts", - "frontend/src/features/channel-monitor-v2/__tests__/monitorFormat.spec.ts", - "frontend/src/features/channel-monitor-v2/monitorFormat.ts", + "frontend/src/components/admin/channel/ModelTagInput.vue", + "frontend/src/components/admin/channel/__tests__/ModelTagInput.keyboard.spec.ts", + "frontend/src/components/admin/payment/AdminRefundDialog.vue", + "frontend/src/components/admin/payment/__tests__/AdminRefundDialog.balance.spec.ts", + "frontend/src/components/admin/user/UserPlatformQuotaModal.vue", + "frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts", + "frontend/src/components/common/BaseDialog.vue", + "frontend/src/components/common/Pagination.vue", + "frontend/src/components/common/ProxySelector.vue", + "frontend/src/components/common/__tests__/BaseDialog.ids.spec.ts", + "frontend/src/components/common/__tests__/Pagination.jump.spec.ts", + "frontend/src/components/common/__tests__/ProxySelector.testing.spec.ts", + "frontend/src/components/payment/AmountInput.vue", + "frontend/src/components/payment/__tests__/AmountInput.spec.ts", + "frontend/src/components/user/profile/TotpDisableDialog.vue", + "frontend/src/components/user/profile/TotpSetupModal.vue", + "frontend/src/components/user/profile/__tests__/Totp.errors.spec.ts", + "frontend/src/components/user/profile/__tests__/TotpSetupModal.inputs.spec.ts", + "frontend/src/composables/__tests__/useClipboard.spec.ts", + "frontend/src/composables/useClipboard.ts", "frontend/src/i18n/locales/en/admin/accounts.ts", - "frontend/src/i18n/locales/en/admin/ops.ts", - "frontend/src/i18n/locales/en/admin/overview.ts", "frontend/src/i18n/locales/en/admin/settings.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/overview.ts", "frontend/src/i18n/locales/zh/admin/settings.ts", "frontend/src/i18n/locales/zh/dashboard.ts", + "frontend/src/stores/__tests__/announcements.markAll.spec.ts", + "frontend/src/stores/__tests__/payment.config.spec.ts", + "frontend/src/stores/__tests__/subscriptions.clear.spec.ts", + "frontend/src/stores/announcements.ts", + "frontend/src/stores/payment.ts", + "frontend/src/stores/subscriptions.ts", "frontend/src/types/index.ts", - "frontend/src/utils/featureFlags.ts", - "frontend/src/utils/platformColors.ts", - "frontend/src/views/admin/AccountsView.vue", - "frontend/src/views/admin/AnnouncementsView.vue", - "frontend/src/views/admin/ChannelsView.vue", - "frontend/src/views/admin/GroupsView.vue", - "frontend/src/views/admin/ProxiesView.vue", "frontend/src/views/admin/SettingsView.vue", - "frontend/src/views/admin/__tests__/AccountsView.lite.spec.ts", - "frontend/src/views/admin/__tests__/AccountsView.selectAllResults.spec.ts", - "frontend/src/views/admin/__tests__/GroupsView.compositePlatforms.spec.ts", - "frontend/src/views/admin/__tests__/channelPlatformOptions.spec.ts", - "frontend/src/views/admin/ops/components/OpsSystemLogTable.vue", - "frontend/src/views/admin/ops/components/__tests__/OpsSystemLogTable.spec.ts", - "frontend/src/views/auth/LoginView.vue", - "frontend/src/views/auth/__tests__/LoginView.spec.ts", - "frontend/src/views/user/ChannelStatusV2View.vue", - "frontend/src/views/user/CustomPageView.vue", - "frontend/src/views/user/PaymentView.vue", - "frontend/src/views/user/__tests__/CustomPageView.spec.ts", - "frontend/src/views/user/__tests__/PaymentView.spec.ts" + "frontend/src/views/admin/__tests__/ChannelMonitorView.grok.spec.ts", + "frontend/src/views/admin/__tests__/GroupsView.codexManifest.spec.ts", + "frontend/src/views/admin/__tests__/SettingsView.spec.ts", + "frontend/src/views/auth/RegisterView.vue", + "frontend/src/views/auth/__tests__/RegisterView.spec.ts", + "frontend/src/views/user/RedeemView.vue", + "frontend/src/views/user/UserOrdersView.vue", + "frontend/src/views/user/__tests__/RedeemView.spec.ts", + "frontend/src/views/user/__tests__/UserOrdersView.filters.spec.ts" + ], + "upstream_parents": [ + "efe9aab1e4ec89a42ba45e8dac20e882c5409a6a", + "3c2f05c957b4b93866318ec8695fc5a28fff70eb" ], - "upstream_tag": "v0.2.4" + "upstream_ref_source": "recovered_exact_commit", + "upstream_tag": "v0.2.6", + "upstream_tag_checked_at": "2026-09-18", + "upstream_tag_status": "withdrawn_not_advertised", + "upstream_tree": "61de86700f217d58c337998f3c51a89969493267" } diff --git a/.gitignore b/.gitignore index aa7dc08e4088..9ec5ff1b0bd4 100644 --- a/.gitignore +++ b/.gitignore @@ -137,6 +137,7 @@ deploy/docker-compose.override.yml .gocache/ vite.config.js docs/* +!docs/ANTIGRAVITY_ATTRIBUTION_429.md !docs/PAYMENT.md !docs/PAYMENT_CN.md !docs/ADMIN_PAYMENT_INTEGRATION_API.md diff --git a/DOWNSTREAM.md b/DOWNSTREAM.md index 38e4f4a95b9c..ac0fc3c829dc 100644 --- a/DOWNSTREAM.md +++ b/DOWNSTREAM.md @@ -11,6 +11,13 @@ This fork maintains the `codexrip` patch set over official Sub2API releases. - The operator workspace `docs/sub2api.md` owns the current production pointer. A source merge or Release alone does not establish deployment completion. +### Recovered upstream v0.2.6 + +- The withdrawn upstream release is recovered from exact merge commit `49a39b6dc1abed30fd227611e8af1108bc427610`; the remote `v0.2.6` tag is not used as a source. +- The merge includes the official changes after v0.2.5 and the six Codex ticket patches. The ticket feature remains runtime-disabled in the published deployment until an administrator supplies a harvest proxy and enables it from the settings page. +- Ticket collection and injection apply only to OpenAI OAuth and Setup Token accounts. Cindy/API-key accounts retain their existing identity, health, quota and sticky-session behavior. +- The integration review and one-off exact-SHA risk evidence are recorded in `.downstream/upstream-review-v0.2.6.md` and `.downstream/upstream-risk.json`. + ## Official behavior and downstream contracts OpenCode Zen/GO, site billing states, WebSocket execution scope and pooling, diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 3a4036fb450f..53a75d673557 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.2.5 +0.2.6 diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index 893b3d787763..cba8765c5973 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -374,6 +374,12 @@ func provideCleanup( } return nil }}, + {"OpenAICodexTicketHarvester", func() error { + if openAIGateway != nil { + openAIGateway.StopOpenAICodexTicketHarvester() + } + return nil + }}, {"ScheduledTestRunnerService", func() error { if scheduledTestRunner != nil { scheduledTestRunner.Stop() diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index d2c0c20d3549..369c7559ccc6 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -277,6 +277,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { errorPassthroughService := service.NewErrorPassthroughService(errorPassthroughRepository, errorPassthroughCache) errorPassthroughHandler := admin.NewErrorPassthroughHandler(errorPassthroughService) tlsFingerprintProfileHandler := admin.NewTLSFingerprintProfileHandler(tlsFingerprintProfileService) + pluginHandler := admin.NewPluginHandler(pluginManager) adminAPIKeyHandler := admin.NewAdminAPIKeyHandler(adminService) scheduledTestPlanRepository := repository.NewScheduledTestPlanRepository(db) scheduledTestResultRepository := repository.NewScheduledTestResultRepository(db) @@ -314,14 +315,14 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db) accountTrafficObserveCache := repository.ProvideAccountTrafficObserveCache(redisClient, configConfig) accountTrafficObserver := service.NewAccountTrafficObserver(accountTrafficObserveCache, configConfig) - adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, accountJobHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, systemPromptHandler, cindyBalanceProbeHandler, accountJobRuntime, upstreamBillingProbeService, ollamaCloudUsageService, accountTrafficObserver) + adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, accountJobHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, systemPromptHandler, cindyBalanceProbeHandler, accountJobRuntime, upstreamBillingProbeService, ollamaCloudUsageService, accountTrafficObserver, settingService) usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) 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) - openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, gatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, accountTrafficObserver, configConfig, coordinator) + openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, gatewayService, pluginManager, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, accountTrafficObserver, configConfig, coordinator) handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService) totpHandler := handler.NewTotpHandler(totpService) passkeyRepository := repository.NewPasskeyRepository(db) @@ -717,6 +718,12 @@ func provideCleanup( } return nil }}, + {"OpenAICodexTicketHarvester", func() error { + if openAIGateway != nil { + openAIGateway.StopOpenAICodexTicketHarvester() + } + return nil + }}, {"ScheduledTestRunnerService", func() error { if scheduledTestRunner != nil { scheduledTestRunner.Stop() diff --git a/backend/go.sum b/backend/go.sum index c7e4804861d3..be6667a0eb0d 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -253,6 +253,8 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:E github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 5d3d240a734a..f1604411e009 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1019,6 +1019,9 @@ type GatewayConfig struct { // OpenAICompactModel: /responses/compact 上游使用的模型。 // compact 端点支持模型滞后于普通 /responses 时,可用该配置降级规避上游错误。 OpenAICompactModel string `mapstructure:"openai_compact_model"` + // OpenAICodexTicket: ChatGPT OAuth 账号按 (账号, 模型) 捕获 292 长度 + // x-codex-turn-state,并在住宅 IP 业务请求中注入该头。默认关闭。 + OpenAICodexTicket OpenAICodexTicketConfig `mapstructure:"openai_codex_ticket"` // OpenAIWS: OpenAI Responses WebSocket 配置(默认开启,可按需回滚到 HTTP) OpenAIWS GatewayOpenAIWSConfig `mapstructure:"openai_ws"` // Live: ChatGPT Frameless Live 会话配置。 @@ -1236,6 +1239,21 @@ func (c *UserMessageQueueConfig) GetEffectiveMode() string { return "" } +// OpenAICodexTicketConfig 控制 ChatGPT OAuth 的 x-codex-turn-state 门票。 +// 打票走 harvest_proxy_url(SOCKS),业务出站仍用账号住宅 proxy_id,只替换该请求头。 +// 门票默认有效 3600 秒,临近过期前 refresh_before_seconds 重新打票。 +type OpenAICodexTicketConfig struct { + Enabled bool `mapstructure:"enabled"` + TargetLength int `mapstructure:"target_length"` + TTLSeconds int `mapstructure:"ttl_seconds"` + RefreshBeforeSeconds int `mapstructure:"refresh_before_seconds"` + HarvestProxyURL string `mapstructure:"harvest_proxy_url"` + HarvestProbeIntervalSeconds int `mapstructure:"harvest_probe_interval_seconds"` + HarvestAttemptTimeoutSeconds int `mapstructure:"harvest_attempt_timeout_seconds"` + FailClosed bool `mapstructure:"fail_closed"` + Models []string `mapstructure:"models"` +} + // DefaultOpenAIWSClientFirstMessageTimeoutSeconds preserves the legacy ingress deadline. const DefaultOpenAIWSClientFirstMessageTimeoutSeconds = 30 @@ -2406,6 +2424,15 @@ func setDefaults() { viper.SetDefault("gateway.codex_image_generation_bridge_enabled", false) viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false) viper.SetDefault("gateway.openai_compact_model", "gpt-5.5") + viper.SetDefault("gateway.openai_codex_ticket.enabled", false) + viper.SetDefault("gateway.openai_codex_ticket.target_length", 292) + viper.SetDefault("gateway.openai_codex_ticket.ttl_seconds", 3600) + viper.SetDefault("gateway.openai_codex_ticket.refresh_before_seconds", 600) + viper.SetDefault("gateway.openai_codex_ticket.harvest_proxy_url", "") + viper.SetDefault("gateway.openai_codex_ticket.harvest_probe_interval_seconds", 6) + viper.SetDefault("gateway.openai_codex_ticket.harvest_attempt_timeout_seconds", 25) + viper.SetDefault("gateway.openai_codex_ticket.fail_closed", true) + viper.SetDefault("gateway.openai_codex_ticket.models", []string{"gpt-6-astra", "gpt-5.6-sol"}) viper.SetDefault("gateway.live.max_session_duration_seconds", 3600) // OpenAI Responses WebSocket(默认开启;可通过 force_http 紧急回滚) viper.SetDefault("gateway.openai_ws.enabled", true) diff --git a/backend/internal/handler/admin/account_codex_ticket_test.go b/backend/internal/handler/admin/account_codex_ticket_test.go new file mode 100644 index 000000000000..2b1d7c193588 --- /dev/null +++ b/backend/internal/handler/admin/account_codex_ticket_test.go @@ -0,0 +1,36 @@ +package admin + +import ( + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" + "testing" +) + +func TestAccountResponseCodexTicketsUsesConfiguredPolicy(t *testing.T) { + account := &service.Account{ID: 41, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth} + h := &AccountHandler{cfg: &config.Config{}} + require.Empty(t, h.accountResponseFromService(account).CodexTurnTickets) + require.Empty(t, h.accountListResponseFromService(account).CodexTurnTickets) + h.cfg.Gateway.OpenAICodexTicket = config.OpenAICodexTicketConfig{Enabled: true, Models: []string{"configured-model"}, FailClosed: false} + status := h.accountListResponseFromService(account).CodexTurnTickets + require.Len(t, status, 1) + require.Equal(t, "configured-model", status[0].Model) + require.False(t, status[0].Blocked) + h.cfg.Gateway.OpenAICodexTicket.FailClosed = true + require.True(t, h.accountResponseFromService(account).CodexTurnTickets[0].Blocked) +} + +func TestAccountResponseCodexTicketsReadsLiveSettingsAfterRestart(t *testing.T) { + cfg := &config.Config{} + repo := &settingHandlerRepoStub{values: map[string]string{service.SettingKeyOpenAICodexTicketEnabled: "true"}} + settings := service.NewSettingService(repo, cfg) + h := &AccountHandler{cfg: cfg} + h.SetCodexTicketSettings(settings) + account := &service.Account{ID: 41, Platform: service.PlatformOpenAI, Type: service.AccountTypeSetupToken} + require.Len(t, h.accountListResponseFromService(account).CodexTurnTickets, 2) + require.False(t, cfg.Gateway.OpenAICodexTicket.Enabled) + repo.values[service.SettingKeyOpenAICodexTicketEnabled] = "false" + settings.InvalidateOpenAICodexTicketEnabledCache() + require.Empty(t, h.accountResponseFromService(account).CodexTurnTickets) +} diff --git a/backend/internal/handler/admin/account_data.go b/backend/internal/handler/admin/account_data.go index 6f594bded414..e6d3d952414c 100644 --- a/backend/internal/handler/admin/account_data.go +++ b/backend/internal/handler/admin/account_data.go @@ -285,7 +285,7 @@ func (h *AccountHandler) ExportData(c *gin.Context) { Platform: acc.Platform, Type: acc.Type, Credentials: acc.Credentials, - Extra: acc.Extra, + Extra: service.RedactOpenAICodexTicketExtra(acc.Extra), ProxyKey: proxyKey, Concurrency: acc.Concurrency, Priority: acc.Priority, diff --git a/backend/internal/handler/admin/account_data_handler_test.go b/backend/internal/handler/admin/account_data_handler_test.go index ee54f7f95a80..b096bd69a227 100644 --- a/backend/internal/handler/admin/account_data_handler_test.go +++ b/backend/internal/handler/admin/account_data_handler_test.go @@ -327,3 +327,25 @@ func TestImportDataReusesProxyAndSkipsDefaultGroup(t *testing.T) { require.Len(t, adminSvc.createdAccounts, 1) require.True(t, adminSvc.createdAccounts[0].SkipDefaultGroupBind) } + +func TestExportDataExcludesCodexTicketMaterial(t *testing.T) { + router, adminSvc := setupAccountDataRouter() + extra := map[string]any{ + "codex_turn_ticket:gpt-6-astra": map[string]any{"state": "private-ticket-blob", "length": 292}, + "codex_harvest_proxy_url": "http://user:legacy-proxy-secret@proxy.example.com:8080", + "ordinary": "retained", + } + adminSvc.accounts = []service.Account{{ID: 21, Name: "account", Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Credentials: map[string]any{"access_token": "backup-token"}, Extra: extra}} + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/data?include_proxies=false", nil)) + require.Equal(t, http.StatusOK, rec.Code) + var resp dataResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Len(t, resp.Data.Accounts, 1) + require.Equal(t, map[string]any{"ordinary": "retained"}, resp.Data.Accounts[0].Extra) + require.Equal(t, "backup-token", resp.Data.Accounts[0].Credentials["access_token"]) + require.NotContains(t, rec.Body.String(), "private-ticket-blob") + require.NotContains(t, rec.Body.String(), "legacy-proxy-secret") + require.Contains(t, extra, "codex_turn_ticket:gpt-6-astra") + require.Contains(t, extra, "codex_harvest_proxy_url") +} diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 7449d7acfcf8..fae08346cdfa 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -68,9 +68,11 @@ type AccountHandler struct { trafficObserver *service.AccountTrafficObserver accountJobs *service.AccountJobService cindyJobMutations service.AccountJobCindyMutationRunner - cfg *config.Config - apiKeyRevealMu sync.RWMutex - apiKeyRevealUsers map[int64]struct{} + codexTicketSettings *service.SettingService + + cfg *config.Config + apiKeyRevealMu sync.RWMutex + apiKeyRevealUsers map[int64]struct{} } // SetUpstreamBillingProbeService attaches the optional remote billing probe service. @@ -87,6 +89,11 @@ func (h *AccountHandler) SetAccountTrafficObserver(observer *service.AccountTraf h.trafficObserver = observer } +// SetCodexTicketSettings supplies the live policy without mutating shared config. +func (h *AccountHandler) SetCodexTicketSettings(settings *service.SettingService) { + h.codexTicketSettings = settings +} + // NewAccountHandler creates a new admin account handler func NewAccountHandler( adminService service.AdminService, @@ -374,6 +381,7 @@ const accountListGroupUngroupedQueryValue = "ungrouped" func (h *AccountHandler) accountResponseFromService(account *service.Account) *dto.Account { out := dto.AccountFromService(account) + h.enrichCodexTicketStatus(account, out) if h != nil && h.ollamaCloudUsage != nil && out != nil { h.ollamaCloudUsage.EnrichState(out.OllamaCloudUsage) } @@ -382,6 +390,7 @@ func (h *AccountHandler) accountResponseFromService(account *service.Account) *d func (h *AccountHandler) accountListResponseFromService(account *service.Account) *dto.Account { out := dto.AccountFromServiceShallow(account) + h.enrichCodexTicketStatus(account, out) if out != nil && account != nil { out.Proxy = dto.ProxyFromService(account.Proxy) } @@ -391,6 +400,16 @@ func (h *AccountHandler) accountListResponseFromService(account *service.Account return out } +func (h *AccountHandler) enrichCodexTicketStatus(account *service.Account, out *dto.Account) { + if h != nil && h.cfg != nil && out != nil { + cfg := h.cfg.Gateway.OpenAICodexTicket + if h.codexTicketSettings != nil { + cfg.Enabled = h.codexTicketSettings.GetOpenAICodexTicketEnabled(context.Background(), cfg.Enabled) + } + out.CodexTurnTickets = service.OpenAICodexTicketStatuses(account, cfg, time.Now()) + } +} + func (h *AccountHandler) isSimpleMode() bool { return h != nil && h.cfg != nil && h.cfg.RunMode == config.RunModeSimple } diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 99bd3413a3ac..6454e7225743 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -309,6 +309,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { OpenAICodexClientVersion: settings.OpenAICodexClientVersion, OpenAICodexClientVersionSynced: settings.OpenAICodexClientVersionSynced, OpenAICodexVersionAutoSyncEnabled: settings.OpenAICodexVersionAutoSyncEnabled, + OpenAICodexTicketEnabled: settings.OpenAICodexTicketEnabled, + OpenAICodexTicketHarvestProxyURL: service.MaskProxyURL(settings.OpenAICodexTicketHarvestProxyURL), + OpenAICodexTicketHarvestProxyConfigured: strings.TrimSpace(settings.OpenAICodexTicketHarvestProxyURL) != "", MinCodexVersion: settings.MinCodexVersion, MaxCodexVersion: settings.MaxCodexVersion, CodexCLIOnlyBlacklist: settings.CodexCLIOnlyBlacklist, diff --git a/backend/internal/handler/admin/setting_handler_audit.go b/backend/internal/handler/admin/setting_handler_audit.go index 81ac7b8c6fc5..12369560d691 100644 --- a/backend/internal/handler/admin/setting_handler_audit.go +++ b/backend/internal/handler/admin/setting_handler_audit.go @@ -483,6 +483,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.AntigravityUserAgentVersion != after.AntigravityUserAgentVersion { changed = append(changed, "antigravity_user_agent_version") } + if before.OpenAICodexTicketEnabled != after.OpenAICodexTicketEnabled { + changed = append(changed, "openai_codex_ticket_enabled") + } + if before.OpenAICodexTicketHarvestProxyURL != after.OpenAICodexTicketHarvestProxyURL { + changed = append(changed, "openai_codex_ticket_harvest_proxy_url") + } if before.OpenAICodexUserAgent != after.OpenAICodexUserAgent { changed = append(changed, "openai_codex_user_agent") } diff --git a/backend/internal/handler/admin/setting_handler_codex_ticket_test.go b/backend/internal/handler/admin/setting_handler_codex_ticket_test.go new file mode 100644 index 000000000000..64b05e7e7494 --- /dev/null +++ b/backend/internal/handler/admin/setting_handler_codex_ticket_test.go @@ -0,0 +1,48 @@ +package admin + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestSettingsCodexTicketProxyWriteReadAndHotReload(t *testing.T) { + key := service.SettingKeyOpenAICodexTicketHarvestProxyURL + oldProxy := "http://user:old-secret@old.example.com:8080" + newProxy := "socks5h://user:new-secret@new.example.com:1080" + h, repo := newStepUpSwitchTestHandler(t, map[string]string{key: oldProxy}) + require.Equal(t, oldProxy, h.settingService.GetOpenAICodexTicketHarvestProxyURL(context.Background())) + rec := doUpdateSettings(t, h, map[string]any{key: newProxy}, nil) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + require.Equal(t, newProxy, repo.values[key]) + require.Equal(t, newProxy, h.settingService.GetOpenAICodexTicketHarvestProxyURL(context.Background())) + require.NotContains(t, rec.Body.String(), "new-secret") + require.Contains(t, rec.Body.String(), `"openai_codex_ticket_harvest_proxy_configured":true`) + // Omission, empty input and the masked GET value all preserve the real secret. + for _, body := range []map[string]any{{"site_name": "updated"}, {key: ""}, {key: service.MaskProxyURL(newProxy)}} { + rec = doUpdateSettings(t, h, body, nil) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + require.Equal(t, newProxy, repo.values[key]) + } + get := httptest.NewRecorder() + c, _ := gin.CreateTestContext(get) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings", nil) + h.GetSettings(c) + require.Equal(t, http.StatusOK, get.Code) + require.NotContains(t, get.Body.String(), "new-secret") + require.Contains(t, get.Body.String(), "new.example.com") +} + +func TestSettingsCodexTicketRejectInvalidProxyWithoutLeakingPassword(t *testing.T) { + key := service.SettingKeyOpenAICodexTicketHarvestProxyURL + h, repo := newStepUpSwitchTestHandler(t, map[string]string{key: "http://previous.example.com:8080"}) + rec := doUpdateSettings(t, h, map[string]any{key: "ftp://user:invalid-secret@proxy.example.com:21"}, nil) + require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + require.NotContains(t, rec.Body.String(), "invalid-secret") + require.Equal(t, "http://previous.example.com:8080", repo.values[key]) +} diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index dbb1b9f1d554..f3d229e2fff1 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -257,6 +257,8 @@ type UpdateSettingsRequest struct { OpenAICodexUserAgent *string `json:"openai_codex_user_agent"` OpenAICodexClientVersion *string `json:"openai_codex_client_version"` OpenAICodexVersionAutoSyncEnabled *bool `json:"openai_codex_version_auto_sync_enabled"` + OpenAICodexTicketEnabled *bool `json:"openai_codex_ticket_enabled"` + OpenAICodexTicketHarvestProxyURL string `json:"openai_codex_ticket_harvest_proxy_url"` // codex_cli_only 加固(global-only) MinCodexVersion string `json:"min_codex_version"` @@ -1776,6 +1778,19 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.OpenAICodexVersionAutoSyncEnabled }(), + OpenAICodexTicketEnabled: func() bool { + if req.OpenAICodexTicketEnabled != nil { + return *req.OpenAICodexTicketEnabled + } + return previousSettings.OpenAICodexTicketEnabled + }(), + OpenAICodexTicketHarvestProxyURL: func() string { + next := strings.TrimSpace(req.OpenAICodexTicketHarvestProxyURL) + if service.IsMaskedProxyURL(next) { + return previousSettings.OpenAICodexTicketHarvestProxyURL + } + return next + }(), MinCodexVersion: strings.TrimSpace(req.MinCodexVersion), MaxCodexVersion: strings.TrimSpace(req.MaxCodexVersion), CodexCLIOnlyBlacklist: strings.TrimSpace(req.CodexCLIOnlyBlacklist), @@ -2336,6 +2351,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { OpenAICodexClientVersion: updatedSettings.OpenAICodexClientVersion, OpenAICodexClientVersionSynced: updatedSettings.OpenAICodexClientVersionSynced, OpenAICodexVersionAutoSyncEnabled: updatedSettings.OpenAICodexVersionAutoSyncEnabled, + OpenAICodexTicketEnabled: updatedSettings.OpenAICodexTicketEnabled, + OpenAICodexTicketHarvestProxyURL: service.MaskProxyURL(updatedSettings.OpenAICodexTicketHarvestProxyURL), + OpenAICodexTicketHarvestProxyConfigured: strings.TrimSpace(updatedSettings.OpenAICodexTicketHarvestProxyURL) != "", MinCodexVersion: updatedSettings.MinCodexVersion, MaxCodexVersion: updatedSettings.MaxCodexVersion, CodexCLIOnlyBlacklist: updatedSettings.CodexCLIOnlyBlacklist, diff --git a/backend/internal/handler/dto/account_mapper_redact_test.go b/backend/internal/handler/dto/account_mapper_redact_test.go index d1a826d7a290..684081dc3812 100644 --- a/backend/internal/handler/dto/account_mapper_redact_test.go +++ b/backend/internal/handler/dto/account_mapper_redact_test.go @@ -2,7 +2,9 @@ package dto import ( "encoding/json" + "strings" "testing" + "time" "github.com/stretchr/testify/require" @@ -93,6 +95,32 @@ func TestAccountFromServiceShallow_RedactsOllamaCloudManagedExtra(t *testing.T) require.Contains(t, src.Extra, service.OllamaCloudUsageSessionExtraKey) } +func TestAccountFromServiceShallow_RedactsCodexTurnTicketState(t *testing.T) { + blob := "gAAAAA" + strings.Repeat("B", 286) + src := &service.Account{ + ID: 41, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, + Extra: map[string]any{ + "codex_harvest_proxy_url": "http://user:legacy-proxy-secret@proxy.example.com:8080", + "codex_turn_ticket:gpt-6-astra": map[string]any{ + "state": blob, + "length": 292, + "model": "gpt-6-astra", + "captured_at": time.Now().Add(-time.Minute), + "expires_at": time.Now().Add(time.Hour), + "attempts": 3, + }, + }, + } + got := AccountFromServiceShallow(src) + require.NotContains(t, got.Extra, "codex_turn_ticket:gpt-6-astra") + raw, err := json.Marshal(got) + require.NoError(t, err) + require.NotContains(t, string(raw), blob) + require.NotContains(t, string(raw), "legacy-proxy-secret") + require.NotContains(t, got.Extra, "codex_harvest_proxy_url") + require.Contains(t, src.Extra, "codex_harvest_proxy_url") +} + func TestAccountFromServiceShallow_NilCredentialsOmitsStatus(t *testing.T) { src := &service.Account{ID: 1, Name: "n", Platform: "anthropic", Type: "oauth"} got := AccountFromServiceShallow(src) diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 74c9df0bdb59..533c20ab4108 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -447,12 +447,14 @@ func redactAccountManagedExtra(extra map[string]any) map[string]any { } redacted := make(map[string]any, len(extra)) for key, value := range extra { - switch key { - case service.OllamaCloudUsageSessionExtraKey, - service.OllamaCloudUsageAutoRefreshExtraKey, - service.OllamaCloudUsageSnapshotExtraKey: + switch { + case key == service.OllamaCloudUsageSessionExtraKey, + key == service.OllamaCloudUsageAutoRefreshExtraKey, + key == service.OllamaCloudUsageSnapshotExtraKey: continue - case service.CindyDeviceIDExtraKey: + case service.IsOpenAICodexTicketPrivateExtraKey(key): + continue + case key == service.CindyDeviceIDExtraKey: deviceID, _ := value.(string) if masked := service.MaskCindyDeviceID(deviceID); masked != "" { redacted[key] = masked @@ -506,8 +508,8 @@ func AccountListItemFromAccount(a *Account) *AccountListItem { CindyBalanceProbeCheckedAt: a.CindyBalanceProbeCheckedAt, ID: a.ID, Name: a.Name, Notes: a.Notes, Platform: a.Platform, Type: a.Type, Credentials: a.Credentials, CredentialsStatus: a.CredentialsStatus, Extra: a.Extra, - OllamaCloudUsage: a.OllamaCloudUsage, - ProxyID: a.ProxyID, ProxyFallbackOriginID: a.ProxyFallbackOriginID, ProxyFallbackOriginName: a.ProxyFallbackOriginName, + OllamaCloudUsage: a.OllamaCloudUsage, CodexTurnTickets: a.CodexTurnTickets, + ProxyID: a.ProxyID, ProxyFallbackOriginID: a.ProxyFallbackOriginID, ProxyFallbackOriginName: a.ProxyFallbackOriginName, Concurrency: a.Concurrency, LoadFactor: a.LoadFactor, Priority: a.Priority, RateMultiplier: a.RateMultiplier, Status: a.Status, ErrorMessage: a.ErrorMessage, LastUsedAt: a.LastUsedAt, ExpiresAt: a.ExpiresAt, AutoPauseOnExpired: a.AutoPauseOnExpired, CreatedAt: a.CreatedAt, UpdatedAt: a.UpdatedAt, diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 4295a478985a..5988c2d96d92 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -222,21 +222,24 @@ type SystemSettings struct { BackendModeEnabled bool `json:"backend_mode_enabled"` // Gateway forwarding behavior - OpenAITTFTMode string `json:"openai_ttft_mode"` - EnableFingerprintUnification bool `json:"enable_fingerprint_unification"` - EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"` - EnableCCHSigning bool `json:"enable_cch_signing"` - EnableClaudeOAuthSystemPromptInjection bool `json:"enable_claude_oauth_system_prompt_injection"` - ClaudeOAuthSystemPrompt string `json:"claude_oauth_system_prompt"` - ClaudeOAuthSystemPromptBlocks string `json:"claude_oauth_system_prompt_blocks"` - EnableAnthropicCacheTTL1hInjection bool `json:"enable_anthropic_cache_ttl_1h_injection"` - RewriteMessageCacheControl bool `json:"rewrite_message_cache_control"` - EnableClientDatelineNormalization bool `json:"enable_client_dateline_normalization"` - AntigravityUserAgentVersion string `json:"antigravity_user_agent_version"` - OpenAICodexUserAgent string `json:"openai_codex_user_agent"` - OpenAICodexClientVersion string `json:"openai_codex_client_version"` - OpenAICodexClientVersionSynced string `json:"openai_codex_client_version_synced"` - OpenAICodexVersionAutoSyncEnabled bool `json:"openai_codex_version_auto_sync_enabled"` + OpenAITTFTMode string `json:"openai_ttft_mode"` + EnableFingerprintUnification bool `json:"enable_fingerprint_unification"` + EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"` + EnableCCHSigning bool `json:"enable_cch_signing"` + EnableClaudeOAuthSystemPromptInjection bool `json:"enable_claude_oauth_system_prompt_injection"` + ClaudeOAuthSystemPrompt string `json:"claude_oauth_system_prompt"` + ClaudeOAuthSystemPromptBlocks string `json:"claude_oauth_system_prompt_blocks"` + EnableAnthropicCacheTTL1hInjection bool `json:"enable_anthropic_cache_ttl_1h_injection"` + RewriteMessageCacheControl bool `json:"rewrite_message_cache_control"` + EnableClientDatelineNormalization bool `json:"enable_client_dateline_normalization"` + AntigravityUserAgentVersion string `json:"antigravity_user_agent_version"` + OpenAICodexUserAgent string `json:"openai_codex_user_agent"` + OpenAICodexClientVersion string `json:"openai_codex_client_version"` + OpenAICodexClientVersionSynced string `json:"openai_codex_client_version_synced"` + OpenAICodexVersionAutoSyncEnabled bool `json:"openai_codex_version_auto_sync_enabled"` + OpenAICodexTicketEnabled bool `json:"openai_codex_ticket_enabled"` + OpenAICodexTicketHarvestProxyURL string `json:"openai_codex_ticket_harvest_proxy_url"` + OpenAICodexTicketHarvestProxyConfigured bool `json:"openai_codex_ticket_harvest_proxy_configured"` // codex_cli_only 加固 MinCodexVersion string `json:"min_codex_version"` diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 50573cb1b623..b95658043216 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -217,26 +217,27 @@ type Account struct { // Credentials 经 RedactCredentials 处理后默认只含非敏感子键;敏感 token / api_key / 私钥 // 的存在性通过 CredentialsStatus(has_)暴露。账号详情在管理员开启 API Key // 可见性后可额外恢复 api_key 原文,其它敏感键仍不返回。 - Credentials map[string]any `json:"credentials"` - CredentialsStatus map[string]bool `json:"credentials_status,omitempty"` - Extra map[string]any `json:"extra"` - OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage,omitempty"` - ProxyID *int64 `json:"proxy_id"` - ProxyFallbackOriginID *int64 `json:"proxy_fallback_origin_id"` - ProxyFallbackOriginName *string `json:"proxy_fallback_origin_name,omitempty"` - ManagementFolder *AccountManagementFolder `json:"management_folder,omitempty"` - Tags []AccountManagementTag `json:"tags"` - Concurrency int `json:"concurrency"` - LoadFactor *int `json:"load_factor,omitempty"` - Priority int `json:"priority"` - RateMultiplier float64 `json:"rate_multiplier"` - Status string `json:"status"` - ErrorMessage string `json:"error_message"` - LastUsedAt *time.Time `json:"last_used_at"` - ExpiresAt *int64 `json:"expires_at"` - AutoPauseOnExpired bool `json:"auto_pause_on_expired"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Credentials map[string]any `json:"credentials"` + CredentialsStatus map[string]bool `json:"credentials_status,omitempty"` + Extra map[string]any `json:"extra"` + OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage,omitempty"` + CodexTurnTickets []service.OpenAICodexTicketStatus `json:"codex_turn_tickets,omitempty"` + ProxyID *int64 `json:"proxy_id"` + ProxyFallbackOriginID *int64 `json:"proxy_fallback_origin_id"` + ProxyFallbackOriginName *string `json:"proxy_fallback_origin_name,omitempty"` + ManagementFolder *AccountManagementFolder `json:"management_folder,omitempty"` + Tags []AccountManagementTag `json:"tags"` + Concurrency int `json:"concurrency"` + LoadFactor *int `json:"load_factor,omitempty"` + Priority int `json:"priority"` + RateMultiplier float64 `json:"rate_multiplier"` + Status string `json:"status"` + ErrorMessage string `json:"error_message"` + LastUsedAt *time.Time `json:"last_used_at"` + ExpiresAt *int64 `json:"expires_at"` + AutoPauseOnExpired bool `json:"auto_pause_on_expired"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` Schedulable bool `json:"schedulable"` IsCindy bool `json:"is_cindy"` @@ -398,10 +399,11 @@ type AccountListItem struct { Platform string `json:"platform"` Type string `json:"type"` - Credentials map[string]any `json:"credentials,omitempty"` - CredentialsStatus map[string]bool `json:"credentials_status,omitempty"` - Extra map[string]any `json:"extra,omitempty"` - OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage,omitempty"` + Credentials map[string]any `json:"credentials,omitempty"` + CredentialsStatus map[string]bool `json:"credentials_status,omitempty"` + Extra map[string]any `json:"extra,omitempty"` + OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage,omitempty"` + CodexTurnTickets []service.OpenAICodexTicketStatus `json:"codex_turn_tickets,omitempty"` ProxyID *int64 `json:"proxy_id"` ProxyFallbackOriginID *int64 `json:"proxy_fallback_origin_id"` diff --git a/backend/internal/handler/gateway_model_allowlist_listing_test.go b/backend/internal/handler/gateway_model_allowlist_listing_test.go index 55a663dbc0ac..cb1fd5d5fa7e 100644 --- a/backend/internal/handler/gateway_model_allowlist_listing_test.go +++ b/backend/internal/handler/gateway_model_allowlist_listing_test.go @@ -91,7 +91,7 @@ func TestGeminiV1BetaListModels_FiltersFallbackByAllowlist(t *testing.T) { repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{ byGroup: map[int64][]service.Account{ // 只有 antigravity 账号:Gemini 选号失败后回落静态模型列表。 - 41: {{ID: 2, Platform: service.PlatformAntigravity, Status: service.StatusActive, Schedulable: true}}, + 41: {{ID: 2, Platform: service.PlatformAntigravity, Extra: map[string]any{"mixed_scheduling": true}, Status: service.StatusActive, Schedulable: true}}, }, }} h := &GatewayHandler{ @@ -109,7 +109,7 @@ func TestGeminiV1BetaListModels_FiltersFallbackByAllowlist(t *testing.T) { Platform: service.PlatformGemini, ModelAllowlist: service.GroupModelAllowlist{ Enabled: true, - Models: []string{"gemini-2.5-pro", "gemini-3-*"}, + Models: []string{"gemini-3.8-flash-high", "gemini-3-*"}, }, }, }) @@ -129,8 +129,8 @@ func TestGeminiV1BetaListModels_FiltersFallbackByAllowlist(t *testing.T) { names = append(names, model.Name) } // models/ 前缀的候选形式也应命中条目。 - require.Contains(t, names, "models/gemini-2.5-pro") - require.Contains(t, names, "models/gemini-3-pro-preview") + require.Contains(t, names, "models/gemini-3.8-flash-high") + require.Contains(t, names, "models/gemini-3-flash") require.NotContains(t, names, "models/gemini-2.5-flash") require.NotContains(t, names, "models/gemini-2.0-flash") } diff --git a/backend/internal/handler/gemini_mixed_models_test.go b/backend/internal/handler/gemini_mixed_models_test.go new file mode 100644 index 000000000000..6a0e2c2eb5b6 --- /dev/null +++ b/backend/internal/handler/gemini_mixed_models_test.go @@ -0,0 +1,136 @@ +package handler + +import ( + "encoding/json" + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/gemini" + "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" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGeminiNativeModelsUsesAccountMappings(t *testing.T) { + for _, tt := range []struct { + name string + forced, mixed, allowlist bool + status int + }{ + {"mixed", false, true, false, 200}, + {"mixed allowlist", false, true, true, 200}, + {"disabled mixed", false, false, false, 503}, + {"forced without mixed opt-in", true, false, false, 200}, + {"forced allowlist", true, false, true, 200}, + } { + t.Run(tt.name, func(t *testing.T) { + groupID := int64(45) + repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{ + groupID: {{ID: 1, Platform: service.PlatformAntigravity, + Extra: map[string]any{"mixed_scheduling": tt.mixed}, + Credentials: map[string]any{"model_mapping": map[string]any{"gemini-synced-custom": "gemini-3.8-flash-high", "claude-custom": "claude-sonnet-4-6"}}}}, + }}} + h := &GatewayHandler{geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, nil, nil, nil, nil, nil)} + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil) + c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{GroupID: &groupID, Group: &service.Group{ID: groupID, Platform: service.PlatformGemini, + ModelAllowlist: service.GroupModelAllowlist{Enabled: tt.allowlist, Models: []string{"gemini-synced-custom"}}, + }}) + if tt.forced { + c.Set(string(middleware.ContextKeyForcePlatform), service.PlatformAntigravity) + } + h.GeminiV1BetaListModels(c) + require.Equal(t, tt.status, rec.Code, rec.Body.String()) + if tt.status != 200 { + return + } + var got gemini.ModelsListResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + names := []string{} + for _, model := range got.Models { + names = append(names, model.Name) + require.Contains(t, model.SupportedGenerationMethods, "generateContent") + } + require.Contains(t, names, "models/gemini-synced-custom") + require.NotContains(t, names, "models/claude-custom") + require.NotContains(t, names, "models/gemini-2.0-flash") + if tt.allowlist { + require.Equal(t, []string{"models/gemini-synced-custom"}, names) + } + }) + } +} + +func TestAppendUpstreamGeminiModelsPreservesMetadata(t *testing.T) { + body := []byte(`{"models":[{"name":"models/gemini-native","inputTokenLimit":123,"custom":{"a":true}}],"nextPageToken":"next","other":42}`) + extra := []gemini.Model{gemini.FallbackModel("gemini-native"), gemini.FallbackModel("gemini-synced-custom"), gemini.FallbackModel("gemini-synced-custom")} + merged, ok := appendUpstreamGeminiModels(body, extra) + require.True(t, ok) + require.JSONEq(t, `{"models":[{"name":"models/gemini-native","inputTokenLimit":123,"custom":{"a":true}},{"name":"models/gemini-synced-custom","supportedGenerationMethods":["generateContent","streamGenerateContent"]}],"nextPageToken":"next","other":42}`, string(merged)) + filtered, dropped, ok := filterUpstreamGeminiModelsBody(merged, service.GroupModelAllowlist{Enabled: true, Models: []string{"gemini-synced-*"}}) + require.True(t, ok) + require.True(t, dropped) + require.NotContains(t, string(filtered), "gemini-native") + require.Contains(t, string(filtered), "gemini-synced-custom") + for _, invalid := range []string{`null`, `not-json`, `{"error":"bad"}`, `{"models":{}}`} { + got, ok := appendUpstreamGeminiModels([]byte(invalid), extra) + require.False(t, ok) + require.Equal(t, invalid, string(got)) + } +} + +// Exercise the real handler's native-upstream branch, including scope fallback. +type geminiMixedModelsUpstream struct { + service.HTTPUpstream + status int + body string +} + +func (u *geminiMixedModelsUpstream) Do(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return &http.Response{StatusCode: u.status, Header: http.Header{"Content-Type": {"application/json"}, "X-Request-Id": {"native-id"}}, Body: io.NopCloser(strings.NewReader(u.body))}, nil +} +func TestGeminiNativeModelsMergesNativeUpstream(t *testing.T) { + for _, tt := range []struct { + name string + status int + body string + expectedNative string + }{ + {"native", 200, `{"models":[{"name":"models/gemini-native","inputTokenLimit":123}],"nextPageToken":"next"}`, "models/gemini-native"}, + {"scope fallback", 403, `{"error":"insufficient authentication scopes"}`, "models/gemini-2.5-pro"}, + {"upstream error", 429, `{"error":"rate limited"}`, ""}, + } { + t.Run(tt.name, func(t *testing.T) { + id := int64(46) + repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{id: { + {ID: 1, Platform: service.PlatformGemini, Type: service.AccountTypeAPIKey, Credentials: map[string]any{"api_key": "test"}}, + {ID: 2, Platform: service.PlatformAntigravity, Extra: map[string]any{"mixed_scheduling": true}, Credentials: map[string]any{"model_mapping": map[string]any{"gemini-synced-custom": "gemini-3.8-flash-high"}}}, + }}}} + upstream := &geminiMixedModelsUpstream{status: tt.status, body: tt.body} + h := &GatewayHandler{geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, nil, nil, upstream, nil, &config.Config{})} + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil) + c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{GroupID: &id, Group: &service.Group{ID: id, Platform: service.PlatformGemini}}) + h.GeminiV1BetaListModels(c) + if tt.status == 429 { + require.Equal(t, 429, rec.Code) + require.JSONEq(t, tt.body, rec.Body.String()) + return + } + require.Equal(t, 200, rec.Code, rec.Body.String()) + require.Contains(t, rec.Body.String(), tt.expectedNative) + require.Contains(t, rec.Body.String(), "models/gemini-synced-custom") + if tt.status == 200 { + require.Contains(t, rec.Body.String(), `"inputTokenLimit":123`) + require.Contains(t, rec.Body.String(), `"nextPageToken":"next"`) + require.Equal(t, "native-id", rec.Header().Get("X-Request-Id")) + } + }) + } +} diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index d8ec24670bdb..a7ae9170c115 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -60,30 +60,24 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) { return filtered } - // 强制 antigravity 模式:返回 antigravity 支持的模型列表 + agModelIDs, err := h.geminiCompatService.AntigravityGeminiModelIDs(c.Request.Context(), apiKey.GroupID, forcePlatform != service.PlatformAntigravity) + if err != nil { + googleError(c, http.StatusServiceUnavailable, "Unable to list Antigravity models") + return + } + agModels := make([]gemini.Model, 0, len(agModelIDs)) + for _, id := range agModelIDs { + agModels = append(agModels, gemini.FallbackModel(id)) + } if forcePlatform == service.PlatformAntigravity { - if apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() { - agModels := antigravity.DefaultGeminiModels() - filtered := make([]antigravity.GeminiModel, 0, len(agModels)) - for _, model := range agModels { - if apiKey.Group.ModelAllowlist.Allows(model.Name) { - filtered = append(filtered, model) - } - } - c.JSON(http.StatusOK, antigravity.GeminiModelsListResponse{Models: filtered}) - return - } - c.JSON(http.StatusOK, antigravity.FallbackGeminiModelsList()) + c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(agModels)}) return } account, err := h.geminiCompatService.SelectAccountForAIStudioEndpoints(c.Request.Context(), apiKey.GroupID) if err != nil { - // 没有 gemini 账户,检查是否有 antigravity 账户可用 - hasAntigravity, _ := h.geminiCompatService.HasAntigravityAccounts(c.Request.Context(), apiKey.GroupID) - if hasAntigravity { - // antigravity 账户使用静态模型列表 - c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(gemini.DefaultModels())}) + if len(agModels) > 0 { + c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(agModels)}) return } markOpsRoutingCapacityLimitedIfNoAvailable(c, err) @@ -97,9 +91,15 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) { return } if shouldFallbackGeminiModels(res) { - c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(gemini.DefaultModels())}) + c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(mergeGeminiModelLists(gemini.DefaultModels(), agModels))}) return } + if res.StatusCode == http.StatusOK && len(agModels) > 0 { + if merged, ok := appendUpstreamGeminiModels(res.Body, agModels); ok { + res.Body = merged + } + } + if apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() { if filtered, dropped, ok := filterUpstreamGeminiModelsBody(res.Body, apiKey.Group.ModelAllowlist); ok && dropped { // 只在确有条目被过滤时替换响应体;全命中或解析失败时保持原始响应, @@ -110,6 +110,64 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) { writeUpstreamResponse(c, res) } +// mergeGeminiModelLists keeps native metadata when both sources advertise a model. +func mergeGeminiModelLists(native, extra []gemini.Model) []gemini.Model { + result := append([]gemini.Model{}, native...) + seen := make(map[string]bool, len(native)) + for _, model := range native { + seen[model.Name] = true + } + for _, model := range extra { + if !seen[model.Name] { + result = append(result, model) + seen[model.Name] = true + } + } + return result +} + +// appendUpstreamGeminiModels preserves unknown model metadata and envelope fields. +func appendUpstreamGeminiModels(body []byte, extra []gemini.Model) ([]byte, bool) { + var envelope map[string]json.RawMessage + if json.Unmarshal(body, &envelope) != nil || envelope == nil { + return body, false + } + var models []json.RawMessage + raw, exists := envelope["models"] + if !exists || json.Unmarshal(raw, &models) != nil { + return body, false + } + seen := make(map[string]bool, len(models)) + for _, raw := range models { + var model struct { + Name string `json:"name"` + } + if json.Unmarshal(raw, &model) != nil { + return body, false + } + seen[model.Name] = true + } + changed := false + for _, model := range extra { + if seen[model.Name] { + continue + } + raw, err := json.Marshal(model) + if err != nil { + return body, false + } + models = append(models, raw) + seen[model.Name] = true + changed = true + } + if !changed { + return body, true + } + envelope["models"], _ = json.Marshal(models) + merged, err := json.Marshal(envelope) + return merged, err == nil +} + // filterUpstreamGeminiModelsBody 按白名单过滤上游 /v1beta/models 响应中的 // models[].name,其余信封字段(如 nextPageToken)原样保留。 // 返回值:filtered 为过滤后的响应体;dropped 表示是否有条目被移除(全命中时 diff --git a/backend/internal/handler/gemini_v1beta_handler_test.go b/backend/internal/handler/gemini_v1beta_handler_test.go index 2bf25a6b9679..8604b07b8816 100644 --- a/backend/internal/handler/gemini_v1beta_handler_test.go +++ b/backend/internal/handler/gemini_v1beta_handler_test.go @@ -39,7 +39,11 @@ func TestGeminiV1BetaListModels_ForcedAntigravityAppliesAllowlist(t *testing.T) }) c.Set(string(middleware.ContextKeyForcePlatform), service.PlatformAntigravity) - (&GatewayHandler{}).GeminiV1BetaListModels(c) + groupID := int64(42) + key, _ := middleware.GetAPIKeyFromContext(c) + key.GroupID = &groupID + repo := &geminiAllowlistAccountRepoStub{} + (&GatewayHandler{geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, nil, nil, nil, nil, nil)}).GeminiV1BetaListModels(c) require.Equal(t, http.StatusOK, rec.Code) var got antigravity.GeminiModelsListResponse diff --git a/backend/internal/handler/redeem_handler.go b/backend/internal/handler/redeem_handler.go index 1b63f4183f4b..5d919cdc4eb5 100644 --- a/backend/internal/handler/redeem_handler.go +++ b/backend/internal/handler/redeem_handler.go @@ -1,7 +1,10 @@ package handler import ( + "strconv" + "github.com/Wei-Shaw/sub2api/internal/handler/dto" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/response" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -68,6 +71,39 @@ func (h *RedeemHandler) GetHistory(c *gin.Context) { return } + // Keep the legacy array response for clients that do not request pagination. + if c.Request.URL.Query().Has("page") || c.Request.URL.Query().Has("page_size") { + page, pageSize := 1, 20 + for name, target := range map[string]*int{"page": &page, "page_size": &pageSize} { + if raw, exists := c.GetQuery(name); exists { + value, err := strconv.Atoi(raw) + if err != nil || value < 1 { + response.BadRequest(c, "Invalid "+name) + return + } + *target = value + } + } + if pageSize > 100 { + pageSize = 100 + } + if page-1 > int(^uint(0)>>1)/pageSize { + response.BadRequest(c, "Invalid page") + return + } + codes, result, err := h.redeemService.GetUserHistoryPaginated(c.Request.Context(), subject.UserID, pagination.PaginationParams{Page: page, PageSize: pageSize}) + if err != nil { + response.ErrorFrom(c, err) + return + } + out := make([]dto.RedeemCode, 0, len(codes)) + for i := range codes { + out = append(out, *dto.RedeemCodeFromService(&codes[i])) + } + response.Paginated(c, out, result.Total, page, pageSize) + return + } + // Default limit is 25 limit := 25 diff --git a/backend/internal/handler/redeem_handler_test.go b/backend/internal/handler/redeem_handler_test.go new file mode 100644 index 000000000000..af84d690fc69 --- /dev/null +++ b/backend/internal/handler/redeem_handler_test.go @@ -0,0 +1,89 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + 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" +) + +type historyRepo struct { + service.RedeemCodeRepository + userID int64 + params pagination.PaginationParams + legacyLimit int +} + +func (r *historyRepo) ListByUser(_ context.Context, userID int64, limit int) ([]service.RedeemCode, error) { + r.userID, r.legacyLimit = userID, limit + return []service.RedeemCode{}, nil +} +func (r *historyRepo) ListByUserPaginated(_ context.Context, userID int64, params pagination.PaginationParams, codeType string) ([]service.RedeemCode, *pagination.PaginationResult, error) { + r.userID, r.params = userID, params + return []service.RedeemCode{}, &pagination.PaginationResult{Total: 101}, nil +} +func TestRedeemHistory(t *testing.T) { + for _, tt := range []struct { + name, query string + userID int64 + status, page, size int + }{ + {"legacy", "", 7, 200, 0, 0}, + {"default", "?page=1", 7, 200, 1, 20}, + {"size only", "?page_size=50", 7, 200, 1, 50}, + {"second user", "?page=2&page_size=100&user_id=7", 8, 200, 2, 100}, + {"cap", "?page_size=101", 7, 200, 1, 100}, + {"beyond last", "?page=100&page_size=20", 7, 200, 100, 20}, + {"zero", "?page=0", 7, 400, 0, 0}, + {"negative", "?page_size=-1", 7, 400, 0, 0}, + {"invalid", "?page=abc", 7, 400, 0, 0}, + {"overflow", "?page=9223372036854775807", 7, 400, 0, 0}, + {"unauthenticated", "?page=1", 0, 401, 0, 0}, + } { + t.Run(tt.name, func(t *testing.T) { + repo := &historyRepo{} + h := NewRedeemHandler(service.NewRedeemService(repo, nil, nil, nil, nil, nil, nil, nil)) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/api/v1/redeem/history"+tt.query, nil) + if tt.userID != 0 { + c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: tt.userID}) + } + h.GetHistory(c) + require.Equal(t, tt.status, w.Code) + if tt.status != 200 { + require.Zero(t, repo.userID) + return + } + require.Equal(t, tt.userID, repo.userID) + var body struct { + Data json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + if tt.query == "" { + require.Equal(t, 25, repo.legacyLimit) + require.JSONEq(t, "[]", string(body.Data)) + return + } + require.Equal(t, tt.page, repo.params.Page) + require.Equal(t, tt.size, repo.params.PageSize) + var data struct { + Items []service.RedeemCode `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + Size int `json:"page_size"` + } + require.NoError(t, json.Unmarshal(body.Data, &data)) + require.NotNil(t, data.Items) + require.Equal(t, 101, data.Total) + require.Equal(t, tt.page, data.Page) + require.Equal(t, tt.size, data.Size) + }) + } +} diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index a93644cf8b10..805a977626f2 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -38,6 +38,7 @@ func ProvideAdminHandlers( userAttributeHandler *admin.UserAttributeHandler, errorPassthroughHandler *admin.ErrorPassthroughHandler, tlsFingerprintProfileHandler *admin.TLSFingerprintProfileHandler, + pluginHandler *admin.PluginHandler, apiKeyHandler *admin.AdminAPIKeyHandler, scheduledTestHandler *admin.ScheduledTestHandler, channelHandler *admin.ChannelHandler, @@ -55,10 +56,12 @@ func ProvideAdminHandlers( upstreamBillingProbe *service.UpstreamBillingProbeService, ollamaCloudUsage *service.OllamaCloudUsageService, accountTrafficObserver *service.AccountTrafficObserver, + settingService *service.SettingService, ) *AdminHandlers { accountHandler.SetUpstreamBillingProbeService(upstreamBillingProbe) accountHandler.SetOllamaCloudUsageService(ollamaCloudUsage) accountHandler.SetAccountTrafficObserver(accountTrafficObserver) + accountHandler.SetCodexTicketSettings(settingService) return &AdminHandlers{ Dashboard: dashboardHandler, User: userHandler, @@ -85,6 +88,7 @@ func ProvideAdminHandlers( UserAttribute: userAttributeHandler, ErrorPassthrough: errorPassthroughHandler, TLSFingerprintProfile: tlsFingerprintProfileHandler, + Plugin: pluginHandler, APIKey: apiKeyHandler, ScheduledTest: scheduledTestHandler, Channel: channelHandler, @@ -129,6 +133,7 @@ func ProvideGatewayHandler( func ProvideOpenAIGatewayHandler( gatewayService *service.OpenAIGatewayService, nativeAnthropicGatewayService *service.GatewayService, + pluginManager *service.PluginManager, concurrencyService *service.ConcurrencyService, billingCacheService *service.BillingCacheService, apiKeyService *service.APIKeyService, @@ -141,6 +146,7 @@ func ProvideOpenAIGatewayHandler( cfg *config.Config, coordinator *securityaudit.Coordinator, ) *OpenAIGatewayHandler { + gatewayService.SetPluginManager(pluginManager) h := NewOpenAIGatewayHandler(gatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, cfg) h.SetNativeAnthropicGatewayService(nativeAnthropicGatewayService) @@ -292,6 +298,7 @@ var ProviderSet = wire.NewSet( admin.NewUserAttributeHandler, admin.NewErrorPassthroughHandler, admin.NewTLSFingerprintProfileHandler, + admin.NewPluginHandler, admin.NewAdminAPIKeyHandler, admin.NewScheduledTestHandler, admin.NewChannelHandler, diff --git a/backend/internal/pkg/antigravity/attribution_test.go b/backend/internal/pkg/antigravity/attribution_test.go new file mode 100644 index 000000000000..8a1274e95d88 --- /dev/null +++ b/backend/internal/pkg/antigravity/attribution_test.go @@ -0,0 +1,88 @@ +package antigravity + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTransformClaudeToGemini_AttributionSystemText(t *testing.T) { + const attribution = "x-anthropic-billing-header: cc_version=2.1.271.4bf; cc_entrypoint=claude-desktop-3p;" + tests := []struct { + name string + text string + want string + }{ + {"metadata only", attribution, ""}, + {"metadata with newline", attribution + "\n", ""}, + {"leading whitespace", " \t\n" + attribution, ""}, + {"following instructions", attribution + "\nKeep these instructions.", "Keep these instructions."}, + {"CRLF", attribution + "\r\n Keep indentation.\n", " Keep indentation.\n"}, + {"CR", attribution + "\rKeep these instructions.", "Keep these instructions."}, + {"blank lines preserved", attribution + "\n\nKeep these instructions.", "\nKeep these instructions."}, + {"ordinary text", " Keep these instructions.\n", " Keep these instructions.\n"}, + {"no colon", "x-anthropic-billing-header keep", "x-anthropic-billing-header keep"}, + {"different field", "x-anthropic-billing-header-extra: keep", "x-anthropic-billing-header-extra: keep"}, + {"quoted within instructions", "Explain this metadata: " + attribution, "Explain this metadata: " + attribution}, + {"later line", "Example:\n" + attribution, "Example:\n" + attribution}, + } + for _, tt := range tests { + for _, arrayForm := range []bool{false, true} { + name := tt.name + "/string" + var system any = tt.text + if arrayForm { + name = tt.name + "/array" + system = []SystemBlock{{Type: "text", Text: tt.text}, {Type: "text", Text: "Keep the next block."}} + } + t.Run(name, func(t *testing.T) { + systemJSON, err := json.Marshal(system) + require.NoError(t, err) + userJSON, err := json.Marshal(attribution) + require.NoError(t, err) + input := &ClaudeRequest{ + Model: "gemini-3.8-flash-high", System: systemJSON, + Messages: []ClaudeMessage{{Role: "user", Content: userJSON}}, + Tools: []ClaudeTool{{Name: "example", Description: attribution, + InputSchema: map[string]any{"type": "object", "properties": map[string]any{"query": map[string]any{"type": "string"}}}}}, + } + before, err := json.Marshal(input) + require.NoError(t, err) + body, err := TransformClaudeToGeminiWithOptions(input, "test-project", input.Model, TransformOptions{}) + require.NoError(t, err) + var got V1InternalRequest + require.NoError(t, json.Unmarshal(body, &got)) + var texts []string + for _, part := range got.Request.SystemInstruction.Parts { + if part.Text != "\n--- [SYSTEM_PROMPT_END] ---" { + texts = append(texts, part.Text) + } + } + var want []string + if tt.want != "" { + want = append(want, tt.want) + } + if arrayForm { + want = append(want, "Keep the next block.") + } + require.Equal(t, want, texts) + // The same literal text in user content and tool descriptions is not metadata. + require.Equal(t, attribution, got.Request.Contents[0].Parts[0].Text) + require.Equal(t, attribution, got.Request.Tools[0].FunctionDeclarations[0].Description) + after, err := json.Marshal(input) + require.NoError(t, err) + require.JSONEq(t, string(before), string(after), "do not mutate the incoming request") + }) + } + } +} + +func TestTransformClaudeToGemini_AttributionDoesNotHideIdentity(t *testing.T) { + system, err := json.Marshal("x-anthropic-billing-header: cc_version=example;\nYou are Antigravity. Keep this identity.") + require.NoError(t, err) + got := buildSystemInstruction(system, "gemini-3.8-flash-high", DefaultTransformOptions(), nil) + require.Len(t, got.Parts, 1) + require.Equal(t, "You are Antigravity. Keep this identity.", got.Parts[0].Text) + require.False(t, strings.Contains(got.Parts[0].Text, "cc_version=")) +} diff --git a/backend/internal/pkg/antigravity/request_transformer.go b/backend/internal/pkg/antigravity/request_transformer.go index 192c4cda39fd..c4af10dcd85e 100644 --- a/backend/internal/pkg/antigravity/request_transformer.go +++ b/backend/internal/pkg/antigravity/request_transformer.go @@ -293,6 +293,26 @@ func filterOpenCodePrompt(text string) string { return "" } +// stripClaudeAttribution removes the leading Claude Code attribution metadata line +// from Antigravity system text. This is prompt metadata, not an HTTP header; it +// can trigger RESOURCE_EXHAUSTED on the Google upstream. Keep this scoped to the +// Antigravity transformer: native Anthropic OAuth may require the attribution. +func stripClaudeAttribution(text string) string { + trimmed := strings.TrimLeft(text, " \t\r\n") + if !strings.HasPrefix(trimmed, "x-anthropic-billing-header:") { + return text + } + end := strings.IndexAny(trimmed, "\r\n") + if end < 0 { + return "" + } + rest := trimmed[end+1:] + if trimmed[end] == '\r' { + rest = strings.TrimPrefix(rest, "\n") + } + return rest +} + // buildSystemInstruction 构建 systemInstruction(与 Antigravity-Manager 保持一致) func buildSystemInstruction(system json.RawMessage, modelName string, opts TransformOptions, tools []ClaudeTool) *GeminiContent { var parts []GeminiPart @@ -305,6 +325,7 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans // 尝试解析为字符串 var sysStr string if err := json.Unmarshal(system, &sysStr); err == nil { + sysStr = stripClaudeAttribution(sysStr) if strings.TrimSpace(sysStr) != "" { if strings.Contains(sysStr, "You are Antigravity") { userHasAntigravityIdentity = true @@ -320,6 +341,7 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans var sysBlocks []SystemBlock if err := json.Unmarshal(system, &sysBlocks); err == nil { for _, block := range sysBlocks { + block.Text = stripClaudeAttribution(block.Text) if block.Type == "text" && strings.TrimSpace(block.Text) != "" { if strings.Contains(block.Text, "You are Antigravity") { userHasAntigravityIdentity = true diff --git a/backend/internal/pkg/apicompat/responses_tool_output_media.go b/backend/internal/pkg/apicompat/responses_tool_output_media.go new file mode 100644 index 000000000000..c6df71cae006 --- /dev/null +++ b/backend/internal/pkg/apicompat/responses_tool_output_media.go @@ -0,0 +1,167 @@ +package apicompat + +import ( + "encoding/json" + "fmt" + "strings" +) + +type responsesToolOutputMedia struct { + callID string + imageURL string +} + +// LiftResponsesToolOutputMedia moves image parts out of Responses tool outputs +// and into a following user message. Native Responses endpoints such as +// DeepSeek accept function_call_output.output as a string, but Codex view_image +// returns an array containing input_image. Keeping the image inside the tool +// output makes the upstream report "No tool output found for tool call ...". +func LiftResponsesToolOutputMedia(input any) (any, bool) { + items, ok := input.([]any) + if !ok { + return input, false + } + + rewritten := make([]any, 0, len(items)+1) + changed := false + + // DeepSeek validates parallel tool outputs as one contiguous run. Codex can + // inject developer notices between image outputs, so keep those notices + // pending until the full batch and its lifted media have been emitted. + for index := 0; index < len(items); { + item, ok := items[index].(map[string]any) + if !ok || !isResponsesToolOutputItem(item) { + rewritten = append(rewritten, items[index]) + index++ + continue + } + + batchStart := index + outputs := make([]any, 0) + trailing := make([]any, 0) + pending := make([]responsesToolOutputMedia, 0) + batchChanged := false + + for index < len(items) { + rawItem := items[index] + item, ok := rawItem.(map[string]any) + if !ok { + break + } + if isResponsesToolOutputItem(item) { + rewrittenItem, media, didRewrite := liftResponsesToolOutputMediaItem(item) + if didRewrite { + batchChanged = true + pending = append(pending, media...) + } + outputs = append(outputs, rewrittenItem) + index++ + continue + } + if isResponsesToolBatchInstruction(item) { + trailing = append(trailing, rawItem) + index++ + continue + } + break + } + + if !batchChanged { + rewritten = append(rewritten, items[batchStart:index]...) + continue + } + + rewritten = append(rewritten, outputs...) + if len(pending) > 0 { + rewritten = append(rewritten, buildResponsesToolOutputMediaMessage(pending)) + } + rewritten = append(rewritten, trailing...) + changed = true + } + + if !changed { + return input, false + } + return rewritten, true +} + +func liftResponsesToolOutputMediaItem(item map[string]any) (any, []responsesToolOutputMedia, bool) { + output, exists := item["output"] + if !exists { + return item, nil, false + } + outputRaw, err := json.Marshal(output) + if err != nil { + return item, nil, false + } + + outputText, media, didRewrite := extractToolOutputMedia(outputRaw) + if !didRewrite { + return item, nil, false + } + + item["output"] = outputText + callID := strings.TrimSpace(stringValue(item["call_id"])) + lifted := make([]responsesToolOutputMedia, 0, len(media)) + for _, part := range media { + if part.ImageURL == nil { + continue + } + imageURL := strings.TrimSpace(part.ImageURL.URL) + if imageURL == "" { + continue + } + lifted = append(lifted, responsesToolOutputMedia{ + callID: callID, + imageURL: imageURL, + }) + } + return item, lifted, true +} + +func buildResponsesToolOutputMediaMessage(pending []responsesToolOutputMedia) map[string]any { + content := make([]map[string]any, 0, len(pending)*2) + lastCallID := "" + for _, media := range pending { + if media.callID != lastCallID { + text := "Tool output media" + if media.callID != "" { + text = fmt.Sprintf(toolOutputMediaAttribution, media.callID) + } + content = append(content, map[string]any{ + "type": "input_text", + "text": text, + }) + lastCallID = media.callID + } + content = append(content, map[string]any{ + "type": "input_image", + "image_url": media.imageURL, + }) + } + + return map[string]any{ + "type": "message", + "role": "user", + "content": content, + } +} + +func isResponsesToolBatchInstruction(item map[string]any) bool { + itemType := strings.TrimSpace(stringValue(item["type"])) + if itemType != "" && itemType != "message" { + return false + } + role := strings.TrimSpace(stringValue(item["role"])) + return role == "developer" || role == "system" +} + +func isResponsesToolOutputItem(item map[string]any) bool { + switch strings.TrimSpace(stringValue(item["type"])) { + case "function_call_output", "custom_tool_call_output", + "tool_search_output", "tool_search_call_output", "mcp_tool_call_output": + return true + default: + return false + } +} diff --git a/backend/internal/pkg/apicompat/responses_tool_output_media_test.go b/backend/internal/pkg/apicompat/responses_tool_output_media_test.go new file mode 100644 index 000000000000..f6f56ba9b954 --- /dev/null +++ b/backend/internal/pkg/apicompat/responses_tool_output_media_test.go @@ -0,0 +1,116 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLiftResponsesToolOutputMedia(t *testing.T) { + var input any + require.NoError(t, json.Unmarshal([]byte(`[ + {"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_image","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]} + ]`), &input)) + + lifted, changed := LiftResponsesToolOutputMedia(input) + require.True(t, changed) + + items, ok := lifted.([]any) + require.True(t, ok) + require.Len(t, items, 3) + + outputItem, ok := items[1].(map[string]any) + require.True(t, ok) + outputText, ok := outputItem["output"].(string) + require.True(t, ok) + require.Contains(t, outputText, toolOutputMediaMarker) + require.NotContains(t, outputText, "data:image/png") + + mediaMessage, ok := items[2].(map[string]any) + require.True(t, ok) + require.Equal(t, "message", mediaMessage["type"]) + require.Equal(t, "user", mediaMessage["role"]) + + parts, ok := mediaMessage["content"].([]map[string]any) + require.True(t, ok) + require.Len(t, parts, 2) + require.Equal(t, "input_text", parts[0]["type"]) + require.Equal(t, "[Tool output media for call call_image]", parts[0]["text"]) + require.Equal(t, "input_image", parts[1]["type"]) + require.Equal(t, "data:image/png;base64,AQID", parts[1]["image_url"]) +} + +func TestLiftResponsesToolOutputMediaLeavesPlainOutputUntouched(t *testing.T) { + input := []any{ + map[string]any{ + "type": "function_call_output", + "call_id": "call_text", + "output": "plain output", + }, + } + + lifted, changed := LiftResponsesToolOutputMedia(input) + require.False(t, changed) + require.Equal(t, input, lifted) +} + +func TestLiftResponsesToolOutputMediaKeepsParallelBatchContiguous(t *testing.T) { + var input any + require.NoError(t, json.Unmarshal([]byte(`[ + {"type":"function_call","call_id":"call_A","name":"view_image","arguments":"{}"}, + {"type":"function_call","call_id":"call_B","name":"view_image","arguments":"{}"}, + {"type":"function_call","call_id":"call_C","name":"view_image","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_A","output":[{"type":"input_image","image_url":"data:image/png;base64,QQ=="}]}, + {"type":"message","role":"developer","content":[{"type":"input_text","text":"resized A"}]}, + {"type":"function_call_output","call_id":"call_B","output":[{"type":"input_image","image_url":"data:image/png;base64,Qg=="}]}, + {"type":"message","role":"developer","content":[{"type":"input_text","text":"resized B"}]}, + {"type":"function_call_output","call_id":"call_C","output":[{"type":"input_image","image_url":"data:image/png;base64,Qw=="}]}, + {"type":"message","role":"developer","content":[{"type":"input_text","text":"resized C"}]} + ]`), &input)) + + lifted, changed := LiftResponsesToolOutputMedia(input) + require.True(t, changed) + + items, ok := lifted.([]any) + require.True(t, ok) + require.Len(t, items, 10) + + for index, callID := range []string{"call_A", "call_B", "call_C"} { + item, ok := items[index+3].(map[string]any) + require.True(t, ok) + require.Equal(t, "function_call_output", item["type"]) + require.Equal(t, callID, item["call_id"]) + outputText, ok := item["output"].(string) + require.True(t, ok) + require.Contains(t, outputText, toolOutputMediaMarker) + require.NotContains(t, outputText, "data:image/png") + } + + mediaMessage, ok := items[6].(map[string]any) + require.True(t, ok) + require.Equal(t, "message", mediaMessage["type"]) + require.Equal(t, "user", mediaMessage["role"]) + parts, ok := mediaMessage["content"].([]map[string]any) + require.True(t, ok) + require.Len(t, parts, 6) + require.Equal(t, "[Tool output media for call call_A]", parts[0]["text"]) + require.Equal(t, "data:image/png;base64,QQ==", parts[1]["image_url"]) + require.Equal(t, "[Tool output media for call call_B]", parts[2]["text"]) + require.Equal(t, "data:image/png;base64,Qg==", parts[3]["image_url"]) + require.Equal(t, "[Tool output media for call call_C]", parts[4]["text"]) + require.Equal(t, "data:image/png;base64,Qw==", parts[5]["image_url"]) + + for index, text := range []string{"resized A", "resized B", "resized C"} { + notice, ok := items[index+7].(map[string]any) + require.True(t, ok) + require.Equal(t, "developer", notice["role"]) + content, ok := notice["content"].([]any) + require.True(t, ok) + require.Len(t, content, 1) + contentPart, ok := content[0].(map[string]any) + require.True(t, ok) + require.Equal(t, text, contentPart["text"]) + } +} diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 05c95a4331c2..3496a5726538 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -57,6 +57,10 @@ var schedulerNeutralExtraKeyPrefixes = []string{ "codex_5h_", "codex_7d_", "codex_reset_credit_", + // 292 门票是纯运行态凭据:它不在 filterSchedulerExtra 的投影白名单里, + // 因此 bucket 重建事件永远搬不动门票状态,续期时开事务+发 outbox 是白干。 + // 归为观测型后仍会同步单账号快照(见 UpdateExtra),不丢任何新鲜度。 + "codex_turn_ticket:", "passive_usage_", "upstream_billing_probe", "upstream_billing_rate_sync", @@ -658,7 +662,8 @@ func lockAndMergeAccountProbeExtra( extra -> 'ollama_cloud_usage_snapshot', extra -> 'upstream_model_context_capacities', extra -> 'model_context_overrides', - extra -> 'upstream_model_metadata' + extra -> 'upstream_model_metadata', + COALESCE(extra, '{}'::jsonb) FROM accounts WHERE id = $1 AND deleted_at IS NULL FOR NO KEY UPDATE @@ -688,6 +693,7 @@ func lockAndMergeAccountProbeExtra( currentContextCapacities []byte currentContextOverrides []byte currentModelMetadata []byte + currentExtraJSON []byte ) if err := rows.Scan( &identityUnchanged, @@ -703,6 +709,7 @@ func lockAndMergeAccountProbeExtra( ¤tContextCapacities, ¤tContextOverrides, ¤tModelMetadata, + ¤tExtraJSON, ); err != nil { return nil, false, err } @@ -716,6 +723,20 @@ func lockAndMergeAccountProbeExtra( if err != nil { return nil, false, err } + // extra 理论上恒为 JSON 对象,但历史数据若存成非对象(数组/标量),在此硬失败 + // 会让该账号的任何编辑都保存不了——而这条路径覆盖所有平台的账号更新。 + // 门票是 1 小时 TTL 的临时凭据,下个打票周期会自动补回,因此解析失败时降级为 + // 「无门票可保留」继续完成编辑,不要把整个账号更新拖垮。 + var currentExtra map[string]any + if len(currentExtraJSON) > 0 { + if err := json.Unmarshal(currentExtraJSON, ¤tExtra); err != nil { + logger.LegacyPrintf("repository.account", + "[Account] current extra unmarshal failed, codex ticket preservation skipped: id=%d err=%v", + account.ID, err) + currentExtra = nil + } + } + extra = service.MergeOpenAICodexTicketExtra(extra, currentExtra) for _, key := range []string{ service.UpstreamBillingProbeEnabledExtraKey, service.UpstreamBillingRateSyncEnabledExtraKey, @@ -1337,11 +1358,20 @@ func (r *accountRepository) ListOAuthRefreshCandidatePage(ctx context.Context, o // NOT (a AND b) 在 PG 三值逻辑下会把 a 或 b 为 NULL 的行(即绝大多数 // 健康账号:temp_unschedulable_until=NULL)也排除,导致后台 token // 刷新工作器漏掉所有正常账号 → access_token 到期后请求开始 401。 + // + // Deliberately NO `schedulable = TRUE` filter here: paused accounts + // (schedulable=false, status=active) still hold valid refresh tokens and + // their stored access_token must keep working for the admin usage-window + // probe. Excluding them lets the token silently expire, after which the + // dashboard reports a false "needs re-auth" even though Test Connection + // (which refreshes on demand) succeeds. Permanent rejection is already + // covered by the status = 'active' filter (error accounts drop out), and + // accounts whose refresh actually fails are rate-limited by the + // ExcludeRetryCooldown clause below. query := ` SELECT id FROM accounts WHERE deleted_at IS NULL - AND schedulable = TRUE AND platform = ANY($1) AND id > $2` if options.ActiveOnly { diff --git a/backend/internal/repository/account_repo_codex_ticket_test.go b/backend/internal/repository/account_repo_codex_ticket_test.go new file mode 100644 index 000000000000..8006ea444fbe --- /dev/null +++ b/backend/internal/repository/account_repo_codex_ticket_test.go @@ -0,0 +1,66 @@ +package repository + +import ( + "context" + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" + "github.com/DATA-DOG/go-sqlmock" + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" + "testing" +) + +func TestLockAndMergeAccountExtraPreservesLatestCodexTicket(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + t.Cleanup(func() { _ = client.Close() }) + mock.ExpectQuery(`(?s)SELECT.*FOR NO KEY UPDATE`). + WithArgs(int64(41), service.PlatformOpenAI, service.AccountTypeOAuth, `{"access_token":"test"}`, nil). + WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "credential_generation_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "upstream_context_capacities", "model_context_overrides", "upstream_model_metadata", "current_extra"}). + AddRow(true, true, false, true, nil, nil, nil, nil, nil, nil, nil, nil, nil, []byte(`{"codex_turn_ticket:model":{"state":"latest-database-ticket"},"old_admin_setting":true}`))) + account := &service.Account{ID: 41, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Credentials: map[string]any{"access_token": "test"}, Extra: map[string]any{"codex_turn_ticket:model": map[string]any{"state": "stale"}, "codex_turn_ticket:injected": map[string]any{"state": "spoofed"}, "new_admin_setting": true}} + extra, _, err := lockAndMergeAccountProbeExtra(context.Background(), client, account, nil, nil) + require.NoError(t, err) + require.Equal(t, map[string]any{"state": "latest-database-ticket"}, extra["codex_turn_ticket:model"]) + require.NotContains(t, extra, "codex_turn_ticket:injected") + require.NotContains(t, extra, "old_admin_setting") + require.Equal(t, true, extra["new_admin_setting"]) + require.NoError(t, mock.ExpectationsWereMet()) +} + +// 这条路径覆盖所有平台的账号更新:extra 若是非对象 JSON,解析失败不得让整个 +// 账号编辑失败,只降级放弃「保留库内门票」这一件事。 +func TestLockAndMergeAccountExtraDegradesOnUnparsableExtra(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + t.Cleanup(func() { _ = client.Close() }) + mock.ExpectQuery(`(?s)SELECT.*FOR NO KEY UPDATE`). + WithArgs(int64(41), service.PlatformOpenAI, service.AccountTypeOAuth, `{"access_token":"test"}`, nil). + WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "credential_generation_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "upstream_context_capacities", "model_context_overrides", "upstream_model_metadata", "current_extra"}). + AddRow(true, true, false, true, nil, nil, nil, nil, nil, nil, nil, nil, nil, []byte(`[1,2,3]`))) + account := &service.Account{ID: 41, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Credentials: map[string]any{"access_token": "test"}, Extra: map[string]any{"codex_turn_ticket:injected": map[string]any{"state": "spoofed"}, "new_admin_setting": true}} + extra, _, err := lockAndMergeAccountProbeExtra(context.Background(), client, account, nil, nil) + require.NoError(t, err, "unparsable extra must not fail the account update") + require.Equal(t, true, extra["new_admin_setting"]) + // 降级也绝不能让管理端伪造的门票键落库。 + require.NotContains(t, extra, "codex_turn_ticket:injected") + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestCodexTicketExtraIsSchedulerNeutral(t *testing.T) { + require.True(t, isSchedulerNeutralExtraKey("codex_turn_ticket:gpt-6-astra")) + // 门票续期不再开事务发 bucket 重建事件(快照仍由 UpdateExtra 单独同步)。 + require.False(t, shouldEnqueueSchedulerOutboxForExtraUpdates(map[string]any{ + "codex_turn_ticket:gpt-6-astra": map[string]any{"state": "x"}, + })) + // 但门票不得掩盖同批写入的真实调度变更。 + require.True(t, shouldEnqueueSchedulerOutboxForExtraUpdates(map[string]any{ + "codex_turn_ticket:gpt-6-astra": map[string]any{"state": "x"}, + "openai_passthrough": true, + })) +} diff --git a/backend/internal/repository/account_repo_integration_test.go b/backend/internal/repository/account_repo_integration_test.go index 7b6af09c9306..9ec25005e78c 100644 --- a/backend/internal/repository/account_repo_integration_test.go +++ b/backend/internal/repository/account_repo_integration_test.go @@ -335,14 +335,16 @@ func (s *AccountRepoSuite) TestListOAuthRefreshCandidatePage_GrokCursorAndExclus "expires_at": now.Add(30 * time.Minute).Format(time.RFC3339), }, }) - unschedulable := mustCreateAccount(s.T(), s.client, &service.Account{ - Name: "grok-oauth-unschedulable-excluded", + // Paused but active OAuth accounts (schedulable=false) must remain refresh + // candidates so their stored access_token does not silently expire. + paused := mustCreateAccount(s.T(), s.client, &service.Account{ + Name: "grok-oauth-paused-included", Platform: service.PlatformGrok, Type: service.AccountTypeOAuth, Status: service.StatusActive, - Credentials: map[string]any{"refresh_token": "refresh-unschedulable"}, + Credentials: map[string]any{"refresh_token": "refresh-paused"}, }) - s.Require().NoError(s.client.Account.UpdateOneID(unschedulable.ID).SetSchedulable(false).Exec(s.ctx)) + s.Require().NoError(s.client.Account.UpdateOneID(paused.ID).SetSchedulable(false).Exec(s.ctx)) mustCreateAccount(s.T(), s.client, &service.Account{ Name: "grok-api-key-excluded", Platform: service.PlatformGrok, @@ -401,15 +403,14 @@ func (s *AccountRepoSuite) TestListOAuthRefreshCandidatePage_GrokCursorAndExclus s.Require().NoError(err) first := firstPage.Accounts s.Require().Len(first, 2) - s.Require().Equal([]int64{valid1.ID, valid2.ID}, []int64{first[0].ID, first[1].ID}) - s.Require().NotContains([]int64{first[0].ID, first[1].ID}, unschedulable.ID) + s.Require().Equal([]int64{valid1.ID, paused.ID}, []int64{first[0].ID, first[1].ID}) options.AfterID = first[len(first)-1].ID secondPage, err := s.repo.ListOAuthRefreshCandidatePage(s.ctx, options) s.Require().NoError(err) second := secondPage.Accounts - s.Require().Len(second, 1) - s.Require().Equal(valid3.ID, second[0].ID) + s.Require().Len(second, 2) + s.Require().Equal([]int64{valid2.ID, valid3.ID}, []int64{second[0].ID, second[1].ID}) s.Require().NotContains([]int64{first[0].ID, first[1].ID}, second[0].ID) } diff --git a/backend/internal/repository/account_repo_model_context_test.go b/backend/internal/repository/account_repo_model_context_test.go index 16200402390a..97f486b13148 100644 --- a/backend/internal/repository/account_repo_model_context_test.go +++ b/backend/internal/repository/account_repo_model_context_test.go @@ -182,9 +182,9 @@ func TestLockAndMergeAccountProbeExtraUsesLockedModelContextValues(t *testing.T) WillReturnRows(sqlmock.NewRows([]string{ "identity_unchanged", "credential_generation_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", - "upstream_context_capacities", "model_context_overrides", "upstream_model_metadata", + "upstream_context_capacities", "model_context_overrides", "upstream_model_metadata", "current_extra", }).AddRow(identityUnchanged, identityUnchanged, false, true, nil, nil, nil, nil, nil, nil, - []byte(`{"fresh":true}`), []byte(`{"other-model":700000}`), []byte(`{"fresh":true}`))) + []byte(`{"fresh":true}`), []byte(`{"other-model":700000}`), []byte(`{"fresh":true}`), nil)) account := modelContextRepositoryAccount(map[string]*int64{"model-a": modelContextRepositoryInt64(1050000)}) account.Extra = map[string]any{ service.UpstreamModelContextCapacitiesExtraKey: map[string]any{"stale": true}, @@ -229,9 +229,9 @@ func TestUpdateAccountModelContextPatchClearsOnlyAfterSuccessfulWrite(t *testing WillReturnRows(sqlmock.NewRows([]string{ "identity_unchanged", "credential_generation_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", - "upstream_context_capacities", "model_context_overrides", "upstream_model_metadata", + "upstream_context_capacities", "model_context_overrides", "upstream_model_metadata", "current_extra", }).AddRow(true, true, false, true, nil, nil, nil, nil, nil, nil, - []byte(`{"fresh":true}`), []byte(`{"other-model":700000}`), []byte(`{"fresh":true}`))) + []byte(`{"fresh":true}`), []byte(`{"other-model":700000}`), []byte(`{"fresh":true}`), nil)) mock.ExpectExec(`(?s)UPDATE .*accounts.*SET.*WHERE .*id.*`). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectQuery(`(?s)SELECT .* FROM "accounts" WHERE "id" = \$1`). diff --git a/backend/internal/repository/account_repo_temp_unsched_test.go b/backend/internal/repository/account_repo_temp_unsched_test.go index c21b48ef3928..a8019b7cffd8 100644 --- a/backend/internal/repository/account_repo_temp_unsched_test.go +++ b/backend/internal/repository/account_repo_temp_unsched_test.go @@ -300,8 +300,8 @@ func TestAccountRepository_ListOAuthRefreshCandidatePage_SQLFilter(t *testing.T) normalized := normalizeSQLWhitespace(capturedSQL) require.Contains(t, normalized, "deleted_at IS NULL") - require.Contains(t, normalized, "schedulable = TRUE", - "permanently unschedulable accounts must not remain OAuth refresh candidates") + require.NotContains(t, normalized, "schedulable = TRUE", + "paused (schedulable=false, status=active) accounts must remain OAuth refresh candidates: excluding them lets their stored access_token expire and the usage-window probe then reports a false 'needs re-auth'; permanent rejection is already covered by status = 'active' and refresh failures are bounded by the retry-cooldown exclusion") require.Contains(t, normalized, "status = 'active'") // setup-token 的 access_token 同为 8h 短期令牌,必须与 oauth 一起纳入后台刷新候选 require.Contains(t, normalized, "type IN ('oauth', 'setup-token')") diff --git a/backend/internal/repository/account_repo_upstream_billing_probe_update_test.go b/backend/internal/repository/account_repo_upstream_billing_probe_update_test.go index 0e5b0fd1f084..b2e6e1571052 100644 --- a/backend/internal/repository/account_repo_upstream_billing_probe_update_test.go +++ b/backend/internal/repository/account_repo_upstream_billing_probe_update_test.go @@ -81,8 +81,8 @@ func TestLockAndMergeAccountProbeExtraUsesCurrentDatabaseSnapshot(t *testing.T) mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")). WithArgs(int64(27), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil). - WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "credential_generation_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "upstream_context_capacities", "model_context_overrides", "upstream_model_metadata"}). - AddRow(tt.identityUnchanged, tt.identityUnchanged, false, true, tt.databaseEnabled, nil, tt.databaseSnapshot, nil, nil, nil, nil, nil, nil)) + WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "credential_generation_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "upstream_context_capacities", "model_context_overrides", "upstream_model_metadata", "current_extra"}). + AddRow(tt.identityUnchanged, tt.identityUnchanged, false, true, tt.databaseEnabled, nil, tt.databaseSnapshot, nil, nil, nil, nil, nil, nil, nil)) account := &service.Account{ ID: 27, @@ -172,8 +172,8 @@ func TestLockAndMergeAccountProbeExtraNeverInfersProbeFromRateSync(t *testing.T) mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")). WithArgs(int64(31), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil). - WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "credential_generation_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "upstream_context_capacities", "model_context_overrides", "upstream_model_metadata"}). - AddRow(true, true, false, true, tt.databaseEnabled, tt.databaseRateSync, nil, nil, nil, nil, nil, nil, nil)) + WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "credential_generation_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "upstream_context_capacities", "model_context_overrides", "upstream_model_metadata", "current_extra"}). + AddRow(true, true, false, true, tt.databaseEnabled, tt.databaseRateSync, nil, nil, nil, nil, nil, nil, nil, nil)) account := &service.Account{ ID: 31, @@ -211,8 +211,8 @@ func TestLockAndMergeAccountProbeExtraProtectsOllamaManagedFields(t *testing.T) mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")). WithArgs(int64(29), service.PlatformAnthropic, service.AccountTypeAPIKey, `{"api_key":"key","base_url":"https://ollama.com"}`, nil). - WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "credential_generation_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "upstream_context_capacities", "model_context_overrides", "upstream_model_metadata"}). - AddRow(identityUnchanged, identityUnchanged, identityUnchanged, true, nil, nil, nil, []byte(`"local-ciphertext"`), []byte(`true`), []byte(`{"status":"ok"}`), nil, nil, nil)) + WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "credential_generation_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "upstream_context_capacities", "model_context_overrides", "upstream_model_metadata", "current_extra"}). + AddRow(identityUnchanged, identityUnchanged, identityUnchanged, true, nil, nil, nil, []byte(`"local-ciphertext"`), []byte(`true`), []byte(`{"status":"ok"}`), nil, nil, nil, nil)) account := &service.Account{ ID: 29, Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, @@ -371,8 +371,8 @@ func TestUpdateWithAccountBillingSettingsRollsBackWhenOutboxFails(t *testing.T) mock.ExpectBegin() mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")). WithArgs(int64(27), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil). - WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "credential_generation_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "upstream_context_capacities", "model_context_overrides", "upstream_model_metadata"}). - AddRow(true, true, false, true, []byte(`true`), []byte(`true`), []byte(`{"status":"ok"}`), nil, nil, nil, nil, nil, nil)) + WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "credential_generation_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "upstream_context_capacities", "model_context_overrides", "upstream_model_metadata", "current_extra"}). + AddRow(true, true, false, true, []byte(`true`), []byte(`true`), []byte(`{"status":"ok"}`), nil, nil, nil, nil, nil, nil, nil)) mock.ExpectExec(`(?s)UPDATE .*accounts.*SET.*WHERE .*id.*`). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectQuery(`(?s)SELECT .* FROM "accounts" WHERE "id" = \$1`). diff --git a/backend/internal/repository/custom_group_usage_rollup_repo.go b/backend/internal/repository/custom_group_usage_rollup_repo.go index ac30edfc1945..1b0e1b292094 100644 --- a/backend/internal/repository/custom_group_usage_rollup_repo.go +++ b/backend/internal/repository/custom_group_usage_rollup_repo.go @@ -10,6 +10,62 @@ import ( "github.com/Wei-Shaw/sub2api/internal/service" ) +// groupUsageRollupSnapshot 是一次汇总水位的快照。 +// closedBefore / retainedFrom 在水位无效时退化为 1970-01-01, +// 与此前 SQL 里 CASE WHEN valid 的语义一一对应。 +type groupUsageRollupSnapshot struct { + valid bool + closedBefore string // date,历史日桶的右开边界 + retainedDate string // retained_from 在服务端时区内的日期,历史日桶的左闭边界 + tailStart time.Time // 尾段(还没进日桶的那部分 usage_logs)的起点 +} + +// readGroupUsageRollupSnapshot 读一次汇总水位并在 Go 侧判定有效性。 +// +// 判定条件与原先 SQL 里的 state_values CTE 完全一致: +// 恰好一行、时区名与当前服务端配置相同、且水位不在未来。 +func (r *usageLogRepository) readGroupUsageRollupSnapshot(ctx context.Context, timezoneName, todayDate string) (groupUsageRollupSnapshot, error) { + epoch := time.Unix(0, 0).UTC() + invalid := groupUsageRollupSnapshot{ + closedBefore: "1970-01-01", + retainedDate: "1970-01-01", + tailStart: epoch, + } + + var rowCount int + var closedBefore sql.NullString + var retainedFrom sql.NullTime + var stateTimezone sql.NullString + if err := scanSingleRow(ctx, r.sql, ` + SELECT + COUNT(*), + MAX(closed_before)::text, + MAX(retained_from), + MAX(timezone_name) + FROM usage_group_rollup_state + WHERE id = 1 + `, nil, &rowCount, &closedBefore, &retainedFrom, &stateTimezone); err != nil { + return groupUsageRollupSnapshot{}, fmt.Errorf("读取分组用量汇总水位: %w", err) + } + if rowCount != 1 || !closedBefore.Valid || !retainedFrom.Valid || + !stateTimezone.Valid || stateTimezone.String != timezoneName || + closedBefore.String > todayDate { + return invalid, nil + } + + tailStart, err := service.ParseGroupUsageDate(closedBefore.String) + if err != nil { + // 水位值解析不出来,按无效处理:宁可多扫一次,也不要算错钱。 + return invalid, nil //nolint:nilerr // 与上面的 valid 判定同语义,均降级为全量重算 + } + return groupUsageRollupSnapshot{ + valid: true, + closedBefore: closedBefore.String, + retainedDate: service.GroupUsageDate(retainedFrom.Time), + tailStart: tailStart.UTC(), + }, nil +} + func (r *usageLogRepository) getAllGroupUsageSummaryFromRollups(ctx context.Context, todayStart time.Time) (results []usagestats.GroupUsageSummary, err error) { todayStart = service.GroupUsageTodayStart(todayStart) yesterdayStart := service.GroupUsageYesterdayStart(todayStart) @@ -17,40 +73,34 @@ func (r *usageLogRepository) getAllGroupUsageSummaryFromRollups(ctx context.Cont todayDate := service.GroupUsageDate(todayStart) yesterdayDate := service.GroupUsageDate(yesterdayStart) + state, err := r.readGroupUsageRollupSnapshot(ctx, timezoneName, todayDate) + if err != nil { + return nil, err + } + + // 尾段起点必须以**查询参数**的形式给进来。 + // + // 此前它是在同一条 SQL 里由 state CTE 算出、再 CROSS JOIN 给 tail 用的, + // 于是 created_at 的下界对 planner 来说是个运行期才知道的值:既进不了 + // index cond,也估不准选择性,只能退化成 usage_logs 全表扫。 + // 生产实测(1721 万行 / PostgreSQL 17): + // Seq Scan on usage_logs … rows=17212870, Rows Removed by Join Filter: 17178082 + // Execution Time: 48720 ms + // 换成参数之后走 idx_usage_logs_created_at: + // Index Scan … Index Cond: (created_at >= $7) + // Execution Time: 34 ms const query = ` - WITH state_values AS ( - SELECT - COUNT(*) = 1 - AND MAX(timezone_name) = $3 - AND MAX(closed_before) <= $4::date AS valid, - MAX(closed_before) AS closed_before, - MAX(retained_from) AS retained_from - FROM usage_group_rollup_state - WHERE id = 1 - ), - state AS ( - SELECT - CASE WHEN valid THEN closed_before ELSE DATE '1970-01-01' END AS closed_before, - CASE WHEN valid THEN retained_from ELSE TIMESTAMPTZ '1970-01-01 00:00:00+00' END AS retained_from, - CASE - WHEN valid THEN closed_before::timestamp AT TIME ZONE $3::text - ELSE TIMESTAMPTZ '1970-01-01 00:00:00+00' - END AS tail_start, - valid - FROM state_values - ), - historical AS ( + WITH historical AS ( SELECT rollup.group_id, COALESCE(SUM(rollup.actual_cost), 0) AS actual_cost, COALESCE(SUM(rollup.actual_cost) FILTER ( - WHERE rollup.bucket_date = $5::date + WHERE rollup.bucket_date = $3::date ), 0) AS yesterday_cost FROM usage_group_daily_rollups rollup - CROSS JOIN state - WHERE state.valid - AND rollup.bucket_date >= (state.retained_from AT TIME ZONE $3::text)::date - AND rollup.bucket_date < state.closed_before + WHERE $4::boolean + AND rollup.bucket_date >= $5::date + AND rollup.bucket_date < $6::date GROUP BY rollup.group_id ), tail AS ( @@ -63,8 +113,7 @@ func (r *usageLogRepository) getAllGroupUsageSummaryFromRollups(ctx context.Cont AND ul.created_at < $1 ), 0) AS yesterday_cost FROM usage_logs ul - CROSS JOIN state - WHERE ul.created_at >= state.tail_start + WHERE ul.created_at >= $7 GROUP BY ul.group_id ) SELECT @@ -83,9 +132,11 @@ func (r *usageLogRepository) getAllGroupUsageSummaryFromRollups(ctx context.Cont query, todayStart, yesterdayStart, - timezoneName, - todayDate, yesterdayDate, + state.valid, + state.retainedDate, + state.closedBefore, + state.tailStart, ) if err != nil { return nil, err diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index 7e38062157a1..89f1688b9a8e 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -100,6 +100,7 @@ const ( upstreamProtocolModeOpenAIH1 = "openai_h1" upstreamProtocolModeOpenAIH2 = "openai_h2" upstreamProtocolModeOpenAIH1Fallback = "openai_h1_fallback" + upstreamProtocolModeOpenAIH1NoReuse = "openai_h1_noreuse" upstreamProtocolModeGrok = "grok" ) @@ -1014,6 +1015,9 @@ func (s *httpUpstreamService) resolveProtocolMode(profile service.HTTPUpstreamPr if profile == service.HTTPUpstreamProfileGrok { return upstreamProtocolModeGrok } + if profile == service.HTTPUpstreamProfileOpenAIHarvest { + return upstreamProtocolModeOpenAIH1NoReuse + } if profile != service.HTTPUpstreamProfileOpenAI { return upstreamProtocolModeDefault } @@ -1352,6 +1356,13 @@ func buildUpstreamTransport(settings poolSettings, proxyURL *url.URL, protocolMo case upstreamProtocolModeOpenAIH1: transport.ForceAttemptHTTP2 = false transport.TLSNextProto = make(map[string]func(string, *tls.Conn) http.RoundTripper) + case upstreamProtocolModeOpenAIH1NoReuse: + // Harvest must open a fresh CONNECT each attempt so the harvest proxy can rotate egress IPs. + transport.ForceAttemptHTTP2 = false + transport.DisableKeepAlives = true + transport.MaxIdleConns = 0 + transport.MaxIdleConnsPerHost = 0 + transport.TLSNextProto = make(map[string]func(string, *tls.Conn) http.RoundTripper) case upstreamProtocolModeOpenAIH1Fallback: // 显式禁用 HTTP/2,确保代理不兼容场景回退到 HTTP/1.1。 transport.ForceAttemptHTTP2 = false diff --git a/backend/internal/repository/http_upstream_test.go b/backend/internal/repository/http_upstream_test.go index 49881ae51144..a1fc8ae22ce4 100644 --- a/backend/internal/repository/http_upstream_test.go +++ b/backend/internal/repository/http_upstream_test.go @@ -692,6 +692,18 @@ func (s *HTTPUpstreamSuite) TestOpenAIProfileHTTP2DisabledUsesHTTP1Transport() { require.Equal(s.T(), upstreamProtocolModeOpenAIH1, entry.protocolMode) } +func (s *HTTPUpstreamSuite) TestOpenAIHarvestProfileDisablesKeepAlives() { + svc := s.newService() + entry, err := svc.getClientEntry("socks5h://user:pass@harvest.example:31", 41, 5, service.HTTPUpstreamProfileOpenAIHarvest, false, false) + require.NoError(s.T(), err) + require.Equal(s.T(), upstreamProtocolModeOpenAIH1NoReuse, entry.protocolMode) + transport, ok := entry.client.Transport.(*http.Transport) + require.True(s.T(), ok, "expected *http.Transport") + require.True(s.T(), transport.DisableKeepAlives) + require.False(s.T(), transport.ForceAttemptHTTP2) + require.Equal(s.T(), 0, transport.MaxIdleConns) +} + func (s *HTTPUpstreamSuite) TestOpenAIHeaderTimeoutChangeRebuildsClient() { s.cfg.Gateway = config.GatewayConfig{ OpenAIHTTP2: config.GatewayOpenAIHTTP2Config{Enabled: true}, diff --git a/backend/internal/repository/redeem_code_repo.go b/backend/internal/repository/redeem_code_repo.go index 2bdb34b42fa6..ef2841705d33 100644 --- a/backend/internal/repository/redeem_code_repo.go +++ b/backend/internal/repository/redeem_code_repo.go @@ -347,7 +347,7 @@ func (r *redeemCodeRepository) ListByUser(ctx context.Context, userID int64, lim codes, err := r.client.RedeemCode.Query(). Where(redeemcode.UsedByEQ(userID)). WithGroup(). - Order(dbent.Desc(redeemcode.FieldUsedAt)). + Order(dbent.Desc(redeemcode.FieldUsedAt), dbent.Desc(redeemcode.FieldID)). Limit(limit). All(ctx) if err != nil { @@ -377,7 +377,7 @@ func (r *redeemCodeRepository) ListByUserPaginated(ctx context.Context, userID i WithGroup(). Offset(params.Offset()). Limit(params.Limit()). - Order(dbent.Desc(redeemcode.FieldUsedAt)). + Order(dbent.Desc(redeemcode.FieldUsedAt), dbent.Desc(redeemcode.FieldID)). All(ctx) if err != nil { return nil, nil, err diff --git a/backend/internal/repository/redeem_code_repo_sort_integration_test.go b/backend/internal/repository/redeem_code_repo_sort_integration_test.go index 30d32f4cf97a..3b5b73e5d7d9 100644 --- a/backend/internal/repository/redeem_code_repo_sort_integration_test.go +++ b/backend/internal/repository/redeem_code_repo_sort_integration_test.go @@ -3,8 +3,10 @@ package repository import ( + "fmt" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/service" + "time" ) func (s *RedeemCodeRepoSuite) TestListWithFilters_SortByValueAsc() { @@ -22,3 +24,50 @@ func (s *RedeemCodeRepoSuite) TestListWithFilters_SortByValueAsc() { s.Require().Equal("VALUE-10", codes[0].Code) s.Require().Equal("VALUE-20", codes[1].Code) } + +func (s *RedeemCodeRepoSuite) TestHistoryPaginationIsolationAndOrdering() { + user := s.createUser("history@example.com") + other := s.createUser("other-history@example.com") + now := time.Now().UTC().Truncate(time.Second) + var ids []int64 + types := []string{service.RedeemTypeBalance, service.RedeemTypeConcurrency, service.RedeemTypeSubscription, "admin_balance", "admin_concurrency"} + for i := 0; i < 105; i++ { + usedAt := now + if i == 0 { + usedAt = now.Add(time.Hour) + } + code, err := s.client.RedeemCode.Create().SetCode(fmt.Sprintf("HISTORY-%d", i)).SetType(types[i%len(types)]).SetStatus(service.StatusUsed).SetUsedBy(user.ID).SetUsedAt(usedAt).Save(s.ctx) + s.Require().NoError(err) + ids = append(ids, code.ID) + } + _, err := s.client.RedeemCode.Create().SetCode("OTHER-HISTORY").SetType(service.RedeemTypeSubscription).SetStatus(service.StatusUsed).SetUsedBy(other.ID).SetUsedAt(now.Add(2 * time.Hour)).Save(s.ctx) + s.Require().NoError(err) + expected := []int64{ids[0]} + for i := len(ids) - 1; i > 0; i-- { + expected = append(expected, ids[i]) + } + for _, size := range []int{20, 50, 100} { + var got []int64 + for page := 1; page <= (105+size-1)/size+1; page++ { + codes, result, err := s.repo.ListByUserPaginated(s.ctx, user.ID, pagination.PaginationParams{Page: page, PageSize: size}, "") + s.Require().NoError(err) + s.Equal(int64(105), result.Total) + s.LessOrEqual(len(codes), size) + for _, code := range codes { + s.Equal(user.ID, *code.UsedBy) + got = append(got, code.ID) + } + } + s.Equal(expected, got) + } + codes, result, err := s.repo.ListByUserPaginated(s.ctx, other.ID, pagination.PaginationParams{Page: 1, PageSize: 20}, "") + s.Require().NoError(err) + s.Equal(int64(1), result.Total) + s.Require().Len(codes, 1) + s.Equal("OTHER-HISTORY", codes[0].Code) + empty := s.createUser("empty-history@example.com") + codes, result, err = s.repo.ListByUserPaginated(s.ctx, empty.ID, pagination.PaginationParams{Page: 1, PageSize: 20}, "") + s.Require().NoError(err) + s.Empty(codes) + s.Zero(result.Total) +} diff --git a/backend/internal/repository/usage_log_repo_group_summary_test.go b/backend/internal/repository/usage_log_repo_group_summary_test.go index f67facb4b9f9..676a252ab870 100644 --- a/backend/internal/repository/usage_log_repo_group_summary_test.go +++ b/backend/internal/repository/usage_log_repo_group_summary_test.go @@ -18,8 +18,23 @@ func TestUsageLogRepositoryGetAllGroupUsageSummaryUsesRollupTail(t *testing.T) { todayStart := time.Date(2026, 3, 9, 4, 0, 0, 0, time.UTC) yesterdayStart := time.Date(2026, 3, 8, 5, 0, 0, 0, time.UTC) - mock.ExpectQuery(`(?s)usage_group_rollup_state.*usage_group_daily_rollups.*created_at >= state\.tail_start`). - WithArgs(todayStart, yesterdayStart, "America/New_York", "2026-03-09", "2026-03-08"). + // 水位有效:closed_before = 2026-03-07(≤ today),时区与配置一致。 + mock.ExpectQuery(`(?s)COUNT\(\*\).*usage_group_rollup_state.*WHERE id = 1`). + WillReturnRows(sqlmock.NewRows([]string{"count", "closed_before", "retained_from", "timezone_name"}). + AddRow(1, "2026-03-07", time.Date(2026, 2, 1, 5, 0, 0, 0, time.UTC), "America/New_York")) + + // 尾段起点必须以参数形式进 WHERE —— 这正是把 usage_logs 全表扫换回 + // idx_usage_logs_created_at 索引扫的那一步。 + mock.ExpectQuery(`(?s)usage_group_daily_rollups.*FROM usage_logs ul\s+WHERE ul\.created_at >= \$7`). + WithArgs( + todayStart, + yesterdayStart, + "2026-03-08", // yesterdayDate + true, // 水位有效 + "2026-02-01", // retained_from 在配置时区内的日期 + "2026-03-07", // closed_before + time.Date(2026, 3, 7, 5, 0, 0, 0, time.UTC), // tail_start = closed_before 在配置时区的零点 + ). WillReturnRows(sqlmock.NewRows([]string{"group_id", "total_cost", "today_cost", "yesterday_cost"}). AddRow(int64(7), 12.5, 1.25, 2.5)) @@ -31,3 +46,51 @@ func TestUsageLogRepositoryGetAllGroupUsageSummaryUsesRollupTail(t *testing.T) { require.InDelta(t, 2.5, result[0].YesterdayCost, 0.0000001) require.NoError(t, mock.ExpectationsWereMet()) } + +// 水位无效(时区变了 / 水位在未来 / 行缺失)时必须退化成"从 epoch 起全量重算", +// 与改动前 SQL 里 CASE WHEN valid 的 ELSE 分支逐字对应: +// 历史日桶整段不参与,尾段起点回到 1970-01-01。 +func TestUsageLogRepositoryGetAllGroupUsageSummaryFallsBackWhenWatermarkInvalid(t *testing.T) { + epoch := time.Unix(0, 0).UTC() + todayStart := time.Date(2026, 3, 9, 4, 0, 0, 0, time.UTC) + yesterdayStart := time.Date(2026, 3, 8, 5, 0, 0, 0, time.UTC) + + cases := []struct { + name string + rows *sqlmock.Rows + }{ + { + name: "水位行缺失", + rows: sqlmock.NewRows([]string{"count", "closed_before", "retained_from", "timezone_name"}). + AddRow(0, nil, nil, nil), + }, + { + name: "时区与当前配置不一致", + rows: sqlmock.NewRows([]string{"count", "closed_before", "retained_from", "timezone_name"}). + AddRow(1, "2026-03-07", time.Date(2026, 2, 1, 5, 0, 0, 0, time.UTC), "Asia/Shanghai"), + }, + { + name: "水位位于未来", + rows: sqlmock.NewRows([]string{"count", "closed_before", "retained_from", "timezone_name"}). + AddRow(1, "2026-03-10", time.Date(2026, 2, 1, 5, 0, 0, 0, time.UTC), "America/New_York"), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + db, mock := newSQLMock(t) + repo := newUsageLogRepositoryWithSQL(nil, db) + useGroupUsageRepositoryTestTimezone(t, "America/New_York") + + mock.ExpectQuery(`(?s)COUNT\(\*\).*usage_group_rollup_state.*WHERE id = 1`). + WillReturnRows(tc.rows) + mock.ExpectQuery(`(?s)usage_group_daily_rollups.*FROM usage_logs ul\s+WHERE ul\.created_at >= \$7`). + WithArgs(todayStart, yesterdayStart, "2026-03-08", false, "1970-01-01", "1970-01-01", epoch). + WillReturnRows(sqlmock.NewRows([]string{"group_id", "total_cost", "today_cost", "yesterday_cost"})) + + _, err := repo.GetAllGroupUsageSummary(context.Background(), todayStart) + require.NoError(t, err) + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 8b6e0d0e0350..efd99ba83698 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -900,6 +900,9 @@ func TestAPIContracts(t *testing.T) { "table_page_size_options": [10, 20, 50, 100], "min_claude_code_version": "", "max_claude_code_version": "", + "openai_codex_ticket_enabled": false, + "openai_codex_ticket_harvest_proxy_url": "", + "openai_codex_ticket_harvest_proxy_configured": false, "min_codex_version": "", "max_codex_version": "", "codex_cli_only_blacklist": "", @@ -1246,6 +1249,9 @@ func TestAPIContracts(t *testing.T) { "rewrite_message_cache_control": false, "enable_client_dateline_normalization": true, "antigravity_user_agent_version": "", + "openai_codex_ticket_enabled": false, + "openai_codex_ticket_harvest_proxy_url": "", + "openai_codex_ticket_harvest_proxy_configured": false, "min_codex_version": "", "max_codex_version": "", "codex_cli_only_blacklist": "", diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go index e77fcc2202c7..f9c4a2f18953 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -465,6 +465,7 @@ func normalizeOpenAILongContextBillingUpdateExtra(account *Account, input *Updat // Grok media eligibility helpers live in account_grok_media_eligibility.go. func buildAccountForCreate(input *CreateAccountInput, accountExtra map[string]any) (*Account, error) { + accountExtra = MergeOpenAICodexTicketExtra(accountExtra, nil) // Probe/session state is system-managed. New accounts always start with automatic refresh disabled. delete(accountExtra, UpstreamBillingProbeEnabledExtraKey) delete(accountExtra, UpstreamBillingRateSyncEnabledExtraKey) @@ -835,6 +836,7 @@ func (s *adminServiceImpl) updateAccount(ctx context.Context, id int64, input *U normalizedExtra[key] = v } } + normalizedExtra = MergeOpenAICodexTicketExtra(normalizedExtra, account.Extra) normalizedExtra = prepareCodexFingerprintExtraForUpdate(account, normalizedExtra) account.Extra = normalizedExtra if account.Platform == PlatformAntigravity && wasOveragesEnabled && !account.IsOveragesEnabled() { @@ -1076,6 +1078,8 @@ func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, upd if err := ValidateOpenAIReasoningPolicyExtra(updates); err != nil { return err } + updates = MergeOpenAICodexTicketExtra(updates, nil) + updates = sanitizedCodexFingerprintExtraUpdates(updates) updates = stripOpenAIAutoResetCreditManagedExtra(updates, true) delete(updates, UpstreamBillingProbeEnabledExtraKey) @@ -1109,6 +1113,7 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp return nil, err } // Managed probe/session state may only enter through dedicated typed endpoints. + input.Extra = MergeOpenAICodexTicketExtra(input.Extra, nil) input.Extra = sanitizedCodexFingerprintExtraUpdates(input.Extra) input.Extra = stripOpenAIAutoResetCreditManagedExtra(input.Extra, true) delete(input.Extra, UpstreamBillingProbeEnabledExtraKey) diff --git a/backend/internal/service/admin_account_codex_ticket_test.go b/backend/internal/service/admin_account_codex_ticket_test.go new file mode 100644 index 000000000000..b6f925dde4e1 --- /dev/null +++ b/backend/internal/service/admin_account_codex_ticket_test.go @@ -0,0 +1,31 @@ +package service + +import ( + "context" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func TestUpdateAccountPreservesCodexTicketOnEdit(t *testing.T) { + account := ticketTestAccount(41) + ticket := &openAICodexTicket{Model: "gpt-6-astra", State: fakeCodexTicketState(292), Length: 292, ExpiresAt: time.Now().Add(time.Hour)} + key := openAICodexTicketExtraKey(ticket.Model) + account.Extra = map[string]any{key: ticket} + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{41: account}} + svc := &adminServiceImpl{accountRepo: repo} + for _, extra := range []map[string]any{{key: map[string]any{"model": ticket.Model, "length": 292}, "custom": true}, {"custom": true}} { + updated, err := svc.UpdateAccount(context.Background(), 41, &UpdateAccountInput{Extra: extra}) + require.NoError(t, err) + require.Equal(t, ticket, updated.Extra[key]) + require.Equal(t, true, updated.Extra["custom"]) + } + require.NoError(t, svc.UpdateAccountExtra(context.Background(), 41, map[string]any{key: map[string]any{"state": "spoofed"}})) + require.Equal(t, ticket, repo.accounts[41].Extra[key]) +} +func TestCreateAccountDropsUserSuppliedCodexTickets(t *testing.T) { + account, err := buildAccountForCreate(&CreateAccountInput{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, map[string]any{"codex_turn_ticket:custom": map[string]any{"state": "spoofed"}, "custom": true}) + require.NoError(t, err) + require.NotContains(t, account.Extra, "codex_turn_ticket:custom") + require.Equal(t, true, account.Extra["custom"]) +} diff --git a/backend/internal/service/cn_providers_test.go b/backend/internal/service/cn_providers_test.go index 7165b2953705..44becd4e5bee 100644 --- a/backend/internal/service/cn_providers_test.go +++ b/backend/internal/service/cn_providers_test.go @@ -834,6 +834,16 @@ func TestNormalizeDeepSeekResponsesRequestBody(t *testing.T) { require.False(t, gjson.GetBytes(adaptiveNormalized, "store").Bool()) require.False(t, gjson.GetBytes(adaptiveNormalized, "previous_response_id").Exists()) + mediaBody := []byte(`{"model":"deepseek-v4.1-flash","store":true,"input":[{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"},{"type":"function_call_output","call_id":"call_image","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]}]}`) + mediaNormalized := normalizeDeepSeekResponsesRequestBody(deepseekResponses, mediaBody) + require.False(t, gjson.GetBytes(mediaNormalized, "store").Bool()) + require.Equal(t, gjson.String, gjson.GetBytes(mediaNormalized, "input.1.output").Type) + require.NotContains(t, gjson.GetBytes(mediaNormalized, "input.1.output").String(), "data:image/png") + require.Equal(t, "message", gjson.GetBytes(mediaNormalized, "input.2.type").String()) + require.Equal(t, "user", gjson.GetBytes(mediaNormalized, "input.2.role").String()) + require.Equal(t, "input_image", gjson.GetBytes(mediaNormalized, "input.2.content.1.type").String()) + require.Equal(t, "data:image/png;base64,AQID", gjson.GetBytes(mediaNormalized, "input.2.content.1.image_url").String()) + // 非 responses 协议(deepseek CC 账号)原样返回 deepseekCC := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey} require.Equal(t, string(body), string(normalizeDeepSeekResponsesRequestBody(deepseekCC, body))) diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index f9f4e12f04a2..6faac2ae1f85 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -716,6 +716,12 @@ const ( SettingKeyOpenAICodexClientVersionSynced = "openai_codex_client_version_synced" // SettingKeyOpenAICodexVersionAutoSyncEnabled 是否启用 Codex 客户端版本号自动同步(默认 true)。 SettingKeyOpenAICodexVersionAutoSyncEnabled = "openai_codex_version_auto_sync_enabled" + // SettingKeyOpenAICodexTicketEnabled Codex 292 打票总开关(后台可改、热更新)。 + // 关闭:不打票、不注入 x-codex-turn-state,按原链路转发。 + // 开启:后台打票并在业务请求中覆盖该头。 + SettingKeyOpenAICodexTicketEnabled = "openai_codex_ticket_enabled" + // SettingKeyOpenAICodexTicketHarvestProxyURL Codex 292 打票出口(socks5h/http),后台可改、热更新。 + SettingKeyOpenAICodexTicketHarvestProxyURL = "openai_codex_ticket_harvest_proxy_url" // SettingKeyOpenAIAllowClaudeCodeCodexPlugin 已废弃:历史全局开关只作为升级迁移输入读取。 // 迁移后等价规则写入 SettingKeyCodexCLIOnlyWhitelist,不再参与运行时判定。 SettingKeyOpenAIAllowClaudeCodeCodexPlugin = "openai_allow_claude_code_codex_plugin" diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index edd727ce5eec..306d84d8f413 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -16,6 +16,7 @@ import ( mathrand "math/rand" "net/http" "regexp" + "sort" "strings" "time" @@ -483,6 +484,32 @@ func (s *GeminiMessagesCompatService) validateUpstreamBaseURL(raw string) (strin return normalized, nil } +// AntigravityGeminiModelIDs returns the effective Gemini mappings of schedulable +// Antigravity accounts. Mixed routes must additionally respect the account opt-in. +func (s *GeminiMessagesCompatService) AntigravityGeminiModelIDs(ctx context.Context, groupID *int64, requireMixed bool) ([]string, error) { + accounts, err := s.listSchedulableAccountsOnce(ctx, groupID, PlatformAntigravity, false) + if err != nil { + return nil, err + } + seen := make(map[string]struct{}) + for _, account := range accounts { + if account.Platform != PlatformAntigravity || (requireMixed && !account.IsMixedSchedulingEnabled()) { + continue + } + for model := range account.GetModelMapping() { + if isAntigravityGeminiModel(model) { + seen[model] = struct{}{} + } + } + } + models := make([]string, 0, len(seen)) + for model := range seen { + models = append(models, model) + } + sort.Strings(models) + return models, nil +} + // HasAntigravityAccounts 检查是否有可用的 antigravity 账户 func (s *GeminiMessagesCompatService) HasAntigravityAccounts(ctx context.Context, groupID *int64) (bool, error) { accounts, err := s.listSchedulableAccountsOnce(ctx, groupID, PlatformAntigravity, false) diff --git a/backend/internal/service/http_upstream_profile.go b/backend/internal/service/http_upstream_profile.go index 92020abb357c..fd1875030f0d 100644 --- a/backend/internal/service/http_upstream_profile.go +++ b/backend/internal/service/http_upstream_profile.go @@ -7,10 +7,11 @@ import "context" type HTTPUpstreamProfile string const ( - HTTPUpstreamProfileDefault HTTPUpstreamProfile = "" - HTTPUpstreamProfileOpenAI HTTPUpstreamProfile = "openai" - HTTPUpstreamProfileGrok HTTPUpstreamProfile = "grok" - HTTPUpstreamProfileLongStream HTTPUpstreamProfile = "long_stream" + HTTPUpstreamProfileDefault HTTPUpstreamProfile = "" + HTTPUpstreamProfileOpenAI HTTPUpstreamProfile = "openai" + HTTPUpstreamProfileOpenAIHarvest HTTPUpstreamProfile = "openai_harvest" + HTTPUpstreamProfileGrok HTTPUpstreamProfile = "grok" + HTTPUpstreamProfileLongStream HTTPUpstreamProfile = "long_stream" ) type httpUpstreamProfileContextKey struct{} @@ -38,7 +39,7 @@ func HTTPUpstreamProfileFromContext(ctx context.Context) HTTPUpstreamProfile { return HTTPUpstreamProfileDefault } switch profile { - case HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileGrok, HTTPUpstreamProfileLongStream: + case HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileOpenAIHarvest, HTTPUpstreamProfileGrok, HTTPUpstreamProfileLongStream: return profile default: return HTTPUpstreamProfileDefault diff --git a/backend/internal/service/http_upstream_profile_test.go b/backend/internal/service/http_upstream_profile_test.go index 38080664cfc9..f38ce618ff38 100644 --- a/backend/internal/service/http_upstream_profile_test.go +++ b/backend/internal/service/http_upstream_profile_test.go @@ -20,6 +20,13 @@ func TestWithHTTPUpstreamProfile_OpenAI(t *testing.T) { } } +func TestWithHTTPUpstreamProfile_OpenAIHarvest(t *testing.T) { + ctx := WithHTTPUpstreamProfile(context.TODO(), HTTPUpstreamProfileOpenAIHarvest) + if profile := HTTPUpstreamProfileFromContext(ctx); profile != HTTPUpstreamProfileOpenAIHarvest { + t.Fatalf("expected profile %q, got %q", HTTPUpstreamProfileOpenAIHarvest, profile) + } +} + func TestWithHTTPUpstreamProfile_LongStream(t *testing.T) { ctx := WithHTTPUpstreamProfile(context.TODO(), HTTPUpstreamProfileLongStream) if profile := HTTPUpstreamProfileFromContext(ctx); profile != HTTPUpstreamProfileLongStream { diff --git a/backend/internal/service/openai_account_runtime_block_fastpath.go b/backend/internal/service/openai_account_runtime_block_fastpath.go index 55348111fe5a..1f94202d1abe 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath.go @@ -1567,14 +1567,22 @@ func (s *OpenAIGatewayService) clearOpenAIAccountRuntimeBlockIfUnchanged(account // Empty/inactive fields remove that contribution with generation+deadline CAS; // an independent request/credential block keeps its own deadline and owner. // Cindy health/balance, model scopes and OAuth Redis leases remain independent. -func (s *OpenAIGatewayService) isOpenAIAccountRequestRuntimeBlocked(account *Account, requestedModel string) bool { - return s.isOpenAIAccountRequestRuntimeBlockedContext(ensureOpenAIRuntimeBreakerProbeOwner(context.Background()), account, requestedModel) +func (s *OpenAIGatewayService) isOpenAIAccountRequestRuntimeBlocked(account *Account, requestedModel string, requireCompact ...bool) bool { + return s.isOpenAIAccountRequestRuntimeBlockedContext(ensureOpenAIRuntimeBreakerProbeOwner(context.Background()), account, requestedModel, requireCompact...) } -func (s *OpenAIGatewayService) isOpenAIAccountRequestRuntimeBlockedContext(ctx context.Context, account *Account, requestedModel string) bool { +func (s *OpenAIGatewayService) isOpenAIAccountRequestRuntimeBlockedContext(ctx context.Context, account *Account, requestedModel string, requireCompact ...bool) bool { if s == nil || account == nil { return false } + // Only OAuth/setup-token accounts can own tickets; API-key paths stay unchanged. + if isOpenAICodexTicketAccount(account) { + compact := len(requireCompact) > 0 && requireCompact[0] + outboundModel := s.openAICodexTicketOutboundModel(account, requestedModel, compact) + if s.openAICodexTicketBlocksAccount(account, outboundModel) { + return true + } + } if !IsCindyRuntimeCompatibleAPIKeyAccount(account.Platform, account.Type, account.Credentials) { snapshot := s.peekOpenAIAccountRuntimeBlock(account) if snapshot.blocked && snapshot.sources.hasPersisted && !snapshot.until.IsZero() && !accountPersistedSchedulingCooldownActive(account) { @@ -1660,8 +1668,9 @@ func (s *OpenAIGatewayService) isOpenAIAccountStrictContinuationBlockedContext( ctx context.Context, account *Account, requestedModel string, + requireCompact ...bool, ) bool { - if !s.isOpenAIAccountRequestRuntimeBlockedContext(ctx, account, requestedModel) { + if !s.isOpenAIAccountRequestRuntimeBlockedContext(ctx, account, requestedModel, requireCompact...) { return false } if account == nil || !IsCindyRuntimeCompatibleAPIKeyAccount(account.Platform, account.Type, account.Credentials) { diff --git a/backend/internal/service/openai_account_runtime_block_fastpath_test.go b/backend/internal/service/openai_account_runtime_block_fastpath_test.go index 3a87c19e676f..b4b2a986fb7a 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath_test.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath_test.go @@ -1231,7 +1231,7 @@ func TestOpenAIPoolModeTempRule_StopsSameAccountRetryAndIsolatesBlockToModel(t * require.Len(t, repo.modelRateLimitCalls, 1) require.Equal(t, "gpt-5.4", repo.modelRateLimitCalls[0].scope) require.False(t, gateway.isOpenAIAccountRuntimeBlocked(account)) - require.False(t, gateway.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.5")) + require.False(t, gateway.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.5", false)) } func TestOpenAIPoolModeRetryable5xx_DoesNotCreateModelTransientBlock(t *testing.T) { @@ -1260,7 +1260,7 @@ func TestOpenAIPoolModeRetryable5xx_DoesNotCreateModelTransientBlock(t *testing. require.False(t, shouldDisable) } - require.False(t, gateway.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.4")) + require.False(t, gateway.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.4", false)) } func TestCooldownOpenAIRetryExhausted_AccountAndModelScopes(t *testing.T) { @@ -1451,7 +1451,7 @@ func TestOpenAIPoolModeNonRetryable5xx_StillCreatesModelTransientBlock(t *testin require.False(t, shouldDisable) } - require.True(t, gateway.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.4")) + require.True(t, gateway.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.4", false)) } func TestOpenAINonPoolAPIKey5xx_StillCreatesModelTransientBlock(t *testing.T) { @@ -1476,7 +1476,7 @@ func TestOpenAINonPoolAPIKey5xx_StillCreatesModelTransientBlock(t *testing.T) { require.False(t, shouldDisable) } - require.True(t, gateway.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.4")) + require.True(t, gateway.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.4", false)) } func TestOpenAIModelNotFound_DoesNotRuntimeBlockWholeAccount(t *testing.T) { diff --git a/backend/internal/service/openai_account_runtime_transient_test.go b/backend/internal/service/openai_account_runtime_transient_test.go index 960d217a6b70..71f77cb9bf83 100644 --- a/backend/internal/service/openai_account_runtime_transient_test.go +++ b/backend/internal/service/openai_account_runtime_transient_test.go @@ -123,7 +123,7 @@ func TestHandleOpenAITransientError_PersistedCooldownClearsWithDatabaseState(t * svc.BlockAccountSchedulingFromPersistedCooldown(account, time.Now().Add(time.Minute), "upstream_disable") require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) - require.False(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.5")) - require.False(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.6-sol")) + require.False(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.5", false)) + require.False(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.6-sol", false)) require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) } diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index f9a791520dcb..ed1984400a8b 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -1520,7 +1520,7 @@ func (s *defaultOpenAIAccountScheduler) selectByLoadBalance( filterStats.exclude("platform_mismatch") continue } - if s.service.isOpenAIAccountRequestRuntimeBlockedContext(ctx, account, openAIRequestedModelForAccount(ctx, account, req.RequestedModel)) { + if s.service.isOpenAIAccountRequestRuntimeBlockedContext(ctx, account, openAIRequestedModelForAccount(ctx, account, req.RequestedModel), req.RequireCompact) { filterStats.exclude("runtime_blocked") filterStats.observeRuntimeCooldown(s.service, account.ID) continue @@ -1846,7 +1846,7 @@ func (s *defaultOpenAIAccountScheduler) isAccountRequestCompatibleReason(ctx con return false, "provider_identity_mismatch" } requestedModel := openAIRequestedModelForAccount(ctx, account, req.RequestedModel) - if s != nil && s.service != nil && s.service.isOpenAIAccountRequestRuntimeBlockedContext(ctx, account, requestedModel) { + if s != nil && s.service != nil && s.service.isOpenAIAccountRequestRuntimeBlockedContext(ctx, account, requestedModel, req.RequireCompact) { return false, "runtime_blocked" } // The scheduler's candidate filter already checks the account's ordinary diff --git a/backend/internal/service/openai_chat_roles.go b/backend/internal/service/openai_chat_roles.go new file mode 100644 index 000000000000..1e80101f5dcb --- /dev/null +++ b/backend/internal/service/openai_chat_roles.go @@ -0,0 +1,82 @@ +package service + +import ( + "encoding/json" + "errors" + "net/url" + "strings" +) + +func requiresSystemChatRole(account *Account, targetURL string) bool { + if account == nil || account.Type != AccountTypeAPIKey { + return false + } + switch account.Platform { + case PlatformDeepseek, PlatformKimi, PlatformZhipu: + return true + } + // OpenAI-compatible accounts can point at the same strict providers. Match + // the selected destination's exact hostname, never the requested model ID. + u, err := url.Parse(targetURL) + if err != nil { + return false + } + switch strings.ToLower(u.Hostname()) { + case "api.deepseek.com", "api.kimi.com", "api.moonshot.cn", "api.moonshot.ai", "open.bigmodel.cn", "api.z.ai": + return true + } + return false +} + +// normalizeStrictChatDeveloperRoles adapts native Chat requests only after an +// account and destination have been selected. Unlike the Responses bridge, it +// does not reorder/merge messages or downgrade instructions to user messages. +// RawMessage preserves unknown fields and numeric precision. The input remains +// untouched so a retry on another account sees the original developer roles. +func normalizeStrictChatDeveloperRoles(account *Account, targetURL string, body []byte) ([]byte, error) { + if !requiresSystemChatRole(account, targetURL) { + return body, nil + } + var root map[string]json.RawMessage + if json.Unmarshal(body, &root) != nil || root == nil { + return nil, errors.New("chat role normalization: invalid request object") + } + raw, exists := root["messages"] + if !exists { + return body, nil + } + var messages []json.RawMessage + if json.Unmarshal(raw, &messages) != nil { + return nil, errors.New("chat role normalization: invalid messages array") + } + changed := false + for i, raw := range messages { + var message map[string]json.RawMessage + if json.Unmarshal(raw, &message) != nil || message == nil { + return nil, errors.New("chat role normalization: invalid message object") + } + var role string + if json.Unmarshal(message["role"], &role) != nil { + return nil, errors.New("chat role normalization: invalid message role") + } + if role != "developer" { + continue + } + message["role"] = json.RawMessage(`"system"`) + updated, err := json.Marshal(message) + if err != nil { + return nil, errors.New("chat role normalization: cannot encode message") + } + messages[i] = updated + changed = true + } + if !changed { + return body, nil + } + updated, err := json.Marshal(messages) + if err != nil { + return nil, errors.New("chat role normalization: cannot encode messages") + } + root["messages"] = updated + return json.Marshal(root) +} diff --git a/backend/internal/service/openai_chat_roles_test.go b/backend/internal/service/openai_chat_roles_test.go new file mode 100644 index 000000000000..27434ad98317 --- /dev/null +++ b/backend/internal/service/openai_chat_roles_test.go @@ -0,0 +1,185 @@ +//go:build unit + +package service + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// Only the upstream transport is stubbed. Requests go through the actual Chat +// dispatcher, model mapping, request construction and response/usage handling. +type chatRoleContractUpstream struct { + httpUpstreamRecorder + strict bool +} + +func (u *chatRoleContractUpstream) Do(req *http.Request, proxyURL string, accountID int64, concurrency int) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + req.Body = io.NopCloser(bytes.NewReader(body)) + status, contentType := http.StatusOK, "application/json" + result := `{"id":"chatcmpl_roles","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":1,"total_tokens":10}}` + if u.strict { + for _, message := range gjson.GetBytes(body, "messages").Array() { + if message.Get("role").String() == "developer" { + status = http.StatusBadRequest + result = "{\"error\":{\"message\":\"unknown variant `developer`\",\"type\":\"invalid_request_error\"}}" + } + } + } + if status == http.StatusOK && gjson.GetBytes(body, "stream").Bool() { + contentType = "text/event-stream" + result = "data: {\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n\ndata: {\"choices\":[],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":1,\"total_tokens\":10}}\n\ndata: [DONE]\n\n" + } + u.resp = &http.Response{StatusCode: status, Header: http.Header{"Content-Type": {contentType}}, Body: io.NopCloser(strings.NewReader(result))} + return u.httpUpstreamRecorder.Do(req, proxyURL, accountID, concurrency) +} + +func TestForwardAsChatCompletions_StrictDeveloperRole(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, stream := range []bool{false, true} { + for _, target := range []struct{ platform, model, base string }{ + {PlatformDeepseek, "deepseek-flash", "https://api.deepseek.com"}, + {PlatformKimi, "k3", "https://api.kimi.com/coding"}, + {PlatformZhipu, "glm-5.3", "https://open.bigmodel.cn/api/paas/v4"}, + {PlatformOpenAI, "deepseek-flash", "https://api.deepseek.com"}, + {PlatformOpenAI, "k3", "https://api.kimi.com/coding"}, + {PlatformOpenAI, "kimi-k3", "https://api.moonshot.ai"}, + {PlatformOpenAI, "glm-5.3", "https://open.bigmodel.cn/api/paas/v4"}, + } { + t.Run(fmt.Sprintf("%s/%s/%s/stream=%t", target.platform, target.model, target.base, stream), func(t *testing.T) { + body := []byte(fmt.Sprintf(`{"model":"example-alias","messages":[{"role":"developer","content":"Keep these instructions."},{"role":"user","content":"ping"}],"stream":%t,"max_tokens":10}`, stream)) + original := bytes.Clone(body) + upstream := &chatRoleContractUpstream{strict: true} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := rawChatCompletionsTestAccount() + account.Platform = target.platform + account.Credentials["base_url"] = target.base + account.Credentials["api_protocol"] = APIProtocolChatCompletions + account.Credentials["model_mapping"] = map[string]any{"example-alias": target.model} + account.Extra = map[string]any{openai_compat.ExtraKeyResponsesSupported: false} + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 9, result.Usage.InputTokens) + require.Equal(t, 1, result.Usage.OutputTokens) + require.Equal(t, target.model, gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "system", gjson.GetBytes(upstream.lastBody, "messages.0.role").String()) + require.Equal(t, "Keep these instructions.", gjson.GetBytes(upstream.lastBody, "messages.0.content").String()) + require.Equal(t, original, body, "per-account adaptation must not mutate the retry body") + require.Equal(t, http.StatusOK, recorder.Code) + require.Contains(t, recorder.Body.String(), "pong") + if stream { + require.Contains(t, recorder.Body.String(), "data: [DONE]") + } + + // A subsequent attempt with the same body on a compatible account + // must still see developer, not the previous account's system role. + upstream.strict = false + account.Platform = PlatformOpenAI + account.Credentials["base_url"] = "https://compatible.example.test/v1" + recorder = httptest.NewRecorder() + c, _ = gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + _, err = svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + require.NoError(t, err) + require.Equal(t, "developer", gjson.GetBytes(upstream.lastBody, "messages.0.role").String()) + }) + } + } +} + +func TestNormalizeStrictChatDeveloperRoles_PreservesConversation(t *testing.T) { + body := []byte(`{"model":"alias","messages":[{"role":"system","content":"first"},{"role":"developer","content":[{"type":"text","text":"developer is just text","cache_control":{"type":"ephemeral"}}],"name":"rules"},{"role":"user","content":"ping"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"echo","arguments":"{\"role\":\"developer\"}"}}]},{"role":"tool","tool_call_id":"call_1","content":"ok"},{"role":"developer","content":"last"}],"metadata":{"large":9007199254740993,"role":"developer"},"stream":true,"reasoning_effort":"high"}`) + original := bytes.Clone(body) + want := strings.ReplaceAll(string(body), `"role":"developer","content"`, `"role":"system","content"`) + for _, platform := range []string{PlatformDeepseek, PlatformKimi, PlatformZhipu} { + t.Run(platform, func(t *testing.T) { + account := &Account{Platform: platform, Type: AccountTypeAPIKey} + got, err := normalizeStrictChatDeveloperRoles(account, "https://relay.example.test/v1/chat/completions", body) + require.NoError(t, err) + require.JSONEq(t, want, string(got)) + require.Contains(t, string(got), "9007199254740993") + require.Equal(t, original, body) + again, err := normalizeStrictChatDeveloperRoles(account, "https://relay.example.test/v1/chat/completions", got) + require.NoError(t, err) + require.Equal(t, got, again) + }) + } +} + +func TestNormalizeStrictChatDeveloperRoles_ExactOfficialHosts(t *testing.T) { + body := []byte(`{"messages":[{"role":"developer","content":"rules"}]}`) + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + for _, host := range []string{"api.deepseek.com", "api.kimi.com", "api.moonshot.cn", "api.moonshot.ai", "open.bigmodel.cn", "api.z.ai", "API.DEEPSEEK.COM:443"} { + t.Run(host, func(t *testing.T) { + got, err := normalizeStrictChatDeveloperRoles(account, "https://"+host+"/v1/chat/completions", body) + require.NoError(t, err) + require.JSONEq(t, `{"messages":[{"role":"system","content":"rules"}]}`, string(got)) + }) + } +} + +func TestNormalizeStrictChatDeveloperRoles_OtherTargetsUntouched(t *testing.T) { + // Neither model names nor host-like strings in the path/userinfo may enable + // normalization on an otherwise compatible provider. + body := []byte("{ \"model\": \"deepseek-flash\", \"messages\": [{\"role\":\"developer\",\"content\":\"rules\"}] }") + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + for _, target := range []string{ + "https://api.openai.com/v1/chat/completions", + "https://relay.example.test/v1/chat/completions", + "https://api.deepseek.com.example.test/v1/chat/completions", + "https://example.test/api.kimi.com/chat/completions", + "https://api.deepseek.com@example.test/v1/chat/completions", + "://invalid-url", + } { + got, err := normalizeStrictChatDeveloperRoles(account, target, body) + require.NoError(t, err) + require.Equal(t, body, got) + } + for _, account := range []*Account{nil, {Platform: PlatformKimi, Type: AccountTypeOAuth}, {Platform: PlatformOpenAI, Type: AccountTypeOAuth}} { + got, err := normalizeStrictChatDeveloperRoles(account, "https://api.kimi.com/coding/v1/chat/completions", body) + require.NoError(t, err) + require.Equal(t, body, got) + } +} + +func TestNormalizeStrictChatDeveloperRoles_CompatibleBodyUntouched(t *testing.T) { + account := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey} + for _, body := range []string{ + "{ \"messages\": [{\"role\":\"system\",\"content\":\"rules\"}] }", + `{"messages":[]}`, + `{"input":[{"role":"developer","content":"Responses is not Chat"}]}`, + } { + got, err := normalizeStrictChatDeveloperRoles(account, "https://api.deepseek.com/v1/chat/completions", []byte(body)) + require.NoError(t, err) + require.Equal(t, body, string(got)) + } +} + +func TestNormalizeStrictChatDeveloperRoles_MalformedBodyDoesNotLeak(t *testing.T) { + account := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey} + for _, body := range []string{`{"secret":`, `null`, `[]`, `{"messages":"secret"}`, `{"messages":["secret"]}`, `{"messages":[null]}`, `{"messages":[{"content":"secret"}]}`, `{"messages":[{"role":12,"content":"secret"}]}`} { + _, err := normalizeStrictChatDeveloperRoles(account, "https://api.deepseek.com/v1/chat/completions", []byte(body)) + require.Error(t, err) + require.NotContains(t, err.Error(), "secret") + } +} diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index e2b342eb8c1c..05355cc93d4b 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -2949,13 +2949,9 @@ func validateCodexModelsManifestEnvelope(body []byte) error { return errors.New("missing top-level models array") } models = bytes.TrimSpace(models) - var entries []json.RawMessage if len(models) == 0 || models[0] != '[' { return errors.New("top-level models field is not an array") } - if err := json.Unmarshal(models, &entries); err != nil { - return fmt.Errorf("decode top-level models array: %w", err) - } return nil } diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index caec9ab0bc7b..932435e68493 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -2885,6 +2885,11 @@ func TestFetchCodexModelsManifestRejectsInvalidEnvelope(t *testing.T) { } } +func TestValidateCodexModelsManifestEnvelopeRequiresExactModelsKey(t *testing.T) { + require.NoError(t, validateCodexModelsManifestEnvelope([]byte(`{"models":[]}`))) + require.Error(t, validateCodexModelsManifestEnvelope([]byte(`{"Models":[]}`))) +} + func TestFetchCodexModelsManifestAPIKeyDoesNotCacheInvalidEnvelope(t *testing.T) { var calls atomic.Int32 upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { diff --git a/backend/internal/service/openai_codex_ticket.go b/backend/internal/service/openai_codex_ticket.go new file mode 100644 index 000000000000..cc455b32f35d --- /dev/null +++ b/backend/internal/service/openai_codex_ticket.go @@ -0,0 +1,682 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "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/google/uuid" + "github.com/tidwall/gjson" + "go.uber.org/zap" +) + +const ( + openAICodexTicketExtraKeyPrefix = "codex_turn_ticket:" + openAICodexAstraMinVersion = "0.153.4" + openAICodexTicketStatePrefix = "gAAAAA" + openAICodexTicketDefaultModel = "gpt-6-astra" + openAICodexTicketDefaultSolModel = "gpt-5.6-sol" +) + +// ErrOpenAICodexTicketUnavailable 表示该号该模型没有可用的 292 门票, +// 且 fail_closed 禁止裸打业务请求。 +var ErrOpenAICodexTicketUnavailable = errors.New("codex turn-state ticket unavailable") + +type openAICodexTicket struct { + AccountID int64 `json:"account_id"` + Model string `json:"model"` + State string `json:"state"` + Length int `json:"length"` + CapturedAt time.Time `json:"captured_at"` + ExpiresAt time.Time `json:"expires_at"` + Attempts int `json:"attempts"` +} + +func openAICodexTicketKey(accountID int64, model string) string { + return fmt.Sprintf("%d\x00%s", accountID, strings.TrimSpace(model)) +} + +func openAICodexTicketExtraKey(model string) string { + return openAICodexTicketExtraKeyPrefix + strings.TrimSpace(model) +} + +func normalizeOpenAICodexTicketModel(model string) string { + return strings.TrimSpace(model) +} + +func extractOpenAICodexTicketModel(body []byte) string { + return normalizeOpenAICodexTicketModel(gjson.GetBytes(body, "model").String()) +} + +func (s *OpenAIGatewayService) openAICodexTicketConfig() config.OpenAICodexTicketConfig { + cfg := config.OpenAICodexTicketConfig{} + if s != nil && s.cfg != nil { + cfg = s.cfg.Gateway.OpenAICodexTicket + } + if cfg.TargetLength <= 0 { + cfg.TargetLength = 292 + } + if cfg.TTLSeconds <= 0 { + cfg.TTLSeconds = 3600 + } + if cfg.RefreshBeforeSeconds <= 0 { + cfg.RefreshBeforeSeconds = 600 + } + if cfg.HarvestProbeIntervalSeconds <= 0 { + cfg.HarvestProbeIntervalSeconds = 6 + } + if cfg.HarvestAttemptTimeoutSeconds <= 0 { + cfg.HarvestAttemptTimeoutSeconds = 25 + } + if len(cfg.Models) == 0 { + cfg.Models = []string{openAICodexTicketDefaultModel, openAICodexTicketDefaultSolModel} + } + return cfg +} + +func (s *OpenAIGatewayService) openAICodexTicketGatedModel(model string) bool { + model = normalizeOpenAICodexTicketModel(model) + if model == "" || !s.openAICodexTicketEnabled() { + return false + } + for _, item := range s.openAICodexTicketConfig().Models { + if normalizeOpenAICodexTicketModel(item) == model { + return true + } + } + return false +} + +// OpenAICodexTicketStatus 是给管理端看的门票摘要,不含 state blob。 +type OpenAICodexTicketStatus struct { + Model string `json:"model"` + Length int `json:"length,omitempty"` + Ready bool `json:"ready"` + RemainingSeconds int64 `json:"remaining_seconds"` + Blocked bool `json:"blocked"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + +func OpenAICodexTicketStatuses(account *Account, cfg config.OpenAICodexTicketConfig, now time.Time) []OpenAICodexTicketStatus { + if !cfg.Enabled || !isOpenAICodexTicketAccount(account) { + return nil + } + models, targetLen := cfg.Models, cfg.TargetLength + if len(models) == 0 { + models = []string{openAICodexTicketDefaultModel, openAICodexTicketDefaultSolModel} + } + if targetLen <= 0 { + targetLen = 292 + } + out := make([]OpenAICodexTicketStatus, 0, len(models)) + for _, model := range models { + model = normalizeOpenAICodexTicketModel(model) + if model == "" { + continue + } + status := OpenAICodexTicketStatus{Model: model} + ticket := parseOpenAICodexTicketFromAny(0, model, nil) + if account != nil && account.Extra != nil { + ticket = parseOpenAICodexTicketFromAny(account.ID, model, account.Extra[openAICodexTicketExtraKey(model)]) + } + if ticket.valid(now, targetLen) { + status.Ready = true + status.Length = ticket.Length + remaining := int64(ticket.ExpiresAt.Sub(now) / time.Second) + if remaining < 0 { + remaining = 0 + } + status.RemainingSeconds = remaining + exp := ticket.ExpiresAt + status.ExpiresAt = &exp + } + status.Blocked = cfg.FailClosed && !status.Ready + out = append(out, status) + } + return out +} + +func (s *OpenAIGatewayService) openAICodexTicketEnabled() bool { + return s.openAICodexTicketEnabledContext(context.Background()) +} + +func (s *OpenAIGatewayService) openAICodexTicketEnabledContext(ctx context.Context) bool { + if s == nil { + return false + } + fallback := s.cfg != nil && s.cfg.Gateway.OpenAICodexTicket.Enabled + if s.settingService != nil { + return s.settingService.GetOpenAICodexTicketEnabled(ctx, fallback) + } + return fallback +} + +func (s *OpenAIGatewayService) openAICodexTicketHarvestProxyURL() string { + return s.openAICodexTicketHarvestProxyURLContext(context.Background()) +} + +func (s *OpenAIGatewayService) openAICodexTicketHarvestProxyURLContext(ctx context.Context) string { + if s.settingService != nil { + if proxy := s.settingService.GetOpenAICodexTicketHarvestProxyURL(ctx); proxy != "" { + return proxy + } + } + return strings.TrimSpace(s.openAICodexTicketConfig().HarvestProxyURL) +} + +func (t *openAICodexTicket) valid(now time.Time, targetLen int) bool { + if t == nil { + return false + } + state := strings.TrimSpace(t.State) + if len(state) != targetLen || t.Length != targetLen || !strings.HasPrefix(state, openAICodexTicketStatePrefix) { + return false + } + if t.ExpiresAt.IsZero() || !now.Before(t.ExpiresAt) { + return false + } + return true +} + +func (t *openAICodexTicket) needsRefresh(now time.Time, refreshBefore time.Duration) bool { + if t == nil || t.ExpiresAt.IsZero() { + return true + } + return !t.ExpiresAt.After(now.Add(refreshBefore)) +} + +func (s *OpenAIGatewayService) lookupOpenAICodexTicket(account *Account, model string) *openAICodexTicket { + if s == nil || account == nil || account.ID <= 0 { + return nil + } + model = normalizeOpenAICodexTicketModel(model) + if model == "" { + return nil + } + key := openAICodexTicketKey(account.ID, model) + targetLen := 292 + if s != nil { + targetLen = s.openAICodexTicketConfig().TargetLength + } + now := time.Now() + var mem *openAICodexTicket + if raw, ok := s.openaiCodexTickets.Load(key); ok { + mem, _ = raw.(*openAICodexTicket) + } + var extra *openAICodexTicket + if account.Extra != nil { + extra = parseOpenAICodexTicketFromAny(account.ID, model, account.Extra[openAICodexTicketExtraKey(model)]) + } + if extra.valid(now, targetLen) && (mem == nil || extra.CapturedAt.After(mem.CapturedAt)) { + s.openaiCodexTickets.Store(key, extra) + return extra + } + if mem.valid(now, targetLen) { + return mem + } + if extra != nil { + s.openaiCodexTickets.Store(key, extra) + return extra + } + if mem != nil { + s.openaiCodexTickets.Delete(key) + } + return nil +} + +func parseOpenAICodexTicketFromAny(accountID int64, model string, raw any) *openAICodexTicket { + if raw == nil { + return nil + } + b, err := json.Marshal(raw) + if err != nil { + return nil + } + var ticket openAICodexTicket + if err := json.Unmarshal(b, &ticket); err != nil { + return nil + } + ticket.AccountID = accountID + if strings.TrimSpace(model) != "" { + ticket.Model = model + } + ticket.State = strings.TrimSpace(ticket.State) + if ticket.Length == 0 { + ticket.Length = len(ticket.State) + } + if ticket.State == "" { + return nil + } + return &ticket +} + +func (s *OpenAIGatewayService) storeOpenAICodexTicket(ctx context.Context, account *Account, ticket *openAICodexTicket) { + if s == nil || account == nil || ticket == nil || account.ID <= 0 { + return + } + model := normalizeOpenAICodexTicketModel(ticket.Model) + ticket.Model = model + ticket.AccountID = account.ID + s.openaiCodexTickets.Store(openAICodexTicketKey(account.ID, model), ticket) + if s.accountRepo == nil { + return + } + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{ + openAICodexTicketExtraKey(model): ticket, + }); err != nil { + logger.L().Warn("openai_codex_ticket persist failed", + zap.Int64("account_id", account.ID), + zap.String("model", model), + zap.Error(err), + ) + } +} + +// applyOpenAICodexTicket 在出站请求上覆盖 x-codex-turn-state。 +// 请求路径只注入已捕获的有效门票,不现场打票;无票则返回 +// ErrOpenAICodexTicketUnavailable。打票由后台 harvester 完成。 +func (s *OpenAIGatewayService) applyOpenAICodexTicket(ctx context.Context, account *Account, model string, h http.Header) error { + if s == nil || h == nil || !isOpenAICodexTicketAccount(account) || !s.openAICodexTicketEnabledContext(ctx) { + return nil + } + model = normalizeOpenAICodexTicketModel(model) + if model == "" || !s.openAICodexTicketGatedModel(model) { + return nil + } + cfg := s.openAICodexTicketConfig() + ticket := s.lookupOpenAICodexTicket(account, model) + if ticket.valid(time.Now(), cfg.TargetLength) { + h.Set(openAICodexTurnStateHeader, ticket.State) + return nil + } + if !cfg.FailClosed { + return nil + } + return ErrOpenAICodexTicketUnavailable +} + +// openAICodexTicketOutboundModel 预测本请求真正出站的模型名,也就是 +// applyOpenAICodexTicket 注入时读到的 body.model。 +// +// 调度门控与注入必须按同一个模型名判定门票。普通请求下二者同源:Forward 的 +// upstreamModel 与本函数都走 resolveOpenAIAccountUpstreamModelForRequest,且 +// Forward 会把 body.model 改写成该值后才注入。但 /responses/compact 例外—— +// Forward 会把出站模型进一步改写为 compact 映射或 gateway.openai_compact_model +// (默认非空),此时若门控仍按客户端原始模型判定,就会把「实际出站是非门控 +// 模型、根本不需要票」的 compact 请求整片误拦成不可调度。 +func (s *OpenAIGatewayService) openAICodexTicketOutboundModel(account *Account, requestedModel string, requireCompact bool) string { + model := strings.TrimSpace(requestedModel) + if account == nil || model == "" { + return model + } + if !account.IsOpenAI() { + return canonicalOpenAIAccountSchedulingModel(account, model) + } + _, upstreamModel := resolveOpenAIForwardMappedModels(account, model, requireCompact) + if requireCompact { + // 与 Forward 同序:compact 兜底模型优先于普通/compact 映射结果。 + if compactModel := strings.TrimSpace(s.resolveOpenAICompactFallbackModel(account, model)); compactModel != "" { + upstreamModel = compactModel + } + } + if upstreamModel = strings.TrimSpace(upstreamModel); upstreamModel != "" { + return upstreamModel + } + return model +} + +// outboundModel 必须是真正会发给上游的模型名(openAICodexTicketOutboundModel), +// 不是客户端原始模型:注入侧读的是出站 body.model,两侧口径必须一致。 +func (s *OpenAIGatewayService) openAICodexTicketBlocksAccount(account *Account, outboundModel string) bool { + if s == nil || !isOpenAICodexTicketAccount(account) || !s.openAICodexTicketEnabled() { + return false + } + cfg := s.openAICodexTicketConfig() + if !cfg.FailClosed { + return false + } + model := normalizeOpenAICodexTicketModel(outboundModel) + if !s.openAICodexTicketGatedModel(model) { + return false + } + ticket := s.lookupOpenAICodexTicket(account, model) + return !ticket.valid(time.Now(), cfg.TargetLength) +} + +func (s *OpenAIGatewayService) fireOpenAICodexTicketProbe(ctx context.Context, account *Account, token, model, proxyURL string, attemptTimeout time.Duration) (state string, status int, err error) { + attemptCtx, cancel := context.WithTimeout(ctx, attemptTimeout) + defer cancel() + + body := []byte(`{"model":` + jsonString(model) + `,"store":false,"stream":true,"instructions":"Reply with exactly: pong","input":[{"role":"user","content":[{"type":"input_text","text":"ping"}]}]}`) + req, err := http.NewRequestWithContext(attemptCtx, http.MethodPost, chatgptCodexURL, bytes.NewReader(body)) + if err != nil { + return "", 0, err + } + req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAIHarvest)) + req.Close = true + req.Host = "chatgpt.com" + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("OpenAI-Beta", "responses=experimental") + req.Header.Set("session_id", uuid.NewString()) + if err := resolveAndSetOpenAIChatGPTAccountHeaders(attemptCtx, s.accountRepo, req.Header, account); err != nil { + return "", 0, err + } + applyOpenAICodexTicketHarvestIdentity(req.Header, model) + + // Synthetic probes must use the dedicated no-reuse transport even when the + // production account is bound to a plugin. This also avoids reading pluginManager + // while handlers are still wiring it during gateway construction. + resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) + if err != nil { + return "", 0, err + } + if resp == nil { + return "", 0, errors.New("nil upstream response") + } + // Only the response header is needed; no connection will be reused. + defer func() { + if resp.Body != nil { + _ = resp.Body.Close() + } + }() + return extractOpenAICodexTurnState(resp.Header), resp.StatusCode, nil +} + +func jsonString(v string) string { + b, err := json.Marshal(v) + if err != nil { + return `""` + } + return string(b) +} + +func applyOpenAICodexTicketHarvestIdentity(h http.Header, model string) { + ensureCodexIdentityHeaders(h) + enforceCodexIdentityHeaders(h) + version := strings.TrimSpace(h.Get("version")) + if needsOpenAICodexAstraVersion(model) && (version == "" || CompareVersions(version, openAICodexAstraMinVersion) < 0) { + h.Set("version", openAICodexAstraMinVersion) + h.Set("user-agent", buildCodexCLIUserAgent(openAICodexAstraMinVersion)) + h.Set("originator", openai.CodexDefaultOriginator) + } +} + +func needsOpenAICodexAstraVersion(model string) bool { + m := strings.ToLower(normalizeOpenAICodexTicketModel(model)) + return strings.Contains(m, "gpt-6") || strings.Contains(m, "astra") +} + +func (s *OpenAIGatewayService) StartOpenAICodexTicketHarvester() { + if s == nil { + return + } + s.openaiCodexTicketLifecycleMu.Lock() + defer s.openaiCodexTicketLifecycleMu.Unlock() + if s.openaiCodexTicketStopped || s.openaiCodexTicketDone != nil { + return + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + s.openaiCodexTicketCancel = cancel + s.openaiCodexTicketDone = done + go func() { + defer close(done) + s.openAICodexTicketHarvestLoop(ctx) + }() + logger.L().Info("openai_codex_ticket harvester started", + zap.Int("ttl_seconds", s.openAICodexTicketConfig().TTLSeconds), + zap.Int("target_length", s.openAICodexTicketConfig().TargetLength), + zap.Strings("models", s.openAICodexTicketConfig().Models), + ) +} + +func (s *OpenAIGatewayService) StopOpenAICodexTicketHarvester() { + if s == nil { + return + } + s.openaiCodexTicketLifecycleMu.Lock() + s.openaiCodexTicketStopped = true + cancel, done := s.openaiCodexTicketCancel, s.openaiCodexTicketDone + s.openaiCodexTicketLifecycleMu.Unlock() + if cancel != nil { + cancel() + } + if done != nil { + <-done + } +} + +func (s *OpenAIGatewayService) openAICodexTicketHarvestLoop(ctx context.Context) { + timer := time.NewTimer(0) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + s.refreshOpenAICodexTickets(ctx) + timer.Reset(time.Duration(s.openAICodexTicketConfig().HarvestProbeIntervalSeconds) * time.Second) + } + } +} + +// refreshOpenAICodexTickets probes each account/model with a missing or soon-to-expire +// ticket once. The loop waits for all probes, then waits the configured interval +// before starting the next cycle. +func (s *OpenAIGatewayService) refreshOpenAICodexTickets(ctx context.Context) { + if s == nil || s.accountRepo == nil || ctx.Err() != nil || !s.openAICodexTicketEnabledContext(ctx) { + return + } + accounts, err := s.accountRepo.ListByPlatform(ctx, PlatformOpenAI) + if err != nil { + logger.L().Warn("openai_codex_ticket list accounts failed", zap.Error(err)) + return + } + cfg := s.openAICodexTicketConfig() + now := time.Now() + refreshBefore := time.Duration(cfg.RefreshBeforeSeconds) * time.Second + var wg sync.WaitGroup + probed := 0 + for i := range accounts { + account := accounts[i] + if account.Status != StatusActive || !isOpenAICodexTicketAccount(&account) { + continue + } + for _, model := range cfg.Models { + model := normalizeOpenAICodexTicketModel(model) + if model == "" { + continue + } + // 已有一张有效且未临近过期的票 → 本周期不打,省得白刷。 + if t := s.lookupOpenAICodexTicket(&account, model); t.valid(now, cfg.TargetLength) && !t.needsRefresh(now, refreshBefore) { + continue + } + acc := account + // Token/header helpers may update account metadata; each model owns its maps. + acc.Extra = maps.Clone(account.Extra) + acc.Credentials = maps.Clone(account.Credentials) + probed++ + wg.Add(1) + go func(acc Account, model string) { + defer wg.Done() + s.probeOnceOpenAICodexTicket(ctx, &acc, model) + }(acc, model) + } + } + wg.Wait() + if probed > 0 { + logger.L().Info("openai_codex_ticket probe cycle", zap.Int("probed", probed)) + } +} + +// probeOnceOpenAICodexTicket 走打票代理打一发。命中合格 292(HTTP 200、长度==target、 +// gAAAAA 前缀)就落库;否则记 Info miss,交给下个周期重试。同一 key 并发去重,避免上一发还没 +// 回来又叠一发。 +func (s *OpenAIGatewayService) probeOnceOpenAICodexTicket(ctx context.Context, account *Account, model string) { + if s == nil || !isOpenAICodexTicketAccount(account) || ctx.Err() != nil || !s.openAICodexTicketEnabledContext(ctx) { + return + } + cfg := s.openAICodexTicketConfig() + proxyURL := s.openAICodexTicketHarvestProxyURLContext(ctx) + if proxyURL == "" || s.httpUpstream == nil || ctx.Err() != nil { + return + } + key := openAICodexTicketKey(account.ID, model) + _, _, _ = s.openaiCodexTicketFlight.Do(key, func() (any, error) { + token, _, err := s.GetAccessToken(ctx, account) + if err != nil || strings.TrimSpace(token) == "" { + logger.L().Info("openai_codex_ticket probe miss", + zap.Int64("account_id", account.ID), zap.String("model", model), + zap.String("reason", "token"), zap.Error(err)) + return nil, nil + } + state, status, perr := s.fireOpenAICodexTicketProbe(ctx, account, token, model, proxyURL, time.Duration(cfg.HarvestAttemptTimeoutSeconds)*time.Second) + if perr != nil { + logger.L().Info("openai_codex_ticket probe miss", + zap.Int64("account_id", account.ID), zap.String("model", model), + zap.String("reason", "error"), zap.Error(perr)) + return nil, nil + } + if status != http.StatusOK || state == "" || len(state) != cfg.TargetLength || !strings.HasPrefix(state, openAICodexTicketStatePrefix) { + logger.L().Info("openai_codex_ticket probe miss", + zap.Int64("account_id", account.ID), zap.String("model", model), + zap.Int("http", status), zap.Int("len", len(state))) + return nil, nil + } + now := time.Now() + ticket := &openAICodexTicket{ + AccountID: account.ID, + Model: model, + State: state, + Length: len(state), + CapturedAt: now, + ExpiresAt: now.Add(time.Duration(cfg.TTLSeconds) * time.Second), + Attempts: 1, + } + s.storeOpenAICodexTicket(ctx, account, ticket) + logger.L().Info("openai_codex_ticket harvested", + zap.Int64("account_id", account.ID), zap.String("model", model), + zap.Int("length", ticket.Length), zap.String("mode", "continuous")) + return nil, nil + }) +} + +// IsOpenAICodexTicketExtraKey identifies server-managed ticket material. +func IsOpenAICodexTicketExtraKey(key string) bool { + return strings.HasPrefix(key, openAICodexTicketExtraKeyPrefix) +} + +// MergeOpenAICodexTicketExtra preserves only persisted tickets, never summaries or +// blobs supplied by an account edit. The repository repeats this under the row +// lock so a concurrent harvest cannot be overwritten by a stale admin snapshot. +func MergeOpenAICodexTicketExtra(extra, current map[string]any) map[string]any { + result := maps.Clone(extra) + for key := range result { + if IsOpenAICodexTicketExtraKey(key) { + delete(result, key) + } + } + for key, value := range current { + if IsOpenAICodexTicketExtraKey(key) { + if result == nil { + result = make(map[string]any) + } + result[key] = value + } + } + return result +} + +// ValidateOpenAICodexTicketHarvestProxyURL validates only syntax, without making +// a network request or including credentials in validation errors. +func ValidateOpenAICodexTicketHarvestProxyURL(raw string) error { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Hostname() == "" || parsed.Opaque != "" || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") { + return errors.New("harvest proxy must be an HTTP(S) or SOCKS5(h) URL with a host and no path, query or fragment") + } + switch parsed.Scheme { + case "http", "https", "socks5", "socks5h": + default: + return errors.New("harvest proxy scheme must be http, https, socks5 or socks5h") + } + if port := parsed.Port(); port != "" { + n, err := strconv.Atoi(port) + if err != nil || n < 1 || n > 65535 { + return errors.New("harvest proxy port must be between 1 and 65535") + } + } + return nil +} + +// MaskProxyURL never returns a stored proxy password, even for invalid legacy data. +func MaskProxyURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" || ValidateOpenAICodexTicketHarvestProxyURL(raw) != nil { + return "" + } + parsed, _ := url.Parse(raw) + if parsed.User != nil { + if _, ok := parsed.User.Password(); ok { + parsed.User = url.UserPassword(parsed.User.Username(), "***") + } + } + return parsed.String() +} + +// IsMaskedProxyURL recognizes the exact password placeholder emitted by the API. +func IsMaskedProxyURL(raw string) bool { + raw = strings.TrimSpace(raw) + if raw == "" { + return true + } + parsed, err := url.Parse(raw) + if err != nil || parsed.User == nil { + return false + } + password, ok := parsed.User.Password() + return ok && password == "***" +} + +// Credential shadows do not own tickets. Keep their existing forwarding policy +// instead of imposing a gate for a key the harvester never populates. +func isOpenAICodexTicketAccount(account *Account) bool { + return account != nil && account.IsOpenAIOAuthLike() && !account.IsShadow() +} + +// IsOpenAICodexTicketPrivateExtraKey also covers the retired account-level proxy +// override, whose credentials may remain in older account records. +func IsOpenAICodexTicketPrivateExtraKey(key string) bool { + return IsOpenAICodexTicketExtraKey(key) || key == "codex_harvest_proxy_url" +} + +// RedactOpenAICodexTicketExtra strips ephemeral ticket material from exports +// without changing the source account or unrelated backup fields. +func RedactOpenAICodexTicketExtra(extra map[string]any) map[string]any { + redacted := maps.Clone(extra) + for key := range redacted { + if IsOpenAICodexTicketPrivateExtraKey(key) { + delete(redacted, key) + } + } + return redacted +} diff --git a/backend/internal/service/openai_codex_ticket_lifecycle_test.go b/backend/internal/service/openai_codex_ticket_lifecycle_test.go new file mode 100644 index 000000000000..272795866137 --- /dev/null +++ b/backend/internal/service/openai_codex_ticket_lifecycle_test.go @@ -0,0 +1,207 @@ +package service + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +type codexTicketFuncUpstream struct { + HTTPUpstream + do func(*http.Request) (*http.Response, error) +} + +func (u *codexTicketFuncUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return u.do(req) +} +func codexTicketResponse() *http.Response { + h := http.Header{} + h.Set(openAICodexTurnStateHeader, fakeCodexTicketState(292)) + return &http.Response{StatusCode: http.StatusOK, Header: h, Body: io.NopCloser(strings.NewReader("data: {}\n\n"))} +} + +func TestCodexTicketProbeBypassesPluginDuringWiring(t *testing.T) { + manager := &PluginManager{} + manager.route.Store(&pluginRoute{pluginID: 1, rolloutPercent: 100, unavailable: "plugin must not handle synthetic probes"}) + var calls atomic.Int64 + upstream := &codexTicketFuncUpstream{do: func(req *http.Request) (*http.Response, error) { + calls.Add(1) + if HTTPUpstreamProfileFromContext(req.Context()) != HTTPUpstreamProfileOpenAIHarvest || !req.Close { + return nil, errors.New("missing no-reuse transport profile") + } + return codexTicketResponse(), nil + }} + svc := ticketTestService(t, config.OpenAICodexTicketConfig{Enabled: true}, upstream) + svc.SetPluginManager(manager) + account := ticketTestAccount(41) + // This binding rejects ordinary traffic; harvesting still uses the dedicated transport. + request, _ := http.NewRequest(http.MethodPost, "https://example.com", nil) + _, err := svc.doOpenAIUpstream(request, "", account) + require.Error(t, err) + start := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + <-start + for i := 0; i < 200; i++ { + svc.SetPluginManager(manager) + } + }() + close(start) + for i := 0; i < 20; i++ { + state, status, err := svc.fireOpenAICodexTicketProbe(context.Background(), account, "test-token", "gpt-6-astra", "http://proxy.example.com:8080", time.Second) + require.NoError(t, err) + require.Equal(t, http.StatusOK, status) + require.Len(t, state, 292) + } + wg.Wait() + require.Equal(t, int64(20), calls.Load()) +} + +type codexTicketLifecycleRepo struct { + AccountRepository + account Account + list func(context.Context) ([]Account, error) + persist func(context.Context) error +} + +func (r *codexTicketLifecycleRepo) ListByPlatform(ctx context.Context, _ string) ([]Account, error) { + if r.list != nil { + return r.list(ctx) + } + return []Account{r.account}, nil +} +func (r *codexTicketLifecycleRepo) UpdateExtra(ctx context.Context, _ int64, _ map[string]any) error { + if r.persist != nil { + return r.persist(ctx) + } + return nil +} + +type codexTicketLifecycleSettings struct { + SettingRepository + get func(context.Context, string) (string, error) +} + +func (r *codexTicketLifecycleSettings) GetValue(ctx context.Context, key string) (string, error) { + return r.get(ctx, key) +} + +func TestCodexTicketHarvesterStopCancelsInFlightWork(t *testing.T) { + for _, stage := range []string{"settings-enabled", "settings-proxy", "accounts", "upstream", "persist"} { + t.Run(stage, func(t *testing.T) { + started := make(chan struct{}) + cancelled := make(chan struct{}) + var once sync.Once + block := func(ctx context.Context) error { + once.Do(func() { close(started) }) + <-ctx.Done() + close(cancelled) + return ctx.Err() + } + account := ticketTestAccount(41) + account.Status = StatusActive + repo := &codexTicketLifecycleRepo{account: *account} + upstream := &codexTicketFuncUpstream{do: func(req *http.Request) (*http.Response, error) { + if stage == "upstream" { + return nil, block(req.Context()) + } + return codexTicketResponse(), nil + }} + svc := ticketTestService(t, config.OpenAICodexTicketConfig{Enabled: true, HarvestProxyURL: "http://proxy.example.com:8080", HarvestAttemptTimeoutSeconds: 25, Models: []string{"gpt-6-astra"}}, upstream) + svc.accountRepo = repo + if stage == "accounts" { + repo.list = func(ctx context.Context) ([]Account, error) { return nil, block(ctx) } + } + if stage == "persist" { + repo.persist = block + } + if strings.HasPrefix(stage, "settings-") { + svc.settingService = NewSettingService(&codexTicketLifecycleSettings{get: func(ctx context.Context, key string) (string, error) { + if stage == "settings-enabled" && key == SettingKeyOpenAICodexTicketEnabled || stage == "settings-proxy" && key == SettingKeyOpenAICodexTicketHarvestProxyURL { + return "", block(ctx) + } + if key == SettingKeyOpenAICodexTicketEnabled { + return "true", nil + } + return "", ErrSettingNotFound + }}, svc.cfg) + } + svc.StartOpenAICodexTicketHarvester() + t.Cleanup(svc.StopOpenAICodexTicketHarvester) + select { + case <-started: + case <-time.After(3 * time.Second): + t.Fatal("harvester did not reach " + stage) + } + // Repeated start must not create a second loop or overwrite the cancellation state. + svc.StartOpenAICodexTicketHarvester() + stopped := make(chan struct{}) + go func() { svc.StopOpenAICodexTicketHarvester(); close(stopped) }() + select { + case <-stopped: + case <-time.After(time.Second): + t.Fatal("stop waited for the probe timeout") + } + select { + case <-cancelled: + case <-time.After(time.Second): + t.Fatal("in-flight operation did not receive cancellation") + } + svc.StopOpenAICodexTicketHarvester() + svc.StartOpenAICodexTicketHarvester() + }) + } +} + +type codexTicketHeaderOnlyBody struct{ reads, closes int } + +func (b *codexTicketHeaderOnlyBody) Read([]byte) (int, error) { b.reads++; return 0, io.EOF } +func (b *codexTicketHeaderOnlyBody) Close() error { b.closes++; return nil } +func TestCodexTicketProbeClosesStreamWithoutDraining(t *testing.T) { + body := &codexTicketHeaderOnlyBody{} + svc := ticketTestService(t, config.OpenAICodexTicketConfig{}, &codexTicketFuncUpstream{do: func(*http.Request) (*http.Response, error) { + response := codexTicketResponse() + response.Body = body + return response, nil + }}) + _, _, err := svc.fireOpenAICodexTicketProbe(context.Background(), ticketTestAccount(41), "test-token", "gpt-6-astra", "", time.Second) + require.NoError(t, err) + require.Zero(t, body.reads) + require.Equal(t, 1, body.closes) +} + +func TestCodexTicketPolicyExemptsCredentialShadows(t *testing.T) { + parentID := int64(41) + parent := ticketTestAccount(parentID) + shadow := ticketTestAccount(42) + shadow.ParentAccountID = &parentID + shadow.Status = StatusActive + cfg := config.OpenAICodexTicketConfig{Enabled: true, FailClosed: true, HarvestProxyURL: "http://proxy.example.com:8080"} + upstream := &httpUpstreamRecorder{} + svc := ticketTestService(t, cfg, upstream) + svc.accountRepo = &codexTicketRefreshRepo{accounts: []Account{*shadow}} + require.True(t, svc.openAICodexTicketBlocksAccount(parent, "gpt-6-astra")) + for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} { + shadow.Type = accountType + require.False(t, svc.openAICodexTicketBlocksAccount(shadow, "gpt-6-astra")) + headers := http.Header{} + headers.Set(openAICodexTurnStateHeader, "client-state") + require.NoError(t, svc.applyOpenAICodexTicket(context.Background(), shadow, "gpt-6-astra", headers)) + require.Equal(t, "client-state", headers.Get(openAICodexTurnStateHeader)) + require.Empty(t, OpenAICodexTicketStatuses(shadow, cfg, time.Now())) + svc.probeOnceOpenAICodexTicket(context.Background(), shadow, "gpt-6-astra") + } + svc.refreshOpenAICodexTickets(context.Background()) + require.Empty(t, upstream.requests) +} diff --git a/backend/internal/service/openai_codex_ticket_test.go b/backend/internal/service/openai_codex_ticket_test.go new file mode 100644 index 000000000000..1def1b28de6a --- /dev/null +++ b/backend/internal/service/openai_codex_ticket_test.go @@ -0,0 +1,450 @@ +package service + +import ( + "context" + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func fakeCodexTicketState(n int) string { + if n < len(openAICodexTicketStatePrefix) { + return strings.Repeat("A", n) + } + return openAICodexTicketStatePrefix + strings.Repeat("B", n-len(openAICodexTicketStatePrefix)) +} + +func ticketTestAccount(id int64) *Account { + return &Account{ + ID: id, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{"access_token": "tok", "chatgpt_account_id": "acc-1"}, + } +} + +func ticketTestService(t *testing.T, cfg config.OpenAICodexTicketConfig, upstream HTTPUpstream) *OpenAIGatewayService { + t.Helper() + return &OpenAIGatewayService{ + cfg: &config.Config{ + Gateway: config.GatewayConfig{OpenAICodexTicket: cfg}, + }, + httpUpstream: upstream, + } +} + +func TestApplyOpenAICodexTicket_ReplacesHeader(t *testing.T) { + state := fakeCodexTicketState(292) + svc := ticketTestService(t, config.OpenAICodexTicketConfig{ + Enabled: true, + TargetLength: 292, + TTLSeconds: 3600, + FailClosed: true, + }, nil) + account := ticketTestAccount(41) + svc.storeOpenAICodexTicket(context.Background(), account, &openAICodexTicket{ + AccountID: 41, + Model: "gpt-6-astra", + State: state, + Length: 292, + CapturedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Hour), + }) + + h := http.Header{} + h.Set(openAICodexTurnStateHeader, fakeCodexTicketState(312)) + err := svc.applyOpenAICodexTicket(context.Background(), account, "gpt-6-astra", h) + require.NoError(t, err) + require.Equal(t, state, h.Get(openAICodexTurnStateHeader)) + require.Equal(t, 292, len(h.Get(openAICodexTurnStateHeader))) +} + +func TestApplyOpenAICodexTicket_DoesNotReuseOtherModelOrAccount(t *testing.T) { + svc := ticketTestService(t, config.OpenAICodexTicketConfig{ + Enabled: true, + TargetLength: 292, + TTLSeconds: 3600, + FailClosed: true, + HarvestProxyURL: "socks5h://harvest", + }, &httpUpstreamRecorder{err: io.EOF}) + a := ticketTestAccount(41) + b := ticketTestAccount(42) + astra := fakeCodexTicketState(292) + svc.storeOpenAICodexTicket(context.Background(), a, &openAICodexTicket{ + AccountID: 41, + Model: "gpt-6-astra", + State: astra, + Length: 292, + CapturedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Hour), + }) + + h := http.Header{} + h.Set(openAICodexTurnStateHeader, "keep-ungated") + err := svc.applyOpenAICodexTicket(context.Background(), a, "gpt-5.5", h) + require.NoError(t, err) + require.Equal(t, "keep-ungated", h.Get(openAICodexTurnStateHeader)) + require.False(t, svc.openAICodexTicketBlocksAccount(a, "gpt-5.5")) + require.True(t, svc.openAICodexTicketBlocksAccount(b, "gpt-6-astra")) + require.False(t, svc.openAICodexTicketBlocksAccount(a, "gpt-6-astra")) + + h = http.Header{} + err = svc.applyOpenAICodexTicket(context.Background(), b, "gpt-6-astra", h) + require.ErrorIs(t, err, ErrOpenAICodexTicketUnavailable) + require.Empty(t, h.Get(openAICodexTurnStateHeader)) +} + +func TestLookupOpenAICodexTicket_PrefersNewerExtra(t *testing.T) { + svc := ticketTestService(t, config.OpenAICodexTicketConfig{Enabled: true, TargetLength: 292, TTLSeconds: 3600}, nil) + account := ticketTestAccount(41) + oldState := fakeCodexTicketState(292) + newState := openAICodexTicketStatePrefix + strings.Repeat("C", 286) + svc.storeOpenAICodexTicket(context.Background(), account, &openAICodexTicket{ + AccountID: 41, + Model: "gpt-6-astra", + State: oldState, + Length: 292, + CapturedAt: time.Now().Add(-30 * time.Minute), + ExpiresAt: time.Now().Add(-time.Minute), + }) + account.Extra = map[string]any{openAICodexTicketExtraKey("gpt-6-astra"): &openAICodexTicket{ + Model: "gpt-6-astra", + State: newState, + Length: 292, + CapturedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Hour), + }, + } + got := svc.lookupOpenAICodexTicket(account, "gpt-6-astra") + require.NotNil(t, got) + require.Equal(t, newState, got.State) + require.True(t, got.valid(time.Now(), 292)) +} + +func TestApplyOpenAICodexTicket_ExpiredNotInjected(t *testing.T) { + svc := ticketTestService(t, config.OpenAICodexTicketConfig{ + Enabled: true, + TargetLength: 292, + TTLSeconds: 3600, + FailClosed: true, + HarvestProxyURL: "socks5h://harvest", + }, &httpUpstreamRecorder{err: io.EOF}) + account := ticketTestAccount(41) + svc.storeOpenAICodexTicket(context.Background(), account, &openAICodexTicket{ + AccountID: 41, + Model: "gpt-6-astra", + State: fakeCodexTicketState(292), + Length: 292, + CapturedAt: time.Now().Add(-2 * time.Hour), + ExpiresAt: time.Now().Add(-time.Minute), + }) + h := http.Header{} + err := svc.applyOpenAICodexTicket(context.Background(), account, "gpt-6-astra", h) + require.ErrorIs(t, err, ErrOpenAICodexTicketUnavailable) + require.Empty(t, h.Get(openAICodexTurnStateHeader)) +} + +func TestApplyOpenAICodexTicket_WrongLengthNotInjected(t *testing.T) { + svc := ticketTestService(t, config.OpenAICodexTicketConfig{ + Enabled: true, + TargetLength: 292, + TTLSeconds: 3600, + FailClosed: true, + }, nil) + account := ticketTestAccount(41) + svc.storeOpenAICodexTicket(context.Background(), account, &openAICodexTicket{ + AccountID: 41, + Model: "gpt-6-astra", + State: fakeCodexTicketState(312), + Length: 312, + CapturedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Hour), + }) + h := http.Header{} + err := svc.applyOpenAICodexTicket(context.Background(), account, "gpt-6-astra", h) + require.ErrorIs(t, err, ErrOpenAICodexTicketUnavailable) + require.Empty(t, h.Get(openAICodexTurnStateHeader)) +} + +func TestApplyOpenAICodexTicket_FailOpenSkipsInject(t *testing.T) { + svc := ticketTestService(t, config.OpenAICodexTicketConfig{ + Enabled: true, + FailClosed: false, + }, nil) + h := http.Header{} + h.Set(openAICodexTurnStateHeader, "client-state") + err := svc.applyOpenAICodexTicket(context.Background(), ticketTestAccount(41), "gpt-6-astra", h) + require.NoError(t, err) + require.Equal(t, "client-state", h.Get(openAICodexTurnStateHeader)) + require.False(t, svc.openAICodexTicketBlocksAccount(ticketTestAccount(41), "gpt-6-astra")) +} + +func TestApplyOpenAICodexTicket_DisabledNoop(t *testing.T) { + svc := ticketTestService(t, config.OpenAICodexTicketConfig{Enabled: false, FailClosed: true}, nil) + h := http.Header{} + h.Set(openAICodexTurnStateHeader, "client-state") + err := svc.applyOpenAICodexTicket(context.Background(), ticketTestAccount(41), "gpt-6-astra", h) + require.NoError(t, err) + require.Equal(t, "client-state", h.Get(openAICodexTurnStateHeader)) +} + +func TestHarvestOpenAICodexTicket_StopsAt292AndUsesHarvestProxy(t *testing.T) { + state312 := fakeCodexTicketState(312) + state292 := fakeCodexTicketState(292) + header312 := http.Header{} + header312.Set(openAICodexTurnStateHeader, state312) + header292 := http.Header{} + header292.Set(openAICodexTurnStateHeader, state292) + upstream := &httpUpstreamRecorder{ + responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: header312, + Body: io.NopCloser(strings.NewReader("data: {}\n\n")), + }, + { + StatusCode: http.StatusOK, + Header: header292, + Body: io.NopCloser(strings.NewReader("data: {}\n\n")), + }, + }, + } + svc := ticketTestService(t, config.OpenAICodexTicketConfig{ + Enabled: true, + TargetLength: 292, + TTLSeconds: 3600, + HarvestProxyURL: "socks5h://user:pass@harvest.example:31", + HarvestAttemptTimeoutSeconds: 5, + FailClosed: true, + }, upstream) + account := ticketTestAccount(41) + + svc.probeOnceOpenAICodexTicket(context.Background(), account, "gpt-6-astra") + require.Nil(t, svc.lookupOpenAICodexTicket(account, "gpt-6-astra")) + svc.probeOnceOpenAICodexTicket(context.Background(), account, "gpt-6-astra") + ticket := svc.lookupOpenAICodexTicket(account, "gpt-6-astra") + require.NotNil(t, ticket) + require.Equal(t, state292, ticket.State) + h := http.Header{} + h.Set(openAICodexTurnStateHeader, "stale") + require.NoError(t, svc.applyOpenAICodexTicket(context.Background(), account, "gpt-6-astra", h)) + require.Equal(t, state292, h.Get(openAICodexTurnStateHeader)) + require.Equal(t, "socks5h://user:pass@harvest.example:31", upstream.lastProxyURL) + require.Len(t, upstream.requests, 2) + require.Empty(t, upstream.requests[0].Header.Get(openAICodexTurnStateHeader)) + require.Equal(t, openAICodexAstraMinVersion, upstream.requests[0].Header.Get("version")) + require.Equal(t, HTTPUpstreamProfileOpenAIHarvest, HTTPUpstreamProfileFromContext(upstream.requests[0].Context())) + require.True(t, upstream.requests[0].Close) +} + +func TestHarvestOpenAICodexTicket_HTTP503DoesNotAbortHunt(t *testing.T) { + state292 := fakeCodexTicketState(292) + header503 := http.Header{} + header292 := http.Header{} + header292.Set(openAICodexTurnStateHeader, state292) + responses := make([]*http.Response, 0, 3) + responses = append(responses, &http.Response{ + StatusCode: http.StatusServiceUnavailable, + Header: header503, + Body: io.NopCloser(strings.NewReader(`{"error":"overloaded"}`)), + }) + responses = append(responses, &http.Response{ + StatusCode: http.StatusOK, + Header: header292, + Body: io.NopCloser(strings.NewReader("data: {}\n\n")), + }) + upstream := &httpUpstreamRecorder{responses: responses} + svc := ticketTestService(t, config.OpenAICodexTicketConfig{ + Enabled: true, + TargetLength: 292, + TTLSeconds: 3600, + HarvestProxyURL: "socks5h://harvest.example:31", + HarvestAttemptTimeoutSeconds: 5, + FailClosed: true, + }, upstream) + account := ticketTestAccount(41) + svc.probeOnceOpenAICodexTicket(context.Background(), account, "gpt-6-astra") + require.Nil(t, svc.lookupOpenAICodexTicket(account, "gpt-6-astra")) + svc.probeOnceOpenAICodexTicket(context.Background(), account, "gpt-6-astra") + ticket := svc.lookupOpenAICodexTicket(account, "gpt-6-astra") + require.NotNil(t, ticket) + require.Equal(t, state292, ticket.State) + require.Len(t, upstream.requests, 2) +} + +func TestLookupOpenAICodexTicket_HydratesFromExtra(t *testing.T) { + svc := ticketTestService(t, config.OpenAICodexTicketConfig{Enabled: true, TargetLength: 292, TTLSeconds: 3600}, nil) + state := fakeCodexTicketState(292) + account := ticketTestAccount(9) + account.Extra = map[string]any{ + openAICodexTicketExtraKey("gpt-6-astra"): map[string]any{ + "state": state, + "length": 292, + "model": "gpt-6-astra", + "captured_at": time.Now().Add(-time.Minute), + "expires_at": time.Now().Add(time.Hour), + }, + } + got := svc.lookupOpenAICodexTicket(account, "gpt-6-astra") + require.NotNil(t, got) + require.Equal(t, state, got.State) + require.True(t, got.valid(time.Now(), 292)) +} + +func TestOpenAICodexTicketStatuses_ReportsRemainingTTL(t *testing.T) { + account := ticketTestAccount(41) + account.Extra = map[string]any{ + openAICodexTicketExtraKey("gpt-6-astra"): map[string]any{ + "state": fakeCodexTicketState(292), + "length": 292, + "model": "gpt-6-astra", + "captured_at": time.Now().Add(-10 * time.Minute), + "expires_at": time.Now().Add(50 * time.Minute), + }, + } + now := time.Now() + got := OpenAICodexTicketStatuses(account, config.OpenAICodexTicketConfig{Enabled: true, FailClosed: true}, now) + require.Len(t, got, 2) + require.Equal(t, "gpt-6-astra", got[0].Model) + require.True(t, got[0].Ready) + require.Greater(t, got[0].RemainingSeconds, int64(40*60)) + require.LessOrEqual(t, got[0].RemainingSeconds, int64(50*60)) + require.Equal(t, "gpt-5.6-sol", got[1].Model) + require.False(t, got[1].Ready) +} + +func TestExtractOpenAICodexTicketModel(t *testing.T) { + require.Equal(t, "gpt-6-astra", extractOpenAICodexTicketModel([]byte(`{"model":"gpt-6-astra"}`))) + require.Empty(t, extractOpenAICodexTicketModel([]byte(`{}`))) +} + +// These stubs exercise the real continuous refresh path with both default models +// completing together. Run under -race to catch writes to the shared account maps. +type codexTicketRefreshRepo struct { + AccountRepository + accounts []Account + mu sync.Mutex + updates map[string]any +} + +func (r *codexTicketRefreshRepo) ListByPlatform(context.Context, string) ([]Account, error) { + return r.accounts, nil +} +func (r *codexTicketRefreshRepo) UpdateExtra(_ context.Context, _ int64, updates map[string]any) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.updates == nil { + r.updates = make(map[string]any) + } + for k, v := range updates { + r.updates[k] = v + } + return nil +} + +type codexTicketConcurrentUpstream struct { + HTTPUpstream + started atomic.Int64 + ready chan struct{} +} + +func (u *codexTicketConcurrentUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + if u.started.Add(1) == 2 { + close(u.ready) + } + select { + case <-u.ready: + case <-req.Context().Done(): + return nil, req.Context().Err() + } + h := http.Header{} + h.Set(openAICodexTurnStateHeader, fakeCodexTicketState(292)) + return &http.Response{StatusCode: 200, Header: h, Body: io.NopCloser(strings.NewReader("data: {}\n\n"))}, nil +} +func TestRefreshOpenAICodexTickets_ConcurrentModelsPreserveAccountSnapshot(t *testing.T) { + account := ticketTestAccount(41) + account.Status = StatusActive + account.Extra = map[string]any{"existing": true} + repo := &codexTicketRefreshRepo{accounts: []Account{*account}} + upstream := &codexTicketConcurrentUpstream{ready: make(chan struct{})} + svc := ticketTestService(t, config.OpenAICodexTicketConfig{Enabled: true, HarvestProxyURL: "socks5h://proxy.example.com:1080"}, upstream) + svc.accountRepo = repo + svc.refreshOpenAICodexTickets(context.Background()) + require.Equal(t, int64(2), upstream.started.Load()) + require.Equal(t, map[string]any{"existing": true}, account.Extra) + require.Len(t, repo.updates, 2) + for _, model := range []string{openAICodexTicketDefaultModel, openAICodexTicketDefaultSolModel} { + ticket := svc.lookupOpenAICodexTicket(account, model) + require.NotNil(t, ticket) + require.True(t, ticket.valid(time.Now(), 292)) + } + // Valid tickets do not produce another probe on the next cycle. + svc.refreshOpenAICodexTickets(context.Background()) + require.Equal(t, int64(2), upstream.started.Load()) +} +func TestOpenAICodexTicketStatuses_RespectRuntimeConfiguration(t *testing.T) { + account := ticketTestAccount(41) + require.Empty(t, OpenAICodexTicketStatuses(account, config.OpenAICodexTicketConfig{}, time.Now())) + cfg := config.OpenAICodexTicketConfig{Enabled: true, Models: []string{"custom-model"}} + status := OpenAICodexTicketStatuses(account, cfg, time.Now()) + require.Len(t, status, 1) + require.Equal(t, "custom-model", status[0].Model) + require.False(t, status[0].Blocked) + cfg.FailClosed = true + require.True(t, OpenAICodexTicketStatuses(account, cfg, time.Now())[0].Blocked) +} +func TestProbeOpenAICodexTicket_RejectsInvalidState(t *testing.T) { + for _, state := range []string{fakeCodexTicketState(312), strings.Repeat("X", 292), ""} { + h := http.Header{} + h.Set(openAICodexTurnStateHeader, state) + upstream := &httpUpstreamRecorder{responses: []*http.Response{{StatusCode: 200, Header: h, Body: io.NopCloser(strings.NewReader(""))}}} + svc := ticketTestService(t, config.OpenAICodexTicketConfig{Enabled: true, HarvestProxyURL: "http://proxy.example.com:8080"}, upstream) + account := ticketTestAccount(41) + svc.probeOnceOpenAICodexTicket(context.Background(), account, "gpt-6-astra") + require.Nil(t, svc.lookupOpenAICodexTicket(account, "gpt-6-astra")) + } +} +func TestOpenAICodexTicket_RequiresActualLengthAndExpiry(t *testing.T) { + ticket := &openAICodexTicket{State: fakeCodexTicketState(312), Length: 292, ExpiresAt: time.Now().Add(time.Hour)} + require.False(t, ticket.valid(time.Now(), 292)) + ticket.State = fakeCodexTicketState(292) + ticket.ExpiresAt = time.Time{} + require.False(t, ticket.valid(time.Now(), 292)) +} + +// /responses/compact 的出站模型被 Forward 改写为 gateway.openai_compact_model +// (默认非空),门票门控必须按该出站模型判定。否则对门控模型发 compact 请求时, +// 所有无票账号都会被 fail_closed 误判为不可调度,而这些请求实际不需要票。 +func TestOpenAICodexTicketGate_CompactRequestUsesForwardOutboundModel(t *testing.T) { + svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{ + OpenAICompactModel: "gpt-5.5", + OpenAICodexTicket: config.OpenAICodexTicketConfig{ + Enabled: true, + TargetLength: 292, + TTLSeconds: 3600, + FailClosed: true, + Models: []string{"gpt-6-astra"}, + }, + }}} + account := ticketTestAccount(41) // 无票 + + // 出站模型预测必须与 Forward 的解析链一致。 + require.Equal(t, "gpt-6-astra", svc.openAICodexTicketOutboundModel(account, "gpt-6-astra", false)) + require.Equal(t, "gpt-5.5", svc.openAICodexTicketOutboundModel(account, "gpt-6-astra", true)) + + // 普通请求:出站仍是门控模型且无票 → fail_closed 必须拦号。 + require.True(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-6-astra", false)) + + // compact 请求:出站已被改写成非门控的 gpt-5.5 → 不得拦号。 + require.False(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-6-astra", true)) + + // 回归锚点:按客户端原始模型判定(旧实现的口径)在 compact 下必然误拦。 + require.True(t, svc.openAICodexTicketBlocksAccount(account, canonicalOpenAIAccountSchedulingModel(account, "gpt-6-astra"))) +} diff --git a/backend/internal/service/openai_codex_turn_state.go b/backend/internal/service/openai_codex_turn_state.go index 774041f18a9e..88c38c7f55a5 100644 --- a/backend/internal/service/openai_codex_turn_state.go +++ b/backend/internal/service/openai_codex_turn_state.go @@ -156,9 +156,8 @@ func (s *OpenAIGatewayService) commitOpenAIWSSessionTurnState( // guardOpenAICodexTurnStateEcho 出站守卫:客户端回带的 turn-state 只有在 // 能证明由当前账号铸造时才保留。来源未知、过期、缺少会话标识或来自其他 -// 账号的值均剥离。只剥离、不注入—— -// /responses 路径的客户端是真实 Codex,会按自身回合语义自行回带;服务端 -// 注入是 Claude 兼容桥(无法回带的客户端)的专属行为。 +// 账号的值均剥离。此守卫只处理回显;开启票据功能后,后续注入步骤会 +// 按当前 OpenAI OAuth/Setup Token 账号和出站模型覆盖为有效的服务端票据。 func (s *OpenAIGatewayService) guardOpenAICodexTurnStateEcho(c *gin.Context, account *Account, h http.Header) { if h == 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 0b0bc2629624..2cc1ac0476e9 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -205,6 +205,11 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( if err != nil { return nil, err } + upstreamBody, err = normalizeStrictChatDeveloperRoles(account, targetURL, upstreamBody) + if err != nil { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, err + } SetActualOpenAIUpstreamEndpoint(c, grokChatRawEndpoint) customUA := account.GetOpenAIUserAgent() if customUA == "" && account.IsGrokOAuth() { diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 574b5c0673c0..0d15da640174 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -1616,6 +1616,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. // 客户端回带的 x-codex-turn-state 若已知由其他账号铸造(failover 换号), // 剥离后再出站——异账号 blob 与本账号的(指纹收敛后)出站身份自相矛盾。 s.guardOpenAICodexTurnStateEcho(c, account, req.Header) + if err := s.applyOpenAICodexTicket(ctx, account, extractOpenAICodexTicketModel(body), req.Header); err != nil { + return nil, err + } if account.UsesOpenAICodexProtocol() { compatMessagesBridge := isOpenAICompatMessagesBridgeContext(c) || isOpenAICompatMessagesBridgeBody(body) // 真实 Codex 只发送连字符形式的 session-id / thread-id;下划线形式的 diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index d190889dac7d..378eb0b9348e 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -415,6 +415,9 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( if compatTurnState != "" && upstreamReq.Header.Get("x-codex-turn-state") == "" { upstreamReq.Header.Set("x-codex-turn-state", compatTurnState) } + if err := s.applyOpenAICodexTicket(ctx, account, upstreamModel, upstreamReq.Header); err != nil { + return nil, err + } // 7. Send request proxyURL := "" diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index 4e9b05e0ac30..282a65afcec3 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -827,6 +827,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( // 客户端回带的 x-codex-turn-state 若已知由其他账号铸造(failover 换号), // 剥离后再出站(openai_codex_turn_state.go)。 s.guardOpenAICodexTurnStateEcho(c, account, req.Header) + if err := s.applyOpenAICodexTicket(ctx, account, extractOpenAICodexTicketModel(body), req.Header); err != nil { + return nil, err + } // 覆盖入站鉴权残留,并注入上游认证 req.Header.Del("authorization") diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 79b1e12bdc90..7272a42c819e 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -11,6 +11,7 @@ import ( "strings" "unicode/utf8" + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" @@ -191,7 +192,25 @@ func normalizeDeepSeekResponsesRequestBody(account *Account, body []byte) []byte if stripped, err := sjson.DeleteBytes(normalized, "previous_response_id"); err == nil { normalized = stripped } - return normalized + + var requestBody map[string]any + if err := decodeOpenAIJSONUseNumber(normalized, &requestBody); err != nil { + return normalized + } + input, exists := requestBody["input"] + if !exists { + return normalized + } + liftedInput, changed := apicompat.LiftResponsesToolOutputMedia(input) + if !changed { + return normalized + } + requestBody["input"] = liftedInput + rebuilt, err := marshalOpenAIUpstreamJSON(requestBody) + if err != nil { + return normalized + } + return rebuilt } func trimOpenAIEncryptedReasoningItems(reqBody map[string]any) bool { diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index a0dfa553fdc4..6386a9fc5b76 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -1652,13 +1652,35 @@ func (s *OpenAIGatewayService) bindHTTPResponseAccount(ctx context.Context, c *g if store == nil { return } + // The client may close the stream immediately after receiving its terminal + // event, canceling the request context before these durable affinity writes + // run. Preserve request values, but give all Redis writes one bounded budget + // independent of the downstream connection lifecycle. + bindBaseCtx := context.Background() + if ctx != nil { + bindBaseCtx = context.WithoutCancel(ctx) + } + bindCtx, cancel := context.WithTimeout(bindBaseCtx, openAIWSStateStoreRedisTimeout) + defer cancel() + groupID := getOpenAIGroupIDFromContext(c) ttl := s.openAIWSResponseStickyTTL() - logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, store.BindResponseAccount(ctx, groupID, responseID, account.ID, ttl)) - if c != nil { - if rawOwner, ok := c.Get(openAIHTTPResponseOwnerContextKey); ok { - if owner, ok := rawOwner.(openAIHTTPResponseOwner); ok && owner.userID > 0 && owner.apiKeyID > 0 { - _ = s.BindOpenAIHTTPResponseOwner(ctx, groupID, responseID, owner.userID, owner.apiKeyID) + logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, store.BindResponseAccount(bindCtx, groupID, responseID, account.ID, ttl)) + if c == nil { + return + } + if rawOwner, ok := c.Get(openAIHTTPResponseOwnerContextKey); ok { + if owner, ok := rawOwner.(openAIHTTPResponseOwner); ok && owner.userID > 0 && owner.apiKeyID > 0 { + if err := s.BindOpenAIHTTPResponseOwner(bindCtx, groupID, responseID, owner.userID, owner.apiKeyID); err != nil { + logger.L().Warn( + "openai.http_bind_response_owner_failed", + zap.Int64("group_id", groupID), + zap.Int64("account_id", account.ID), + zap.Int64("user_id", owner.userID), + zap.Int64("api_key_id", owner.apiKeyID), + zap.String("response_id", truncateOpenAIWSLogValue(responseID, openAIWSIDValueMaxLen)), + zap.Error(err), + ) } } } diff --git a/backend/internal/service/openai_gateway_scheduling.go b/backend/internal/service/openai_gateway_scheduling.go index 93b841c10367..aea1c6abe471 100644 --- a/backend/internal/service/openai_gateway_scheduling.go +++ b/backend/internal/service/openai_gateway_scheduling.go @@ -999,7 +999,7 @@ func (s *OpenAIGatewayService) tryStickySessionHit(ctx context.Context, groupID _ = s.deleteStickySessionAccountIDIfMatches(ctx, groupID, sessionHash, accountID) return nil } - if s.isOpenAIAccountRequestRuntimeBlockedContext(ctx, account, accountRequestedModel) { + if s.isOpenAIAccountRequestRuntimeBlockedContext(ctx, account, accountRequestedModel, requireCompact) { // Runtime blocks and half-open probe ownership are temporary. Preserve the // sticky binding until replacement or permanent invalidation. return nil @@ -1229,7 +1229,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex _ = s.deleteStickySessionAccountIDIfMatches(ctx, groupID, sessionHash, accountID) } else if !s.openAIAccountMatchesSchedulingGroup(account, groupID) { _ = s.deleteStickySessionAccountIDIfMatches(ctx, groupID, sessionHash, accountID) - } else if s.isOpenAIAccountRequestRuntimeBlockedContext(ctx, account, accountRequestedModel) { + } else if s.isOpenAIAccountRequestRuntimeBlockedContext(ctx, account, accountRequestedModel, requireCompact) { // Keep temporary runtime-blocked sticky bindings. } else if needsUpstreamCheck && s.isUpstreamModelRestrictedByChannel(ctx, *groupID, account, accountRequestedModel, requireCompact) { _ = s.deleteStickySessionAccountIDIfMatches(ctx, groupID, sessionHash, accountID) @@ -1298,7 +1298,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex continue } accountRequestedModel := openAIRequestedModelForAccount(ctx, acc, requestedModel) - if s.isOpenAIAccountRequestRuntimeBlockedContext(ctx, acc, accountRequestedModel) { + if s.isOpenAIAccountRequestRuntimeBlockedContext(ctx, acc, accountRequestedModel, requireCompact) { filterStats.exclude("runtime_blocked") filterStats.observeRuntimeCooldown(s, acc.ID) continue @@ -1583,7 +1583,7 @@ func (s *OpenAIGatewayService) resolveFreshSchedulableOpenAIAccountBeforeProfit( if !parentHealthyForShadow(fresh, s.parentAccountLookup(ctx)) { return nil } - if s.isOpenAIAccountRequestRuntimeBlockedContext(ctx, fresh, openAIRequestedModelForAccount(ctx, fresh, requestedModel)) { + if s.isOpenAIAccountRequestRuntimeBlockedContext(ctx, fresh, openAIRequestedModelForAccount(ctx, fresh, requestedModel), requireCompact) { return nil } if s.isOpenAIAccountBlockedBySchedulingThreshold(ctx, fresh) { @@ -1654,7 +1654,7 @@ func (s *OpenAIGatewayService) recheckSelectedOpenAIAccountFromDBBeforeProfit(ct if !parentHealthyForShadow(latest, s.parentAccountLookup(ctx)) { return nil } - if s.isOpenAIAccountRequestRuntimeBlockedContext(ctx, latest, openAIRequestedModelForAccount(ctx, latest, requestedModel)) { + if s.isOpenAIAccountRequestRuntimeBlockedContext(ctx, latest, openAIRequestedModelForAccount(ctx, latest, requestedModel), requireCompact) { return nil } if s.isOpenAIAccountBlockedBySchedulingThreshold(ctx, latest) { diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 65bde6949684..73752c212650 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -24,6 +24,7 @@ import ( "github.com/cespare/xxhash/v2" "github.com/gin-gonic/gin" "go.uber.org/zap" + "golang.org/x/sync/singleflight" ) const ( @@ -529,6 +530,13 @@ type OpenAIGatewayService struct { // 剥离跨账号回带(openai_codex_turn_state.go)。 openaiCodexTurnStateOrigins sync.Map openaiCodexTurnStateWrites atomic.Uint64 + // openaiCodexTickets: accountID\x00model → *openAICodexTicket,292 长度门票。 + openaiCodexTickets sync.Map + openaiCodexTicketFlight singleflight.Group + openaiCodexTicketLifecycleMu sync.Mutex + openaiCodexTicketCancel context.CancelFunc + openaiCodexTicketDone chan struct{} + openaiCodexTicketStopped bool } // SetBusinessSystemPromptService attaches the global business prompt policy to @@ -640,6 +648,7 @@ func NewOpenAIGatewayService( openAITokenProvider.SetAccountRuntimeBlocker(svc) } svc.logOpenAIWSModeBootstrap() + svc.StartOpenAICodexTicketHarvester() return svc } diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index 1945f9d11724..6f91b4be5a63 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -589,6 +589,37 @@ func TestOpenAIGatewayService_BindHTTPResponseAccount(t *testing.T) { require.False(t, owned) } +func TestOpenAIGatewayService_BindHTTPResponseAccount_DetachesCanceledRequestContext(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) + groupID := int64(4201) + c.Set("api_key", &APIKey{ID: 501, GroupID: &groupID}) + SetOpenAIHTTPResponseOwner(c, 601, 501) + + cache := &responseBindContextProbeCache{} + svc := &OpenAIGatewayService{cache: cache} + account := &Account{ID: 37001, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + requestCtx, cancelRequest := context.WithCancel(context.Background()) + cancelRequest() + startedAt := time.Now() + + svc.bindHTTPResponseAccount(requestCtx, c, account, "resp_http_canceled_001") + + require.Len(t, cache.setContextErrors, 3) + for _, contextErr := range cache.setContextErrors { + require.NoError(t, contextErr, "response affinity writes must survive downstream cancellation") + } + require.Len(t, cache.setDeadlines, 3) + for _, deadline := range cache.setDeadlines { + require.True(t, deadline.After(startedAt)) + require.LessOrEqual(t, deadline.Sub(startedAt), openAIWSStateStoreRedisTimeout+100*time.Millisecond) + } + require.Len(t, cache.sessionBindings, 3) + require.Contains(t, cache.sessionBindings, openAIWSResponseAccountCacheKey("resp_http_canceled_001")) +} + func TestOpenAIGatewayService_GenerateExplicitSessionHash_SkipsContentFallback(t *testing.T) { gin.SetMode(gin.TestMode) svc := &OpenAIGatewayService{} @@ -710,6 +741,23 @@ type stubGatewayCache struct { deletedSessions map[string]int } +type responseBindContextProbeCache struct { + stubGatewayCache + setContextErrors []error + setDeadlines []time.Time +} + +func (c *responseBindContextProbeCache) SetSessionAccountID(ctx context.Context, groupID int64, sessionHash string, accountID int64, ttl time.Duration) error { + c.setContextErrors = append(c.setContextErrors, ctx.Err()) + if deadline, ok := ctx.Deadline(); ok { + c.setDeadlines = append(c.setDeadlines, deadline) + } + if err := ctx.Err(); err != nil { + return err + } + return c.stubGatewayCache.SetSessionAccountID(ctx, groupID, sessionHash, accountID, ttl) +} + func (c *stubGatewayCache) GetSessionAccountID(ctx context.Context, groupID int64, sessionHash string) (int64, error) { if id, ok := c.sessionBindings[sessionHash]; ok { return id, nil diff --git a/backend/internal/service/openai_guardian_affinity_test.go b/backend/internal/service/openai_guardian_affinity_test.go index 4901b366476f..709071bc1f80 100644 --- a/backend/internal/service/openai_guardian_affinity_test.go +++ b/backend/internal/service/openai_guardian_affinity_test.go @@ -276,7 +276,7 @@ func TestOpenAIGatewayService_GuardianParentAffinityHonorsRequiredPrivacy(t *tes require.NotNil(t, selection) require.Equal(t, int64(39032), selection.Account.ID) require.Zero(t, repo.setErrorCalls, "a group-scoped privacy gate must not globally error a shared account") - require.False(t, svc.isOpenAIAccountRequestRuntimeBlocked(&accounts[0], codexAutoReviewModel)) + require.False(t, svc.isOpenAIAccountRequestRuntimeBlocked(&accounts[0], codexAutoReviewModel, false)) if selection.ReleaseFunc != nil { selection.ReleaseFunc() } @@ -378,7 +378,7 @@ func TestOpenAIGatewayService_PreviousResponseHonorsGroupAndRequiredPrivacy(t *t require.Equal(t, fallback.ID, selection.Account.ID) require.NotEqual(t, openAIAccountScheduleLayerPreviousResponse, decision.Layer) require.Zero(t, repo.setErrorCalls) - require.False(t, svc.isOpenAIAccountRequestRuntimeBlocked(&accounts[0], codexAutoReviewModel)) + require.False(t, svc.isOpenAIAccountRequestRuntimeBlocked(&accounts[0], codexAutoReviewModel, false)) boundAccountID, getErr := store.GetResponseAccount(context.Background(), groupID, responseID) require.NoError(t, getErr) require.Equal(t, tc.boundAccount.ID, boundAccountID, "transient policy misses must preserve the response binding") diff --git a/backend/internal/service/openai_ws_forwarder_payload.go b/backend/internal/service/openai_ws_forwarder_payload.go index b8759b101e80..42d5f2e13715 100644 --- a/backend/internal/service/openai_ws_forwarder_payload.go +++ b/backend/internal/service/openai_ws_forwarder_payload.go @@ -133,6 +133,9 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( if state := strings.TrimSpace(turnState); state != "" { headers.Set(openAIWSTurnStateHeader, state) } + if err := s.applyOpenAICodexTicket(ctx, account, routingModel, headers); err != nil { + return nil, sessionResolution, err + } if metadata := strings.TrimSpace(turnMetadata); metadata != "" { headers.Set(openAIWSTurnMetadataHeader, metadata) } diff --git a/backend/internal/service/openai_ws_forwarder_support.go b/backend/internal/service/openai_ws_forwarder_support.go index 4e2a1e753e48..6d080afd2ea5 100644 --- a/backend/internal/service/openai_ws_forwarder_support.go +++ b/backend/internal/service/openai_ws_forwarder_support.go @@ -932,7 +932,7 @@ func (s *OpenAIGatewayService) resolveAccountByPreviousResponseIDForCapability( if vetoed, _ := openAIProfitControlVetoReason(ctx, account); vetoed { return policyMiss() } - if s.isOpenAIAccountStrictContinuationBlockedContext(ctx, account, requestedModel) { + if s.isOpenAIAccountStrictContinuationBlockedContext(ctx, account, accountRequestedModel, requireCompact) { return miss(false, true) } if requireCompact && openAICompactSupportTier(account) == 0 { diff --git a/backend/internal/service/redeem_service.go b/backend/internal/service/redeem_service.go index 5e7cdee68529..7b42b04d70da 100644 --- a/backend/internal/service/redeem_service.go +++ b/backend/internal/service/redeem_service.go @@ -742,3 +742,8 @@ func (s *RedeemService) reduceOrCancelSubscription(ctx context.Context, userID, return nil } + +// GetUserHistoryPaginated returns all redemption types for the authenticated user. +func (s *RedeemService) GetUserHistoryPaginated(ctx context.Context, userID int64, params pagination.PaginationParams) ([]RedeemCode, *pagination.PaginationResult, error) { + return s.redeemRepo.ListByUserPaginated(ctx, userID, params, "") +} diff --git a/backend/internal/service/setting_codex_ticket_test.go b/backend/internal/service/setting_codex_ticket_test.go new file mode 100644 index 000000000000..c13ae6eccff4 --- /dev/null +++ b/backend/internal/service/setting_codex_ticket_test.go @@ -0,0 +1,119 @@ +package service + +import ( + "context" + "errors" + "net/http" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +type codexTicketSettingRepo struct { + *codexPolicyMigrationRepoStub + err error +} + +func (r *codexTicketSettingRepo) GetValue(ctx context.Context, key string) (string, error) { + if r.err != nil { + return "", r.err + } + return r.codexPolicyMigrationRepoStub.GetValue(ctx, key) +} + +func TestCodexTicketEnabledRuntimeSettingOverridesYaml(t *testing.T) { + repo := &codexTicketSettingRepo{codexPolicyMigrationRepoStub: &codexPolicyMigrationRepoStub{values: map[string]string{}}} + settings := NewSettingService(repo, &config.Config{}) + svc := ticketTestService(t, config.OpenAICodexTicketConfig{Enabled: false, FailClosed: true}, nil) + svc.settingService = settings + account := ticketTestAccount(41) + svc.storeOpenAICodexTicket(context.Background(), account, &openAICodexTicket{ + AccountID: 41, + Model: "gpt-6-astra", + State: fakeCodexTicketState(292), + Length: 292, + CapturedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Hour), + }) + + h := http.Header{} + h.Set(openAICodexTurnStateHeader, "client-state") + require.False(t, svc.openAICodexTicketEnabled()) + require.NoError(t, svc.applyOpenAICodexTicket(context.Background(), account, "gpt-6-astra", h)) + require.Equal(t, "client-state", h.Get(openAICodexTurnStateHeader)) + + repo.values[SettingKeyOpenAICodexTicketEnabled] = "true" + settings.InvalidateOpenAICodexTicketEnabledCache() + require.True(t, svc.openAICodexTicketEnabled()) + h = http.Header{} + h.Set(openAICodexTurnStateHeader, "client-state") + require.NoError(t, svc.applyOpenAICodexTicket(context.Background(), account, "gpt-6-astra", h)) + require.Equal(t, fakeCodexTicketState(292), h.Get(openAICodexTurnStateHeader)) + + repo.values[SettingKeyOpenAICodexTicketEnabled] = "false" + settings.InvalidateOpenAICodexTicketEnabledCache() + require.False(t, svc.openAICodexTicketEnabled()) + h = http.Header{} + h.Set(openAICodexTurnStateHeader, "client-state") + require.NoError(t, svc.applyOpenAICodexTicket(context.Background(), account, "gpt-6-astra", h)) + require.Equal(t, "client-state", h.Get(openAICodexTurnStateHeader)) +} + +func TestRefreshOpenAICodexTickets_DisabledSkipsHarvest(t *testing.T) { + upstream := &httpUpstreamRecorder{} + svc := ticketTestService(t, config.OpenAICodexTicketConfig{ + Enabled: false, + HarvestProxyURL: "socks5h://proxy.example.com:1080", + }, upstream) + svc.accountRepo = &codexTicketRefreshRepo{accounts: []Account{*ticketTestAccount(41)}} + svc.refreshOpenAICodexTickets(context.Background()) + require.Empty(t, upstream.requests) +} + +func TestCodexTicketProxyRuntimeSettingAndFallback(t *testing.T) { + repo := &codexTicketSettingRepo{codexPolicyMigrationRepoStub: &codexPolicyMigrationRepoStub{values: map[string]string{}}} + settings := NewSettingService(repo, &config.Config{}) + svc := ticketTestService(t, config.OpenAICodexTicketConfig{HarvestProxyURL: "http://fallback.example.com:8080"}, nil) + svc.settingService = settings + require.Equal(t, "http://fallback.example.com:8080", svc.openAICodexTicketHarvestProxyURL()) + repo.values[SettingKeyOpenAICodexTicketHarvestProxyURL] = "socks5h://user:secret@first.example.com:1080" + settings.InvalidateOpenAICodexTicketHarvestProxyCache() + require.Equal(t, repo.values[SettingKeyOpenAICodexTicketHarvestProxyURL], svc.openAICodexTicketHarvestProxyURL()) + repo.values[SettingKeyOpenAICodexTicketHarvestProxyURL] = "http://second.example.com:8080" + settings.InvalidateOpenAICodexTicketHarvestProxyCache() + require.Equal(t, "http://second.example.com:8080", svc.openAICodexTicketHarvestProxyURL()) + // Simulate another instance's settings write after the local cache expires. + repo.values[SettingKeyOpenAICodexTicketHarvestProxyURL] = "https://third.example.com:443" + settings.openAICodexTicketHarvestProxyCache.Store(&cachedOpenAICodexTicketHarvestProxy{value: "http://second.example.com:8080", expiresAt: time.Now().Add(-time.Second).UnixNano()}) + require.Equal(t, "https://third.example.com:443", svc.openAICodexTicketHarvestProxyURL()) + repo.err = errors.New("database unavailable") + settings.openAICodexTicketHarvestProxyCache.Store(&cachedOpenAICodexTicketHarvestProxy{value: "https://third.example.com:443", expiresAt: 0}) + require.Equal(t, "https://third.example.com:443", svc.openAICodexTicketHarvestProxyURL()) +} + +func TestCodexTicketProxyMaskAndValidation(t *testing.T) { + for _, raw := range []string{"http://user:secret@proxy.example.com:8080", "socks5h://user:secret@proxy.example.com:1080", "https://user:secret@[::1]:443"} { + require.NoError(t, ValidateOpenAICodexTicketHarvestProxyURL(raw)) + masked := MaskProxyURL(raw) + require.NotContains(t, masked, "secret") + require.True(t, IsMaskedProxyURL(masked)) + } + require.True(t, IsMaskedProxyURL("")) + require.False(t, IsMaskedProxyURL("http://user:secret***suffix@proxy.example.com:8080")) + for _, raw := range []string{"user:secret@host:1234", "http://user:secret@", "ftp://user:secret@host:1234", "http://user:secret@host:99999", "http://host:1234/?password=secret", "http://host:1234/#secret", "http://user:secret%zz@host:1234"} { + err := ValidateOpenAICodexTicketHarvestProxyURL(raw) + require.Error(t, err) + require.NotContains(t, err.Error(), "secret") + require.Empty(t, MaskProxyURL(raw)) + } +} + +func TestCodexTicketSettingsRefreshDoesNotMutateSharedConfig(t *testing.T) { + cfg := &config.Config{} + svc := NewSettingService(&codexTicketSettingRepo{codexPolicyMigrationRepoStub: &codexPolicyMigrationRepoStub{values: map[string]string{SettingKeyOpenAICodexTicketEnabled: "true"}}}, cfg) + svc.refreshCachedSettings(&SystemSettings{OpenAICodexTicketEnabled: true}) + require.False(t, cfg.Gateway.OpenAICodexTicket.Enabled, "runtime settings must not write the shared immutable startup configuration") + require.True(t, svc.GetOpenAICodexTicketEnabled(context.Background(), false)) +} diff --git a/backend/internal/service/setting_gateway_runtime.go b/backend/internal/service/setting_gateway_runtime.go index 9f6616e56710..cc64275c5b47 100644 --- a/backend/internal/service/setting_gateway_runtime.go +++ b/backend/internal/service/setting_gateway_runtime.go @@ -387,6 +387,149 @@ func (s *SettingService) GetAntigravityUserAgentVersion(ctx context.Context) str return fallback } +type cachedOpenAICodexTicketEnabled struct { + value bool + expiresAt int64 +} + +const openAICodexTicketEnabledCacheTTL = 5 * time.Second + +// GetOpenAICodexTicketEnabled 返回后台 292 打票总开关。 +// 设置键存在时以后台为准;缺失则回退 yaml/env。 +func (s *SettingService) GetOpenAICodexTicketEnabled(ctx context.Context, fallback bool) bool { + if ctx == nil { + ctx = context.Background() + } + if ctx.Err() != nil { + return fallback + } + if s == nil || s.settingRepo == nil { + return fallback + } + if cached, ok := s.openAICodexTicketEnabledCache.Load().(*cachedOpenAICodexTicketEnabled); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.value + } + } + resultCh := s.openAICodexTicketEnabledSF.DoChan(SettingKeyOpenAICodexTicketEnabled, func() (any, error) { + if cached, ok := s.openAICodexTicketEnabledCache.Load().(*cachedOpenAICodexTicketEnabled); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.value, nil + } + } + dbCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + value, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpenAICodexTicketEnabled) + if ctx.Err() != nil { + return nil, ctx.Err() + } + if err != nil && !errors.Is(err, ErrSettingNotFound) { + if cached, ok := s.openAICodexTicketEnabledCache.Load().(*cachedOpenAICodexTicketEnabled); ok && cached != nil { + return cached.value, nil + } + return fallback, nil + } + enabled := fallback + if err == nil && strings.TrimSpace(value) != "" { + enabled = value == "true" + } + s.openAICodexTicketEnabledCache.Store(&cachedOpenAICodexTicketEnabled{ + value: enabled, + expiresAt: time.Now().Add(openAICodexTicketEnabledCacheTTL).UnixNano(), + }) + return enabled, nil + }) + select { + case <-ctx.Done(): + return fallback + case result := <-resultCh: + if v, ok := result.Val.(bool); ok && result.Err == nil { + return v + } + return fallback + } +} + +func (s *SettingService) InvalidateOpenAICodexTicketEnabledCache() { + if s == nil { + return + } + s.openAICodexTicketEnabledSF.Forget(SettingKeyOpenAICodexTicketEnabled) + s.openAICodexTicketEnabledCache.Store(&cachedOpenAICodexTicketEnabled{expiresAt: 0}) +} + +type cachedOpenAICodexTicketHarvestProxy struct { + value string + expiresAt int64 +} + +const openAICodexTicketHarvestProxyCacheTTL = 5 * time.Second + +// GetOpenAICodexTicketHarvestProxyURL 返回后台配置的 292 打票代理。空则调用方回退 yaml/env。 +func (s *SettingService) GetOpenAICodexTicketHarvestProxyURL(ctx context.Context) string { + if ctx == nil { + ctx = context.Background() + } + if ctx.Err() != nil { + return "" + } + if s == nil || s.settingRepo == nil { + return "" + } + if cached, ok := s.openAICodexTicketHarvestProxyCache.Load().(*cachedOpenAICodexTicketHarvestProxy); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.value + } + } + resultCh := s.openAICodexTicketHarvestProxySF.DoChan(SettingKeyOpenAICodexTicketHarvestProxyURL, func() (any, error) { + if cached, ok := s.openAICodexTicketHarvestProxyCache.Load().(*cachedOpenAICodexTicketHarvestProxy); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.value, nil + } + } + dbCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + value, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpenAICodexTicketHarvestProxyURL) + if ctx.Err() != nil { + return nil, ctx.Err() + } + if err != nil && !errors.Is(err, ErrSettingNotFound) { + // Keep the last known proxy during transient storage failures. + if cached, ok := s.openAICodexTicketHarvestProxyCache.Load().(*cachedOpenAICodexTicketHarvestProxy); ok && cached != nil { + value = cached.value + } + s.openAICodexTicketHarvestProxyCache.Store(&cachedOpenAICodexTicketHarvestProxy{ + value: value, + expiresAt: time.Now().Add(time.Second).UnixNano(), + }) + return value, nil + } + value = strings.TrimSpace(value) + s.openAICodexTicketHarvestProxyCache.Store(&cachedOpenAICodexTicketHarvestProxy{ + value: value, + expiresAt: time.Now().Add(openAICodexTicketHarvestProxyCacheTTL).UnixNano(), + }) + return value, nil + }) + select { + case <-ctx.Done(): + return "" + case result := <-resultCh: + if v, ok := result.Val.(string); ok && result.Err == nil { + return v + } + return "" + } +} + +func (s *SettingService) InvalidateOpenAICodexTicketHarvestProxyCache() { + if s == nil { + return + } + s.openAICodexTicketHarvestProxySF.Forget(SettingKeyOpenAICodexTicketHarvestProxyURL) + s.openAICodexTicketHarvestProxyCache.Store(&cachedOpenAICodexTicketHarvestProxy{expiresAt: 0}) +} + // GetOpenAICodexUserAgent 返回 OpenAI Codex 上游请求使用的 User-Agent。 // 后台设置优先;为空时回退到内置默认值。 func (s *SettingService) GetOpenAICodexUserAgent(ctx context.Context) string { diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index f7a422864f6d..ef71b157a809 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -253,6 +253,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyOpenAICodexClientVersion: "", SettingKeyOpenAICodexClientVersionSynced: "", SettingKeyOpenAICodexVersionAutoSyncEnabled: "true", + SettingKeyOpenAICodexTicketHarvestProxyURL: "", SettingPaymentVisibleMethodAlipaySource: "", SettingPaymentVisibleMethodWxpaySource: "", SettingPaymentVisibleMethodAlipayEnabled: "false", @@ -905,6 +906,12 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin } else { result.OpenAICodexVersionAutoSyncEnabled = true } + if v, ok := settings[SettingKeyOpenAICodexTicketEnabled]; ok && v != "" { + result.OpenAICodexTicketEnabled = v == "true" + } else if s != nil && s.cfg != nil { + result.OpenAICodexTicketEnabled = s.cfg.Gateway.OpenAICodexTicket.Enabled + } + result.OpenAICodexTicketHarvestProxyURL = strings.TrimSpace(settings[SettingKeyOpenAICodexTicketHarvestProxyURL]) // codex_cli_only 加固 result.MinCodexVersion = settings[SettingKeyMinCodexVersion] result.MaxCodexVersion = settings[SettingKeyMaxCodexVersion] diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 1ee9b407e0ba..692ad0e574db 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -117,21 +117,25 @@ type WebSearchManagerBuilder func(cfg *WebSearchEmulationConfig, proxyURLs map[i // SettingService 系统设置服务 type SettingService struct { - settingRepo SettingRepository - defaultSubGroupReader DefaultSubscriptionGroupReader - proxyRepo ProxyRepository // for resolving websearch provider proxy URLs - cfg *config.Config - onUpdate func() // Callback when settings are updated (for cache invalidation) - version string // Application version - webSearchManagerBuilder WebSearchManagerBuilder - antigravityUAVersionCache atomic.Value // *cachedAntigravityUserAgentVersion - antigravityUAVersionSF singleflight.Group - openAICodexUACache atomic.Value // *cachedOpenAICodexUserAgent - openAICodexUASF singleflight.Group - openAICodexVersionCache atomic.Value // *cachedOpenAICodexClientVersion - openAICodexVersionSF singleflight.Group - codexRestrictionPolicyCache atomic.Value // *cachedCodexRestrictionPolicy - codexRestrictionPolicySF singleflight.Group + settingRepo SettingRepository + defaultSubGroupReader DefaultSubscriptionGroupReader + proxyRepo ProxyRepository // for resolving websearch provider proxy URLs + cfg *config.Config + onUpdate func() // Callback when settings are updated (for cache invalidation) + version string // Application version + webSearchManagerBuilder WebSearchManagerBuilder + antigravityUAVersionCache atomic.Value // *cachedAntigravityUserAgentVersion + antigravityUAVersionSF singleflight.Group + openAICodexUACache atomic.Value // *cachedOpenAICodexUserAgent + openAICodexUASF singleflight.Group + openAICodexVersionCache atomic.Value // *cachedOpenAICodexClientVersion + openAICodexVersionSF singleflight.Group + openAICodexTicketEnabledCache atomic.Value // *cachedOpenAICodexTicketEnabled + openAICodexTicketEnabledSF singleflight.Group + openAICodexTicketHarvestProxyCache atomic.Value // *cachedOpenAICodexTicketHarvestProxy + openAICodexTicketHarvestProxySF singleflight.Group + codexRestrictionPolicyCache atomic.Value // *cachedCodexRestrictionPolicy + codexRestrictionPolicySF singleflight.Group cyberSessionBlockRuntimeCache atomic.Value // *cachedCyberSessionBlockRuntime cyberSessionBlockRuntimeSF singleflight.Group diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index 1180d3f5c212..eeb6d4d4fd2a 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -504,6 +504,11 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyOpenAICodexUserAgent] = strings.TrimSpace(settings.OpenAICodexUserAgent) updates[SettingKeyOpenAICodexClientVersion] = NormalizeCodexClientVersion(settings.OpenAICodexClientVersion) updates[SettingKeyOpenAICodexVersionAutoSyncEnabled] = strconv.FormatBool(settings.OpenAICodexVersionAutoSyncEnabled) + updates[SettingKeyOpenAICodexTicketEnabled] = strconv.FormatBool(settings.OpenAICodexTicketEnabled) + if err := ValidateOpenAICodexTicketHarvestProxyURL(settings.OpenAICodexTicketHarvestProxyURL); err != nil { + return nil, infraerrors.BadRequest("INVALID_CODEX_HARVEST_PROXY", err.Error()) + } + updates[SettingKeyOpenAICodexTicketHarvestProxyURL] = strings.TrimSpace(settings.OpenAICodexTicketHarvestProxyURL) // SettingKeyOpenAICodexClientVersionSynced 由自动同步任务独占写入,此处不得覆盖, // 否则面板保存会把同步结果清空。 // codex_cli_only 加固 @@ -777,6 +782,8 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) { // 版本号缓存只做失效,不在此重算:生效值还取决于自动同步写入的 synced 键, // 这里没有它的最新值,重算会把同步结果覆盖成陈旧值。 s.InvalidateOpenAICodexClientVersionCache() + s.InvalidateOpenAICodexTicketEnabledCache() + s.InvalidateOpenAICodexTicketHarvestProxyCache() openAIAdvancedSchedulerSettingSF.Forget(openAIAdvancedSchedulerSettingKey) openAIAdvancedSchedulerSettingCache.Store(&cachedOpenAIAdvancedSchedulerSetting{ lowUpstreamRatePriorityEnabled: settings.OpenAILowUpstreamRatePriorityEnabled, diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index ae1b4711068c..15f881488be1 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -257,6 +257,8 @@ type SystemSettings struct { OpenAICodexClientVersion string // 出站声明的 Codex 客户端版本号(管理员覆写);空值跟随自动同步值 OpenAICodexClientVersionSynced string // 自动同步到的官方最新稳定版版本号(只读展示) OpenAICodexVersionAutoSyncEnabled bool // 是否启用 Codex 客户端版本号自动同步(默认 true) + OpenAICodexTicketEnabled bool // Codex 292 打票总开关;关闭则不打票不注入 + OpenAICodexTicketHarvestProxyURL string // Codex 292 打票代理 URL;空则回退 yaml/env MinCodexVersion string // codex_cli_only 最低 Codex 引擎版本;空=不检查 MaxCodexVersion string // codex_cli_only 最高 Codex 引擎版本;空=不检查 CodexCLIOnlyBlacklist string // codex_cli_only 全局黑名单 JSON([]AllowedClientEntry,OR deny) diff --git a/backend/internal/service/token_refresh_service_candidates_test.go b/backend/internal/service/token_refresh_service_candidates_test.go index 263135294890..ff78e139725c 100644 --- a/backend/internal/service/token_refresh_service_candidates_test.go +++ b/backend/internal/service/token_refresh_service_candidates_test.go @@ -51,7 +51,6 @@ func (r *tokenRefreshCandidateRepo) ListOAuthRefreshCandidatePage(_ context.Cont } } if options.ActiveOnly && account.Status != StatusActive || - !account.Schedulable || account.Type != AccountTypeOAuth || !platformAllowed || options.RequireRefreshToken && strings.TrimSpace(refreshToken) == "" || @@ -183,7 +182,7 @@ func TestTokenRefreshService_ProcessRefreshUsesOAuthRefreshCandidates(t *testing Type: AccountTypeOAuth, Status: StatusActive, Schedulable: false, - Credentials: map[string]any{"refresh_token": "permanently-rejected-token"}, + Credentials: map[string]any{"refresh_token": "paused-account-token"}, }, }, } @@ -202,7 +201,9 @@ func TestTokenRefreshService_ProcessRefreshUsesOAuthRefreshCandidates(t *testing svc.processRefresh() require.Zero(t, repo.listActiveCalls, "TokenRefreshService should not use the broad active-account query") - require.ElementsMatch(t, []int64{1, 6}, repo.updatedCredentialIDs) + // Account 7 is paused (schedulable=false) but active: it must still be + // refreshed so its stored access_token does not silently expire. + require.ElementsMatch(t, []int64{1, 6, 7}, repo.updatedCredentialIDs) require.Equal(t, 1, repo.clearTempCalls, "successful refresh should clear the OAuth 401 temp-unschedulable state") } diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 2a15c90a6a0f..f0e1e44df4d9 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -327,6 +327,31 @@ gateway: # Use this to avoid compact failures when newer models are not yet supported by the compact endpoint. # 当 compact 端点暂未支持更新模型时,可通过这里降级规避失败。 openai_compact_model: "gpt-5.5" + # ChatGPT OAuth x-codex-turn-state ticket: harvest a 292-length header via a + # dedicated HTTP(S)/SOCKS proxy, store it for ttl_seconds (default 3600), then inject + # it on residential-IP production requests. Off by default. + # ChatGPT OAuth 门票:用独立 HTTP(S)/SOCKS 代理打到 292 长度的 x-codex-turn-state,默认缓存 1 小时, + # 再在住宅 IP 业务请求中覆盖该头。默认关闭。 + # Env: GATEWAY_OPENAI_CODEX_TICKET_ENABLED=true + # GATEWAY_OPENAI_CODEX_TICKET_HARVEST_PROXY_URL=socks5h://user:pass@host:port + # Admin UI 开关(系统设置 → 网关 → Codex 设置 → 292 打票)可覆盖 enabled,无需重启。 + # Off: do not harvest or inject. On: harvest in the background and inject on production requests. + # The harvest proxy should rotate egress IPs itself (fresh CONNECT per request). + # Credential shadow accounts retain their existing forwarding policy and are not ticket-gated. + openai_codex_ticket: + enabled: false + target_length: 292 + ttl_seconds: 3600 + refresh_before_seconds: 600 + # 后台「系统设置 → 网关 → Codex 设置」可覆盖此值,无需重启。 + # 后台值的读取缓存为 5 秒,下个探测周期使用新代理;代理服务商自行负责轮换 IP。 + harvest_proxy_url: "" + harvest_probe_interval_seconds: 6 + harvest_attempt_timeout_seconds: 25 + fail_closed: true + models: + - gpt-6-astra + - gpt-5.6-sol # ChatGPT Frameless Live 单会话硬上限(秒)。 live: max_session_duration_seconds: 3600 diff --git a/docs/ANTIGRAVITY_ATTRIBUTION_429.md b/docs/ANTIGRAVITY_ATTRIBUTION_429.md new file mode 100644 index 000000000000..1a50a20ebe14 --- /dev/null +++ b/docs/ANTIGRAVITY_ATTRIBUTION_429.md @@ -0,0 +1,61 @@ +# Antigravity:Claude Desktop attribution 元数据触发 429 的排查记录 + +本文记录一次实际问题的定位和修复,适用于 Claude Desktop / Claude Code 经 Anthropic Messages 协议转到 Antigravity 的场景。它不是所有 `429 RESOURCE_EXHAUSTED` 的通用解释:额度耗尽、请求频率、上游容量等仍应独立排查。 + +## 现象与环境 + +- 实测部署为 Sub2API 0.2.0,Claude Desktop 2.110.0,内嵌 Claude Code 2.1.271,CC Switch 3.20.3。 +- 请求链路:Claude Desktop → CC Switch 本地代理 → Sub2API Antigravity → Google 上游;映射模型为 `gemini-3.8-flash-high`。 +- 新对话中的简单问候也返回 `429 RESOURCE_EXHAUSTED`,上游信息为 `Resource has been exhausted (e.g. check quota).`。 +- 随后账号进入模型冷却,单账号组还可能出现 `503 No available accounts`。 +- 独立构造的极简请求成功,但真实客户端请求失败。客户端即使只发送一句问候,也会附带 system 提示词和工具定义。 + +## 定位方法与对照结果 + +保留一次失败请求,在相同账号、模型和上游端点下逐项删减内容。不要仅用手写的短请求判断完整客户端链路已经恢复,也不要在公开 issue / PR 上传原始请求或访问令牌。 + +| 对照项 | 本次观察 | +| --- | --- | +| 原始客户端请求(约 187 KB、106 个工具) | 429 | +| 移除工具定义,保留 system 内容 | 429 | +| 移除 messages 中的 system 角色消息 | 429 | +| 直接请求 Google,保留原始顶层 system 内容 | 429 | +| 同一 Google 请求改用简单 system 内容 | 200 | +| 分别测试 system 文本块 | attribution 元数据块返回 429,其余指令块返回 200 | +| 原始请求仅移除 attribution 元数据块,保留模型、工具及其他内容 | 200,收到完整回复 | + +触发问题的文本形如: + +```text +x-anthropic-billing-header: cc_version=2.1.271.4bf; cc_entrypoint=claude-desktop-3p; +``` + +这里的 “header” 是请求 JSON 的 `system` 文本中的一行,**不是 HTTP 请求头**。因此,不需要在 Claude Desktop 的 “Custom inference headers” 里增删同名字段;也不应通过修改正常工作的 Bearer 认证来处理这个问题。 + +这些对照支持“该元数据文本在本次 Google 请求中触发拒绝”的判断,但不能据此确定 Google 内部策略,也不能推断所有账号或模型都具有相同行为。 + +## 本次修复 + +在 Antigravity 的 Claude → Gemini 请求转换中,移除顶层 `system` 字符串或文本块**开头**的 `x-anthropic-billing-header:` 行: + +- 支持字符串和文本块数组,兼容 LF / CRLF / CR 换行及前导空白。 +- 如果同一文本块后面还有指令,原样保留后续内容;仅元数据的空块不再发送。 +- 不匹配缺少冒号的普通文本、其他字段名,以及正常指令中间引用的相同文本。 +- 不修改用户消息、工具定义、模型映射、认证和账号额度状态。 +- 处理限于 Antigravity 转换器,不修改原生 Anthropic 转发路径。 + +上线前的临时修复采用相同范围的反向代理 JSON 过滤,并通过原始完整请求和真实 Claude Desktop 会话验证成功。本文 PR 将处理放入转换器,避免用户必须自行维护代理脚本。原部署曾尝试降低账号并发,但未解决问题;定位后已恢复原配置。 + +如需在旧版本临时处理,应只对目标 Antigravity Messages 路径修改 JSON 的顶层 system 文本。不要用全局字符串替换,否则可能误改消息、工具参数或代码示例。代理实现还需保持空数组和空对象类型、更新请求体长度,并覆盖实际使用的 token-count 路径。面板重新生成代理配置时也可能覆盖自定义过滤规则。 + +## 不要全局禁用 attribution + +Claude Code 提供 [`CLAUDE_CODE_ATTRIBUTION_HEADER`](https://code.claude.com/docs/en/env-vars) 环境变量,但桌面端是否继承该配置需要实测。本次仅修改全局 Claude 设置并未消除 Desktop 请求中的元数据。 + +更重要的是,原生 Anthropic OAuth 线路有相反的报告:禁用 attribution 会造成 429,见 [issue #6344](https://github.com/Wei-Shaw/sub2api/issues/6344)。因此,不建议为这个 Antigravity 问题统一设置 `CLAUDE_CODE_ATTRIBUTION_HEADER=0` 或在所有上游请求中删除该文本。 + +## 验证与仍需排查的情况 + +回归测试覆盖两种 system 格式、换行与空白、保留后续指令、保留普通文本和用户 / 工具内容,以及不修改原请求。已有的无冒号 billing-header 文本保留测试继续适用。 + +修复后应使用真实客户端新建会话验证完整响应,并确认已有冷却已到期。如果仍返回 429,继续检查原始上游错误、实际额度、并发和模型映射;不要用无限重试或反复清空冷却替代定位。 diff --git a/frontend/src/api/__tests__/redeem.spec.ts b/frontend/src/api/__tests__/redeem.spec.ts new file mode 100644 index 000000000000..8c4284e9f5ed --- /dev/null +++ b/frontend/src/api/__tests__/redeem.spec.ts @@ -0,0 +1,20 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getHistory } from '../redeem' + +const { get } = vi.hoisted(() => ({ get: vi.fn() })) +vi.mock('../client', () => ({ apiClient: { get } })) + +describe('redemption history pagination', () => { + beforeEach(() => vi.clearAllMocks()) + + it.each([[undefined, undefined, 1, 20], [3, 50, 3, 50], [2, 100, 2, 100]])( + 'sends page %s and size %s to the server', async (page, size, expectedPage, expectedSize) => { + const response = { items: [], total: 105, page: expectedPage, page_size: expectedSize, pages: 6 } + get.mockResolvedValue({ data: response }) + expect(await getHistory(page, size)).toEqual(response) + expect(get).toHaveBeenCalledWith('/redeem/history', { + params: { page: expectedPage, page_size: expectedSize } + }) + } + ) +}) diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 659a652b9c17..c381a449a201 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -688,6 +688,9 @@ export interface SystemSettings { openai_codex_client_version: string; openai_codex_client_version_synced: string; openai_codex_version_auto_sync_enabled: boolean; + openai_codex_ticket_enabled: boolean; + openai_codex_ticket_harvest_proxy_url: string; + openai_codex_ticket_harvest_proxy_configured: boolean; // codex_cli_only 加固 min_codex_version: string; max_codex_version: string; @@ -1013,6 +1016,8 @@ export interface UpdateSettingsRequest { openai_codex_user_agent?: string; openai_codex_client_version?: string; openai_codex_version_auto_sync_enabled?: boolean; + openai_codex_ticket_enabled?: boolean; + openai_codex_ticket_harvest_proxy_url?: string; // codex_cli_only 加固 min_codex_version?: string; max_codex_version?: string; diff --git a/frontend/src/api/redeem.ts b/frontend/src/api/redeem.ts index 22abf4d80cd7..4a2eb3a544ac 100644 --- a/frontend/src/api/redeem.ts +++ b/frontend/src/api/redeem.ts @@ -4,7 +4,7 @@ */ import { apiClient } from './client' -import type { RedeemCodeRequest } from '@/types' +import type { PaginatedResponse, RedeemCodeRequest } from '@/types' export interface RedeemHistoryItem { id: number @@ -52,10 +52,12 @@ export async function redeem(code: string): Promise<{ /** * Get user's redemption history - * @returns List of redeemed codes + * @returns The requested page of redeemed codes and the total count */ -export async function getHistory(): Promise { - const { data } = await apiClient.get('/redeem/history') +export async function getHistory(page = 1, pageSize = 20): Promise> { + const { data } = await apiClient.get>('/redeem/history', { + params: { page, page_size: pageSize } + }) return data } diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index 2a4ea879a2fa..788513a0b5a4 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -179,8 +179,20 @@ - -