diff --git a/.downstream/gpt6-routing-and-prompts.md b/.downstream/gpt6-routing-and-prompts.md index 6e5c6bffbb85..60c5e8a5bac7 100644 --- a/.downstream/gpt6-routing-and-prompts.md +++ b/.downstream/gpt6-routing-and-prompts.md @@ -8,17 +8,17 @@ 同步返回的 `model_list_source` 单独标明型号名单来自实时上游还是配置回退,与能力补充来源分开;只有实时目录可用来确认账号当时公布了哪些型号。Anthropic(`limit=1000`、`after_id`/`has_more`)与 Gemini(`pageSize=1000`、`nextPageToken`)目录按页取全。 -容量对所有主机使用同一优先级:自定义覆盖 > 本账号上游声明 > 参考目录 > models.dev 参考 > 未知。声明与 models.dev 参考存放在账号 `extra.upstream_model_metadata` 的模型条目中(`context_window`、`max_context_window`、`max_input_tokens`、`max_output_tokens`、`source: upstream|registry`、`observed_at`),旧的 `upstream_model_context_capacities` 已由迁移 258 并入。`context_window` 是默认工作窗口,`max_context_window` 是真实最大值。分组对外容量取能服务该模型的全部 `active` 账号(含 OAuth)的最小值;未知容量不写入 `/v1/models`,Codex 清单保留上游描述模板;图像、视频和音频模型不带文本容量。 +容量对所有主机使用同一优先级:自定义覆盖 > 适用官方 API 规格 > 来源身份匹配的本账号上游声明 > models.dev 参考 > 未知。官方记录一旦胜出,缺失的独立输入/输出字段保持未知,不从中转或 Codex 订阅观测补齐。声明与 models.dev 参考存放在账号 `extra.upstream_model_metadata` 的模型条目中(`context_window`、`max_context_window`、`max_input_tokens`、`max_output_tokens`、`source: upstream|registry`、`observed_at`),旧的 `upstream_model_context_capacities` 已由迁移 258 并入;快照另存规范化 `source_identity`,缺失或不匹配的旧观测只供管理员诊断。有效窗口使用 `max_context_window`,否则 `context_window`,再否则独立输入限制。分组对外容量取能服务该模型的全部 `active` 账号(含 OAuth)的最小值;未知容量不写入 `/v1/models`,Codex 清单保留真实上游字段,无证据的本地生成条目不伪造容量;图像、视频和音频模型不带文本容量。 `active` 的 API Key 账号每 24 小时分批重新同步(失败退避),创建或修改凭据后立即同步;OAuth 账号不轮询,Codex 清单经过网关时记录其声明。手动同步保留。 | 来源 | GPT-6 Sol / Luna 上下文 | 输出与推理 | | --- | --- | --- | -| 官方 API 文档 | 1,050,000(不作容量参考) | 最大输出 128,000;none、low、medium、high、xhigh、max | -| 参考目录(Codex 订阅值,适用于所有账号类型) | 默认 272,000,最大 872,000 | 默认 medium;Sol 支持客户端 Ultra 委派,Luna 最高 Max | -| 账号上游或中转声明 | 按实际声明,优先于参考目录 | 实时能力优先于旧快照和默认值 | +| 官方 API 文档 | 1,050,000(有适用条目时作为有效容量) | 最大输出 128,000;none、low、medium、high、xhigh、max | +| Codex 订阅或账号上游声明 | 仅在官方 API 规格缺失时作为完整来源 | 默认与最大值按来源原样保留;不覆盖已适用的官方 API 记录 | +| models.dev | 仅在官方 API 与来源绑定的上游声明均缺失时使用 | 缺失字段不从其他低优先级来源拼接 | -参考目录的其他 GPT 值同样取 Codex 订阅值:gpt-5.5 为 272,000/272,000,gpt-5.4 默认 272,000、最大 1,000,000,gpt-5.4-mini 与 gpt-5.2 为 272,000,gpt-5.6-* 为 272,000/872,000;Codex 目录中没有的型号保留 API 规格。只收录官方文档列出的日期快照别名;Qwen 参考只适用于百炼(dashscope)主机。 +GPT 参考目录按官方 API 规格维护:GPT-5.5/GPT-5.4/GPT-5.6/GPT-6 为 1,050,000,GPT-5.4-mini/GPT-5.2/Daybreak Red 为 400,000,最大输出按官方条目记录。只收录官方文档列出的日期快照别名;Qwen 参考只适用于百炼(dashscope)主机。 API Key 使用完整 Responses。OAuth 目录保留实际 Lite 与上下文字段。Ultra 不作为原生 API reasoning effort 发送。Cindy 库存不因 OpenAI 发布新模型而扩大。 diff --git a/.downstream/upstream-base b/.downstream/upstream-base index d2db7dbfe658..8f51ea947e07 100644 --- a/.downstream/upstream-base +++ b/.downstream/upstream-base @@ -1 +1 @@ -v0.2.8 +v0.2.9 diff --git a/.downstream/upstream-review-v0.2.9.md b/.downstream/upstream-review-v0.2.9.md new file mode 100644 index 000000000000..90a80ed89174 --- /dev/null +++ b/.downstream/upstream-review-v0.2.9.md @@ -0,0 +1,37 @@ +# Upstream v0.2.9 integration and API context capacity + +## Source + +- Downstream base: `fb7467b7a2479cd9092fc4b1e659c6c8e9bba7b4` (`v0.2.8-codexrip.12`). +- Upstream: stable tag `v0.2.9`, tag object `8532ec28b56188d3c845ed97070e6ee5136bd9bd`, commit `4c00df2e0183e2c70b7fa8ba45914205e36aad0c`. +- Upstream delta: 70 commits, 117 paths; 61 overlap downstream changes and 37 match the existing critical-path classifier. Six actual conflict paths were resolved by semantic review. The complete classifier output remains in `upstream-risk.json`. +- There are no upstream schema, migration or dependency changes. Existing migration names and checksums are unchanged. The snapshot extension uses an optional JSON field, without SQL backfill. + +## Integration decisions + +- Adopt arbitrary-position allowlist globs and passthrough model discovery without hiding configured models. Capacity still resolves actual account/channel mapping targets, not public aliases. +- Adopt `thinking.type=disabled`, GPT-5-or-newer reasoning detection, Responses message item types, seeded tool input preservation and streamed text recovery. Keep explicit `none`, downstream `max` semantics, exact date aliases, opaque reasoning state and authoritative terminal-event requirements. +- Forward the upstream-supported `OpenAI-Beta` header with its legacy Responses token cleanup. Keep downstream session/thread/request headers and body-only installation identity. +- On a Codex WebSocket window rollover, remove the previous response ID and do not infer an anchor from the old window. Keep the existing owner, identity and verified-replay rules. Do not resurrect retired encrypted-lineage rewriting. +- Combine upstream client-disconnect 499 handling with downstream quality-request isolation, selection-scoped reporting and cooldown behavior. Antigravity keeps both upstream empty-stream errors and downstream adapter protocol errors. +- Keep `OpenAIQuotaWindows` as the source of primary/secondary quota deadlines, including future reset deadlines. Adopt scheduler snapshot auto-reset fields and retry/backoff improvements. +- Carry the account long-context pricing flag through the complete account-cost call chain. Adopt inherited image pricing, zero-cost Free Fast records and terminal-success alpha-search billing while preserving downstream reasoning-effort accounting. +- Adopt the admin/client bug fixes and Redis exec-list compose templates while retaining the Apple theme and complete admin visibility. Production deployment does not apply compose-template changes or rebuild Redis. +- Draft PR #208 and its production hold remain separate; this merge contains no commits from that branch. + +## Capacity contract + +- All account types and providers use `custom > applicable official API > source-bound upstream > registry > unknown`. +- Select one complete automatic evidence record. A winning official record never borrows omitted input/output limits from a relay or Codex subscription observation. A custom window overlays that record, retaining only its compatible independently declared I/O limits; unknown independent limits stay absent. +- The effective planning window is the explicit `max_context_window`, otherwise `context_window`, otherwise the independently declared input limit. A smaller explicit maximum remains authoritative. Group aggregation takes the minimum of the resolved real targets among active accounts, independent of the schedulable toggle. +- GPT primary records now contain API specifications and precise source links. Old Codex reference records are diagnostic only. Provider/product host constraints and finite identity aliases remain unchanged. +- `upstream_model_metadata.source_identity` binds observations and endpoint-derived registry enrichment to their normalized endpoint and protocol contract, including OpenAI Responses mode and escaped URL paths. Old unbound observations remain visible for diagnosis but do not become current evidence. Source changes and late asynchronous responses cannot rebind them. Conditional writes use the existing row revision gate with one bounded reread/retry only when the source and complete stored catalog (including an empty catalog) are unchanged; they never refetch upstream merely to retry persistence. Deduplication also checks the current snapshot. +- No new hard TTL, no invented default capacity, no SQL backfill and no changes to administrator overrides or account mappings. +- Admin rows, ordinary models and Codex manifests use the same resolver. Locally generated Codex descriptors contain no capacity defaults; real upstream fields are preserved when no effective evidence supersedes them. OpenCode configuration reads the existing manifest instead of maintaining a second numeric table; it emits a limit only when the client schema's required values are known. +- Runtime self-test contract is version 4 with `priority=custom,official,upstream,registry`; companion operational fixtures follow that exact line. + +## Validation and release + +- Validation is limited to the changed resolver, source lifecycle, projection, protocol and frontend behavior, plus normal required PR checks. Successful checks for the same code are reused; no real model requests or production migration exercises are part of this release. +- Target release: `v0.2.9-codexrip.1`, fixed downstream image digest, `deploy-preserve`, resource profile `preserve`, natural drain, only the Sub2API container recreated. +- The final source SHA, PR/check results, image digest and production outcome belong to the operational delivery evidence. Source integration alone does not claim a successful deployment or model-quality acceptance. diff --git a/.downstream/upstream-risk.json b/.downstream/upstream-risk.json index 2d8d5d2ed769..8551949846b1 100644 --- a/.downstream/upstream-risk.json +++ b/.downstream/upstream-risk.json @@ -1,184 +1,57 @@ { - "critical_file_count": 166, + "critical_file_count": 37, "critical_files": [ - ".github/workflows/backend-ci.yml", - ".github/workflows/release.yml", - "backend/internal/repository/account_repo.go", - "backend/internal/repository/account_repo_codex_display_snapshot_test.go", - "backend/internal/repository/account_repo_opencode_go_usage.go", - "backend/internal/repository/account_repo_opencode_go_usage_integration_test.go", - "backend/internal/repository/account_repo_opencode_go_usage_test.go", - "backend/internal/repository/account_repo_upstream_billing_probe_update_test.go", - "backend/internal/repository/affiliate_repo.go", - "backend/internal/repository/affiliate_repo_integration_test.go", - "backend/internal/repository/affiliate_repo_test.go", - "backend/internal/repository/channel_reasoning_effort_migration_integration_test.go", - "backend/internal/repository/channel_reasoning_pricing_integration_test.go", - "backend/internal/repository/channel_repo_account_stats_pricing.go", - "backend/internal/repository/channel_repo_pricing.go", - "backend/internal/repository/channel_repo_pricing_reasoning_test.go", - "backend/internal/repository/channel_repo_pricing_time_test.go", - "backend/internal/repository/content_moderation_repo.go", - "backend/internal/repository/content_moderation_repo_test.go", - "backend/internal/repository/dashboard_aggregation_group_usage_test.go", - "backend/internal/repository/dashboard_aggregation_repo.go", - "backend/internal/repository/ent.go", - "backend/internal/repository/http_upstream.go", - "backend/internal/repository/http_upstream_billing_lifecycle_test.go", - "backend/internal/repository/http_upstream_body_lifecycle_test.go", - "backend/internal/repository/http_upstream_http2_keepalive_test.go", - "backend/internal/repository/http_upstream_http2_ping_test.go", - "backend/internal/repository/openai_referral_client.go", - "backend/internal/repository/openai_referral_client_test.go", - "backend/internal/repository/proxy_expiry_renewal_integration_test.go", - "backend/internal/repository/proxy_inactive_backup_integration_test.go", - "backend/internal/repository/proxy_repo.go", - "backend/internal/repository/proxy_restore_probe_integration_test.go", - "backend/internal/repository/redeem_reduction_lock_integration_test.go", - "backend/internal/repository/redeem_reduction_remainder_integration_test.go", - "backend/internal/repository/request_log_retention_integration_test.go", "backend/internal/repository/scheduler_cache.go", "backend/internal/repository/scheduler_cache_unit_test.go", - "backend/internal/repository/simple_mode_startup_test.go", - "backend/internal/repository/wire.go", - "backend/internal/service/billing_cache_service.go", - "backend/internal/service/billing_cache_service_simple_mode_test.go", "backend/internal/service/billing_service.go", "backend/internal/service/billing_service_test.go", - "backend/internal/service/billing_token_cost_request_test.go", "backend/internal/service/gateway_anthropic_apikey_passthrough_test.go", - "backend/internal/service/gateway_billing_header.go", - "backend/internal/service/gateway_billing_header_test.go", - "backend/internal/service/gateway_claude_oauth_body.go", - "backend/internal/service/gateway_cli_version_runtime_test.go", - "backend/internal/service/gateway_compat_reasoning_pricing_test.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_responses.go", - "backend/internal/service/gateway_forward_as_responses_test.go", - "backend/internal/service/gateway_image_reasoning_pricing_test.go", - "backend/internal/service/gateway_multiplatform_test.go", - "backend/internal/service/gateway_reasoning_pricing_test.go", - "backend/internal/service/gateway_request.go", - "backend/internal/service/gateway_scheduling.go", + "backend/internal/service/gateway_hotpath_optimization_test.go", "backend/internal/service/gateway_service.go", - "backend/internal/service/gateway_simple_mode_record_usage_test.go", - "backend/internal/service/gateway_thinking_budget_test.go", + "backend/internal/service/gateway_structured_outputs_beta_test.go", "backend/internal/service/gateway_upstream_request.go", - "backend/internal/service/gateway_upstream_response.go", "backend/internal/service/gateway_upstream_transport_error.go", "backend/internal/service/gateway_usage_billing.go", - "backend/internal/service/gateway_usage_billing_simple_mode_test.go", - "backend/internal/service/openai_access_state_failover_test.go", - "backend/internal/service/openai_account_runtime_block_fastpath.go", - "backend/internal/service/openai_account_scheduler.go", - "backend/internal/service/openai_account_scheduler_canonical_quota_test.go", - "backend/internal/service/openai_account_scheduler_upstream_cost_test.go", - "backend/internal/service/openai_capacity_shed_test.go", - "backend/internal/service/openai_codex_account_identity.go", - "backend/internal/service/openai_codex_fingerprint.go", - "backend/internal/service/openai_codex_model_metadata_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_codex_turn_metadata.go", - "backend/internal/service/openai_codex_turn_metadata_test.go", - "backend/internal/service/openai_codex_version_sync_service_test.go", - "backend/internal/service/openai_compat_prompt_cache_key.go", - "backend/internal/service/openai_compat_prompt_cache_key_test.go", - "backend/internal/service/openai_gateway_cc_pipeline.go", - "backend/internal/service/openai_gateway_chat_completions.go", - "backend/internal/service/openai_gateway_chat_completions_anthropic_native.go", - "backend/internal/service/openai_gateway_chat_completions_raw.go", - "backend/internal/service/openai_gateway_chat_completions_raw_test.go", - "backend/internal/service/openai_gateway_chat_completions_test.go", - "backend/internal/service/openai_gateway_converted_reasoning_pricing_test.go", - "backend/internal/service/openai_gateway_deepseek_input_image_test.go", + "backend/internal/service/openai_account_scheduler_test.go", + "backend/internal/service/openai_alpha_search.go", + "backend/internal/service/openai_alpha_search_completion_test.go", + "backend/internal/service/openai_alpha_search_test.go", + "backend/internal/service/openai_apikey_responses_probe.go", + "backend/internal/service/openai_apikey_responses_probe_model_unavailable_test.go", + "backend/internal/service/openai_compat_model.go", + "backend/internal/service/openai_compat_model_test.go", + "backend/internal/service/openai_gateway_deepseek_chat_reasoning_test.go", "backend/internal/service/openai_gateway_forward.go", - "backend/internal/service/openai_gateway_grok.go", - "backend/internal/service/openai_gateway_grok_chat_bridge.go", - "backend/internal/service/openai_gateway_messages.go", - "backend/internal/service/openai_gateway_messages_anthropic_native.go", - "backend/internal/service/openai_gateway_messages_chat_fallback.go", - "backend/internal/service/openai_gateway_passthrough.go", - "backend/internal/service/openai_gateway_request_body.go", - "backend/internal/service/openai_gateway_response_flush_test.go", - "backend/internal/service/openai_gateway_response_handling.go", - "backend/internal/service/openai_gateway_responses_anthropic_native.go", + "backend/internal/service/openai_gateway_messages_chat_fallback_test.go", + "backend/internal/service/openai_gateway_record_usage_test.go", + "backend/internal/service/openai_gateway_responses_chat_fallback.go", "backend/internal/service/openai_gateway_scheduling.go", - "backend/internal/service/openai_gateway_service_hotpath_test.go", + "backend/internal/service/openai_gateway_service.go", "backend/internal/service/openai_gateway_service_test.go", - "backend/internal/service/openai_gateway_upstream_errors.go", "backend/internal/service/openai_gateway_usage.go", - "backend/internal/service/openai_images.go", - "backend/internal/service/openai_images_balance.go", - "backend/internal/service/openai_images_balance_test.go", - "backend/internal/service/openai_images_compatible_test.go", - "backend/internal/service/openai_legacy_scheduler_decision_test.go", - "backend/internal/service/openai_lite_mapped_gpt55.go", - "backend/internal/service/openai_lite_mapped_gpt55_test.go", - "backend/internal/service/openai_model_alias.go", - "backend/internal/service/openai_opencode_session.go", - "backend/internal/service/openai_opencode_session_test.go", - "backend/internal/service/openai_passthrough_normalization_test.go", - "backend/internal/service/openai_plugin_account_directory.go", - "backend/internal/service/openai_plugin_account_directory_test.go", - "backend/internal/service/openai_quota_credits_test.go", - "backend/internal/service/openai_quota_reset_credits_test.go", - "backend/internal/service/openai_quota_service.go", - "backend/internal/service/openai_quota_spark_window_test.go", - "backend/internal/service/openai_referral_client.go", - "backend/internal/service/openai_referral_service.go", - "backend/internal/service/openai_referral_service_test.go", - "backend/internal/service/openai_response_model_rewrite_test.go", - "backend/internal/service/openai_responses_item_id.go", - "backend/internal/service/openai_responses_item_id_test.go", - "backend/internal/service/openai_responses_tool_schema.go", - "backend/internal/service/openai_responses_tool_schema_test.go", - "backend/internal/service/openai_scheduling_rate_fallback_test.go", + "backend/internal/service/openai_quota_auto_reset.go", + "backend/internal/service/openai_quota_auto_reset_test.go", "backend/internal/service/openai_upstream_transport_error.go", - "backend/internal/service/openai_visible_ttft_test.go", - "backend/internal/service/openai_ws_forwarder_ingress_execution_scope_test.go", - "backend/internal/service/openai_ws_http_bridge.go", - "backend/internal/service/openai_ws_http_bridge_test.go", - "backend/internal/service/plugin_host_services.go", - "backend/internal/service/plugin_host_services_broker_test.go", - "backend/internal/service/plugin_host_services_test.go", - "backend/internal/service/plugin_manager.go", - "backend/internal/service/pricing_service.go", - "backend/internal/service/ratelimit_service.go", - "backend/internal/service/ratelimit_service_401_test.go", - "backend/internal/service/ratelimit_service_403_html_test.go", - "backend/internal/service/ratelimit_service_model_not_found_test.go", - "backend/migrations/238b_content_moderation_engine_meta.sql", - "backend/migrations/239_channel_reasoning_effort_multipliers.sql", - "backend/migrations/240_affiliate_ledger_operation_id.sql", - "backend/migrations/channel_reasoning_effort_multipliers_migration_test.go", - "backend/migrations/content_moderation_engine_meta_test.go", - "backend/pkg/pluginapi/v1/plugin.pb.go", - "backend/pkg/pluginapi/v1/plugin.proto", - "backend/pkg/pluginapi/v1/plugin_grpc.pb.go", - "backend/pkg/pluginapi/v1/runtime.go", - "deploy/.env.example", - "deploy/config.example.yaml", + "backend/internal/service/openai_ws_forwarder_ingress.go", + "backend/internal/service/openai_ws_forwarder_ingress_test.go", + "backend/internal/service/openai_ws_forwarder_payload.go", "deploy/docker-compose.dev.yml", "deploy/docker-compose.local.yml", - "deploy/docker-compose.standalone.yml", - "deploy/docker-compose.yml", - "deploy/tests/docker-compose-simple-mode-env-test.sh" + "deploy/docker-compose.yml" ], - "downstream_commit": "f78c2cf54c797a2ccb655848be5452c42a13adde", - "downstream_file_count": 2553, + "downstream_commit": "fb7467b7a2479cd9092fc4b1e659c6c8e9bba7b4", + "downstream_file_count": 1428, "downstream_files": [ ".dockerignore", ".downstream/README.md", ".downstream/cindy-catalog-refresh-20260905.json", ".downstream/cindy-catalog-refresh-20260911.json", + ".downstream/codex-quality-diagnostics.md", ".downstream/coverage-v0.2.7.json", ".downstream/coverage-v0.2.7.md", ".downstream/gpt6-routing-and-prompts.md", ".downstream/implementation-v0.2.7.md", + ".downstream/native-domains.md", ".downstream/refresh-coverage.mjs", ".downstream/refresh-coverage.test.mjs", ".downstream/upstream-0.2.0-overlap-review.md", @@ -186,14 +59,13 @@ ".downstream/upstream-review-v0.2.1.md", ".downstream/upstream-review-v0.2.5.md", ".downstream/upstream-review-v0.2.6.md", + ".downstream/upstream-review-v0.2.8.md", ".downstream/upstream-risk.json", ".gitattributes", - ".github/scripts/initialize_plugin_publisher.go", - ".github/scripts/initialize_plugin_publisher_test.go", ".github/scripts/release_preflight.py", ".github/scripts/test_agent_protocol.sh", - ".github/scripts/test_first_party_plugins.sh", ".github/scripts/test_model_context_capacity.sh", + ".github/scripts/test_native_domains.sh", ".github/scripts/test_reasoning_replay.sh", ".github/scripts/test_release_preflight.py", ".github/scripts/test_upstream_automation_contract.py", @@ -204,7 +76,6 @@ ".github/workflows/downstream-release.yml", ".github/workflows/downstream-verify.yml", ".github/workflows/legacy-ghcr-cleanup.yml", - ".github/workflows/plugin-release.yml", ".github/workflows/production-deploy.yml", ".github/workflows/release.yml", ".github/workflows/security-scan.yml", @@ -229,9 +100,6 @@ "THIRD_PARTY_NOTICES.md", "backend/.golangci.yml", "backend/cmd/cleanup-ingress-reject-logs/main.go", - "backend/cmd/package-plugin/bundle.go", - "backend/cmd/package-plugin/main.go", - "backend/cmd/package-plugin/verify.go", "backend/cmd/profit-preview/main.go", "backend/cmd/profit-preview/main_test.go", "backend/cmd/server/VERSION", @@ -274,11 +142,6 @@ "backend/ent/apikey_update.go", "backend/ent/client.go", "backend/ent/ent.go", - "backend/ent/group.go", - "backend/ent/group/group.go", - "backend/ent/group/where.go", - "backend/ent/group_create.go", - "backend/ent/group_update.go", "backend/ent/hook/hook.go", "backend/ent/intercept/intercept.go", "backend/ent/migrate/schema.go", @@ -290,66 +153,57 @@ "backend/ent/schema/account_tag.go", "backend/ent/schema/account_tag_binding.go", "backend/ent/schema/api_key.go", - "backend/ent/schema/group.go", - "backend/ent/schema/system_prompt_runtime.go", - "backend/ent/schema/system_prompt_template.go", - "backend/ent/schema/system_prompt_template_version.go", - "backend/ent/schema/user_platform_quota.go", - "backend/ent/schema/user_platform_quota_cindy_test.go", - "backend/ent/systempromptruntime.go", - "backend/ent/systempromptruntime/systempromptruntime.go", - "backend/ent/systempromptruntime/where.go", - "backend/ent/systempromptruntime_create.go", - "backend/ent/systempromptruntime_delete.go", - "backend/ent/systempromptruntime_query.go", - "backend/ent/systempromptruntime_update.go", - "backend/ent/systemprompttemplate.go", - "backend/ent/systemprompttemplate/systemprompttemplate.go", - "backend/ent/systemprompttemplate/where.go", - "backend/ent/systemprompttemplate_create.go", - "backend/ent/systemprompttemplate_delete.go", - "backend/ent/systemprompttemplate_query.go", - "backend/ent/systemprompttemplate_update.go", - "backend/ent/systemprompttemplateversion.go", - "backend/ent/systemprompttemplateversion/systemprompttemplateversion.go", - "backend/ent/systemprompttemplateversion/where.go", - "backend/ent/systemprompttemplateversion_create.go", - "backend/ent/systemprompttemplateversion_delete.go", - "backend/ent/systemprompttemplateversion_query.go", - "backend/ent/systemprompttemplateversion_update.go", "backend/ent/tx.go", "backend/go.mod", "backend/go.sum", + "backend/internal/accounttools/policy/import.go", + "backend/internal/accounttools/policy/import_test.go", + "backend/internal/accounttools/policy/module.go", + "backend/internal/accounttools/policy/module_test.go", + "backend/internal/codexruntime/profile/identity.go", + "backend/internal/codexruntime/profile/identity_test.go", + "backend/internal/codexruntime/profile/transport.go", + "backend/internal/codexruntime/profile/transport_test.go", + "backend/internal/codexruntime/recovery/policy.go", + "backend/internal/codexruntime/recovery/policy_test.go", + "backend/internal/codexruntime/tickets/cutover_test.go", + "backend/internal/codexruntime/tickets/identity.go", + "backend/internal/codexruntime/tickets/identity_test.go", + "backend/internal/codexruntime/tickets/lifecycle_test.go", + "backend/internal/codexruntime/tickets/module.go", + "backend/internal/codexruntime/tickets/module_test.go", + "backend/internal/codexruntime/tickets/outcome.go", + "backend/internal/codexruntime/tickets/outcome_test.go", + "backend/internal/codexruntime/tickets/proxy.go", + "backend/internal/codexruntime/tickets/proxy_selection_test.go", + "backend/internal/codexruntime/tickets/proxy_test.go", + "backend/internal/codexruntime/tickets/routing.go", + "backend/internal/codexruntime/tickets/routing_test.go", + "backend/internal/codexruntime/tickets/state.go", + "backend/internal/codexruntime/tickets/state_test.go", "backend/internal/config/config.go", "backend/internal/config/config_test.go", "backend/internal/config/image_studio_feature_flags.go", - "backend/internal/domain/constants.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_bulk_job_scope_test.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", + "backend/internal/handler/admin/account_bulk_selection_guard_test.go", "backend/internal/handler/admin/account_codex_import.go", "backend/internal/handler/admin/account_codex_import_job_test.go", "backend/internal/handler/admin/account_codex_import_test.go", + "backend/internal/handler/admin/account_codex_quality.go", "backend/internal/handler/admin/account_codex_routing.go", + "backend/internal/handler/admin/account_codex_stop_job.go", "backend/internal/handler/admin/account_codex_ticket.go", "backend/internal/handler/admin/account_codex_ticket_test.go", - "backend/internal/handler/admin/account_create_profile_test.go", "backend/internal/handler/admin/account_data.go", "backend/internal/handler/admin/account_data_handler_test.go", "backend/internal/handler/admin/account_data_identity.go", "backend/internal/handler/admin/account_data_import_decision.go", "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_edit_test.go", "backend/internal/handler/admin/account_handler.go", "backend/internal/handler/admin/account_handler_available_models_test.go", "backend/internal/handler/admin/account_handler_batch_delete_test.go", @@ -362,6 +216,7 @@ "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", + "backend/internal/handler/admin/account_job_legacy_codex.go", "backend/internal/handler/admin/account_job_submit_test.go", "backend/internal/handler/admin/account_reasoning_policy.go", "backend/internal/handler/admin/account_reasoning_policy_test.go", @@ -371,33 +226,18 @@ "backend/internal/handler/admin/account_test_plan_test.go", "backend/internal/handler/admin/account_today_stats_cache.go", "backend/internal/handler/admin/admin_basic_handlers_test.go", - "backend/internal/handler/admin/admin_service_stub_test.go", "backend/internal/handler/admin/batch_update_credentials_test.go", - "backend/internal/handler/admin/channel_handler_test.go", - "backend/internal/handler/admin/cindy_balance_probe_handler.go", - "backend/internal/handler/admin/cindy_balance_probe_handler_test.go", - "backend/internal/handler/admin/grok_import_probe.go", - "backend/internal/handler/admin/group_cindy_split.go", - "backend/internal/handler/admin/group_cindy_split_test.go", + "backend/internal/handler/admin/codex_error_diagnostics.go", "backend/internal/handler/admin/group_handler.go", - "backend/internal/handler/admin/group_handler_platform_test.go", "backend/internal/handler/admin/group_handler_simple_mode_test.go", - "backend/internal/handler/admin/group_plugin_options.go", + "backend/internal/handler/admin/native_policy_fixture_test.go", "backend/internal/handler/admin/openai_oauth_handler.go", "backend/internal/handler/admin/payment_handler_test.go", - "backend/internal/handler/admin/plugin_account_views.go", - "backend/internal/handler/admin/plugin_account_views_test.go", - "backend/internal/handler/admin/plugin_contract_fixture_test.go", - "backend/internal/handler/admin/plugin_error_diagnostics.go", "backend/internal/handler/admin/plugin_handler.go", - "backend/internal/handler/admin/plugin_precondition.go", - "backend/internal/handler/admin/plugin_precondition_test.go", - "backend/internal/handler/admin/plugin_resource_scope_test.go", - "backend/internal/handler/admin/plugin_resources.go", - "backend/internal/handler/admin/plugin_update.go", - "backend/internal/handler/admin/plugin_upload_guard_test.go", - "backend/internal/handler/admin/provider_identity_validation_test.go", "backend/internal/handler/admin/scheduled_test_handler.go", + "backend/internal/handler/admin/setting_codex_proxy_selection_test.go", + "backend/internal/handler/admin/setting_codex_runtime.go", + "backend/internal/handler/admin/setting_codex_runtime_receipt_test.go", "backend/internal/handler/admin/setting_codex_ticket_proxy.go", "backend/internal/handler/admin/setting_handler.go", "backend/internal/handler/admin/setting_handler_audit.go", @@ -408,28 +248,17 @@ "backend/internal/handler/admin/snapshot_cache.go", "backend/internal/handler/admin/snapshot_cache_test.go", "backend/internal/handler/admin/system_prompt_handler.go", - "backend/internal/handler/admin/system_prompt_handler_test.go", - "backend/internal/handler/admin/system_prompt_rules_handler.go", "backend/internal/handler/admin/user_platform_quota_admin_test.go", - "backend/internal/handler/cindy_balance_handler_failover_test.go", - "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_edit_state_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/account_view_facts_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", "backend/internal/handler/dto/settings.go", "backend/internal/handler/dto/types.go", - "backend/internal/handler/endpoint.go", - "backend/internal/handler/endpoint_test.go", "backend/internal/handler/failover_loop_profit_veto_test.go", "backend/internal/handler/failover_loop_test.go", "backend/internal/handler/gateway_handler.go", @@ -447,34 +276,25 @@ "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", "backend/internal/handler/openai_codex_models_handler.go", "backend/internal/handler/openai_codex_models_handler_test.go", "backend/internal/handler/openai_count_tokens_model_not_supported_test.go", "backend/internal/handler/openai_embeddings.go", "backend/internal/handler/openai_failover_retry.go", "backend/internal/handler/openai_failover_retry_test.go", - "backend/internal/handler/openai_gateway_cindy_count_tokens_test.go", - "backend/internal/handler/openai_gateway_cindy_image_validation_test.go", "backend/internal/handler/openai_gateway_count_tokens.go", "backend/internal/handler/openai_gateway_credential_failover_loop_test.go", "backend/internal/handler/openai_gateway_first_output_timeout_test.go", "backend/internal/handler/openai_gateway_handler.go", "backend/internal/handler/openai_gateway_handler_test.go", - "backend/internal/handler/openai_gateway_native_usage_test.go", - "backend/internal/handler/openai_gateway_reasoning_failover.go", - "backend/internal/handler/openai_gateway_reasoning_failover_test.go", "backend/internal/handler/openai_gateway_usage_context_test.go", "backend/internal/handler/openai_gateway_ws_model_allowlist_test.go", "backend/internal/handler/openai_http_response_owner_test.go", "backend/internal/handler/openai_images.go", "backend/internal/handler/openai_images_failover_test.go", - "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", @@ -495,13 +315,26 @@ "backend/internal/handler/ops_error_logger_continuation_test.go", "backend/internal/handler/ops_error_logger_test.go", "backend/internal/handler/plugin_contract_fixture_test.go", - "backend/internal/handler/remote_skill_handler.go", - "backend/internal/handler/remote_skill_handler_test.go", "backend/internal/handler/request_body_limit.go", "backend/internal/handler/request_body_limit_test.go", "backend/internal/handler/setting_handler.go", "backend/internal/handler/stream_error_event.go", "backend/internal/handler/wire.go", + "backend/internal/nativeapi/account_create.go", + "backend/internal/nativeapi/account_edit.go", + "backend/internal/nativeapi/account_import.go", + "backend/internal/nativeapi/account_tools.go", + "backend/internal/nativeapi/account_view.go", + "backend/internal/nativeapi/api.go", + "backend/internal/nativeapi/catalog.go", + "backend/internal/nativeapi/codex_identity.go", + "backend/internal/nativeapi/codex_recovery.go", + "backend/internal/nativeapi/codex_routing.go", + "backend/internal/nativeapi/codex_transport.go", + "backend/internal/nativeapi/decision.go", + "backend/internal/nativeapi/image.go", + "backend/internal/nativeapi/observability.go", + "backend/internal/nativeapi/resources.go", "backend/internal/payment/provider/alipay_test.go", "backend/internal/payment/provider/easypay_refund_test.go", "backend/internal/payment/provider/wxpay_test.go", @@ -552,60 +385,38 @@ "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_create_fence_integration_test.go", - "backend/internal/repository/account_credential_identity_repo.go", - "backend/internal/repository/account_credential_identity_repo_test.go", - "backend/internal/repository/account_edit_fence_integration_test.go", + "backend/internal/proxytransport/address.go", + "backend/internal/proxytransport/address_test.go", + "backend/internal/proxytransport/transport.go", "backend/internal/repository/account_extra_revision.go", "backend/internal/repository/account_extra_revision_integration_test.go", - "backend/internal/repository/account_job_cindy_mutation.go", - "backend/internal/repository/account_job_cindy_mutation_test.go", - "backend/internal/repository/account_job_plugin_integration_test.go", + "backend/internal/repository/account_job_connection_integration_test.go", + "backend/internal/repository/account_job_legacy_digest_integration_test.go", "backend/internal/repository/account_job_repo.go", "backend/internal/repository/account_job_repo_test.go", "backend/internal/repository/account_plugin_projection.go", - "backend/internal/repository/account_prompt_binding.go", "backend/internal/repository/account_quota_estimate.go", "backend/internal/repository/account_quota_estimate_integration_test.go", "backend/internal/repository/account_quota_snapshot_order_integration_test.go", "backend/internal/repository/account_repo.go", - "backend/internal/repository/account_repo_cindy_balance.go", - "backend/internal/repository/account_repo_cindy_balance_integration_test.go", - "backend/internal/repository/account_repo_cindy_balance_test.go", - "backend/internal/repository/account_repo_cindy_group_identity_integration_test.go", - "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_lifecycle.go", "backend/internal/repository/account_repo_codex_ticket_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", "backend/internal/repository/account_repo_ollama_cloud_usage.go", - "backend/internal/repository/account_repo_ollama_cloud_usage_integration_test.go", "backend/internal/repository/account_repo_ollama_cloud_usage_test.go", + "backend/internal/repository/account_repo_opencode_go_usage_test.go", "backend/internal/repository/account_repo_schedulable_projection_integration_test.go", "backend/internal/repository/account_repo_schedulable_projection_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/account_taxonomy_integration_test.go", "backend/internal/repository/account_traffic_observe_cache.go", - "backend/internal/repository/account_view_atomic_integration_test.go", - "backend/internal/repository/account_view_fence_test.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", "backend/internal/repository/api_key_repo_integration_test.go", "backend/internal/repository/audit_log_repo.go", - "backend/internal/repository/auth_cache_invalidation_cindy_identity_integration_test.go", "backend/internal/repository/batch_image_repo_integration_test.go", - "backend/internal/repository/business_system_prompt_repository.go", - "backend/internal/repository/business_system_prompt_repository_test.go", - "backend/internal/repository/business_system_prompt_revision_bus.go", - "backend/internal/repository/business_system_prompt_revision_bus_test.go", - "backend/internal/repository/business_system_prompt_rules.go", - "backend/internal/repository/business_system_prompt_rules_integration_test.go", - "backend/internal/repository/business_system_prompt_rules_test.go", "backend/internal/repository/channel_monitor_repo.go", "backend/internal/repository/channel_monitor_template_duplicate_metadata_unit_test.go", "backend/internal/repository/channel_monitor_template_repo.go", @@ -614,22 +425,20 @@ "backend/internal/repository/channel_repo.go", "backend/internal/repository/channel_repo_account_stats_pricing.go", "backend/internal/repository/channel_repo_pricing.go", + "backend/internal/repository/channel_repo_pricing_reasoning_test.go", "backend/internal/repository/channel_repo_pricing_time_test.go", "backend/internal/repository/channel_repo_test.go", - "backend/internal/repository/cindy_account_stats_reset_test.go", - "backend/internal/repository/cindy_account_view_atomic_test.go", - "backend/internal/repository/cindy_balance_probe_complete_integration_test.go", - "backend/internal/repository/cindy_balance_probe_dispatch_integration_test.go", - "backend/internal/repository/cindy_balance_probe_finalize_integration_test.go", - "backend/internal/repository/cindy_balance_probe_lifecycle_integration_test.go", - "backend/internal/repository/cindy_balance_probe_repo.go", - "backend/internal/repository/cindy_balance_probe_repo_test.go", - "backend/internal/repository/cindy_group_identity.go", - "backend/internal/repository/cindy_health_repo.go", - "backend/internal/repository/cindy_health_repo_test.go", "backend/internal/repository/codex_connection_lease.go", + "backend/internal/repository/codex_connection_lease_check_test.go", "backend/internal/repository/codex_connection_lease_test.go", "backend/internal/repository/codex_fingerprint_seed_migration_integration_test.go", + "backend/internal/repository/codex_native_lease.go", + "backend/internal/repository/codex_native_lease_monitor_test.go", + "backend/internal/repository/codex_native_runtime.go", + "backend/internal/repository/codex_native_runtime_test.go", + "backend/internal/repository/codex_native_state.go", + "backend/internal/repository/codex_quality_breaker.go", + "backend/internal/repository/codex_quality_lease.go", "backend/internal/repository/concurrency_cache_live_test.go", "backend/internal/repository/dashboard_aggregation_repo.go", "backend/internal/repository/db_pool.go", @@ -638,19 +447,12 @@ "backend/internal/repository/ent.go", "backend/internal/repository/ent_init_retry_test.go", "backend/internal/repository/error_translate.go", - "backend/internal/repository/fixtures_integration_test.go", "backend/internal/repository/gateway_cache.go", - "backend/internal/repository/gateway_cache_cindy_balance_test.go", "backend/internal/repository/gateway_cache_live_test.go", "backend/internal/repository/gateway_cache_reasoning_state.go", "backend/internal/repository/gateway_cache_reasoning_state_test.go", "backend/internal/repository/group_model_allowlist_repair_migration_integration_test.go", "backend/internal/repository/group_repo.go", - "backend/internal/repository/group_repo_cindy_health_test.go", - "backend/internal/repository/group_repo_cindy_split.go", - "backend/internal/repository/group_repo_cindy_split_identity_integration_test.go", - "backend/internal/repository/group_repo_cindy_split_integration_test.go", - "backend/internal/repository/group_repo_cindy_split_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", @@ -661,32 +463,17 @@ "backend/internal/repository/migrations_historical_preflight.go", "backend/internal/repository/migrations_historical_preflight_test.go", "backend/internal/repository/migrations_runner.go", - "backend/internal/repository/minimax_cindy_quota_migration_integration_test.go", + "backend/internal/repository/native_feature_bootstrap.go", + "backend/internal/repository/native_feature_bootstrap_integration_test.go", + "backend/internal/repository/native_plugin_boundary.go", + "backend/internal/repository/native_plugin_boundary_test.go", + "backend/internal/repository/native_policy_fixture_test.go", "backend/internal/repository/openai_oauth_service.go", + "backend/internal/repository/ops_error_where_test.go", "backend/internal/repository/ops_repo.go", "backend/internal/repository/ops_write_pressure_integration_test.go", "backend/internal/repository/passkey_repo.go", "backend/internal/repository/pgx_sqlmock_test.go", - "backend/internal/repository/plugin_bootstrap_update_integration_test.go", - "backend/internal/repository/plugin_bundle.go", - "backend/internal/repository/plugin_bundle_integration_test.go", - "backend/internal/repository/plugin_config_receipt_integration_test.go", - "backend/internal/repository/plugin_contract_fixture_test.go", - "backend/internal/repository/plugin_extension_state.go", - "backend/internal/repository/plugin_extension_state_integration_test.go", - "backend/internal/repository/plugin_host_io_admission_integration_test.go", - "backend/internal/repository/plugin_install_guard_integration_test.go", - "backend/internal/repository/plugin_legacy_import.go", - "backend/internal/repository/plugin_registry_atomicity_design_integration_test.go", - "backend/internal/repository/plugin_registry_tx.go", - "backend/internal/repository/plugin_registry_tx_integration_test.go", - "backend/internal/repository/plugin_registry_tx_test.go", - "backend/internal/repository/plugin_repo.go", - "backend/internal/repository/plugin_repo_integration_test.go", - "backend/internal/repository/plugin_runtime_lease_loss_integration_test.go", - "backend/internal/repository/plugin_runtime_lease_monitor_test.go", - "backend/internal/repository/plugin_update.go", - "backend/internal/repository/plugin_update_integration_test.go", "backend/internal/repository/postgres_batch_insert.go", "backend/internal/repository/postgres_batch_insert_test.go", "backend/internal/repository/postgres_driver.go", @@ -694,13 +481,10 @@ "backend/internal/repository/public_model_retirement_migration_integration_test.go", "backend/internal/repository/quota_activity.go", "backend/internal/repository/quota_activity_test.go", - "backend/internal/repository/remote_skill_job_expiry_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_reasoning_policy_test.go", "backend/internal/repository/scheduler_cache_unit_test.go", + "backend/internal/repository/system_prompt_bindings.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", @@ -721,10 +505,10 @@ "backend/internal/server/middleware/group_model_allowlist_body_compat_test.go", "backend/internal/server/middleware/group_model_allowlist_test.go", "backend/internal/server/middleware/openai_fast_policy_forwarding_test.go", + "backend/internal/server/middleware/security_headers_test.go", "backend/internal/server/router.go", "backend/internal/server/routes/account_job_routes_test.go", "backend/internal/server/routes/admin.go", - "backend/internal/server/routes/auth.go", "backend/internal/server/routes/composite_platform_test.go", "backend/internal/server/routes/gateway.go", "backend/internal/server/routes/gateway_codex_models_test.go", @@ -732,8 +516,6 @@ "backend/internal/server/routes/gateway_model_allowlist_test.go", "backend/internal/server/routes/gateway_models_pinned_test.go", "backend/internal/server/routes/gateway_test.go", - "backend/internal/server/routes/plugin_account_tools.go", - "backend/internal/server/routes/plugin_account_tools_test.go", "backend/internal/server/routes/system_prompt_routes_test.go", "backend/internal/server/routes/user.go", "backend/internal/service/account.go", @@ -742,22 +524,21 @@ "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_credential_identity.go", - "backend/internal/service/account_credential_identity_test.go", - "backend/internal/service/account_edit_bulk.go", "backend/internal/service/account_edit_codec.go", + "backend/internal/service/account_import_policy.go", + "backend/internal/service/account_import_policy_test.go", "backend/internal/service/account_job.go", - "backend/internal/service/account_job_batch_test_runtime.go", - "backend/internal/service/account_job_plugin.go", - "backend/internal/service/account_job_plugin_test.go", + "backend/internal/service/account_job_legacy.go", + "backend/internal/service/account_job_legacy_operation.go", + "backend/internal/service/account_job_legacy_test.go", "backend/internal/service/account_job_test.go", - "backend/internal/service/account_quota_schedulable_test.go", "backend/internal/service/account_quota_window_state.go", "backend/internal/service/account_quota_window_state_test.go", "backend/internal/service/account_reasoning_policy_test.go", "backend/internal/service/account_scheduling_threshold_eval.go", "backend/internal/service/account_scheduling_threshold_eval_test.go", "backend/internal/service/account_service.go", + "backend/internal/service/account_test_model.go", "backend/internal/service/account_test_models_test.go", "backend/internal/service/account_test_prompt.go", "backend/internal/service/account_test_prompt_test.go", @@ -766,129 +547,87 @@ "backend/internal/service/account_test_reasoning_wire_test.go", "backend/internal/service/account_test_service.go", "backend/internal/service/account_test_service_cn_adaptive.go", + "backend/internal/service/account_test_service_cn_adaptive_test.go", + "backend/internal/service/account_test_service_gemini_test.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_test_stream.go", + "backend/internal/service/account_test_stream_contract_test.go", + "backend/internal/service/account_tools.go", + "backend/internal/service/account_tools_module.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", - "backend/internal/service/account_wildcard_test.go", + "backend/internal/service/account_view_facts.go", "backend/internal/service/admin_account.go", - "backend/internal/service/admin_account_cindy_probe.go", - "backend/internal/service/admin_account_cindy_probe_test.go", "backend/internal/service/admin_account_codex_ticket_test.go", "backend/internal/service/admin_account_model_context_test.go", "backend/internal/service/admin_account_taxonomy.go", "backend/internal/service/admin_account_taxonomy_test.go", - "backend/internal/service/admin_cindy_balance.go", - "backend/internal/service/admin_cindy_balance_test.go", - "backend/internal/service/admin_cindy_duplicate_inventory.go", - "backend/internal/service/admin_cindy_group_split.go", - "backend/internal/service/admin_cindy_group_split_test.go", - "backend/internal/service/admin_group.go", "backend/internal/service/admin_group_duplicate.go", "backend/internal/service/admin_group_platform_cache_test.go", + "backend/internal/service/admin_observability.go", + "backend/internal/service/admin_observability_test.go", "backend/internal/service/admin_service.go", + "backend/internal/service/admin_service_apikey_test.go", "backend/internal/service/admin_service_bulk_update_test.go", "backend/internal/service/admin_service_composite_group_test.go", "backend/internal/service/admin_service_group_test.go", "backend/internal/service/admin_service_spark_shadow_test.go", + "backend/internal/service/admin_user.go", "backend/internal/service/agent_cc_stream_contract_test.go", "backend/internal/service/agent_raw_stream_contract_test.go", "backend/internal/service/agent_request_capabilities_test.go", "backend/internal/service/agent_selection_diagnostics_test.go", "backend/internal/service/agent_upstream_error_scope_test.go", - "backend/internal/service/anthropic_apikey_auth.go", "backend/internal/service/antigravity_gateway_compat_stream.go", + "backend/internal/service/antigravity_gateway_gemini.go", + "backend/internal/service/antigravity_gateway_retry.go", "backend/internal/service/antigravity_gateway_service.go", + "backend/internal/service/antigravity_gateway_upstream.go", "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_profit_test.go", "backend/internal/service/api_key_auth_cache_version_test.go", "backend/internal/service/api_key_service.go", + "backend/internal/service/audit_log_service.go", + "backend/internal/service/audit_log_test.go", "backend/internal/service/auth_service_platform_quota_test.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_account_scope_test.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", - "backend/internal/service/business_system_prompt_composition.go", - "backend/internal/service/business_system_prompt_composition_test.go", - "backend/internal/service/business_system_prompt_gateway_test.go", - "backend/internal/service/business_system_prompt_offline_integration_test.go", - "backend/internal/service/business_system_prompt_policy.go", - "backend/internal/service/business_system_prompt_rules_apply.go", - "backend/internal/service/business_system_prompt_rules_gateway_test.go", - "backend/internal/service/business_system_prompt_rules_observation.go", - "backend/internal/service/business_system_prompt_rules_preview.go", - "backend/internal/service/business_system_prompt_rules_service.go", - "backend/internal/service/business_system_prompt_rules_test.go", - "backend/internal/service/business_system_prompt_rules_ws.go", - "backend/internal/service/business_system_prompt_service.go", - "backend/internal/service/business_system_prompt_service_test.go", - "backend/internal/service/business_system_prompt_test.go", - "backend/internal/service/business_system_prompt_turn_cache.go", - "backend/internal/service/business_system_prompt_turn_cache_test.go", - "backend/internal/service/channel_cindy_managed_crud_test.go", - "backend/internal/service/channel_service.go", - "backend/internal/service/cindy_account_test_plan.go", - "backend/internal/service/cindy_account_view_probe_test.go", - "backend/internal/service/cindy_alpha_search_plan.go", - "backend/internal/service/cindy_alpha_search_plan_test.go", - "backend/internal/service/cindy_balance.go", - "backend/internal/service/cindy_balance_native_messages_test.go", - "backend/internal/service/cindy_balance_pending_test.go", - "backend/internal/service/cindy_balance_probe_service.go", - "backend/internal/service/cindy_balance_probe_service_test.go", - "backend/internal/service/cindy_balance_probe_types.go", - "backend/internal/service/cindy_balance_recheck.go", - "backend/internal/service/cindy_balance_recheck_test.go", - "backend/internal/service/cindy_balance_response_test.go", - "backend/internal/service/cindy_balance_shared_test.go", - "backend/internal/service/cindy_capability_catalog.go", - "backend/internal/service/cindy_catalog_snapshot.go", - "backend/internal/service/cindy_channel_catalog.go", - "backend/internal/service/cindy_channel_catalog_test.go", - "backend/internal/service/cindy_codex_client_version.go", - "backend/internal/service/cindy_duplicate_inventory.go", - "backend/internal/service/cindy_duplicate_inventory_test.go", - "backend/internal/service/cindy_endpoint_scheduling_test.go", - "backend/internal/service/cindy_feature_flags.go", - "backend/internal/service/cindy_feature_flags_independence_test.go", - "backend/internal/service/cindy_feature_flags_test.go", - "backend/internal/service/cindy_group.go", - "backend/internal/service/cindy_group_identity_test.go", - "backend/internal/service/cindy_group_materialized_test.go", - "backend/internal/service/cindy_health.go", - "backend/internal/service/cindy_health_policy_scope_test.go", - "backend/internal/service/cindy_health_service_test.go", - "backend/internal/service/cindy_identity.go", - "backend/internal/service/cindy_identity_test.go", - "backend/internal/service/cindy_image_pricing_test.go", - "backend/internal/service/cindy_independent_snapshot_test.go", - "backend/internal/service/cindy_native_count_tokens_test.go", - "backend/internal/service/cindy_native_image_snapshot_test.go", - "backend/internal/service/cindy_native_messages_scope_test.go", - "backend/internal/service/cindy_native_messages_test.go", - "backend/internal/service/cindy_pricing_snapshot.go", - "backend/internal/service/cindy_pricing_snapshot_test.go", - "backend/internal/service/cindy_probe_account_scope_test.go", - "backend/internal/service/cindy_rollout_testmain_test.go", - "backend/internal/service/cindy_snapshot_pricing_second_review_test.go", - "backend/internal/service/cindy_terminal_scheduling_isolation_test.go", - "backend/internal/service/cindy_text_billing.go", - "backend/internal/service/cindy_text_billing_test.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/codex_connection_lease.go", + "backend/internal/service/codex_error_diagnostics.go", + "backend/internal/service/codex_error_diagnostics_test.go", + "backend/internal/service/codex_native_accounts.go", + "backend/internal/service/codex_native_accounts_test.go", + "backend/internal/service/codex_native_compatibility_test.go", + "backend/internal/service/codex_native_contract.go", + "backend/internal/service/codex_native_fixture_test.go", + "backend/internal/service/codex_native_host.go", + "backend/internal/service/codex_native_policy.go", + "backend/internal/service/codex_native_provider.go", + "backend/internal/service/codex_native_provider_receipt_test.go", + "backend/internal/service/codex_native_runtime.go", + "backend/internal/service/codex_outbound_contract.go", + "backend/internal/service/codex_quality_composition_test.go", + "backend/internal/service/codex_quality_contract_test.go", + "backend/internal/service/codex_quality_health.go", + "backend/internal/service/codex_quality_manual_stop_test.go", + "backend/internal/service/codex_quality_observation.go", + "backend/internal/service/codex_quality_observation_test.go", + "backend/internal/service/codex_quality_route.go", + "backend/internal/service/codex_quality_runtime.go", + "backend/internal/service/codex_quality_state.go", + "backend/internal/service/codex_quality_state_test.go", + "backend/internal/service/codex_quality_test.go", + "backend/internal/service/codex_quality_wire_test.go", "backend/internal/service/codex_routing_completion.go", "backend/internal/service/codex_routing_cookies.go", "backend/internal/service/codex_routing_demand.go", @@ -920,12 +659,12 @@ "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_responses.go", + "backend/internal/service/gateway_image_reasoning_pricing_test.go", "backend/internal/service/gateway_model_availability.go", "backend/internal/service/gateway_model_availability_test.go", "backend/internal/service/gateway_multiplatform_test.go", @@ -937,10 +676,10 @@ "backend/internal/service/gateway_service_subscription_billing_test.go", "backend/internal/service/gateway_usage_billing.go", "backend/internal/service/gemini_chat_completions_compat_service.go", + "backend/internal/service/gemini_messages_compat_service.go", "backend/internal/service/gemini_messages_compat_service_test.go", "backend/internal/service/gemini_multiplatform_test.go", "backend/internal/service/gemini_oauth_service_test.go", - "backend/internal/service/gpt56_prompt_source.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", @@ -961,24 +700,27 @@ "backend/internal/service/image_studio_job_service_test.go", "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/image_tools.go", + "backend/internal/service/image_tools_policy.go", + "backend/internal/service/image_tools_policy_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_catalog_test_fixture_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_context_official_catalog.go", + "backend/internal/service/model_context_official_catalog_data.go", + "backend/internal/service/model_context_official_catalog_test.go", "backend/internal/service/model_pricing_resolver.go", - "backend/internal/service/model_pricing_resolver_test.go", "backend/internal/service/model_rate_limit.go", - "backend/internal/service/namespace_roundtrip_control_test.go", - "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/native_feature_bootstrap.go", + "backend/internal/service/native_feature_bootstrap_test.go", + "backend/internal/service/native_policy_errors.go", + "backend/internal/service/native_policy_fixtures.go", + "backend/internal/service/native_setting_load_test.go", "backend/internal/service/oauth_refresh_api.go", "backend/internal/service/oauth_refresh_api_test.go", "backend/internal/service/oauth_service.go", @@ -992,27 +734,15 @@ "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", "backend/internal/service/openai_alpha_search_test.go", "backend/internal/service/openai_apikey_compat.go", "backend/internal/service/openai_apikey_compat_test.go", - "backend/internal/service/openai_apikey_responses_probe.go", - "backend/internal/service/openai_apikey_responses_probe_test.go", "backend/internal/service/openai_astra_pro_mode_test.go", "backend/internal/service/openai_astra_pro_retry_test.go", + "backend/internal/service/openai_budget_exceeded.go", "backend/internal/service/openai_bulk_account_settings.go", - "backend/internal/service/openai_business_system_prompt.go", - "backend/internal/service/openai_business_system_prompt_observability.go", - "backend/internal/service/openai_business_system_prompt_observability_test.go", "backend/internal/service/openai_chat_reasoning_replay.go", "backend/internal/service/openai_chat_reasoning_replay_test.go", - "backend/internal/service/openai_cindy_continuation.go", - "backend/internal/service/openai_cindy_continuation_ingress_test.go", - "backend/internal/service/openai_cindy_continuation_test.go", - "backend/internal/service/openai_cindy_http_wsv2.go", - "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", @@ -1026,7 +756,6 @@ "backend/internal/service/openai_codex_identity_snapshot.go", "backend/internal/service/openai_codex_model_metadata.go", "backend/internal/service/openai_codex_model_metadata_test.go", - "backend/internal/service/openai_codex_models_pinned.go", "backend/internal/service/openai_codex_models_service.go", "backend/internal/service/openai_codex_models_service_test.go", "backend/internal/service/openai_codex_request_compression.go", @@ -1058,7 +787,6 @@ "backend/internal/service/openai_encrypted_content_lineage_legacy_test.go", "backend/internal/service/openai_failover_cached_body_test.go", "backend/internal/service/openai_fast_policy_test.go", - "backend/internal/service/openai_gateway_403_reset_test.go", "backend/internal/service/openai_gateway_anthropic_native_pump_test.go", "backend/internal/service/openai_gateway_apikey_item_id_test.go", "backend/internal/service/openai_gateway_cc_pipeline.go", @@ -1068,9 +796,9 @@ "backend/internal/service/openai_gateway_chat_completions_raw.go", "backend/internal/service/openai_gateway_chat_completions_raw_retry_metadata_test.go", "backend/internal/service/openai_gateway_chat_completions_raw_test.go", + "backend/internal/service/openai_gateway_chat_completions_test.go", "backend/internal/service/openai_gateway_cn_fixes_test.go", "backend/internal/service/openai_gateway_count_tokens.go", - "backend/internal/service/openai_gateway_count_tokens_test.go", "backend/internal/service/openai_gateway_deepseek_chat_reasoning_test.go", "backend/internal/service/openai_gateway_forward.go", "backend/internal/service/openai_gateway_grok.go", @@ -1084,6 +812,8 @@ "backend/internal/service/openai_gateway_messages_chat_fallback_test.go", "backend/internal/service/openai_gateway_model_availability.go", "backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning_test.go", + "backend/internal/service/openai_gateway_ops_visibility.go", + "backend/internal/service/openai_gateway_ops_visibility_test.go", "backend/internal/service/openai_gateway_passthrough.go", "backend/internal/service/openai_gateway_passthrough_flush_test.go", "backend/internal/service/openai_gateway_record_usage_test.go", @@ -1109,19 +839,18 @@ "backend/internal/service/openai_gateway_usage.go", "backend/internal/service/openai_gpt6_models.go", "backend/internal/service/openai_gpt6_models_test.go", - "backend/internal/service/openai_gpt6_pricing.go", "backend/internal/service/openai_gpt6_pricing_test.go", "backend/internal/service/openai_guardian_affinity_test.go", - "backend/internal/service/openai_http_response_owner_bridge_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", "backend/internal/service/openai_images_incomplete_test.go", "backend/internal/service/openai_images_responses.go", "backend/internal/service/openai_images_test.go", + "backend/internal/service/openai_images_tool_cooldown_test.go", "backend/internal/service/openai_lineage_regression_helpers_test.go", "backend/internal/service/openai_live.go", "backend/internal/service/openai_load_sticky_privacy_test.go", + "backend/internal/service/openai_messages_continuation.go", "backend/internal/service/openai_model_alias.go", "backend/internal/service/openai_model_mapping.go", "backend/internal/service/openai_model_mapping_test.go", @@ -1141,14 +870,17 @@ "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_account_directory_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_privacy_service.go", "backend/internal/service/openai_profit_control.go", "backend/internal/service/openai_profit_control_legacy_diagnostics_test.go", "backend/internal/service/openai_profit_control_paths_test.go", "backend/internal/service/openai_profit_control_pricing_test.go", "backend/internal/service/openai_profit_control_test.go", + "backend/internal/service/openai_proxy_stream_circuit.go", "backend/internal/service/openai_quota_auto_reset.go", "backend/internal/service/openai_quota_service.go", "backend/internal/service/openai_raw_stream_truncation.go", @@ -1175,19 +907,22 @@ "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_response_model_rewrite_test.go", + "backend/internal/service/openai_responses_continuation.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_rejected_field_retry_test.go", "backend/internal/service/openai_responses_tool_schema_test.go", + "backend/internal/service/openai_routing_hint.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", + "backend/internal/service/openai_subscription_test.go", "backend/internal/service/openai_token_provider.go", "backend/internal/service/openai_tool_continuation.go", "backend/internal/service/openai_tool_continuation_test.go", @@ -1195,6 +930,8 @@ "backend/internal/service/openai_upstream_transport_error.go", "backend/internal/service/openai_upstream_transport_error_handle_test.go", "backend/internal/service/openai_v183_compat.go", + "backend/internal/service/openai_workspace_identity.go", + "backend/internal/service/openai_workspace_identity_test.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", @@ -1228,6 +965,7 @@ "backend/internal/service/ops_metrics_collector.go", "backend/internal/service/ops_metrics_collector_projection_test.go", "backend/internal/service/ops_metrics_collector_test.go", + "backend/internal/service/ops_models.go", "backend/internal/service/ops_queue_sanitize_test.go", "backend/internal/service/ops_service.go", "backend/internal/service/ops_service_user_error_test.go", @@ -1235,118 +973,33 @@ "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_account_create.go", - "backend/internal/service/plugin_account_create_test.go", - "backend/internal/service/plugin_account_edit.go", - "backend/internal/service/plugin_account_edit_test.go", - "backend/internal/service/plugin_account_import.go", - "backend/internal/service/plugin_account_import_test.go", - "backend/internal/service/plugin_account_tools.go", - "backend/internal/service/plugin_account_view_jobs_test.go", - "backend/internal/service/plugin_account_view_resources_test.go", - "backend/internal/service/plugin_account_views.go", - "backend/internal/service/plugin_account_views_test.go", - "backend/internal/service/plugin_bootstrap_completion_test.go", - "backend/internal/service/plugin_broker_scope_test.go", - "backend/internal/service/plugin_bundle.go", - "backend/internal/service/plugin_bundle_pin_race_test.go", - "backend/internal/service/plugin_catalog_test.go", - "backend/internal/service/plugin_cindy.go", - "backend/internal/service/plugin_cindy_codex.go", - "backend/internal/service/plugin_cindy_codex_test.go", - "backend/internal/service/plugin_cindy_management.go", - "backend/internal/service/plugin_cindy_management_test.go", - "backend/internal/service/plugin_cindy_probe.go", - "backend/internal/service/plugin_cindy_probe_test.go", "backend/internal/service/plugin_codex_identity.go", "backend/internal/service/plugin_codex_identity_test.go", "backend/internal/service/plugin_codex_recovery.go", "backend/internal/service/plugin_codex_recovery_test.go", "backend/internal/service/plugin_codex_scope_test.go", "backend/internal/service/plugin_codex_transport.go", - "backend/internal/service/plugin_compatibility.go", - "backend/internal/service/plugin_config_receipt_test.go", - "backend/internal/service/plugin_contribution_admission.go", - "backend/internal/service/plugin_contribution_admission_test.go", - "backend/internal/service/plugin_credential_scope_test.go", - "backend/internal/service/plugin_domain_operation.go", - "backend/internal/service/plugin_domain_operation_test.go", - "backend/internal/service/plugin_error_diagnostics.go", - "backend/internal/service/plugin_error_diagnostics_test.go", - "backend/internal/service/plugin_extension_accounts.go", - "backend/internal/service/plugin_extension_accounts_test.go", - "backend/internal/service/plugin_extension_package_test.go", - "backend/internal/service/plugin_extension_runtime_test.go", - "backend/internal/service/plugin_extension_state.go", - "backend/internal/service/plugin_extensions.go", - "backend/internal/service/plugin_extensions_test.go", - "backend/internal/service/plugin_feature_decode_test.go", - "backend/internal/service/plugin_host_io_admission_test.go", - "backend/internal/service/plugin_host_services.go", - "backend/internal/service/plugin_host_services_broker_test.go", - "backend/internal/service/plugin_image_bridge.go", - "backend/internal/service/plugin_image_bridge_test.go", - "backend/internal/service/plugin_image_tools.go", - "backend/internal/service/plugin_install_guard_test.go", - "backend/internal/service/plugin_jobs.go", - "backend/internal/service/plugin_legacy_host_port_test.go", - "backend/internal/service/plugin_manager.go", - "backend/internal/service/plugin_manifest.go", - "backend/internal/service/plugin_manifest_schema_test.go", - "backend/internal/service/plugin_observability.go", - "backend/internal/service/plugin_observability_test.go", - "backend/internal/service/plugin_operation.go", - "backend/internal/service/plugin_operation_admission.go", - "backend/internal/service/plugin_operation_admission_test.go", - "backend/internal/service/plugin_operation_context.go", - "backend/internal/service/plugin_operation_context_test.go", - "backend/internal/service/plugin_operation_test.go", - "backend/internal/service/plugin_package.go", - "backend/internal/service/plugin_package_test.go", - "backend/internal/service/plugin_precondition.go", "backend/internal/service/plugin_prompt_fixture_test.go", - "backend/internal/service/plugin_prompt_management.go", - "backend/internal/service/plugin_prompt_profile_test.go", - "backend/internal/service/plugin_prompt_request_context.go", - "backend/internal/service/plugin_prompt_test.go", "backend/internal/service/plugin_provider.go", - "backend/internal/service/plugin_reconcile_update_registry_test.go", - "backend/internal/service/plugin_request.go", - "backend/internal/service/plugin_request_scope_test.go", - "backend/internal/service/plugin_resources.go", - "backend/internal/service/plugin_resources_test.go", - "backend/internal/service/plugin_runtime.go", - "backend/internal/service/plugin_runtime_lease_loss_test.go", - "backend/internal/service/plugin_scheduling.go", - "backend/internal/service/plugin_security_regression_test.go", - "backend/internal/service/plugin_theme.go", - "backend/internal/service/plugin_theme_test.go", - "backend/internal/service/plugin_ui_session_test.go", - "backend/internal/service/plugin_update.go", - "backend/internal/service/plugin_update_contract.go", - "backend/internal/service/plugin_update_test.go", + "backend/internal/service/policy_context.go", + "backend/internal/service/policy_context_test.go", "backend/internal/service/pricing_named_family_test.go", "backend/internal/service/pricing_service.go", "backend/internal/service/profit_control_dormant_regression_test.go", "backend/internal/service/profit_preview.go", "backend/internal/service/profit_preview_test.go", - "backend/internal/service/prompt_domain_runtime.go", - "backend/internal/service/prompt_domain_runtime_test.go", - "backend/internal/service/provider_identity.go", - "backend/internal/service/provider_identity_admin_test.go", - "backend/internal/service/provider_identity_test.go", "backend/internal/service/quota_activity.go", "backend/internal/service/quota_activity_test.go", "backend/internal/service/quota_estimate.go", "backend/internal/service/quota_estimate_test.go", "backend/internal/service/quota_measurement_test.go", "backend/internal/service/ratelimit_service.go", - "backend/internal/service/ratelimit_service_403_test.go", "backend/internal/service/ratelimit_service_anthropic_test.go", - "backend/internal/service/ratelimit_service_cindy_balance_test.go", + "backend/internal/service/ratelimit_service_model_not_found_test.go", "backend/internal/service/ratelimit_service_openai_image_test.go", "backend/internal/service/ratelimit_service_openai_test.go", "backend/internal/service/ratelimit_session_window_test.go", + "backend/internal/service/reasoning_effort_billing_test.go", "backend/internal/service/reasoning_fidelity_control_test.go", "backend/internal/service/reasoning_fidelity_live_test.go", "backend/internal/service/reasoning_fidelity_parse_test.go", @@ -1355,40 +1008,25 @@ "backend/internal/service/reasoning_replay_diagnostic_live_test.go", "backend/internal/service/reasoning_replay_diagnostic_parse_test.go", "backend/internal/service/reasoning_replay_diagnostic_support_test.go", - "backend/internal/service/remote_skill_prompt.go", - "backend/internal/service/remote_skill_prompt_test.go", - "backend/internal/service/remote_skill_publication.go", - "backend/internal/service/remote_skill_publication_test.go", - "backend/internal/service/remote_skill_registry.go", - "backend/internal/service/remote_skill_registry_filesystem.go", - "backend/internal/service/remote_skill_registry_filesystem_test.go", - "backend/internal/service/remote_skill_registry_manifest.go", - "backend/internal/service/remote_skill_registry_manifest_test.go", - "backend/internal/service/remote_skill_registry_source.go", - "backend/internal/service/remote_skill_registry_source_provenance_test.go", - "backend/internal/service/remote_skill_registry_source_test.go", - "backend/internal/service/remote_skill_registry_test.go", - "backend/internal/service/remote_skill_registry_validation.go", - "backend/internal/service/remote_skill_seed_fixture_test.go", "backend/internal/service/responses_chat_protocol_callers_test.go", - "backend/internal/service/scheduled_test_port.go", "backend/internal/service/scheduler_snapshot_bulk_event_test.go", - "backend/internal/service/scheduler_snapshot_group_lifecycle_test.go", "backend/internal/service/scheduler_snapshot_service.go", + "backend/internal/service/setting_admin_observability.go", "backend/internal/service/setting_codex_ticket_test.go", "backend/internal/service/setting_gateway_runtime.go", "backend/internal/service/setting_gateway_runtime_refusal_test.go", + "backend/internal/service/setting_image_tools.go", + "backend/internal/service/setting_json.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/system_prompt.go", + "backend/internal/service/system_prompt_inject.go", + "backend/internal/service/system_prompt_openai.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-11.json", - "backend/internal/service/testdata/codexrip_reverse_skill_system_prompt_legacy_dual_marker.txt", "backend/internal/service/testdata/plugin_contribution_buckets.json", "backend/internal/service/timing_wheel_service_test.go", "backend/internal/service/token_refresh_service.go", @@ -1396,6 +1034,7 @@ "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_model_catalog_refresh.go", "backend/internal/service/upstream_models.go", "backend/internal/service/upstream_models_capacity_sync_test.go", "backend/internal/service/upstream_models_test.go", @@ -1418,9 +1057,7 @@ "backend/migrations/198_business_system_prompt_codex_skill_hybrid.sql", "backend/migrations/199_business_system_prompt_managed_source.sql", "backend/migrations/200_remote_skill_sources_and_legacy_cleanup.sql", - "backend/migrations/200_remote_skill_sources_and_legacy_cleanup_test.go", "backend/migrations/201_remote_skill_paired_candidates.sql", - "backend/migrations/201_remote_skill_paired_candidates_test.go", "backend/migrations/202_add_cindy_balance_insufficient_at.sql", "backend/migrations/207_codexrip_restore_cindy_openai_defaults.sql", "backend/migrations/208_codexrip_use_cindy_prompt_cache_hash.sql", @@ -1458,13 +1095,16 @@ "backend/migrations/251_user_platform_quota_platform_union.sql", "backend/migrations/252_cindy_identity_null_total.sql", "backend/migrations/253_prompt_rule_policies.sql", + "backend/migrations/254_plugin_job_action_digest.sql", + "backend/migrations/255_prompt_rule_policy_v2.sql", + "backend/migrations/256_prompt_independent_content.sql", + "backend/migrations/258_fold_model_context_capacities_into_metadata.sql", + "backend/migrations/259_system_prompt_library.sql", + "backend/migrations/260_cindy_openai_apikey_preset.sql", + "backend/migrations/261_channel_pricing_interval_tier_label.sql", + "backend/migrations/262_account_job_error_text.sql", "backend/migrations/account_credential_identity_migration_test.go", "backend/migrations/account_jobs_slim_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", - "backend/migrations/business_system_prompt_skill_bundles_migration_test.go", - "backend/migrations/business_system_prompts_migration_test.go", "backend/migrations/cindy_account_stats_reset_migration_test.go", "backend/migrations/cindy_alpha_search_bridge_migration_integration_test.go", "backend/migrations/cindy_alpha_search_bridge_migration_test.go", @@ -1487,77 +1127,12 @@ "backend/migrations/minimax_platform_migration_test.go", "backend/migrations/opencode_go_platform_migration_test.go", "backend/migrations/platform_quota_forward_union_migration_integration_test.go", - "backend/migrations/prompt_rule_policies_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/pkg/extensionapi/proxy/address.go", - "backend/pkg/extensionapi/proxy/transport.go", - "backend/pkg/extensionapi/ui/account-create.ts", - "backend/pkg/extensionapi/ui/account-edit.ts", - "backend/pkg/extensionapi/ui/account-view.ts", - "backend/pkg/extensionapi/ui/base.css", - "backend/pkg/extensionapi/ui/bridge.js", - "backend/pkg/extensionapi/ui/build.mjs", - "backend/pkg/extensionapi/ui/client.ts", - "backend/pkg/extensionapi/ui/components/BaseDialog.vue", - "backend/pkg/extensionapi/ui/components/ConfirmDialog.vue", - "backend/pkg/extensionapi/ui/components/Icon.vue", - "backend/pkg/extensionapi/ui/components/Pagination.vue", - "backend/pkg/extensionapi/ui/components/Select.vue", - "backend/pkg/extensionapi/ui/components/Toggle.vue", - "backend/pkg/extensionapi/ui/components/dialogStack.ts", - "backend/pkg/extensionapi/ui/context.ts", - "backend/pkg/extensionapi/ui/errors.ts", - "backend/pkg/extensionapi/ui/index.ts", - "backend/pkg/extensionapi/ui/media.ts", - "backend/pkg/extensionapi/ui/presentation.ts", - "backend/pkg/extensionapi/ui/sizing.ts", - "backend/pkg/extensionapi/ui/stylesheets.ts", - "backend/pkg/extensionapi/ui/tailwind.preset.mjs", - "backend/pkg/extensionapi/ui/theme.css", - "backend/pkg/extensionapi/ui/tsconfig.json", - "backend/pkg/extensionapi/v1/account_create.go", - "backend/pkg/extensionapi/v1/account_edit.go", - "backend/pkg/extensionapi/v1/account_import.go", - "backend/pkg/extensionapi/v1/account_tools.go", - "backend/pkg/extensionapi/v1/account_view.go", - "backend/pkg/extensionapi/v1/api.go", - "backend/pkg/extensionapi/v1/catalog.go", - "backend/pkg/extensionapi/v1/cindy.go", - "backend/pkg/extensionapi/v1/cindy_account_test_plan.go", - "backend/pkg/extensionapi/v1/cindy_alpha_search.go", - "backend/pkg/extensionapi/v1/cindy_catalog_snapshot.go", - "backend/pkg/extensionapi/v1/cindy_clone.go", - "backend/pkg/extensionapi/v1/cindy_codex.go", - "backend/pkg/extensionapi/v1/cindy_health.go", - "backend/pkg/extensionapi/v1/cindy_management.go", - "backend/pkg/extensionapi/v1/cindy_probe.go", - "backend/pkg/extensionapi/v1/codex_identity.go", - "backend/pkg/extensionapi/v1/codex_recovery.go", - "backend/pkg/extensionapi/v1/codex_routing.go", - "backend/pkg/extensionapi/v1/codex_transport.go", - "backend/pkg/extensionapi/v1/decision.go", - "backend/pkg/extensionapi/v1/document.go", - "backend/pkg/extensionapi/v1/document_path.go", - "backend/pkg/extensionapi/v1/image.go", - "backend/pkg/extensionapi/v1/observability.go", - "backend/pkg/extensionapi/v1/prompt.go", - "backend/pkg/extensionapi/v1/prompt_rules.go", - "backend/pkg/extensionapi/v1/publication.go", - "backend/pkg/extensionapi/v1/publisher.go", - "backend/pkg/extensionapi/v1/publisher.json", - "backend/pkg/extensionapi/v1/resources.go", - "backend/pkg/extensionapi/v1/rpc.go", - "backend/pkg/extensionapi/v1/rpc_test.go", - "backend/pkg/extensionapi/v1/runtime.go", - "backend/pkg/extensionapi/v1/runtime_config_test.go", - "backend/pkg/extensionapi/v1/skills.go", "backend/pkg/pluginapi/v1/manifest.schema.json", "backend/pkg/pluginapi/v1/runtime.go", "backend/resources/model-pricing/model_prices_and_context_window.json", - "backend/scripts/build-development-plugins.sh", "deploy/.env.example", "deploy/Dockerfile", "deploy/README.md", @@ -1568,45 +1143,33 @@ "deploy/docker-compose.yml", "deploy/docker-entrypoint.sh", "deploy/plugin-bundle/README.md", - "deploy/tests/cindy-rollout-defaults-test.sh", "deploy/tests/docker-compose-gateway-env-test.sh", "deploy/tests/docker-runtime-resources-test.sh", "deploy/tests/production-deploy-workflow-test.sh", - "deploy/tests/test_verify_business_system_prompt_bundle.py", - "docs/PLUGIN_DEVELOPMENT.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__/fixtures/accountEdit.ts", "frontend/src/__tests__/integration/data-import.spec.ts", "frontend/src/api/__tests__/accountOperationIdempotency.spec.ts", "frontend/src/api/__tests__/admin.accounts.contextCapacity.spec.ts", - "frontend/src/api/__tests__/admin.accounts.editContext.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__/codex.spec.ts", "frontend/src/api/__tests__/downstreamStatus.spec.ts", "frontend/src/api/__tests__/pluginRawConfig.spec.ts", "frontend/src/api/__tests__/settings.authSourceDefaults.spec.ts", "frontend/src/api/accountOperationIdempotency.ts", "frontend/src/api/admin/__tests__/accountJobs.spec.ts", - "frontend/src/api/admin/__tests__/plugins.precondition.spec.ts", - "frontend/src/api/admin/__tests__/systemPrompts.spec.ts", "frontend/src/api/admin/accountJobs.ts", "frontend/src/api/admin/accountViewClient.ts", "frontend/src/api/admin/accounts.ts", - "frontend/src/api/admin/cindyBalanceProbe.ts", "frontend/src/api/admin/cnProviders.ts", + "frontend/src/api/admin/codexRuntime.ts", "frontend/src/api/admin/codexTickets.ts", "frontend/src/api/admin/grok.ts", - "frontend/src/api/admin/groups.ts", "frontend/src/api/admin/index.ts", - "frontend/src/api/admin/pluginDispatch.ts", "frontend/src/api/admin/plugins.ts", "frontend/src/api/admin/scheduledTests.ts", "frontend/src/api/admin/settings.ts", @@ -1614,13 +1177,14 @@ "frontend/src/api/admin/systemPrompts.ts", "frontend/src/api/admin/users.ts", "frontend/src/api/client.ts", + "frontend/src/api/codex.ts", "frontend/src/api/downstreamStatus.ts", - "frontend/src/components/AliyunCaptchaWidget.vue", - "frontend/src/components/TurnstileWidget.vue", + "frontend/src/assets/fonts/geist/GeistMono-Variable.latin.woff2", + "frontend/src/assets/fonts/geist/OFL.txt", + "frontend/src/assets/fonts/inter/Inter-Variable.latin.woff2", + "frontend/src/assets/fonts/inter/OFL.txt", "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", @@ -1629,25 +1193,15 @@ "frontend/src/components/account/BulkEditAccountModal.vue", "frontend/src/components/account/CNProviderBalanceCell.vue", "frontend/src/components/account/CNProviderQuotaCell.vue", - "frontend/src/components/account/CapacityBadge.vue", "frontend/src/components/account/CreateAccountModal.vue", "frontend/src/components/account/EditAccountModal.vue", "frontend/src/components/account/GrokQuotaProbeCell.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/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/UsageProgressBar.vue", "frontend/src/components/account/__tests__/AccountStatusIndicator.spec.ts", "frontend/src/components/account/__tests__/AccountTestModal.spec.ts", @@ -1655,20 +1209,22 @@ "frontend/src/components/account/__tests__/AccountUsageCell.spec.ts", "frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts", "frontend/src/components/account/__tests__/CreateAccountModal.codexFingerprint.spec.ts", - "frontend/src/components/account/__tests__/CreateAccountModal.providerCreate.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.grokUpstream.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__/OpenAIQuotaResetCell.spark_shadow.spec.ts", "frontend/src/components/account/__tests__/OpenAIReasoningPolicyFields.spec.ts", + "frontend/src/components/account/__tests__/TempUnschedStatusModal.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/ImageToolsSettingsPanel.vue", + "frontend/src/components/admin/ObservabilitySettingsPanel.vue", + "frontend/src/components/admin/OfficialModelCatalogPanel.vue", + "frontend/src/components/admin/RetiredPluginsPanel.vue", "frontend/src/components/admin/account-jobs/AccountOperationConfirmDialog.vue", "frontend/src/components/admin/account-jobs/AccountOperationDialog.vue", "frontend/src/components/admin/account-jobs/AccountOperationProgress.vue", @@ -1682,53 +1238,52 @@ "frontend/src/components/admin/account/AccountConsoleFilters.vue", "frontend/src/components/admin/account/AccountDetailsDrawer.vue", "frontend/src/components/admin/account/AccountFolderBar.vue", - "frontend/src/components/admin/account/AccountPromptBindingPanel.vue", + "frontend/src/components/admin/account/AccountImportSettingsEditor.vue", + "frontend/src/components/admin/account/AccountLastUpstreamError.vue", "frontend/src/components/admin/account/AccountStatsModal.vue", + "frontend/src/components/admin/account/AccountSystemPromptBinding.vue", "frontend/src/components/admin/account/AccountTableFilters.vue", + "frontend/src/components/admin/account/AccountTaxonomyEditor.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/AccountTestReasoningSelect.vue", "frontend/src/components/admin/account/AccountTextTestPrompt.vue", "frontend/src/components/admin/account/AccountViewModeSwitcher.vue", - "frontend/src/components/admin/account/BatchTestAccountModal.vue", + "frontend/src/components/admin/account/BatchAccountTestModal.vue", "frontend/src/components/admin/account/CodexFingerprintPanel.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.position.spec.ts", "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", "frontend/src/components/admin/account/__tests__/AccountConsoleFilters.spec.ts", "frontend/src/components/admin/account/__tests__/AccountDetailsDrawer.spec.ts", + "frontend/src/components/admin/account/__tests__/AccountFolderBar.spec.ts", + "frontend/src/components/admin/account/__tests__/AccountLastUpstreamError.spec.ts", + "frontend/src/components/admin/account/__tests__/AccountTaxonomyManager.spec.ts", "frontend/src/components/admin/account/__tests__/AccountTestModal.displayContract.spec.ts", "frontend/src/components/admin/account/__tests__/AccountTestModal.policyPlan.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__/CodexFingerprintPanel.spec.ts", - "frontend/src/components/admin/announcements/AnnouncementTargetingEditor.vue", - "frontend/src/components/admin/channel/PricingEntryCard.vue", - "frontend/src/components/admin/channel/TimePricingSection.vue", + "frontend/src/components/admin/account/__tests__/ImportDataModal.spec.ts", + "frontend/src/components/admin/codex/CodexAccountActions.vue", + "frontend/src/components/admin/codex/CodexContinuationDiagnostics.vue", + "frontend/src/components/admin/codex/CodexRuntimeSettings.vue", + "frontend/src/components/admin/codex/CodexTicketOperationModal.vue", + "frontend/src/components/admin/codex/__tests__/CodexRuntime.native.spec.ts", + "frontend/src/components/admin/codex/__tests__/CodexRuntime.proxy.spec.ts", "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/MonitorFiltersBar.vue", - "frontend/src/components/admin/monitor/MonitorFormDialog.vue", - "frontend/src/components/admin/monitor/MonitorTemplateApplyPickerDialog.vue", - "frontend/src/components/admin/monitor/MonitorTemplateManagerDialog.vue", - "frontend/src/components/admin/payment/TopUsersLeaderboard.vue", + "frontend/src/components/admin/payment/DailyRevenueChart.vue", "frontend/src/components/admin/usage/UsageTable.vue", - "frontend/src/components/admin/usage/UserTokenRanking.vue", "frontend/src/components/admin/usage/__tests__/UsageTable.latency.spec.ts", - "frontend/src/components/admin/user/UserAllowedGroupsModal.vue", - "frontend/src/components/admin/user/UserBalanceHistoryModal.vue", - "frontend/src/components/admin/user/UserPlatformQuotaModal.vue", + "frontend/src/components/admin/user/UserApiKeysModal.vue", "frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts", - "frontend/src/components/auth/LoginAgreementPrompt.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", @@ -1738,39 +1293,23 @@ "frontend/src/components/common/AnnouncementBell.vue", "frontend/src/components/common/AnnouncementPopup.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/GroupBadge.vue", - "frontend/src/components/common/GroupCapacityBadge.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/IpGeoCell.vue", - "frontend/src/components/common/LoadingSpinner.vue", - "frontend/src/components/common/ModelIcon.vue", + "frontend/src/components/common/InfoPopover.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/SearchInput.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.availability.spec.ts", "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__/Select.availability.spec.ts", + "frontend/src/components/common/__tests__/PlatformTypeBadge.openaiPlans.spec.ts", + "frontend/src/components/common/__tests__/Select.disabled.spec.ts", "frontend/src/components/common/__tests__/VersionBadge.downstream.spec.ts", "frontend/src/components/common/dialogStack.ts", - "frontend/src/components/icons/Icon.vue", - "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", @@ -1780,89 +1319,17 @@ "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/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/payment/__tests__/StripePaymentInline.contrast.spec.ts", "frontend/src/components/payment/__tests__/SubscriptionPlanCard.spec.ts", - "frontend/src/components/plugins/AccountColumnDisplay.vue", - "frontend/src/components/plugins/AccountViewPage.vue", - "frontend/src/components/plugins/ExtensionDialog.vue", - "frontend/src/components/plugins/ExtensionDisplay.vue", - "frontend/src/components/plugins/ExtensionFields.vue", - "frontend/src/components/plugins/ExtensionModal.vue", - "frontend/src/components/plugins/ExtensionPage.vue", - "frontend/src/components/plugins/ExtensionSlot.vue", - "frontend/src/components/plugins/ExtensionSurface.vue", - "frontend/src/components/plugins/ExtensionWidget.vue", - "frontend/src/components/plugins/PluginFrame.vue", - "frontend/src/components/plugins/__tests__/AccountViewPage.spec.ts", - "frontend/src/components/plugins/__tests__/CindyAccountControls.spec.ts", - "frontend/src/components/plugins/__tests__/ExtensionDialog.admission.spec.ts", - "frontend/src/components/plugins/__tests__/ExtensionFields.spec.ts", - "frontend/src/components/plugins/__tests__/ExtensionSlot.spec.ts", - "frontend/src/components/plugins/__tests__/PluginFrame.precondition.spec.ts", - "frontend/src/components/plugins/__tests__/accountView.fixtures.ts", - "frontend/src/components/plugins/__tests__/accountView.spec.ts", - "frontend/src/components/plugins/__tests__/accountViewNative.spec.ts", - "frontend/src/components/plugins/__tests__/accountViewStepUp.spec.ts", - "frontend/src/components/plugins/__tests__/bridgeLifecycle.spec.ts", - "frontend/src/components/plugins/__tests__/contributionAdmission.spec.ts", - "frontend/src/components/plugins/__tests__/localResources.spec.ts", - "frontend/src/components/plugins/__tests__/pluginViewDispatch.spec.ts", - "frontend/src/components/plugins/__tests__/preferences.spec.ts", - "frontend/src/components/plugins/__tests__/presentationStability.spec.ts", - "frontend/src/components/plugins/__tests__/resourceAvailability.spec.ts", - "frontend/src/components/plugins/__tests__/resourceClient.spec.ts", - "frontend/src/components/plugins/__tests__/theme.spec.ts", - "frontend/src/components/plugins/accountView.ts", - "frontend/src/components/plugins/context.ts", - "frontend/src/components/plugins/contributionAdmission.ts", - "frontend/src/components/plugins/localResources.ts", - "frontend/src/components/plugins/pluginContext.ts", - "frontend/src/components/plugins/pluginViewDispatch.ts", - "frontend/src/components/plugins/preferences.ts", - "frontend/src/components/plugins/resourceClient.ts", - "frontend/src/components/plugins/theme.ts", - "frontend/src/components/plugins/useRetainedContribution.ts", - "frontend/src/components/user/PlatformCostCell.vue", - "frontend/src/components/user/PlatformUsageBreakdown.vue", - "frontend/src/components/user/UserAttributesConfigModal.vue", - "frontend/src/components/user/UserConcurrencyCell.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/composables/__tests__/useAccountEditProfile.spec.ts", "frontend/src/composables/__tests__/useAccountSelectionMetadata.spec.ts", "frontend/src/composables/__tests__/useAccountTestPrompt.spec.ts", "frontend/src/composables/__tests__/useModelContextCapacities.spec.ts", "frontend/src/composables/__tests__/useModelWhitelist.spec.ts", - "frontend/src/composables/useAccountCreateProfile.ts", - "frontend/src/composables/useAccountEditProfile.ts", "frontend/src/composables/useAccountSelectionMetadata.ts", "frontend/src/composables/useAccountTestPrompt.ts", "frontend/src/composables/useAccountViewContext.ts", @@ -1872,33 +1339,31 @@ "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__/CindyBalanceProbeSummary.spec.ts", - "frontend/src/features/cindy-duplicate-inventory/CindyDuplicateInventoryPanel.vue", - "frontend/src/features/cindy-group-split/CindyGroupAuditDialog.vue", + "frontend/src/features/image-studio/ImageStudioPage.vue", + "frontend/src/features/image-studio/__tests__/ImageStudioPage.spec.ts", + "frontend/src/features/image-studio/__tests__/api.spec.ts", + "frontend/src/features/image-studio/api.ts", "frontend/src/features/image-studio/history.legacy.spec.ts", "frontend/src/features/image-studio/history.spec.ts", "frontend/src/features/image-studio/history.ts", + "frontend/src/features/image-studio/locales/en.ts", + "frontend/src/features/image-studio/locales/zh.ts", "frontend/src/features/prompt-audit/PromptAuditView.vue", "frontend/src/features/prompt-audit/__tests__/PromptAuditView.spec.ts", - "frontend/src/features/prompt-audit/__tests__/api.spec.ts", - "frontend/src/features/prompt-audit/__tests__/integrationSurface.spec.ts", - "frontend/src/features/prompt-audit/api.ts", + "frontend/src/features/prompt-audit/__tests__/components.spec.ts", + "frontend/src/features/prompt-audit/components/EndpointPool.vue", + "frontend/src/features/prompt-audit/viewModel.ts", "frontend/src/i18n/__tests__/accountConsoleLocales.spec.ts", - "frontend/src/i18n/__tests__/cindyProbeLocales.spec.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", "frontend/src/i18n/locales/en/admin/overview.ts", "frontend/src/i18n/locales/en/admin/plugins.ts", - "frontend/src/i18n/locales/en/admin/promptAudit.ts", + "frontend/src/i18n/locales/en/admin/resources.ts", "frontend/src/i18n/locales/en/admin/settings.ts", + "frontend/src/i18n/locales/en/admin/systemPrompts.ts", "frontend/src/i18n/locales/en/common.ts", "frontend/src/i18n/locales/en/imageStudio.ts", "frontend/src/i18n/locales/en/index.ts", @@ -1908,8 +1373,9 @@ "frontend/src/i18n/locales/zh/admin/index.ts", "frontend/src/i18n/locales/zh/admin/overview.ts", "frontend/src/i18n/locales/zh/admin/plugins.ts", - "frontend/src/i18n/locales/zh/admin/promptAudit.ts", + "frontend/src/i18n/locales/zh/admin/resources.ts", "frontend/src/i18n/locales/zh/admin/settings.ts", + "frontend/src/i18n/locales/zh/admin/systemPrompts.ts", "frontend/src/i18n/locales/zh/common.ts", "frontend/src/i18n/locales/zh/imageStudio.ts", "frontend/src/i18n/locales/zh/index.ts", @@ -1923,45 +1389,46 @@ "frontend/src/stores/accountJobs.ts", "frontend/src/stores/app.ts", "frontend/src/stores/index.ts", - "frontend/src/stores/pluginExtensions.ts", "frontend/src/style.css", - "frontend/src/styles/announcement-markdown.css", - "frontend/src/styles/onboarding.css", + "frontend/src/styles/flat-theme.css", "frontend/src/styles/theme.css", + "frontend/src/types/accountEdit.ts", + "frontend/src/types/imageStudio.ts", "frontend/src/types/index.ts", "frontend/src/utils/__tests__/accountEditCodec.spec.ts", - "frontend/src/utils/__tests__/cindyOpenAIDefaults.spec.ts", + "frontend/src/utils/__tests__/imageStudioLocalResources.spec.ts", "frontend/src/utils/__tests__/modelContextCapacity.spec.ts", "frontend/src/utils/__tests__/openaiReasoningPolicy.spec.ts", "frontend/src/utils/accountEditCodec.ts", + "frontend/src/utils/accountImportParser.ts", + "frontend/src/utils/accountImportWorker.ts", "frontend/src/utils/accountPresentation.ts", "frontend/src/utils/accountQuotaWindows.ts", "frontend/src/utils/accountTestModels.ts", - "frontend/src/utils/apiError.ts", - "frontend/src/utils/cindyOpenAIDefaults.ts", + "frontend/src/utils/browserActions.ts", + "frontend/src/utils/browserPreferences.ts", + "frontend/src/utils/codexTickets.ts", "frontend/src/utils/featureFlags.ts", + "frontend/src/utils/flatTheme.ts", + "frontend/src/utils/imageStudioLocalResources.ts", "frontend/src/utils/keyGroupProviders.ts", "frontend/src/utils/modelContextCapacity.ts", + "frontend/src/utils/nativeResource.ts", "frontend/src/utils/openaiReasoningPolicy.ts", + "frontend/src/utils/planType.ts", "frontend/src/utils/platformColors.ts", + "frontend/src/utils/systemPromptBinding.ts", "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/AuditLogView.vue", - "frontend/src/views/admin/ChannelsView.vue", - "frontend/src/views/admin/CindyAccountsView.vue", + "frontend/src/views/admin/CodexRuntimeView.vue", "frontend/src/views/admin/DashboardView.vue", "frontend/src/views/admin/GroupsView.vue", "frontend/src/views/admin/PluginsView.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/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", @@ -1970,1477 +1437,235 @@ "frontend/src/views/admin/__tests__/AccountsView.priorityColumn.spec.ts", "frontend/src/views/admin/__tests__/AccountsView.schedulerScore.spec.ts", "frontend/src/views/admin/__tests__/AccountsView.selectAllResults.spec.ts", + "frontend/src/views/admin/__tests__/AccountsView.selectionScope.spec.ts", "frontend/src/views/admin/__tests__/AccountsView.sparkShadow.spec.ts", "frontend/src/views/admin/__tests__/AccountsView.usageWindowsHint.spec.ts", "frontend/src/views/admin/__tests__/GroupsView.codexManifest.spec.ts", - "frontend/src/views/admin/__tests__/GroupsView.columnSettings.spec.ts", - "frontend/src/views/admin/__tests__/GroupsView.compositePlatforms.spec.ts", "frontend/src/views/admin/__tests__/GroupsView.duplicate.spec.ts", "frontend/src/views/admin/__tests__/PluginsView.spec.ts", + "frontend/src/views/admin/__tests__/ProxiesView.copyFormats.spec.ts", "frontend/src/views/admin/__tests__/SettingsView.spec.ts", "frontend/src/views/admin/__tests__/channelPlatformOptions.spec.ts", "frontend/src/views/admin/__tests__/groupModelAllowlist.spec.ts", "frontend/src/views/admin/__tests__/groupsProfitControl.spec.ts", - "frontend/src/views/admin/__tests__/systemPromptsIntegrationSurface.spec.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/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/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/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/ops/components/__tests__/OpsErrorDetailModal.spec.ts", - "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/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/PaymentView.vue", - "frontend/src/views/user/RedeemView.vue", "frontend/src/views/user/StripePaymentView.vue", - "frontend/src/views/user/SubscriptionsView.vue", "frontend/src/views/user/__tests__/StripePaymentView.spec.ts", + "frontend/src/workers/accountImport.worker.ts", "frontend/tailwind.config.js", "frontend/tsconfig.json", "frontend/vite.config.ts", - "frontend/vitest.config.ts", - "plugins/account-tools/cmd/plugin/main.go", - "plugins/account-tools/go.mod", - "plugins/account-tools/go.sum", - "plugins/account-tools/manifest.source.json", - "plugins/account-tools/policy/batch.go", - "plugins/account-tools/policy/batch_test.go", - "plugins/account-tools/policy/import.go", - "plugins/account-tools/policy/import_test.go", - "plugins/account-tools/policy/module.go", - "plugins/account-tools/policy/module_test.go", - "plugins/account-tools/scripts/check-ui.py", - "plugins/account-tools/ui/assets/app.js", - "plugins/account-tools/ui/assets/styles.css", - "plugins/account-tools/ui/index.html", - "plugins/account-tools/ui/src/AccountBulkTaxonomyModal.vue", - "plugins/account-tools/ui/src/AccountFolderBar.vue", - "plugins/account-tools/ui/src/AccountImportSettingsEditor.vue", - "plugins/account-tools/ui/src/AccountTaxonomyEditor.vue", - "plugins/account-tools/ui/src/AccountTaxonomyManager.vue", - "plugins/account-tools/ui/src/AccountTestModelSelect.vue", - "plugins/account-tools/ui/src/AccountTestReasoningSelect.vue", - "plugins/account-tools/ui/src/AccountTextTestPrompt.vue", - "plugins/account-tools/ui/src/App.vue", - "plugins/account-tools/ui/src/BatchTestAccountModal.vue", - "plugins/account-tools/ui/src/ImportDataModal.vue", - "plugins/account-tools/ui/src/__tests__/AccountBulkTaxonomyModal.spec.ts", - "plugins/account-tools/ui/src/__tests__/AccountFolderBar.spec.ts", - "plugins/account-tools/ui/src/__tests__/AccountTaxonomyManager.spec.ts", - "plugins/account-tools/ui/src/__tests__/App.bulkTaxonomyEvents.spec.ts", - "plugins/account-tools/ui/src/__tests__/BatchTestAccountModal.plan.spec.ts", - "plugins/account-tools/ui/src/__tests__/BatchTestAccountModal.spec.ts", - "plugins/account-tools/ui/src/__tests__/ImportDataModal.spec.ts", - "plugins/account-tools/ui/src/__tests__/data-import.spec.ts", - "plugins/account-tools/ui/src/accountImport.worker.ts", - "plugins/account-tools/ui/src/accountImportParser.ts", - "plugins/account-tools/ui/src/accountImportWorker.ts", - "plugins/account-tools/ui/src/accountTestModels.ts", - "plugins/account-tools/ui/src/api.ts", - "plugins/account-tools/ui/src/index.html", - "plugins/account-tools/ui/src/locales/en-accounts.ts", - "plugins/account-tools/ui/src/locales/en-common.ts", - "plugins/account-tools/ui/src/locales/zh-accounts.ts", - "plugins/account-tools/ui/src/locales/zh-common.ts", - "plugins/account-tools/ui/src/main.ts", - "plugins/account-tools/ui/src/useAccountTestPrompt.ts", - "plugins/admin-observability/cmd/plugin/main.go", - "plugins/admin-observability/go.mod", - "plugins/admin-observability/go.sum", - "plugins/admin-observability/manifest.source.json", - "plugins/admin-observability/policy/module.go", - "plugins/admin-observability/policy/module_test.go", - "plugins/admin-observability/scripts/check-chart-colors.py", - "plugins/admin-observability/scripts/check-theme.py", - "plugins/admin-observability/scripts/check-ui.py", - "plugins/admin-observability/ui/assets/app.js", - "plugins/admin-observability/ui/assets/fonts/MiSans-Bold.woff2", - "plugins/admin-observability/ui/assets/fonts/MiSans-Font-Intellectual-Property-License.pdf", - "plugins/admin-observability/ui/assets/fonts/MiSans-Medium.woff2", - "plugins/admin-observability/ui/assets/fonts/MiSans-Regular.woff2", - "plugins/admin-observability/ui/assets/fonts/MiSans-Semibold.woff2", - "plugins/admin-observability/ui/assets/fonts/NOTICE.md", - "plugins/admin-observability/ui/assets/styles.css", - "plugins/admin-observability/ui/assets/theme.css", - "plugins/admin-observability/ui/index.html", - "plugins/admin-observability/ui/src/PromptAuditView.vue", - "plugins/admin-observability/ui/src/__tests__/PromptAuditView.spec.ts", - "plugins/admin-observability/ui/src/__tests__/api.spec.ts", - "plugins/admin-observability/ui/src/__tests__/components.spec.ts", - "plugins/admin-observability/ui/src/__tests__/viewModel.spec.ts", - "plugins/admin-observability/ui/src/api.ts", - "plugins/admin-observability/ui/src/components/EndpointPool.vue", - "plugins/admin-observability/ui/src/components/EventDetailDialog.vue", - "plugins/admin-observability/ui/src/components/EventWorkspace.vue", - "plugins/admin-observability/ui/src/components/FilterDeleteDialog.vue", - "plugins/admin-observability/ui/src/components/Pagination.vue", - "plugins/admin-observability/ui/src/components/PolicyPanel.vue", - "plugins/admin-observability/ui/src/components/RuntimeOverview.vue", - "plugins/admin-observability/ui/src/index.html", - "plugins/admin-observability/ui/src/locales/en.ts", - "plugins/admin-observability/ui/src/locales/zh.ts", - "plugins/admin-observability/ui/src/main.ts", - "plugins/admin-observability/ui/src/types.ts", - "plugins/admin-observability/ui/src/viewModel.ts", - "plugins/bundle.source.json", - "plugins/cindy-provider/catalog/account_test_projection.go", - "plugins/cindy-provider/catalog/account_test_projection_test.go", - "plugins/cindy-provider/catalog/catalog.go", - "plugins/cindy-provider/catalog/catalog_data.go", - "plugins/cindy-provider/catalog/channel.go", - "plugins/cindy-provider/catalog/channel_test.go", - "plugins/cindy-provider/catalog/codex_manifest.go", - "plugins/cindy-provider/catalog/codex_manifest_test.go", - "plugins/cindy-provider/catalog/health.go", - "plugins/cindy-provider/catalog/management.go", - "plugins/cindy-provider/catalog/management_test.go", - "plugins/cindy-provider/catalog/metadata.go", - "plugins/cindy-provider/catalog/module.go", - "plugins/cindy-provider/catalog/module_test.go", - "plugins/cindy-provider/catalog/probe.go", - "plugins/cindy-provider/catalog/probe_test.go", - "plugins/cindy-provider/catalog/search.go", - "plugins/cindy-provider/catalog/search_test.go", - "plugins/cindy-provider/cmd/plugin/main.go", - "plugins/cindy-provider/go.mod", - "plugins/cindy-provider/go.sum", - "plugins/cindy-provider/manifest.source.json", - "plugins/cindy-provider/scripts/check-ui.py", - "plugins/cindy-provider/ui/assets/app.js", - "plugins/cindy-provider/ui/assets/styles.css", - "plugins/cindy-provider/ui/index.html", - "plugins/cindy-provider/ui/src/App.vue", - "plugins/cindy-provider/ui/src/CindyAccountControls.vue", - "plugins/cindy-provider/ui/src/CindyBalanceProbePanel.vue", - "plugins/cindy-provider/ui/src/CindyCleanupDialog.vue", - "plugins/cindy-provider/ui/src/CindyDuplicateInventoryPanel.vue", - "plugins/cindy-provider/ui/src/CindyGroupAuditDialog.vue", - "plugins/cindy-provider/ui/src/__tests__/CindyBalanceProbePanel.spec.ts", - "plugins/cindy-provider/ui/src/__tests__/CindyDuplicateInventoryPanel.spec.ts", - "plugins/cindy-provider/ui/src/__tests__/CindyGroupAuditDialog.spec.ts", - "plugins/cindy-provider/ui/src/__tests__/CindyUISecondReview.spec.ts", - "plugins/cindy-provider/ui/src/__tests__/ProviderSettings.spec.ts", - "plugins/cindy-provider/ui/src/api.ts", - "plugins/cindy-provider/ui/src/index.html", - "plugins/cindy-provider/ui/src/locales/en.ts", - "plugins/cindy-provider/ui/src/locales/zh.ts", - "plugins/cindy-provider/ui/src/main.ts", - "plugins/codex-runtime/cmd/plugin/main.go", - "plugins/codex-runtime/go.mod", - "plugins/codex-runtime/go.sum", - "plugins/codex-runtime/internal/tickets/cutover_test.go", - "plugins/codex-runtime/internal/tickets/identity.go", - "plugins/codex-runtime/internal/tickets/identity_test.go", - "plugins/codex-runtime/internal/tickets/module.go", - "plugins/codex-runtime/internal/tickets/module_test.go", - "plugins/codex-runtime/internal/tickets/outcome.go", - "plugins/codex-runtime/internal/tickets/proxy.go", - "plugins/codex-runtime/internal/tickets/proxy_test.go", - "plugins/codex-runtime/internal/tickets/routing.go", - "plugins/codex-runtime/internal/tickets/routing_test.go", - "plugins/codex-runtime/internal/tickets/state.go", - "plugins/codex-runtime/internal/tickets/state_test.go", - "plugins/codex-runtime/manifest.source.json", - "plugins/codex-runtime/profile/identity.go", - "plugins/codex-runtime/profile/identity_test.go", - "plugins/codex-runtime/profile/transport.go", - "plugins/codex-runtime/profile/transport_test.go", - "plugins/codex-runtime/recovery/policy.go", - "plugins/codex-runtime/recovery/policy_test.go", - "plugins/codex-runtime/scripts/check-ui.py", - "plugins/codex-runtime/ui/assets/app.js", - "plugins/codex-runtime/ui/assets/diagnostics.js", - "plugins/codex-runtime/ui/assets/styles.css", - "plugins/codex-runtime/ui/diagnostics.html", - "plugins/codex-runtime/ui/index.html", - "plugins/image-tools/cmd/plugin/main.go", - "plugins/image-tools/go.mod", - "plugins/image-tools/go.sum", - "plugins/image-tools/manifest.source.json", - "plugins/image-tools/policy/bridge.go", - "plugins/image-tools/policy/bridge_test.go", - "plugins/image-tools/policy/module.go", - "plugins/image-tools/policy/module_test.go", - "plugins/image-tools/policy/native.go", - "plugins/image-tools/policy/native_test.go", - "plugins/image-tools/scripts/check-ui.py", - "plugins/image-tools/ui/assets/app.js", - "plugins/image-tools/ui/assets/styles.css", - "plugins/image-tools/ui/index.html", - "plugins/image-tools/ui/src/App.vue", - "plugins/image-tools/ui/src/__tests__/App.spec.ts", - "plugins/image-tools/ui/src/__tests__/api.spec.ts", - "plugins/image-tools/ui/src/api.ts", - "plugins/image-tools/ui/src/history.ts", - "plugins/image-tools/ui/src/index.html", - "plugins/image-tools/ui/src/locales/en.ts", - "plugins/image-tools/ui/src/locales/zh.ts", - "plugins/image-tools/ui/src/main.ts", - "plugins/model-policy/catalog/catalog_data.go", - "plugins/model-policy/catalog/module.go", - "plugins/model-policy/catalog/module_test.go", - "plugins/model-policy/cmd/plugin/main.go", - "plugins/model-policy/go.mod", - "plugins/model-policy/go.sum", - "plugins/model-policy/manifest.source.json", - "plugins/model-policy/ui/assets/app.js", - "plugins/model-policy/ui/assets/styles.css", - "plugins/model-policy/ui/index.html", - "plugins/prompt-skills/cmd/plugin/main.go", - "plugins/prompt-skills/go.mod", - "plugins/prompt-skills/go.sum", - "plugins/prompt-skills/manifest.source.json", - "plugins/prompt-skills/policy/management.go", - "plugins/prompt-skills/policy/module.go", - "plugins/prompt-skills/policy/module_test.go", - "plugins/prompt-skills/policy/rules.go", - "plugins/prompt-skills/policy/rules_test.go", - "plugins/prompt-skills/registry/controller.go", - "plugins/prompt-skills/registry/manifest.go", - "plugins/prompt-skills/registry/prompts/codexrip_reverse_skill_system_prompt.txt", - "plugins/prompt-skills/registry/publication_test.go", - "plugins/prompt-skills/registry/references.go", - "plugins/prompt-skills/registry/seed/manifest.json", - "plugins/prompt-skills/registry/seed/pinned/waf-bypass.md", - "plugins/prompt-skills/registry/seed/tree/CHANGELOG.md", - "plugins/prompt-skills/registry/seed/tree/LICENSE", - "plugins/prompt-skills/registry/seed/tree/NOTICE.md", - "plugins/prompt-skills/registry/seed/tree/README_AI.md", - "plugins/prompt-skills/registry/seed/tree/RULES.md", - "plugins/prompt-skills/registry/seed/tree/SKILL.md", - "plugins/prompt-skills/registry/seed/tree/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/assets/templates/cloud-handoff-prompt.md", - "plugins/prompt-skills/registry/seed/tree/assets/templates/ctf-writeup.md", - "plugins/prompt-skills/registry/seed/tree/assets/templates/mission-state.md", - "plugins/prompt-skills/registry/seed/tree/assets/templates/research-result.json", - "plugins/prompt-skills/registry/seed/tree/assets/templates/writeups-index.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/LICENSE", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/README.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ad-certificate-abuse/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ad-certificate-abuse/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ad-certificate-abuse/references/ad-certificate-abuse.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-agent-cloud/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-agent-cloud/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-agent-cloud/references/agent-cloud.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-android-hooking/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-android-hooking/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-android-hooking/references/android-hooking.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-browser-persistence/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-browser-persistence/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-browser-persistence/references/browser-persistence.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-bundle-sourcemap-recovery/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-bundle-sourcemap-recovery/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-bundle-sourcemap-recovery/references/bundle-sourcemap-recovery.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-cloud-metadata-path/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-cloud-metadata-path/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-cloud-metadata-path/references/cloud-metadata-path.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-container-runtime/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-container-runtime/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-container-runtime/references/container-runtime.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-crypto-mobile/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-crypto-mobile/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-crypto-mobile/references/crypto-mobile.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-custom-protocol-replay/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-custom-protocol-replay/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-custom-protocol-replay/references/custom-protocol-replay.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-dpapi-credential-chain/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-dpapi-credential-chain/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-dpapi-credential-chain/references/dpapi-credential-chain.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-file-parser-chain/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-file-parser-chain/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-file-parser-chain/references/file-parser-chain.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-firmware-layout/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-firmware-layout/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-firmware-layout/references/firmware-layout.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-forensic-timeline/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-forensic-timeline/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-forensic-timeline/references/forensic-timeline.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-graphql-rpc-drift/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-graphql-rpc-drift/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-graphql-rpc-drift/references/graphql-rpc-drift.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-identity-windows/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-identity-windows/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-identity-windows/references/identity-windows.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ios-runtime/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ios-runtime/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ios-runtime/references/ios-runtime.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-jwt-claim-confusion/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-jwt-claim-confusion/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-jwt-claim-confusion/references/jwt-claim-confusion.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-k8s-control-plane/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-k8s-control-plane/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-k8s-control-plane/references/k8s-control-plane.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-kerberos-delegation/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-kerberos-delegation/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-kerberos-delegation/references/kerberos-delegation.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-kernel-container-escape/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-kernel-container-escape/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-kernel-container-escape/references/kernel-container-escape.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-linux-credential-pivot/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-linux-credential-pivot/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-linux-credential-pivot/references/linux-credential-pivot.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-lsass-ticket-material/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-lsass-ticket-material/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-lsass-ticket-material/references/lsass-ticket-material.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-mailbox-abuse/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-mailbox-abuse/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-mailbox-abuse/references/mailbox-abuse.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-malware-config/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-malware-config/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-malware-config/references/malware-config.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-oauth-oidc-chain/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-oauth-oidc-chain/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-oauth-oidc-chain/references/oauth-oidc-chain.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-pcap-protocol/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-pcap-protocol/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-pcap-protocol/references/pcap-protocol.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-prompt-injection/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-prompt-injection/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-prompt-injection/references/prompt-injection.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-queue-worker-drift/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-queue-worker-drift/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-queue-worker-drift/references/queue-worker-drift.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-race-condition-state-drift/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-race-condition-state-drift/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-race-condition-state-drift/references/race-condition-state-drift.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-relay-coercion-chain/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-relay-coercion-chain/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-relay-coercion-chain/references/relay-coercion-chain.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-request-normalization-smuggling/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-request-normalization-smuggling/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-request-normalization-smuggling/references/request-normalization-smuggling.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-reverse-pwn/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-reverse-pwn/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-reverse-pwn/references/reverse-pwn.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-runtime-routing/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-runtime-routing/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-runtime-routing/references/runtime-routing.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ssrf-metadata-pivot/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ssrf-metadata-pivot/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-ssrf-metadata-pivot/references/ssrf-metadata-pivot.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-stego-media/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-stego-media/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-stego-media/references/stego-media.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-supply-chain/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-supply-chain/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-supply-chain/references/supply-chain.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-template-render-path/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-template-render-path/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-template-render-path/references/template-render-path.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-web-runtime/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-web-runtime/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-web-runtime/references/cookie-hmac-key-reuse-auth-bypass.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-web-runtime/references/routing-runtime.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-websocket-runtime/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-websocket-runtime/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-websocket-runtime/references/websocket-runtime.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-windows-pivot/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-windows-pivot/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/competition-windows-pivot/references/windows-pivot.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/agents/openai.yaml", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/agent-cloud.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/crypto-mobile.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/ctf-resources-digest.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/identity-windows.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/reporting.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/reverse-native.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/router-matrix.md", - "plugins/prompt-skills/registry/seed/tree/ctf-orchestrator/ctf-sandbox-orchestrator/references/web-api.md", - "plugins/prompt-skills/registry/seed/tree/evals/evals.json", - "plugins/prompt-skills/registry/seed/tree/evals/files/decoy-flags.txt", - "plugins/prompt-skills/registry/seed/tree/evals/files/redaction-sample.har", - "plugins/prompt-skills/registry/seed/tree/evals/files/runtime-conflict/capture-summary.json", - "plugins/prompt-skills/registry/seed/tree/evals/files/runtime-conflict/source-note.md", - "plugins/prompt-skills/registry/seed/tree/evals/files/safe_handler.py", - "plugins/prompt-skills/registry/seed/tree/evals/files/untrusted-agent-document.txt", - "plugins/prompt-skills/registry/seed/tree/evals/trigger-evals.json", - "plugins/prompt-skills/registry/seed/tree/references/ai-security.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/ai-ml/adversarial-ml.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/ai-ml/index.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/ai-ml/llm-attacks.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/ai-ml/model-attacks.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/advanced-math.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/classic-ciphers.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/ecc-attacks.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/exotic-crypto-2.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/exotic-crypto.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/historical.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/index.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/lattice-and-lwe.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/modern-ciphers-2.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/modern-ciphers-3.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/modern-ciphers.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/prng-attacks.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/prng.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/rsa-attacks-2.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/rsa-attacks.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/stream-ciphers.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/crypto/zkp-and-advanced.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/3d-printing.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/disk-advanced.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/disk-and-memory.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/disk-recovery.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/index.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/linux-forensics.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/network-advanced.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/network.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/peripheral-capture.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/signals-and-hardware.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/steganography.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/stego-advanced-2.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/stego-advanced.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/stego-image.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/forensics/windows.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/malware/c2-and-protocols.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/malware/index.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/malware/pe-and-dotnet.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/malware/scripts-and-obfuscation.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/misc/bashjails.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/misc/ctfd-navigation.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/misc/dns.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/misc/encodings-advanced.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/misc/encodings.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/misc/games-and-vms-2.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/misc/games-and-vms-3.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/misc/games-and-vms-4.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/misc/games-and-vms.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/misc/index.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/misc/linux-privesc.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/misc/pyjails.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/misc/rf-sdr.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/osint/geolocation-and-media.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/osint/index.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/osint/social-media.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/osint/web-and-dns.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/advanced-exploits-2.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/advanced-exploits-3.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/advanced-exploits-4.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/advanced-exploits-5.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/advanced-exploits.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/advanced.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/field-notes.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/format-string.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/heap-fsop.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/heap-techniques-2.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/heap-techniques.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/index.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/kernel-bypass.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/kernel-techniques.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/kernel.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/overflow-basics.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/rop-advanced.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/rop-and-shellcode.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/pwn/sandbox-escape.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/anti-analysis-ctf.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/anti-analysis.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/field-notes.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/index.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/languages-compiled.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/languages-platforms.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/languages.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/patterns-ctf-2.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/patterns-ctf-3.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/patterns-ctf.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/patterns-runtime.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/patterns.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/platforms-hardware.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/platforms.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/tools-advanced-2.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/tools-advanced.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/tools-dynamic.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/tools-emulation.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/reverse/tools.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/web/auth-and-access-2.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/web/auth-and-access.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/web/auth-infra.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/web/auth-jwt.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/web/client-side-advanced.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/web/client-side.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/web/cves.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/web/field-notes.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/web/index.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/web/node-and-prototype.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side-2.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side-advanced-2.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side-advanced-3.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side-advanced-4.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side-advanced.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side-deser.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side-exec-2.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side-exec.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/web/server-side.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/web/sql-injection.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/web/web3.md", - "plugins/prompt-skills/registry/seed/tree/references/ctf/writeup/index.md", - "plugins/prompt-skills/registry/seed/tree/references/environment-and-resources.md", - "plugins/prompt-skills/registry/seed/tree/references/evidence-workflow.md", - "plugins/prompt-skills/registry/seed/tree/references/experience-index.md", - "plugins/prompt-skills/registry/seed/tree/references/experience/deobfuscation.md", - "plugins/prompt-skills/registry/seed/tree/references/experience/pitfalls.md", - "plugins/prompt-skills/registry/seed/tree/references/experience/reverse-unpacking.md", - "plugins/prompt-skills/registry/seed/tree/references/experience/web-captcha.md", - "plugins/prompt-skills/registry/seed/tree/references/field-journal-template.md", - "plugins/prompt-skills/registry/seed/tree/references/malware.md", - "plugins/prompt-skills/registry/seed/tree/references/pentest.md", - "plugins/prompt-skills/registry/seed/tree/references/precedent-auth.md", - "plugins/prompt-skills/registry/seed/tree/references/progressive-reasoning.md", - "plugins/prompt-skills/registry/seed/tree/references/reporting.md", - "plugins/prompt-skills/registry/seed/tree/references/reverse.md", - "plugins/prompt-skills/registry/seed/tree/references/routing.md", - "plugins/prompt-skills/registry/seed/tree/references/scanner.md", - "plugins/prompt-skills/registry/seed/tree/references/scope-and-evidence.md", - "plugins/prompt-skills/registry/seed/tree/references/source-provenance.md", - "plugins/prompt-skills/registry/seed/tree/references/tools.md", - "plugins/prompt-skills/registry/seed/tree/schemas/research-result.schema.json", - "plugins/prompt-skills/registry/seed/tree/scripts/env_probe.py", - "plugins/prompt-skills/registry/seed/tree/scripts/reusable/artifact_inventory.py", - "plugins/prompt-skills/registry/seed/tree/scripts/reusable/har_summary.py", - "plugins/prompt-skills/registry/seed/tree/scripts/reusable/new-experience-entry.ps1", - "plugins/prompt-skills/registry/seed/tree/scripts/reusable/new_experience_entry.py", - "plugins/prompt-skills/registry/seed/tree/scripts/reusable/pack_cloud_handoff.py", - "plugins/prompt-skills/registry/seed/tree/scripts/reusable/pe_entropy_triage.py", - "plugins/prompt-skills/registry/seed/tree/scripts/reusable/route_task.py", - "plugins/prompt-skills/registry/seed/tree/scripts/reusable/scaffold_project.py", - "plugins/prompt-skills/registry/seed/tree/scripts/validate_result.py", - "plugins/prompt-skills/registry/seed/tree/scripts/validate_skill.py", - "plugins/prompt-skills/registry/seed/tree/skills/sec-ai-security/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-ai-security/references/llm-deep/_llm-security-workflow.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-ai-security/references/llm-deep/agent-obedience-engineering.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-ai-security/references/llm-deep/agent-security-testing.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-ai-security/references/llm-deep/owasp-llm-top10.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-ai-security/references/llm-deep/prompt-injection-methodology.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/payloads/README.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/ai-pentest-agents.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/ai-pentesting-landscape-2026.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/automation-loop-pattern.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/awesome-pentest-digest.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/burpsuite-mcp-guide.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/kali-mcp-ecosystem.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/msf-protocol.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/network-attack-defense.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/nuclei-guide-2026.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/pentest-ai-agents-matrix.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/pentest-loop.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/references/web-attack-cheatsheet.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/.claude-plugin/marketplace.json", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/.gitignore", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/LICENSE", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/README.en.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/README.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/dictionaries/00-index.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/dictionaries/chinese-srcfingerprints.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/dictionaries/default-credentials-cn.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/industry/00-index.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/industry/banking-finance.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/industry/telecom-isp.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/00-index.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/01-attack-priority.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/02-bypass-toolkit.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/03-evidence-discipline.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/04-control-gap-hunting.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/methodology/05-srctimebox-priority.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/adcs\u653b\u51fb.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/exchange\u653b\u51fb.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/sharepoint\u653b\u51fb.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\u4fe1\u606f\u6536\u96c6.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\u514d\u6740\u4e0e\u89c4\u907f.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\u51ed\u8bc1\u7a83\u53d6.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\u57df\u6e17\u900f\u653b\u51fb.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\u6743\u9650\u63d0\u5347.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\u6743\u9650\u7ef4\u6301.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\u6a2a\u5411\u79fb\u52a8.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/intranet/\u96a7\u9053\u4ee3\u7406.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/ai\u5b89\u5168.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/api\u5b89\u5168.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/csrf\u8de8\u7ad9\u8bf7\u6c42\u4f2a\u9020.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/jwt\u5b89\u5168.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/lfi-rfi\u6587\u4ef6\u5305\u542b.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/rce\u8fdc\u7a0b\u4ee3\u7801\u6267\u884c.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/sql-nosql\u6ce8\u5165.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/ssrf\u670d\u52a1\u7aef\u8bf7\u6c42\u4f2a\u9020.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/ssti\u6a21\u677f\u6ce8\u5165.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/websocket\u5b89\u5168.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/xss\u8de8\u7ad9\u811a\u672c.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/xxe\u5b9e\u4f53\u6ce8\u5165.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\u4e1a\u52a1\u903b\u8f91\u6f0f\u6d1e.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\u4e91\u5b89\u5168\u6f0f\u6d1e.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\u4f9b\u5e94\u94fe\u653b\u51fb.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\u539f\u578b\u94fe\u6c61\u67d3.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\u5f00\u653e\u91cd\u5b9a\u5411.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\u6587\u4ef6\u6f0f\u6d1e.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\u6846\u67b6\u6f0f\u6d1e.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\u70b9\u51fb\u52ab\u6301.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\u7f13\u5b58\u4e0ecdn\u5b89\u5168.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\u8ba4\u8bc1\u6f0f\u6d1e.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/by-category/web/\u8bf7\u6c42\u8d70\u79c1.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/index.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/raw/navigation.json", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/raw/tools.json", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/raw/web.json", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/web\u6e17\u900f.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/windows\u6e17\u900f.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\u4fe1\u606f\u6536\u96c6.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\u5185\u7f51\u6e17\u900f.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\u51ed\u8bc1\u7a83\u53d6.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\u57df\u6e17\u900f.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\u5bc6\u7801\u653b\u51fb.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\u6743\u9650\u63d0\u5347.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\u6f0f\u6d1e\u5229\u7528.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\u7cfb\u7edf\u547d\u4ee4.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\u7ea2\u961f\u5de5\u5177.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\u7f16\u7801\u89e3\u7801.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/payloader/tools/\u96a7\u9053\u4ee3\u7406.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/00-index.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/api-rest.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/arbitrary-x-authz.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/dos.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/file-upload.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/graphql.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/http-smuggling.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/info-disclosure.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/intranet-postexp.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/llm-prompt-injection.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/logic-flaws.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/mobile.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/oauth-saml-jwt.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/path-traversal.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/race-conditions.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/rce.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/sqli.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/ssrf-cache-host.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/unauth-access.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/playbooks/xss.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/templates/report-submission.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/src-hunter/references/tools/mcp-jshook.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/templates/findings.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/templates/progress.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/templates/rules.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/templates/scope.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-assessment-tooling/pentest-tools/templates/task_plan.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-attack-chain/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-attack-chain/references/attack-playbooks.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-attack-chain/references/evasion-cheatsheet.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-crypto/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-edr-bypass/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-edr-bypass/references/hook-survey.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-edr-bypass/references/telemetry-blinding.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-edr-bypass/references/unhook-techniques.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-firmware/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-firmware/references/emba-automated-analysis.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-firmware/references/emulation-and-fuzz.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-firmware/references/extraction-methodology.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-forensics-dfir/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-malware/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-malware/references/malware-deep/_malware-workflow.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-malware/references/malware-deep/anti-analysis-techniques.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-malware/references/malware-deep/sandbox-orchestration.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-malware/references/malware-deep/yara-sigma-rules.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-misc/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-osint/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-patch-diff/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-patch-diff/references/diff-tools-comparison.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-patch-diff/references/patch-tuesday-workflow.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-patch-diff/references/root-cause-and-poc.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-pwn-chain/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-pwn-chain/references/heap-pwn.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-pwn-chain/references/kernel-pwn.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-pwn-chain/references/stack-pwn.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-pwn-native/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-reporting/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-reverse/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-supply-chain/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-supply-chain/references/cicd-pipeline-security.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-supply-chain/references/sbom-sca-methodology.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-web-api/INSTRUCTIONS.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-web-api/references/api-deep/_api-security-workflow.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-web-api/references/api-deep/jwt-oauth-testing.md", - "plugins/prompt-skills/registry/seed/tree/skills/sec-web-api/references/api-deep/rest-graphql-testing.md", - "plugins/prompt-skills/registry/support.go", - "plugins/prompt-skills/registry/validation.go", - "plugins/prompt-skills/scripts/check-ui.py", - "plugins/prompt-skills/source/gpt56.go", - "plugins/prompt-skills/source/gpt56_test.go", - "plugins/prompt-skills/source/prompts/gpt_5_6_instruct_v45.md", - "plugins/prompt-skills/source/support.go", - "plugins/prompt-skills/ui/assets/app.js", - "plugins/prompt-skills/ui/assets/styles.css", - "plugins/prompt-skills/ui/index.html", - "plugins/prompt-skills/ui/src/AccountPromptBinding.vue", - "plugins/prompt-skills/ui/src/App.vue", - "plugins/prompt-skills/ui/src/PromptRulesManager.vue", - "plugins/prompt-skills/ui/src/SystemPromptAdvancedDrawer.vue", - "plugins/prompt-skills/ui/src/__tests__/App.spec.ts", - "plugins/prompt-skills/ui/src/__tests__/PromptRules.spec.ts", - "plugins/prompt-skills/ui/src/__tests__/locales.spec.ts", - "plugins/prompt-skills/ui/src/api.ts", - "plugins/prompt-skills/ui/src/index.html", - "plugins/prompt-skills/ui/src/locales/en.ts", - "plugins/prompt-skills/ui/src/locales/rules.en.ts", - "plugins/prompt-skills/ui/src/locales/rules.zh.ts", - "plugins/prompt-skills/ui/src/locales/zh.ts", - "plugins/prompt-skills/ui/src/main.ts", - "plugins/prompt-skills/ui/src/rules-api.ts", - "tools/check-account-layout-stability.py", - "tools/sync_modelgang_remote_skill_seed.ps1", - "tools/verify_business_system_prompt_bundle.py" + "frontend/vitest.config.ts" ], "initial_conflict_files": [ - ".github/workflows/release.yml", - "backend/cmd/server/wire.go", - "backend/cmd/server/wire_gen.go", - "backend/cmd/server/wire_gen_test.go", - "backend/internal/config/config_test.go", - "backend/internal/handler/admin/account_handler.go", - "backend/internal/handler/dto/mappers.go", - "backend/internal/handler/dto/types.go", - "backend/internal/handler/openai_gateway_handler.go", - "backend/internal/handler/openai_images.go", - "backend/internal/handler/wire.go", - "backend/internal/pkg/apicompat/types.go", - "backend/internal/repository/account_repo.go", - "backend/internal/repository/account_repo_upstream_billing_probe_update_test.go", - "backend/internal/repository/http_upstream.go", - "backend/internal/server/routes/admin.go", - "backend/internal/service/admin_account.go", - "backend/internal/service/billing_service.go", - "backend/internal/service/domain_constants.go", - "backend/internal/service/gateway_service.go", - "backend/internal/service/gateway_usage_billing.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_gateway_chat_completions_raw.go", - "backend/internal/service/openai_gateway_forward.go", - "backend/internal/service/openai_gateway_messages.go", - "backend/internal/service/openai_gateway_messages_chat_fallback.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_gateway_service_hotpath_test.go", - "backend/internal/service/openai_gateway_usage.go", - "backend/internal/service/openai_images.go", - "backend/internal/service/openai_passthrough_normalization_test.go", - "backend/internal/service/openai_responses_tool_schema_test.go", - "backend/internal/service/openai_ws_http_bridge.go", - "backend/internal/service/plugin_host_services.go", - "backend/internal/service/plugin_host_services_broker_test.go", - "backend/internal/service/plugin_manager.go", - "backend/internal/service/pricing_service.go", - "backend/internal/service/wire.go", - "frontend/src/api/admin/accounts.ts", - "frontend/src/api/admin/users.ts", - "frontend/src/components/account/AccountUsageCell.vue", - "frontend/src/components/account/EditAccountModal.vue", - "frontend/src/components/account/OpenAIQuotaResetCell.vue", - "frontend/src/components/account/TempUnschedStatusModal.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/Select.vue", - "frontend/src/components/keys/__tests__/UseKeyModal.spec.ts", - "frontend/src/types/index.ts", - "frontend/src/views/admin/GroupsView.vue", - "frontend/src/views/admin/RiskControlView.vue" + "backend/internal/handler/openai_alpha_search.go", + "backend/internal/service/account_scheduling_threshold_eval.go", + "backend/internal/service/antigravity_gateway_compat_stream.go", + "backend/internal/service/openai_gateway_service.go", + "backend/internal/service/openai_upstream_transport_error.go", + "backend/internal/service/openai_ws_forwarder_ingress.go" ], "merge_conflicts": [], - "overlap_file_count": 175, + "overlap_file_count": 61, "overlap_files": [ - ".github/workflows/backend-ci.yml", - ".github/workflows/release.yml", - ".gitignore", - "Makefile", - "README.md", - "README_CN.md", "backend/cmd/server/VERSION", - "backend/cmd/server/wire.go", - "backend/cmd/server/wire_gen.go", - "backend/cmd/server/wire_gen_test.go", - "backend/internal/config/config.go", - "backend/internal/config/config_test.go", - "backend/internal/handler/admin/account_handler.go", - "backend/internal/handler/admin/account_handler_long_context_billing_test.go", - "backend/internal/handler/admin/channel_handler_test.go", - "backend/internal/handler/admin/openai_oauth_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/mappers.go", - "backend/internal/handler/dto/settings.go", - "backend/internal/handler/dto/types.go", "backend/internal/handler/gateway_handler.go", + "backend/internal/handler/gateway_handler_chat_completions.go", + "backend/internal/handler/gateway_handler_responses.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/openai_images.go", - "backend/internal/handler/wire.go", + "backend/internal/handler/openai_alpha_search.go", + "backend/internal/handler/ops_error_logger.go", + "backend/internal/handler/ops_error_logger_test.go", + "backend/internal/pkg/antigravity/request_transformer_test.go", "backend/internal/pkg/apicompat/anthropic_responses_test.go", "backend/internal/pkg/apicompat/anthropic_to_responses.go", + "backend/internal/pkg/apicompat/chatcompletions_responses_test.go", "backend/internal/pkg/apicompat/chatcompletions_to_responses.go", + "backend/internal/pkg/apicompat/responses_to_chatcompletions.go", "backend/internal/pkg/apicompat/types.go", - "backend/internal/pkg/openai/constants.go", - "backend/internal/repository/account_repo.go", - "backend/internal/repository/account_repo_upstream_billing_probe_update_test.go", - "backend/internal/repository/affiliate_repo.go", - "backend/internal/repository/channel_repo_account_stats_pricing.go", - "backend/internal/repository/channel_repo_pricing.go", - "backend/internal/repository/channel_repo_pricing_time_test.go", - "backend/internal/repository/dashboard_aggregation_repo.go", - "backend/internal/repository/ent.go", - "backend/internal/repository/http_upstream.go", "backend/internal/repository/scheduler_cache.go", "backend/internal/repository/scheduler_cache_unit_test.go", - "backend/internal/repository/wire.go", - "backend/internal/server/api_contract_test.go", - "backend/internal/server/routes/admin.go", - "backend/internal/service/account.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_usage_service.go", - "backend/internal/service/admin_account.go", - "backend/internal/service/antigravity_gateway_service.go", + "backend/internal/service/account_scheduling_threshold_eval.go", + "backend/internal/service/account_scheduling_threshold_eval_test.go", + "backend/internal/service/antigravity_gateway_compat_stream.go", + "backend/internal/service/antigravity_gateway_gemini.go", "backend/internal/service/billing_service.go", - "backend/internal/service/channel_service.go", - "backend/internal/service/domain_constants.go", - "backend/internal/service/gateway_anthropic_apikey_passthrough_test.go", - "backend/internal/service/gateway_count_tokens.go", - "backend/internal/service/gateway_forward_as_chat_completions.go", - "backend/internal/service/gateway_multiplatform_test.go", - "backend/internal/service/gateway_scheduling.go", "backend/internal/service/gateway_service.go", "backend/internal/service/gateway_usage_billing.go", - "backend/internal/service/gemini_chat_completions_compat_service.go", - "backend/internal/service/gemini_messages_compat_service_test.go", + "backend/internal/service/group_model_allowlist.go", + "backend/internal/service/group_model_allowlist_test.go", "backend/internal/service/model_pricing_resolver.go", - "backend/internal/service/openai_account_runtime_block_fastpath.go", - "backend/internal/service/openai_account_scheduler.go", - "backend/internal/service/openai_codex_account_identity.go", - "backend/internal/service/openai_codex_fingerprint.go", - "backend/internal/service/openai_codex_model_metadata_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_cc_pipeline.go", - "backend/internal/service/openai_gateway_chat_completions.go", - "backend/internal/service/openai_gateway_chat_completions_anthropic_native.go", - "backend/internal/service/openai_gateway_chat_completions_raw.go", - "backend/internal/service/openai_gateway_chat_completions_raw_test.go", + "backend/internal/service/openai_account_scheduler_test.go", + "backend/internal/service/openai_alpha_search.go", + "backend/internal/service/openai_alpha_search_test.go", + "backend/internal/service/openai_compat_model.go", + "backend/internal/service/openai_compat_model_test.go", + "backend/internal/service/openai_gateway_deepseek_chat_reasoning_test.go", "backend/internal/service/openai_gateway_forward.go", - "backend/internal/service/openai_gateway_grok.go", - "backend/internal/service/openai_gateway_grok_chat_bridge.go", - "backend/internal/service/openai_gateway_messages.go", - "backend/internal/service/openai_gateway_messages_anthropic_native.go", - "backend/internal/service/openai_gateway_messages_chat_fallback.go", - "backend/internal/service/openai_gateway_passthrough.go", - "backend/internal/service/openai_gateway_request_body.go", - "backend/internal/service/openai_gateway_response_flush_test.go", - "backend/internal/service/openai_gateway_response_handling.go", - "backend/internal/service/openai_gateway_responses_anthropic_native.go", + "backend/internal/service/openai_gateway_messages_chat_fallback_test.go", + "backend/internal/service/openai_gateway_record_usage_test.go", + "backend/internal/service/openai_gateway_responses_chat_fallback.go", "backend/internal/service/openai_gateway_scheduling.go", - "backend/internal/service/openai_gateway_service_hotpath_test.go", + "backend/internal/service/openai_gateway_service.go", "backend/internal/service/openai_gateway_service_test.go", - "backend/internal/service/openai_gateway_upstream_errors.go", "backend/internal/service/openai_gateway_usage.go", - "backend/internal/service/openai_images.go", - "backend/internal/service/openai_model_alias.go", - "backend/internal/service/openai_passthrough_normalization_test.go", - "backend/internal/service/openai_quota_service.go", - "backend/internal/service/openai_responses_tool_schema_test.go", + "backend/internal/service/openai_quota_auto_reset.go", "backend/internal/service/openai_upstream_transport_error.go", - "backend/internal/service/openai_ws_http_bridge.go", - "backend/internal/service/openai_ws_http_bridge_test.go", - "backend/internal/service/plugin_host_services.go", - "backend/internal/service/plugin_host_services_broker_test.go", - "backend/internal/service/plugin_manager.go", - "backend/internal/service/pricing_service.go", - "backend/internal/service/ratelimit_service.go", - "backend/internal/service/setting_gateway_runtime.go", - "backend/internal/service/setting_parse.go", - "backend/internal/service/setting_service.go", - "backend/internal/service/setting_update.go", - "backend/internal/service/settings_view.go", - "backend/internal/service/upstream_models.go", - "backend/internal/service/wire.go", - "backend/pkg/pluginapi/v1/runtime.go", - "backend/resources/model-pricing/model_prices_and_context_window.json", - "deploy/.env.example", - "deploy/config.example.yaml", + "backend/internal/service/openai_ws_forwarder_ingress.go", + "backend/internal/service/openai_ws_forwarder_ingress_test.go", + "backend/internal/service/openai_ws_forwarder_payload.go", "deploy/docker-compose.dev.yml", "deploy/docker-compose.local.yml", - "deploy/docker-compose.standalone.yml", "deploy/docker-compose.yml", - "frontend/src/api/__tests__/client.spec.ts", - "frontend/src/api/admin/accounts.ts", - "frontend/src/api/admin/settings.ts", - "frontend/src/api/admin/users.ts", - "frontend/src/api/client.ts", - "frontend/src/components/account/AccountStatusIndicator.vue", - "frontend/src/components/account/AccountUsageCell.vue", - "frontend/src/components/account/EditAccountModal.vue", - "frontend/src/components/account/OpenAIQuotaResetCell.vue", - "frontend/src/components/account/TempUnschedStatusModal.vue", - "frontend/src/components/account/__tests__/AccountUsageCell.spec.ts", - "frontend/src/components/admin/channel/PricingEntryCard.vue", + "frontend/src/components/account/UsageProgressBar.vue", + "frontend/src/components/account/__tests__/UsageProgressBar.spec.ts", "frontend/src/components/admin/group/GroupRPMOverridesModal.vue", "frontend/src/components/admin/group/GroupRateMultipliersModal.vue", - "frontend/src/components/admin/monitor/MonitorTemplateApplyPickerDialog.vue", - "frontend/src/components/admin/user/UserAllowedGroupsModal.vue", - "frontend/src/components/admin/user/UserBalanceHistoryModal.vue", - "frontend/src/components/admin/user/UserPlatformQuotaModal.vue", - "frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts", - "frontend/src/components/channels/SupportedModelChip.vue", - "frontend/src/components/common/BaseDialog.vue", - "frontend/src/components/common/DateRangePicker.vue", - "frontend/src/components/common/ImageUpload.vue", - "frontend/src/components/common/Select.vue", - "frontend/src/components/common/SubscriptionProgressMini.vue", "frontend/src/components/keys/UseKeyModal.vue", "frontend/src/components/keys/__tests__/UseKeyModal.spec.ts", - "frontend/src/components/modelPlaza/PlazaModelPricingTable.vue", - "frontend/src/components/payment/PaymentProviderDialog.vue", - "frontend/src/components/user/UserAttributesConfigModal.vue", - "frontend/src/components/user/profile/ProfileInfoCard.vue", - "frontend/src/composables/__tests__/useModelWhitelist.spec.ts", - "frontend/src/composables/useModelWhitelist.ts", - "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/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/common.ts", - "frontend/src/types/index.ts", - "frontend/src/views/admin/ChannelsView.vue", "frontend/src/views/admin/GroupsView.vue", - "frontend/src/views/admin/RiskControlView.vue", - "frontend/src/views/admin/SettingsView.vue", - "frontend/src/views/admin/UsersView.vue", - "frontend/src/views/admin/__tests__/GroupsView.codexManifest.spec.ts", - "frontend/src/views/admin/__tests__/GroupsView.duplicate.spec.ts", - "frontend/src/views/admin/__tests__/SettingsView.spec.ts", - "frontend/src/views/admin/affiliates/AdminAffiliateRecordsTable.vue", - "frontend/src/views/admin/ops/components/OpsSystemLogTable.vue" + "frontend/src/views/admin/__tests__/groupModelAllowlist.spec.ts", + "frontend/src/views/admin/groupModelAllowlist.ts", + "frontend/src/views/user/KeysView.vue" ], "risk_class": "review_required", "schema": 1, - "upstream_base": "v0.2.7", - "upstream_file_count": 473, + "upstream_base": "v0.2.8", + "upstream_file_count": 117, "upstream_files": [ - ".github/release-tools/.gitignore", - ".github/release-tools/README.md", - ".github/release-tools/release-images.sh", - ".github/release-tools/release_matrix.py", - ".github/release-tools/requirements-release.txt", - ".github/release-tools/test_release_matrix.py", - ".github/workflows/backend-ci.yml", - ".github/workflows/release.yml", - ".gitignore", - "Makefile", - "README.md", - "README_CN.md", "backend/cmd/server/VERSION", - "backend/cmd/server/wire.go", - "backend/cmd/server/wire_gen.go", - "backend/cmd/server/wire_gen_test.go", - "backend/internal/config/config.go", - "backend/internal/config/config_test.go", - "backend/internal/handler/admin/account_handler.go", - "backend/internal/handler/admin/account_handler_long_context_billing_test.go", - "backend/internal/handler/admin/account_opencode_go_usage.go", - "backend/internal/handler/admin/account_opencode_go_usage_test.go", - "backend/internal/handler/admin/affiliate_handler.go", - "backend/internal/handler/admin/affiliate_handler_withdraw_test.go", - "backend/internal/handler/admin/backup_handler.go", - "backend/internal/handler/admin/channel_handler.go", - "backend/internal/handler/admin/channel_handler_test.go", - "backend/internal/handler/admin/content_moderation_engine_test.go", - "backend/internal/handler/admin/content_moderation_handler.go", - "backend/internal/handler/admin/openai_oauth_handler.go", - "backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go", - "backend/internal/handler/admin/openai_referral_handler.go", - "backend/internal/handler/admin/openai_referral_handler_test.go", - "backend/internal/handler/admin/setting_handler.go", - "backend/internal/handler/admin/setting_handler_audit.go", - "backend/internal/handler/admin/setting_handler_oauth_rate_test.go", - "backend/internal/handler/admin/setting_handler_update.go", - "backend/internal/handler/auth_email_oauth_test.go", - "backend/internal/handler/available_channel_handler.go", - "backend/internal/handler/available_channel_handler_test.go", - "backend/internal/handler/dto/account_list_item_opencode_usage_test.go", - "backend/internal/handler/dto/mappers.go", - "backend/internal/handler/dto/settings.go", - "backend/internal/handler/dto/types.go", + "backend/internal/handler/concurrency_error_response_test.go", "backend/internal/handler/gateway_handler.go", + "backend/internal/handler/gateway_handler_cancellation_test.go", + "backend/internal/handler/gateway_handler_chat_completions.go", + "backend/internal/handler/gateway_handler_error_fallback_test.go", + "backend/internal/handler/gateway_handler_responses.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/openai_images.go", - "backend/internal/handler/openai_images_balance_test.go", - "backend/internal/handler/wire.go", - "backend/internal/pkg/antigravity/attribution_test.go", + "backend/internal/handler/gemini_client_cancel_test.go", + "backend/internal/handler/gemini_v1beta_handler.go", + "backend/internal/handler/model_plaza_handler.go", + "backend/internal/handler/model_plaza_handler_test.go", + "backend/internal/handler/openai_alpha_search.go", + "backend/internal/handler/ops_error_logger.go", + "backend/internal/handler/ops_error_logger_test.go", "backend/internal/pkg/antigravity/request_transformer.go", + "backend/internal/pkg/antigravity/request_transformer_test.go", "backend/internal/pkg/antigravity/schema_cleaner.go", - "backend/internal/pkg/antigravity/schema_cleaner_test.go", + "backend/internal/pkg/antigravity/schema_const_test.go", + "backend/internal/pkg/antigravity/stream_transformer.go", "backend/internal/pkg/apicompat/anthropic_responses_test.go", "backend/internal/pkg/apicompat/anthropic_to_responses.go", "backend/internal/pkg/apicompat/anthropic_to_responses_response.go", - "backend/internal/pkg/apicompat/anthropic_to_responses_stream_test.go", + "backend/internal/pkg/apicompat/anthropic_to_responses_stream_tool_input_test.go", + "backend/internal/pkg/apicompat/anthropic_to_responses_tool_input_restore_chain_test.go", + "backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go", + "backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go", + "backend/internal/pkg/apicompat/chatcompletions_responses_test.go", "backend/internal/pkg/apicompat/chatcompletions_to_responses.go", - "backend/internal/pkg/apicompat/responses_to_anthropic_instructions_test.go", + "backend/internal/pkg/apicompat/reasoning_generation_test.go", + "backend/internal/pkg/apicompat/responses_to_anthropic_invalid_blocks_test.go", "backend/internal/pkg/apicompat/responses_to_anthropic_request.go", - "backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go", + "backend/internal/pkg/apicompat/responses_to_chatcompletions.go", + "backend/internal/pkg/apicompat/responses_to_chatcompletions_text_recovery_test.go", "backend/internal/pkg/apicompat/types.go", - "backend/internal/pkg/claude/cli_version_runtime.go", - "backend/internal/pkg/claude/cli_version_runtime_test.go", - "backend/internal/pkg/claude/cli_version_test.go", "backend/internal/pkg/claude/constants.go", - "backend/internal/pkg/claude/constants_cli_version_test.go", - "backend/internal/pkg/claude/constants_model_test.go", "backend/internal/pkg/claude/effort_catalog.go", - "backend/internal/pkg/openai/constants.go", - "backend/internal/pkg/openai/constants_test.go", - "backend/internal/pkg/typesafe/client.go", - "backend/internal/pkg/typesafe/client_test.go", - "backend/internal/pkg/xai/models.go", - "backend/internal/pkg/xai/models_test.go", - "backend/internal/repository/account_repo.go", - "backend/internal/repository/account_repo_codex_display_snapshot_test.go", - "backend/internal/repository/account_repo_opencode_go_usage.go", - "backend/internal/repository/account_repo_opencode_go_usage_integration_test.go", - "backend/internal/repository/account_repo_opencode_go_usage_test.go", - "backend/internal/repository/account_repo_upstream_billing_probe_update_test.go", - "backend/internal/repository/affiliate_repo.go", - "backend/internal/repository/affiliate_repo_integration_test.go", - "backend/internal/repository/affiliate_repo_test.go", - "backend/internal/repository/channel_reasoning_effort_migration_integration_test.go", - "backend/internal/repository/channel_reasoning_pricing_integration_test.go", - "backend/internal/repository/channel_repo_account_stats_pricing.go", - "backend/internal/repository/channel_repo_pricing.go", - "backend/internal/repository/channel_repo_pricing_reasoning_test.go", - "backend/internal/repository/channel_repo_pricing_time_test.go", - "backend/internal/repository/content_moderation_repo.go", - "backend/internal/repository/content_moderation_repo_test.go", - "backend/internal/repository/dashboard_aggregation_group_usage_test.go", - "backend/internal/repository/dashboard_aggregation_repo.go", - "backend/internal/repository/ent.go", - "backend/internal/repository/http_upstream.go", - "backend/internal/repository/http_upstream_billing_lifecycle_test.go", - "backend/internal/repository/http_upstream_body_lifecycle_test.go", - "backend/internal/repository/http_upstream_http2_keepalive_test.go", - "backend/internal/repository/http_upstream_http2_ping_test.go", - "backend/internal/repository/openai_referral_client.go", - "backend/internal/repository/openai_referral_client_test.go", - "backend/internal/repository/proxy_expiry_renewal_integration_test.go", - "backend/internal/repository/proxy_inactive_backup_integration_test.go", - "backend/internal/repository/proxy_repo.go", - "backend/internal/repository/proxy_restore_probe_integration_test.go", - "backend/internal/repository/redeem_reduction_lock_integration_test.go", - "backend/internal/repository/redeem_reduction_remainder_integration_test.go", - "backend/internal/repository/request_log_retention_integration_test.go", + "backend/internal/pkg/claude/effort_catalog_test.go", + "backend/internal/pkg/googleapi/status.go", + "backend/internal/pkg/googleapi/status_test.go", "backend/internal/repository/scheduler_cache.go", "backend/internal/repository/scheduler_cache_unit_test.go", - "backend/internal/repository/simple_mode_startup_test.go", - "backend/internal/repository/wire.go", - "backend/internal/server/api_contract_test.go", - "backend/internal/server/routes/admin.go", - "backend/internal/server/routes/composite_images_compatible_test.go", - "backend/internal/service/account.go", + "backend/internal/service/account_scheduling_threshold_eval.go", + "backend/internal/service/account_scheduling_threshold_eval_test.go", "backend/internal/service/account_stats_pricing.go", "backend/internal/service/account_stats_pricing_test.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_usage_service.go", - "backend/internal/service/account_usage_service_batch_test.go", - "backend/internal/service/account_usage_service_spark_shadow_test.go", - "backend/internal/service/admin_account.go", - "backend/internal/service/admin_account_upstream_billing_probe_test.go", - "backend/internal/service/affiliate_service.go", - "backend/internal/service/affiliate_service_test.go", + "backend/internal/service/admin_service_group_model_allowlist_test.go", "backend/internal/service/antigravity_gateway_claude.go", "backend/internal/service/antigravity_gateway_compat.go", + "backend/internal/service/antigravity_gateway_compat_stream.go", "backend/internal/service/antigravity_gateway_gemini.go", - "backend/internal/service/antigravity_gateway_service.go", - "backend/internal/service/antigravity_gemini_thinking_variant.go", - "backend/internal/service/antigravity_gemini_thinking_variant_test.go", - "backend/internal/service/backup_recovery_test.go", - "backend/internal/service/backup_restore_state.go", - "backend/internal/service/backup_retention.go", - "backend/internal/service/backup_retention_test.go", - "backend/internal/service/backup_service.go", - "backend/internal/service/backup_service_test.go", - "backend/internal/service/billing_cache_service.go", - "backend/internal/service/billing_cache_service_simple_mode_test.go", + "backend/internal/service/antigravity_gateway_streaming.go", "backend/internal/service/billing_service.go", "backend/internal/service/billing_service_test.go", - "backend/internal/service/billing_token_cost_request_test.go", - "backend/internal/service/channel.go", - "backend/internal/service/channel_available.go", - "backend/internal/service/channel_reasoning_pricing_test.go", - "backend/internal/service/channel_service.go", - "backend/internal/service/channel_test.go", - "backend/internal/service/claude_code_version_sync_service.go", - "backend/internal/service/claude_code_version_sync_service_test.go", - "backend/internal/service/content_moderation.go", - "backend/internal/service/content_moderation_engines.go", - "backend/internal/service/content_moderation_engines_test.go", - "backend/internal/service/content_moderation_input.go", - "backend/internal/service/content_moderation_input_test.go", - "backend/internal/service/content_moderation_reminder_test.go", - "backend/internal/service/content_moderation_typesafe.go", - "backend/internal/service/content_moderation_typesafe_live_test.go", - "backend/internal/service/dashboard_aggregation_service.go", - "backend/internal/service/dashboard_aggregation_service_test.go", - "backend/internal/service/domain_constants.go", + "backend/internal/service/client_disconnect_status_test.go", "backend/internal/service/gateway_anthropic_apikey_passthrough_test.go", - "backend/internal/service/gateway_billing_header.go", - "backend/internal/service/gateway_billing_header_test.go", - "backend/internal/service/gateway_claude_oauth_body.go", - "backend/internal/service/gateway_cli_version_runtime_test.go", - "backend/internal/service/gateway_compat_reasoning_pricing_test.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_responses.go", - "backend/internal/service/gateway_forward_as_responses_test.go", - "backend/internal/service/gateway_image_reasoning_pricing_test.go", - "backend/internal/service/gateway_multiplatform_test.go", - "backend/internal/service/gateway_reasoning_pricing_test.go", - "backend/internal/service/gateway_request.go", - "backend/internal/service/gateway_scheduling.go", + "backend/internal/service/gateway_hotpath_optimization_test.go", "backend/internal/service/gateway_service.go", - "backend/internal/service/gateway_simple_mode_record_usage_test.go", - "backend/internal/service/gateway_thinking_budget_test.go", + "backend/internal/service/gateway_structured_outputs_beta_test.go", "backend/internal/service/gateway_upstream_request.go", - "backend/internal/service/gateway_upstream_response.go", "backend/internal/service/gateway_upstream_transport_error.go", "backend/internal/service/gateway_usage_billing.go", - "backend/internal/service/gateway_usage_billing_simple_mode_test.go", - "backend/internal/service/gemini_chat_completions_compat_service.go", - "backend/internal/service/gemini_error_policy_test.go", - "backend/internal/service/gemini_messages_compat_service.go", - "backend/internal/service/gemini_messages_compat_service_test.go", - "backend/internal/service/gemini_native_reasoning_pricing_test.go", - "backend/internal/service/gemini_reasoning_effort.go", - "backend/internal/service/gemini_sse_comment_compat_test.go", "backend/internal/service/gemini_upstream_transport_error.go", - "backend/internal/service/gemini_upstream_transport_error_test.go", - "backend/internal/service/grok_quota_cooldown_test.go", - "backend/internal/service/grok_quota_service.go", - "backend/internal/service/identity_service.go", - "backend/internal/service/identity_service_user_agent_validation_test.go", - "backend/internal/service/model_not_found_error_test.go", + "backend/internal/service/group_model_allowlist.go", + "backend/internal/service/group_model_allowlist_test.go", "backend/internal/service/model_plaza_service.go", "backend/internal/service/model_plaza_service_test.go", "backend/internal/service/model_pricing_resolver.go", - "backend/internal/service/openai_access_state_failover_test.go", - "backend/internal/service/openai_account_runtime_block_fastpath.go", - "backend/internal/service/openai_account_scheduler.go", - "backend/internal/service/openai_account_scheduler_canonical_quota_test.go", - "backend/internal/service/openai_account_scheduler_upstream_cost_test.go", - "backend/internal/service/openai_capacity_shed_test.go", - "backend/internal/service/openai_codex_account_identity.go", - "backend/internal/service/openai_codex_fingerprint.go", - "backend/internal/service/openai_codex_model_metadata_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_codex_turn_metadata.go", - "backend/internal/service/openai_codex_turn_metadata_test.go", - "backend/internal/service/openai_codex_version_sync_service_test.go", - "backend/internal/service/openai_compat_prompt_cache_key.go", - "backend/internal/service/openai_compat_prompt_cache_key_test.go", - "backend/internal/service/openai_gateway_cc_pipeline.go", - "backend/internal/service/openai_gateway_chat_completions.go", - "backend/internal/service/openai_gateway_chat_completions_anthropic_native.go", - "backend/internal/service/openai_gateway_chat_completions_raw.go", - "backend/internal/service/openai_gateway_chat_completions_raw_test.go", - "backend/internal/service/openai_gateway_chat_completions_test.go", - "backend/internal/service/openai_gateway_converted_reasoning_pricing_test.go", - "backend/internal/service/openai_gateway_deepseek_input_image_test.go", + "backend/internal/service/model_pricing_resolver_test.go", + "backend/internal/service/openai_account_scheduler_test.go", + "backend/internal/service/openai_alpha_search.go", + "backend/internal/service/openai_alpha_search_completion_test.go", + "backend/internal/service/openai_alpha_search_test.go", + "backend/internal/service/openai_apikey_responses_probe.go", + "backend/internal/service/openai_apikey_responses_probe_model_unavailable_test.go", + "backend/internal/service/openai_compat_model.go", + "backend/internal/service/openai_compat_model_test.go", + "backend/internal/service/openai_gateway_deepseek_chat_reasoning_test.go", "backend/internal/service/openai_gateway_forward.go", - "backend/internal/service/openai_gateway_grok.go", - "backend/internal/service/openai_gateway_grok_chat_bridge.go", - "backend/internal/service/openai_gateway_messages.go", - "backend/internal/service/openai_gateway_messages_anthropic_native.go", - "backend/internal/service/openai_gateway_messages_chat_fallback.go", - "backend/internal/service/openai_gateway_passthrough.go", - "backend/internal/service/openai_gateway_request_body.go", - "backend/internal/service/openai_gateway_response_flush_test.go", - "backend/internal/service/openai_gateway_response_handling.go", - "backend/internal/service/openai_gateway_responses_anthropic_native.go", + "backend/internal/service/openai_gateway_messages_chat_fallback_test.go", + "backend/internal/service/openai_gateway_record_usage_test.go", + "backend/internal/service/openai_gateway_responses_chat_fallback.go", "backend/internal/service/openai_gateway_scheduling.go", - "backend/internal/service/openai_gateway_service_hotpath_test.go", + "backend/internal/service/openai_gateway_service.go", "backend/internal/service/openai_gateway_service_test.go", - "backend/internal/service/openai_gateway_upstream_errors.go", "backend/internal/service/openai_gateway_usage.go", - "backend/internal/service/openai_images.go", - "backend/internal/service/openai_images_balance.go", - "backend/internal/service/openai_images_balance_test.go", - "backend/internal/service/openai_images_compatible_test.go", - "backend/internal/service/openai_legacy_scheduler_decision_test.go", - "backend/internal/service/openai_lite_mapped_gpt55.go", - "backend/internal/service/openai_lite_mapped_gpt55_test.go", - "backend/internal/service/openai_model_alias.go", - "backend/internal/service/openai_opencode_session.go", - "backend/internal/service/openai_opencode_session_test.go", - "backend/internal/service/openai_passthrough_normalization_test.go", - "backend/internal/service/openai_plugin_account_directory.go", - "backend/internal/service/openai_plugin_account_directory_test.go", - "backend/internal/service/openai_quota_credits_test.go", - "backend/internal/service/openai_quota_reset_credits_test.go", - "backend/internal/service/openai_quota_service.go", - "backend/internal/service/openai_quota_spark_window_test.go", - "backend/internal/service/openai_referral_client.go", - "backend/internal/service/openai_referral_service.go", - "backend/internal/service/openai_referral_service_test.go", - "backend/internal/service/openai_response_model_rewrite_test.go", - "backend/internal/service/openai_responses_item_id.go", - "backend/internal/service/openai_responses_item_id_test.go", - "backend/internal/service/openai_responses_tool_schema.go", - "backend/internal/service/openai_responses_tool_schema_test.go", - "backend/internal/service/openai_scheduling_rate_fallback_test.go", + "backend/internal/service/openai_quota_auto_reset.go", + "backend/internal/service/openai_quota_auto_reset_test.go", "backend/internal/service/openai_upstream_transport_error.go", - "backend/internal/service/openai_visible_ttft_test.go", - "backend/internal/service/openai_ws_forwarder_ingress_execution_scope_test.go", - "backend/internal/service/openai_ws_http_bridge.go", - "backend/internal/service/openai_ws_http_bridge_test.go", - "backend/internal/service/opencode_go.go", - "backend/internal/service/opencode_go_test.go", - "backend/internal/service/opencode_go_usage.go", - "backend/internal/service/opencode_go_usage_test.go", - "backend/internal/service/ops_log_runtime.go", - "backend/internal/service/ops_settings_models.go", - "backend/internal/service/payment_fulfillment_test.go", - "backend/internal/service/plugin_host_services.go", - "backend/internal/service/plugin_host_services_broker_test.go", - "backend/internal/service/plugin_host_services_test.go", - "backend/internal/service/plugin_manager.go", - "backend/internal/service/pricing_service.go", - "backend/internal/service/proxy_fallback.go", - "backend/internal/service/proxy_fallback_test.go", - "backend/internal/service/ratelimit_service.go", - "backend/internal/service/ratelimit_service_401_test.go", - "backend/internal/service/ratelimit_service_403_html_test.go", - "backend/internal/service/ratelimit_service_model_not_found_test.go", - "backend/internal/service/reasoning_effort_billing_test.go", - "backend/internal/service/redeem_service.go", - "backend/internal/service/redeem_subscription_reduction_lock_test.go", - "backend/internal/service/redeem_subscription_reduction_remainder_test.go", - "backend/internal/service/request_log_retention_test.go", - "backend/internal/service/setting_claude_code_version_test.go", - "backend/internal/service/setting_gateway_runtime.go", - "backend/internal/service/setting_parse.go", - "backend/internal/service/setting_service.go", - "backend/internal/service/setting_service_update_test.go", - "backend/internal/service/setting_update.go", - "backend/internal/service/settings_view.go", - "backend/internal/service/upstream_models.go", - "backend/internal/service/upstream_response_model.go", - "backend/internal/service/wire.go", - "backend/migrations/238b_content_moderation_engine_meta.sql", - "backend/migrations/239_channel_reasoning_effort_multipliers.sql", - "backend/migrations/240_affiliate_ledger_operation_id.sql", - "backend/migrations/channel_reasoning_effort_multipliers_migration_test.go", - "backend/migrations/content_moderation_engine_meta_test.go", - "backend/pkg/pluginapi/v1/plugin.pb.go", - "backend/pkg/pluginapi/v1/plugin.proto", - "backend/pkg/pluginapi/v1/plugin_grpc.pb.go", - "backend/pkg/pluginapi/v1/runtime.go", - "backend/resources/model-pricing/model_prices_and_context_window.json", - "deploy/.env.example", - "deploy/config.example.yaml", + "backend/internal/service/openai_ws_forwarder_ingress.go", + "backend/internal/service/openai_ws_forwarder_ingress_test.go", + "backend/internal/service/openai_ws_forwarder_payload.go", + "backend/internal/setup/setup.go", + "backend/internal/setup/setup_test.go", "deploy/docker-compose.dev.yml", "deploy/docker-compose.local.yml", - "deploy/docker-compose.standalone.yml", "deploy/docker-compose.yml", - "deploy/tests/docker-compose-simple-mode-env-test.sh", - "docs/ANTIGRAVITY_ATTRIBUTION_429.md", - "docs/screenshots/reasoning-effort-pricing.png", - "frontend/src/api/__tests__/admin.accounts.opencodeGoUsage.spec.ts", - "frontend/src/api/__tests__/client.spec.ts", - "frontend/src/api/admin/accounts.ts", - "frontend/src/api/admin/affiliates.ts", - "frontend/src/api/admin/backup.ts", - "frontend/src/api/admin/channels.ts", - "frontend/src/api/admin/ops.ts", - "frontend/src/api/admin/riskControl.ts", - "frontend/src/api/admin/settings.ts", - "frontend/src/api/admin/users.ts", - "frontend/src/api/channels.ts", - "frontend/src/api/client.ts", - "frontend/src/components/account/AccountStatusIndicator.vue", - "frontend/src/components/account/AccountUsageCell.vue", - "frontend/src/components/account/EditAccountModal.vue", - "frontend/src/components/account/OpenAIQuotaResetCell.vue", - "frontend/src/components/account/OpenAIReferralCell.vue", - "frontend/src/components/account/OpenCodeGoUsageCell.vue", - "frontend/src/components/account/TempUnschedStatusModal.vue", - "frontend/src/components/account/__tests__/AccountUsageCell.spec.ts", - "frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts", - "frontend/src/components/account/__tests__/OpenAIReferralCell.spec.ts", - "frontend/src/components/account/__tests__/OpenAIReferralCell.transport.spec.ts", - "frontend/src/components/account/__tests__/OpenCodeGoUsageCell.spec.ts", - "frontend/src/components/account/__tests__/TempUnschedStatusModal.spec.ts", - "frontend/src/components/admin/BackupArchiveSettings.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/__tests__/IntervalRow.spec.ts", - "frontend/src/components/admin/channel/__tests__/ModelTagInput.keyboard.spec.ts", - "frontend/src/components/admin/channel/__tests__/PricingEntryCard.timePricing.spec.ts", - "frontend/src/components/admin/channel/__tests__/types.spec.ts", - "frontend/src/components/admin/channel/types.ts", + "frontend/src/api/modelPlaza.ts", + "frontend/src/components/account/UsageProgressBar.vue", + "frontend/src/components/account/__tests__/UsageProgressBar.spec.ts", "frontend/src/components/admin/group/GroupRPMOverridesModal.vue", "frontend/src/components/admin/group/GroupRateMultipliersModal.vue", - "frontend/src/components/admin/group/__tests__/GroupRPMOverridesModal.spec.ts", - "frontend/src/components/admin/group/__tests__/GroupRateMultipliersModal.spec.ts", - "frontend/src/components/admin/monitor/MonitorTemplateApplyPickerDialog.vue", - "frontend/src/components/admin/monitor/__tests__/MonitorTemplateApplyPickerDialog.spec.ts", - "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/UserPlatformQuotaModal.vue", - "frontend/src/components/admin/user/__tests__/GroupReplaceModal.spec.ts", - "frontend/src/components/admin/user/__tests__/UserAllowedGroupsModal.spec.ts", - "frontend/src/components/admin/user/__tests__/UserApiKeysModal.spec.ts", - "frontend/src/components/admin/user/__tests__/UserBalanceHistoryModal.spec.ts", - "frontend/src/components/admin/user/__tests__/UserBalanceModal.spec.ts", - "frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts", - "frontend/src/components/channels/SupportedModelChip.vue", - "frontend/src/components/channels/__tests__/SupportedModelChip.spec.ts", - "frontend/src/components/common/BaseDialog.vue", - "frontend/src/components/common/DateRangePicker.vue", - "frontend/src/components/common/ImageUpload.vue", - "frontend/src/components/common/SearchInput.vue", - "frontend/src/components/common/Select.vue", - "frontend/src/components/common/SubscriptionProgressMini.vue", - "frontend/src/components/common/__tests__/BaseDialog.scrollLock.spec.ts", - "frontend/src/components/common/__tests__/DateRangePicker.dismiss.spec.ts", - "frontend/src/components/common/__tests__/DateRangePicker.midnight.spec.ts", - "frontend/src/components/common/__tests__/ImageUpload.readOrder.spec.ts", - "frontend/src/components/common/__tests__/SearchInput.spec.ts", - "frontend/src/components/common/__tests__/Select.keyboard.spec.ts", - "frontend/src/components/common/__tests__/Select.searchHighlight.spec.ts", - "frontend/src/components/common/__tests__/SubscriptionProgressMini.spec.ts", + "frontend/src/components/admin/group/__tests__/GroupModal.cleanup.spec.ts", "frontend/src/components/keys/UseKeyModal.vue", "frontend/src/components/keys/__tests__/UseKeyModal.spec.ts", + "frontend/src/components/modelPlaza/PlazaGroupSection.vue", "frontend/src/components/modelPlaza/PlazaModelPricingTable.vue", + "frontend/src/components/modelPlaza/__tests__/PlazaGroupSection.spec.ts", "frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts", - "frontend/src/components/payment/PaymentProviderDialog.vue", - "frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts", - "frontend/src/components/user/MonitorDetailDialog.vue", - "frontend/src/components/user/UserAttributeForm.vue", - "frontend/src/components/user/UserAttributesConfigModal.vue", - "frontend/src/components/user/UserErrorDetailModal.vue", - "frontend/src/components/user/__tests__/MonitorDetailDialog.spec.ts", - "frontend/src/components/user/__tests__/UserAttributeForm.race.spec.ts", - "frontend/src/components/user/__tests__/UserAttributeForm.spec.ts", - "frontend/src/components/user/__tests__/UserAttributesConfigModal.spec.ts", - "frontend/src/components/user/__tests__/UserErrorDetailModal.spec.ts", - "frontend/src/components/user/profile/ProfileInfoCard.vue", - "frontend/src/components/user/profile/TotpDisableDialog.vue", - "frontend/src/components/user/profile/TotpSetupModal.vue", - "frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts", - "frontend/src/components/user/profile/__tests__/totp-timer-cleanup.spec.ts", - "frontend/src/composables/__tests__/antigravityMappings.retry.spec.ts", - "frontend/src/composables/__tests__/useModelWhitelist.spec.ts", - "frontend/src/composables/useAutoRefresh.ts", - "frontend/src/composables/useModelWhitelist.ts", - "frontend/src/constants/channel.ts", - "frontend/src/i18n/locales/en/admin/accounts.ts", - "frontend/src/i18n/locales/en/admin/channels.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/channels.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/common.ts", - "frontend/src/i18n/locales/zh/dashboard.ts", - "frontend/src/stores/__tests__/adminSettings.retry.spec.ts", - "frontend/src/stores/__tests__/announcements.fetch.spec.ts", - "frontend/src/stores/adminSettings.ts", - "frontend/src/stores/announcements.ts", - "frontend/src/types/index.ts", - "frontend/src/types/openaiReferrals.ts", - "frontend/src/utils/__tests__/ccswitchImport.antigravityUrl.spec.ts", "frontend/src/utils/__tests__/ccswitchImport.spec.ts", - "frontend/src/utils/__tests__/proxyExpiry.boundary.spec.ts", "frontend/src/utils/ccswitchImport.ts", - "frontend/src/utils/proxyExpiry.ts", - "frontend/src/views/admin/BackupView.vue", - "frontend/src/views/admin/ChannelsView.vue", "frontend/src/views/admin/GroupsView.vue", - "frontend/src/views/admin/RiskControlView.vue", - "frontend/src/views/admin/SettingsView.vue", - "frontend/src/views/admin/UsersView.vue", - "frontend/src/views/admin/__tests__/BackupView.spec.ts", - "frontend/src/views/admin/__tests__/GroupsView.codexManifest.spec.ts", - "frontend/src/views/admin/__tests__/GroupsView.duplicate.spec.ts", - "frontend/src/views/admin/__tests__/RiskControlView.spec.ts", - "frontend/src/views/admin/__tests__/SettingsView.spec.ts", - "frontend/src/views/admin/__tests__/UsersView.spec.ts", - "frontend/src/views/admin/affiliates/AdminAffiliateRecordsTable.vue", - "frontend/src/views/admin/affiliates/AffiliateOfflineWithdrawDialog.vue", - "frontend/src/views/admin/affiliates/__tests__/AdminAffiliateRecordsTable.spec.ts", - "frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts", - "frontend/src/views/admin/affiliates/__tests__/affiliateWithdrawOperation.spec.ts", - "frontend/src/views/admin/affiliates/affiliateWithdrawOperation.ts", - "frontend/src/views/admin/ops/components/LogRetentionSelect.vue", - "frontend/src/views/admin/ops/components/OpsSystemLogTable.vue", - "frontend/src/views/admin/ops/components/__tests__/OpsSystemLogTable.spec.ts", - "frontend/src/views/user/ChannelStatusV1View.vue", - "frontend/src/views/user/UsageView.vue", - "frontend/src/views/user/__tests__/ChannelStatusV1View.refresh.spec.ts", - "frontend/src/views/user/__tests__/UsageView.spec.ts" + "frontend/src/views/admin/__tests__/groupModelAllowlist.spec.ts", + "frontend/src/views/admin/groupModelAllowlist.ts", + "frontend/src/views/user/KeysView.vue" ], - "upstream_tag": "v0.2.8" + "upstream_tag": "v0.2.9" } diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index a45be4627678..1866a362b7be 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.2.8 +0.2.9 diff --git a/backend/internal/handler/concurrency_error_response_test.go b/backend/internal/handler/concurrency_error_response_test.go index 2d1b3d6ad2e9..4a6b8a79ae09 100644 --- a/backend/internal/handler/concurrency_error_response_test.go +++ b/backend/internal/handler/concurrency_error_response_test.go @@ -2,10 +2,13 @@ package handler import ( "context" + "encoding/json" "errors" "net/http" + "net/http/httptest" "testing" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -73,3 +76,65 @@ func TestConcurrencyErrorResponse(t *testing.T) { }) } } + +func TestGoogleConcurrencyError(t *testing.T) { + tests := []struct { + name string + err error + wantStatus int + wantGStatus string + wantMessage string + }{ + { + name: "client cancellation is 499", + err: context.Canceled, + wantStatus: statusClientClosedRequest, + wantGStatus: "CANCELLED", + wantMessage: "context canceled", + }, + { + name: "full wait queue is 429", + err: &WaitQueueFullError{SlotType: "user"}, + wantStatus: http.StatusTooManyRequests, + wantGStatus: "RESOURCE_EXHAUSTED", + wantMessage: "Too many pending requests, please retry later", + }, + { + name: "slot wait timeout is 429", + err: &ConcurrencyError{SlotType: "user", IsTimeout: true}, + wantStatus: http.StatusTooManyRequests, + wantGStatus: "RESOURCE_EXHAUSTED", + wantMessage: "Concurrency limit exceeded for user, please retry later", + }, + { + name: "acquire backend error is 503", + err: errors.New("redis unavailable"), + wantStatus: http.StatusServiceUnavailable, + wantGStatus: "INTERNAL", + wantMessage: "Service temporarily unavailable, please retry later", + }, + } + + gin.SetMode(gin.TestMode) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + + googleConcurrencyError(c, tt.err, "user") + + require.Equal(t, tt.wantStatus, rec.Code) + var body struct { + Error struct { + Code int `json:"code"` + Message string `json:"message"` + Status string `json:"status"` + } `json:"error"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.Equal(t, tt.wantStatus, body.Error.Code) + require.Equal(t, tt.wantGStatus, body.Error.Status) + require.Equal(t, tt.wantMessage, body.Error.Message) + }) + } +} diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 261839e71ecd..e0b73929cf61 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -330,6 +330,10 @@ func (h *GatewayHandler) Messages(c *gin.Context) { for { selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, "", int64(0)) // Gemini 不使用会话限制 if err != nil { + if failoverClientGone(c) { + reqLog.Info("gateway.account_select_aborted_client_disconnected", zap.Error(err)) + return + } if len(fs.FailedAccountIDs) == 0 { cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformGemini) if !cls.ModelNotFound { @@ -660,6 +664,10 @@ func (h *GatewayHandler) Messages(c *gin.Context) { ) selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), currentAPIKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, parsedReq.MetadataUserID, subject.UserID) if err != nil { + if failoverClientGone(c) { + reqLog.Info("gateway.account_select_aborted_client_disconnected", zap.Error(err)) + return + } if len(fs.FailedAccountIDs) == 0 { cls := classifyNoAccountErrorFromGin(c, h.gatewayService, currentAPIKey, reqModel, reqModel, platform) if !cls.ModelNotFound { @@ -1995,10 +2003,15 @@ func (h *GatewayHandler) handleStreamingAwareErrorWithCode(c *gin.Context, statu // Writer 已被写过时(ping 已 flush)走 streamStarted 分支, // 让 handleStreamingAwareError 通过 SSE 发协议合规的终止事件, // 否则下游收到的就是 silent EOF。 +// 客户端已断开时不补写,响应未提交则标记 499(见 failoverClientGone)。 func (h *GatewayHandler) ensureForwardErrorResponse(c *gin.Context, streamStarted bool) bool { if c == nil || c.Writer == nil { return false } + if c.Request != nil && errors.Is(c.Request.Context().Err(), context.Canceled) { + failoverClientGone(c) + return false + } if service.IsResponseCommitted(c) { return false } diff --git a/backend/internal/handler/gateway_handler_cancellation_test.go b/backend/internal/handler/gateway_handler_cancellation_test.go index 07bde1eb134b..9573aa839188 100644 --- a/backend/internal/handler/gateway_handler_cancellation_test.go +++ b/backend/internal/handler/gateway_handler_cancellation_test.go @@ -93,6 +93,8 @@ func TestGatewayHandlerPreCancelledCompatibleRequestsDoNotSelectAccount(t *testi require.Zero(t, schedulerCache.snapshotCalls.Load(), "a cancelled request must stop before the account selector") _, selected := c.Get(opsAccountIDKey) require.False(t, selected) + require.Equal(t, statusClientClosedRequest, c.Writer.Status(), "an uncommitted cancelled request is marked 499") + require.Zero(t, recorder.Body.Len()) }) } } diff --git a/backend/internal/handler/gateway_handler_chat_completions.go b/backend/internal/handler/gateway_handler_chat_completions.go index a932dc78464b..97a41d82a327 100644 --- a/backend/internal/handler/gateway_handler_chat_completions.go +++ b/backend/internal/handler/gateway_handler_chat_completions.go @@ -168,11 +168,15 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) { } for { - if c.Request.Context().Err() != nil { + if failoverClientGone(c) { return } selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, selectionSessionHash, reqModel, fs.FailedAccountIDs, "", int64(0)) if err != nil { + if failoverClientGone(c) { + reqLog.Info("gateway.cc.account_select_aborted_client_disconnected", zap.Error(err)) + return + } if len(fs.FailedAccountIDs) == 0 { cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, groupPlatform) cls = classifySelectionFailureError(err, cls) diff --git a/backend/internal/handler/gateway_handler_error_fallback_test.go b/backend/internal/handler/gateway_handler_error_fallback_test.go index a38580dfc6db..917a85e009aa 100644 --- a/backend/internal/handler/gateway_handler_error_fallback_test.go +++ b/backend/internal/handler/gateway_handler_error_fallback_test.go @@ -1,6 +1,7 @@ package handler import ( + "context" "encoding/json" "errors" "net/http" @@ -70,6 +71,43 @@ func TestGatewayEnsureForwardErrorResponse_SkipsCommittedSSEError(t *testing.T) require.Equal(t, 1, strings.Count(w.Body.String(), "event: error")) } +// 客户端已断开且响应未提交:不补写 502,标记 499 供访问日志与 ops 归类。 +func TestGatewayEnsureForwardErrorResponse_SkipsCanceledClient(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + ctx, cancel := context.WithCancel(context.Background()) + c.Request = httptest.NewRequest(http.MethodPost, EndpointMessages, nil).WithContext(ctx) + cancel() + + h := &GatewayHandler{} + wrote := h.ensureForwardErrorResponse(c, false) + + require.False(t, wrote) + require.Equal(t, statusClientClosedRequest, c.Writer.Status()) + require.Empty(t, w.Body.String()) +} + +// 客户端已断开但流已开始:状态码已固化为 200,不再向断开的连接追加错误帧。 +func TestGatewayEnsureForwardErrorResponse_CanceledClientAfterStreamStartedAppendsNothing(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + ctx, cancel := context.WithCancel(context.Background()) + c.Request = httptest.NewRequest(http.MethodPost, EndpointMessages, nil).WithContext(ctx) + c.Header("Content-Type", "text/event-stream") + _, _ = c.Writer.WriteString(":\n\n") + cancel() + + h := &GatewayHandler{} + wrote := h.ensureForwardErrorResponse(c, true) + + require.False(t, wrote) + require.Equal(t, http.StatusOK, c.Writer.Status()) + require.Equal(t, ":\n\n", w.Body.String()) + require.Empty(t, service.GetOpsStreamErrors(c)) +} + // case B 回归:Anthropic-backed /responses,Writer 已被写过时 // ensureForwardErrorResponse 仍要发 response.failed。 func TestGatewayEnsureForwardErrorResponse_ResponsesRouteAfterWrittenEmitsResponseFailed(t *testing.T) { diff --git a/backend/internal/handler/gateway_handler_responses.go b/backend/internal/handler/gateway_handler_responses.go index dc36e68ac810..4b956f5c56c8 100644 --- a/backend/internal/handler/gateway_handler_responses.go +++ b/backend/internal/handler/gateway_handler_responses.go @@ -170,11 +170,15 @@ func (h *GatewayHandler) Responses(c *gin.Context) { fs := NewFailoverState(h.maxAccountSwitches, false) for { - if requestCtx.Err() != nil { + if failoverClientGone(c) { return } selection, err := h.gatewayService.SelectAccountWithLoadAwareness(requestCtx, apiKey.GroupID, sessionHash, reqModel, fs.FailedAccountIDs, "", int64(0)) if err != nil { + if failoverClientGone(c) { + reqLog.Info("gateway.responses.account_select_aborted_client_disconnected", zap.Error(err)) + return + } if len(fs.FailedAccountIDs) == 0 { cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, effectiveAPIKeyPlatform(c, apiKey)) cls = classifySelectionFailureError(err, cls) diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 3d946a27a52a..9e41496c9c03 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -299,6 +299,18 @@ func TestGatewayModels_UnmappedOpenAIAccountsSupplementMappedModels(t *testing.T accounts: accounts[1:], want: []string{sparkModel, alias}, }, + { + // A passthrough account with a stale mapping behaves like an unmapped + // one: it adds the defaults but never its own mapping keys, and it no + // longer hides the aliases declared on ordinary accounts. + name: "passthrough account contributes defaults without hiding mapped aliases", + accounts: append([]service.Account{{ + ID: 5, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, + Credentials: map[string]any{"model_mapping": map[string]any{"stale-model": "stale-model"}}, + Extra: map[string]any{"openai_passthrough": true}, + }}, accounts[1:]...), + want: append(openai.DefaultModelIDs(), alias), + }, { name: "unmapped accounts from another platform do not add defaults", accounts: append([]service.Account{{ID: 4, Platform: service.PlatformAnthropic}}, accounts[1:]...), diff --git a/backend/internal/handler/gemini_client_cancel_test.go b/backend/internal/handler/gemini_client_cancel_test.go new file mode 100644 index 000000000000..aad811f025b2 --- /dev/null +++ b/backend/internal/handler/gemini_client_cancel_test.go @@ -0,0 +1,130 @@ +//go:build unit + +package handler + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "net/url" + "sync/atomic" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" + middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// clientCancelUpstream 模拟上游尚未响应时客户端断开:Do 期间取消请求 context, +// 并按 net/http 的形态返回包裹 context.Canceled 的 *url.Error。 +type clientCancelUpstream struct { + service.HTTPUpstream + cancel context.CancelFunc + calls atomic.Int32 +} + +func (u *clientCancelUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + u.calls.Add(1) + u.cancel() + return nil, &url.Error{Op: "Post", URL: req.URL.String(), Err: context.Canceled} +} + +type geminiClientCancelFixture struct { + handler *GatewayHandler + group *service.Group + apiKey *service.APIKey + upstream *clientCancelUpstream + ctx context.Context +} + +func newGeminiClientCancelFixture(t *testing.T) *geminiClientCancelFixture { + t.Helper() + groupID := int64(9200) + accountID := int64(9201) + group := &service.Group{ID: groupID, Hydrated: true, Platform: service.PlatformGemini, Status: service.StatusActive} + account := &service.Account{ + ID: accountID, + Platform: service.PlatformGemini, + Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "test-key"}, + Status: service.StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 1, + AccountGroups: []service.AccountGroup{{AccountID: accountID, GroupID: groupID}}, + } + h, cleanup := newTestGatewayHandler(t, group, []*service.Account{account}) + t.Cleanup(cleanup) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + upstream := &clientCancelUpstream{cancel: cancel} + h.geminiCompatService = service.NewGeminiMessagesCompatService(nil, nil, nil, nil, nil, nil, upstream, nil, &config.Config{}) + + apiKey := &service.APIKey{ + ID: 9202, UserID: 9203, GroupID: &groupID, Group: group, Status: service.StatusActive, + User: &service.User{ID: 9203, Concurrency: 10, Balance: 100}, + } + return &geminiClientCancelFixture{handler: h, group: group, apiKey: apiKey, upstream: upstream, ctx: ctx} +} + +// serve 经 ops 错误日志中间件执行请求,返回响应与 ops 队列中的条目数。 +func (f *geminiClientCancelFixture) serve(t *testing.T, route, path, body string, call func(*gin.Context)) (*httptest.ResponseRecorder, int64) { + t.Helper() + setupOpsErrorLogTestQueue(t, 4) + ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router := gin.New() + router.Use(OpsErrorLoggerMiddleware(ops)) + router.POST(route, func(c *gin.Context) { + c.Set(string(middleware.ContextKeyAPIKey), f.apiKey) + c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: f.apiKey.UserID, Concurrency: 10}) + call(c) + }) + + req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body)) + req = req.WithContext(context.WithValue(f.ctx, ctxkey.Group, f.group)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec, OpsErrorLogQueueLength() +} + +// 原生入口:上游响应前客户端断开,响应未提交,标记 499;纯客户端取消不落 ops 错误日志。 +func TestGeminiV1BetaModels_ClientCancelBeforeUpstreamResponseMarks499(t *testing.T) { + gin.SetMode(gin.TestMode) + f := newGeminiClientCancelFixture(t) + + rec, opsQueued := f.serve(t, + "/v1beta/models/*modelAction", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + `{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + f.handler.GeminiV1BetaModels, + ) + + require.Equal(t, int32(1), f.upstream.calls.Load()) + require.Equal(t, statusClientClosedRequest, rec.Code) + require.Zero(t, rec.Body.Len()) + require.Zero(t, opsQueued) +} + +// Chat Completions 兼容入口(Gemini 分组):同一场景标记 499,不再补写 502。 +func TestGatewayChatCompletions_GeminiClientCancelBeforeUpstreamResponseMarks499(t *testing.T) { + gin.SetMode(gin.TestMode) + f := newGeminiClientCancelFixture(t) + + rec, opsQueued := f.serve(t, + "/v1/chat/completions", + "/v1/chat/completions", + `{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"hi"}],"stream":true}`, + f.handler.ChatCompletions, + ) + + require.Equal(t, int32(1), f.upstream.calls.Load()) + require.Equal(t, statusClientClosedRequest, rec.Code) + require.Zero(t, rec.Body.Len()) + require.Zero(t, opsQueued) +} diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index a7ae9170c115..140e294794ed 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -370,7 +370,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { userReleaseFunc, err := geminiConcurrency.AcquireUserSlotWithWait(c, authSubject.UserID, authSubject.Concurrency, stream, &streamStarted) if err != nil { reqLog.Warn("gemini.user_slot_acquire_failed", zap.Error(err)) - googleError(c, http.StatusTooManyRequests, err.Error()) + googleConcurrencyError(c, err, "user") return } // 确保请求取消时也会释放槽位,避免长连接被动中断造成泄漏 @@ -506,6 +506,10 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { for { selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, modelName, fs.FailedAccountIDs, "", int64(0)) // Gemini 不使用会话限制 if err != nil { + if failoverClientGone(c) { + reqLog.Info("gemini.account_select_aborted_client_disconnected", zap.Error(err)) + return + } if len(fs.FailedAccountIDs) == 0 { cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, modelName, modelName, service.PlatformGemini) if !cls.ModelNotFound { @@ -598,7 +602,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { ) if err != nil { reqLog.Warn("gemini.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err)) - googleError(c, http.StatusTooManyRequests, err.Error()) + googleConcurrencyError(c, err, "account") return } if accountWaitCounted { @@ -674,7 +678,8 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { return } } - // ForwardNative already wrote the response + // 转发层已写出错误响应;客户端断开时转发层不写,响应未提交则标记 499。 + failoverClientGone(c) reqLog.Error("gemini.forward_failed", zap.Int64("account_id", account.ID), zap.Error(err)) return } @@ -836,6 +841,13 @@ func googleError(c *gin.Context, status int, message string) { }) } +// googleConcurrencyError 以 Google 错误格式回写并发槽获取失败,状态码与文案 +// 沿用 concurrencyErrorResponse 的统一映射(客户端断开为 499)。 +func googleConcurrencyError(c *gin.Context, err error, slotType string) { + status, _, _, message := concurrencyErrorResponse(err, slotType) + googleError(c, status, message) +} + func writeUpstreamResponse(c *gin.Context, res *service.UpstreamHTTPResult) { if res == nil { googleError(c, http.StatusBadGateway, "Empty upstream response") diff --git a/backend/internal/handler/model_plaza_handler.go b/backend/internal/handler/model_plaza_handler.go index a4d8d502bb9f..e613080a7470 100644 --- a/backend/internal/handler/model_plaza_handler.go +++ b/backend/internal/handler/model_plaza_handler.go @@ -92,6 +92,8 @@ type modelPlazaGroup struct { // 不取分组/用户专属倍率。 ImageRateIndependent bool `json:"image_rate_independent"` ImageRateMultiplier float64 `json:"image_rate_multiplier"` + VideoRateIndependent bool `json:"video_rate_independent"` + VideoRateMultiplier float64 `json:"video_rate_multiplier"` // 分组是否启用长上下文阶梯计费;关闭时模型实付列只展示最低档/基础价。 LongContextPricingEnabled bool `json:"long_context_pricing_enabled"` Models []modelPlazaModel `json:"models"` @@ -211,6 +213,8 @@ func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) mo IsExclusive: g.IsExclusive, ImageRateIndependent: g.ImageRateIndependent, ImageRateMultiplier: g.ImageRateMultiplier, + VideoRateIndependent: g.VideoRateIndependent, + VideoRateMultiplier: g.VideoRateMultiplier, LongContextPricingEnabled: g.LongContextPricingEnabled, Models: models, } diff --git a/backend/internal/handler/model_plaza_handler_test.go b/backend/internal/handler/model_plaza_handler_test.go index 2de6cbdbe5f9..f356d68b5ef3 100644 --- a/backend/internal/handler/model_plaza_handler_test.go +++ b/backend/internal/handler/model_plaza_handler_test.go @@ -89,6 +89,7 @@ func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) { g := service.PlazaGroup{ ID: 2, Name: "vip", Description: "d", Platform: "anthropic", SubscriptionType: "standard", RateMultiplier: 1, IsExclusive: true, + VideoRateIndependent: true, VideoRateMultiplier: 0.7, Models: []service.PlazaModel{{ Name: "claude-sonnet", Platform: "anthropic", @@ -115,11 +116,14 @@ func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) { "rate_multiplier", "user_rate_multiplier", "is_exclusive", "models", "peak_rate_enabled", "peak_start", "peak_end", "peak_rate_multiplier", "image_rate_independent", "image_rate_multiplier", "long_context_pricing_enabled", + "video_rate_independent", "video_rate_multiplier", } { _, exists := decoded[key] require.Truef(t, exists, "plaza group DTO must expose %q", key) } require.InDelta(t, 0.5, decoded["user_rate_multiplier"].(float64), 1e-9) + require.Equal(t, true, decoded["video_rate_independent"]) + require.Equal(t, 0.7, decoded["video_rate_multiplier"]) // 模型条目:pricing + official_pricing 并存;official 缺失字段输出 null 而非省略 models := decoded["models"].([]any) diff --git a/backend/internal/handler/openai_alpha_search.go b/backend/internal/handler/openai_alpha_search.go index 8b30d4f43e96..639c2d6b6655 100644 --- a/backend/internal/handler/openai_alpha_search.go +++ b/backend/internal/handler/openai_alpha_search.go @@ -228,6 +228,11 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) { var failoverErr *service.UpstreamFailoverError if !errors.As(err, &failoverErr) { + if failoverClientGone(c) { + h.gatewayService.ReleaseOpenAIRuntimeBreakerProbeForSelection(selection) + reqLog.Info("openai_alpha_search.forward_aborted_client_disconnected", zap.Int64("account_id", account.ID), zap.Error(err)) + return + } h.gatewayService.ReportOpenAIAccountScheduleResultForSelection(selection, account.ID, account.GetMappedModel(requestedModel), false, nil) if c.Writer.Size() == writerSizeBeforeForward { h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed") diff --git a/backend/internal/handler/openai_models_handler_test.go b/backend/internal/handler/openai_models_handler_test.go index 9d56465ef612..0e304fcbf90e 100644 --- a/backend/internal/handler/openai_models_handler_test.go +++ b/backend/internal/handler/openai_models_handler_test.go @@ -239,6 +239,12 @@ func TestPinnedModelsAllowlistExpandsWildcardsForBothRepresentations(t *testing. accounts[0].Credentials["model_mapping"] = map[string]any{ "public-a": "gpt-5.5", "public-b": "gpt-5.5", "blocked": "gpt-5.5", } + accounts[0].SetUpstreamModelMetadataSnapshot(service.UpstreamModelMetadataSnapshot{ + Source: "upstream", SourceIdentity: service.UpstreamModelMetadataSourceIdentity(&accounts[0]), + Models: map[string]service.UpstreamModelMetadata{ + "gpt-5.5": {ID: "gpt-5.5", ContextWindow: 272000, MaxContextWindow: 872000, MaxOutputTokens: 64000}, + }, + }) body := `{"data":[{"id":"gpt-5.5","owned_by":"provider"}]}` if codex { body = `{"models":[{"slug":"gpt-5.5","context_window":424242}]}` @@ -249,7 +255,10 @@ func TestPinnedModelsAllowlistExpandsWildcardsForBothRepresentations(t *testing. ModelAllowlist: service.GroupModelAllowlist{Enabled: true, Models: []string{"public-b", "public-*"}}, CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{1}}} wantCapacity := service.ResolveAccountModelContextCapacity(&accounts[0], "gpt-5.5") - require.Equal(t, "official", wantCapacity.Source, "without a persisted upstream declaration the public aliases use the real upstream's reference capacity") + require.Equal(t, "official", wantCapacity.Source, "the real upstream target uses its official API capacity even with a current smaller observation") + require.EqualValues(t, 1050000, wantCapacity.ContextWindow) + require.EqualValues(t, 1050000, wantCapacity.MaxContextWindow) + require.EqualValues(t, 128000, wantCapacity.MaxOutputTokens) if codex { recorder := performPinnedCodexModelsRequest(t, h, group, "") require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) diff --git a/backend/internal/handler/ops_error_logger.go b/backend/internal/handler/ops_error_logger.go index 5311d7b07912..4c2d91ff5613 100644 --- a/backend/internal/handler/ops_error_logger.go +++ b/backend/internal/handler/ops_error_logger.go @@ -1231,6 +1231,9 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { if shouldSkipOpsErrorLog(c.Request.Context(), ops, parsed.Message, string(body), c.Request.URL.Path) { return } + if shouldSkipOpsClientClosed(c, ops, status) { + return + } apiKey := getOpsAPIKey(c) @@ -2621,6 +2624,21 @@ func shouldSkipOpsErrorLog(ctx context.Context, ops *service.OpsService, message return false } +// shouldSkipOpsClientClosed 按 IgnoreContextCanceled 过滤纯客户端取消的 499。 +// 499 表示客户端在响应提交前断开(见 failoverClientGone),通常不带 +// "context canceled" 文案,shouldSkipOpsErrorLog 的文本过滤命中不了。 +// 本次请求未观察到上游错误时是纯客户端取消;已有上游错误的 499 表示上游失败后 +// 客户端没等到换号结果就离开,仍按上游失败落库。 +func shouldSkipOpsClientClosed(c *gin.Context, ops *service.OpsService, status int) bool { + if status != statusClientClosedRequest || ops == nil { + return false + } + if !ops.OpsAdvancedSettingsSnapshot().IgnoreContextCanceled { + return false + } + return !hasOpsUpstreamErrorContext(c) +} + // shouldSkipOpsErrorLogForCyber:cyber_policy 命中的请求由 recordCyberPolicyIfMarked // 统一落一条 status=403 的错误请求,故中间件跳过自身落库,避免双写。 func shouldSkipOpsErrorLogForCyber(c *gin.Context) bool { diff --git a/backend/internal/handler/ops_error_logger_test.go b/backend/internal/handler/ops_error_logger_test.go index db56f64ddb38..ede7071c35ae 100644 --- a/backend/internal/handler/ops_error_logger_test.go +++ b/backend/internal/handler/ops_error_logger_test.go @@ -2284,3 +2284,83 @@ func TestNormalizeOpsErrorType_KeepsGeminiInBandSignalTypes(t *testing.T) { require.Equal(t, errType, normalizeOpsErrorType(errType, "PROHIBITED_CONTENT"), errType) } } + +type opsAdvancedSettingsRepoStub struct { + service.SettingRepository + advanced string +} + +func (r *opsAdvancedSettingsRepoStub) GetValue(context.Context, string) (string, error) { + return "", service.ErrSettingNotFound +} + +func (r *opsAdvancedSettingsRepoStub) GetMultiple(context.Context, []string) (map[string]string, error) { + return map[string]string{service.SettingKeyOpsAdvancedSettings: r.advanced}, nil +} + +func (r *opsAdvancedSettingsRepoStub) Set(context.Context, string, string) error { + return nil +} + +// serveClientClosedRequest 以已取消的请求 context 走 failoverClientGone,复现网关标记 499 的收尾路径。 +func serveClientClosedRequest(t *testing.T, ops *service.OpsService, prepare func(c *gin.Context)) { + t.Helper() + router := gin.New() + router.Use(OpsErrorLoggerMiddleware(ops)) + router.POST("/v1/messages", func(c *gin.Context) { + if prepare != nil { + prepare(c) + } + failoverClientGone(c) + }) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/messages", nil).WithContext(ctx)) + require.Equal(t, statusClientClosedRequest, recorder.Code) + require.Zero(t, recorder.Body.Len()) +} + +func TestOpsErrorLoggerMiddleware_SkipsPureClientClosed(t *testing.T) { + setupOpsErrorLogTestQueue(t, 2) + gin.SetMode(gin.TestMode) + ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + require.True(t, ops.OpsAdvancedSettingsSnapshot().IgnoreContextCanceled) + + serveClientClosedRequest(t, ops, nil) + + require.Zero(t, OpsErrorLogQueueLength(), "纯客户端取消的 499 按 IgnoreContextCanceled 跳过") +} + +func TestOpsErrorLoggerMiddleware_RecordsClientClosedAfterUpstreamFailure(t *testing.T) { + setupOpsErrorLogTestQueue(t, 2) + gin.SetMode(gin.TestMode) + ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + + serveClientClosedRequest(t, ops, func(c *gin.Context) { + c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{ + AccountID: 7, UpstreamStatusCode: 524, Kind: "failover", Message: "upstream timeout", + }}) + }) + + require.Equal(t, int64(1), OpsErrorLogQueueLength(), "上游失败后客户端离开仍按上游失败落库") + job := <-opsErrorLogQueue + require.Equal(t, statusClientClosedRequest, job.entry.StatusCode) + require.Equal(t, "upstream", job.entry.ErrorPhase) + require.NotNil(t, job.entry.UpstreamStatusCode) + require.Equal(t, 524, *job.entry.UpstreamStatusCode) +} + +func TestOpsErrorLoggerMiddleware_RecordsClientClosedWhenIgnoreContextCanceledDisabled(t *testing.T) { + setupOpsErrorLogTestQueue(t, 2) + gin.SetMode(gin.TestMode) + settings := &opsAdvancedSettingsRepoStub{advanced: `{"ignore_context_canceled":false}`} + ops := service.NewOpsService(nil, settings, nil, nil, nil, nil, nil, nil, nil, nil, nil) + require.False(t, ops.OpsAdvancedSettingsSnapshot().IgnoreContextCanceled) + + serveClientClosedRequest(t, ops, nil) + + require.Equal(t, int64(1), OpsErrorLogQueueLength()) + job := <-opsErrorLogQueue + require.Equal(t, statusClientClosedRequest, job.entry.StatusCode) +} diff --git a/backend/internal/pkg/antigravity/request_transformer.go b/backend/internal/pkg/antigravity/request_transformer.go index f9b917fb17e8..9349e54ee864 100644 --- a/backend/internal/pkg/antigravity/request_transformer.go +++ b/backend/internal/pkg/antigravity/request_transformer.go @@ -532,8 +532,8 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu } parts = append(parts, part) - case "image": - if block.Source != nil && block.Source.Type == "base64" { + case "image", "document": + if block.Source != nil && block.Source.Type == "base64" && strings.TrimSpace(block.Source.Data) != "" { parts = append(parts, GeminiPart{ InlineData: &GeminiInlineData{ MimeType: block.Source.MediaType, diff --git a/backend/internal/pkg/antigravity/request_transformer_test.go b/backend/internal/pkg/antigravity/request_transformer_test.go index a5f260eb2a0f..b937182b1982 100644 --- a/backend/internal/pkg/antigravity/request_transformer_test.go +++ b/backend/internal/pkg/antigravity/request_transformer_test.go @@ -648,6 +648,20 @@ func TestGeminiToolConfig_DropsBuiltinsWhenClientFunctionsPresent(t *testing.T) }) } +func TestBuildParts_DocumentBecomesInlineData(t *testing.T) { + content := `[ + {"type":"text","text":"read this"}, + {"type":"document","source":{"type":"base64","media_type":"application/pdf","data":"JVBERi0="}} + ]` + parts, stripped, err := buildParts(json.RawMessage(content), map[string]string{}, true) + require.NoError(t, err) + require.False(t, stripped) + require.Len(t, parts, 2) + require.NotNil(t, parts[1].InlineData) + require.Equal(t, "application/pdf", parts[1].InlineData.MimeType) + require.Equal(t, "JVBERi0=", parts[1].InlineData.Data) +} + // TestToolConfigAlwaysPresent ensures toolConfig is always emitted, including for // reasoning models without any tools: upstream rejects requests that omit it. func TestToolConfigAlwaysPresent(t *testing.T) { diff --git a/backend/internal/pkg/antigravity/schema_cleaner.go b/backend/internal/pkg/antigravity/schema_cleaner.go index 9ac4211e4aff..73cf362561bb 100644 --- a/backend/internal/pkg/antigravity/schema_cleaner.go +++ b/backend/internal/pkg/antigravity/schema_cleaner.go @@ -258,6 +258,18 @@ func cleanJSONSchemaRecursive(value any) any { if constVal, exists := schemaMap["const"]; exists { if _, hasEnum := schemaMap["enum"]; !hasEnum { schemaMap["enum"] = []any{constVal} + } else if constant, ok := constVal.(string); ok { + if existing, ok := schemaMap["enum"].([]any); ok { + // Both constraints apply; preserve their intersection before dropping const. + values := []any{} + for _, value := range existing { + if text, ok := value.(string); ok && text == constant { + values = append(values, constant) + break + } + } + schemaMap["enum"] = values + } } if _, hasType := schemaMap["type"]; !hasType { switch constVal.(type) { diff --git a/backend/internal/pkg/antigravity/schema_const_test.go b/backend/internal/pkg/antigravity/schema_const_test.go new file mode 100644 index 000000000000..05551fbd8338 --- /dev/null +++ b/backend/internal/pkg/antigravity/schema_const_test.go @@ -0,0 +1,45 @@ +package antigravity + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBuildToolsPreservesStringConst(t *testing.T) { + for _, tc := range []struct { + name string + schema string + want string + }{ + {"typed", `{"type":"string","const":"browser"}`, `{"type":"string","enum":["browser"]}`}, + {"inferred", `{"const":"browser"}`, `{"type":"string","enum":["browser"]}`}, + {"empty", `{"type":"string","const":""}`, `{"type":"string","enum":[""]}`}, + {"existing enum", `{"type":"string","const":"browser","enum":["browser","shell"]}`, `{"type":"string","enum":["browser"]}`}, + {"conflicting enum", `{"type":"string","const":"browser","enum":["shell"]}`, `{"type":"string","enum":[]}`}, + {"ordinary enum", `{"type":"string","enum":["browser","shell"]}`, `{"type":"string","enum":["browser","shell"]}`}, + {"array items", `{"type":"array","items":{"type":"string","const":"browser"}}`, `{"type":"array","items":{"type":"string","enum":["browser"]}}`}, + {"nested property named const", `{"type":"object","properties":{"const":{"const":"browser"}}}`, `{"type":"object","properties":{"const":{"type":"string","enum":["browser"]}}}`}, + {"schema metadata", `{"type":"string","const":"browser","$schema":"https://json-schema.org/draft/2020-12/schema","description":"Tool kind"}`, `{"type":"string","enum":["browser"],"description":"Tool kind"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + var property map[string]any + require.NoError(t, json.Unmarshal([]byte(tc.schema), &property)) + tools := buildTools([]ClaudeTool{{ + Name: "dispatch", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{"action": property}, + }, + }}) + require.Len(t, tools, 1) + require.Len(t, tools[0].FunctionDeclarations, 1) + properties, ok := tools[0].FunctionDeclarations[0].Parameters["properties"].(map[string]any) + require.True(t, ok, "tool parameters must contain a properties object") + got, err := json.Marshal(properties["action"]) + require.NoError(t, err) + require.JSONEq(t, tc.want, string(got)) + }) + } +} diff --git a/backend/internal/pkg/antigravity/stream_transformer.go b/backend/internal/pkg/antigravity/stream_transformer.go index 902646aaabd3..13471ddddc51 100644 --- a/backend/internal/pkg/antigravity/stream_transformer.go +++ b/backend/internal/pkg/antigravity/stream_transformer.go @@ -40,6 +40,7 @@ type StreamingProcessor struct { outputTokens int cacheReadTokens int imageOutputTokens int + hasContent bool } // NewStreamingProcessor 创建流式响应处理器 @@ -175,6 +176,11 @@ func (p *StreamingProcessor) MessageStartSent() bool { return p.messageStartSent } +// HasContent reports whether any substantive text, thinking, or tool calls were emitted. +func (p *StreamingProcessor) HasContent() bool { + return p.hasContent +} + // emitMessageStart 发送 message_start 事件 func (p *StreamingProcessor) emitMessageStart(v1Resp *V1InternalResponse) []byte { if p.messageStartSent { @@ -296,6 +302,7 @@ func (p *StreamingProcessor) processThinking(text, signature string) []byte { } if text != "" { + p.hasContent = true _, _ = result.Write(p.emitDelta("thinking_delta", map[string]any{ "thinking": text, })) @@ -350,6 +357,7 @@ func (p *StreamingProcessor) processText(text, signature string) []byte { })) } + p.hasContent = true _, _ = result.Write(p.emitDelta("text_delta", map[string]any{ "text": text, })) @@ -362,6 +370,7 @@ func (p *StreamingProcessor) processFunctionCall(fc *GeminiFunctionCall, signatu var result bytes.Buffer p.usedTool = true + p.hasContent = true toolID := fc.ID if toolID == "" { diff --git a/backend/internal/pkg/apicompat/anthropic_responses_test.go b/backend/internal/pkg/apicompat/anthropic_responses_test.go index a903ba3c1a00..3710b79e919e 100644 --- a/backend/internal/pkg/apicompat/anthropic_responses_test.go +++ b/backend/internal/pkg/apicompat/anthropic_responses_test.go @@ -1140,9 +1140,24 @@ func TestAnthropicToResponses_ThinkingDisabled(t *testing.T) { resp, err := AnthropicToResponses(req) require.NoError(t, err) - // Default effort applies (medium) even when thinking is disabled. require.NotNil(t, resp.Reasoning) - assert.Equal(t, "medium", resp.Reasoning.Effort) + assert.Equal(t, "none", resp.Reasoning.Effort) + assert.Empty(t, resp.Reasoning.Summary) +} + +func TestAnthropicToResponses_ThinkingDisabledOverridesOutputEffort(t *testing.T) { + req := &AnthropicRequest{ + Model: "gpt-5.6-sol", + MaxTokens: 1024, + Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"Hello"`)}}, + Thinking: &AnthropicThinking{Type: "disabled"}, + OutputConfig: &AnthropicOutputConfig{Effort: "max"}, + } + + resp, err := AnthropicToResponses(req) + require.NoError(t, err) + require.NotNil(t, resp.Reasoning) + assert.Equal(t, "none", resp.Reasoning.Effort) } func TestAnthropicToResponses_NoThinking(t *testing.T) { diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses.go b/backend/internal/pkg/apicompat/anthropic_to_responses.go index 8296830a100f..05ca9d1f72b0 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses.go @@ -57,17 +57,13 @@ func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) { out.Tools = convertAnthropicToolsToResponses(req.Tools) } - // Determine reasoning effort: only output_config.effort controls the - // level; thinking.type is ignored. Default follows Codex CLI / airgate's - // Anthropic bridge shape, which uses medium when unset. - // Anthropic levels map 1:1 to OpenAI: low→low, medium→medium, high→high, max→xhigh. - effort := "medium" - if req.OutputConfig != nil && req.OutputConfig.Effort != "" { - effort = req.OutputConfig.Effort - } + // An explicit thinking disable takes precedence over output_config.effort. + effort := anthropicReasoningEffort(req) out.Reasoning = &ResponsesReasoning{ - Effort: mapAnthropicEffortToResponses(effort), - Summary: "auto", + Effort: effort, + } + if effort != "none" { + out.Reasoning.Summary = "auto" } // Convert tool_choice @@ -435,17 +431,22 @@ func extractAnthropicTextFromBlocks(blocks []AnthropicContentBlock) string { return strings.Join(parts, "\n\n") } -// mapAnthropicEffortToResponses converts Anthropic reasoning effort levels to -// OpenAI Responses API effort levels. -// -// Both APIs default to "high". The mapping is 1:1 for shared levels; -// only Anthropic's "max" (Opus 4.6 exclusive) maps to OpenAI's "xhigh" -// (GPT-5.2+ exclusive) as both represent the highest reasoning tier. -// -// low → low -// medium → medium -// high → high -// max → xhigh +// anthropicReasoningEffort resolves the Anthropic request preference for both +// OpenAI bridges. Explicitly disabled thinking overrides output_config.effort; +// otherwise the bridge keeps its medium default. +func anthropicReasoningEffort(req *AnthropicRequest) string { + if req.Thinking != nil && req.Thinking.Type == "disabled" { + return "none" + } + effort := "medium" + if req.OutputConfig != nil && req.OutputConfig.Effort != "" { + effort = req.OutputConfig.Effort + } + return mapAnthropicEffortToResponses(effort) +} + +// mapAnthropicEffortToResponses maps shared effort levels directly and maps +// Anthropic's max to OpenAI's xhigh. func mapAnthropicEffortToResponses(effort string) string { if effort == "max" { return "xhigh" @@ -481,10 +482,38 @@ func boolPtr(v bool) *bool { // isReasoningModel reports whether model is a reasoning model that does not // support sampling parameters (temperature, top_p) via the Responses API. -// All gpt-5.x models are reasoning-only; the Responses API returns -// "Unsupported parameter: temperature" if these fields are present. +// GPT-5 and every later generation are reasoning-only; the Responses API +// returns "Unsupported parameter: temperature" if these fields are present. +// +// Keyed on the generation number instead of a "gpt-5" prefix: pinning the +// prefix meant each new family (gpt-6-astra and whatever follows) silently +// fell through to the sampling branch and failed upstream on every compat +// request until someone edited this line. func isReasoningModel(model string) bool { - return strings.HasPrefix(model, "gpt-5") || openai.IsGPT6SolOrLunaModelSpelling(model) + major, ok := openAIModelGeneration(model) + return (ok && major >= 5) || openai.IsGPT6SolOrLunaModelSpelling(model) +} + +// openAIModelGeneration extracts N from a "gpt-N[.M][-suffix]" model id. +// ok is false for non-GPT ids and for GPT families that carry no numeric +// generation (gpt-image-1, gpt-audio, ...). +func openAIModelGeneration(model string) (int, bool) { + rest, ok := strings.CutPrefix(strings.ToLower(strings.TrimSpace(model)), "gpt-") + if !ok { + return 0, false + } + major, digits := 0, 0 + for _, r := range rest { + if r < '0' || r > '9' { + break + } + major = major*10 + int(r-'0') + digits++ + } + if digits == 0 { + return 0, false + } + return major, true } // normalizeToolParameters ensures the tool parameter schema is valid for diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses_response.go b/backend/internal/pkg/apicompat/anthropic_to_responses_response.go index f4c52fcef639..f0712ecb7473 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses_response.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses_response.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "strings" "time" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" @@ -198,6 +199,12 @@ type AnthropicEventToResponsesState struct { CurrentThinking AnthropicContentBlock PreserveThinkingSignatures bool + // PendingToolInput holds tool arguments that arrived complete on + // content_block_start instead of as input_json_delta. It is only consumed at + // content_block_stop, and only when no delta ever arrived, so a canonical + // Anthropic stream keeps its exact event sequence. + PendingToolInput string + // Outputs accumulates every closed output item so that response.completed // can carry the full output list. The OpenAI SDK's get_final_response() // parses the terminal event's response directly; without this, clients see @@ -382,6 +389,12 @@ func anthToResHandleContentBlockStart(evt *AnthropicStreamEvent, state *Anthropi state.CurrentItemType = "function_call" state.CurrentCallID = toResponsesCallID(evt.ContentBlock.ID) state.CurrentName = evt.ContentBlock.Name + // The canonical Anthropic stream leaves input empty here and streams the + // arguments as input_json_delta, but Anthropic-compatible relays may put + // the complete arguments on this event and never send a delta. Keep them + // as a seed rather than emitting now: a delta, if one follows, is + // authoritative and must not be concatenated onto this JSON. + state.PendingToolInput = seedToolArguments(evt.ContentBlock.Input) events = append(events, makeResponsesEvent(state, "response.output_item.added", &ResponsesStreamEvent{ OutputIndex: state.OutputIndex, @@ -432,6 +445,9 @@ func anthToResHandleContentBlockDelta(evt *AnthropicStreamEvent, state *Anthropi if evt.Delta.PartialJSON == "" { return nil } + // A real delta supersedes whatever content_block_start carried; keeping + // both would splice two complete JSON documents together. + state.PendingToolInput = "" state.CurrentArgs += evt.Delta.PartialJSON return []ResponsesStreamEvent{makeResponsesEvent(state, "response.function_call_arguments.delta", &ResponsesStreamEvent{ OutputIndex: state.OutputIndex, @@ -467,21 +483,35 @@ func anthToResHandleContentBlockStop(evt *AnthropicStreamEvent, state *Anthropic return events case "function_call": + var events []ResponsesStreamEvent + // No delta ever arrived, so the arguments the upstream put on + // content_block_start are all there is. Emit them as one delta here so + // the done event below still repeats exactly what the deltas streamed. + if state.CurrentArgs == "" && state.PendingToolInput != "" { + state.CurrentArgs = state.PendingToolInput + events = append(events, makeResponsesEvent(state, "response.function_call_arguments.delta", &ResponsesStreamEvent{ + OutputIndex: state.OutputIndex, + Delta: state.PendingToolInput, + ItemID: state.CurrentItemID, + CallID: state.CurrentCallID, + Name: state.CurrentName, + })) + } + state.PendingToolInput = "" + // Emit function_call_arguments.done + output item done. // arguments must repeat exactly what the deltas already streamed for this // item: clients reconcile the done event against the accumulated // function_call_arguments.delta payloads and reject the call as // inconsistent_tool_call when the two disagree. Omitting the field left it // empty while the deltas carried the whole JSON. - events := []ResponsesStreamEvent{ - makeResponsesEvent(state, "response.function_call_arguments.done", &ResponsesStreamEvent{ - OutputIndex: state.OutputIndex, - ItemID: state.CurrentItemID, - CallID: state.CurrentCallID, - Name: state.CurrentName, - Arguments: state.CurrentArgs, - }), - } + events = append(events, makeResponsesEvent(state, "response.function_call_arguments.done", &ResponsesStreamEvent{ + OutputIndex: state.OutputIndex, + ItemID: state.CurrentItemID, + CallID: state.CurrentCallID, + Name: state.CurrentName, + Arguments: state.CurrentArgs, + })) events = append(events, closeCurrentResponsesItem(state)...) return events @@ -562,6 +592,19 @@ func anthropicResponsesStreamTerminalState(stopReason string) (string, *Response return "completed", nil } +// seedToolArguments normalizes a tool_use content block's inline input into a +// seed for the streaming converter. Empty, absent and no-argument payloads +// return "" so the existing "{}" fallback still applies and no empty delta is +// synthesized. +func seedToolArguments(input json.RawMessage) string { + trimmed := strings.TrimSpace(string(input)) + switch trimmed { + case "", "{}", "null": + return "" + } + return trimmed +} + func closeCurrentResponsesItem(state *AnthropicEventToResponsesState) []ResponsesStreamEvent { if state.CurrentItemType == "" { return nil @@ -605,6 +648,7 @@ func closeCurrentResponsesItem(state *AnthropicEventToResponsesState) []Response state.CurrentName = "" state.CurrentContent = nil state.CurrentArgs = "" + state.PendingToolInput = "" state.CurrentSummary = "" state.CurrentThinking = AnthropicContentBlock{} state.TextAccum = "" diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses_stream_tool_input_test.go b/backend/internal/pkg/apicompat/anthropic_to_responses_stream_tool_input_test.go new file mode 100644 index 000000000000..fc582de68bf4 --- /dev/null +++ b/backend/internal/pkg/apicompat/anthropic_to_responses_stream_tool_input_test.go @@ -0,0 +1,168 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// collectToolCallStreamEvents drives a tool_use block through the Anthropic → +// Responses stream converter and returns every emitted event in order. +func collectToolCallStreamEvents(t *testing.T, blockInput json.RawMessage, partials []string) []ResponsesStreamEvent { + t.Helper() + + state := NewAnthropicEventToResponsesState() + var all []ResponsesStreamEvent + + all = append(all, AnthropicEventToResponsesEvents(&AnthropicStreamEvent{ + Type: "message_start", + Message: &AnthropicResponse{ + ID: "msg_tool_input", + Model: "gemini-3.7-flash", + Usage: AnthropicUsage{InputTokens: 12}, + }, + }, state)...) + + all = append(all, AnthropicEventToResponsesEvents(&AnthropicStreamEvent{ + Type: "content_block_start", + ContentBlock: &AnthropicContentBlock{ + Type: "tool_use", + ID: "toolu_01abc", + Name: "eval", + Input: blockInput, + }, + }, state)...) + + for _, partial := range partials { + all = append(all, AnthropicEventToResponsesEvents(&AnthropicStreamEvent{ + Type: "content_block_delta", + Delta: &AnthropicDelta{Type: "input_json_delta", PartialJSON: partial}, + }, state)...) + } + + all = append(all, AnthropicEventToResponsesEvents(&AnthropicStreamEvent{Type: "content_block_stop"}, state)...) + all = append(all, AnthropicEventToResponsesEvents(&AnthropicStreamEvent{ + Type: "message_delta", + Usage: &AnthropicUsage{OutputTokens: 9}, + }, state)...) + all = append(all, AnthropicEventToResponsesEvents(&AnthropicStreamEvent{Type: "message_stop"}, state)...) + + return all +} + +func concatArgumentDeltas(events []ResponsesStreamEvent) string { + out := "" + for _, e := range events { + if e.Type == "response.function_call_arguments.delta" { + out += e.Delta + } + } + return out +} + +func findFunctionCallOutput(events []ResponsesStreamEvent) *ResponsesOutput { + for i := range events { + if events[i].Type != "response.output_item.done" || events[i].Item == nil { + continue + } + if events[i].Item.Type == "function_call" { + return events[i].Item + } + } + return nil +} + +// Upstreams that are not the canonical Anthropic API (here: an Anthropic-compatible +// relay fronting Gemini) put the complete tool arguments on content_block_start and +// never emit an input_json_delta. The converter must not drop them: this repository +// already reads ContentBlock.Input on the non-streaming path +// (anthropicResponseToResponsesOutputs) and on the ChatCompletions bridge, so the +// streaming path has to agree. Dropping it makes every client-side tool call arrive +// with `{}` and the agent loop cannot proceed. +func TestAnthropicEventToResponses_ToolInputOnContentBlockStart(t *testing.T) { + const args = `{"language":"py","code":"print(1)"}` + + events := collectToolCallStreamEvents(t, json.RawMessage(args), nil) + + item := findFunctionCallOutput(events) + require.NotNil(t, item, "a function_call output item must be emitted") + assert.Equal(t, args, item.Arguments, + "arguments carried on content_block_start must survive to output_item.done") + assert.Equal(t, "eval", item.Name) + + // Clients that accumulate from the event stream (rather than reading the + // terminal item) must see the arguments too. + assert.Equal(t, args, concatArgumentDeltas(events), + "argument deltas must reconstruct the full arguments JSON") + + done := findEvent(events, "response.function_call_arguments.done") + require.NotNil(t, done, "function_call_arguments.done must be emitted") + assert.Equal(t, args, done.Arguments, + "function_call_arguments.done must carry the complete arguments") + + completed := findEvent(events, "response.completed") + require.NotNil(t, completed) + require.NotNil(t, completed.Response) + require.Len(t, completed.Response.Output, 1) + assert.Equal(t, args, completed.Response.Output[0].Arguments, + "response.completed must carry the same arguments") +} + +// The canonical Anthropic shape (empty input on content_block_start, arguments +// streamed as input_json_delta) must keep its existing event sequence exactly. +func TestAnthropicEventToResponses_ToolInputFromDeltasUnchanged(t *testing.T) { + const args = `{"language":"py","code":"print(1)"}` + + events := collectToolCallStreamEvents(t, json.RawMessage(`{}`), + []string{`{"language":"py",`, `"code":"print(1)"}`}) + + item := findFunctionCallOutput(events) + require.NotNil(t, item) + assert.Equal(t, args, item.Arguments) + + // Exactly the two upstream deltas, not duplicated by the seed. + var deltas []string + for _, e := range events { + if e.Type == "response.function_call_arguments.delta" { + deltas = append(deltas, e.Delta) + } + } + assert.Equal(t, []string{`{"language":"py",`, `"code":"print(1)"}`}, deltas, + "canonical delta streaming must not gain or lose events") +} + +// An upstream that sends both a populated content_block_start and real deltas +// must not have the two concatenated into malformed JSON. +func TestAnthropicEventToResponses_ToolInputSeedNotDuplicatedByDeltas(t *testing.T) { + const args = `{"language":"py","code":"print(1)"}` + + events := collectToolCallStreamEvents(t, json.RawMessage(args), + []string{`{"language":"py",`, `"code":"print(1)"}`}) + + item := findFunctionCallOutput(events) + require.NotNil(t, item) + assert.Equal(t, args, item.Arguments, "deltas win over the content_block_start seed") + assert.True(t, json.Valid([]byte(item.Arguments)), "arguments must stay valid JSON") + assert.Equal(t, args, concatArgumentDeltas(events), "seed must not be replayed alongside deltas") +} + +// An empty or absent input on content_block_start with no deltas keeps the +// existing "{}" fallback rather than emitting a spurious delta. +func TestAnthropicEventToResponses_ToolInputEmptyStaysEmptyObject(t *testing.T) { + for name, input := range map[string]json.RawMessage{ + "absent": nil, + "empty": json.RawMessage(`{}`), + } { + t.Run(name, func(t *testing.T) { + events := collectToolCallStreamEvents(t, input, nil) + + item := findFunctionCallOutput(events) + require.NotNil(t, item) + assert.Equal(t, "{}", item.Arguments) + assert.Empty(t, concatArgumentDeltas(events), + "no argument delta should be synthesized when there are no arguments") + }) + } +} diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses_tool_input_restore_chain_test.go b/backend/internal/pkg/apicompat/anthropic_to_responses_tool_input_restore_chain_test.go new file mode 100644 index 000000000000..a1f9de72e222 --- /dev/null +++ b/backend/internal/pkg/apicompat/anthropic_to_responses_tool_input_restore_chain_test.go @@ -0,0 +1,131 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// driveToolUseThroughRestorer runs a tool_use block through the full outbound +// chain the gateway actually uses: the Anthropic→Responses stream converter +// feeding ResponsesClientToolStreamRestorer. Returns the events a client sees. +func driveToolUseThroughRestorer( + blockInput json.RawMessage, + partials []string, + mapping ResponsesClientToolMapping, +) []ResponsesStreamEvent { + state := NewAnthropicEventToResponsesState() + restorer := NewResponsesClientToolStreamRestorer(mapping) + + var clientEvents []ResponsesStreamEvent + feed := func(evt *AnthropicStreamEvent) { + for _, converted := range AnthropicEventToResponsesEvents(evt, state) { + clientEvents = append(clientEvents, restorer.Restore(converted)...) + } + } + + feed(&AnthropicStreamEvent{ + Type: "message_start", + Message: &AnthropicResponse{ + ID: "msg_chain", + Model: "gemini-3.7-flash", + Usage: AnthropicUsage{InputTokens: 11}, + }, + }) + feed(&AnthropicStreamEvent{ + Type: "content_block_start", + ContentBlock: &AnthropicContentBlock{ + Type: "tool_use", + ID: "toolu_chain", + Name: "eval", + Input: blockInput, + }, + }) + for _, partial := range partials { + feed(&AnthropicStreamEvent{ + Type: "content_block_delta", + Delta: &AnthropicDelta{Type: "input_json_delta", PartialJSON: partial}, + }) + } + feed(&AnthropicStreamEvent{Type: "content_block_stop"}) + feed(&AnthropicStreamEvent{Type: "message_delta", Usage: &AnthropicUsage{OutputTokens: 6}}) + feed(&AnthropicStreamEvent{Type: "message_stop"}) + + return clientEvents +} + +func firstEventOfType(events []ResponsesStreamEvent, typ string) *ResponsesStreamEvent { + for i := range events { + if events[i].Type == typ { + return &events[i] + } + } + return nil +} + +// A client-side custom tool whose arguments arrived only on content_block_start +// must still reach the client as a populated custom_tool_call. The restorer +// suppresses function_call_arguments.delta for adapted tools and rebuilds the +// input from the accumulated arguments, so a converter that drops the inline +// input yields an empty tool call even though nothing errors. +func TestToolInputOnContentBlockStart_SurvivesClientToolRestore(t *testing.T) { + mapping := ResponsesClientToolMapping{CustomTools: map[string]bool{"eval": true}} + + events := driveToolUseThroughRestorer( + json.RawMessage(`{"input":"print(1)"}`), nil, mapping) + + inputDone := firstEventOfType(events, "response.custom_tool_call_input.done") + require.NotNil(t, inputDone, "custom_tool_call_input.done must be emitted") + assert.Equal(t, "print(1)", inputDone.Input, + "inline tool input must survive conversion and client-tool restoration") + + var itemDone *ResponsesStreamEvent + for i := range events { + if events[i].Type == "response.output_item.done" && events[i].Item != nil && + events[i].Item.Type == "custom_tool_call" { + itemDone = &events[i] + } + } + require.NotNil(t, itemDone, "a custom_tool_call item must be closed") + assert.Equal(t, "print(1)", itemDone.Item.Input) +} + +// The same block delivered as a plain (non-adapted) function tool must carry +// its arguments through untouched. +func TestToolInputOnContentBlockStart_PlainFunctionToolChain(t *testing.T) { + const args = `{"language":"py","code":"print(1)"}` + + events := driveToolUseThroughRestorer( + json.RawMessage(args), nil, ResponsesClientToolMapping{}) + + done := firstEventOfType(events, "response.function_call_arguments.done") + require.NotNil(t, done) + assert.Equal(t, args, done.Arguments) + + var itemDone *ResponsesStreamEvent + for i := range events { + if events[i].Type == "response.output_item.done" && events[i].Item != nil && + events[i].Item.Type == "function_call" { + itemDone = &events[i] + } + } + require.NotNil(t, itemDone) + assert.Equal(t, args, itemDone.Item.Arguments) +} + +// Canonical delta streaming through the same chain must keep working, so the +// seed path cannot be mistaken for the only source of arguments. +func TestToolInputFromDeltas_SurvivesClientToolRestore(t *testing.T) { + mapping := ResponsesClientToolMapping{CustomTools: map[string]bool{"eval": true}} + + events := driveToolUseThroughRestorer( + json.RawMessage(`{}`), + []string{`{"input":"pri`, `nt(1)"}`}, + mapping) + + inputDone := firstEventOfType(events, "response.custom_tool_call_input.done") + require.NotNil(t, inputDone) + assert.Equal(t, "print(1)", inputDone.Input) +} diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go index 47d4601c24d2..fc73afa3a4b8 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go @@ -99,13 +99,8 @@ func AnthropicToChatCompletionsRequest(req *AnthropicRequest) (*ChatCompletionsR } } - // Reasoning effort: output_config.effort maps 1:1 (max→xhigh). thinking.type - // itself is ignored (the Responses bridge behaves identically). - effort := "medium" - if req.OutputConfig != nil && req.OutputConfig.Effort != "" { - effort = req.OutputConfig.Effort - } - out.ReasoningEffort = mapAnthropicEffortToResponses(effort) + // Match the Responses bridge, including an explicit thinking disable. + out.ReasoningEffort = anthropicReasoningEffort(req) parallelToolCalls := true out.ParallelToolCalls = ¶llelToolCalls diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go index 8ebd5092fa5e..b278c28a83e4 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go @@ -150,6 +150,20 @@ func TestAnthropicToChatCompletionsRequest_ThinkingDropped(t *testing.T) { require.Empty(t, out.Messages[0].ReasoningContent) } +func TestAnthropicToChatCompletionsRequest_ThinkingDisabledOverridesOutputEffort(t *testing.T) { + req := &AnthropicRequest{ + Model: "gpt-5.6-luna", + MaxTokens: 1024, + Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"Hello"`)}}, + Thinking: &AnthropicThinking{Type: "disabled"}, + OutputConfig: &AnthropicOutputConfig{Effort: "max"}, + } + + out, err := AnthropicToChatCompletionsRequest(req) + require.NoError(t, err) + require.Equal(t, "none", out.ReasoningEffort) +} + func TestAnthropicToChatCompletionsRequest_ToolChoiceAuto(t *testing.T) { req := &AnthropicRequest{ Model: "claude-sonnet-4-20250514", diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index 47fca97e6a92..09d80f53799a 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -92,6 +92,56 @@ func TestChatCompletionsToResponses_SystemMessage(t *testing.T) { assert.Equal(t, "user", items[1].Role) } +func TestChatCompletionsToResponses_MessageTypesWithReasoningAndTools(t *testing.T) { + req := &ChatCompletionsRequest{ + Model: "step-5-preview", + Messages: []ChatMessage{ + {Role: "system", Content: json.RawMessage(`"You are helpful."`)}, + {Role: "user", Content: json.RawMessage(`"Check the directory"`)}, + { + Role: "assistant", + Content: json.RawMessage(`"I will check."`), + ReasoningContent: "Need to inspect the directory.", + ToolCalls: []ChatToolCall{{ + ID: "call_1", Type: "function", + Function: ChatFunctionCall{Name: "bash", Arguments: `{"cmd":"pwd"}`}, + }}, + }, + {Role: "tool", ToolCallID: "call_1", Content: json.RawMessage(`"/tmp"`)}, + {Role: "assistant", ReasoningContent: "The tool returned /tmp."}, + {Role: "user", Content: json.RawMessage(`"Continue"`)}, + }, + } + + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + + var items []ResponsesInputItem + require.NoError(t, json.Unmarshal(resp.Input, &items)) + require.Len(t, items, 7) + for i, want := range []struct{ typ, role string }{ + {"message", "system"}, + {"message", "user"}, + {"message", "assistant"}, + {"function_call", ""}, + {"function_call_output", ""}, + {"message", "assistant"}, + {"message", "user"}, + } { + assert.Equal(t, want.typ, items[i].Type, "input item %d type", i) + assert.Equal(t, want.role, items[i].Role, "input item %d role", i) + } + var assistantContent, reasoningOnlyContent []ResponsesContentPart + require.NoError(t, json.Unmarshal(items[2].Content, &assistantContent)) + require.NoError(t, json.Unmarshal(items[5].Content, &reasoningOnlyContent)) + require.Len(t, assistantContent, 1) + require.Len(t, reasoningOnlyContent, 1) + assert.Equal(t, "Need to inspect the directory.\nI will check.", assistantContent[0].Text) + assert.Equal(t, "The tool returned /tmp.", reasoningOnlyContent[0].Text) + assert.Equal(t, "call_1", items[3].CallID) + assert.Equal(t, "call_1", items[4].CallID) +} + func TestChatCompletionsToResponses_ToolCalls(t *testing.T) { req := &ChatCompletionsRequest{ Model: "gpt-4o", diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index 740461e23a91..482232667ed9 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -168,7 +168,7 @@ func chatSystemToResponses(m ChatMessage) ([]ResponsesInputItem, error) { if err != nil { return nil, err } - return []ResponsesInputItem{{Role: "system", Content: content}}, nil + return []ResponsesInputItem{{Type: "message", Role: "system", Content: content}}, nil } // chatUserToResponses converts a user message, handling both plain strings and @@ -182,7 +182,7 @@ func chatUserToResponses(m ChatMessage) ([]ResponsesInputItem, error) { if err != nil { return nil, err } - return []ResponsesInputItem{{Role: "user", Content: content}}, nil + return []ResponsesInputItem{{Type: "message", Role: "user", Content: content}}, nil } // chatAssistantToResponses converts an assistant message. If there is both @@ -217,7 +217,7 @@ func chatAssistantToResponses(m ChatMessage) ([]ResponsesInputItem, error) { if err != nil { return nil, err } - items = append(items, ResponsesInputItem{Role: "assistant", Content: partsJSON}) + items = append(items, ResponsesInputItem{Type: "message", Role: "assistant", Content: partsJSON}) } // Emit one function_call item per tool_call. diff --git a/backend/internal/pkg/apicompat/reasoning_generation_test.go b/backend/internal/pkg/apicompat/reasoning_generation_test.go new file mode 100644 index 000000000000..8347be8fdda3 --- /dev/null +++ b/backend/internal/pkg/apicompat/reasoning_generation_test.go @@ -0,0 +1,78 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// GPT-5 and every later generation are reasoning-only: forwarding temperature or +// top_p makes the Responses API reject the request with "Unsupported parameter". +// The check is keyed on the generation number so a new family (gpt-6-astra and +// whatever follows) does not silently fall into the sampling branch. +func TestIsReasoningModelCoversLaterGenerations(t *testing.T) { + for _, tc := range []struct { + model string + want bool + }{ + {"gpt-6-astra", true}, + {"gpt-6", true}, + {"gpt-7-whatever", true}, + {"gpt-6-sol", true}, + {"gpt-5.5", true}, + {"gpt-5.2", true}, + {"gpt-5", true}, + {"GPT-6-Astra", true}, + {" gpt-6-astra ", true}, + {"gpt-4o", false}, + {"gpt-4.1", false}, + {"gpt-image-1", false}, + {"gpt-audio", false}, + {"claude-opus-4-6", false}, + {"gemini-3.1-pro", false}, + {"", false}, + } { + if got := isReasoningModel(tc.model); got != tc.want { + t.Errorf("isReasoningModel(%q) = %v, want %v", tc.model, got, tc.want) + } + } +} + +func TestOpenAIModelGeneration(t *testing.T) { + for _, tc := range []struct { + model string + wantMajor int + wantOK bool + }{ + {"gpt-6-astra", 6, true}, + {"gpt-5.5", 5, true}, + {"gpt-4o", 4, true}, + {"gpt-10-future", 10, true}, + {"gpt-image-1", 0, false}, + {"claude-opus-4-6", 0, false}, + {"", 0, false}, + } { + major, ok := openAIModelGeneration(tc.model) + if major != tc.wantMajor || ok != tc.wantOK { + t.Errorf("openAIModelGeneration(%q) = (%d, %v), want (%d, %v)", + tc.model, major, ok, tc.wantMajor, tc.wantOK) + } + } +} + +func TestAnthropicToResponses_TemperatureStrippedForGPT6Astra(t *testing.T) { + temp := 0.7 + req := &AnthropicRequest{ + Model: "gpt-6-astra", + MaxTokens: 1024, + Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"Hello"`)}}, + Temperature: &temp, + TopP: &temp, + } + + resp, err := AnthropicToResponses(req) + require.NoError(t, err) + require.Nil(t, resp.Temperature, "gpt-6-astra is reasoning-only: temperature must be stripped") + require.Nil(t, resp.TopP, "gpt-6-astra is reasoning-only: top_p must be stripped") +} diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_invalid_blocks_test.go b/backend/internal/pkg/apicompat/responses_to_anthropic_invalid_blocks_test.go index ed410f68519a..d3bd29081b13 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_invalid_blocks_test.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_invalid_blocks_test.go @@ -109,7 +109,23 @@ func TestResponsesToAnthropic_UnknownItemTypeKeepsRecognizableText(t *testing.T) require.NotContains(t, string(messages[0].Content), "drop me") } -// user 消息的分片全部不可识别时,以前会退化成 content:"",Anthropic 拒收空内容消息。 +// data URI 形式的 input_file 要变成 Anthropic document,供后续 Gemini inlineData 使用。 +func TestResponsesToAnthropic_InputFileDataURIBecomesDocument(t *testing.T) { + messages := responsesToAnthropicMessages(t, `[ + {"type":"message","role":"user","content":[ + {"type":"input_text","text":"read this"}, + {"type":"input_file","filename":"token.pdf","file_data":"data:application/pdf;base64,JVBERi0="} + ]} + ]`) + + requireAnthropicMessagesAreSendable(t, messages) + require.Len(t, messages, 1) + require.Contains(t, string(messages[0].Content), `"type":"document"`) + require.Contains(t, string(messages[0].Content), `"media_type":"application/pdf"`) + require.Contains(t, string(messages[0].Content), `"data":"JVBERi0="`) +} + +// 只有 file_id、没有 data URI 的 input_file 仍然无法转换,整条消息丢掉。 func TestResponsesToAnthropic_UserMessageWithOnlyUnknownPartsIsDropped(t *testing.T) { messages := responsesToAnthropicMessages(t, `[ {"type":"message","role":"user","content":[{"type":"input_file","file_id":"file_1"}]} diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go index 06d9669d29c4..69928591d3c2 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go @@ -530,6 +530,14 @@ func convertResponsesUserToAnthropicContent(raw json.RawMessage) (json.RawMessag Source: src, }) } + case "input_file": + src := dataURIToAnthropicFileSource(p.FileData) + if src != nil { + blocks = append(blocks, AnthropicContentBlock{ + Type: "document", + Source: src, + }) + } } } @@ -617,6 +625,12 @@ func dataURIToAnthropicImageSource(dataURI string) *AnthropicImageSource { } } +// dataURIToAnthropicFileSource parses a data URI into a document source. +// file_id-only parts are not convertible here and stay dropped. +func dataURIToAnthropicFileSource(fileData string) *AnthropicImageSource { + return dataURIToAnthropicImageSource(fileData) +} + // mergeConsecutiveMessages merges consecutive messages with the same role // because Anthropic requires alternating user/assistant turns. func mergeConsecutiveMessages(messages []AnthropicMessage) []AnthropicMessage { diff --git a/backend/internal/pkg/apicompat/responses_to_chatcompletions.go b/backend/internal/pkg/apicompat/responses_to_chatcompletions.go index 48e89ce745a9..78aadfd495bb 100644 --- a/backend/internal/pkg/apicompat/responses_to_chatcompletions.go +++ b/backend/internal/pkg/apicompat/responses_to_chatcompletions.go @@ -661,6 +661,24 @@ func (a *BufferedResponseAccumulator) SupplementResponseOutput(resp *ResponsesRe return } + // The terminal event can carry a non-empty output array whose message has + // no usable text. Trusting it as-is silently drops the text that already + // streamed: the client gets an empty reply while usage still bills the + // terminal output_tokens. Refill it from the accumulated deltas; non-empty + // terminal text stays authoritative. + if a.text.Len() > 0 && !responsesOutputHasText(resp.Output) { + if !fillResponsesOutputText(resp.Output, a.text.String()) { + resp.Output = append(resp.Output, ResponsesOutput{ + Type: "message", + Role: "assistant", + Content: []ResponsesContentPart{{ + Type: "output_text", + Text: a.text.String(), + }}, + }) + } + } + for outputIndex := range resp.Output { item := &resp.Output[outputIndex] if item.Type != "function_call" || item.Arguments != "" { @@ -686,3 +704,46 @@ func (a *BufferedResponseAccumulator) SupplementResponseOutput(resp *ResponsesRe } } } + +// responsesOutputHasText reports whether the terminal output already carries +// usable message text. Whitespace-only text does not count. +func responsesOutputHasText(output []ResponsesOutput) bool { + for i := range output { + if output[i].Type != "message" { + continue + } + for _, part := range output[i].Content { + if part.Type == "output_text" && strings.TrimSpace(part.Text) != "" { + return true + } + } + } + return false +} + +// fillResponsesOutputText writes text into the first empty output_text part of +// the first message item, adding an output_text part when that message has +// none. It returns false when the output holds no message item, leaving the +// caller to append one. +func fillResponsesOutputText(output []ResponsesOutput, text string) bool { + for i := range output { + if output[i].Type != "message" { + continue + } + for j := range output[i].Content { + if output[i].Content[j].Type != "output_text" { + continue + } + if strings.TrimSpace(output[i].Content[j].Text) == "" { + output[i].Content[j].Text = text + return true + } + } + output[i].Content = append(output[i].Content, ResponsesContentPart{ + Type: "output_text", + Text: text, + }) + return true + } + return false +} diff --git a/backend/internal/pkg/apicompat/responses_to_chatcompletions_text_recovery_test.go b/backend/internal/pkg/apicompat/responses_to_chatcompletions_text_recovery_test.go new file mode 100644 index 000000000000..1da89aa7b345 --- /dev/null +++ b/backend/internal/pkg/apicompat/responses_to_chatcompletions_text_recovery_test.go @@ -0,0 +1,134 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// When the terminal event carries a non-empty output array whose message has +// no text, the text that already streamed must be restored. Otherwise the +// client gets an empty reply while usage still bills the terminal +// output_tokens. The accumulator previously rebuilt output only when the +// terminal output array was empty, so this upstream shape silently lost text. +func TestSupplementResponseOutput_RecoversTextWhenTerminalMessageIsEmpty(t *testing.T) { + acc := NewBufferedResponseAccumulator() + acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "Hello, "}) + acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "world"}) + + resp := &ResponsesResponse{ + ID: "resp_empty_msg", + Status: "completed", + Output: []ResponsesOutput{{ + Type: "message", + Role: "assistant", + Content: []ResponsesContentPart{}, + }}, + Usage: &ResponsesUsage{InputTokens: 13168, OutputTokens: 3329}, + } + + acc.SupplementResponseOutput(resp) + + chat := ResponsesToChatCompletions(resp, "gpt-5.5") + require.Len(t, chat.Choices, 1) + require.NotNil(t, chat.Choices[0].Message.Content, "the client must not receive an empty reply") + + var got string + require.NoError(t, json.Unmarshal(chat.Choices[0].Message.Content, &got)) + assert.Equal(t, "Hello, world", got) +} + +// Whitespace-only terminal text counts as missing too. +func TestSupplementResponseOutput_RecoversTextWhenTerminalTextIsBlank(t *testing.T) { + acc := NewBufferedResponseAccumulator() + acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "real text"}) + + resp := &ResponsesResponse{ + Status: "completed", + Output: []ResponsesOutput{{ + Type: "message", + Content: []ResponsesContentPart{{Type: "output_text", Text: " "}}, + }}, + } + + acc.SupplementResponseOutput(resp) + + chat := ResponsesToChatCompletions(resp, "m") + var got string + require.NotNil(t, chat.Choices[0].Message.Content) + require.NoError(t, json.Unmarshal(chat.Choices[0].Message.Content, &got)) + assert.Equal(t, "real text", got) +} + +// A terminal output array without any message item gets one appended to carry +// the accumulated text; existing items are kept. +func TestSupplementResponseOutput_AppendsMessageWhenTerminalHasNoMessage(t *testing.T) { + acc := NewBufferedResponseAccumulator() + acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "only in the stream"}) + + resp := &ResponsesResponse{ + Status: "completed", + Output: []ResponsesOutput{{ + Type: "function_call", + CallID: "call_1", + Name: "verify", + }}, + } + + acc.SupplementResponseOutput(resp) + + var text string + for _, item := range resp.Output { + if item.Type != "message" { + continue + } + for _, p := range item.Content { + text += p.Text + } + } + assert.Equal(t, "only in the stream", text) + + require.Len(t, resp.Output, 2) + assert.Equal(t, "function_call", resp.Output[0].Type) + assert.Equal(t, "verify", resp.Output[0].Name) +} + +// Non-empty terminal text stays authoritative and is never overwritten by the +// accumulated stream. +func TestSupplementResponseOutput_KeepsTerminalTextAuthoritative(t *testing.T) { + acc := NewBufferedResponseAccumulator() + acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "from the stream"}) + + resp := &ResponsesResponse{ + Status: "completed", + Output: []ResponsesOutput{{ + Type: "message", + Content: []ResponsesContentPart{{Type: "output_text", Text: "from the terminal event"}}, + }}, + } + + acc.SupplementResponseOutput(resp) + + require.Len(t, resp.Output, 1) + assert.Equal(t, "from the terminal event", resp.Output[0].Content[0].Text) +} + +// Without streamed text no synthetic message is created. +func TestSupplementResponseOutput_NoSyntheticMessageWithoutStreamText(t *testing.T) { + acc := NewBufferedResponseAccumulator() + + resp := &ResponsesResponse{ + Status: "completed", + Output: []ResponsesOutput{{ + Type: "message", + Content: []ResponsesContentPart{}, + }}, + } + + acc.SupplementResponseOutput(resp) + + require.Len(t, resp.Output, 1) + assert.Empty(t, resp.Output[0].Content) +} diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index b25a80835079..b99935b2cb19 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -270,7 +270,7 @@ type ResponsesText struct { // The Type field determines which other fields are populated. type ResponsesInputItem struct { // Common - Type string `json:"type,omitempty"` // "" for role-based messages + Type string `json:"type,omitempty"` // "message" for role-based messages // Role-based messages (developer/system/user/assistant) Role string `json:"role,omitempty"` diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index c6b13f4ed1b8..9cdcfc16b80c 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -18,6 +18,8 @@ const ( BetaTokenCounting = "token-counting-2024-11-01" BetaContext1M = "context-1m-2025-08-07" BetaFastMode = "fast-mode-2026-02-01" + // Legacy structured output compatibility; forwarded only when explicitly requested. + BetaStructuredOutputs = "structured-outputs-2025-11-13" // 新增(对齐官方 CLI 2.1.9x 以来的流量) BetaPromptCachingScope = "prompt-caching-scope-2026-01-05" diff --git a/backend/internal/pkg/claude/effort_catalog.go b/backend/internal/pkg/claude/effort_catalog.go index 00fd337917f0..760281aa2e67 100644 --- a/backend/internal/pkg/claude/effort_catalog.go +++ b/backend/internal/pkg/claude/effort_catalog.go @@ -53,6 +53,11 @@ func normalizeEffortModelID(model string) string { } id = strings.TrimPrefix(id, "anthropic.") id = strings.TrimSuffix(id, "-thinking") + // OpenRouter uses a dotted minor version for this exact Opus 5.5 ID. + // Normalize it before effort, thinking, and billing family lookups. + if id == "claude-opus-5.5" { + id = "claude-opus-5-5" + } if mapped, ok := ModelIDReverseOverrides[id]; ok { id = mapped } diff --git a/backend/internal/pkg/claude/effort_catalog_test.go b/backend/internal/pkg/claude/effort_catalog_test.go index 718e20eb5754..9bcd5206ee4f 100644 --- a/backend/internal/pkg/claude/effort_catalog_test.go +++ b/backend/internal/pkg/claude/effort_catalog_test.go @@ -16,6 +16,7 @@ func TestEffortLevelsForModel(t *testing.T) { {model: "claude-opus-4-6", want: []string{"low", "medium", "high", "max"}}, {model: "anthropic/claude-sonnet-4-6", want: []string{"low", "medium", "high", "max"}}, {model: "claude-opus-5", want: []string{"low", "medium", "high", "xhigh", "max"}}, + {model: "anthropic/claude-opus-5.5", want: []string{"low", "medium", "high", "xhigh", "max"}}, {model: "claude-opus-4-5-20251101", want: []string{"low", "medium", "high"}}, {model: "claude-haiku-4-5-20251001", want: nil}, {model: "gpt-5.6", want: nil}, @@ -27,3 +28,13 @@ func TestEffortLevelsForModel(t *testing.T) { }) } } + +func TestIsOpus55OpenRouterExactAlias(t *testing.T) { + t.Parallel() + for _, model := range []string{"claude-opus-5-5", "anthropic/claude-opus-5.5"} { + require.True(t, IsOpus55(model), model) + } + for _, model := range []string{"claude-opus-5", "anthropic/claude-opus-5.6", "anthropic/claude-opus-5.5-preview"} { + require.False(t, IsOpus55(model), model) + } +} diff --git a/backend/internal/pkg/googleapi/status.go b/backend/internal/pkg/googleapi/status.go index 5eb0c54addde..18a0eacb41d3 100644 --- a/backend/internal/pkg/googleapi/status.go +++ b/backend/internal/pkg/googleapi/status.go @@ -16,6 +16,8 @@ func HTTPStatusToGoogleStatus(status int) string { return "NOT_FOUND" case http.StatusTooManyRequests: return "RESOURCE_EXHAUSTED" + case 499: // client closed request + return "CANCELLED" default: if status >= 500 { return "INTERNAL" diff --git a/backend/internal/pkg/googleapi/status_test.go b/backend/internal/pkg/googleapi/status_test.go new file mode 100644 index 000000000000..123515be0fa7 --- /dev/null +++ b/backend/internal/pkg/googleapi/status_test.go @@ -0,0 +1,28 @@ +package googleapi + +import ( + "net/http" + "testing" +) + +func TestHTTPStatusToGoogleStatus(t *testing.T) { + cases := []struct { + status int + want string + }{ + {http.StatusBadRequest, "INVALID_ARGUMENT"}, + {http.StatusUnauthorized, "UNAUTHENTICATED"}, + {http.StatusForbidden, "PERMISSION_DENIED"}, + {http.StatusNotFound, "NOT_FOUND"}, + {http.StatusTooManyRequests, "RESOURCE_EXHAUSTED"}, + {499, "CANCELLED"}, + {http.StatusBadGateway, "INTERNAL"}, + {http.StatusServiceUnavailable, "INTERNAL"}, + {http.StatusConflict, "UNKNOWN"}, + } + for _, tc := range cases { + if got := HTTPStatusToGoogleStatus(tc.status); got != tc.want { + t.Errorf("HTTPStatusToGoogleStatus(%d) = %q, want %q", tc.status, got, tc.want) + } + } +} diff --git a/backend/internal/repository/scheduler_cache.go b/backend/internal/repository/scheduler_cache.go index 5ae5c4fe9f6c..816ac3dfeb61 100644 --- a/backend/internal/repository/scheduler_cache.go +++ b/backend/internal/repository/scheduler_cache.go @@ -1113,6 +1113,13 @@ func filterSchedulerExtra(extra map[string]any) map[string]any { "auto_pause_7d_threshold", "auto_pause_5h_disabled", "auto_pause_7d_disabled", + // 自动用卡:卡可用的 OpenAI 号在暂停阈值与用卡阈值之间继续调度。 + // 候选过滤读的是本投影,缺这几个键时放行分支永远不会生效, + // 账号会在暂停阈值处被一刀切停调,直到窗口自然重置。 + service.OpenAIAutoResetCreditEnabledExtraKey, + service.OpenAIAutoResetCredit5hThresholdExtraKey, + service.OpenAIAutoResetCredit7dThresholdExtraKey, + service.OpenAIAutoResetCreditStateExtraKey, "model_rate_limits", service.UpstreamBillingProbeExtraKey, service.GrokMediaEligibleExtraKey, diff --git a/backend/internal/repository/scheduler_cache_unit_test.go b/backend/internal/repository/scheduler_cache_unit_test.go index 53d73836760a..4e01e4195922 100644 --- a/backend/internal/repository/scheduler_cache_unit_test.go +++ b/backend/internal/repository/scheduler_cache_unit_test.go @@ -589,6 +589,47 @@ func TestBuildSchedulerMetadataAccount_KeepsQuotaAutoPauseFields(t *testing.T) { require.Equal(t, false, got.Extra["auto_pause_7d_disabled"]) } +// 候选过滤读的是 Redis 元数据投影;自动用卡的开关、阈值和卡状态缺失时, +// 卡可用的 OpenAI 号会在暂停阈值处被一刀切停调,放行到用卡阈值的逻辑永远不生效。 +func TestSchedulerMetadataPayload_KeepsOpenAIAutoResetCreditFields(t *testing.T) { + checkedAt := time.Now().UTC().Format(time.RFC3339) + account := service.Account{ + ID: 40, + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + Schedulable: true, + Extra: map[string]any{ + "codex_7d_used_percent": 90.0, + service.OpenAIAutoResetCreditEnabledExtraKey: true, + service.OpenAIAutoResetCredit5hThresholdExtraKey: 0.95, + service.OpenAIAutoResetCredit7dThresholdExtraKey: 0.98, + service.OpenAIAutoResetCreditStateExtraKey: map[string]any{ + "status": service.OpenAIAutoResetStatusAvailable, + "available_count": 3, + "checked_at": checkedAt, + "trigger_window": "7d", + }, + }, + } + + _, metaPayload, err := marshalSchedulerCacheAccount(account) + require.NoError(t, err) + var cached service.Account + require.NoError(t, json.Unmarshal(metaPayload, &cached)) + + config := service.ResolveOpenAIAutoResetCreditConfig(&cached) + require.True(t, config.Enabled) + require.Equal(t, 0.95, config.Threshold5h) + require.Equal(t, 0.98, config.Threshold7d) + + state, ok := cached.Extra[service.OpenAIAutoResetCreditStateExtraKey].(map[string]any) + require.True(t, ok, "卡状态必须进入调度投影") + require.Equal(t, service.OpenAIAutoResetStatusAvailable, state["status"]) + require.EqualValues(t, 3, state["available_count"]) + require.Equal(t, checkedAt, state["checked_at"]) +} + func TestBuildSchedulerMetadataAccount_KeepsQuotaStateForCachedAccounts(t *testing.T) { now := time.Now().UTC() activeStart := now.Add(-time.Hour).Format(time.RFC3339) diff --git a/backend/internal/service/account_scheduling_threshold_eval_test.go b/backend/internal/service/account_scheduling_threshold_eval_test.go index d249c3e8e7b3..f9b5a762214b 100644 --- a/backend/internal/service/account_scheduling_threshold_eval_test.go +++ b/backend/internal/service/account_scheduling_threshold_eval_test.go @@ -184,6 +184,63 @@ func TestEvaluateAccountSchedulingThreshold_OpenAIKeepsKnownDeadlineFromStaleSna require.False(t, decision.ShouldPause, "an advisory pause without a deadline still needs fresh evidence") } +func TestOpenAIThresholdCandidate_StaleSnapshotFutureReset(t *testing.T) { + now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC) + for _, tc := range []struct { + name string + reset map[string]any + want bool + }{ + {"absolute future", map[string]any{"codex_5h_reset_at": now.Add(time.Hour).Format(time.RFC3339)}, true}, + {"relative future", map[string]any{"codex_5h_reset_after_seconds": 4 * 3600}, true}, + {"missing", nil, false}, + {"invalid", map[string]any{"codex_5h_reset_at": "invalid"}, false}, + {"past", map[string]any{"codex_5h_reset_at": now.Add(-time.Minute).Format(time.RFC3339)}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + extra := map[string]any{"codex_usage_updated_at": now.Add(-3 * time.Hour).Format(time.RFC3339), "codex_5h_used_percent": 99.0} + for key, value := range tc.reset { + extra[key] = value + } + candidate := openAIThresholdCandidate(extra, "5h", now) + require.Equal(t, tc.want, candidate != nil) + if tc.want { + decision := EvaluateAccountSchedulingThreshold(&Account{Platform: PlatformOpenAI, Extra: extra}, map[string]int{PlatformOpenAI: 95}, now) + require.True(t, decision.ShouldPause) + require.NotNil(t, decision.Until) + require.True(t, now.Before(*decision.Until)) + } + }) + } +} + +func TestResolveOpenAIQuotaUtilization_StaleSnapshotFutureReset(t *testing.T) { + now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC) + for _, tc := range []struct { + name string + reset map[string]any + want bool + }{ + {"absolute future", map[string]any{"codex_5h_reset_at": now.Add(time.Hour).Format(time.RFC3339)}, true}, + {"relative future", map[string]any{"codex_5h_reset_after_seconds": 4 * 3600}, true}, + {"missing", nil, false}, + {"invalid", map[string]any{"codex_5h_reset_at": "invalid"}, false}, + {"past", map[string]any{"codex_5h_reset_at": now.Add(-time.Minute).Format(time.RFC3339)}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + extra := map[string]any{"codex_usage_updated_at": now.Add(-3 * time.Hour).Format(time.RFC3339), "codex_5h_used_percent": 99.0} + for key, value := range tc.reset { + extra[key] = value + } + utilization, ok := resolveOpenAIQuotaUtilization(extra, "5h", now) + require.Equal(t, tc.want, ok) + if ok { + require.Equal(t, 0.99, utilization) + } + }) + } +} + func TestEvaluateAccountSchedulingThreshold_OpenAISkipsResetWindow(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/account_stats_pricing.go b/backend/internal/service/account_stats_pricing.go index 4a738ccf7715..dcd08e119367 100644 --- a/backend/internal/service/account_stats_pricing.go +++ b/backend/internal/service/account_stats_pricing.go @@ -19,6 +19,8 @@ import ( // totalCost 是本次请求的客户计费(倍率前),用于优先级 2。 // serviceTier 是最终参与用户计费的 OpenAI 服务层级,用于优先级 3。 // pricingAt 与本次客户计费使用同一时刻,避免跨峰谷请求的成本与售价错位。 +// longContextPricingEnabled 表示上游是否对本次请求收取长上下文费率,用于优先级 3; +// 由 accountStatsLongContextPricingEnabled 按账号开关得出,不受分组售价开关影响。 // reasoningEffort 是最终转发等级;按账号统计定价中配置的等级倍率计费。 func resolveAccountStatsCost( ctx context.Context, @@ -32,6 +34,7 @@ func resolveAccountStatsCost( totalCost float64, serviceTier string, pricingAt time.Time, + longContextPricingEnabled bool, reasoningEfforts ...string, ) *float64 { reasoningEffort := "" @@ -64,7 +67,7 @@ func resolveAccountStatsCost( // 优先级 3:模型定价文件(LiteLLM)默认价格 if billingService != nil { - return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier, pricingAt, reasoningEffort) + return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier, pricingAt, longContextPricingEnabled, reasoningEffort) } return nil @@ -74,11 +77,16 @@ func resolveAccountStatsCost( // 与用户计费共用同一条定价管线,避免这里维护第二份"单价 × token 数"实现后, // 每加一个定价特性都要手工镜像一次。解析器不配置渠道或分组,保持优先级 3 的 // 语义:只取模型定价文件,不引入自定义售价。 -func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string, pricingAt time.Time, reasoningEfforts ...string) *float64 { +func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string, pricingAt time.Time, longContextPricingEnabled bool, reasoningEfforts ...string) *float64 { reasoningEffort := "" if len(reasoningEfforts) > 0 { reasoningEffort = reasoningEfforts[0] } + resolver := NewModelPricingResolver(nil, billingService) + resolved := resolver.Resolve(context.Background(), PricingInput{Model: model}) + // 无分组的解析结果默认开启长上下文,CostInput.LongContextBillingEnabled=false 无法否决, + // 因此直接覆写解析结果。 + resolved.longContextPricingEnabled = longContextPricingEnabled breakdown, err := billingService.CalculateCostUnified(CostInput{ Ctx: context.Background(), Model: model, @@ -87,7 +95,8 @@ func tryModelFilePricing(billingService *BillingService, model string, tokens Us ServiceTier: normalizeBillingServiceTier(serviceTier), ReasoningEffort: reasoningEffort, PricingAt: pricingAt, - Resolver: NewModelPricingResolver(nil, billingService), + Resolver: resolver, + Resolved: resolved, }) if err != nil || breakdown == nil || breakdown.TotalCost <= 0 { return nil @@ -263,6 +272,7 @@ func applyAccountStatsCost( tokens UsageTokens, totalCost float64, pricingAt time.Time, + longContextPricingEnabled bool, ) { model := upstreamModel if model == "" { @@ -281,6 +291,14 @@ func applyAccountStatsCost( reasoningEffort = *usageLog.ReasoningEffort } usageLog.AccountStatsCost = resolveAccountStatsCost( - ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, serviceTier, pricingAt, reasoningEffort, + ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, serviceTier, pricingAt, longContextPricingEnabled, reasoningEffort, ) } + +// accountStatsLongContextPricingEnabled 判断账号统计成本是否计入长上下文阶梯。 +// 账号统计成本反映上游实际成本:OpenAI 账号由开关声明上游是否收取长上下文费率; +// 其它平台没有该开关(accountGate 为 nil),按官方阶梯计。分组开关只决定客户售价, +// 不参与成本判断。 +func accountStatsLongContextPricingEnabled(accountGate *bool) bool { + return accountGate == nil || *accountGate +} diff --git a/backend/internal/service/account_stats_pricing_test.go b/backend/internal/service/account_stats_pricing_test.go index 622fc73f3c9a..26eb645935ed 100644 --- a/backend/internal/service/account_stats_pricing_test.go +++ b/backend/internal/service/account_stats_pricing_test.go @@ -472,7 +472,7 @@ func TestTryModelFilePricing_Success(t *testing.T) { }, }) tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} - result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}) + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}, true) require.NotNil(t, result) // 100*0.001 + 50*0.002 = 0.1 + 0.1 = 0.2 require.InDelta(t, 0.2, *result, 1e-12) @@ -483,8 +483,8 @@ func TestTryModelFilePricing_Fable51HasNoImplicitReasoningMultiplier(t *testing. "claude-fable-5-1": {InputPricePerToken: 0.001}, }) tokens := UsageTokens{InputTokens: 100} - standard := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, "xhigh") - max := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, "max") + standard := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, true, "xhigh") + max := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, true, "max") require.NotNil(t, standard) require.NotNil(t, max) require.Equal(t, *standard, *max) @@ -503,7 +503,7 @@ func TestTryModelFilePricing_AppliesLongContextPricing(t *testing.T) { }) tokens := UsageTokens{InputTokens: 101, OutputTokens: 10, CacheReadTokens: 5} - result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "", time.Time{}) + result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "", time.Time{}, true) require.NotNil(t, result) // Input and cache-read use the 2x input tier; output uses the 1.5x tier. @@ -542,7 +542,7 @@ func TestTryModelFilePricing_AppliesServiceTierPricing(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, tt.serviceTier, time.Time{}) + result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, tt.serviceTier, time.Time{}, true) require.NotNil(t, result) require.InDelta(t, tt.want, *result, 1e-12) }) @@ -572,7 +572,7 @@ func TestTryModelFilePricing_CombinesPriorityAndLongContextPricing(t *testing.T) CacheReadTokens: 5, } - result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "priority", time.Time{}) + result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "priority", time.Time{}, true) require.NotNil(t, result) // priority 单价先应用,再叠加长上下文输入 2x、输出 1.5x。 @@ -583,7 +583,7 @@ func TestTryModelFilePricing_PricingNotFound(t *testing.T) { // "nonexistent-model" does not match any fallback pattern bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{}) tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} - result := tryModelFilePricing(bs, "nonexistent-model", tokens, "", time.Time{}) + result := tryModelFilePricing(bs, "nonexistent-model", tokens, "", time.Time{}, true) require.Nil(t, result) } @@ -593,7 +593,7 @@ func TestTryModelFilePricing_NilFallback(t *testing.T) { "claude-sonnet-4": nil, }) tokens := UsageTokens{InputTokens: 100} - result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}) + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}, true) require.Nil(t, result) } @@ -605,7 +605,7 @@ func TestTryModelFilePricing_ZeroCost(t *testing.T) { }, }) tokens := UsageTokens{} // all zero tokens → cost = 0 → nil - result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}) + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}, true) require.Nil(t, result) } @@ -622,7 +622,7 @@ func TestTryModelFilePricing_WithImageOutput(t *testing.T) { OutputTokens: 50, ImageOutputTokens: 10, } - result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}) + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}, true) require.NotNil(t, result) // ImageOutputTokens 是 OutputTokens 的子集,先扣除再按图片单价计。 // 100*0.001 + (50-10)*0.002 + 10*0.01 = 0.1 + 0.08 + 0.1 = 0.28 @@ -644,7 +644,7 @@ func TestTryModelFilePricing_WithCacheTokens(t *testing.T) { CacheCreationTokens: 200, CacheReadTokens: 300, } - result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}) + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}, true) require.NotNil(t, result) // 100*0.001 + 50*0.002 + 200*0.003 + 300*0.0005 // = 0.1 + 0.1 + 0.6 + 0.15 = 0.95 @@ -692,7 +692,7 @@ func TestTryModelFilePricing_DeepSeekPeakPricing(t *testing.T) { {"sunday", time.Date(2026, time.August, 23, 7, 0, 0, 0, time.UTC), 1}, } { t.Run(slot.name, func(t *testing.T) { - cost := tryModelFilePricing(bs, model.name, tokens, "", slot.at) + cost := tryModelFilePricing(bs, model.name, tokens, "", slot.at, true) require.NotNil(t, cost) require.InDelta(t, baseCost*slot.multiplier, *cost, 1e-12) }) @@ -738,7 +738,7 @@ func TestResolveAccountStatsCost_DeepSeekPricingPriority(t *testing.T) { groupID = 99 } cost := resolveAccountStatsCost(context.Background(), cs, newTestBillingService(), - 1, groupID, "deepseek-v4-flash", UsageTokens{InputTokens: 1000}, 1, 0.75, "", peak) + 1, groupID, "deepseek-v4-flash", UsageTokens{InputTokens: 1000}, 1, 0.75, "", peak, true) if tt.noChannel { require.Nil(t, cost) return @@ -759,7 +759,7 @@ func TestResolveAccountStatsCost_NilChannelService(t *testing.T) { nil, // channelService is nil newTestBillingServiceWithPrices(map[string]*ModelPricing{}), 1, 1, "claude-sonnet-4", - UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, + UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, true, ) require.Nil(t, result) } @@ -775,7 +775,7 @@ func TestResolveAccountStatsCost_EmptyUpstreamModel(t *testing.T) { cs, newTestBillingServiceWithPrices(map[string]*ModelPricing{}), 1, 1, "", // empty upstream model - UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, + UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, true, ) require.Nil(t, result) } @@ -792,7 +792,7 @@ func TestResolveAccountStatsCost_GetChannelForGroupReturnsNil(t *testing.T) { cs, newTestBillingServiceWithPrices(map[string]*ModelPricing{}), 1, 99, "claude-sonnet-4", // groupID 99 has no channel - UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, + UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, true, ) require.Nil(t, result) } @@ -823,7 +823,7 @@ func TestResolveAccountStatsCost_HitsCustomRule(t *testing.T) { context.Background(), cs, nil, // billingService not needed when custom rule hits 1, 10, "claude-sonnet-4", - tokens, 1, 999.0, "priority", time.Time{}, // 自定义账号价格不叠加服务层级倍率 + tokens, 1, 999.0, "priority", time.Time{}, true, // 自定义账号价格不叠加服务层级倍率 ) require.NotNil(t, result) // 100*0.01 + 50*0.02 = 1.0 + 1.0 = 2.0 @@ -845,7 +845,7 @@ func TestResolveAccountStatsCost_ApplyPricingToAccountStats_UsesTotalCost(t *tes context.Background(), cs, nil, 1, 10, "claude-sonnet-4", - tokens, 1, 0.75, "priority", time.Time{}, // 已完成用户计费,不再重复应用服务层级倍率 + tokens, 1, 0.75, "priority", time.Time{}, true, // 已完成用户计费,不再重复应用服务层级倍率 ) require.NotNil(t, result) require.InDelta(t, 0.75, *result, 1e-12) @@ -863,7 +863,7 @@ func TestResolveAccountStatsCost_ApplyPricingToAccountStats_ZeroTotalCost_Return context.Background(), cs, nil, 1, 10, "claude-sonnet-4", - UsageTokens{}, 1, 0.0, "", time.Time{}, // totalCost = 0 + UsageTokens{}, 1, 0.0, "", time.Time{}, true, // totalCost = 0 ) require.Nil(t, result) } @@ -890,7 +890,7 @@ func TestResolveAccountStatsCost_FallsBackToLiteLLM(t *testing.T) { context.Background(), cs, bs, 1, 10, "claude-sonnet-4", - tokens, 1, 999.0, "", time.Time{}, // totalCost ignored + tokens, 1, 999.0, "", time.Time{}, true, // totalCost ignored ) require.NotNil(t, result) // 100*0.001 + 50*0.002 = 0.1 + 0.1 = 0.2 @@ -911,7 +911,7 @@ func TestResolveAccountStatsCost_FallbackHonorsAnthropicFast(t *testing.T) { context.Background(), cs, bs, 1, 10, "claude-opus-5", UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000}, - 1, 0, "fast", time.Time{}, + 1, 0, "fast", time.Time{}, true, ) require.NotNil(t, result) require.InDelta(t, 60, *result, 1e-12) @@ -930,7 +930,7 @@ func TestResolveAccountStatsCost_Gemini36FlashTierUsesFallbackPricing(t *testing context.Background(), cs, bs, 1, 10, "gemini-3.6-flash-low", - UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000}, 1, 0, "", time.Time{}, + UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000}, 1, 0, "", time.Time{}, true, ) require.NotNil(t, result) require.InDelta(t, 9.15, *result, 1e-12) @@ -954,7 +954,7 @@ func TestResolveAccountStatsCost_AllMiss_ReturnsNil(t *testing.T) { context.Background(), cs, bs, 1, 10, "totally-unknown-model", - tokens, 1, 0.0, "", time.Time{}, + tokens, 1, 0.0, "", time.Time{}, true, ) require.Nil(t, result) } @@ -971,7 +971,7 @@ func TestResolveAccountStatsCost_NilBillingService_SkipsLiteLLM(t *testing.T) { context.Background(), cs, nil, // billingService is nil 1, 10, "claude-sonnet-4", - UsageTokens{InputTokens: 100}, 1, 0.0, "", time.Time{}, + UsageTokens{InputTokens: 100}, 1, 0.0, "", time.Time{}, true, ) require.Nil(t, result) } @@ -1004,7 +1004,7 @@ func TestResolveAccountStatsCost_CustomRulePriorityOverApplyPricing(t *testing.T context.Background(), cs, nil, 1, 10, "claude-sonnet-4", - tokens, 1, 99.0, "", time.Time{}, // totalCost = 99.0 (would be used if ApplyPricing wins) + tokens, 1, 99.0, "", time.Time{}, true, // totalCost = 99.0 (would be used if ApplyPricing wins) ) require.NotNil(t, result) // Custom rule: 100*0.05 = 5.0 (NOT 99.0 from totalCost) @@ -1032,13 +1032,111 @@ func TestApplyAccountStatsCost_UsesUsageLogServiceTier(t *testing.T) { applyAccountStatsCost( context.Background(), usageLog, cs, bs, 1, 10, "gpt-5.6-sol", "gpt-5.6-sol", - UsageTokens{InputTokens: 100, OutputTokens: 50}, 999, time.Time{}, + UsageTokens{InputTokens: 100, OutputTokens: 50}, 999, time.Time{}, true, ) require.NotNil(t, usageLog.AccountStatsCost) require.InDelta(t, 0.4, *usageLog.AccountStatsCost, 1e-12) } +func TestApplyAccountStatsCost_LongContextFollowsAccountGate(t *testing.T) { + // 渠道售价不参与优先级 3:结果只取模型定价文件。 + channel := &Channel{ + ID: 1, + Status: StatusActive, + ModelPricing: []ChannelModelPricing{{ + Models: []string{"gpt-5.6-sol"}, InputPrice: testPtrFloat64(0.01), + }}, + } + cs := newTestChannelServiceForStats(t, channel, 10, PlatformOpenAI) + bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{ + "gpt-5.6-sol": { + InputPricePerToken: 0.001, + InputPricePerTokenPriority: 0.002, + OutputPricePerToken: 0.002, + OutputPricePerTokenPriority: 0.004, + CacheReadPricePerToken: 0.0001, + CacheReadPricePerTokenPriority: 0.0002, + LongContextInputThreshold: 100, + LongContextInputMultiplier: 2, + LongContextOutputMultiplier: 1.5, + }, + }) + tokens := UsageTokens{InputTokens: 101, OutputTokens: 10, CacheReadTokens: 5} + accountOff, accountOn := false, true + for _, tt := range []struct { + name string + gate *bool + tier string + wantCost float64 + }{ + {name: "account_off", gate: &accountOff, wantCost: 0.1215}, + {name: "account_off_priority", gate: &accountOff, tier: "priority", wantCost: 0.243}, + {name: "account_on_priority", gate: &accountOn, tier: "priority", wantCost: 0.466}, + // 非 OpenAI 平台没有账号开关,按官方阶梯计。 + {name: "no_gate", wantCost: 0.233}, + {name: "no_gate_priority", tier: "priority", wantCost: 0.466}, + } { + t.Run(tt.name, func(t *testing.T) { + usageLog := &UsageLog{ServiceTier: &tt.tier} + applyAccountStatsCost(context.Background(), usageLog, cs, bs, + 1, 10, "gpt-5.6-sol", "gpt-5.6-sol", tokens, 999, time.Time{}, + accountStatsLongContextPricingEnabled(tt.gate)) + require.NotNil(t, usageLog.AccountStatsCost) + require.InDelta(t, tt.wantCost, *usageLog.AccountStatsCost, 1e-12) + }) + } +} + +// 分组开关只决定客户售价;账号统计成本按账号开关判断上游是否收取长上下文费率。 +func TestOpenAIGatewayServiceRecordUsage_AccountStatsLongContextFollowsAccountGate(t *testing.T) { + baseCost := 300000*2.5e-6 + 2000*15e-6 + longContextCost := 300000*2.5e-6*2 + 2000*15e-6*1.5 + for _, tt := range []struct { + name string + groupLongContext bool + accountExtra map[string]any + wantTotalCost float64 + wantAccountCost float64 + }{ + {name: "group_on_account_off", groupLongContext: true, wantTotalCost: longContextCost, wantAccountCost: baseCost}, + {name: "group_off_account_off", wantTotalCost: baseCost, wantAccountCost: baseCost}, + { + name: "group_off_account_on", + accountExtra: map[string]any{"openai_long_context_billing_enabled": true}, + wantTotalCost: longContextCost, + wantAccountCost: longContextCost, + }, + } { + t.Run(tt.name, func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + swapInOpenAILadderCatalog(t, svc) + svc.channelService = newTestChannelServiceForStats(t, &Channel{ID: 1, Status: StatusActive}, 1, PlatformOpenAI) + apiKey := openAIRecordUsageAPIKeyWithGroup(svc, 1015, tt.groupLongContext) + apiKey.GroupID = i64p(1) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_account_stats_long_context_" + tt.name, + Usage: OpenAIUsage{InputTokens: 300000, OutputTokens: 2000}, + Model: "gpt-5.4-2026-03-05", + Duration: time.Second, + }, + APIKey: apiKey, + User: &User{ID: 2015}, + Account: &Account{ID: 3015, Platform: PlatformOpenAI, Extra: tt.accountExtra}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.InDelta(t, tt.wantTotalCost, usageRepo.lastLog.TotalCost, 1e-10) + require.NotNil(t, usageRepo.lastLog.AccountStatsCost) + require.InDelta(t, tt.wantAccountCost, *usageRepo.lastLog.AccountStatsCost, 1e-10) + }) + } +} + // --------------------------------------------------------------------------- // helpers for resolveAccountStatsCost tests // --------------------------------------------------------------------------- diff --git a/backend/internal/service/admin_service_group_model_allowlist_test.go b/backend/internal/service/admin_service_group_model_allowlist_test.go index fc5a14cfe244..afce719af8f3 100644 --- a/backend/internal/service/admin_service_group_model_allowlist_test.go +++ b/backend/internal/service/admin_service_group_model_allowlist_test.go @@ -29,7 +29,7 @@ func TestAdminService_CreateGroup_RejectsEmptyEnabledModelAllowlist(t *testing.T require.Nil(t, repo.created, "拒绝时不得落库") } -func TestAdminService_CreateGroup_RejectsInvalidAllowlistWildcard(t *testing.T) { +func TestAdminService_CreateGroup_AcceptsInteriorAllowlistWildcard(t *testing.T) { repo := &groupRepoStubForAdmin{createID: 51} svc := &adminServiceImpl{groupRepo: repo} @@ -40,11 +40,9 @@ func TestAdminService_CreateGroup_RejectsInvalidAllowlistWildcard(t *testing.T) ModelAllowlist: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-5.4"}}, }) - require.Error(t, err) - appErr := infraerrors.FromError(err) - require.Equal(t, int32(http.StatusBadRequest), appErr.Code) - require.Equal(t, "INVALID_MODEL_ALLOWLIST", appErr.Reason) - require.Nil(t, repo.created, "拒绝时不得落库") + require.NoError(t, err) + require.NotNil(t, repo.created) + require.Equal(t, []string{"gpt-*-5.4"}, repo.created.ModelAllowlist.Models) } func TestAdminService_CreateGroup_NormalizesModelAllowlist(t *testing.T) { @@ -83,7 +81,7 @@ func TestAdminService_UpdateGroup_RejectsEmptyEnabledModelAllowlist(t *testing.T require.Nil(t, repo.updated, "拒绝时不得落库") } -func TestAdminService_UpdateGroup_RejectsInvalidAllowlistWildcard(t *testing.T) { +func TestAdminService_UpdateGroup_AcceptsInteriorAllowlistWildcard(t *testing.T) { existing := &Group{ID: 1, Name: "existing", Platform: PlatformOpenAI, Status: StatusActive} repo := &groupRepoStubForAdmin{getByID: existing} svc := &adminServiceImpl{groupRepo: repo} @@ -92,11 +90,9 @@ func TestAdminService_UpdateGroup_RejectsInvalidAllowlistWildcard(t *testing.T) ModelAllowlist: &GroupModelAllowlist{Enabled: true, Models: []string{"foo-*bar"}}, }) - require.Error(t, err) - appErr := infraerrors.FromError(err) - require.Equal(t, int32(http.StatusBadRequest), appErr.Code) - require.Equal(t, "INVALID_MODEL_ALLOWLIST", appErr.Reason) - require.Nil(t, repo.updated, "拒绝时不得落库") + require.NoError(t, err) + require.NotNil(t, repo.updated) + require.Equal(t, []string{"foo-*bar"}, repo.updated.ModelAllowlist.Models) } func TestAdminService_UpdateGroup_NormalizesAndResetsModelAllowlist(t *testing.T) { diff --git a/backend/internal/service/antigravity_gateway_claude.go b/backend/internal/service/antigravity_gateway_claude.go index 7f03536b7f0d..264cbe998094 100644 --- a/backend/internal/service/antigravity_gateway_claude.go +++ b/backend/internal/service/antigravity_gateway_claude.go @@ -131,7 +131,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, } // 区分客户端取消和真正的上游失败,返回更准确的错误消息 if c.Request.Context().Err() != nil { - return nil, s.writeClaudeError(c, http.StatusBadGateway, "client_disconnected", "Client disconnected before upstream response") + return nil, s.writeClaudeError(c, antigravityStatusClientClosed, "client_disconnected", "Client disconnected before upstream response") } return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed after retries") } diff --git a/backend/internal/service/antigravity_gateway_compat.go b/backend/internal/service/antigravity_gateway_compat.go index 8763da57b0aa..b62e32c84234 100644 --- a/backend/internal/service/antigravity_gateway_compat.go +++ b/backend/internal/service/antigravity_gateway_compat.go @@ -374,7 +374,7 @@ func (s *AntigravityGatewayService) handleAntigravityCompatTransportError(c *gin } } if c.Request.Context().Err() != nil { - return s.writeAntigravityCompatError(c, http.StatusBadGateway, "client_disconnected", "Client disconnected before upstream response") + return s.writeAntigravityCompatError(c, antigravityStatusClientClosed, "client_disconnected", "Client disconnected before upstream response") } return s.writeAntigravityCompatError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed after retries") } diff --git a/backend/internal/service/antigravity_gateway_compat_stream.go b/backend/internal/service/antigravity_gateway_compat_stream.go index 4275939a7103..a55d29fdfd64 100644 --- a/backend/internal/service/antigravity_gateway_compat_stream.go +++ b/backend/internal/service/antigravity_gateway_compat_stream.go @@ -172,15 +172,18 @@ func (s *antigravityCompatStreamSession) consume(line string) { } func (s *antigravityCompatStreamSession) hasMeaningfulData() bool { - return s.meaningfulData + return s.meaningfulData || s.processor.HasContent() } -func (s *antigravityCompatStreamSession) finish() *antigravityStreamResult { +func (s *antigravityCompatStreamSession) finish() (*antigravityStreamResult, error) { finalEvents, usage := s.processor.Finish() mergeAntigravityCompatUsage(s.usage, usage) s.consumeClaudeEvents(finalEvents) + if !s.hasMeaningfulData() && !s.writer.Disconnected() { + return nil, antigravityCompatEmptyStreamError() + } s.adapter.Finalize(s.writer) - return s.result(s.writer.Disconnected()) + return s.result(s.writer.Disconnected()), nil } func (s *antigravityCompatStreamSession) collectResult(clientDisconnect bool) *antigravityStreamResult { @@ -251,24 +254,18 @@ func isMeaningfulAntigravityCompatEvent(event *apicompat.AnthropicStreamEvent) b if event == nil { return false } - if event.Type == "message_stop" { - return true - } if event.ContentBlock != nil { block := event.ContentBlock return block.Type == "tool_use" || block.Text != "" || block.Thinking != "" || - block.Signature != "" || block.Source != nil } if event.Delta != nil { delta := event.Delta return delta.Text != "" || delta.PartialJSON != "" || - delta.Thinking != "" || - delta.Signature != "" || - delta.StopReason != "" + delta.Thinking != "" } return false } @@ -326,12 +323,12 @@ func (s *AntigravityGatewayService) handleAntigravityCompatStream( if !session.hasMeaningfulData() && !writer.Disconnected() { return nil, antigravityCompatEmptyStreamError() } - result := session.finish() + result, finishErr := session.finish() if err := adapter.Err(); err != nil { MarkResponseCommitted(c) return result, err } - return result, nil + return result, finishErr } if event.err != nil { return s.handleAntigravityCompatReadError(c, session, event.err, maxLineSize, prefix) diff --git a/backend/internal/service/antigravity_gateway_gemini.go b/backend/internal/service/antigravity_gateway_gemini.go index f8cacdcd44de..4bcc825891e9 100644 --- a/backend/internal/service/antigravity_gateway_gemini.go +++ b/backend/internal/service/antigravity_gateway_gemini.go @@ -180,7 +180,7 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co } // 区分客户端取消和真正的上游失败,返回更准确的错误消息 if c.Request.Context().Err() != nil { - return nil, s.writeGoogleError(c, http.StatusBadGateway, "Client disconnected before upstream response") + return nil, s.writeGoogleError(c, antigravityStatusClientClosed, "Client disconnected before upstream response") } return nil, s.writeGoogleError(c, http.StatusBadGateway, "Upstream request failed after retries") } diff --git a/backend/internal/service/antigravity_gateway_streaming.go b/backend/internal/service/antigravity_gateway_streaming.go index 59d4e459b7a6..675f0d7f8751 100644 --- a/backend/internal/service/antigravity_gateway_streaming.go +++ b/backend/internal/service/antigravity_gateway_streaming.go @@ -686,6 +686,9 @@ func mergeTextPartsToResponse(response map[string]any, textParts []string) map[s return result } +// antigravityStatusClientClosed 是客户端在上游响应前断开时回写的状态码(499 client closed request)。 +const antigravityStatusClientClosed = 499 + func (s *AntigravityGatewayService) writeClaudeError(c *gin.Context, status int, errType, message string) error { MarkResponseCommitted(c) c.JSON(status, gin.H{ @@ -790,6 +793,8 @@ func (s *AntigravityGatewayService) writeGoogleError(c *gin.Context, status int, statusStr = "NOT_FOUND" case 429: statusStr = "RESOURCE_EXHAUSTED" + case antigravityStatusClientClosed: + statusStr = "CANCELLED" case 500: statusStr = "INTERNAL" case 502, 503: diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 5a6f738d1a7d..f26df8f435e6 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -1216,8 +1216,8 @@ func (s *BillingService) GetModelPricing(model string) (*ModelPricing, error) { return nil, fmt.Errorf("%w for model: %s", ErrModelPricingUnavailable, model) } -// GetModelPricingWithChannel 获取模型定价,渠道配置的价格覆盖默认值 -// 渠道存在时,未配置的图片输出价格归零(不回退到 LiteLLM) +// GetModelPricingWithChannel 获取模型定价,渠道配置的价格覆盖默认值。 +// 与其他 token 字段一致,渠道留空的图片输入/输出价沿用目录价,见 applyChannelImagePriceOverrides。 func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing *ChannelModelPricing) (*ModelPricing, error) { pricing, err := s.GetModelPricing(model) if err != nil { @@ -1233,13 +1233,7 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing pricing.FastMultiplier = channelPricing.FastMultiplier pricing.FlexMultiplier = channelPricing.FlexMultiplier pricing.ReasoningEffortMultipliers = maps.Clone(channelPricing.ReasoningEffortMultipliers) - if channelPricing.ImageOutputPrice != nil { - pricing.ImageOutputPricePerToken = *channelPricing.ImageOutputPrice - } else { - pricing.ImageOutputPricePerToken = 0 - } - pricing.ImageOutputPriceExplicit = true - applyChannelImageInputPrice(channelPricing, pricing) + applyChannelImagePriceOverrides(channelPricing, pricing) return pricing, nil } diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 12e133df794a..e79e68391f6d 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -1874,19 +1874,49 @@ func TestGetModelPricingWithChannel_UnknownModelReturnsError(t *testing.T) { require.Contains(t, err.Error(), "pricing not found") } -func TestGetModelPricingWithChannel_NilImageOutputPriceZerosAndMarksExplicit(t *testing.T) { - svc := newTestBillingService() +func TestGetModelPricingWithChannel_NilImagePricesInheritCatalog(t *testing.T) { + svc := NewBillingService(&config.Config{}, newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{ + "gpt-image-2": { + Mode: "image_generation", + InputCostPerToken: 5e-6, + OutputCostPerToken: 10e-6, + InputCostPerImageToken: 8e-6, + OutputCostPerImageToken: 30e-6, + }, + })) chPricing := &ChannelModelPricing{ - InputPrice: testPtrFloat64(10e-6), - OutputPrice: testPtrFloat64(20e-6), - // ImageOutputPrice intentionally nil + InputPrice: testPtrFloat64(6e-6), + OutputPrice: testPtrFloat64(12e-6), + // ImageInputPrice / ImageOutputPrice intentionally nil } - pricing, err := svc.GetModelPricingWithChannel("claude-sonnet-4", chPricing) + pricing, err := svc.GetModelPricingWithChannel("gpt-image-2", chPricing) require.NoError(t, err) + require.InDelta(t, 6e-6, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 30e-6, pricing.ImageOutputPricePerToken, 1e-12) + require.False(t, pricing.ImageOutputPriceExplicit) + require.InDelta(t, 8e-6, pricing.ImageInputPricePerToken, 1e-12) +} + +func TestGetModelPricingWithChannel_ExplicitImagePricesOverrideCatalog(t *testing.T) { + svc := NewBillingService(&config.Config{}, newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{ + "gpt-image-2": { + Mode: "image_generation", + InputCostPerToken: 5e-6, + InputCostPerImageToken: 8e-6, + OutputCostPerImageToken: 30e-6, + }, + })) + + pricing, err := svc.GetModelPricingWithChannel("gpt-image-2", &ChannelModelPricing{ + ImageInputPrice: testPtrFloat64(9e-6), + ImageOutputPrice: testPtrFloat64(0), + }) + require.NoError(t, err) require.Equal(t, 0.0, pricing.ImageOutputPricePerToken) - require.True(t, pricing.ImageOutputPriceExplicit) + require.True(t, pricing.ImageOutputPriceExplicit, "显式 0 仍表示图片输出免费") + require.InDelta(t, 9e-6, pricing.ImageInputPricePerToken, 1e-12) } func TestComputeTokenBreakdown_ExplicitZeroImagePrice_NoFallback(t *testing.T) { @@ -1976,18 +2006,20 @@ func TestNewModelPricingCatalogFallbackAndContext(t *testing.T) { } }) } - t.Run(source+"/opus", func(t *testing.T) { - tokens := UsageTokens{InputTokens: 300000, OutputTokens: 500, CacheReadTokens: 1000, CacheCreationTokens: 1000, CacheCreation5mTokens: 400, CacheCreation1hTokens: 600} - for tier, mult := range map[string]float64{"": 1, "fast": 2} { - cost, err := svc.CalculateCostWithServiceTier("claude-opus-5-5", tokens, 1, tier) - require.NoError(t, err) - require.InDelta(t, 1.2*mult, cost.InputCost, 1e-10) - require.InDelta(t, (400*5e-6+600*8e-6)*mult, cost.CacheCreationCost, 1e-10) - require.InDelta(t, 1000*0.2e-6*mult, cost.CacheReadCost, 1e-10) - require.InDelta(t, 500*20e-6*mult, cost.OutputCost, 1e-10) - require.False(t, cost.LongContextBillingApplied) - } - }) + for _, model := range []string{"claude-opus-5-5", "anthropic/claude-opus-5.5"} { + t.Run(source+"/"+model, func(t *testing.T) { + tokens := UsageTokens{InputTokens: 300000, OutputTokens: 500, CacheReadTokens: 1000, CacheCreationTokens: 1000, CacheCreation5mTokens: 400, CacheCreation1hTokens: 600} + for tier, mult := range map[string]float64{"": 1, "fast": 2} { + cost, err := svc.CalculateCostWithServiceTier(model, tokens, 1, tier) + require.NoError(t, err) + require.InDelta(t, 1.2*mult, cost.InputCost, 1e-10) + require.InDelta(t, (400*5e-6+600*8e-6)*mult, cost.CacheCreationCost, 1e-10) + require.InDelta(t, 1000*0.2e-6*mult, cost.CacheReadCost, 1e-10) + require.InDelta(t, 500*20e-6*mult, cost.OutputCost, 1e-10) + require.False(t, cost.LongContextBillingApplied) + } + }) + } } } diff --git a/backend/internal/service/client_disconnect_status_test.go b/backend/internal/service/client_disconnect_status_test.go new file mode 100644 index 000000000000..a2e4080af4e5 --- /dev/null +++ b/backend/internal/service/client_disconnect_status_test.go @@ -0,0 +1,93 @@ +//go:build unit + +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// 三条传输层失败处理的共同契约:请求 context 已取消(客户端断开)时不记 Ops 上游错误事件; +// 请求 context 仍存活时即便错误是 context.Canceled 也照常记录,不归为客户端断开。 +func TestTransportErrorHandlers_ClientDisconnectRecordsNoOpsEvent(t *testing.T) { + account := &Account{ID: 9, Name: "acc", Platform: PlatformAnthropic} + clientErr := &url.Error{Op: "Post", URL: "https://upstream.example/v1/messages", Err: context.Canceled} + canceledCtx, cancel := context.WithCancel(context.Background()) + cancel() + + handlers := []struct { + name string + handle func(ctx context.Context, c *gin.Context) error + }{ + {"anthropic", func(ctx context.Context, c *gin.Context) error { + s := &GatewayService{accountRepo: &transportTempUnschedRepoStub{}} + return s.handleUpstreamTransportError(ctx, c, account, clientErr, OpsUpstreamErrorEvent{}) + }}, + {"gemini", func(ctx context.Context, c *gin.Context) error { + s := &GeminiMessagesCompatService{accountRepo: &transportTempUnschedRepoStub{}} + return s.handleUpstreamTransportError(ctx, c, account, clientErr) + }}, + {"openai", func(ctx context.Context, c *gin.Context) error { + s := &OpenAIGatewayService{accountRepo: &openaiTransportAccountRepoStub{}} + return s.handleOpenAIUpstreamTransportError(ctx, c, account, clientErr, false) + }}, + } + + for _, h := range handlers { + t.Run(h.name+"/client disconnected", func(t *testing.T) { + c := newTransportErrorTestGin(t) + + err := h.handle(canceledCtx, c) + + require.ErrorIs(t, err, context.Canceled) + _, recorded := c.Get(OpsUpstreamErrorsKey) + require.False(t, recorded, "客户端断开不是上游故障") + _, messageSet := c.Get(OpsUpstreamErrorMessageKey) + require.False(t, messageSet) + }) + t.Run(h.name+"/request context alive", func(t *testing.T) { + c := newTransportErrorTestGin(t) + + err := h.handle(context.Background(), c) + + require.ErrorIs(t, err, context.Canceled) + raw, recorded := c.Get(OpsUpstreamErrorsKey) + require.True(t, recorded) + require.Len(t, raw.([]*OpsUpstreamErrorEvent), 1) + }) + } +} + +func TestAntigravityCompatTransportError_ClientDisconnectWrites499(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil).WithContext(ctx) + + err := (&AntigravityGatewayService{}).handleAntigravityCompatTransportError(c, context.Canceled) + + require.Error(t, err) + require.Equal(t, antigravityStatusClientClosed, rec.Code) + require.Equal(t, "client_disconnected", gjson.Get(rec.Body.String(), "error.type").String()) +} + +func TestAntigravityWriteGoogleError_ClientClosedStatus(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", nil) + + _ = (&AntigravityGatewayService{}).writeGoogleError(c, antigravityStatusClientClosed, "Client disconnected before upstream response") + + require.Equal(t, antigravityStatusClientClosed, rec.Code) + require.Equal(t, "CANCELLED", gjson.Get(rec.Body.String(), "error.status").String()) +} diff --git a/backend/internal/service/deepseek_pricing_test.go b/backend/internal/service/deepseek_pricing_test.go index 2968d106435a..502d6918c966 100644 --- a/backend/internal/service/deepseek_pricing_test.go +++ b/backend/internal/service/deepseek_pricing_test.go @@ -330,7 +330,7 @@ func TestDeepseekAllCostEntrypointsAndProRetention(t *testing.T) { require.NoError(t, err) require.InDelta(t, want, viaRequest.TotalCost, 1e-12) } - stats := tryModelFilePricing(bs, model.name, tokens, "", at) + stats := tryModelFilePricing(bs, model.name, tokens, "", at, true) require.NotNil(t, stats) require.InDelta(t, want, *stats, 1e-12) } diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index 8a4159c11302..1925404467cc 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -1803,6 +1803,7 @@ func TestOpus55RejectsUnsupportedParametersBeforeMimicry(t *testing.T) { func TestOpus55ThinkingDefaultPreservesSignedHistory(t *testing.T) { body := []byte(`{"model":"claude-opus-5-5","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"signed"},{"type":"redacted_thinking","data":"encrypted"},{"type":"tool_use","id":"toolu_1","name":"lookup","input":{}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"ok"}]}],"tool_choice":{"type":"none"},"thinking":{"type":"adaptive","display":"omitted"}}`) require.Equal(t, string(body), string(FilterThinkingBlocks(body, "claude-opus-5-5"))) + require.Equal(t, string(body), string(FilterThinkingBlocks(body, "anthropic/claude-opus-5.5"))) withoutThinking, _ := deleteJSONPathBytes(body, "thinking") require.Equal(t, string(withoutThinking), string(FilterThinkingBlocks(withoutThinking, "claude-opus-5-5"))) out, _ := normalizeClaudeOAuthRequestBody(body, "claude-opus-5-5", claudeOAuthNormalizeOptions{}) diff --git a/backend/internal/service/gateway_hotpath_optimization_test.go b/backend/internal/service/gateway_hotpath_optimization_test.go index 8855b13e5289..317043dafe88 100644 --- a/backend/internal/service/gateway_hotpath_optimization_test.go +++ b/backend/internal/service/gateway_hotpath_optimization_test.go @@ -10,6 +10,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" gocache "github.com/patrickmn/go-cache" "github.com/stretchr/testify/require" @@ -670,7 +671,9 @@ func TestGetAvailableModels_OpenAIPassthroughUsesDefaultFallback(t *testing.T) { want: nil, }, { - name: "passthrough wins over ordinary account mapping", + // The passthrough account serves the default set (its stale mapping is + // ignored), while the ordinary account's mapping still reaches the list. + name: "passthrough contributes defaults alongside ordinary account mapping", accounts: []Account{ { ID: 2, @@ -684,7 +687,7 @@ func TestGetAvailableModels_OpenAIPassthroughUsesDefaultFallback(t *testing.T) { Extra: map[string]any{"openai_passthrough": true}, }, }, - want: nil, + want: dedupeAndSortModelIDs(append([]string{"configured-model"}, openai.DefaultModelIDs()...)), }, { name: "ordinary accounts preserve mapped whitelist", @@ -708,7 +711,9 @@ func TestGetAvailableModels_OpenAIPassthroughUsesDefaultFallback(t *testing.T) { modelsListCacheTTL: time.Minute, } - require.Equal(t, tt.want, svc.GetAvailableModels(context.Background(), &groupID, PlatformOpenAI)) + got := svc.GetAvailableModels(context.Background(), &groupID, PlatformOpenAI) + require.Equal(t, tt.want, got) + require.NotContains(t, got, "stale-model", "passthrough mapping must never reach the public list") }) } } @@ -719,9 +724,10 @@ func TestGetAvailableModels_GlobalListPreservesMappedModelsWithOpenAIPassthrough byGroup: map[int64][]Account{ groupID: { { - ID: 1, - Platform: PlatformOpenAI, - Extra: map[string]any{"openai_passthrough": true}, + ID: 1, + Platform: PlatformOpenAI, + Credentials: map[string]any{"model_mapping": map[string]any{"stale-model": "upstream-model"}}, + Extra: map[string]any{"openai_passthrough": true}, }, { ID: 2, diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 9b9ac2b63e5e..9c54a5f86d41 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -1485,15 +1485,13 @@ func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64, hasAnyMapping := false for _, acc := range accounts { - // Passthrough routing accepts models independently of model_mapping. A stale - // mapping on any eligible passthrough account therefore cannot define the - // public whitelist; return nil so the handler uses its default model set. - if platform == PlatformOpenAI && acc.IsOpenAIPassthroughEnabled() { - if s.modelsListCache != nil { - s.modelsListCache.Set(cacheKey, []string(nil), s.modelsListCacheTTL) - modelsListCacheStoreTotal.Add(1) - } - return nil + // Passthrough routing accepts models independently of model_mapping, so a + // stale mapping on a passthrough account must not narrow the public list. + // Treat it like an unmapped account: skip its mapping here and let + // supplementUnmappedOpenAIModels contribute the default set. Mappings on + // the ordinary accounts in the same group still count. + if acc.IsOpenAIPassthroughEnabled() { + continue } mapping := acc.GetModelMapping() diff --git a/backend/internal/service/gateway_structured_outputs_beta_test.go b/backend/internal/service/gateway_structured_outputs_beta_test.go new file mode 100644 index 000000000000..720fe407f1e0 --- /dev/null +++ b/backend/internal/service/gateway_structured_outputs_beta_test.go @@ -0,0 +1,74 @@ +//go:build unit + +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestStructuredOutputsBetaOAuthMimic(t *testing.T) { + const beta = "structured-outputs-2025-11-13" + for _, tc := range []struct { + name string + header string + drop map[string]struct{} + want bool + }{ + {"explicit", beta, nil, true}, + {"mixed and duplicate", "custom-beta, " + beta + "," + beta, nil, true}, + {"absent", "custom-beta", nil, false}, + {"similar token", beta + "-other", nil, false}, + {"filtered", beta, map[string]struct{}{beta: {}}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + s := newTestGatewayServiceForBeta(false) + header := http.Header{} + header.Set("anthropic-beta", tc.header) + body := []byte(`{"output_format":{"type":"json_schema","schema":{"type":"object"}}}`) + got, set := s.computeFinalAnthropicBeta("oauth", true, "claude-sonnet-5", header, body, tc.drop) + require.True(t, set) + require.Equal(t, tc.want, containsBetaToken(got, beta)) + require.False(t, containsBetaToken(got, "custom-beta")) + require.True(t, containsBetaToken(got, "oauth-2025-04-20")) + if tc.want { + require.Equal(t, 1, strings.Count(got, beta)) + } + }) + } +} + +func TestBuildUpstreamRequestStructuredOutputsBeta(t *testing.T) { + const beta = "structured-outputs-2025-11-13" + for _, filtered := range []bool{false, true} { + t.Run(map[bool]string{false: "preserved", true: "policy filtered"}[filtered], func(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + c.Request.Header.Set("anthropic-beta", beta+",custom-beta") + if filtered { + c.Set(betaPolicyFilterSetKey, map[string]struct{}{beta: {}}) + } + account := &Account{ID: 701, Platform: PlatformAnthropic, Type: AccountTypeOAuth, + Credentials: map[string]any{"access_token": "test-token"}, Status: StatusActive, Schedulable: true} + body := []byte(`{"model":"claude-sonnet-5","max_tokens":1024,"output_format":{"type":"json_schema","schema":{"type":"object","properties":{"ok":{"type":"boolean"}},"required":["ok"],"additionalProperties":false}},"messages":[{"role":"user","content":"Return JSON"}]}`) + svc := &GatewayService{cfg: &config.Config{}} + req, _, err := svc.buildUpstreamRequest(context.Background(), c, account, body, + "test-token", "oauth", "claude-sonnet-5", false, true) + require.NoError(t, err) + out := readUpstreamBodyForTest(t, req) + require.JSONEq(t, gjson.GetBytes(body, "output_format").Raw, gjson.GetBytes(out, "output_format").Raw) + header := getHeaderRaw(req.Header, "anthropic-beta") + require.Equal(t, !filtered, containsBetaToken(header, beta)) + require.False(t, containsBetaToken(header, "custom-beta")) + }) + } +} diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go index ff2438a615a0..ff88e71003db 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -97,8 +97,8 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex // === 计算最终 anthropic-beta header(先于 body sanitize 与 CCH 签名)=== // // 顺序约束: - // 1) 算 finalBeta(纯函数,不依赖 req.Header;mimicry 路径会忽略客户端 beta, - // 与原“OAuth + mimicClaudeCode 跳过白名单透传”行为对齐) + // 1) 算 finalBeta(纯函数,不依赖 req.Header;mimicry 路径仅保留明确支持的 + // 客户端兼容性 beta,其余使用固定列表) // 2) 按 finalBeta 做能力维度 body sanitize(如 context-management beta 缺失 → // strip body.context_management,与 Bedrock 路径对称) // 3) CCH 签名(必须使用 strip 后的 body,否则 hash 与最终 body 不一致 → @@ -509,9 +509,14 @@ func (s *GatewayService) computeFinalAnthropicBeta( if tokenType == "oauth" { if mimicClaudeCode { - // mimic 路径跳过白名单透传,incomingBeta 始终为空;所有模型都必须 - // 携带完整 Claude Code beta 集合,避免 Haiku 被识别为第三方客户端。 - return mergeAnthropicBetaDropping(claude.FullClaudeCodeMimicryBetas(), "", effectiveDropSet), true + // Keep the default beta set, with a narrow opt-in for legacy structured + // output requests. Unknown client betas remain excluded and policy drops + // still take precedence over this compatibility token. + incomingBeta := "" + if containsBetaToken(clientBeta, claude.BetaStructuredOutputs) { + incomingBeta = claude.BetaStructuredOutputs + } + return mergeAnthropicBetaDropping(claude.FullClaudeCodeMimicryBetas(), incomingBeta, effectiveDropSet), true } // 真 Claude Code 客户端透传路径 return stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBeta), effectiveDropSet), true @@ -536,7 +541,7 @@ func (s *GatewayService) computeFinalAnthropicBeta( // 两条特殊规则: // // - OAuth mimic:requiredBetas 为 FullClaudeCodeMimicryBetas + BetaTokenCounting; -// count_tokens 另外保留客户端 beta,而 messages mimic 会忽略客户端 beta。 +// count_tokens 另外保留客户端 beta,而 messages mimic 仅保留明确支持的兼容性 token。 // - OAuth 透传 + 客户端未传 anthropic-beta:补齐 CountTokensBetaHeader // - OAuth 透传 + 客户端传了:补齐 BetaTokenCounting(如果未含) // diff --git a/backend/internal/service/gateway_upstream_transport_error.go b/backend/internal/service/gateway_upstream_transport_error.go index 7781d5ceddb3..a9f459fed94e 100644 --- a/backend/internal/service/gateway_upstream_transport_error.go +++ b/backend/internal/service/gateway_upstream_transport_error.go @@ -28,7 +28,8 @@ var gatewayTransportFailoverBody = []byte(`{"type":"error","error":{"type":"upst // 1. records the failure in Ops error logs (status 0, kind=request_error) — // the caller passes path-specific fields (UpstreamURL, Passthrough) via // event; identity, proxy attribution and classification fields are -// filled here from the same account snapshot that built the transport; +// filled here from the same account snapshot that built the transport. +// A client disconnect is not recorded (see isClientCanceledTransportError); // 2. for durable faults (expired/rejected proxy creds, dead proxy, // DNS/routing) temporarily unschedules the account and logs a stable warn // event that alert rules can key on; @@ -39,6 +40,9 @@ var gatewayTransportFailoverBody = []byte(`{"type":"error","error":{"type":"upst // It deliberately does NOT write to the response: the handler owns the // response (failover, or a protocol-correct error once failover is exhausted). func (s *GatewayService) handleUpstreamTransportError(ctx context.Context, c *gin.Context, account *Account, err error, event OpsUpstreamErrorEvent) error { + if isClientCanceledTransportError(ctx, err) { + return err + } safeErr := sanitizeUpstreamErrorMessage(err.Error()) setOpsUpstreamError(c, 0, safeErr, "") event.ProxyID, event.ProxyName = opsUpstreamProxyAttribution(account) diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 4dbeeebd9c8f..45c4d19069e6 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -884,6 +884,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage ImageOutputTokens: result.Usage.ImageOutputTokens, }, cost.TotalCost, pricingAt, + accountStatsLongContextPricingEnabled(nil), ) } diff --git a/backend/internal/service/gemini_upstream_transport_error.go b/backend/internal/service/gemini_upstream_transport_error.go index ea3f7ebc6624..27eaec57fb34 100644 --- a/backend/internal/service/gemini_upstream_transport_error.go +++ b/backend/internal/service/gemini_upstream_transport_error.go @@ -16,7 +16,8 @@ var geminiTransportFailoverBody = []byte(`{"error":{"code":502,"message":"Upstre // Chat Completions 兼容)上的传输层失败:Do 返回非 HTTP 错误(代理 / DNS / TCP / TLS / // 响应头超时),没有拿到任何上游状态码。 // -// - 记录 Ops 错误事件(status 0,kind=request_error); +// - 记录 Ops 错误事件(status 0,kind=request_error);客户端断开不记录 +// (见 isClientCanceledTransportError); // - 持久性故障(代理凭据失效、端点拒绝连接、DNS/路由不可达)临时摘除账号; // 瞬时故障(EOF / reset / 超时)账号保持可调度; // - 客户端已断开(context.Canceled)原样返回:不换号、不摘号; @@ -24,6 +25,9 @@ var geminiTransportFailoverBody = []byte(`{"error":{"code":502,"message":"Upstre // // 本函数不写响应:响应归 handler 所有(换号,或耗尽后按端点格式渲染错误)。 func (s *GeminiMessagesCompatService) handleUpstreamTransportError(ctx context.Context, c *gin.Context, account *Account, err error) error { + if isClientCanceledTransportError(ctx, err) { + return err + } safeErr := sanitizeUpstreamErrorMessage(err.Error()) setOpsUpstreamError(c, 0, safeErr, "") event := OpsUpstreamErrorEvent{ diff --git a/backend/internal/service/group_model_allowlist.go b/backend/internal/service/group_model_allowlist.go index dfe5c292ccb2..3d3cd6c4cb4e 100644 --- a/backend/internal/service/group_model_allowlist.go +++ b/backend/internal/service/group_model_allowlist.go @@ -29,15 +29,17 @@ func GroupModelAllowlistFromDomain(cfg domain.GroupModelAllowlist) GroupModelAll } // supplementUnmappedOpenAIModels ensures a partial mapping catalog does not -// hide models from unmapped OpenAI accounts. An empty catalog is left unchanged -// so callers retain their existing discovery fallback. +// hide models from unmapped or passthrough OpenAI accounts (passthrough routing +// ignores model_mapping, so it serves the same default set as an unmapped +// account). An empty catalog is left unchanged so callers retain their existing +// discovery fallback. func supplementUnmappedOpenAIModels(accounts []Account, models []string) []string { if len(models) == 0 { return models } for i := range accounts { account := &accounts[i] - if account.Platform == PlatformOpenAI && len(account.GetModelMapping()) == 0 { + if account.Platform == PlatformOpenAI && (account.IsOpenAIPassthroughEnabled() || len(account.GetModelMapping()) == 0) { return dedupeAndSortModelIDs(slices.Concat(models, openai.DefaultModelIDs())) } } @@ -45,7 +47,7 @@ func supplementUnmappedOpenAIModels(accounts []Account, models []string) []strin } // normalizeGroupModelAllowlist 归一化管理端提交的分组模型白名单: -// 条目 TrimSpace、按小写去重保序;`*` 只允许出现在条目末尾; +// 条目 TrimSpace、按小写去重保序;`*` 可出现在任意位置; // enabled=true 且列表为空视为配置错误,返回 400 而不是运行时静默放行/拒绝。 func normalizeGroupModelAllowlist(cfg GroupModelAllowlist) (GroupModelAllowlist, error) { out := GroupModelAllowlist{Enabled: cfg.Enabled} @@ -63,9 +65,6 @@ func normalizeGroupModelAllowlist(cfg GroupModelAllowlist) (GroupModelAllowlist, if model == "" { continue } - if strings.Contains(strings.TrimSuffix(model, "*"), "*") { - return out, infraerrors.New(http.StatusBadRequest, "INVALID_MODEL_ALLOWLIST", `wildcard "*" is only allowed at the end of an allowlist entry`) - } key := strings.ToLower(model) if _, ok := seen[key]; ok { continue @@ -106,18 +105,8 @@ func (a GroupModelAllowlist) Allows(model string) bool { if entry == "" { continue } - entry = strings.ToLower(entry) - if strings.HasSuffix(entry, "*") { - prefix := strings.TrimSuffix(entry, "*") - for _, candidate := range candidates { - if strings.HasPrefix(candidate, prefix) { - return true - } - } - continue - } for _, candidate := range candidates { - if candidate == entry { + if groupAllowlistPatternMatches(entry, candidate) { return true } } @@ -125,6 +114,29 @@ func (a GroupModelAllowlist) Allows(model string) bool { return false } +// groupAllowlistPatternMatches treats only * as a wildcard, including in the middle +// or at either end. Matching is anchored and case-insensitive. +func groupAllowlistPatternMatches(pattern, model string) bool { + pattern = strings.ToLower(pattern) + model = strings.ToLower(model) + parts := strings.Split(pattern, "*") + if len(parts) == 1 { + return pattern == model + } + if !strings.HasPrefix(model, parts[0]) { + return false + } + model = model[len(parts[0]):] + for _, part := range parts[1 : len(parts)-1] { + index := strings.Index(model, part) + if index < 0 { + return false + } + model = model[index+len(part):] + } + return strings.HasSuffix(model, parts[len(parts)-1]) +} + // groupModelAllowlistCandidates 返回客户端模型名在白名单匹配中的候选形式(均已小写)。 func groupModelAllowlistCandidates(model string) []string { candidates := make([]string, 0, 5) @@ -174,6 +186,9 @@ func groupModelAllowlistLegacyOpenAIReasoningBase(model string) string { // FilterForListing 按白名单条目顺序生成模型列表输出。 // 精确条目沿用既有规则:只有出现在 source(账号映射键 ∪ 平台默认列表)的模式 // 集合中才输出;通配条目展开为 source 中所有匹配项并保持 source 顺序;全局去重。 +// 当 source 是前缀通配模式且白名单模式的固定前缀位于该前缀之内时, +// 可直接输出白名单模式:它的所有实例都属于 source。其他未处理的无限 +// 交集不会被枚举为模型 ID,列表可能省略仍可请求的模型。 func (a GroupModelAllowlist) FilterForListing(source []string) []string { if !a.Enabled { return source @@ -211,12 +226,14 @@ func (a GroupModelAllowlist) FilterForListing(source []string) []string { if entry == "" { continue } - if strings.HasSuffix(entry, "*") { - // 空 前缀(裸 `*` 条目)匹配全部来源,与 Allows 的全放行语义一致。 - prefix := strings.ToLower(strings.TrimSuffix(entry, "*")) + if strings.Contains(entry, "*") { for _, pattern := range patterns { - if strings.HasPrefix(strings.ToLower(pattern), prefix) { + if groupAllowlistPatternMatches(entry, pattern) { add(pattern) + } else if strings.HasSuffix(pattern, "*") && + !strings.Contains(strings.TrimSuffix(pattern, "*"), "*") && + strings.HasPrefix(strings.ToLower(entry), strings.ToLower(strings.TrimSuffix(pattern, "*"))) { + add(entry) } } continue diff --git a/backend/internal/service/group_model_allowlist_test.go b/backend/internal/service/group_model_allowlist_test.go index 355fc02ad8c6..dff83cdf5747 100644 --- a/backend/internal/service/group_model_allowlist_test.go +++ b/backend/internal/service/group_model_allowlist_test.go @@ -41,9 +41,9 @@ func TestNormalizeGroupModelAllowlist(t *testing.T) { want: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-5.5-*"}}, }, { - name: "wildcard in the middle is rejected", - in: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-5.4"}}, - wantErr: "INVALID_MODEL_ALLOWLIST", + name: "wildcards at any position are accepted", + in: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-5.4", "*codex", "gpt-*-codex-*"}}, + want: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-5.4", "*codex", "gpt-*-codex-*"}}, }, { name: "bare wildcard is accepted as allow-all", @@ -52,9 +52,9 @@ func TestNormalizeGroupModelAllowlist(t *testing.T) { wantErr: "", }, { - name: "disabled config with invalid wildcard still rejected", - in: GroupModelAllowlist{Enabled: false, Models: []string{"foo-*bar"}}, - wantErr: "INVALID_MODEL_ALLOWLIST", + name: "disabled config preserves interior wildcard", + in: GroupModelAllowlist{Enabled: false, Models: []string{"foo-*bar"}}, + want: GroupModelAllowlist{Enabled: false, Models: []string{"foo-*bar"}}, }, } @@ -113,6 +113,7 @@ func TestGroupModelAllowlistAllows(t *testing.T) { {name: "openai reasoning suffix on unlisted model", model: "gpt-4.1-low", want: false}, {name: "trailing wildcard prefix match", model: "grok-4.6", want: true}, {name: "trailing wildcard requires prefix", model: "grok", want: false}, + {name: "interior and suffix wildcard", model: "gpt-5-codex-fast", want: false}, {name: "empty model passes (handler decides required-ness)", model: "", want: true}, } @@ -124,6 +125,20 @@ func TestGroupModelAllowlistAllows(t *testing.T) { }) } + t.Run("wildcards match at any position and remain anchored", func(t *testing.T) { + cfg := GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-codex-*", "*sonnet*", "gemini-?.*-pro"}} + for _, model := range []string{"gpt-5-codex-high", "CLAUDE-SONNET-4.5", "models/claude-sonnet-4.5"} { + if !cfg.Allows(model) { + t.Fatalf("expected %q to match", model) + } + } + for _, model := range []string{"xgpt-5-codex-high", "gpt-5-codex", "gemini-2.5-pro"} { + if cfg.Allows(model) { + t.Fatalf("expected %q not to match", model) + } + } + }) + t.Run("disabled allowlist allows everything", func(t *testing.T) { disabled := GroupModelAllowlist{Enabled: false, Models: []string{"only-model"}} if !disabled.Allows("anything") { @@ -216,6 +231,42 @@ func TestGroupModelAllowlistFilterForListing(t *testing.T) { } }) + t.Run("interior and leading wildcards expand source models", func(t *testing.T) { + cfg := GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-codex", "*sonnet*"}} + got := cfg.FilterForListing(source) + want := []string{"gpt-5.5-codex", "claude-sonnet-4.5"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("got %#v want %#v", got, want) + } + }) + + t.Run("source prefix wildcard retains only a safe infinite intersection", func(t *testing.T) { + cfg := GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-codex"}} + if !cfg.Allows("gpt-5-codex") || cfg.Allows("gpt-5-mini") { + t.Fatal("request admission must distinguish allowed and denied models") + } + got := cfg.FilterForListing([]string{"gpt-*"}) + if strings.Join(got, ",") != "gpt-*-codex" { + t.Fatalf("expected safe allowlist pattern, got %#v", got) + } + if groupAllowlistPatternMatches(got[0], "gpt-5-mini") { + t.Fatal("listing pattern must not expose unauthorized models") + } + }) + + t.Run("unrepresentable wildcard intersections do not leak source patterns", func(t *testing.T) { + cfg := GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-codex"}} + for _, source := range [][]string{{"gpt-5*"}, {"*-codex"}} { + if got := cfg.FilterForListing(source); len(got) != 0 { + t.Fatalf("source %#v would expose unauthorized models: %#v", source, got) + } + } + leadingWildcard := GroupModelAllowlist{Enabled: true, Models: []string{"*codex"}} + if got := leadingWildcard.FilterForListing([]string{"gpt-*"}); len(got) != 0 { + t.Fatalf("source prefix would expose unauthorized models: %#v", got) + } + }) + t.Run("mixed exact and wildcard dedupes globally", func(t *testing.T) { cfg := GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*", "gpt-5.4"}} got := cfg.FilterForListing(source) diff --git a/backend/internal/service/group_model_capacity.go b/backend/internal/service/group_model_capacity.go index cf39b587cd1b..a391480524d8 100644 --- a/backend/internal/service/group_model_capacity.go +++ b/backend/internal/service/group_model_capacity.go @@ -368,9 +368,9 @@ func modelContextSourceRank(source string) int { switch source { case ModelContextSourceCustom: return 0 - case ModelContextSourceUpstream: - return 1 case ModelContextSourceOfficial: + return 1 + case ModelContextSourceUpstream: return 2 default: return 3 @@ -379,7 +379,7 @@ func modelContextSourceRank(source string) int { // minimumModelContextCapacity takes the smallest known value of every limit so // that no candidate is promised a window it cannot hold. The answer carries the -// source of the smallest default window. +// source of the smallest effective maximum window. func minimumModelContextCapacity(candidates []ResolvedModelContextCapacity) ResolvedModelContextCapacity { result := candidates[0] minimum := func(current, value int64) int64 { diff --git a/backend/internal/service/group_model_capacity_test.go b/backend/internal/service/group_model_capacity_test.go index 48566bddc022..1e62da251aed 100644 --- a/backend/internal/service/group_model_capacity_test.go +++ b/backend/internal/service/group_model_capacity_test.go @@ -172,12 +172,20 @@ func TestGroupModelCapacityOAuthParticipatesAndQueryFailure(t *testing.T) { Extra: map[string]any{UpstreamModelMetadataExtraKey: observedCapacityExtra(map[string]UpstreamModelMetadata{ "gpt-6-sol": {ContextWindow: 272000, MaxContextWindow: 872000}, })}} + snapshot := oauth.GetUpstreamModelMetadataSnapshot() + snapshot.SourceIdentity = UpstreamModelMetadataSourceIdentity(&oauth) + oauth.SetUpstreamModelMetadataSnapshot(*snapshot) catalog := newGroupModelCapacityCatalog([]Account{ordinary, oauth}, true, nil, true, nil, nil) capacity := catalog.resolve(context.Background(), PlatformOpenAI, "alias") - require.Equal(t, int64(272000), capacity.ContextWindow, "an OAuth candidate bounds the group with its observed Codex window") + require.Equal(t, int64(700000), capacity.ContextWindow, "the group minimum uses the official API capacity for the OAuth target") require.Equal(t, int64(700000), capacity.MaxContextWindow) - require.Equal(t, "upstream", capacity.Source) + require.Equal(t, "custom", capacity.Source) require.Equal(t, "group_minimum", capacity.Reason) + ordinary.Extra[ModelContextOverridesExtraKey] = map[string]int64{"native-model": 1200000} + catalog = newGroupModelCapacityCatalog([]Account{ordinary, oauth}, true, nil, true, nil, nil) + capacity = catalog.resolve(context.Background(), PlatformOpenAI, "alias") + require.Equal(t, int64(1050000), capacity.ContextWindow, "the OAuth candidate still bounds the pool when its official maximum is smaller") + require.Equal(t, "official", capacity.Source) repo := &groupCapacityAccountRepo{err: errors.New("database unavailable")} catalog = loadGroupModelCapacityCatalog(context.Background(), repo, nil, nil, nil, nil, PlatformOpenAI) capacity = catalog.resolve(context.Background(), PlatformOpenAI, "gpt-6-astra") @@ -190,9 +198,14 @@ func TestGroupModelCapacityAggregatesDistinctMaxInputOutputConservatively(t *tes second := newGroupCapacityAccount(2, nil, nil) first.Extra[UpstreamModelMetadataExtraKey] = observedCapacityExtra(map[string]UpstreamModelMetadata{"unlisted": {ContextWindow: 400000, MaxContextWindow: 900000, MaxInputTokens: 390000, MaxOutputTokens: 64000}}) second.Extra[UpstreamModelMetadataExtraKey] = observedCapacityExtra(map[string]UpstreamModelMetadata{"unlisted": {ContextWindow: 500000, MaxContextWindow: 700000, MaxOutputTokens: 32000}}) + for _, account := range []*Account{&first, &second} { + snapshot := account.GetUpstreamModelMetadataSnapshot() + snapshot.SourceIdentity = UpstreamModelMetadataSourceIdentity(account) + account.SetUpstreamModelMetadataSnapshot(*snapshot) + } catalog := newGroupModelCapacityCatalog([]Account{first, second}, true, nil, true, nil, nil) capacity := catalog.resolve(context.Background(), PlatformOpenAI, "unlisted") - require.Equal(t, int64(400000), capacity.ContextWindow) + require.Equal(t, int64(700000), capacity.ContextWindow, "the group takes the minimum of each target's effective maximum") require.Equal(t, int64(700000), capacity.MaxContextWindow) require.Equal(t, int64(390000), capacity.MaxInputTokens, "a peer without an input limit is bounded by its larger window") require.Equal(t, int64(32000), capacity.MaxOutputTokens) diff --git a/backend/internal/service/model_context_capacity.go b/backend/internal/service/model_context_capacity.go index 7b915f370a7e..04dade75fcf1 100644 --- a/backend/internal/service/model_context_capacity.go +++ b/backend/internal/service/model_context_capacity.go @@ -19,8 +19,8 @@ const ( ) // Capacity evidence, highest priority first. The order is the same for every -// account and host: an admin override, what this account's own upstream -// declared, the release-pinned reference catalog, then the models.dev +// account and host: an admin override, the applicable official API +// specification, this account's own upstream declaration, then the models.dev // registry. There is no default: an unknown capacity is never advertised. const ( ModelContextSourceCustom = "custom" @@ -67,8 +67,10 @@ type AccountModelContextCapacityRow struct { UpstreamModelIDs []string `json:"upstream_model_ids,omitempty"` Editable bool `json:"editable"` Upstream *ModelContextCapacity `json:"upstream,omitempty"` + UpstreamEvidenceStatus string `json:"upstream_evidence_status,omitempty"` Official *OfficialModelContextCapacity `json:"official,omitempty"` Registry *ModelContextCapacity `json:"registry,omitempty"` + RegistryEvidenceStatus string `json:"registry_evidence_status,omitempty"` CustomContextWindow *int64 `json:"custom_context_window,omitempty"` AutomaticContextWindow int64 `json:"automatic_context_window"` AutomaticSource string `json:"automatic_source"` @@ -90,29 +92,68 @@ type modelContextEvidence struct { capacity ModelContextCapacity } -// resolveModelContextEvidence takes the planning window (context_window as the -// default working window, max_context_window as the real maximum) from the -// highest-priority evidence that states one. Independent input/output limits -// keep the first compatible declaration in the same priority order; they are -// never synthesized from the context window. +// resolveModelContextEvidence selects one complete automatic evidence record, +// then overlays an optional custom window. Once an automatic source wins, its +// independent limits stay together: a lower-priority source must not silently +// fill an omitted field. In particular, a custom window does not erase an +// official output limit or fabricate one from a relay declaration. func resolveModelContextEvidence(items []modelContextEvidence) ResolvedModelContextCapacity { + automatic := resolveAutomaticModelContextEvidence(items) + var custom *ModelContextCapacity for _, item := range items { - planning, ok := modelContextPlanningCapacity(item.capacity) - if !ok { - continue + if item.source == ModelContextSourceCustom { + value := sanitizeModelContextCapacity(item.capacity) + if _, ok := modelContextPlanningCapacity(value); ok { + custom = &value + break + } } - result := ResolvedModelContextCapacity{ModelContextCapacity: planning, Source: item.source} - compatible := func(limit func(ModelContextCapacity) int64) int64 { - for _, candidate := range items { - if value := limit(sanitizeModelContextCapacity(candidate.capacity)); value > 0 && value <= result.ContextWindow { - return value - } + } + if custom == nil { + return automatic + } + customPlanning, ok := modelContextPlanningCapacity(*custom) + if !ok { + return automatic + } + // An admin override changes only the planning window. Independent limits + // belong to the selected automatic source and are retained only when they + // remain compatible with the overridden window; omitted official fields + // stay omitted. + result := ResolvedModelContextCapacity{ + ModelContextCapacity: ModelContextCapacity{ + ContextWindow: customPlanning.ContextWindow, MaxContextWindow: customPlanning.ContextWindow, + CapacityBasis: ModelContextCapacityBasisTotal, + }, + Source: ModelContextSourceCustom, + } + if automatic.Source != "" { + result.MaxInputTokens = compatibleModelContextLimit(automatic.MaxInputTokens, result.ContextWindow) + result.MaxOutputTokens = compatibleModelContextLimit(automatic.MaxOutputTokens, result.ContextWindow) + result.ObservedAt = automatic.ObservedAt + } + return result +} + +func compatibleModelContextLimit(value, contextWindow int64) int64 { + if validModelContextTokens(value) && value <= contextWindow { + return value + } + return 0 +} + +func resolveAutomaticModelContextEvidence(items []modelContextEvidence) ResolvedModelContextCapacity { + // Resolve by source even if a caller assembles evidence in another order. + for _, source := range [...]string{ModelContextSourceOfficial, ModelContextSourceUpstream, ModelContextSourceRegistry} { + for _, item := range items { + if item.source != source { + continue + } + planning, ok := modelContextPlanningCapacity(item.capacity) + if ok { + return ResolvedModelContextCapacity{ModelContextCapacity: planning, Source: item.source} } - return 0 } - result.MaxInputTokens = compatible(func(value ModelContextCapacity) int64 { return value.MaxInputTokens }) - result.MaxOutputTokens = compatible(func(value ModelContextCapacity) int64 { return value.MaxOutputTokens }) - return result } return unknownModelContextCapacity("no_capacity_evidence") } @@ -120,25 +161,21 @@ func resolveModelContextEvidence(items []modelContextEvidence) ResolvedModelCont func modelContextPlanningCapacity(raw ModelContextCapacity) (ModelContextCapacity, bool) { value := sanitizeModelContextCapacity(raw) switch { + case value.MaxContextWindow > 0: + // The externally usable planning window is the proven maximum. A + // smaller context_window is a product default, not an upper bound. + value.ContextWindow = value.MaxContextWindow + value.CapacityBasis = ModelContextCapacityBasisMaximum case value.ContextWindow > 0: value.CapacityBasis = ModelContextCapacityBasisTotal + value.MaxContextWindow = value.ContextWindow case value.MaxInputTokens > 0: value.ContextWindow = value.MaxInputTokens + value.MaxContextWindow = value.MaxInputTokens value.CapacityBasis = ModelContextCapacityBasisInput - case value.MaxContextWindow > 0: - value.ContextWindow = value.MaxContextWindow - value.CapacityBasis = ModelContextCapacityBasisMaximum default: return ModelContextCapacity{}, false } - // A declared maximum below the default window caps it: a client never - // plans beyond the real maximum. - if value.MaxContextWindow > 0 && value.MaxContextWindow < value.ContextWindow { - value.ContextWindow = value.MaxContextWindow - } - if value.MaxContextWindow < value.ContextWindow { - value.MaxContextWindow = value.ContextWindow - } return value, true } @@ -293,12 +330,12 @@ func (evidence *accountModelCapacityEvidence) resolve(modelID string, useCustom ContextWindow: value, MaxContextWindow: value, CapacityBasis: ModelContextCapacityBasisTotal, }}) } - if value, ok := evidence.upstream[modelID]; ok { - items = append(items, modelContextEvidence{ModelContextSourceUpstream, value}) - } if official := LookupOfficialModelContextCapacity(account, modelID); official != nil { items = append(items, modelContextEvidence{ModelContextSourceOfficial, official.ModelContextCapacity}) } + if value, ok := evidence.upstream[modelID]; ok { + items = append(items, modelContextEvidence{ModelContextSourceUpstream, value}) + } if value, ok := evidence.registry[modelID]; ok { items = append(items, modelContextEvidence{ModelContextSourceRegistry, value}) } @@ -416,7 +453,17 @@ func BuildAccountModelContextCapacityRows(account *Account, modelIDs []string) [ } } evidence := newAccountModelCapacityEvidence(account) - for _, values := range []map[string]ModelContextCapacity{evidence.upstream, evidence.registry} { + // Historical or foreign-endpoint observations remain visible to admins, + // but only identity-bound evidence above may influence the resolved value. + rawUpstream, rawRegistry, _ := accountRawCapacityObservations(account) + upstreamEvidenceStatus := "unbound" + if snapshot := account.GetUpstreamModelMetadataSnapshot(); snapshot != nil && snapshot.SourceIdentity != "" { + upstreamEvidenceStatus = "source_mismatch" + if upstreamModelMetadataSourceMatches(account, snapshot) { + upstreamEvidenceStatus = "current" + } + } + for _, values := range []map[string]ModelContextCapacity{rawUpstream, rawRegistry} { for modelID := range values { add(modelID) } @@ -439,11 +486,13 @@ func BuildAccountModelContextCapacityRows(account *Account, modelIDs []string) [ if len(aliases[modelID])+len(variants[modelID]) > 0 { row.Aliases = dedupeAndSortModelIDs(append(aliases[modelID], variants[modelID]...)) } - if value, ok := evidence.upstream[modelID]; ok { + if value, ok := rawUpstream[modelID]; ok { row.Upstream = &value + row.UpstreamEvidenceStatus = upstreamEvidenceStatus } - if value, ok := evidence.registry[modelID]; ok { + if value, ok := rawRegistry[modelID]; ok { row.Registry = &value + row.RegistryEvidenceStatus = upstreamEvidenceStatus } row.Official = LookupOfficialModelContextCapacity(account, modelID) if value, ok := evidence.overrides[modelID]; ok { diff --git a/backend/internal/service/model_context_capacity_catalog_test.go b/backend/internal/service/model_context_capacity_catalog_test.go index d401db2c0d25..6937edf7cce6 100644 --- a/backend/internal/service/model_context_capacity_catalog_test.go +++ b/backend/internal/service/model_context_capacity_catalog_test.go @@ -217,8 +217,8 @@ func TestOfficialModelContextCapacityCatalogVerifiedRepresentativeValues(t *test modelID string contextWindow, maxInput, maxOut int64 }{ - {"gpt-6-astra", 272000, 0, 128000}, - {"gpt-5.4-mini", 272000, 0, 0}, + {"gpt-6-astra", 1050000, 0, 128000}, + {"gpt-5.4-mini", 400000, 0, 128000}, {"gpt-4.1", 1047576, 0, 32768}, {"claude-sonnet-4-5-20250929", 200000, 0, 64000}, {"deepseek-v4-pro", 1000000, 0, 384000}, diff --git a/backend/internal/service/model_context_capacity_reference_test.go b/backend/internal/service/model_context_capacity_reference_test.go index 2f2a808d0890..91dc83676888 100644 --- a/backend/internal/service/model_context_capacity_reference_test.go +++ b/backend/internal/service/model_context_capacity_reference_test.go @@ -15,19 +15,19 @@ func TestModelContextCapacitySelectedGPTReferenceAndExactVariants(t *testing.T) window int64 canonical string }{ - {"gpt-6-astra", 272000, "gpt-6-astra"}, - {"team/region/GPT-6", 272000, "gpt-6-astra"}, - {"vendor/gpt-5.6", 272000, "gpt-5.6-sol"}, - {"vendor/gpt-5.6-terra-high", 272000, "gpt-5.6-terra"}, - {"gpt-5.6-luna", 272000, "gpt-5.6-luna"}, - {"gpt-daybreak-blue-latest", 272000, "gpt-daybreak-blue-latest"}, - {"gpt-daybreak-red-latest", 372000, "gpt-daybreak-red-latest"}, - {"vendor/gpt-5.4", 272000, "gpt-5.4"}, - {"gpt-5.4-2026-03-05", 272000, "gpt-5.4"}, + {"gpt-6-astra", 1050000, "gpt-6-astra"}, + {"team/region/GPT-6", 1050000, "gpt-6-astra"}, + {"vendor/gpt-5.6", 1050000, "gpt-5.6-sol"}, + {"vendor/gpt-5.6-terra-high", 1050000, "gpt-5.6-terra"}, + {"gpt-5.6-luna", 1050000, "gpt-5.6-luna"}, + {"gpt-daybreak-blue-latest", 1050000, "gpt-daybreak-blue-latest"}, + {"gpt-daybreak-red-latest", 400000, "gpt-daybreak-red-latest"}, + {"vendor/gpt-5.4", 1050000, "gpt-5.4"}, + {"gpt-5.4-2026-03-05", 1050000, "gpt-5.4"}, {"gpt-5.2-pro-2025-12-11", 400000, "gpt-5.2-pro"}, - {"gpt-5.5", 272000, "gpt-5.5"}, - {"vendor/GPT5.4mini", 272000, "gpt-5.4-mini"}, - {"gpt-5.2", 272000, "gpt-5.2"}, + {"gpt-5.5", 1050000, "gpt-5.5"}, + {"vendor/GPT5.4mini", 400000, "gpt-5.4-mini"}, + {"gpt-5.2", 400000, "gpt-5.2"}, {"vendor/gpt-5.3-codex", 400000, "gpt-5.3-codex"}, {"vendor/claude-sonnet-4-6", 1000000, "claude-sonnet-4-6"}, {"anthropic/claude-opus-4.6", 1000000, "claude-opus-4-6"}, @@ -52,17 +52,17 @@ func TestModelContextCapacitySelectedGPTReferenceAndExactVariants(t *testing.T) }) } ref := LookupOfficialModelContextCapacity(account, "gpt-6") - require.Equal(t, int64(272000), ref.ContextWindow) - require.Equal(t, int64(872000), ref.MaxContextWindow, "the API-key reference is the Codex subscription maximum") + require.Equal(t, int64(1050000), ref.ContextWindow) + require.Zero(t, ref.MaxContextWindow, "the API spec only declares a total window") require.Equal(t, int64(272000), ref.Reference.ContextWindow) require.Equal(t, int64(872000), ref.Reference.MaxContextWindow) - require.Equal(t, "codex_subscription", ref.Product) - require.Contains(t, ref.Conditions, "Codex") + require.Equal(t, "api", ref.Product) + require.Contains(t, ref.Conditions, "API") ref.Reference.ContextWindow = 1 require.Equal(t, int64(272000), LookupOfficialModelContextCapacity(account, "gpt-6").Reference.ContextWindow, "returned evidence must not mutate the release catalog") mini := LookupOfficialModelContextCapacity(account, "gpt-5.4-mini") - require.Equal(t, "codex_subscription", mini.Product) - require.Zero(t, mini.MaxOutputTokens, "the subscription reference does not publish an independent output hard limit") + require.Equal(t, "api", mini.Product) + require.Equal(t, int64(128000), mini.MaxOutputTokens, "the API spec publishes an independent output hard limit") } func TestModelContextCapacityReferenceMatchingKeepsRawKeys(t *testing.T) { @@ -70,6 +70,9 @@ func TestModelContextCapacityReferenceMatchingKeepsRawKeys(t *testing.T) { account.Extra[UpstreamModelMetadataExtraKey] = observedCapacityExtra(map[string]UpstreamModelMetadata{ "team/gpt-5.4": {ContextWindow: 256000}, "team/unknown": {ContextWindow: 258000}, }) + snapshot := account.GetUpstreamModelMetadataSnapshot() + snapshot.SourceIdentity = UpstreamModelMetadataSourceIdentity(&account) + account.SetUpstreamModelMetadataSnapshot(*snapshot) before, err := json.Marshal(account) require.NoError(t, err) rows := BuildAccountModelContextCapacityRows(&account, []string{"team/gpt-5.4"}) @@ -83,7 +86,7 @@ func TestModelContextCapacityReferenceMatchingKeepsRawKeys(t *testing.T) { require.Equal(t, []string{"public"}, matched.Aliases) require.Equal(t, "gpt-5.4", matched.Official.ModelID) require.Equal(t, int64(258000), matched.EffectiveContextWindow) - require.Equal(t, int64(256000), matched.AutomaticContextWindow, "the account's own declaration outranks the reference catalog") + require.Equal(t, int64(1050000), matched.AutomaticContextWindow, "the official API specification outranks the account declaration") require.Equal(t, "custom", matched.EffectiveSource) upstream := ResolveAccountModelContextCapacity(&account, "team/unknown") require.Equal(t, int64(258000), upstream.ContextWindow) @@ -94,7 +97,7 @@ func TestModelContextCapacityReferenceMatchingKeepsRawKeys(t *testing.T) { overrides, ok := account.Extra[ModelContextOverridesExtraKey].(map[string]int64) require.True(t, ok) delete(overrides, "team/gpt-5.4") - require.Equal(t, int64(256000), ResolveAccountModelContextCapacity(&account, "team/gpt-5.4").ContextWindow, "clearing raw override must not borrow the bare-ID override") + require.Equal(t, int64(1050000), ResolveAccountModelContextCapacity(&account, "team/gpt-5.4").ContextWindow, "clearing raw override must not borrow the bare-ID override") } func TestModelContextCapacityReferenceCandidateMinimumAndWireIdentity(t *testing.T) { @@ -115,7 +118,7 @@ func TestModelContextCapacityReferenceCandidateMinimumAndWireIdentity(t *testing require.Equal(t, "gpt-6", envelope.Data[0]["id"]) require.Equal(t, "Keep label", envelope.Data[0]["display_name"]) require.Equal(t, true, envelope.Data[0]["sentinel"]) - require.Equal(t, float64(272000), envelope.Data[0]["context_window"]) - require.Equal(t, float64(1000000), envelope.Data[0]["max_context_window"], "a GPT-looking public alias must not identify the unknown actual target") + require.Equal(t, float64(1050000), envelope.Data[0]["context_window"]) + require.Equal(t, float64(1050000), envelope.Data[0]["max_context_window"], "a GPT-looking public alias must not identify the unknown actual target") require.NotContains(t, string(body), "context_window", "projection must not mutate the input cache body") } diff --git a/backend/internal/service/model_context_capacity_test.go b/backend/internal/service/model_context_capacity_test.go index bcf1cf38c5fe..322ae5992e70 100644 --- a/backend/internal/service/model_context_capacity_test.go +++ b/backend/internal/service/model_context_capacity_test.go @@ -7,67 +7,158 @@ import ( ) func TestModelContextCapacityPriorityAndDistinctLimits(t *testing.T) { - registry := ModelContextCapacity{ContextWindow: 2000000, MaxOutputTokens: 100000} - upstream := ModelContextCapacity{ContextWindow: 128000, MaxContextWindow: 256000, MaxInputTokens: 120000, MaxOutputTokens: 8000} - accountFor := func(baseURL string, custom map[string]int64, observed, registryValue *ModelContextCapacity) *Account { - models := make(map[string]UpstreamModelMetadata) - if observed != nil { - models["gpt-5.5"] = UpstreamModelMetadata{ID: "gpt-5.5", ContextWindow: observed.ContextWindow, MaxContextWindow: observed.MaxContextWindow, - MaxInputTokens: observed.MaxInputTokens, MaxOutputTokens: observed.MaxOutputTokens, CapacitySource: ModelContextSourceUpstream} - } - if registryValue != nil { - models["gpt-5.5-unlisted"] = UpstreamModelMetadata{ID: "gpt-5.5-unlisted", ContextWindow: registryValue.ContextWindow, - MaxOutputTokens: registryValue.MaxOutputTokens, CapacitySource: ModelContextSourceRegistry} - } - return &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": baseURL}, Extra: map[string]any{ - ModelContextOverridesExtraKey: custom, - UpstreamModelMetadataExtraKey: UpstreamModelMetadataSnapshot{Source: "models.dev", Models: models}, - }} - } - // The same order applies on the vendor's host and on a relay. - for _, baseURL := range []string{"https://api.openai.com/v1", "https://relay.example/v1"} { - cases := []struct { - name, model, source string - account *Account - window, maximum int64 - output int64 - }{ - {"custom over upstream", "gpt-5.5", "custom", accountFor(baseURL, map[string]int64{"gpt-5.5": 768000}, &upstream, nil), 768000, 768000, 8000}, - {"upstream over catalog", "gpt-5.5", "upstream", accountFor(baseURL, nil, &upstream, nil), 128000, 256000, 8000}, - {"catalog is the Codex subscription window", "gpt-5.5", "official", accountFor(baseURL, nil, nil, nil), 272000, 272000, 0}, - {"catalog default and real maximum stay distinct", "gpt-5.4", "official", accountFor(baseURL, nil, nil, nil), 272000, 1000000, 0}, - {"registry below catalog", "gpt-5.5-unlisted", "registry", accountFor(baseURL, nil, nil, ®istry), 2000000, 2000000, 100000}, - {"unknown is not invented", "never-seen", "", accountFor(baseURL, nil, nil, nil), 0, 0, 0}, - } - for _, test := range cases { - t.Run(baseURL+"/"+test.name, func(t *testing.T) { - got := ResolveAccountModelContextCapacity(test.account, test.model) - if got.Source != test.source || got.ContextWindow != test.window || got.MaxContextWindow != test.maximum || got.MaxOutputTokens != test.output { - t.Fatalf("unexpected resolution: %+v", got) + for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth} { + for _, baseURL := range []string{"https://api.openai.com/v1", "https://relay.example/v1"} { + t.Run(accountType+"/"+baseURL, func(t *testing.T) { + account := &Account{Platform: PlatformOpenAI, Type: accountType, Credentials: map[string]any{"base_url": baseURL}} + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{ + Source: "upstream", SourceIdentity: UpstreamModelMetadataSourceIdentity(account), Models: map[string]UpstreamModelMetadata{ + "gpt-5.5": {ContextWindow: 272000, MaxContextWindow: 872000, MaxInputTokens: 200000, MaxOutputTokens: 8000}, + "custom-model": {ContextWindow: 272000, MaxContextWindow: 872000, MaxInputTokens: 200000, MaxOutputTokens: 8000}, + "registry-model": {ContextWindow: 2000000, MaxOutputTokens: 100000, CapacitySource: ModelContextSourceRegistry}, + }, + }) + for _, test := range []struct { + model, source string + window, input, output int64 + }{ + {"gpt-5.5", ModelContextSourceOfficial, 1050000, 0, 128000}, + {"gpt-5.4", ModelContextSourceOfficial, 1050000, 0, 128000}, + {"gpt-5.4-mini", ModelContextSourceOfficial, 400000, 0, 128000}, + {"custom-model", ModelContextSourceUpstream, 872000, 200000, 8000}, + {"registry-model", ModelContextSourceRegistry, 2000000, 0, 100000}, + {"never-seen", "", 0, 0, 0}, + } { + got := ResolveAccountModelContextCapacity(account, test.model) + if got.Source != test.source || got.ContextWindow != test.window || got.MaxContextWindow != test.window || got.MaxInputTokens != test.input || got.MaxOutputTokens != test.output { + t.Fatalf("%s: unexpected resolution: %+v", test.model, got) + } + } + account.Extra[ModelContextOverridesExtraKey] = map[string]int64{"gpt-5.5": 768000} + got := ResolveAccountModelContextCapacity(account, "gpt-5.5") + if got.Source != ModelContextSourceCustom || got.ContextWindow != 768000 || got.MaxInputTokens != 0 || got.MaxOutputTokens != 128000 { + t.Fatalf("custom override borrowed unrelated limits: %+v", got) + } + delete(account.Extra, ModelContextOverridesExtraKey) + got = ResolveAccountModelContextCapacity(account, "gpt-5.5") + if got.Source != ModelContextSourceOfficial || got.ContextWindow != 1050000 { + t.Fatalf("clearing an override did not restore the API specification: %+v", got) } }) } } - inputOnly := resolveModelContextEvidence([]modelContextEvidence{ - {ModelContextSourceUpstream, ModelContextCapacity{MaxInputTokens: 1048576, MaxOutputTokens: 65536}}, - {ModelContextSourceOfficial, upstream}, - }) - if inputOnly.ContextWindow != 1048576 || inputOnly.CapacityBasis != "input_limit" || inputOnly.Source != "upstream" || inputOnly.MaxOutputTokens != 65536 { - t.Fatalf("input budget was misrepresented or lost to lower evidence: %+v", inputOnly) - } - outputOnly := resolveModelContextEvidence([]modelContextEvidence{ - {ModelContextSourceUpstream, ModelContextCapacity{MaxOutputTokens: 8192}}, - {ModelContextSourceOfficial, ModelContextCapacity{ContextWindow: 400000}}, - }) - if outputOnly.Source != "official" || outputOnly.ContextWindow != 400000 || outputOnly.MaxOutputTokens != 8192 { - t.Fatalf("an output limit alone is not a window but keeps its evidence: %+v", outputOnly) - } - smallCustom := resolveModelContextEvidence([]modelContextEvidence{ - {ModelContextSourceCustom, ModelContextCapacity{ContextWindow: 100000, MaxContextWindow: 100000}}, - {ModelContextSourceUpstream, ModelContextCapacity{MaxInputTokens: 500000, MaxOutputTokens: 8192}}, - }) - if smallCustom.MaxInputTokens != 0 || smallCustom.MaxOutputTokens != 8192 { - t.Fatalf("incompatible independent limits were clamped or not safely omitted: %+v", smallCustom) +} + +func TestModelContextCapacityEvidenceDoesNotMixSources(t *testing.T) { + for _, test := range []struct { + name string + items []modelContextEvidence + source, basis string + window, input, output int64 + }{ + {"official selected independent of evidence order", []modelContextEvidence{ + {ModelContextSourceUpstream, ModelContextCapacity{ContextWindow: 200000, MaxInputTokens: 100000, MaxOutputTokens: 8192}}, + {ModelContextSourceRegistry, ModelContextCapacity{ContextWindow: 600000, MaxOutputTokens: 32768}}, + {ModelContextSourceOfficial, ModelContextCapacity{ContextWindow: 400000}}, + }, ModelContextSourceOfficial, ModelContextCapacityBasisTotal, 400000, 0, 0}, + {"maximum before default and input", []modelContextEvidence{ + {ModelContextSourceUpstream, ModelContextCapacity{ContextWindow: 272000, MaxContextWindow: 872000, MaxInputTokens: 250000, MaxOutputTokens: 128000}}, + }, ModelContextSourceUpstream, ModelContextCapacityBasisMaximum, 872000, 250000, 128000}, + {"explicit smaller maximum honored", []modelContextEvidence{ + {ModelContextSourceUpstream, ModelContextCapacity{ContextWindow: 872000, MaxContextWindow: 272000, MaxOutputTokens: 128000}}, + }, ModelContextSourceUpstream, ModelContextCapacityBasisMaximum, 272000, 0, 128000}, + {"input only stays input only", []modelContextEvidence{ + {ModelContextSourceUpstream, ModelContextCapacity{MaxInputTokens: 1048576, MaxOutputTokens: 65536}}, + {ModelContextSourceRegistry, ModelContextCapacity{ContextWindow: 2000000}}, + }, ModelContextSourceUpstream, ModelContextCapacityBasisInput, 1048576, 1048576, 65536}, + {"output alone cannot fill lower priority record", []modelContextEvidence{ + {ModelContextSourceUpstream, ModelContextCapacity{MaxOutputTokens: 8192}}, + {ModelContextSourceRegistry, ModelContextCapacity{ContextWindow: 400000}}, + }, ModelContextSourceRegistry, ModelContextCapacityBasisTotal, 400000, 0, 0}, + {"bad context preserves independent valid input", []modelContextEvidence{ + {ModelContextSourceUpstream, ModelContextCapacity{ContextWindow: -1, MaxContextWindow: MaxSafeModelContextTokens + 1, MaxInputTokens: 100000, MaxOutputTokens: 8192}}, + }, ModelContextSourceUpstream, ModelContextCapacityBasisInput, 100000, 100000, 8192}, + } { + t.Run(test.name, func(t *testing.T) { + got := resolveModelContextEvidence(test.items) + if got.Source != test.source || got.CapacityBasis != test.basis || got.ContextWindow != test.window || got.MaxContextWindow != test.window || got.MaxInputTokens != test.input || got.MaxOutputTokens != test.output { + t.Fatalf("unexpected complete evidence result: %+v", got) + } + }) + } +} + +func TestModelContextCapacityCustomOverrideOverlaysOneAutomaticRecord(t *testing.T) { + tests := []struct { + name string + items []modelContextEvidence + window, maximum, input, output int64 + source string + }{ + { + name: "official output is preserved", + items: []modelContextEvidence{ + {ModelContextSourceCustom, ModelContextCapacity{ContextWindow: 768000, MaxContextWindow: 768000}}, + {ModelContextSourceOfficial, ModelContextCapacity{ContextWindow: 1050000, MaxOutputTokens: 128000}}, + }, + window: 768000, maximum: 768000, output: 128000, source: ModelContextSourceCustom, + }, + { + name: "official missing output stays missing", + items: []modelContextEvidence{ + {ModelContextSourceCustom, ModelContextCapacity{ContextWindow: 512000, MaxContextWindow: 512000}}, + {ModelContextSourceOfficial, ModelContextCapacity{ContextWindow: 400000}}, + {ModelContextSourceUpstream, ModelContextCapacity{ContextWindow: 400000, MaxOutputTokens: 32000}}, + }, + window: 512000, maximum: 512000, source: ModelContextSourceCustom, + }, + { + name: "unknown automatic evidence", + items: []modelContextEvidence{ + {ModelContextSourceCustom, ModelContextCapacity{ContextWindow: 256000, MaxContextWindow: 256000}}, + }, + window: 256000, maximum: 256000, source: ModelContextSourceCustom, + }, + { + name: "independent limit above override is omitted", + items: []modelContextEvidence{ + {ModelContextSourceCustom, ModelContextCapacity{ContextWindow: 32000, MaxContextWindow: 32000}}, + {ModelContextSourceOfficial, ModelContextCapacity{ContextWindow: 1050000, MaxInputTokens: 64000, MaxOutputTokens: 128000}}, + }, + window: 32000, maximum: 32000, source: ModelContextSourceCustom, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := resolveModelContextEvidence(test.items) + if got.Source != test.source || got.ContextWindow != test.window || got.MaxContextWindow != test.maximum || got.MaxInputTokens != test.input || got.MaxOutputTokens != test.output { + t.Fatalf("custom overlay mismatch: %+v", got) + } + }) + } +} + +func TestModelContextCapacityOfficialPolicyAppliesToOtherVendors(t *testing.T) { + for _, test := range []struct { + platform, model string + window int64 + }{ + {PlatformAnthropic, "claude-opus-4-6", 1000000}, + {PlatformGemini, "gemini-3.8-flash", 1048576}, + {PlatformOpenAI, "deepseek-v4-pro", 1000000}, + {PlatformOpenAI, "MiniMax-M3", 1000000}, + } { + t.Run(test.model, func(t *testing.T) { + account := &Account{Platform: test.platform, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://relay.example/v1"}} + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Source: "upstream", SourceIdentity: UpstreamModelMetadataSourceIdentity(account), Models: map[string]UpstreamModelMetadata{test.model: {ContextWindow: 16000, MaxInputTokens: 15000, MaxOutputTokens: 8000}}}) + got := ResolveAccountModelContextCapacity(account, test.model) + if got.Source != ModelContextSourceOfficial || got.ContextWindow != test.window { + t.Fatalf("official API policy did not apply across vendors: %+v", got) + } + if test.model == "MiniMax-M3" && (got.MaxInputTokens != 0 || got.MaxOutputTokens != 0) { + t.Fatalf("unknown official limits filled from relay: %+v", got) + } + }) } } @@ -172,13 +263,16 @@ func TestModelContextCapacityRowsUseTrueTargetsAndPreserveSources(t *testing.T) }}, }, } + snapshot := account.GetUpstreamModelMetadataSnapshot() + snapshot.SourceIdentity = UpstreamModelMetadataSourceIdentity(account) + account.SetUpstreamModelMetadataSnapshot(*snapshot) rows := BuildAccountModelContextCapacityRows(account, []string{"registry-only"}) byID := make(map[string]AccountModelContextCapacityRow) for _, row := range rows { byID[row.UpstreamModelID] = row } row := byID["real"] - if row.Upstream == nil || row.Upstream.ContextWindow != 100000 || row.Upstream.ObservedAt != "2026-09-06T01:00:00Z" || row.EffectiveContextWindow != 600000 || row.AutomaticContextWindow != 100000 || !row.Editable { + if row.Upstream == nil || row.Upstream.ContextWindow != 100000 || row.Upstream.ObservedAt != "2026-09-06T01:00:00Z" || row.UpstreamEvidenceStatus != "current" || row.EffectiveContextWindow != 600000 || row.AutomaticContextWindow != 100000 || !row.Editable { t.Fatalf("raw/custom/automatic provenance lost: %+v", row) } if !reflect.DeepEqual(row.Aliases, []string{"public-one", "public-two"}) { @@ -221,3 +315,49 @@ func TestModelContextCapacityOfficialKimiCodingNeedsProductIdentity(t *testing.T }) } } + +func TestModelContextCapacityRowsDescribeIgnoredUpstreamEvidence(t *testing.T) { + for _, status := range []string{"current", "unbound", "source_mismatch"} { + t.Run(status, func(t *testing.T) { + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://relay.example/v1"}} + snapshot := UpstreamModelMetadataSnapshot{Source: ModelContextSourceUpstream, Models: map[string]UpstreamModelMetadata{ + "unknown-upstream-model": {ContextWindow: 272000, MaxContextWindow: 872000}, + }} + switch status { + case "current": + snapshot.SourceIdentity = UpstreamModelMetadataSourceIdentity(account) + case "source_mismatch": + prior := *account + prior.Credentials = map[string]any{"base_url": "https://prior.example/v1"} + snapshot.SourceIdentity = UpstreamModelMetadataSourceIdentity(&prior) + } + account.SetUpstreamModelMetadataSnapshot(snapshot) + rows := BuildAccountModelContextCapacityRows(account, nil) + if len(rows) != 1 || rows[0].Upstream == nil || rows[0].Upstream.ContextWindow != 272000 || rows[0].Upstream.MaxContextWindow != 872000 || rows[0].UpstreamEvidenceStatus != status { + t.Fatalf("raw evidence lost or incorrectly labelled: %+v", rows) + } + row := rows[0] + if status == "current" { + if row.AutomaticContextWindow != 872000 || row.AutomaticSource != ModelContextSourceUpstream { + t.Fatalf("current evidence not selected: %+v", row) + } + } else if row.AutomaticContextWindow != 0 || row.AutomaticSource != "" { + t.Fatalf("ignored upstream evidence was applied: %+v", row) + } + }) + } +} + +func TestModelContextCapacityRowsRetainIgnoredRegistryEvidence(t *testing.T) { + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://relay.example/v1"}} + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Source: ModelContextSourceRegistry, Models: map[string]UpstreamModelMetadata{ + "legacy-registry-model": {ContextWindow: 700000, CapacitySource: ModelContextSourceRegistry}, + }}) + rows := BuildAccountModelContextCapacityRows(account, nil) + if len(rows) != 1 || rows[0].Registry == nil || rows[0].Registry.ContextWindow != 700000 || rows[0].RegistryEvidenceStatus != "unbound" { + t.Fatalf("legacy registry evidence was not retained as diagnostic data: %+v", rows) + } + if rows[0].AutomaticContextWindow != 0 || rows[0].EffectiveContextWindow != 0 { + t.Fatalf("unbound registry evidence was applied: %+v", rows[0]) + } +} diff --git a/backend/internal/service/model_context_identity.go b/backend/internal/service/model_context_identity.go index 50e8a0803017..422c6c8fa5fd 100644 --- a/backend/internal/service/model_context_identity.go +++ b/backend/internal/service/model_context_identity.go @@ -1,10 +1,95 @@ package service import ( - infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net" + "net/url" "strings" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" ) +// UpstreamModelMetadataSourceIdentity binds observations to the provider and +// endpoint contract that produced them. Credentials, proxies and public model +// aliases are deliberately absent: changing those does not change the source. +func UpstreamModelMetadataSourceIdentity(account *Account) string { + if account == nil { + return "" + } + identity := struct { + Platform, Type, Mode, Protocol, CatalogBase, AnthropicBase string + ResponsesMode, ResponsesSupport string + ProtocolBases map[string]string + }{ + Platform: strings.ToLower(strings.TrimSpace(account.Platform)), + Type: strings.ToLower(strings.TrimSpace(account.Type)), + Mode: strings.ToLower(strings.TrimSpace(account.GetCredential("account_mode"))), + Protocol: account.GetAPIProtocol(), + CatalogBase: normalizeCapacitySourceURL(upstreamModelRegistryBaseURL(account)), + AnthropicBase: normalizeCapacitySourceURL(account.GetAnthropicProtocolBaseURL()), + } + if account.IsOpenAI() { + identity.ResponsesMode = string(openai_compat.NormalizeResponsesSupportMode(account.GetExtraString(openai_compat.ExtraKeyResponsesMode))) + switch openai_compat.ResolveResponsesSupport(account.Extra) { + case openai_compat.ResponsesSupportYes: + identity.ResponsesSupport = "yes" + case openai_compat.ResponsesSupportNo: + identity.ResponsesSupport = "no" + default: + identity.ResponsesSupport = "unknown" + } + } + if account.IsOpenAIOAuth() { + identity.CatalogBase = normalizeCapacitySourceURL(chatgptCodexModelsURL) + } + if account.IsAnthropic() && account.IsOAuth() { + identity.CatalogBase = "https://api.anthropic.com" + } + if account.IsMultiProtocolAPIKey() { + identity.ProtocolBases = make(map[string]string) + for _, protocol := range []string{APIProtocolChatCompletions, APIProtocolResponses, APIProtocolAnthropic} { + identity.ProtocolBases[protocol] = normalizeCapacitySourceURL(account.GetCNProtocolBaseURL(protocol)) + } + } + body, _ := json.Marshal(identity) + digest := sha256.Sum256(body) + return "v1:" + hex.EncodeToString(digest[:]) +} + +func normalizeCapacitySourceURL(raw string) string { + raw = strings.TrimSpace(raw) + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" { + return strings.TrimRight(raw, "/") + } + parsed.Scheme = strings.ToLower(parsed.Scheme) + host, port := strings.ToLower(parsed.Hostname()), parsed.Port() + if (parsed.Scheme == "https" && port == "443") || (parsed.Scheme == "http" && port == "80") { + port = "" + } + parsed.Host = host + if port != "" { + parsed.Host = net.JoinHostPort(host, port) + } else if strings.Contains(host, ":") { + parsed.Host = "[" + host + "]" + } + parsed.User, parsed.Fragment, parsed.RawFragment = nil, "", "" + // url.Parse keeps an escaped path in RawPath. Preserve it so a reserved + // slash (%2F) cannot become the same identity as a path separator. + if parsed.RawPath == "" { + parsed.Path = strings.TrimRight(parsed.Path, "/") + } + parsed.RawQuery = parsed.Query().Encode() + return parsed.String() +} + +func upstreamModelMetadataSourceMatches(account *Account, snapshot *UpstreamModelMetadataSnapshot) bool { + return snapshot != nil && snapshot.SourceIdentity != "" && snapshot.SourceIdentity == UpstreamModelMetadataSourceIdentity(account) +} + // Input is an upstream target, never a public routing alias. In particular, // A -> B and B -> C must not cause a second mapping when resolving A's capacity. func capacityCanonicalUpstreamID(account *Account, model string) string { @@ -63,6 +148,16 @@ func resolveCapacityOverrides(account *Account) (map[string]int64, map[string]bo // over alias spellings; two alias spellings with different limits are a // conflict, not a choice. func accountCapacityObservations(account *Account) (upstream, registry map[string]ModelContextCapacity, conflicts map[string]bool) { + return accountCapacityObservationsWithSourceCheck(account, true) +} + +// Raw observations remain available to administrators after an endpoint edit +// or when a legacy snapshot has no source binding. They are not current evidence. +func accountRawCapacityObservations(account *Account) (upstream, registry map[string]ModelContextCapacity, conflicts map[string]bool) { + return accountCapacityObservationsWithSourceCheck(account, false) +} + +func accountCapacityObservationsWithSourceCheck(account *Account, checkSource bool) (upstream, registry map[string]ModelContextCapacity, conflicts map[string]bool) { upstream = make(map[string]ModelContextCapacity) registry = make(map[string]ModelContextCapacity) conflicts = make(map[string]bool) @@ -70,6 +165,7 @@ func accountCapacityObservations(account *Account) (upstream, registry map[strin if snapshot == nil { return upstream, registry, conflicts } + usableSource := !checkSource || upstreamModelMetadataSourceMatches(account, snapshot) valuesFor := func(source string) map[string]ModelContextCapacity { if source == ModelContextSourceRegistry { return registry @@ -79,6 +175,9 @@ func accountCapacityObservations(account *Account) (upstream, registry map[strin exact := make(map[string]bool) for id, entry := range snapshot.Models { capacity, source := upstreamMetadataCapacity(snapshot.Source, entry) + if !usableSource { + continue + } if source == "" || !validModelContextID(id) || capacityCanonicalUpstreamID(account, id) != id { continue } @@ -87,6 +186,9 @@ func accountCapacityObservations(account *Account) (upstream, registry map[strin } for id, entry := range snapshot.Models { capacity, source := upstreamMetadataCapacity(snapshot.Source, entry) + if !usableSource { + continue + } target := capacityCanonicalUpstreamID(account, id) if source == "" || !validModelContextID(id) || target == id || exact[source+"\x00"+target] { continue diff --git a/backend/internal/service/model_context_identity_test.go b/backend/internal/service/model_context_identity_test.go index 216d2c07b392..9ebbfe2de50a 100644 --- a/backend/internal/service/model_context_identity_test.go +++ b/backend/internal/service/model_context_identity_test.go @@ -54,9 +54,11 @@ func TestCapacityIdentityDoesNotRemapRealTargetTwice(t *testing.T) { func TestCapacityIdentitySnapshotUsesRecognizedSpellingOnly(t *testing.T) { account := newGroupCapacityAccount(1, nil, nil) - account.Extra[UpstreamModelMetadataExtraKey] = observedCapacityExtra(map[string]UpstreamModelMetadata{ + snapshot := observedCapacityExtra(map[string]UpstreamModelMetadata{ "gpt-5.6-sol-high": {ContextWindow: 650001}, "vendor/gpt-5.6-sol-high": {ContextWindow: 450001}, }) + snapshot.SourceIdentity = UpstreamModelMetadataSourceIdentity(&account) + account.SetUpstreamModelMetadataSnapshot(snapshot) values, _, _ := accountCapacityObservations(&account) require.Equal(t, int64(650001), values["gpt-5.6-sol"].ContextWindow) require.Equal(t, int64(450001), values["vendor/gpt-5.6-sol-high"].ContextWindow) diff --git a/backend/internal/service/model_context_official_catalog_data.go b/backend/internal/service/model_context_official_catalog_data.go index 7aacb2ef88cb..70c05a38520d 100644 --- a/backend/internal/service/model_context_official_catalog_data.go +++ b/backend/internal/service/model_context_official_catalog_data.go @@ -1,8 +1,8 @@ package service // This release-pinned reference catalog describes capacity, not model -// availability or routing. It ranks below an account's own upstream declaration -// and above the models.dev registry. Stored IDs/aliases are exact; namespace, +// availability or routing. Applicable official API specifications rank above +// the account's upstream declaration and the models.dev registry. Stored IDs/aliases are exact; namespace, // service-tier suffix, dotted/hyphen version and GPT spelling variants are // lookup-only and handled by the resolver. MatchHosts and MatchAccountModes // constrain product-specific evidence; an equal self-hosted model ID is @@ -14,8 +14,9 @@ const ( GPTContextCapacityReferenceRelease = "rust-v0.153.4" gptContextCapacityReferenceSource = "https://github.com/openai/codex/blob/3d2ee51ca2d5db578f328aa75e20aa22c0197c9a/codex-rs/models-manager/models.json" gptContextCapacityReferenceVerifiedAt = "2026-09-08" - gptSubscriptionReferenceSelection = "GPT 参考值统一采用官方 Codex 内置目录的默认窗口与最大窗口(API Key 账号同样适用);账号上游或中转声明优先于本参考,不代表单个账号的实时容量或可用性。" - gptSnapshotAliasSelection = " 官方 API 文档列出的默认日期快照为同一型号的精确别名(2026-09-17 核实),沿用本行容量;其他日期后缀不推断。" + gptAPICapacityVerifiedAt = "2026-09-28" + gptAPISpecificationSelection = "采用官方 API 规格,适用于 API Key、OAuth 及中转的同一模型;Codex 订阅目录只作为独立产品的历史诊断参考,不参与 API 容量解析,也不证明模型可用性。" + gptSnapshotAliasSelection = " 官方 API 文档列出的默认日期快照为同一型号的精确别名(2026-09-28 核实),沿用本行容量;其他日期后缀不推断。" ) const GPT6ContextCapacityReferenceCommit = "4b664e0ef0397f82e68c60088a90fcd035deb796" @@ -70,99 +71,88 @@ var ( ) var officialModelContextCapacityCatalog = []OfficialModelContextCapacity{ - // GPT reference values are the Codex subscription catalog for every account - // type (default window / real maximum). An account's own upstream or relay - // declaration outranks this reference; GPT entries without a Codex reference - // keep the API specification. - { - ModelID: "gpt-6-astra", Aliases: []string{"gpt-6"}, Provider: "openai", Product: "codex_subscription", - ModelContextCapacity: ModelContextCapacity{ContextWindow: 272000, MaxContextWindow: 872000, MaxOutputTokens: 128000, CapacityBasis: ModelContextCapacityBasisTotal}, - SourceURL: gptContextCapacityReferenceSource, - SourceURLs: []string{"https://developers.openai.com/api/docs/models/gpt-6-astra"}, - VerifiedAt: gptContextCapacityReferenceVerifiedAt, - OriginalText: "context_window: 272000; max_context_window: 872000 (Codex); 128,000 max output tokens (API)", + // GPT API specifications apply to every account type. Any Codex product + // reference is retained as diagnostic metadata, not an effective limit. + { + ModelID: "gpt-6-astra", Aliases: []string{"gpt-6"}, Provider: "openai", Product: "api", + ModelContextCapacity: ModelContextCapacity{ContextWindow: 1050000, MaxOutputTokens: 128000, CapacityBasis: ModelContextCapacityBasisTotal}, + SourceURL: "https://developers.openai.com/api/docs/models/gpt-6-astra", + VerifiedAt: gptAPICapacityVerifiedAt, + OriginalText: "1,050,000 context window; 128,000 max output tokens", Reference: codexModelContextCapacityReference(272000, 872000), - Conditions: gptSubscriptionReferenceSelection, + Conditions: gptAPISpecificationSelection, }, { - ModelID: "gpt-6-sol", Provider: "openai", Product: "codex_subscription", - ModelContextCapacity: ModelContextCapacity{ContextWindow: 272000, MaxContextWindow: 872000, MaxOutputTokens: 128000, CapacityBasis: ModelContextCapacityBasisTotal}, - SourceURL: GPT6ContextCapacityReferenceSource, - SourceURLs: []string{"https://developers.openai.com/api/docs/models/gpt-6-sol"}, - VerifiedAt: GPT6ContextCapacityReferenceVerifiedAt, - OriginalText: "context_window: 272000; max_context_window: 872000 (Codex); 128,000 max output tokens (API)", + ModelID: "gpt-6-sol", Provider: "openai", Product: "api", + ModelContextCapacity: ModelContextCapacity{ContextWindow: 1050000, MaxOutputTokens: 128000, CapacityBasis: ModelContextCapacityBasisTotal}, + SourceURL: "https://developers.openai.com/api/docs/models/gpt-6-sol", + VerifiedAt: gptAPICapacityVerifiedAt, + OriginalText: "1,050,000 context window; 128,000 max output tokens", Reference: gpt6CodexCapacityReference(), - Conditions: gptSubscriptionReferenceSelection, + Conditions: gptAPISpecificationSelection, }, { - ModelID: "gpt-6-luna", Provider: "openai", Product: "codex_subscription", - ModelContextCapacity: ModelContextCapacity{ContextWindow: 272000, MaxContextWindow: 872000, MaxOutputTokens: 128000, CapacityBasis: ModelContextCapacityBasisTotal}, - SourceURL: GPT6ContextCapacityReferenceSource, - SourceURLs: []string{"https://developers.openai.com/api/docs/models/gpt-6-luna"}, - VerifiedAt: GPT6ContextCapacityReferenceVerifiedAt, - OriginalText: "context_window: 272000; max_context_window: 872000 (Codex); 128,000 max output tokens (API)", + ModelID: "gpt-6-luna", Provider: "openai", Product: "api", + ModelContextCapacity: ModelContextCapacity{ContextWindow: 1050000, MaxOutputTokens: 128000, CapacityBasis: ModelContextCapacityBasisTotal}, + SourceURL: "https://developers.openai.com/api/docs/models/gpt-6-luna", + VerifiedAt: gptAPICapacityVerifiedAt, + OriginalText: "1,050,000 context window; 128,000 max output tokens", Reference: gpt6CodexCapacityReference(), - Conditions: gptSubscriptionReferenceSelection, + Conditions: gptAPISpecificationSelection, }, { - ModelID: "gpt-5.6-sol", Aliases: []string{"gpt-5.6"}, Provider: "openai", Product: "codex_subscription", - ModelContextCapacity: ModelContextCapacity{ContextWindow: 272000, MaxContextWindow: 872000, MaxOutputTokens: 128000, CapacityBasis: ModelContextCapacityBasisTotal}, - SourceURL: gptContextCapacityReferenceSource, - SourceURLs: []string{"https://developers.openai.com/api/docs/models/gpt-5.6-sol"}, - VerifiedAt: gptContextCapacityReferenceVerifiedAt, - OriginalText: "context_window: 272000; max_context_window: 872000 (Codex); 128,000 max output tokens (API)", + ModelID: "gpt-5.6-sol", Aliases: []string{"gpt-5.6"}, Provider: "openai", Product: "api", + ModelContextCapacity: ModelContextCapacity{ContextWindow: 1050000, MaxOutputTokens: 128000, CapacityBasis: ModelContextCapacityBasisTotal}, + SourceURL: "https://developers.openai.com/api/docs/models/gpt-5.6-sol", + VerifiedAt: gptAPICapacityVerifiedAt, + OriginalText: "1,050,000 context window; 128,000 max output tokens", Reference: codexModelContextCapacityReference(272000, 872000), - Conditions: gptSubscriptionReferenceSelection + " gpt-5.6 是 Sol 的明确别名;不写回账号模型映射。", + Conditions: gptAPISpecificationSelection + " gpt-5.6 是 Sol 的明确别名;不写回账号模型映射。", }, { - ModelID: "gpt-5.6-terra", Provider: "openai", Product: "codex_subscription", - ModelContextCapacity: ModelContextCapacity{ContextWindow: 272000, MaxContextWindow: 872000, MaxOutputTokens: 128000, CapacityBasis: ModelContextCapacityBasisTotal}, - SourceURL: gptContextCapacityReferenceSource, - SourceURLs: []string{"https://developers.openai.com/api/docs/models/gpt-5.6-terra"}, - VerifiedAt: gptContextCapacityReferenceVerifiedAt, - OriginalText: "context_window: 272000; max_context_window: 872000 (Codex); 128,000 max output tokens (API)", + ModelID: "gpt-5.6-terra", Provider: "openai", Product: "api", + ModelContextCapacity: ModelContextCapacity{ContextWindow: 1050000, MaxOutputTokens: 128000, CapacityBasis: ModelContextCapacityBasisTotal}, + SourceURL: "https://developers.openai.com/api/docs/models/gpt-5.6-terra", + VerifiedAt: gptAPICapacityVerifiedAt, + OriginalText: "1,050,000 context window; 128,000 max output tokens", Reference: codexModelContextCapacityReference(272000, 872000), - Conditions: gptSubscriptionReferenceSelection, + Conditions: gptAPISpecificationSelection, }, { - ModelID: "gpt-5.6-luna", Provider: "openai", Product: "codex_subscription", - ModelContextCapacity: ModelContextCapacity{ContextWindow: 272000, MaxContextWindow: 872000, MaxOutputTokens: 128000, CapacityBasis: ModelContextCapacityBasisTotal}, - SourceURL: gptContextCapacityReferenceSource, - SourceURLs: []string{"https://developers.openai.com/api/docs/models/gpt-5.6-luna"}, - VerifiedAt: gptContextCapacityReferenceVerifiedAt, - OriginalText: "context_window: 272000; max_context_window: 872000 (Codex); 128,000 max output tokens (API)", + ModelID: "gpt-5.6-luna", Provider: "openai", Product: "api", + ModelContextCapacity: ModelContextCapacity{ContextWindow: 1050000, MaxOutputTokens: 128000, CapacityBasis: ModelContextCapacityBasisTotal}, + SourceURL: "https://developers.openai.com/api/docs/models/gpt-5.6-luna", + VerifiedAt: gptAPICapacityVerifiedAt, + OriginalText: "1,050,000 context window; 128,000 max output tokens", Reference: codexModelContextCapacityReference(272000, 872000), - Conditions: gptSubscriptionReferenceSelection, + Conditions: gptAPISpecificationSelection, }, { - ModelID: "gpt-5.5", Aliases: []string{"gpt-5.5-2026-04-23"}, Provider: "openai", Product: "codex_subscription", - ModelContextCapacity: ModelContextCapacity{ContextWindow: 272000, MaxContextWindow: 272000, CapacityBasis: ModelContextCapacityBasisTotal}, - SourceURL: gptContextCapacityReferenceSource, - SourceURLs: []string{"https://developers.openai.com/api/docs/models/gpt-5.5"}, - VerifiedAt: gptContextCapacityReferenceVerifiedAt, - OriginalText: "context_window: 272000; max_context_window: 272000", + ModelID: "gpt-5.5", Aliases: []string{"gpt-5.5-2026-04-23"}, Provider: "openai", Product: "api", + ModelContextCapacity: ModelContextCapacity{ContextWindow: 1050000, MaxOutputTokens: 128000, CapacityBasis: ModelContextCapacityBasisTotal}, + SourceURL: "https://developers.openai.com/api/docs/models/gpt-5.5", + VerifiedAt: gptAPICapacityVerifiedAt, + OriginalText: "1,050,000 context window; 128,000 max output tokens", Reference: codexModelContextCapacityReference(272000, 272000), - Conditions: gptSubscriptionReferenceSelection + gptSnapshotAliasSelection, + Conditions: gptAPISpecificationSelection + gptSnapshotAliasSelection, }, { - ModelID: "gpt-5.4", Aliases: []string{"gpt-5.4-2026-03-05"}, Provider: "openai", Product: "codex_subscription", - ModelContextCapacity: ModelContextCapacity{ContextWindow: 272000, MaxContextWindow: 1000000, CapacityBasis: ModelContextCapacityBasisTotal}, - SourceURL: gptContextCapacityReferenceSource, - SourceURLs: []string{"https://developers.openai.com/api/docs/models/gpt-5.4"}, - VerifiedAt: gptContextCapacityReferenceVerifiedAt, - OriginalText: "context_window: 272000; max_context_window: 1000000", + ModelID: "gpt-5.4", Aliases: []string{"gpt-5.4-2026-03-05"}, Provider: "openai", Product: "api", + ModelContextCapacity: ModelContextCapacity{ContextWindow: 1050000, MaxOutputTokens: 128000, CapacityBasis: ModelContextCapacityBasisTotal}, + SourceURL: "https://developers.openai.com/api/docs/models/gpt-5.4", + VerifiedAt: gptAPICapacityVerifiedAt, + OriginalText: "1,050,000 context window; 128,000 max output tokens", Reference: codexModelContextCapacityReference(272000, 1000000), - Conditions: gptSubscriptionReferenceSelection + gptSnapshotAliasSelection, + Conditions: gptAPISpecificationSelection + gptSnapshotAliasSelection, }, { - ModelID: "gpt-5.4-mini", Aliases: []string{"gpt-5.4-mini-2026-03-17"}, Provider: "openai", Product: "codex_subscription", - ModelContextCapacity: ModelContextCapacity{ContextWindow: 272000, MaxContextWindow: 272000, CapacityBasis: ModelContextCapacityBasisTotal}, - SourceURL: gptContextCapacityReferenceSource, - SourceURLs: []string{"https://developers.openai.com/api/docs/models/gpt-5.4-mini"}, - VerifiedAt: gptContextCapacityReferenceVerifiedAt, - OriginalText: "context_window: 272000; max_context_window: 272000", + ModelID: "gpt-5.4-mini", Aliases: []string{"gpt-5.4-mini-2026-03-17"}, Provider: "openai", Product: "api", + ModelContextCapacity: ModelContextCapacity{ContextWindow: 400000, MaxOutputTokens: 128000, CapacityBasis: ModelContextCapacityBasisTotal}, + SourceURL: "https://developers.openai.com/api/docs/models/gpt-5.4-mini", + VerifiedAt: gptAPICapacityVerifiedAt, + OriginalText: "400,000 context window; 128,000 max output tokens", Reference: codexModelContextCapacityReference(272000, 272000), - Conditions: gptSubscriptionReferenceSelection + gptSnapshotAliasSelection, + Conditions: gptAPISpecificationSelection + gptSnapshotAliasSelection, }, { ModelID: "gpt-5.4-nano", Provider: "openai", Product: "api", @@ -179,14 +169,13 @@ var officialModelContextCapacityCatalog = []OfficialModelContextCapacity{ OriginalText: "400,000 context window; 128,000 max output tokens", }, { - ModelID: "gpt-5.2", Aliases: []string{"gpt-5.2-2025-12-11"}, Provider: "openai", Product: "codex_subscription", - ModelContextCapacity: ModelContextCapacity{ContextWindow: 272000, MaxContextWindow: 272000, CapacityBasis: ModelContextCapacityBasisTotal}, - SourceURL: gptContextCapacityReferenceSource, - SourceURLs: []string{"https://developers.openai.com/api/docs/models/gpt-5.2"}, - VerifiedAt: gptContextCapacityReferenceVerifiedAt, - OriginalText: "context_window: 272000; max_context_window: 272000", + ModelID: "gpt-5.2", Aliases: []string{"gpt-5.2-2025-12-11"}, Provider: "openai", Product: "api", + ModelContextCapacity: ModelContextCapacity{ContextWindow: 400000, MaxOutputTokens: 128000, CapacityBasis: ModelContextCapacityBasisTotal}, + SourceURL: "https://developers.openai.com/api/docs/models/gpt-5.2", + VerifiedAt: gptAPICapacityVerifiedAt, + OriginalText: "400,000 context window; 128,000 max output tokens", Reference: codexModelContextCapacityReference(272000, 272000), - Conditions: gptSubscriptionReferenceSelection + gptSnapshotAliasSelection, + Conditions: gptAPISpecificationSelection + gptSnapshotAliasSelection, }, { ModelID: "gpt-5.2-pro", Aliases: []string{"gpt-5.2-pro-2025-12-11"}, Provider: "openai", Product: "api", @@ -197,23 +186,22 @@ var officialModelContextCapacityCatalog = []OfficialModelContextCapacity{ Conditions: "官方 API 规格;gpt-5.2-pro-2025-12-11 为文档列出的默认快照。Pro 型号不在 Codex 订阅参考目录内,无订阅参考。", }, { - ModelID: "gpt-daybreak-blue-latest", Provider: "openai", Product: "codex_subscription", - ModelContextCapacity: ModelContextCapacity{ContextWindow: 272000, MaxContextWindow: 872000, MaxOutputTokens: 128000, CapacityBasis: ModelContextCapacityBasisTotal}, - SourceURL: gptContextCapacityReferenceSource, - SourceURLs: []string{"https://developers.openai.com/api/docs/models/gpt-daybreak-blue-latest"}, - VerifiedAt: gptContextCapacityReferenceVerifiedAt, - OriginalText: "context_window: 272000; max_context_window: 872000 (Codex); 128,000 max output tokens (API)", + ModelID: "gpt-daybreak-blue-latest", Provider: "openai", Product: "api", + ModelContextCapacity: ModelContextCapacity{ContextWindow: 1050000, MaxOutputTokens: 128000, CapacityBasis: ModelContextCapacityBasisTotal}, + SourceURL: "https://developers.openai.com/api/docs/models/gpt-daybreak-blue-latest", + VerifiedAt: gptAPICapacityVerifiedAt, + OriginalText: "1,050,000 context window; 128,000 max output tokens", Reference: codexModelContextCapacityReference(272000, 872000), - Conditions: gptSubscriptionReferenceSelection + " Daybreak 仍需单独授权,不据容量目录开放模型。", + Conditions: gptAPISpecificationSelection + " Daybreak 仍需单独授权,不据容量目录开放模型。", }, { - ModelID: "gpt-daybreak-red-latest", Provider: "openai", Product: "codex_subscription", - ModelContextCapacity: ModelContextCapacity{ContextWindow: 372000, MaxContextWindow: 372000, CapacityBasis: ModelContextCapacityBasisTotal}, - SourceURL: gptContextCapacityReferenceSource, - VerifiedAt: gptContextCapacityReferenceVerifiedAt, - OriginalText: "context_window: 372000; max_context_window: 372000", + ModelID: "gpt-daybreak-red-latest", Provider: "openai", Product: "api", + ModelContextCapacity: ModelContextCapacity{ContextWindow: 400000, MaxOutputTokens: 128000, CapacityBasis: ModelContextCapacityBasisTotal}, + SourceURL: "https://developers.openai.com/api/docs/models/gpt-daybreak-red-latest", + VerifiedAt: gptAPICapacityVerifiedAt, + OriginalText: "400,000 context window; 128,000 max output tokens", Reference: codexModelContextCapacityReference(372000, 372000), - Conditions: gptSubscriptionReferenceSelection + " Daybreak 仍需单独授权,不据容量目录开放模型。", + Conditions: gptAPISpecificationSelection + " Daybreak 仍需单独授权,不据容量目录开放模型。", }, { ModelID: "gpt-4.1", Provider: "openai", Product: "api", diff --git a/backend/internal/service/model_context_official_catalog_test.go b/backend/internal/service/model_context_official_catalog_test.go index cca8dad605a0..8c0369d60bbd 100644 --- a/backend/internal/service/model_context_official_catalog_test.go +++ b/backend/internal/service/model_context_official_catalog_test.go @@ -32,15 +32,15 @@ func TestOfficialModelCatalogSnapshotCannotMutateActiveReferences(t *testing.T) } } -func TestOfficialModelCatalogGPT6UsesCodexSubscriptionValues(t *testing.T) { +func TestOfficialModelCatalogGPT6UsesAPIValuesAndRetainsProductReference(t *testing.T) { for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} { query := officialCatalogQuery{Candidates: []string{model}, Platform: "openai", Scheme: "https", Host: "api.openai.com"} entry := lookupOfficialModelCatalog(query) if entry == nil { t.Fatalf("%s: reference unavailable", model) } - if entry.Product != "codex_subscription" || entry.ContextWindow != 272000 || entry.MaxContextWindow != 872000 || entry.MaxInputTokens != 0 || len(entry.Aliases) != 0 { - t.Fatalf("%s: incorrect subscription capacity or invented alias: %+v", model, entry) + if entry.Product != "api" || entry.ContextWindow != 1050000 || entry.MaxContextWindow != 0 || entry.MaxInputTokens != 0 || entry.MaxOutputTokens != 128000 || len(entry.Aliases) != 0 { + t.Fatalf("%s: incorrect API capacity or invented alias: %+v", model, entry) } ref := entry.Reference if ref == nil || ref.ContextWindow != 272000 || ref.MaxContextWindow != 872000 || ref.SourceURL != GPT6ContextCapacityReferenceSource || ref.Release != GPT6ContextCapacityReferenceCommit { diff --git a/backend/internal/service/model_plaza_service.go b/backend/internal/service/model_plaza_service.go index 17cb9b1b9094..0f24a13f1e65 100644 --- a/backend/internal/service/model_plaza_service.go +++ b/backend/internal/service/model_plaza_service.go @@ -52,6 +52,9 @@ type PlazaGroup struct { // = 档位价 × ImageRateMultiplier,不乘分组/用户专属倍率(与计费口径一致)。 ImageRateIndependent bool ImageRateMultiplier float64 + // 视频独立倍率与图片独立倍率分别配置,开启时覆盖分组/用户专属倍率。 + VideoRateIndependent bool + VideoRateMultiplier float64 // LongContextPricingEnabled 分组是否按上下文长度应用阶梯价;关闭时模型展示的是最低档。 LongContextPricingEnabled bool Models []PlazaModel @@ -132,6 +135,8 @@ func (s *ModelPlazaService) ListGroups(ctx context.Context) ([]PlazaGroup, error IsExclusive: g.IsExclusive, ImageRateIndependent: g.ImageRateIndependent, ImageRateMultiplier: g.ImageRateMultiplier, + VideoRateIndependent: g.VideoRateIndependent, + VideoRateMultiplier: g.VideoRateMultiplier, LongContextPricingEnabled: g.LongContextPricingEnabled, } groupEnt[g.ID] = g diff --git a/backend/internal/service/model_plaza_service_test.go b/backend/internal/service/model_plaza_service_test.go index fa2e7a8345a1..b7008f87e924 100644 --- a/backend/internal/service/model_plaza_service_test.go +++ b/backend/internal/service/model_plaza_service_test.go @@ -265,7 +265,8 @@ func TestListPlazaGroups_GroupImagePriceOverridesChannelPricing(t *testing.T) { }} groups := []Group{ {ID: 10, Name: "g-media", Platform: "openai", RateMultiplier: 1, - ImagePrice1K: &imgPrice, ImageRateIndependent: true, ImageRateMultiplier: 1}, + ImagePrice1K: &imgPrice, ImageRateIndependent: true, ImageRateMultiplier: 1, + VideoRateIndependent: true, VideoRateMultiplier: 0.7}, {ID: 20, Name: "g-plain", Platform: "openai", RateMultiplier: 0.1}, } svc := newPlazaService(channels, groups, nil) @@ -279,6 +280,8 @@ func TestListPlazaGroups_GroupImagePriceOverridesChannelPricing(t *testing.T) { media := byName["g-media"] require.True(t, media.ImageRateIndependent) + require.True(t, media.VideoRateIndependent) + require.InDelta(t, 0.7, media.VideoRateMultiplier, 1e-9) require.InDelta(t, 1.0, media.ImageRateMultiplier, 1e-9) require.Len(t, media.Models, 1) p := media.Models[0].Pricing @@ -295,6 +298,7 @@ func TestListPlazaGroups_GroupImagePriceOverridesChannelPricing(t *testing.T) { plain := byName["g-plain"] require.False(t, plain.ImageRateIndependent) + require.False(t, plain.VideoRateIndependent) require.Len(t, plain.Models, 1) pp := plain.Models[0].Pricing require.NotNil(t, pp) diff --git a/backend/internal/service/model_pricing_resolver.go b/backend/internal/service/model_pricing_resolver.go index fc723ef265ba..46c84da230f1 100644 --- a/backend/internal/service/model_pricing_resolver.go +++ b/backend/internal/service/model_pricing_resolver.go @@ -269,29 +269,30 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin resolved.BasePricing.FastMultiplier = chPricing.FastMultiplier resolved.BasePricing.FlexMultiplier = chPricing.FlexMultiplier resolved.BasePricing.ReasoningEffortMultipliers = maps.Clone(chPricing.ReasoningEffortMultipliers) - // 渠道定价覆盖一切:显式配置则用配置值,未配置则归零(不回退到 LiteLLM) - if chPricing.ImageOutputPrice != nil { - resolved.BasePricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice - } else { - resolved.BasePricing.ImageOutputPricePerToken = 0 - } - resolved.BasePricing.ImageOutputPriceExplicit = true - applyChannelImageInputPrice(chPricing, resolved.BasePricing) + applyChannelImagePriceOverrides(chPricing, resolved.BasePricing) // 区间未命中时回退到上面已经应用渠道覆盖的基础价。 resolved.Intervals = filterValidIntervals(chPricing.Intervals) } -// applyChannelImageInputPrice 应用渠道图片输入价:显式配置则用配置值; -// 未配置时归零,使 computeTokenBreakdown 回退到文本输入价(向后兼容, -// 避免 commit 引入的 LiteLLM 图片输入价泄漏进渠道自定义定价)。 -// 与 image_output 不同,此处不设 Explicit 标志——图片输入未配置应回退文本价, -// 而非硬置 0。 -func applyChannelImageInputPrice(chPricing *ChannelModelPricing, pricing *ModelPricing) { - if chPricing != nil && chPricing.ImageInputPrice != nil { +// applyChannelImagePriceOverrides 应用渠道图片输入/输出价,规则与其他 token 字段一致: +// - 渠道填写了(含 0):覆盖目录价。图片输出价同时标记 ImageOutputPriceExplicit, +// 显式 0 表示图片输出免费,computeTokenBreakdown 不再回退文本输出价。 +// - 渠道留空(nil):沿用目录价(LiteLLM 价格表或内置兜底价)。目录也没有图片价时, +// computeTokenBreakdown 分别回退文本输入/输出价。 +// +// 此前留空会被归零,图片输出还被标成显式 0:只建了空价卡的图片模型,图片输出 +// 一律按 0 计费,图片输入按文本价计费。 +func applyChannelImagePriceOverrides(chPricing *ChannelModelPricing, pricing *ModelPricing) { + if chPricing == nil || pricing == nil { + return + } + if chPricing.ImageOutputPrice != nil { + pricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice + pricing.ImageOutputPriceExplicit = true + } + if chPricing.ImageInputPrice != nil { pricing.ImageInputPricePerToken = *chPricing.ImageInputPrice - } else { - pricing.ImageInputPricePerToken = 0 } } @@ -389,15 +390,8 @@ func intervalToModelPricing(iv *PricingInterval, base *ModelPricing, chPricing * pricing.CacheReadPricePerToken = applyMultiplier(pricing.CacheReadPricePerToken, iv.CacheReadMultiplier) pricing.CacheReadPricePerTokenPriority = applyMultiplier(pricing.CacheReadPricePerTokenPriority, iv.CacheReadMultiplier) } - // 渠道定价存在时,ImageOutputPrice 显式覆盖;图片输入价用渠道级配置 - // (区间不携带图片输入价,与 image_output 一致)。 - if chPricing != nil { - pricing.ImageOutputPriceExplicit = true - if chPricing.ImageOutputPrice != nil { - pricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice - } - applyChannelImageInputPrice(chPricing, pricing) - } + // 区间不携带图片价,沿用渠道级配置;渠道留空时保留基础价里的目录图片价。 + applyChannelImagePriceOverrides(chPricing, pricing) return pricing } diff --git a/backend/internal/service/model_pricing_resolver_test.go b/backend/internal/service/model_pricing_resolver_test.go index a0373a1247cb..ef47cd39c5c9 100644 --- a/backend/internal/service/model_pricing_resolver_test.go +++ b/backend/internal/service/model_pricing_resolver_test.go @@ -722,23 +722,50 @@ func TestFilterValidIntervals(t *testing.T) { // 9. ImageOutputPriceExplicit tests // =========================================================================== -func TestApplyTokenOverrides_FlatSetsImageOutputPriceExplicit(t *testing.T) { - r := newResolverWithChannel(t, []ChannelModelPricing{{ +// withCatalogImagePrices 给测试基础价补上目录图片价($8 输入 / $30 输出 per MTok)。 +func withCatalogImagePrices(r *ModelPricingResolver) *ModelPricingResolver { + base := r.billingService.fallbackPrices["claude-sonnet-4"] + base.ImageInputPricePerToken = 8e-6 + base.ImageOutputPricePerToken = 30e-6 + return r +} + +func TestApplyTokenOverrides_FlatUnsetImagePricesInheritCatalog(t *testing.T) { + r := withCatalogImagePrices(newResolverWithChannel(t, []ChannelModelPricing{{ Platform: "anthropic", Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken, InputPrice: testPtrFloat64(3e-6), OutputPrice: testPtrFloat64(15e-6), - // ImageOutputPrice intentionally nil - }}) + // ImageInputPrice / ImageOutputPrice intentionally nil + }})) resolved := r.Resolve(context.Background(), PricingInput{ Model: "claude-sonnet-4", GroupID: groupIDPtr(), }) require.Equal(t, PricingSourceChannel, resolved.Source) + require.False(t, resolved.BasePricing.ImageOutputPriceExplicit, "留空不应标记显式") + require.InDelta(t, 30e-6, resolved.BasePricing.ImageOutputPricePerToken, 1e-12) + require.InDelta(t, 8e-6, resolved.BasePricing.ImageInputPricePerToken, 1e-12) +} + +func TestApplyTokenOverrides_ExplicitZeroImageOutputPriceStaysZero(t *testing.T) { + // 渠道显式填 0 仍表示图片输出免费,不回退目录价,也不回退文本输出价。 + r := withCatalogImagePrices(newResolverWithChannel(t, []ChannelModelPricing{{ + Platform: "anthropic", + Models: []string{"claude-sonnet-4"}, + BillingMode: BillingModeToken, + ImageOutputPrice: testPtrFloat64(0), + }})) + resolved := r.Resolve(context.Background(), PricingInput{ + Model: "claude-sonnet-4", + GroupID: groupIDPtr(), + }) + require.True(t, resolved.BasePricing.ImageOutputPriceExplicit) require.Equal(t, 0.0, resolved.BasePricing.ImageOutputPricePerToken) + require.InDelta(t, 8e-6, resolved.BasePricing.ImageInputPricePerToken, 1e-12, "图片输入留空仍沿用目录价") } func TestApplyTokenOverrides_FlatWithImageOutputPriceSetsExplicit(t *testing.T) { @@ -759,29 +786,72 @@ func TestApplyTokenOverrides_FlatWithImageOutputPriceSetsExplicit(t *testing.T) require.InDelta(t, 50e-6, resolved.BasePricing.ImageOutputPricePerToken, 1e-12) } -func TestApplyTokenOverrides_IntervalSetsImageOutputPriceExplicit(t *testing.T) { - r := newResolverWithChannel(t, []ChannelModelPricing{{ +func TestApplyTokenOverrides_IntervalUnsetImagePricesInheritCatalog(t *testing.T) { + r := withCatalogImagePrices(newResolverWithChannel(t, []ChannelModelPricing{{ Platform: "anthropic", Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken, - // No ImageOutputPrice + // No ImageInputPrice / ImageOutputPrice Intervals: []PricingInterval{ {MinTokens: 0, MaxTokens: testPtrInt(100000), InputPrice: testPtrFloat64(3e-6), OutputPrice: testPtrFloat64(15e-6)}, }, - }}) + }})) resolved := r.Resolve(context.Background(), PricingInput{ Model: "claude-sonnet-4", GroupID: groupIDPtr(), }) - // BasePricing should have explicit mark (for interval fallback) - require.True(t, resolved.BasePricing.ImageOutputPriceExplicit) - require.Equal(t, 0.0, resolved.BasePricing.ImageOutputPricePerToken) + require.False(t, resolved.BasePricing.ImageOutputPriceExplicit) + require.InDelta(t, 30e-6, resolved.BasePricing.ImageOutputPricePerToken, 1e-12) + + // 区间命中时同样沿用目录图片价 + pricing := r.GetIntervalPricing(resolved, 50000) + require.False(t, pricing.ImageOutputPriceExplicit) + require.InDelta(t, 30e-6, pricing.ImageOutputPricePerToken, 1e-12) + require.InDelta(t, 8e-6, pricing.ImageInputPricePerToken, 1e-12) +} + +func TestApplyTokenOverrides_IntervalExplicitImageOutputPrice(t *testing.T) { + r := withCatalogImagePrices(newResolverWithChannel(t, []ChannelModelPricing{{ + Platform: "anthropic", + Models: []string{"claude-sonnet-4"}, + BillingMode: BillingModeToken, + ImageOutputPrice: testPtrFloat64(50e-6), + Intervals: []PricingInterval{ + {MinTokens: 0, MaxTokens: testPtrInt(100000), InputPrice: testPtrFloat64(3e-6), OutputPrice: testPtrFloat64(15e-6)}, + }, + }})) + resolved := r.Resolve(context.Background(), PricingInput{ + Model: "claude-sonnet-4", + GroupID: groupIDPtr(), + }) - // intervalToModelPricing should also have explicit mark pricing := r.GetIntervalPricing(resolved, 50000) require.True(t, pricing.ImageOutputPriceExplicit) - require.Equal(t, 0.0, pricing.ImageOutputPricePerToken) + require.InDelta(t, 50e-6, pricing.ImageOutputPricePerToken, 1e-12) +} + +func TestCalculateCostUnified_EmptyChannelEntryBillsCatalogImagePrices(t *testing.T) { + // 常见配置:图片模型只建了空价卡(token 模式、价格全留空)。图片输出此前按 0 计费。 + r := withCatalogImagePrices(newResolverWithChannel(t, []ChannelModelPricing{{ + Platform: "anthropic", + Models: []string{"claude-sonnet-4"}, + BillingMode: BillingModeToken, + }})) + tokens := UsageTokens{InputTokens: 1000, ImageInputTokens: 600, OutputTokens: 500, ImageOutputTokens: 500} + cost, err := r.billingService.CalculateCostUnified(CostInput{ + Ctx: context.Background(), + Model: "claude-sonnet-4", + GroupID: groupIDPtr(), + Tokens: tokens, + RateMultiplier: 1, + Resolver: r, + }) + require.NoError(t, err) + require.InDelta(t, 500*30e-6, cost.ImageOutputCost, 1e-12) + require.InDelta(t, 600*8e-6, cost.ImageInputCost, 1e-12) + require.InDelta(t, 400*3e-6, cost.InputCost, 1e-12) + require.InDelta(t, 0, cost.OutputCost, 1e-12, "输出全部是图片 token") } // =========================================================================== @@ -835,7 +905,7 @@ func TestApplyTokenOverrides_IntervalDoesNotPolluteFallbackPrices(t *testing.T) }) require.NotNil(t, resolved) - require.True(t, resolved.BasePricing.ImageOutputPriceExplicit) + require.False(t, resolved.BasePricing.ImageOutputPriceExplicit, "渠道未填图片价,不应标记显式") // Global fallbackPrices must NOT be polluted fp := r.billingService.fallbackPrices["claude-sonnet-4"] diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go index c47cf47ba55e..74eb9411541e 100644 --- a/backend/internal/service/openai_account_scheduler_test.go +++ b/backend/internal/service/openai_account_scheduler_test.go @@ -1898,7 +1898,7 @@ func TestOpenAIGatewayService_SelectAccountForModelWithExclusions_FreshUsageWind // inversion) gets excluded from scheduling, and a paused account never receives traffic to // refresh its snapshot. When the snapshot is stale (codex_usage_updated_at older than the // staleness bound) the account must be allowed a request so it can self-heal from the real -// response headers — independent of the window's reset time. +// response headers when no valid future window reset is known. func TestOpenAIGatewayService_SelectAccountForModelWithExclusions_StaleUsageSnapshotSkipsPause_Issue2994(t *testing.T) { ctx := context.Background() primary := Account{ @@ -1912,8 +1912,7 @@ func TestOpenAIGatewayService_SelectAccountForModelWithExclusions_StaleUsageSnap Extra: map[string]any{ "codex_5h_used_percent": 99.0, "auto_pause_5h_threshold": 0.95, - // Window has NOT reset yet, so the reset guard stays inactive. - "codex_5h_reset_at": time.Now().Add(time.Hour).Format(time.RFC3339), + // No reset time is known, so stale snapshot self-heal applies. // Snapshot is stale: older than openAICodexAutoPauseStaleAfter (2h). "codex_usage_updated_at": time.Now().Add(-3 * time.Hour).Format(time.RFC3339), }, diff --git a/backend/internal/service/openai_alpha_search.go b/backend/internal/service/openai_alpha_search.go index 0f8bbb22f68d..f03557bd888e 100644 --- a/backend/internal/service/openai_alpha_search.go +++ b/backend/internal/service/openai_alpha_search.go @@ -599,7 +599,10 @@ func shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(statusCode int) bool { } func openAIAlphaSearchResponseFromResponsesSSE(body []byte) ([]byte, error) { - output, results := parseOpenAIResponsesSSEForAlphaSearch(body) + output, results, err := parseOpenAIResponsesSSEForAlphaSearch(body) + if err != nil { + return nil, err + } resp := map[string]any{ "output": output, } @@ -609,7 +612,7 @@ func openAIAlphaSearchResponseFromResponsesSSE(body []byte) ([]byte, error) { return json.Marshal(resp) } -func parseOpenAIResponsesSSEForAlphaSearch(body []byte) (string, []any) { +func parseOpenAIResponsesSSEForAlphaSearch(body []byte) (string, []any, error) { text := strings.ReplaceAll(string(body), "\r\n", "\n") var output strings.Builder var completedResponse any @@ -625,21 +628,36 @@ func parseOpenAIResponsesSSEForAlphaSearch(body []byte) (string, []any) { if err := json.Unmarshal([]byte(data), &event); err != nil { continue } + switch event["type"] { + case "error", "response.failed", "response.incomplete": + return "", nil, fmt.Errorf("alpha search responses stream ended with %s", event["type"]) + case "response.completed": + response, ok := event["response"].(map[string]any) + if !ok || response == nil { + return "", nil, fmt.Errorf("alpha search responses completion is missing its response") + } + if status, present := response["status"]; present && status != "completed" { + return "", nil, fmt.Errorf("alpha search responses completion has a non-success status") + } + completedResponse = response + } if delta, _ := event["delta"].(string); delta != "" && event["type"] == "response.output_text.delta" { _, _ = output.WriteString(delta) } - if event["type"] == "response.completed" { - completedResponse = event["response"] - } collectOpenAIAlphaSearchURLCitations(event, &results, seenURLs) } + // A transport-level 200, deltas or [DONE] alone are not a successful + // search. Do not return partial content or a billable result on EOF. + if completedResponse == nil { + return "", nil, fmt.Errorf("alpha search responses stream ended before completion") + } out := output.String() if strings.TrimSpace(out) == "" && completedResponse != nil { out = extractOpenAIResponsesCompletedText(completedResponse) collectOpenAIAlphaSearchURLCitations(completedResponse, &results, seenURLs) } - return out, results + return out, results, nil } func openAIAlphaSearchSSEData(block string) string { diff --git a/backend/internal/service/openai_alpha_search_completion_test.go b/backend/internal/service/openai_alpha_search_completion_test.go new file mode 100644 index 000000000000..97ab5cff4dd7 --- /dev/null +++ b/backend/internal/service/openai_alpha_search_completion_test.go @@ -0,0 +1,61 @@ +package service + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestAlphaSearchFallbackRequiresSuccessfulCompletion(t *testing.T) { + gin.SetMode(gin.TestMode) + delta := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"unfinished answer\"}\n\n" + cases := map[string]string{ + "empty": "", + "truncated": delta, + "done_only": delta + "data: [DONE]\n\n", + "failed": delta + "data: {\"type\":\"response.failed\",\"response\":{\"status\":\"failed\"}}\n\n", + "incomplete": delta + "data: {\"type\":\"response.incomplete\",\"response\":{\"status\":\"incomplete\"}}\n\n", + "error": delta + "data: {\"type\":\"error\",\"error\":{\"message\":\"unavailable\"}}\n\n", + "invalid_completed": delta + "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"failed\"}}\n\n", + "missing_response": delta + "data: {\"type\":\"response.completed\"}\n\n", + } + for name, wire := range cases { + t.Run(name, func(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","commands":{"search_query":[{"q":"example"}]}}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/alpha/search", bytes.NewReader(body)) + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(wire)), + }} + service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 43, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{"access_token": "at-test-token", "auth_mode": OpenAIAuthModePersonalAccessToken, "chatgpt_account_id": "fixture-account"}} + result, err := service.ForwardAlphaSearch(context.Background(), c, account, body) + require.Error(t, err) + require.Nil(t, result, "a failed search must not produce a billable WebSearchCalls result") + require.False(t, c.Writer.Written(), "do not write partial content as a successful search") + }) + } +} + +func TestAlphaSearchFallbackCompletionPreservesOutputAndCitations(t *testing.T) { + for _, wire := range []string{ + alphaSearchResponsesSSE("search result"), + "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"search result\",\"annotations\":[{\"type\":\"url_citation\",\"url\":\"https://example.com/news\",\"title\":\"Example News\"}]}]}]}}\n\n", + } { + body, err := openAIAlphaSearchResponseFromResponsesSSE([]byte(wire)) + require.NoError(t, err) + require.JSONEq(t, `{"output":"search result","results":[{"type":"text_result","ref_id":"turn0search0","url":"https://example.com/news","title":"Example News"}]}`, string(body)) + } +} diff --git a/backend/internal/service/openai_alpha_search_test.go b/backend/internal/service/openai_alpha_search_test.go index 05f874b40530..2020add1b747 100644 --- a/backend/internal/service/openai_alpha_search_test.go +++ b/backend/internal/service/openai_alpha_search_test.go @@ -369,8 +369,8 @@ func TestForwardAlphaSearchPATBackfillsMissingChatGPTAccountMetadata(t *testing. upstream := &httpUpstreamRecorder{resp: &http.Response{ StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"application/json"}}, - Body: io.NopCloser(strings.NewReader(`{"output":"search result"}`)), + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(alphaSearchResponsesSSE("search result"))), }} oauthService := NewOpenAIOAuthService(nil, nil) service := &OpenAIGatewayService{ diff --git a/backend/internal/service/openai_apikey_responses_probe.go b/backend/internal/service/openai_apikey_responses_probe.go index 5b2d92c69a73..d942afab08e7 100644 --- a/backend/internal/service/openai_apikey_responses_probe.go +++ b/backend/internal/service/openai_apikey_responses_probe.go @@ -77,7 +77,8 @@ func openaiResponsesProbePayload(modelID string) []byte { // // 工具能力探测必须用上游真实存在的模型——用占位模型(DefaultTestModel)打第三方 // 上游只会拿到 400 model-not-found,无从判定工具能力。优先取账号 model_mapping -// 的上游模型(值),按字典序取首个具体(非通配符)模型以保证可复现;无映射时回退 +// 的上游模型(值),其中优先通用 GPT 文本模型,同类按字典序取首个具体(非通配符) +// 模型以保证可复现;无映射时回退 // DefaultTestModel(适配 OpenAI 官方 APIKey 账号)。 func selectResponsesProbeModel(account *Account) string { mapping := account.GetModelMapping() @@ -93,6 +94,13 @@ func selectResponsesProbeModel(account *Account) string { return openai.DefaultTestModel } sort.Strings(candidates) + // Prefer a general text model over auxiliary entries such as + // codex-auto-review or image-only models, which cannot prove tool support. + for _, candidate := range candidates { + if strings.HasPrefix(candidate, "gpt-") && !isOpenAIImageGenerationModel(candidate) { + return candidate + } + } return candidates[0] } @@ -259,9 +267,13 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte // 其余 2xx 一律可下结论——尤其 status=completed 却只回 reasoning 的上游(火山方舟 // coding/v3 × kimi-k2.6),仍按原逻辑判为不支持。 // -// 非 2xx 的结论只看状态码、不依赖响应内容,恒可下结论。 +// 明确指向探测模型不可用的 400/404(model_not_found 等)只说明模型不存在,不说明端点 +// 能力,不下结论;其余非 2xx 的结论只看状态码、不依赖响应内容,恒可下结论。 // 缺少 status 字段的响应体(含非 JSON)也按可下结论处理,保持既有行为。 func responsesProbeVerdictIsConclusive(status int, body []byte) bool { + if isResponsesProbeModelUnavailable(status, body) { + return false + } if status < 200 || status >= 300 { return true } @@ -296,13 +308,17 @@ func isResponsesEndpointSupportedByStatus(status int) bool { // decideResponsesProbeSupport 依据探测响应判定上游 /v1/responses 是否真正可用于 // 携带工具的请求。 // -// - 404 / 405:端点不存在 → false +// - 探测模型不可用的 400/404:与端点能力无关 → true(调用方同时不下结论,不写入) +// - 其他 404 / 405:端点不存在 → false // - 其他非 2xx(401/403/422/5xx 等):端点存在,但本次无法判定工具能力 // (鉴权/校验/瞬时故障)→ 保守按 true,保持既有"端点存在即支持"行为 // - 2xx:探测以 tool_choice=required 强制工具调用,响应必须含 function_call // 输出项才算真正可用;否则(如火山方舟 coding/v3 × kimi-k2.6 仅回 reasoning) // 判为 false,使网关改走 /v1/chat/completions 直转路径。 func decideResponsesProbeSupport(status int, body []byte) bool { + if isResponsesProbeModelUnavailable(status, body) { + return true // The model error says nothing negative about the endpoint. + } if status == http.StatusNotFound || status == http.StatusMethodNotAllowed { return false } @@ -312,6 +328,21 @@ func decideResponsesProbeSupport(status int, body []byte) bool { return responsesProbeBodyHasFunctionCall(body) } +// isResponsesProbeModelUnavailable 判断 400/404 是否只是在说探测模型不可用 +// (错误码/类型为 model_not_found 等,或错误文案明确说模型不存在/不可用)。 +func isResponsesProbeModelUnavailable(status int, body []byte) bool { + if status != http.StatusBadRequest && status != http.StatusNotFound { + return false + } + for _, path := range []string{"error.code", "error.type", "response.error.code", "response.error.type"} { + switch strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, path).String())) { + case "model_not_found", "model_not_available", "unsupported_model", "invalid_model": + return true + } + } + return isExplicitOpenAIModelAvailabilityMessage(extractUpstreamErrorMessage(body)) +} + // responsesProbeBodyHasFunctionCall 判断非流式 Responses 响应体的 output 数组里 // 是否存在 function_call 输出项。 func responsesProbeBodyHasFunctionCall(body []byte) bool { diff --git a/backend/internal/service/openai_apikey_responses_probe_model_unavailable_test.go b/backend/internal/service/openai_apikey_responses_probe_model_unavailable_test.go new file mode 100644 index 000000000000..0f3904380df8 --- /dev/null +++ b/backend/internal/service/openai_apikey_responses_probe_model_unavailable_test.go @@ -0,0 +1,49 @@ +package service + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +// A 400/404 that only says the probe model does not exist says nothing about +// the /v1/responses endpoint: the verdict must stay inconclusive and the +// account capability must not be overwritten with "unsupported". +func TestResponsesProbeModelUnavailableIsInconclusive(t *testing.T) { + for _, body := range []string{ + `{"error":{"type":"model_not_found","message":"Model codex-auto-review is not supported by any configured account in this group"}}`, + `{"error":{"code":"model_not_available"}}`, + `{"error":{"message":"The model missing does not exist"}}`, + } { + require.False(t, responsesProbeVerdictIsConclusive(404, []byte(body))) + require.True(t, decideResponsesProbeSupport(404, []byte(body))) + updates := make(chan map[string]any, 1) + account := Account{ID: 96, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "test", "base_url": "https://upstream.example"}} + repo := &snapshotUpdateAccountRepo{stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}}, updateExtraCalls: updates} + svc := &AccountTestService{accountRepo: repo, cfg: &config.Config{}, httpUpstream: &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: 404, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(body)), + }}} + svc.ProbeOpenAIAPIKeyResponsesSupport(context.Background(), account.ID) + select { + case <-updates: + t.Fatal("a model failure must not overwrite the account endpoint capability") + default: + } + } + // A plain 404/405 still means the endpoint does not exist. + require.True(t, responsesProbeVerdictIsConclusive(404, []byte("404 page not found"))) + require.False(t, decideResponsesProbeSupport(404, []byte("404 page not found"))) + require.False(t, decideResponsesProbeSupport(405, nil)) +} + +func TestSelectResponsesProbeModelPrefersGeneralTextModel(t *testing.T) { + account := &Account{Credentials: map[string]any{"model_mapping": map[string]any{ + "codex-auto-review": "codex-auto-review", "gpt-image-2": "gpt-image-2", "gpt-5.5": "gpt-5.5", + }}} + require.Equal(t, "gpt-5.5", selectResponsesProbeModel(account)) +} diff --git a/backend/internal/service/openai_codex_context_contract.go b/backend/internal/service/openai_codex_context_contract.go index 7583b8ec6d90..c23574834435 100644 --- a/backend/internal/service/openai_codex_context_contract.go +++ b/backend/internal/service/openai_codex_context_contract.go @@ -9,10 +9,10 @@ import ( const ( // This deterministic self-test reports policy, not production model limits. - OfficialCodexContextContractSuccessLine = "CODEX_CONTEXT_CONTRACT|version=3|valid=true|priority=custom,upstream,official,registry|unknown=unchanged|aggregate=group_minimum|sentinel=preserved" + OfficialCodexContextContractSuccessLine = "CODEX_CONTEXT_CONTRACT|version=4|valid=true|priority=custom,official,upstream,registry|unknown=unchanged|aggregate=group_minimum|sentinel=preserved" OfficialCodexContextContractFailureLine = "CODEX_CONTEXT_CONTRACT|valid=false|reason=contract-mismatch" OfficialCodexContextInvalidArgsLine = "CODEX_CONTEXT_CONTRACT|valid=false|reason=invalid-arguments" - officialCodexContextContractSentinel = "codex-context-contract-v3" + officialCodexContextContractSentinel = "codex-context-contract-v4" ) type officialCodexContextContractEnvelope struct { @@ -24,6 +24,8 @@ type officialCodexContextContractModel struct { Slug string `json:"slug"` ContextWindow *int64 `json:"context_window"` MaxContextWindow *int64 `json:"max_context_window"` + MaxInputTokens *int64 `json:"max_input_tokens"` + MaxOutputTokens *int64 `json:"max_output_tokens"` AutoCompactLimit json.RawMessage `json:"auto_compact_token_limit"` Source string `json:"context_capacity_source"` Reason string `json:"context_capacity_reason"` @@ -47,9 +49,9 @@ func VerifyOfficialCodexContextContract() (string, error) { func buildOfficialCodexContextContractFixture() ([]byte, error) { custom := modelContextEvidence{ModelContextSourceCustom, ModelContextCapacity{ContextWindow: 512000, MaxContextWindow: 512000}} - upstream := modelContextEvidence{ModelContextSourceUpstream, ModelContextCapacity{ContextWindow: 64000, MaxContextWindow: 128000}} + upstream := modelContextEvidence{ModelContextSourceUpstream, ModelContextCapacity{ContextWindow: 64000, MaxContextWindow: 128000, MaxInputTokens: 64000, MaxOutputTokens: 8192}} official := modelContextEvidence{ModelContextSourceOfficial, ModelContextCapacity{ContextWindow: 272000, MaxContextWindow: 872000}} - registry := modelContextEvidence{ModelContextSourceRegistry, ModelContextCapacity{ContextWindow: 400000}} + registry := modelContextEvidence{ModelContextSourceRegistry, ModelContextCapacity{ContextWindow: 400000, MaxOutputTokens: 100000}} relay := modelContextEvidence{ModelContextSourceUpstream, ModelContextCapacity{ContextWindow: 1050000}} cases := []struct { slug string @@ -57,12 +59,12 @@ func buildOfficialCodexContextContractFixture() ([]byte, error) { compact int64 }{ {"fixture-custom", resolveModelContextEvidence([]modelContextEvidence{custom, upstream, official, registry}), 9999999}, - {"fixture-upstream", resolveModelContextEvidence([]modelContextEvidence{upstream, official, registry}), 50000}, - {"fixture-official", resolveModelContextEvidence([]modelContextEvidence{official, registry}), 9999999}, + {"fixture-upstream", resolveModelContextEvidence([]modelContextEvidence{upstream, registry}), 50000}, + {"fixture-official", resolveModelContextEvidence([]modelContextEvidence{upstream, registry, official}), 9999999}, {"fixture-registry", resolveModelContextEvidence([]modelContextEvidence{registry}), 9999999}, {"fixture-unknown", resolveModelContextEvidence(nil), 666666}, {"fixture-group", minimumModelContextCapacity([]ResolvedModelContextCapacity{ - resolveModelContextEvidence([]modelContextEvidence{relay, official}), + resolveModelContextEvidence([]modelContextEvidence{relay}), resolveModelContextEvidence([]modelContextEvidence{official}), }), 9999999}, } @@ -72,6 +74,8 @@ func buildOfficialCodexContextContractFixture() ([]byte, error) { "slug": json.RawMessage(fmt.Sprintf("%q", test.slug)), "context_window": json.RawMessage("777000"), "max_context_window": json.RawMessage("888000"), + "max_input_tokens": json.RawMessage("123000"), + "max_output_tokens": json.RawMessage("456000"), "auto_compact_token_limit": json.RawMessage(fmt.Sprintf("%d", test.compact)), "contract_sentinel": json.RawMessage(fmt.Sprintf("%q", officialCodexContextContractSentinel)), "unknown_capability": json.RawMessage(`{"keep":true}`), @@ -95,11 +99,11 @@ func verifyNormalizedOfficialCodexContextContract(body []byte) error { contextWindow, maxWindow int64 }{ {"fixture-custom", "custom", "", "null", 512000, 512000}, - {"fixture-upstream", "upstream", "", "50000", 64000, 128000}, - {"fixture-official", "official", "", "null", 272000, 872000}, + {"fixture-upstream", "upstream", "", "50000", 128000, 128000}, + {"fixture-official", "official", "", "null", 872000, 872000}, {"fixture-registry", "registry", "", "null", 400000, 400000}, {"fixture-unknown", "", "", "666666", 777000, 888000}, - {"fixture-group", "official", "group_minimum", "null", 272000, 872000}, + {"fixture-group", "official", "group_minimum", "null", 872000, 872000}, } if len(envelope.Models) != len(checks) { return errors.New("model fixture count changed") @@ -119,6 +123,9 @@ func verifyNormalizedOfficialCodexContextContract(body []byte) error { !bytes.Equal(bytes.TrimSpace(model.AutoCompactLimit), []byte(check.compact)) { return fmt.Errorf("model %q policy mismatch", check.slug) } + if (check.source == ModelContextSourceOfficial || check.source == ModelContextSourceCustom) && (model.MaxInputTokens != nil || model.MaxOutputTokens != nil) { + return fmt.Errorf("model %q borrowed independent limits from another source", check.slug) + } if model.ContractSentinel != officialCodexContextContractSentinel || !bytes.Equal(bytes.TrimSpace(model.UnknownCapability), []byte(`{"keep":true}`)) { return errors.New("unrelated model capability changed") diff --git a/backend/internal/service/openai_codex_context_contract_test.go b/backend/internal/service/openai_codex_context_contract_test.go index fce64020a83d..a7195db461de 100644 --- a/backend/internal/service/openai_codex_context_contract_test.go +++ b/backend/internal/service/openai_codex_context_contract_test.go @@ -12,7 +12,8 @@ func TestModelContextCapacityContractUsesRuntimePolicyAndProjection(t *testing.T line, err := VerifyOfficialCodexContextContract() require.NoError(t, err) require.Equal(t, OfficialCodexContextContractSuccessLine, line) - require.Contains(t, line, "version=3") + require.Contains(t, line, "version=4") + require.Contains(t, line, "priority=custom,official,upstream,registry") require.NotContains(t, line, "default_context=") } @@ -28,6 +29,7 @@ func TestModelContextCapacityContractFailsClosed(t *testing.T) { {"unsafe compact threshold", strings.Replace(valid, `"auto_compact_token_limit":null`, `"auto_compact_token_limit":9999999`, 1)}, {"unknown capacity invented", strings.Replace(valid, `"context_window":777000`, `"context_window":200000`, 1)}, {"group minimum lost", strings.Replace(valid, `"context_capacity_reason":"group_minimum"`, `"context_capacity_reason":"other"`, 1)}, + {"borrowed official output limit", strings.Replace(valid, `"slug":"fixture-official"`, `"slug":"fixture-official","max_output_tokens":8192`, 1)}, {"sentinel changed", strings.Replace(valid, officialCodexContextContractSentinel, "changed", 1)}, {"unknown capability changed", strings.Replace(valid, `"keep":true`, `"keep":false`, 1)}, } diff --git a/backend/internal/service/openai_codex_context_window_test.go b/backend/internal/service/openai_codex_context_window_test.go index bd7e776dd2b5..0650fdab1458 100644 --- a/backend/internal/service/openai_codex_context_window_test.go +++ b/backend/internal/service/openai_codex_context_window_test.go @@ -19,7 +19,7 @@ func TestCodexContextWindowSyncRoundTrip(t *testing.T) { wantWindow int64 wantMax int64 }{ - {"distinct windows", `"context_window":272000,"max_context_window":872000`, 272000, 872000}, + {"distinct windows", `"context_window":272000,"max_context_window":872000`, 872000, 872000}, {"equal windows", `"context_window":272000,"max_context_window":272000`, 272000, 272000}, {"default only", `"context_window":272000`, 272000, 272000}, {"maximum only", `"max_context_window":872000`, 872000, 872000}, @@ -29,12 +29,12 @@ func TestCodexContextWindowSyncRoundTrip(t *testing.T) { {"maximum below default", `"context_window":272000,"max_context_window":128000`, 128000, 128000}, } { t.Run(tc.name, func(t *testing.T) { - account := codexContextWindowAccount(t, 1, `"context_window":64000`) + account := codexContextWindowAccount(t, 1, "unlisted-model", `"context_window":64000`) upstream := &httpUpstreamRecorder{resp: &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(fmt.Sprintf(`{"models":[{ - "slug":"gpt-6-astra","reasoning":true, + "slug":"unlisted-model","reasoning":true, "supported_reasoning_levels":[{"effort":"high"}], "input_modalities":["text","image"],%s }]}`, tc.fields))), @@ -53,7 +53,7 @@ func TestCodexContextWindowSyncRoundTrip(t *testing.T) { require.NoError(t, err) account.Extra = nil require.NoError(t, json.Unmarshal(persisted, &account.Extra)) - model := codexContextWindowManifest(t, []Account{account}) + model := codexContextWindowManifest(t, []Account{account}, "unlisted-model") require.EqualValues(t, tc.wantWindow, model["context_window"]) require.EqualValues(t, tc.wantMax, model["max_context_window"]) }) @@ -68,33 +68,33 @@ func TestCodexContextWindowAccountIntersection(t *testing.T) { wantMax int64 }{ { - "independent minima", + "minimum effective maximum", []string{`"context_window":272000,"max_context_window":872000`, `"context_window":300000,"max_context_window":512000`}, - 272000, 512000, + 512000, 512000, }, { - "legacy snapshot caps maximum", + "context-only snapshot caps maximum", []string{`"context_window":272000,"max_context_window":872000`, `"context_window":300000`}, - 272000, 300000, + 300000, 300000, }, { - "legacy snapshot alone", + "context-only snapshot alone", []string{`"context_window":272000`}, 272000, 272000, }, { "invalid maximum uses known default", []string{`"context_window":272000,"max_context_window":872000`, `"context_window":300000,"max_context_window":-1`}, - 272000, 300000, + 300000, 300000, }, } { t.Run(tc.name, func(t *testing.T) { accounts := make([]Account, len(tc.accounts)) for i, fields := range tc.accounts { - accounts[i] = codexContextWindowAccount(t, int64(i+1), fields) + accounts[i] = codexContextWindowAccount(t, int64(i+1), "unlisted-model", fields) } for range 2 { - model := codexContextWindowManifest(t, accounts) + model := codexContextWindowManifest(t, accounts, "unlisted-model") require.EqualValues(t, tc.wantWindow, model["context_window"]) require.EqualValues(t, tc.wantMax, model["max_context_window"]) for i, j := 0, len(accounts)-1; i < j; i, j = i+1, j-1 { @@ -115,12 +115,12 @@ func TestCodexContextWindowRegistryEnrichment(t *testing.T) { wantPublicMax int64 wantSource string }{ - {"preserve upstream limits", `"context_window":272000,"max_context_window":872000`, 272000, 872000, 272000, 872000, "upstream"}, - {"upstream default without maximum", `"context_window":272000`, 272000, 0, 272000, 272000, "upstream"}, - {"registry supplies missing limits", `"description":"Model without context metadata"`, 1050000, 1050000, 272000, 872000, "official"}, + {"preserve upstream limits", `"context_window":272000,"max_context_window":872000`, 272000, 872000, 1050000, 1050000, "official"}, + {"upstream default without maximum", `"context_window":272000`, 272000, 0, 1050000, 1050000, "official"}, + {"registry supplies missing limits", `"description":"Model without context metadata"`, 1050000, 1050000, 1050000, 1050000, "official"}, } { t.Run(tc.name, func(t *testing.T) { - account := codexContextWindowAccount(t, 1, `"context_window":64000`) + account := codexContextWindowAccount(t, 1, "gpt-6-astra", `"context_window":64000`) account.Type = AccountTypeAPIKey account.Credentials["api_key"] = "test-key" account.Credentials["base_url"] = "https://provider.example/v1" @@ -148,13 +148,11 @@ func TestCodexContextWindowRegistryEnrichment(t *testing.T) { require.NoError(t, err) require.Empty(t, catalog.Warnings) require.Len(t, upstream.requests, 2) - // Synchronization preserves the upstream observation and the public - // projection follows it on every host. Registry enrichment is only a - // reference below the release catalog, so a silent upstream is - // advertised with the catalog's Codex subscription values. + // Synchronization preserves the upstream observation, while the public + // projection follows the applicable official API specification. require.EqualValues(t, tc.wantWindow, catalog.Metadata["gpt-6-astra"].ContextWindow) require.EqualValues(t, tc.wantMax, catalog.Metadata["gpt-6-astra"].MaxContextWindow) - model := codexContextWindowManifest(t, []Account{account}) + model := codexContextWindowManifest(t, []Account{account}, "gpt-6-astra") require.EqualValues(t, tc.wantPublicWindow, model["context_window"]) require.EqualValues(t, tc.wantPublicMax, model["max_context_window"]) require.Equal(t, tc.wantSource, model["context_capacity_source"]) @@ -162,33 +160,37 @@ func TestCodexContextWindowRegistryEnrichment(t *testing.T) { } } -func codexContextWindowAccount(t *testing.T, id int64, fields string) Account { +func codexContextWindowAccount(t *testing.T, id int64, model, fields string) Account { t.Helper() account := Account{ ID: id, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ "access_token": "test-token", - "model_mapping": map[string]any{"gpt-6-astra": "gpt-6-astra"}, + "model_mapping": map[string]any{model: model}, }, } err := json.Unmarshal([]byte(fmt.Sprintf(`{"upstream_model_metadata":{ - "source":"upstream","models":{"gpt-6-astra":{ - "id":"gpt-6-astra","reasoning":true, + "source":"upstream","models":{%q:{ + "id":%q,"reasoning":true, "supported_reasoning_levels":["high"],"input_modalities":["text","image"],%s }} - }}`, fields)), &account.Extra) + }}`, model, model, fields)), &account.Extra) require.NoError(t, err) + snapshot := account.GetUpstreamModelMetadataSnapshot() + require.NotNil(t, snapshot) + snapshot.SourceIdentity = UpstreamModelMetadataSourceIdentity(&account) + account.SetUpstreamModelMetadataSnapshot(*snapshot) return account } -func codexContextWindowManifest(t *testing.T, accounts []Account) map[string]any { +func codexContextWindowManifest(t *testing.T, accounts []Account, model string) map[string]any { t.Helper() const groupID int64 = 7042 svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ groupID: accounts, }}} body, err := svc.BuildCodexModelsManifestForGroup( - context.Background(), &Group{ID: groupID, Platform: PlatformOpenAI}, "", []string{"gpt-6-astra"}, + context.Background(), &Group{ID: groupID, Platform: PlatformOpenAI}, "", []string{model}, ) require.NoError(t, err) models := decodeCodexManifestModels(t, body) diff --git a/backend/internal/service/openai_codex_model_metadata.go b/backend/internal/service/openai_codex_model_metadata.go index 9fc384ef368a..f4d6c3fb2bb6 100644 --- a/backend/internal/service/openai_codex_model_metadata.go +++ b/backend/internal/service/openai_codex_model_metadata.go @@ -49,7 +49,7 @@ func accountCodexToolCapabilities(account *Account, modelID string) map[string]j if account == nil { return capabilities } - if metadata, ok := account.GetUpstreamModelMetadata(modelID); ok { + if metadata, ok := account.CurrentUpstreamModelMetadata(modelID); ok { applyCodexToolCapabilities(capabilities, metadata.CodexToolCapabilities, true) } if defaults, ok := gpt6APIModelMetadata(account, modelID); ok { @@ -194,7 +194,7 @@ func groupCodexModelMetadata( if strings.TrimSpace(lookupModel) != modelID { publicAlias = true } - metadata, ok := account.GetUpstreamModelMetadata(lookupModel) + metadata, ok := account.CurrentUpstreamModelMetadata(lookupModel) if defaults, known := gpt6AccountModelMetadata(account, lookupModel); known { metadata, _ = mergeUpstreamModelMetadata(metadata, defaults) ok = true diff --git a/backend/internal/service/openai_codex_model_metadata_test.go b/backend/internal/service/openai_codex_model_metadata_test.go index d761ffaf730b..8016e6b1b9f1 100644 --- a/backend/internal/service/openai_codex_model_metadata_test.go +++ b/backend/internal/service/openai_codex_model_metadata_test.go @@ -20,7 +20,7 @@ func TestAstraUltraCatalogPreservesWorkflowMetadata(t *testing.T) { account := Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ "base_url": "https://relay.example/v1", "model_mapping": map[string]any{"public-astra": "gpt-6-astra"}, }} - account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: metadata}) + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{SourceIdentity: UpstreamModelMetadataSourceIdentity(&account), Models: metadata}) body, err := buildCodexModelsManifestForAccounts(PlatformOpenAI, []string{"public-astra"}, []Account{account}, nil, nil, true) require.NoError(t, err) model := decodeCodexManifestModels(t, body)[0] @@ -94,7 +94,7 @@ func TestAstraCodexToolCapabilitiesUseAccountScopeAndSharedDeclarations(t *testi } }) } - custom.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + custom.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{SourceIdentity: UpstreamModelMetadataSourceIdentity(&custom), Models: map[string]UpstreamModelMetadata{ "gpt-6-astra": {CodexToolCapabilities: map[string]json.RawMessage{ "supports_search_tool": json.RawMessage("true"), "apply_patch_tool_type": json.RawMessage("null"), "comp_hash": json.RawMessage(`"3000"`), "tool_mode": json.RawMessage("null"), "use_responses_lite": json.RawMessage("false"), @@ -111,7 +111,7 @@ func TestAstraCodexToolCapabilitiesUseAccountScopeAndSharedDeclarations(t *testi func TestAstraCodexToolCapabilitiesPreserveLiveNullAndFalse(t *testing.T) { account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://api.openai.com/v1"}} - account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{SourceIdentity: UpstreamModelMetadataSourceIdentity(account), Models: map[string]UpstreamModelMetadata{ "gpt-6-astra": {CodexToolCapabilities: map[string]json.RawMessage{ "supports_search_tool": json.RawMessage("true"), "apply_patch_tool_type": json.RawMessage(`"freeform"`), "comp_hash": json.RawMessage(`"3000"`), "tool_mode": json.RawMessage(`"code_mode_only"`), @@ -171,6 +171,10 @@ func TestBuildCodexModelsManifestForGroupUsesSyncedNonCapacityMetadataAndRegistr }, }, } + stored := account.GetUpstreamModelMetadataSnapshot() + require.NotNil(t, stored) + stored.SourceIdentity = UpstreamModelMetadataSourceIdentity(&account) + account.SetUpstreamModelMetadataSnapshot(*stored) svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ groupID: {account}, }}, compositeResolver: NewCompositeRouteResolver(&groupCapacityRouteRepo{})} @@ -236,7 +240,7 @@ func TestBuildCodexModelsManifestForGroupMergesOpenCodeGoSyncedMetadata(t *testi "model_mapping": map[string]any{publicModel: upstreamModel}, }, } - account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{SourceIdentity: UpstreamModelMetadataSourceIdentity(&account), Models: map[string]UpstreamModelMetadata{ upstreamModel: { ID: upstreamModel, Reasoning: &reasoning, @@ -279,7 +283,7 @@ func TestBuildCodexModelsManifestForGroupUsesNoneForExplicitNonReasoningMetadata "model_mapping": map[string]any{"company-coding-model": "company-coding-model"}, }, } - account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{SourceIdentity: UpstreamModelMetadataSourceIdentity(&account), Models: map[string]UpstreamModelMetadata{ "company-coding-model": { ID: "company-coding-model", Reasoning: &reasoning, InputModalities: []string{"text"}, ContextWindow: 64_000, @@ -390,7 +394,7 @@ func TestBuildCodexModelsManifestForGroupIntersectsSyncedAccountMetadata(t *test "model_mapping": map[string]any{"shared-model": "shared-model"}, }, } - account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{SourceIdentity: UpstreamModelMetadataSourceIdentity(&account), Models: map[string]UpstreamModelMetadata{ "shared-model": { ID: "shared-model", Reasoning: &reasoning, SupportedReasoningLevels: levels, @@ -435,7 +439,7 @@ func TestBuildCodexModelsManifestForGroupIntersectsDifferentMappedTargetsWithout "model_mapping": map[string]any{"my-coder": target}, }, } - account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{SourceIdentity: UpstreamModelMetadataSourceIdentity(&account), Models: map[string]UpstreamModelMetadata{ target: { ID: target, DisplayName: displayName, Description: description, Reasoning: &reasoning, SupportedReasoningLevels: levels, @@ -480,8 +484,8 @@ func TestBuildCodexModelsManifestForGroupIntersectsDifferentMappedTargetsWithout require.Equal(t, "Custom model routed through Sub2API.", models[0]["description"]) require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0])) require.Equal(t, []any{"text"}, models[0]["input_modalities"]) - require.EqualValues(t, 272_000, models[0]["context_window"], "each account's own declaration wins before the group minimum") - require.Equal(t, "upstream", models[0]["context_capacity_source"]) + require.EqualValues(t, 1_000_000, models[0]["context_window"], "resolve each real target from its official API specification before taking the group minimum") + require.Equal(t, "official", models[0]["context_capacity_source"]) } } @@ -568,8 +572,8 @@ func TestBuildCodexModelsManifestForGroupIgnoresPersistentlyDisabledMappedAccoun models := decodeCodexManifestModels(t, body) require.Len(t, models, 1) require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) - require.EqualValues(t, 272_000, models[0]["context_window"], "an unschedulable active account still bounds capacity; only capability intersection skips it") - require.Equal(t, "upstream", models[0]["context_capacity_source"]) + require.EqualValues(t, 1_000_000, models[0]["context_window"], "an unschedulable active account still bounds capacity using its official specification; only capability intersection skips it") + require.Equal(t, "official", models[0]["context_capacity_source"]) } func TestBuildCodexModelsManifestForGroupKeepsNonCapacityFallbackWhenAvailabilityLookupFails(t *testing.T) { @@ -614,7 +618,7 @@ func TestBuildCodexModelsManifestForGroupKeepsCrossPlatformAliasAmbiguityClosed( ID: id, Platform: platform, Type: AccountTypeAPIKey, Credentials: map[string]any{"model_mapping": map[string]any{"shared-alias": target}}, } - account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{SourceIdentity: UpstreamModelMetadataSourceIdentity(&account), Models: map[string]UpstreamModelMetadata{ target: { ID: target, DisplayName: target, Reasoning: &reasoning, SupportedReasoningLevels: []string{"low", "high"}, @@ -656,7 +660,7 @@ func TestBuildCodexModelsManifestForGroupDoesNotAdvertiseNoneWhenAccountReasonin "model_mapping": map[string]any{"shared-model": "shared-model"}, }, } - account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{SourceIdentity: UpstreamModelMetadataSourceIdentity(&account), Models: map[string]UpstreamModelMetadata{ "shared-model": metadata, }}) return account @@ -690,7 +694,7 @@ func TestAstraCodexToolCapabilitiesFollowAPIKeyAlias(t *testing.T) { account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ "base_url": "https://relay.example/v1", "model_mapping": map[string]any{"my-astra": "gpt-6-astra"}, }} - account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{SourceIdentity: UpstreamModelMetadataSourceIdentity(account), Models: map[string]UpstreamModelMetadata{ "gpt-6-astra": {CodexToolCapabilities: map[string]json.RawMessage{ "supports_search_tool": json.RawMessage("true"), "apply_patch_tool_type": json.RawMessage(`"freeform"`), "use_responses_lite": json.RawMessage("false"), @@ -715,7 +719,7 @@ func TestAstraCodexToolCapabilitiesKeepAPIKeyResponsesLiteGuard(t *testing.T) { account := Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ "base_url": "https://relay.example/v1", "model_mapping": map[string]any{"my-astra": "gpt-6-astra"}, }} - account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{SourceIdentity: UpstreamModelMetadataSourceIdentity(&account), Models: map[string]UpstreamModelMetadata{ "gpt-6-astra": {CodexToolCapabilities: map[string]json.RawMessage{"use_responses_lite": json.RawMessage("true")}}, }}) body, err := buildCodexModelsManifestForAccounts(PlatformOpenAI, []string{"my-astra"}, []Account{account}, nil, nil, true) diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index ddb299412c85..3298435b56af 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -330,12 +330,6 @@ func openAIConfiguredCodexModelIDsForGroup(accounts []Account, group *Group) []s const ( configuredCodexModelPriority = 50 configuredCodexCustomDescription = "Custom model routed through Sub2API." - configuredCodexFallbackContext = 272_000 - configuredCodexDeepSeekV4Context = 1_000_000 - configuredCodexGrokContext = 500_000 - configuredCodexGrokBuildContext = 256_000 - configuredCodexGPT56MaxContext = 872_000 - configuredCodexGPT6AstraContext = 1_050_000 configuredCodexToolOutputMaxTokens = 10_000 ) @@ -401,11 +395,11 @@ type configuredCodexModelDescriptor struct { TruncationPolicy configuredCodexTruncationPolicy `json:"truncation_policy"` SupportsImageDetailOriginal bool `json:"supports_image_detail_original"` SupportsParallelToolCalls bool `json:"supports_parallel_tool_calls"` - ContextWindow int64 `json:"context_window"` - MaxContextWindow int64 `json:"max_context_window"` + ContextWindow int64 `json:"context_window,omitempty"` + MaxContextWindow int64 `json:"max_context_window,omitempty"` AutoCompactTokenLimit any `json:"auto_compact_token_limit"` CompHash any `json:"comp_hash"` - EffectiveContextWindowPercent int64 `json:"effective_context_window_percent"` + EffectiveContextWindowPercent int64 `json:"effective_context_window_percent,omitempty"` ExperimentalSupportedTools []string `json:"experimental_supported_tools"` InputModalities []string `json:"input_modalities"` SupportsSearchTool bool `json:"supports_search_tool"` @@ -446,9 +440,6 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr DefaultReasoningSummary: "auto", WebSearchToolType: "text", TruncationPolicy: configuredCodexTruncationPolicy{Mode: "bytes", Limit: configuredCodexToolOutputMaxTokens}, - ContextWindow: configuredCodexFallbackContext, - MaxContextWindow: configuredCodexFallbackContext, - EffectiveContextWindowPercent: 95, ExperimentalSupportedTools: []string{}, InputModalities: []string{"text"}, } @@ -464,16 +455,12 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr {Effort: "max", Description: "Maximum reasoning depth for complex tasks"}, } descriptor.SupportsParallelToolCalls = true - descriptor.ContextWindow = configuredCodexDeepSeekV4Context - descriptor.MaxContextWindow = configuredCodexDeepSeekV4Context } if isGrokCodexModel(modelID) { descriptor.DisplayName = grokCodexDisplayName(modelID) descriptor.Description = "Grok coding and reasoning model routed through Sub2API." descriptor.SupportsParallelToolCalls = true - descriptor.ContextWindow = grokCodexContextWindow(modelID) - descriptor.MaxContextWindow = descriptor.ContextWindow if grokCodexSupportsReasoningEffort(modelID) { defaultReasoningLevel := "high" descriptor.DefaultReasoningLevel = &defaultReasoningLevel @@ -482,10 +469,6 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr } if isClaudeCodexModel(modelID) { - if claude.IsOpus55(modelID) { - descriptor.ContextWindow = 1_000_000 - descriptor.MaxContextWindow = 1_000_000 - } descriptor.DisplayName = claudeCodexDisplayName(modelID) descriptor.Description = "Claude coding and reasoning model routed through Sub2API." descriptor.SupportsParallelToolCalls = true @@ -510,11 +493,6 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr descriptor.SupportedReasoningLevels = configuredCodexGPTReasoningLevels(modelID) descriptor.DefaultReasoningSummary = "none" descriptor.TruncationPolicy = configuredCodexTruncationPolicy{Mode: "tokens", Limit: configuredCodexToolOutputMaxTokens} - // GPT-6 Sol/Luna retain the existing 5.6 Codex window as an offline - // compatibility template; live account metadata remains authoritative. - if isOpenAIGPT56Model(modelID) || openai.IsGPT6SolOrLunaModelSpelling(modelID) { - descriptor.MaxContextWindow = configuredCodexGPT56MaxContext - } if isOpenAIGPT6SolOrLunaModel(modelID) { descriptor.MultiAgentVersion = "v2" descriptor.MinimalClientVersion = openai.GPT6CodexMinimumClientVersion @@ -532,8 +510,6 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr multiAgentEffort := "xhigh" descriptor.MultiAgentReasoningEffort = &multiAgentEffort descriptor.MultiAgentVersion = "v2" - descriptor.ContextWindow = configuredCodexGPT6AstraContext - descriptor.MaxContextWindow = configuredCodexGPT6AstraContext } } if SupportsVerbosity(modelID) { @@ -807,14 +783,6 @@ func grokDefaultDisplayName(modelID string) string { return "" } -func grokCodexContextWindow(modelID string) int64 { - normalized := strings.ToLower(xai.StripGrokProviderPrefix(strings.TrimSpace(modelID))) - if strings.HasPrefix(normalized, "grok-build") { - return configuredCodexGrokBuildContext - } - return configuredCodexGrokContext -} - func isClaudeCodexModel(modelID string) bool { platform, detected := DetectModelPlatform(modelID) return detected && platform == PlatformAnthropic @@ -1282,7 +1250,7 @@ func accountCodexModelSupportsImageInput(account *Account, upstreamModel string) } switch account.Platform { case PlatformOpenAI, PlatformDeepseek, PlatformOpenCodeGo: - if metadata, ok := account.GetUpstreamModelMetadata(upstreamModel); ok { + if metadata, ok := account.CurrentUpstreamModelMetadata(upstreamModel); ok { if modalities := normalizeCodexInputModalities(metadata.InputModalities); len(modalities) > 0 { // Official GPT-6 Astra metadata briefly shipped with a stale // text-only modality list. Keep explicit provider metadata @@ -2248,7 +2216,7 @@ func convertOpenAIModelListToCodexManifestForAccount(body []byte, account *Accou if encoded, err := json.Marshal(entry); err == nil && json.Unmarshal(encoded, &rawEntry) == nil { metadata = upstreamMetadataFromCapabilityEntry(id, rawEntry) } - if snapshot, exists := account.GetUpstreamModelMetadata(capabilityModel); exists { + if snapshot, exists := account.CurrentUpstreamModelMetadata(capabilityModel); exists { metadata, _ = mergeUpstreamModelMetadata(metadata, snapshot) } metadata, _ = mergeUpstreamModelMetadata(metadata, defaults) @@ -2325,7 +2293,7 @@ func (s *OpenAIGatewayService) CompleteAPIKeyCodexModelsManifestForClient(manife } func applySyncedAPIKeyCodexModelMetadata(body []byte, account *Account, overwriteLocalDefaults bool) ([]byte, error) { - snapshot := account.GetUpstreamModelMetadataSnapshot() + snapshot := upstreamMetadataForSource(account, UpstreamModelMetadataSourceIdentity(account)) if snapshot == nil || len(snapshot.Models) == 0 { return body, nil } diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index b864deaaa9d6..bfd5cf3b8823 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -175,7 +175,7 @@ func newCodexCatalogMappedAccount( ContextWindow: 1_000_000, } } - account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Source: "upstream", Models: models}) + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Source: "upstream", SourceIdentity: UpstreamModelMetadataSourceIdentity(&account), Models: models}) return account } @@ -270,8 +270,8 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t deepSeek := newConfiguredCodexModelDescriptor("deepseek-v4-pro") require.Equal(t, "DeepSeek V4 Pro", deepSeek.DisplayName) - require.Equal(t, int64(1_000_000), deepSeek.ContextWindow) - require.Equal(t, int64(1_000_000), deepSeek.MaxContextWindow) + require.Zero(t, deepSeek.ContextWindow, "synthetic descriptors must not invent a context window") + require.Zero(t, deepSeek.MaxContextWindow, "synthetic descriptors must not invent a maximum context window") require.NotNil(t, deepSeek.DefaultReasoningLevel) require.Equal(t, "high", *deepSeek.DefaultReasoningLevel) require.Equal(t, []configuredCodexReasoningLevel{ @@ -284,8 +284,8 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t grok := newConfiguredCodexModelDescriptor("grok-4.6") require.Equal(t, "Grok 4.6", grok.DisplayName) - require.Equal(t, int64(500_000), grok.ContextWindow) - require.Equal(t, int64(500_000), grok.MaxContextWindow) + require.Zero(t, grok.ContextWindow, "synthetic descriptors must not invent a context window") + require.Zero(t, grok.MaxContextWindow, "synthetic descriptors must not invent a maximum context window") require.NotNil(t, grok.DefaultReasoningLevel) require.Equal(t, "high", *grok.DefaultReasoningLevel) require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromConfiguredCodexLevels(grok.SupportedReasoningLevels)) @@ -343,7 +343,8 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t require.True(t, gpt56.SupportsParallelToolCalls) require.True(t, gpt56.SupportVerbosity) require.Equal(t, []string{"text"}, gpt56.InputModalities) - require.Equal(t, int64(872_000), gpt56.MaxContextWindow) + require.Zero(t, gpt56.ContextWindow, "GPT capacity is resolved from account evidence") + require.Zero(t, gpt56.MaxContextWindow, "GPT capacity is resolved from account evidence") require.Equal(t, configuredCodexTruncationPolicy{Mode: "tokens", Limit: 10_000}, gpt56.TruncationPolicy) require.NotNil(t, gpt56.DefaultVerbosity) require.Equal(t, "low", *gpt56.DefaultVerbosity) @@ -374,15 +375,15 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t require.True(t, isOpenAICodexReasoningGPTModel("openai/gpt-6-astra")) require.True(t, isOpenAIGPT6AstraModel("gpt-6-astra-2026-09-01")) require.False(t, isOpenAIGPT6AstraModel("gpt-6-other")) - require.Equal(t, int64(1_050_000), gpt6Astra.ContextWindow) - require.Equal(t, int64(1_050_000), gpt6Astra.MaxContextWindow) + require.Zero(t, gpt6Astra.ContextWindow, "GPT capacity is resolved from account evidence") + require.Zero(t, gpt6Astra.MaxContextWindow, "GPT capacity is resolved from account evidence") gpt6 := newConfiguredCodexModelDescriptor("gpt-6") require.Equal(t, "GPT-6 (Astra)", gpt6.DisplayName) require.True(t, strings.HasPrefix(strings.TrimSpace(gpt6.ModelMessages.InstructionsTemplate), "You are Codex, an agent based on GPT-6.")) require.Equal(t, []string{"low", "medium", "high", "xhigh", "max", "ultra"}, effortsFromConfiguredCodexLevels(gpt6.SupportedReasoningLevels)) require.NotNil(t, gpt6.MultiAgentReasoningEffort) require.Equal(t, "xhigh", *gpt6.MultiAgentReasoningEffort) - require.Equal(t, int64(1_050_000), gpt6.ContextWindow) + require.Zero(t, gpt6.ContextWindow, "GPT capacity is resolved from account evidence") gpt55 := newConfiguredCodexModelDescriptor("gpt-5.5") require.Equal(t, "GPT-5.5", gpt55.DisplayName) @@ -408,7 +409,7 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t custom := newConfiguredCodexModelDescriptor("company-coding-model") require.Equal(t, "company-coding-model", custom.DisplayName) - require.Equal(t, int64(272_000), custom.ContextWindow) + require.Zero(t, custom.ContextWindow, "unknown custom models must not receive a fabricated capacity") require.NotNil(t, custom.DefaultReasoningLevel) require.Equal(t, "none", *custom.DefaultReasoningLevel) require.Equal(t, []configuredCodexReasoningLevel{ @@ -1237,7 +1238,7 @@ func TestBuildGroupConfiguredCodexModelsManifestUsesAdministratorConfiguration(t }, }, } - arkAccount.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + arkAccount.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{SourceIdentity: UpstreamModelMetadataSourceIdentity(&arkAccount), Models: map[string]UpstreamModelMetadata{ "glm-5.3": { ID: "glm-5.3", DisplayName: "GLM 5.3", @@ -1321,6 +1322,8 @@ func TestBuildGroupConfiguredCodexModelsManifestUsesResolvedTargetsForOrdinaryGP } for _, slug := range []string{"gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"} { require.NotContains(t, bySlug[slug], "context_capacity_source", "public GPT aliases must not identify unknown upstream targets") + require.NotContains(t, bySlug[slug], "context_window", "a provider template cannot establish the unknown target's capacity") + require.NotContains(t, bySlug[slug], "max_context_window") require.Nil(t, bySlug[slug]["auto_compact_token_limit"]) } notModified, configured, err := svc.BuildGroupConfiguredCodexModelsManifest( @@ -1335,6 +1338,75 @@ func TestBuildGroupConfiguredCodexModelsManifestUsesResolvedTargetsForOrdinaryGP require.Equal(t, manifest.ETag, notModified.ETag) } +func TestConfiguredCodexManifestCapacityComesFromResolver(t *testing.T) { + t.Parallel() + + const groupID int64 = 789 + const publicModel = "gpt-5.6-sol" + for _, test := range []struct { + name string + target string + override int64 + wantOutput int64 + }{ + {name: "unknown", target: "unknown-provider-model"}, + {name: "unknown with custom", target: "unknown-provider-model", override: 640000}, + {name: "known with custom", target: "gpt-5.5", override: 640000, wantOutput: 128000}, + } { + for _, path := range []string{"configured", "generic", "merged", "converted"} { + t.Run(test.name+"/"+path, func(t *testing.T) { + account := newCodexModelsAPIKeyTestAccount("https://provider.example/v1") + account.Credentials["model_mapping"] = map[string]any{publicModel: test.target} + if test.override > 0 { + account.Extra = map[string]any{ModelContextOverridesExtraKey: map[string]int64{test.target: test.override}} + } + repo := codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{groupID: {*account}}} + svc := &OpenAIGatewayService{accountRepo: repo} + group := &Group{ID: groupID, Platform: PlatformOpenAI} + var body []byte + switch path { + case "configured": + manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest(context.Background(), group, "") + require.NoError(t, err) + require.True(t, configured) + body = manifest.Body + case "generic": + var err error + body, err = (&GatewayService{accountRepo: repo}).BuildCodexModelsManifestForGroup(context.Background(), group, "", []string{publicModel}) + require.NoError(t, err) + case "merged", "converted": + manifest := &OpenAIModelsResponse{Body: []byte(`{"models":[]}`)} + if path == "converted" { + manifest.Body = convertOpenAIModelListToCodexManifestForAccount([]byte(`{"data":[{"id":"`+publicModel+`"}]}`), account) + require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account)) + } + require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, manifest, "")) + body = manifest.Body + } + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + model := models[0] + require.Equal(t, publicModel, model["slug"]) + require.NotContains(t, model, "max_input_tokens", "no independent input limit was declared") + if test.override == 0 { + require.NotContains(t, model, "context_window") + require.NotContains(t, model, "max_context_window") + require.NotContains(t, model, "context_capacity_source") + } else { + require.EqualValues(t, test.override, model["context_window"]) + require.EqualValues(t, test.override, model["max_context_window"]) + require.Equal(t, ModelContextSourceCustom, model["context_capacity_source"]) + } + if test.wantOutput == 0 { + require.NotContains(t, model, "max_output_tokens") + } else { + require.EqualValues(t, test.wantOutput, model["max_output_tokens"]) + } + }) + } + } +} + func TestMergeGroupConfiguredCodexModelsAppliesReferenceCapacityAfterFinalMerge(t *testing.T) { t.Parallel() @@ -1363,8 +1435,9 @@ func TestMergeGroupConfiguredCodexModelsAppliesReferenceCapacityAfterFinalMerge( bySlug[slug] = model } for _, slug := range []string{"gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"} { - require.EqualValues(t, 272_000, bySlug[slug]["context_window"], "GPT references are the Codex subscription values for API keys too") - require.EqualValues(t, 872_000, bySlug[slug]["max_context_window"]) + require.EqualValues(t, 1_050_000, bySlug[slug]["context_window"], "official API specifications supersede Codex subscription declarations") + require.EqualValues(t, 1_050_000, bySlug[slug]["max_context_window"]) + require.EqualValues(t, 128_000, bySlug[slug]["max_output_tokens"]) require.Equal(t, "official", bySlug[slug]["context_capacity_source"]) require.Nil(t, bySlug[slug]["auto_compact_token_limit"], "final projection must not invent a fixed 900K compaction threshold") } @@ -1509,8 +1582,8 @@ func TestBuildGroupConfiguredCodexModelsManifestIgnoresPersistentlyDisabledMappe require.Equal(t, "my-coder", models[0]["slug"]) require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromManifestModel(t, models[0])) require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) - require.EqualValues(t, 272_000, models[0]["context_window"], "an unschedulable active account still bounds capacity; only capability intersection skips it") - require.Equal(t, "upstream", models[0]["context_capacity_source"]) + require.EqualValues(t, 1_000_000, models[0]["context_window"], "an unschedulable active account still bounds capacity using its official specification; only capability intersection skips it") + require.Equal(t, "official", models[0]["context_capacity_source"]) } // Scenario: 没有管理员模型配置时保留现有上游发现路径。 @@ -2142,7 +2215,7 @@ func TestFetchCodexModelsManifestAPIKeyConvertsStandardOpenAIModelList(t *testin for _, model := range completeModels { require.NotEmpty(t, model.DisplayName) require.NotEmpty(t, model.ModelMessages.InstructionsTemplate) - require.Positive(t, model.ContextWindow) + require.Zero(t, model.ContextWindow, "a converted descriptor without upstream capacity must omit it") } officialModels := decodeCodexManifestModels(t, manifest.Body) require.Len(t, officialModels, 2) @@ -3220,7 +3293,7 @@ func TestCompleteAPIKeyCodexModelsManifestForClientFillsMissingProviderFieldsWit reasoning := true account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1") - account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{SourceIdentity: UpstreamModelMetadataSourceIdentity(account), Models: map[string]UpstreamModelMetadata{ "provider-model": { ID: "provider-model", DisplayName: "Synced Display", Description: "Synced description", Reasoning: &reasoning, DefaultReasoningLevel: "ultra", @@ -3334,7 +3407,7 @@ func TestCompleteAPIKeyCodexModelsManifestForClientUsesCurrentSnapshotForCachedN reasoning := true account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1") setSnapshot := func(displayName string, contextWindow int64) { - account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{SourceIdentity: UpstreamModelMetadataSourceIdentity(account), Models: map[string]UpstreamModelMetadata{ "provider-reasoner": { ID: "provider-reasoner", DisplayName: displayName, Reasoning: &reasoning, DefaultReasoningLevel: "ultra", @@ -3436,14 +3509,14 @@ func TestCompleteAPIKeyCodexModelsManifestForClientDoesNotApplyLegacyFixedSolCon require.True(t, ok) bySlug[slug] = model } - require.EqualValues(t, 272_000, bySlug["gpt-5.6-sol"]["context_window"]) - require.EqualValues(t, 872_000, bySlug["gpt-5.6-sol"]["max_context_window"]) + require.NotContains(t, bySlug["gpt-5.6-sol"], "context_window") + require.NotContains(t, bySlug["gpt-5.6-sol"], "max_context_window") require.Nil(t, bySlug["gpt-5.6-sol"]["auto_compact_token_limit"]) - require.EqualValues(t, 272_000, bySlug["gpt-5.6-terra"]["context_window"]) - require.EqualValues(t, 872_000, bySlug["gpt-5.6-terra"]["max_context_window"]) + require.NotContains(t, bySlug["gpt-5.6-terra"], "context_window") + require.NotContains(t, bySlug["gpt-5.6-terra"], "max_context_window") require.Nil(t, bySlug["gpt-5.6-terra"]["auto_compact_token_limit"]) - require.EqualValues(t, 272_000, bySlug["gpt-5.6-luna"]["context_window"]) - require.EqualValues(t, 872_000, bySlug["gpt-5.6-luna"]["max_context_window"]) + require.NotContains(t, bySlug["gpt-5.6-luna"], "context_window") + require.NotContains(t, bySlug["gpt-5.6-luna"], "max_context_window") require.Nil(t, bySlug["gpt-5.6-luna"]["auto_compact_token_limit"]) } @@ -3455,7 +3528,7 @@ func TestCompleteAPIKeyCodexModelsManifestForClientUsesSyncedMetadataForConverte const groupID int64 = 84 reasoning := true account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1") - account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{SourceIdentity: UpstreamModelMetadataSourceIdentity(account), Models: map[string]UpstreamModelMetadata{ "future-reasoner": { ID: "future-reasoner", DisplayName: "Future Reasoner", Description: "Synced upstream capability", Reasoning: &reasoning, DefaultReasoningLevel: "ultra", @@ -3565,8 +3638,8 @@ func TestConvertOpenAIModelListToCompleteCodexManifest(t *testing.T) { requireCompleteConfiguredCodexModel(t, models[1], "deepseek-v4-pro") require.Equal(t, "DeepSeek V4 Flash", models[0]["display_name"]) require.Equal(t, "DeepSeek V4 Pro", models[1]["display_name"]) - require.EqualValues(t, 1_000_000, models[0]["context_window"]) - require.EqualValues(t, 1_000_000, models[1]["context_window"]) + require.NotContains(t, models[0], "context_window") + require.NotContains(t, models[1], "context_window") } func TestFetchCodexModelsManifestAPIKeyCacheSurvivesClientMutation(t *testing.T) { diff --git a/backend/internal/service/openai_compat_model.go b/backend/internal/service/openai_compat_model.go index c0b87bf6c0d4..c5e16d26b374 100644 --- a/backend/internal/service/openai_compat_model.go +++ b/backend/internal/service/openai_compat_model.go @@ -64,6 +64,9 @@ func splitOpenAICompatReasoningModel(model string) (normalizedModel string, reas // distinct max level may retain max; native parameter normalization is not a // capability check. func openAICompatAnthropicReasoningEffort(req *apicompat.AnthropicRequest, upstreamModel, convertedEffort string) string { + if convertedEffort == "none" { + return convertedEffort + } if req == nil || req.OutputConfig == nil || !strings.EqualFold(strings.TrimSpace(req.OutputConfig.Effort), "max") { return convertedEffort } diff --git a/backend/internal/service/openai_compat_model_test.go b/backend/internal/service/openai_compat_model_test.go index d597e2118d46..a5d6f7d30467 100644 --- a/backend/internal/service/openai_compat_model_test.go +++ b/backend/internal/service/openai_compat_model_test.go @@ -314,6 +314,31 @@ func TestForwardAsAnthropic_PreservesMaxForFinalGPT56ResponsesModel(t *testing.T } } +func TestForwardAsAnthropic_DisabledThinkingOverridesMaxForResponses(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"sol","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"thinking":{"type":"disabled"},"output_config":{"effort":"max"},"stream":false}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: openAICompatSSECompletedResponse("resp_no_thinking", "gpt-5.6-sol")} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := rawGPT56ResponsesOAuthAccount("sol", "gpt-5.6-sol") + + result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "none", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String()) + require.NotNil(t, result.ReasoningEffort) + require.Equal(t, "none", *result.ReasoningEffort) +} + func rawGPT56ResponsesAPIKeyAccount(requestedModel, mappedModel string) *Account { return &Account{ ID: 501, diff --git a/backend/internal/service/openai_gateway_chat_completions_test.go b/backend/internal/service/openai_gateway_chat_completions_test.go index 54a3114b1ed2..14fd3fbcded9 100644 --- a/backend/internal/service/openai_gateway_chat_completions_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_test.go @@ -1167,7 +1167,7 @@ func TestGPT6ReasoningModeAndSamplingCompatibility(t *testing.T) { d := newConfiguredCodexModelDescriptor(model) require.NotNil(t, d.DefaultReasoningLevel) require.Equal(t, "medium", *d.DefaultReasoningLevel) - require.EqualValues(t, 872000, d.MaxContextWindow) + require.Zero(t, d.MaxContextWindow, "raw descriptor has no fabricated capacity before account resolver projection") wantLevels := 5 if model == "gpt-6-sol" { wantLevels = 6 // Downstream: Sol also lists Codex's Ultra workflow, as the official catalog does. diff --git a/backend/internal/service/openai_gateway_deepseek_chat_reasoning_test.go b/backend/internal/service/openai_gateway_deepseek_chat_reasoning_test.go index 201933d06e4e..c2a92e15735f 100644 --- a/backend/internal/service/openai_gateway_deepseek_chat_reasoning_test.go +++ b/backend/internal/service/openai_gateway_deepseek_chat_reasoning_test.go @@ -87,6 +87,66 @@ func TestEnsureDeepSeekChatReasoningPlaceholders(t *testing.T) { }) } +// OpenCode Zen / Go 把 deepseek-* 模型转发给 DeepSeek,同一条 thinking-mode 400 +// 会原样回吐,占位注入必须覆盖这条链路;同账号下的非 DeepSeek 模型不受影响。 +func TestEnsureDeepSeekChatReasoningPlaceholders_OpenCodeZenUpstream(t *testing.T) { + openCodeGo := &Account{ + ID: 24, + Name: "opencode-go", + Platform: PlatformOpenCodeGo, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-test", "base_url": DefaultOpenCodeGoBaseURL}, + } + openAIViaZen := &Account{ + ID: 25, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-test", "base_url": DefaultOpenCodeZenBaseURL}, + } + thirdPartyDeepSeekName := &Account{ + ID: 26, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-test", "base_url": "http://upstream.example"}, + } + + missingFor := func(model string) []byte { + return []byte(`{"model":"` + model + `","messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"exec","arguments":"{}"}}]},{"role":"tool","tool_call_id":"call_1","content":"ok"}]}`) + } + + t.Run("opencode_go_deepseek_model_fills_empty_assistant", func(t *testing.T) { + body := missingFor("deepseek-v4.1-flash") + got := ensureDeepSeekChatReasoningPlaceholders(openCodeGo, body) + require.Equal(t, deepSeekChatReasoningPlaceholderText, gjson.GetBytes(got, "messages.1.reasoning_content").String()) + require.False(t, gjson.GetBytes(got, "messages.0.reasoning_content").Exists()) + require.False(t, gjson.GetBytes(got, "messages.2.reasoning_content").Exists()) + }) + + t.Run("opencode_go_non_deepseek_model_unchanged", func(t *testing.T) { + body := missingFor("glm-5.3") + got := ensureDeepSeekChatReasoningPlaceholders(openCodeGo, body) + require.Equal(t, string(body), string(got)) + }) + + t.Run("opencode_prefixed_model_id_fills_empty_assistant", func(t *testing.T) { + body := missingFor("opencode-go/DeepSeek-V4-Pro") + got := ensureDeepSeekChatReasoningPlaceholders(openCodeGo, body) + require.Equal(t, deepSeekChatReasoningPlaceholderText, gjson.GetBytes(got, "messages.1.reasoning_content").String()) + }) + + t.Run("openai_platform_pointed_at_zen_host_fills_empty_assistant", func(t *testing.T) { + body := missingFor("deepseek-v4-flash") + got := ensureDeepSeekChatReasoningPlaceholders(openAIViaZen, body) + require.Equal(t, deepSeekChatReasoningPlaceholderText, gjson.GetBytes(got, "messages.1.reasoning_content").String()) + }) + + t.Run("unrelated_upstream_with_deepseek_model_name_unchanged", func(t *testing.T) { + body := missingFor("deepseek-v4.1-flash") + got := ensureDeepSeekChatReasoningPlaceholders(thirdPartyDeepSeekName, body) + require.Equal(t, string(body), string(got)) + }) +} + type reasoningHitCache struct { stubGatewayCache values map[string]string diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 84f1f610d977..b72341436a16 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -1499,6 +1499,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestPrepared(ctx context.Context, req.Header.Del("OpenAI-Beta") req.Header.Del("originator") } else { + stripOpenAILegacyResponsesBeta(req.Header) req.Header.Set("originator", resolveOpenAIUpstreamOriginator(c, isCodexCLI)) } apiKeyID := getAPIKeyIDFromContext(c) diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback_test.go b/backend/internal/service/openai_gateway_messages_chat_fallback_test.go index 1ea7faca9e8f..3ef4bcd84c47 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback_test.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback_test.go @@ -91,6 +91,13 @@ func TestForwardAsAnthropic_ForceChatCompletionsPreservesFinalModelReasoningEffo mapped: "gpt-5.6-luna", wantEffort: "medium", }, + { + name: "disabled thinking overrides max", + model: "gpt-5.6-luna", + mapped: "gpt-5.6-luna", + effortJSON: `,"output_config":{"effort":"max"},"thinking":{"type":"disabled"}`, + wantEffort: "none", + }, } for _, tt := range tests { diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index f35ea88aa22e..94023d8128bc 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -3252,6 +3252,51 @@ func TestOpenAIGatewayServiceRecordUsage_FreeOpenAIFastChargesStandard(t *testin require.InDelta(t, standardTotal*0.5, usageRepo.lastLog.ActualCost, 1e-10) } +func TestOpenAIGatewayServiceRecordUsage_FreeOpenAIFastMissingPricingRecordsZeroCostUsageLog(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest( + usageRepo, + &openAIRecordUsageUserRepoStub{}, + &openAIRecordUsageSubRepoStub{}, + nil, + ) + svc.resolver = NewModelPricingResolver(nil, svc.billingService) + groupID := int64(78) + serviceTier := "priority" + apiKey := &APIKey{ + ID: 1021, + GroupID: &groupID, + Group: &Group{ + ID: groupID, Platform: PlatformOpenAI, Status: StatusActive, + Hydrated: true, RateMultiplier: 1, FreeOpenAIFast: true, + }, + } + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_free_fast_missing_pricing", + ServiceTier: &serviceTier, + Usage: OpenAIUsage{InputTokens: 1200, OutputTokens: 300}, + Model: "pricing-missing-test-model", + Duration: time.Second, + }, + APIKey: apiKey, + User: &User{ID: 2021}, + Account: &Account{ID: 3021, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + }) + + require.NoError(t, err) + require.Equal(t, 1, usageRepo.calls) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, "resp_free_fast_missing_pricing", usageRepo.lastLog.RequestID) + require.Equal(t, 1200, usageRepo.lastLog.InputTokens) + require.Equal(t, 300, usageRepo.lastLog.OutputTokens) + require.NotNil(t, usageRepo.lastLog.ServiceTier) + require.Equal(t, "priority", *usageRepo.lastLog.ServiceTier) + require.Zero(t, usageRepo.lastLog.TotalCost) + require.Zero(t, usageRepo.lastLog.ActualCost) +} + func TestGroupBillsOpenAIFastAtStandardRequiresOpenAIAccount(t *testing.T) { apiKey := &APIKey{Group: &Group{Platform: PlatformComposite, FreeOpenAIFast: true}} diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go index dbb335a6a341..39deab491286 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go @@ -490,15 +490,53 @@ func targetsDeepSeekAPIHost(account *Account) bool { return strings.EqualFold(u.Hostname(), ds.Hostname()) } +// targetsOpenCodeZenUpstream 报告该账号上游是否是官方 OpenCode Zen / Go 网关。 +// platform=opencode_go 直接命中;其他平台按 base_url 主机判定,覆盖用 API Key +// 直接把 openai 平台账号指向 opencode.ai 的接入方式。 +func targetsOpenCodeZenUpstream(account *Account) bool { + if account == nil { + return false + } + if account.IsOpenCodeGo() { + return true + } + return isOfficialOpenCodeHost(account.GetOpenAIBaseURL()) +} + +// isDeepSeekCatalogModel 判定(剥掉 opencode 前缀后的)模型 ID 是否属于 +// DeepSeek 系列,例如 deepseek-v4-flash / deepseek-v4.1-flash / deepseek-chat。 +func isDeepSeekCatalogModel(model string) bool { + return strings.HasPrefix(normalizeOpenCodeGoModelID(model), "deepseek") +} + +// requiresDeepSeekChatReasoning 报告该 Chat Completions 请求的上游是否按 +// DeepSeek thinking mode 语义校验 reasoning_content。 +// +// DeepSeek 官方 API 直接命中。OpenCode Zen / Go 是转发型订阅网关:deepseek-* +// 模型由 DeepSeek 实际承载,同一条 400 原文会被一字不差地回吐,所以按 +// 「官方 OpenCode 上游 + deepseek-* 模型」补一条判定。 +// +// 不放宽成「任意上游的 deepseek-* 模型名」:占位符是写进请求体的额外字段, +// 对没有这条约束的上游属于无谓改写,且可能触发未知字段校验。 +func requiresDeepSeekChatReasoning(account *Account, body []byte) bool { + if targetsDeepSeekAPIHost(account) { + return true + } + if !targetsOpenCodeZenUpstream(account) { + return false + } + return isDeepSeekCatalogModel(gjson.GetBytes(body, "model").String()) +} + // ensureDeepSeekChatReasoningPlaceholders 给缺 reasoning_content 的 assistant // 消息补单个空格占位。DeepSeek thinking mode 要求历史里每条产生过思维的 // assistant 消息都回传该字段,否则 400 // "The `reasoning_content` in the thinking mode must be passed back to the API"。 // // 桥接会从 summary / 缓存回注真实明文;这里只填仍为空的缺口,不覆盖已有内容。 -// 非 DeepSeek 上游原样返回(字节不变)。 +// 判定见 requiresDeepSeekChatReasoning,不命中的上游原样返回(字节不变)。 func ensureDeepSeekChatReasoningPlaceholders(account *Account, body []byte) []byte { - if !targetsDeepSeekAPIHost(account) { + if !requiresDeepSeekChatReasoning(account, body) { return body } messages := gjson.GetBytes(body, "messages") diff --git a/backend/internal/service/openai_gateway_scheduling.go b/backend/internal/service/openai_gateway_scheduling.go index 880d46e50278..18035f7ab168 100644 --- a/backend/internal/service/openai_gateway_scheduling.go +++ b/backend/internal/service/openai_gateway_scheduling.go @@ -501,11 +501,11 @@ func shouldAutoPauseOpenAIAccountByQuota(ctx context.Context, account *Account) utilization5h, has5h := resolveOpenAIQuotaUtilization(account.Extra, "5h", now) utilization7d, has7d := resolveOpenAIQuotaUtilization(account.Extra, "7d", now) if has5h && utilization5h >= config.Threshold5h { - notifyOpenAIAutoReset(account.ID) + notifyOpenAIAutoResetFromScheduler(account.ID) return true, openAIQuotaAutoPauseDecision{window: "5h", threshold: config.Threshold5h, utilization: utilization5h, reason: "quota_auto_reset_pending_5h"} } if has7d && utilization7d >= config.Threshold7d { - notifyOpenAIAutoReset(account.ID) + notifyOpenAIAutoResetFromScheduler(account.ID) return true, openAIQuotaAutoPauseDecision{window: "7d", threshold: config.Threshold7d, utilization: utilization7d, reason: "quota_auto_reset_pending_7d"} } @@ -519,7 +519,7 @@ func shouldAutoPauseOpenAIAccountByQuota(ctx context.Context, account *Account) if state != nil && state.Status == OpenAIAutoResetStatusAvailable && state.AvailableCount > 0 && !openAIAutoResetStateStale(state, now) { return false, openAIQuotaAutoPauseDecision{} } - notifyOpenAIAutoReset(account.ID) + notifyOpenAIAutoResetFromScheduler(account.ID) if pauseReached5h { return true, openAIQuotaAutoPauseDecision{window: "5h", threshold: pause5h, utilization: utilization5h, reason: "quota_auto_reset_credit_check_5h"} } @@ -634,8 +634,8 @@ func resolveAccountExtraNumber(extra map[string]any, keys ...string) (float64, b // resolveOpenAIQuotaUtilization returns the current utilization ratio (0..1) for the // given Codex usage window. ok=false means there is no usable signal to pause on: -// either no snapshot exists, or the window has already rolled over so the cached -// percentage is stale. The stale guard matters because a paused account stops +// either no snapshot exists, the window has rolled over, or an old snapshot has +// no known future reset. The stale guard matters because a paused account stops // receiving requests, so its snapshot is never refreshed from upstream headers — // without this check an old used_percent would keep the account paused forever even // after the real window reset. @@ -647,14 +647,19 @@ func resolveOpenAIQuotaUtilization(extra map[string]any, window string, now time if openAIQuotaWindowReset(extra, window, now) { return 0, false } - // 快照过于陈旧(账号长期未收到流量刷新)时,不再据此暂停。放行后下一次响应头 + // 快照过于陈旧且没有明确的未来重置时间时,不再据此暂停。放行后下一次响应头 // 会刷新快照实现自愈,避免账号在错误/过期的 used% 上被永久跳过(issue #2994)。 - if openAICodexSnapshotStaleForPause(extra, now) { + if openAICodexSnapshotStaleForPause(extra, now) && !openAIQuotaWindowResetPending(extra, window, now) { return 0, false } return usedPercent / 100, true } +func openAIQuotaWindowResetPending(extra map[string]any, window string, now time.Time) bool { + resetAt, ok := openAICodexWindowResetAt(extra, window) + return ok && now.Before(resetAt) +} + // openAICodexSnapshotStaleForPause reports whether the Codex usage snapshot is stale // enough that it should no longer keep an account auto-paused. It anchors on // codex_usage_updated_at (always written by buildCodexUsageExtraUpdates). A missing or diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 827d692a7eec..bcd1f5f4a2f4 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -75,6 +75,7 @@ var openaiAllowedHeaders = map[string]bool{ "accept-language": true, "content-type": true, "conversation_id": true, + "openai-beta": true, "user-agent": true, "originator": true, "session_id": true, diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index d068d2a5d871..a75407605cc3 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -3260,6 +3260,58 @@ func TestOpenAIBuildUpstreamRequestOAuthMessagesBridgeUsesSessionOnly(t *testing require.Empty(t, req.Header.Get("originator")) } +func TestOpenAIBuildUpstreamRequestOAuthResponsesPreservesCallerBeta(t *testing.T) { + gin.SetMode(gin.TestMode) + makeRequest := func(betaValues ...string) *gin.Context { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader([]byte(`{"model":"gpt-5.5","input":"hello"}`))) + for _, value := range betaValues { + c.Request.Header.Add("OpenAI-Beta", value) + } + return c + } + svc := &OpenAIGatewayService{} + oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-acc"}} + apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "test-api-key"}} + + t.Run("preserves caller multi-agent beta", func(t *testing.T) { + c := makeRequest("responses_multi_agent=v1") + req, err := svc.buildUpstreamRequest(c.Request.Context(), c, oauth, []byte(`{"model":"gpt-5.5","input":"hello"}`), "token", true, "", false) + require.NoError(t, err) + require.Equal(t, "responses_multi_agent=v1", req.Header.Get("OpenAI-Beta")) + }) + + t.Run("preserves mixed beta tokens and removes legacy token", func(t *testing.T) { + c := makeRequest("responses_multi_agent=v1, responses=experimental", "future_feature=v2") + req, err := svc.buildUpstreamRequest(c.Request.Context(), c, oauth, []byte(`{"model":"gpt-5.5","input":"hello"}`), "token", true, "", false) + require.NoError(t, err) + require.Equal(t, []string{"responses_multi_agent=v1", "future_feature=v2"}, req.Header.Values("OpenAI-Beta")) + }) + + t.Run("absent beta remains absent", func(t *testing.T) { + c := makeRequest() + req, err := svc.buildUpstreamRequest(c.Request.Context(), c, oauth, []byte(`{"model":"gpt-5.5","input":"hello"}`), "token", true, "", false) + require.NoError(t, err) + require.Empty(t, req.Header.Values("OpenAI-Beta")) + }) + + t.Run("api key remains caller controlled", func(t *testing.T) { + c := makeRequest("responses=experimental, responses_multi_agent=v1") + req, err := svc.buildUpstreamRequest(c.Request.Context(), c, apiKey, []byte(`{"model":"gpt-5.5","input":"hello"}`), "token", true, "", false) + require.NoError(t, err) + require.Equal(t, []string{"responses=experimental, responses_multi_agent=v1"}, req.Header.Values("OpenAI-Beta")) + }) + + t.Run("compact OAuth removes legacy token and preserves independent beta", func(t *testing.T) { + c := makeRequest("responses=experimental, future_feature=v1") + c.Request.URL.Path = "/v1/responses/compact" + req, err := svc.buildUpstreamRequest(c.Request.Context(), c, oauth, []byte(`{"model":"gpt-5.5","input":"hello"}`), "token", false, "", false) + require.NoError(t, err) + require.Equal(t, "future_feature=v1", req.Header.Get("OpenAI-Beta")) + }) +} + func TestOpenAIBuildUpstreamRequestPreservesCompactPathForAPIKeyBaseURL(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 15f667880cc0..75f57d3b45ce 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -316,10 +316,12 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec longContextBillingGate, pricingAt, ) - if standardErr != nil { + if standardErr != nil && !isUsagePricingUnavailableError(standardErr) { return standardErr } - if cost != nil && standardCost != nil { + // Missing pricing already fell back to a zero-cost log above; keep that + // usage row instead of dropping it on the Standard re-evaluation. + if standardErr == nil && cost != nil && standardCost != nil { cost.ActualCost = standardCost.ActualCost } } @@ -486,6 +488,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec applyAccountStatsCost(ctx, usageLog, s.channelService, s.billingService, account.ID, *apiKey.GroupID, result.UpstreamModel, result.Model, tokens, cost.TotalCost, pricingAt, + accountStatsLongContextPricingEnabled(longContextBillingGate), ) } diff --git a/backend/internal/service/openai_gpt6_models_test.go b/backend/internal/service/openai_gpt6_models_test.go index 57873c3fc8e2..fd2998184847 100644 --- a/backend/internal/service/openai_gpt6_models_test.go +++ b/backend/internal/service/openai_gpt6_models_test.go @@ -43,8 +43,8 @@ func TestGPT6CodexReferenceAndAPICatalogDefaults(t *testing.T) { Credentials: map[string]any{"base_url": "https://api.openai.com"}} for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} { descriptor := newConfiguredCodexModelDescriptor(model) - require.EqualValues(t, 272000, descriptor.ContextWindow) - require.EqualValues(t, 872000, descriptor.MaxContextWindow) + require.Zero(t, descriptor.ContextWindow, "a synthetic descriptor must not invent a context window") + require.Zero(t, descriptor.MaxContextWindow, "a synthetic descriptor must not invent a maximum context window") require.Nil(t, descriptor.AutoCompactTokenLimit) require.Nil(t, descriptor.MultiAgentReasoningEffort) require.Equal(t, "v2", descriptor.MultiAgentVersion) @@ -61,8 +61,8 @@ func TestGPT6CodexReferenceAndAPICatalogDefaults(t *testing.T) { require.Equal(t, "priority", descriptor.ServiceTiers[0].ID) body := convertOpenAIModelListToCodexManifestForAccount([]byte(`{"data":[{"id":"`+model+`"}]}`), account) - require.EqualValues(t, 272000, gjson.GetBytes(body, "models.0.context_window").Int(), "API defaults carry capabilities, not a capacity") - require.EqualValues(t, 872000, gjson.GetBytes(body, "models.0.max_context_window").Int()) + require.False(t, gjson.GetBytes(body, "models.0.context_window").Exists(), "API defaults carry capabilities, not a capacity") + require.False(t, gjson.GetBytes(body, "models.0.max_context_window").Exists()) require.Equal(t, "medium", gjson.GetBytes(body, "models.0.default_reasoning_level").String()) models := decodeCodexManifestModels(t, body) require.Equal(t, openai.GPT6APIReasoningEfforts(), effortsFromManifestModel(t, models[0])) @@ -70,14 +70,14 @@ func TestGPT6CodexReferenceAndAPICatalogDefaults(t *testing.T) { } } -func TestGPT6CapacityUsesSubscriptionReferenceBelowObservations(t *testing.T) { +func TestGPT6CapacityUsesOfficialAPIReferenceAboveObservations(t *testing.T) { for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} { t.Run(model, func(t *testing.T) { api := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://api.openai.com"}} reference := LookupOfficialModelContextCapacity(api, "openai/"+model) require.NotNil(t, reference) - require.EqualValues(t, 272000, reference.ContextWindow) - require.EqualValues(t, 872000, reference.MaxContextWindow) + require.EqualValues(t, 1050000, reference.ContextWindow) + require.Zero(t, reference.MaxContextWindow, "the API specification only declares a total window") require.EqualValues(t, 128000, reference.MaxOutputTokens) require.Zero(t, reference.MaxInputTokens) require.Equal(t, openai.GPT6CodexReferenceSource, reference.Reference.SourceURL) @@ -87,32 +87,36 @@ func TestGPT6CapacityUsesSubscriptionReferenceBelowObservations(t *testing.T) { }} for _, baseURL := range []string{"https://api.openai.com", "https://relay.example.test/v1"} { api.Credentials["base_url"] = baseURL + declared.SourceIdentity = UpstreamModelMetadataSourceIdentity(api) api.Extra = map[string]any{UpstreamModelMetadataExtraKey: declared} observed := ResolveAccountModelContextCapacity(api, model) - require.EqualValues(t, 200000, observed.ContextWindow, "the account's own declaration wins on every host") - require.EqualValues(t, 64000, observed.MaxOutputTokens) - require.Equal(t, "upstream", observed.Source) + require.EqualValues(t, 1050000, observed.ContextWindow, "the official API specification wins on every host") + require.EqualValues(t, 1050000, observed.MaxContextWindow) + require.EqualValues(t, 128000, observed.MaxOutputTokens) + require.Equal(t, "official", observed.Source) } api.Extra[ModelContextOverridesExtraKey] = map[string]int64{model: 512000} require.EqualValues(t, 512000, ResolveAccountModelContextCapacity(api, model).ContextWindow) oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} fallback := ResolveAccountModelContextCapacity(oauth, model) - require.Equal(t, "official", fallback.Source, "an OAuth account participates through the reference catalog") - require.EqualValues(t, 272000, fallback.ContextWindow) - require.EqualValues(t, 872000, fallback.MaxContextWindow) + require.Equal(t, "official", fallback.Source, "an OAuth account participates through the API catalog") + require.EqualValues(t, 1050000, fallback.ContextWindow) + require.EqualValues(t, 1050000, fallback.MaxContextWindow) rows := BuildAccountModelContextCapacityRows(oauth, []string{model}) require.Len(t, rows, 1) require.True(t, rows[0].Editable) require.NotNil(t, rows[0].Official.Reference) - oauth.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Source: "upstream", Models: map[string]UpstreamModelMetadata{ + snapshot := UpstreamModelMetadataSnapshot{Source: "upstream", Models: map[string]UpstreamModelMetadata{ model: {ID: model, ContextWindow: 300000, MaxContextWindow: 900000, MaxOutputTokens: 48000}, - }}) + }} + snapshot.SourceIdentity = UpstreamModelMetadataSourceIdentity(oauth) + oauth.SetUpstreamModelMetadataSnapshot(snapshot) observed := ResolveAccountModelContextCapacity(oauth, model) - require.EqualValues(t, 300000, observed.ContextWindow) - require.EqualValues(t, 900000, observed.MaxContextWindow) - require.EqualValues(t, 48000, observed.MaxOutputTokens) - require.Equal(t, "upstream", observed.Source) + require.EqualValues(t, 1050000, observed.ContextWindow) + require.EqualValues(t, 1050000, observed.MaxContextWindow) + require.EqualValues(t, 128000, observed.MaxOutputTokens) + require.Equal(t, "official", observed.Source) }) } } @@ -140,7 +144,7 @@ func TestGPT6APIKeyLiteAndNativeReasoning(t *testing.T) { func TestGPT6ProviderMetadataOverridesAPIDefaults(t *testing.T) { account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://relay.example.test"}} reasoning := true - account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Source: "upstream", Models: map[string]UpstreamModelMetadata{ + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Source: "upstream", SourceIdentity: UpstreamModelMetadataSourceIdentity(account), Models: map[string]UpstreamModelMetadata{ "gpt-6-sol": {ID: "gpt-6-sol", Reasoning: &reasoning, DefaultReasoningLevel: "max", SupportedReasoningLevels: []string{"high", "max"}, InputModalities: []string{"text", "image"}, ContextWindow: 100000}, }}) body := []byte(`{"data":[{"id":"gpt-6-sol","reasoning":false,"input_modalities":["text"],"context_window":64000,"max_output_tokens":8000,"auto_compact_token_limit":50000,"supports_search_tool":false}]}`) @@ -158,11 +162,11 @@ func TestGPT6ProviderMetadataOverridesAPIDefaults(t *testing.T) { require.Equal(t, false, model["supports_search_tool"]) } -func TestGPT6ConfiguredOAuthCatalogUsesObservedCapacity(t *testing.T) { +func TestGPT6ConfiguredOAuthCatalogUsesOfficialAPICapacity(t *testing.T) { const groupID int64 = 764 account := Account{ID: 25, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Credentials: map[string]any{"model_mapping": map[string]any{"gpt-6-sol": "gpt-6-sol"}}} - account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Source: "upstream", Models: map[string]UpstreamModelMetadata{ + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Source: "upstream", SourceIdentity: UpstreamModelMetadataSourceIdentity(&account), Models: map[string]UpstreamModelMetadata{ "gpt-6-sol": {ID: "gpt-6-sol", ContextWindow: 320000, MaxContextWindow: 940000, CapacitySource: ModelContextSourceUpstream}, }}) svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{groupID: {account}}}} @@ -171,8 +175,9 @@ func TestGPT6ConfiguredOAuthCatalogUsesObservedCapacity(t *testing.T) { require.True(t, configured) model := decodeCodexManifestModels(t, manifest.Body)[0] require.Equal(t, "gpt-6-sol", model["slug"]) - require.EqualValues(t, 320000, model["context_window"]) - require.EqualValues(t, 940000, model["max_context_window"]) + require.EqualValues(t, 1050000, model["context_window"]) + require.EqualValues(t, 1050000, model["max_context_window"]) + require.Equal(t, "official", model["context_capacity_source"]) require.Nil(t, model["auto_compact_token_limit"]) require.Equal(t, true, model["use_responses_lite"]) } diff --git a/backend/internal/service/openai_quota_auto_reset.go b/backend/internal/service/openai_quota_auto_reset.go index 167bc89355d7..19ea84ce040d 100644 --- a/backend/internal/service/openai_quota_auto_reset.go +++ b/backend/internal/service/openai_quota_auto_reset.go @@ -27,6 +27,13 @@ const ( openAIAutoResetQueueCapacity = 1024 openAIAutoResetAttemptTTL = 8 * 24 * time.Hour openAIAutoResetLeaderLockKey = "jobs:openai-auto-reset-credit" + // 查询阶段失败后的最短重试间隔。调度热路径和扫描都会触发评估,不设间隔时上游 + // 故障期间同一账号会被十几秒重查一次;用卡阶段的失败不受此限,超时后仍立即用 + // 同一幂等键重试。 + openAIAutoResetQueryFailureRetryAfter = time.Minute + // 调度热路径每次过滤候选都会评估暂停,对同一账号的通知按此冷却合并; + // 后台每分钟全量扫描兜底,冷却不会让账号漏检。 + openAIAutoResetSchedulerNotifyCooldown = 30 * time.Second ) const ( @@ -288,10 +295,16 @@ func (s *OpenAIQuotaAutoResetService) evaluateAccount(ctx context.Context, accou now := time.Now() assessment := s.assessExtra(account, config, now) state := openAIAutoResetStateFromExtra(account.Extra) - needsQuery := openAIAutoResetSnapshotStale(account.Extra, now) || assessment.resetReached + // 达到用卡阈值本应立即查询以便用卡;但 10 分钟内已确认无卡时,重查不会改变结论, + // 只会让调度热路径的通知把同一账号的上游额度接口打到十几秒一次。 + needsQuery := openAIAutoResetSnapshotStale(account.Extra, now) || + (assessment.resetReached && !openAIAutoResetNoCreditConfirmed(state, now)) if assessment.pauseReached && !assessment.resetReached { needsQuery = needsQuery || state == nil || state.Status == OpenAIAutoResetStatusChecking || state.Status == OpenAIAutoResetStatusFailed || openAIAutoResetStateStale(state, now) } + if needsQuery && openAIAutoResetQueryFailureBackoffActive(state, now) { + needsQuery = false + } if !needsQuery { if !assessment.pauseReached && state != nil && state.TriggerWindow != "" { state.TriggerWindow = "" @@ -669,6 +682,30 @@ func openAIAutoResetStateFromExtra(extra map[string]any) *OpenAIAutoResetCreditS return &state } +// openAIAutoResetNoCreditConfirmed 报告账号在状态有效期内已被确认无卡。 +func openAIAutoResetNoCreditConfirmed(state *OpenAIAutoResetCreditState, now time.Time) bool { + return state != nil && state.Status == OpenAIAutoResetStatusNoCredit && !openAIAutoResetStateStale(state, now) +} + +// openAIAutoResetQueryFailureBackoffActive 报告查询阶段刚失败、仍在重试间隔内。 +// 只覆盖查询、快照写入、卡明细缺失三类失败;用卡阶段的失败需要立即按原幂等键重试。 +func openAIAutoResetQueryFailureBackoffActive(state *OpenAIAutoResetCreditState, now time.Time) bool { + if state == nil || state.Status != OpenAIAutoResetStatusFailed { + return false + } + switch state.ErrorCode { + case "RESET_CREDIT_QUERY_FAILED", "USAGE_SNAPSHOT_WRITE_FAILED", "RESET_CREDIT_DETAILS_UNAVAILABLE": + default: + return false + } + failedAt, err := time.Parse(time.RFC3339, state.LastResultAt) + if err != nil { + return false + } + elapsed := now.Sub(failedAt) + return elapsed >= 0 && elapsed < openAIAutoResetQueryFailureRetryAfter +} + func openAIAutoResetStateStale(state *OpenAIAutoResetCreditState, now time.Time) bool { if state == nil || state.CheckedAt == "" { return true @@ -787,3 +824,29 @@ func notifyOpenAIAutoReset(accountID int64) { func NotifyOpenAIAutoResetCredit(accountID int64) { notifyOpenAIAutoReset(accountID) } + +// openAIAutoResetSchedulerNotifiedAt 记录调度热路径最近一次为某账号发出通知的时间。 +var openAIAutoResetSchedulerNotifiedAt sync.Map // accountID(int64) -> time.Time + +// notifyOpenAIAutoResetFromScheduler 供调度候选过滤使用。候选过滤按请求逐账号执行, +// 每条通知都会让后台读一次账号,不做冷却时无卡或待用卡的账号会持续占满后台协程。 +func notifyOpenAIAutoResetFromScheduler(accountID int64) { + notifyOpenAIAutoResetFromSchedulerAt(accountID, time.Now()) +} + +func notifyOpenAIAutoResetFromSchedulerAt(accountID int64, now time.Time) bool { + if accountID <= 0 { + return false + } + if last, ok := openAIAutoResetSchedulerNotifiedAt.Load(accountID); ok { + if lastAt, ok := last.(time.Time); ok { + elapsed := now.Sub(lastAt) + if elapsed >= 0 && elapsed < openAIAutoResetSchedulerNotifyCooldown { + return false + } + } + } + openAIAutoResetSchedulerNotifiedAt.Store(accountID, now) + notifyOpenAIAutoReset(accountID) + return true +} diff --git a/backend/internal/service/openai_quota_auto_reset_test.go b/backend/internal/service/openai_quota_auto_reset_test.go index 356224cad002..3860f0bec48c 100644 --- a/backend/internal/service/openai_quota_auto_reset_test.go +++ b/backend/internal/service/openai_quota_auto_reset_test.go @@ -3,6 +3,7 @@ package service import ( "context" "encoding/json" + "errors" "sync" "sync/atomic" "testing" @@ -100,6 +101,43 @@ func TestShouldAutoPauseOpenAIAccountByQuota_AutoResetCreditStates(t *testing.T) }) } +func TestNotifyOpenAIAutoResetFromScheduler_CoolsDownPerAccount(t *testing.T) { + svc := &OpenAIQuotaAutoResetService{ctx: context.Background(), queue: make(chan int64, 8)} + setOpenAIAutoResetNotifier(svc) + t.Cleanup(func() { clearOpenAIAutoResetNotifier(svc) }) + + const accountA, accountB int64 = 9_900_001, 9_900_002 + t.Cleanup(func() { + openAIAutoResetSchedulerNotifiedAt.Delete(accountA) + openAIAutoResetSchedulerNotifiedAt.Delete(accountB) + }) + drain := func() []int64 { + var got []int64 + for { + select { + case id := <-svc.queue: + svc.pending.Delete(id) + got = append(got, id) + default: + return got + } + } + } + + base := time.Now() + require.True(t, notifyOpenAIAutoResetFromSchedulerAt(accountA, base)) + require.Equal(t, []int64{accountA}, drain()) + + require.False(t, notifyOpenAIAutoResetFromSchedulerAt(accountA, base.Add(10*time.Second)), "冷却期内同一账号不重复通知") + require.Empty(t, drain()) + + require.True(t, notifyOpenAIAutoResetFromSchedulerAt(accountB, base.Add(10*time.Second)), "冷却按账号独立") + require.Equal(t, []int64{accountB}, drain()) + + require.True(t, notifyOpenAIAutoResetFromSchedulerAt(accountA, base.Add(openAIAutoResetSchedulerNotifyCooldown))) + require.Equal(t, []int64{accountA}, drain()) +} + func TestSelectOpenAIAutoResetCandidate_FailsClosed(t *testing.T) { candidates := []openAIAutoResetCreditCandidate{ {ID: "later", ExpiresAt: "2026-09-02T00:00:00Z"}, @@ -181,9 +219,15 @@ type autoResetTestQuota struct { mu sync.Mutex resetArgs [][2]string failFirst bool + queryCalls atomic.Int32 + queryErr error } func (q *autoResetTestQuota) QueryUsage(context.Context, int64) (*OpenAIQuotaUsage, error) { + q.queryCalls.Add(1) + if q.queryErr != nil { + return nil, q.queryErr + } copy := *q.usage return ©, nil } @@ -334,3 +378,100 @@ func TestOpenAIQuotaAutoResetService_TimeoutRetryReusesRequestBody(t *testing.T) require.Len(t, args, 2) require.Equal(t, args[0], args[1], "超时重试必须复用相同 credit_id 与 redeem_request_id") } + +// newResetThresholdTestFixture 构造一个 7d 用量已过用卡阈值、处于停调中的账号。 +func newResetThresholdTestFixture(t *testing.T, state *OpenAIAutoResetCreditState, availableCredits int) (*OpenAIQuotaAutoResetService, *autoResetTestAccountRepo, *autoResetTestQuota) { + t.Helper() + now := time.Now().UTC() + extra := map[string]any{ + OpenAIAutoResetCreditEnabledExtraKey: true, + OpenAIAutoResetCredit5hThresholdExtraKey: 0.95, + OpenAIAutoResetCredit7dThresholdExtraKey: 0.95, + "codex_7d_used_percent": 99.0, + "codex_usage_updated_at": now.Format(time.RFC3339), + "codex_7d_reset_at": now.Add(48 * time.Hour).Format(time.RFC3339), + } + if state != nil { + extra[OpenAIAutoResetCreditStateExtraKey] = state + } + account := &Account{ID: 501, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Extra: extra} + repo := &autoResetTestAccountRepo{account: account} + quota := &autoResetTestQuota{usage: &OpenAIQuotaUsage{ + FetchedAt: now.Unix(), + RateLimit: &OpenAIRateLimit{ + SecondaryWindow: &OpenAIRateLimitWindow{UsedPercent: 99, LimitWindowSeconds: 7 * 24 * 60 * 60, ResetAfterSeconds: 48 * 3600, ResetAt: now.Add(48 * time.Hour).Unix()}, + }, + RateLimitResetCredits: &OpenAIRateLimitResetCredits{AvailableCount: availableCredits}, + }} + idempotencyConfig := DefaultIdempotencyConfig() + idempotencyConfig.ObserveOnly = false + service := NewOpenAIQuotaAutoResetService(repo, quota, autoResetTestRecoverer{}, + NewIdempotencyCoordinator(newInMemoryIdempotencyRepo(), idempotencyConfig), nil, nil, nil) + return service, repo, quota +} + +func (r *autoResetTestAccountRepo) setExtraForTest(key string, value any) { + r.mu.Lock() + defer r.mu.Unlock() + r.account.Extra[key] = value +} + +func (r *autoResetTestAccountRepo) stateForTest() *OpenAIAutoResetCreditState { + r.mu.Lock() + defer r.mu.Unlock() + return openAIAutoResetStateFromExtra(r.account.Extra) +} + +func TestOpenAIQuotaAutoResetService_ResetThresholdSkipsRequeryWhileNoCreditIsFresh(t *testing.T) { + now := time.Now().UTC() + service, repo, quota := newResetThresholdTestFixture(t, &OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusNoCredit, TriggerWindow: "7d", CheckedAt: now.Format(time.RFC3339), ErrorCode: "NO_RESET_CREDIT", + }, 0) + + for i := 0; i < 3; i++ { + require.NoError(t, service.evaluateAccount(context.Background(), 501)) + } + require.Zero(t, quota.queryCalls.Load(), "10 分钟内已确认无卡,调度通知不应再触发上游查询") + + stale := now.Add(-openAIAutoResetSnapshotTTL - time.Minute).Format(time.RFC3339) + repo.setExtraForTest(OpenAIAutoResetCreditStateExtraKey, &OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusNoCredit, TriggerWindow: "7d", CheckedAt: stale, ErrorCode: "NO_RESET_CREDIT", + }) + repo.setExtraForTest("codex_usage_updated_at", stale) + require.NoError(t, service.evaluateAccount(context.Background(), 501)) + require.Equal(t, int32(1), quota.queryCalls.Load(), "无卡结论过期后应重新查询,以便发现新发放的卡") + require.Equal(t, OpenAIAutoResetStatusNoCredit, repo.stateForTest().Status) +} + +func TestOpenAIQuotaAutoResetService_ResetThresholdStillQueriesWhenCreditAvailable(t *testing.T) { + now := time.Now().UTC() + service, _, quota := newResetThresholdTestFixture(t, &OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusAvailable, AvailableCount: 1, TriggerWindow: "7d", CheckedAt: now.Format(time.RFC3339), + }, 0) + + require.NoError(t, service.evaluateAccount(context.Background(), 501)) + require.Equal(t, int32(1), quota.queryCalls.Load(), "有卡时到达用卡阈值必须立即查询并进入用卡流程") +} + +func TestOpenAIQuotaAutoResetService_QueryFailureBacksOffBeforeRetry(t *testing.T) { + service, repo, quota := newResetThresholdTestFixture(t, nil, 0) + quota.queryErr = errors.New("upstream EOF") + + require.Error(t, service.evaluateAccount(context.Background(), 501)) + require.Equal(t, int32(1), quota.queryCalls.Load()) + state := repo.stateForTest() + require.Equal(t, OpenAIAutoResetStatusFailed, state.Status) + require.Equal(t, "RESET_CREDIT_QUERY_FAILED", state.ErrorCode) + + for i := 0; i < 3; i++ { + require.NoError(t, service.evaluateAccount(context.Background(), 501)) + } + require.Equal(t, int32(1), quota.queryCalls.Load(), "查询失败后一分钟内不应重复打上游") + + state.LastResultAt = time.Now().UTC().Add(-openAIAutoResetQueryFailureRetryAfter - time.Second).Format(time.RFC3339) + repo.setExtraForTest(OpenAIAutoResetCreditStateExtraKey, state) + quota.queryErr = nil + require.NoError(t, service.evaluateAccount(context.Background(), 501)) + require.Equal(t, int32(2), quota.queryCalls.Load(), "重试间隔过后应恢复查询") + require.Equal(t, OpenAIAutoResetStatusNoCredit, repo.stateForTest().Status) +} diff --git a/backend/internal/service/openai_upstream_transport_error.go b/backend/internal/service/openai_upstream_transport_error.go index eacfa8ccd08b..189471c74cdc 100644 --- a/backend/internal/service/openai_upstream_transport_error.go +++ b/backend/internal/service/openai_upstream_transport_error.go @@ -99,9 +99,18 @@ func classifyUpstreamTransportError(err error) upstreamTransportErrorClass { return upstreamTransportErrorClass{} } +// isClientCanceledTransportError reports whether a transport-level failure was +// caused by the client disconnecting: the request context itself is canceled +// and the round-trip aborted with context.Canceled. Such a failure says nothing +// about the upstream, so it is not recorded as an Ops upstream error event. +func isClientCanceledTransportError(ctx context.Context, err error) bool { + return errors.Is(err, context.Canceled) && ctx != nil && errors.Is(ctx.Err(), context.Canceled) +} + // handleOpenAIUpstreamTransportError handles a transport-level upstream failure // (Do/DoWithTLS returned a non-HTTP error: proxy/DNS/TCP/TLS). It: -// 1. records the failure in Ops error logs (status 0, kind=request_error); +// 1. records the failure in Ops error logs (status 0, kind=request_error), +// except for client disconnects and scoped Codex quality requests; // 2. restores immediate durable cooldowns for persistent faults only on the // marked ordinary HTTP path; other paths keep downstream classification; // 3. returns *UpstreamFailoverError for the handler's applicable retry policy, @@ -112,7 +121,7 @@ func classifyUpstreamTransportError(err error) upstreamTransportErrorClass { // // passthrough tags the Ops error event for the OpenAI passthrough forward path. func (s *OpenAIGatewayService) handleOpenAIUpstreamTransportError(ctx context.Context, c *gin.Context, account *Account, err error, passthrough bool) error { - if IsCodexQualityRequest(ctx) { + if IsCodexQualityRequest(ctx) || isClientCanceledTransportError(ctx, err) { return err } safeErr := sanitizeUpstreamErrorMessage(err.Error()) diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index cbf225833a19..ad0566ae051a 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -1611,6 +1611,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( turnPrevRecoveryTried := false lastTurnFinishedAt := time.Time{} lastTurnResponseID := "" + lastTurnWindowID := "" lastTurnReplayInput := []json.RawMessage(nil) lastTurnReplayInputExists := false lastTurnReplayVerified := false @@ -1701,8 +1702,32 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } } skipBeforeTurn = false + boundaryPayload, contextWindowBoundary, boundaryErr := normalizeOpenAIWSContextWindowBoundary( + currentPayload, + lastTurnWindowID, + ) + if boundaryErr != nil { + return fmt.Errorf("normalize Codex websocket context-window boundary: %w", boundaryErr) + } + if contextWindowBoundary.Changed { + currentPayload = boundaryPayload + logOpenAIWSModeInfo( + "ingress_ws_context_window_changed account_id=%d turn=%d conn_id=%s action=break_previous_response_chain previous_window_id=%s current_window_id=%s previous_response_id_removed=%v", + account.ID, + turn, + truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen), + truncateOpenAIWSLogValue(lastTurnWindowID, openAIWSIDValueMaxLen), + truncateOpenAIWSLogValue(contextWindowBoundary.WindowID, openAIWSIDValueMaxLen), + contextWindowBoundary.PreviousResponseIDRemoved, + ) + } currentPreviousResponseID := openAIWSPayloadStringFromRaw(currentPayload, "previous_response_id") expectedPrev := strings.TrimSpace(lastTurnResponseID) + if contextWindowBoundary.Changed { + // A context-window rollover is a new Responses root. Do not infer a + // continuation anchor from the response produced in the old window. + expectedPrev = "" + } toolSignals := ToolContinuationSignals{ HasFunctionCallOutput: openAIWSRawPayloadHasToolCallOutput(currentPayload), } @@ -1890,6 +1915,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } responseID := strings.TrimSpace(result.RequestID) lastTurnResponseID = responseID + if contextWindowBoundary.WindowID != "" { + lastTurnWindowID = contextWindowBoundary.WindowID + } // 正文共享:currentPayload/currentTurnReplayInput 均不可变,历史直接引用; // collector 增量经 combine 合并(新头数组)。 lastTurnReplayInput = currentTurnReplayInput diff --git a/backend/internal/service/openai_ws_forwarder_ingress_test.go b/backend/internal/service/openai_ws_forwarder_ingress_test.go index 54d24229ea7e..4fcac6e79d1f 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_test.go @@ -193,6 +193,49 @@ func TestDropPreviousResponseIDFromRawPayload(t *testing.T) { }) } +func TestNormalizeOpenAIWSContextWindowBoundary(t *testing.T) { + t.Parallel() + + t.Run("same_window_keeps_previous_response_id", func(t *testing.T) { + payload := []byte("{\"type\":\"response.create\",\"previous_response_id\":\"resp_old\",\"client_metadata\":{\"x-codex-window-id\":\"window-a\"}}") + updated, boundary, err := normalizeOpenAIWSContextWindowBoundary(payload, "window-a") + require.NoError(t, err) + require.False(t, boundary.Changed) + require.False(t, boundary.PreviousResponseIDRemoved) + require.Equal(t, "window-a", boundary.WindowID) + require.Equal(t, "resp_old", gjson.GetBytes(updated, "previous_response_id").String()) + }) + + t.Run("new_window_drops_previous_response_id", func(t *testing.T) { + payload := []byte("{\"type\":\"response.create\",\"previous_response_id\":\"resp_old\",\"client_metadata\":{\"x-codex-window-id\":\"window-b\"}}") + updated, boundary, err := normalizeOpenAIWSContextWindowBoundary(payload, "window-a") + require.NoError(t, err) + require.True(t, boundary.Changed) + require.True(t, boundary.PreviousResponseIDRemoved) + require.Equal(t, "window-b", boundary.WindowID) + require.False(t, gjson.GetBytes(updated, "previous_response_id").Exists()) + }) + + t.Run("new_window_without_previous_response_id_still_marks_boundary", func(t *testing.T) { + payload := []byte("{\"type\":\"response.create\",\"client_metadata\":{\"x-codex-window-id\":\"window-b\"}}") + updated, boundary, err := normalizeOpenAIWSContextWindowBoundary(payload, "window-a") + require.NoError(t, err) + require.True(t, boundary.Changed) + require.False(t, boundary.PreviousResponseIDRemoved) + require.Equal(t, string(payload), string(updated)) + }) + + t.Run("embedded_turn_metadata_is_fallback", func(t *testing.T) { + payload := []byte("{\"type\":\"response.create\",\"previous_response_id\":\"resp_old\",\"client_metadata\":{\"x-codex-turn-metadata\":\"{\\\"window_id\\\":\\\"window-b\\\"}\"}}") + updated, boundary, err := normalizeOpenAIWSContextWindowBoundary(payload, "window-a") + require.NoError(t, err) + require.True(t, boundary.Changed) + require.True(t, boundary.PreviousResponseIDRemoved) + require.Equal(t, "window-b", boundary.WindowID) + require.False(t, gjson.GetBytes(updated, "previous_response_id").Exists()) + }) +} + func TestStripCodexSparkImageGenerationToolFromRawPayload(t *testing.T) { t.Run("strips_image_generation_for_spark", func(t *testing.T) { payload := []byte(`{"type":"response.create","model":"gpt-5.3-codex-spark","tools":[{"type":"function","name":"shell"},{"type":"image_generation","output_format":"png"}]}`) diff --git a/backend/internal/service/openai_ws_forwarder_payload.go b/backend/internal/service/openai_ws_forwarder_payload.go index d8f2b060df55..262530480227 100644 --- a/backend/internal/service/openai_ws_forwarder_payload.go +++ b/backend/internal/service/openai_ws_forwarder_payload.go @@ -379,6 +379,48 @@ func setPreviousResponseIDToRawPayload(payload []byte, previousResponseID string return rebuilt, nil } +type openAIWSContextWindowBoundary struct { + WindowID string + Changed bool + PreviousResponseIDRemoved bool +} + +func openAIWSPayloadCodexWindowID(payload []byte) string { + if len(payload) == 0 { + return "" + } + if windowID := strings.TrimSpace(gjson.GetBytes(payload, "client_metadata.x-codex-window-id").String()); windowID != "" { + return windowID + } + turnMetadata := strings.TrimSpace(gjson.GetBytes(payload, "client_metadata.x-codex-turn-metadata").String()) + if turnMetadata == "" { + return "" + } + return strings.TrimSpace(gjson.Get(turnMetadata, "window_id").String()) +} + +// normalizeOpenAIWSContextWindowBoundary breaks a Responses continuation chain +// when Codex moves to a new local context window. WebSocket response.create can +// still carry the previous window's previous_response_id after new_context, +// while HTTP starts the new window without that continuation anchor. +func normalizeOpenAIWSContextWindowBoundary( + payload []byte, + previousWindowID string, +) ([]byte, openAIWSContextWindowBoundary, error) { + currentWindowID := openAIWSPayloadCodexWindowID(payload) + boundary := openAIWSContextWindowBoundary{WindowID: currentWindowID} + if previousWindowID == "" || currentWindowID == "" || currentWindowID == previousWindowID { + return payload, boundary, nil + } + boundary.Changed = true + updated, removed, err := dropPreviousResponseIDFromRawPayload(payload) + if err != nil { + return payload, boundary, err + } + boundary.PreviousResponseIDRemoved = removed + return updated, boundary, nil +} + func shouldInferIngressFunctionCallOutputPreviousResponseID( storeDisabled bool, turn int, diff --git a/backend/internal/service/upstream_model_catalog_refresh.go b/backend/internal/service/upstream_model_catalog_refresh.go index 2c9602aa9057..340cf79cf9a8 100644 --- a/backend/internal/service/upstream_model_catalog_refresh.go +++ b/backend/internal/service/upstream_model_catalog_refresh.go @@ -248,18 +248,35 @@ func recordUpstreamModelCapacityObservations(ctx context.Context, repo AccountRe return } now := time.Now().UTC() + identity := UpstreamModelMetadataSourceIdentity(account) digestBody, _ := json.Marshal(observed) - digest := string(digestBody) + digest := identity + "\x00" + string(digestBody) + _, changed := buildUpstreamCapacityObservationSnapshot(account, identity, observed, now) if value, ok := upstreamCapacityObservationMarks.Load(account.ID); ok { - if mark, ok := value.(upstreamCapacityObservationMark); ok && mark.digest == digest && now.Sub(mark.at) < upstreamCapacityObservationMaxAge { + if mark, ok := value.(upstreamCapacityObservationMark); ok && mark.digest == digest && !changed && now.Sub(mark.at) < upstreamCapacityObservationMaxAge { return } } - snapshot := account.GetUpstreamModelMetadataSnapshot() + if changed { + applied, err := persistUpstreamModelMetadataSnapshot(ctx, repo, account, identity, func(current *Account) UpstreamModelMetadataSnapshot { + snapshot, _ := buildUpstreamCapacityObservationSnapshot(current, identity, observed, now) + return snapshot + }) + if err != nil || !applied { + slog.Warn("upstream_capacity_observation_save_failed", "account_id", account.ID, "error", err) + return + } + } + upstreamCapacityObservationMarks.Store(account.ID, upstreamCapacityObservationMark{digest: digest, at: now}) +} + +func buildUpstreamCapacityObservationSnapshot(account *Account, identity string, observed map[string]ModelContextCapacity, now time.Time) (UpstreamModelMetadataSnapshot, bool) { + snapshot := upstreamMetadataForSource(account, identity) if snapshot == nil { - snapshot = &UpstreamModelMetadataSnapshot{Source: "upstream", Models: make(map[string]UpstreamModelMetadata)} + snapshot = &UpstreamModelMetadataSnapshot{Source: ModelContextSourceUpstream, Models: make(map[string]UpstreamModelMetadata)} } - stamp := now.Format(time.RFC3339) + snapshot.SourceIdentity = identity + stamp := now.Format(time.RFC3339Nano) changed := false for modelID, capacity := range observed { declared := upstreamCapacityDeclaration(capacity, stamp) @@ -267,7 +284,7 @@ func recordUpstreamModelCapacityObservations(ctx context.Context, repo AccountRe if exists && entry.CapacitySource == ModelContextSourceUpstream && entry.ContextWindow == declared.ContextWindow && entry.MaxContextWindow == declared.MaxContextWindow && entry.MaxInputTokens == declared.MaxInputTokens && entry.MaxOutputTokens == declared.MaxOutputTokens { - if observedAt, parseErr := time.Parse(time.RFC3339, entry.ObservedAt); parseErr == nil && now.Sub(observedAt) < upstreamCapacityObservationMaxAge { + if observedAt, parseErr := time.Parse(time.RFC3339Nano, entry.ObservedAt); parseErr == nil && now.Sub(observedAt) < upstreamCapacityObservationMaxAge { continue } } @@ -278,13 +295,7 @@ func recordUpstreamModelCapacityObservations(ctx context.Context, repo AccountRe changed = true } if changed { - if snapshot.SyncedAt == "" { - snapshot.SyncedAt = stamp - } - if err := repo.UpdateExtra(ctx, account.ID, map[string]any{UpstreamModelMetadataExtraKey: *snapshot}); err != nil { - slog.Warn("upstream_capacity_observation_save_failed", "account_id", account.ID, "error", err) - return - } + snapshot.SyncedAt = stamp } - upstreamCapacityObservationMarks.Store(account.ID, upstreamCapacityObservationMark{digest: digest, at: now}) + return *snapshot, changed } diff --git a/backend/internal/service/upstream_model_metadata_identity_test.go b/backend/internal/service/upstream_model_metadata_identity_test.go new file mode 100644 index 000000000000..44313f29ed3d --- /dev/null +++ b/backend/internal/service/upstream_model_metadata_identity_test.go @@ -0,0 +1,278 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" + "github.com/stretchr/testify/require" +) + +func metadataIdentityTestAccount() *Account { + return &Account{ID: 9201, Platform: PlatformDeepseek, Type: AccountTypeAPIKey, + UpdatedAt: time.Date(2026, 9, 28, 1, 0, 0, 0, time.UTC), + Credentials: map[string]any{"base_url": "https://provider.example/v1", "api_key": "old-key", "api_protocol": APIProtocolAdaptive}} +} + +func TestUpstreamMetadataIdentityTracksSourceContract(t *testing.T) { + base := UpstreamModelMetadataSourceIdentity(metadataIdentityTestAccount()) + for name, edit := range map[string]func(*Account){ + "platform": func(a *Account) { a.Platform = PlatformKimi }, + "type": func(a *Account) { a.Type = AccountTypeOAuth }, + "base URL": func(a *Account) { a.Credentials["base_url"] = "https://other.example/v1" }, + "account mode": func(a *Account) { a.Credentials["account_mode"] = AccountModeCoding }, + "protocol": func(a *Account) { a.Credentials["api_protocol"] = APIProtocolAnthropic }, + "protocol endpoint": func(a *Account) { + a.Credentials["api_base_urls"] = map[string]any{APIProtocolResponses: "https://responses.example/v1"} + }, + } { + t.Run(name, func(t *testing.T) { + account := metadataIdentityTestAccount() + edit(account) + require.NotEqual(t, base, UpstreamModelMetadataSourceIdentity(account)) + }) + } + for name, edit := range map[string]func(*Account){ + "key rotation": func(a *Account) { a.Credentials["api_key"] = "new-key" }, + "public mapping": func(a *Account) { a.Credentials["model_mapping"] = map[string]any{"public": "real"} }, + "name and proxy": func(a *Account) { id := int64(9); a.Name = "Renamed"; a.ProxyID = &id }, + "URL spelling": func(a *Account) { a.Credentials["base_url"] = " https://PROVIDER.EXAMPLE:443/v1/ " }, + } { + t.Run(name, func(t *testing.T) { + account := metadataIdentityTestAccount() + edit(account) + require.Equal(t, base, UpstreamModelMetadataSourceIdentity(account)) + }) + } +} + +func TestUpstreamMetadataIdentityInvalidatesEvidenceWithoutDeletingDiagnostics(t *testing.T) { + account := metadataIdentityTestAccount() + snapshot := UpstreamModelMetadataSnapshot{Source: ModelContextSourceUpstream, SourceIdentity: UpstreamModelMetadataSourceIdentity(account), + SyncedAt: "2020-01-01T00:00:00Z", Models: map[string]UpstreamModelMetadata{ + "native": {ID: "native", ContextWindow: 555000, CapacitySource: ModelContextSourceUpstream}, + "reference": {ID: "reference", ContextWindow: 444000, CapacitySource: ModelContextSourceRegistry}, + }} + account.SetUpstreamModelMetadataSnapshot(snapshot) + upstream, registry, _ := accountCapacityObservations(account) + require.Equal(t, int64(555000), upstream["native"].ContextWindow, "age is not a hard expiration") + require.Equal(t, int64(444000), registry["reference"].ContextWindow) + account.Credentials["base_url"] = "https://new.example/v1" + upstream, registry, _ = accountCapacityObservations(account) + require.Empty(t, upstream) + require.Empty(t, registry, "registry capacity is also bound to the old source snapshot") + raw, _, _ := accountRawCapacityObservations(account) + require.Equal(t, int64(555000), raw["native"].ContextWindow) + _, rawRegistry, _ := accountRawCapacityObservations(account) + require.Equal(t, int64(444000), rawRegistry["reference"].ContextWindow) + require.Equal(t, snapshot, *account.GetUpstreamModelMetadataSnapshot()) + snapshot.SourceIdentity = "" + account.SetUpstreamModelMetadataSnapshot(snapshot) + upstream, _, _ = accountCapacityObservations(account) + require.Empty(t, upstream, "legacy data must not be bound implicitly") +} + +func TestUpstreamMetadataIdentityTracksOpenAIWireModeAndEscapedPath(t *testing.T) { + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1%2Fmodels"}} + base := UpstreamModelMetadataSourceIdentity(account) + account.Credentials["base_url"] = "https://provider.example/v1/models" + require.NotEqual(t, base, UpstreamModelMetadataSourceIdentity(account), "escaped reserved path is not a path separator") + account.Credentials["base_url"] = "https://provider.example/v1%2Fmodels" + for _, mode := range []openai_compat.ResponsesSupportMode{ + openai_compat.ResponsesSupportModeForceResponses, + openai_compat.ResponsesSupportModeForceChatCompletions, + } { + account.Extra = map[string]any{openai_compat.ExtraKeyResponsesMode: string(mode)} + require.NotEqual(t, base, UpstreamModelMetadataSourceIdentity(account), "forced response wire mode binds a different source") + account.Extra = nil + } + account.Extra = map[string]any{openai_compat.ExtraKeyResponsesSupported: false} + require.NotEqual(t, base, UpstreamModelMetadataSourceIdentity(account), "automatic wire mode observation binds a different source") +} + +type metadataCASRepo struct { + AccountRepository + latest *Account + revisions []time.Time + results []bool + err error + reads int + updates map[string]any +} + +func (r *metadataCASRepo) GetByID(context.Context, int64) (*Account, error) { + r.reads++ + return r.latest, nil +} + +func (r *metadataCASRepo) UpdateExtraIfRevision(_ context.Context, _ int64, revision time.Time, updates map[string]any) (bool, error) { + r.revisions = append(r.revisions, revision) + if r.err != nil { + return false, r.err + } + applied := true + if len(r.results) >= len(r.revisions) { + applied = r.results[len(r.revisions)-1] + } + if applied { + r.updates = updates + } + return applied, nil +} + +func TestUpstreamMetadataCASPreservesSourceAndNewerCatalog(t *testing.T) { + for _, name := range []string{"ordinary edit", "endpoint changed", "newer catalog", "newer empty catalog", "second conflict", "write failure"} { + t.Run(name, func(t *testing.T) { + account := metadataIdentityTestAccount() + latest := metadataIdentityTestAccount() + latest.UpdatedAt = latest.UpdatedAt.Add(time.Minute) + latest.Name = "edited name" + repo := &metadataCASRepo{latest: latest, results: []bool{false, true}} + switch name { + case "endpoint changed": + latest.Credentials["base_url"] = "https://new.example" + case "newer catalog": + latest.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Source: "upstream", SourceIdentity: UpstreamModelMetadataSourceIdentity(latest), Models: map[string]UpstreamModelMetadata{"newer": {ContextWindow: 900000}}}) + case "newer empty catalog": + latest.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Source: "upstream", SourceIdentity: UpstreamModelMetadataSourceIdentity(latest), SyncedAt: latest.UpdatedAt.Format(time.RFC3339Nano), Models: map[string]UpstreamModelMetadata{}}) + case "second conflict": + repo.results = []bool{false, false} + case "write failure": + repo.err = errors.New("write failed") + } + builds := 0 + applied, err := persistUpstreamModelMetadataSnapshot(context.Background(), repo, account, UpstreamModelMetadataSourceIdentity(account), func(current *Account) UpstreamModelMetadataSnapshot { + builds++ + return UpstreamModelMetadataSnapshot{Source: "upstream", SourceIdentity: UpstreamModelMetadataSourceIdentity(current), Models: map[string]UpstreamModelMetadata{"received": {ContextWindow: 700000}}} + }) + if name == "ordinary edit" { + require.NoError(t, err) + require.True(t, applied) + require.Equal(t, 2, builds, "retry rebuilds from the one reread without requesting upstream again") + require.Equal(t, []time.Time{account.UpdatedAt, latest.UpdatedAt}, repo.revisions) + require.Contains(t, account.GetUpstreamModelMetadataSnapshot().Models, "received") + } else { + require.Error(t, err) + require.False(t, applied) + require.Nil(t, repo.updates) + require.Nil(t, account.GetUpstreamModelMetadataSnapshot()) + } + require.LessOrEqual(t, repo.reads, 1) + require.LessOrEqual(t, len(repo.revisions), 2) + }) + } +} + +func TestUpstreamMetadataCASRequiresFencedRepository(t *testing.T) { + account := metadataIdentityTestAccount() + builds := 0 + applied, err := persistUpstreamModelMetadataSnapshot(context.Background(), &struct{ AccountRepository }{}, account, UpstreamModelMetadataSourceIdentity(account), func(*Account) UpstreamModelMetadataSnapshot { + builds++ + return UpstreamModelMetadataSnapshot{} + }) + require.ErrorContains(t, err, "revision-fenced") + require.False(t, applied) + require.Zero(t, builds) +} + +func TestUpstreamMetadataObservationBindsOnlyFreshDeclarations(t *testing.T) { + account := metadataIdentityTestAccount() + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Source: "upstream", Models: map[string]UpstreamModelMetadata{ + "old": {ID: "old", ContextWindow: 900000}, + "new": {ID: "new", ContextWindow: 800000, Description: "retained capability"}, + "reference": {ID: "reference", ContextWindow: 400000, CapacitySource: ModelContextSourceRegistry}, + }}) + snapshot, changed := buildUpstreamCapacityObservationSnapshot(account, UpstreamModelMetadataSourceIdentity(account), map[string]ModelContextCapacity{"new": {ContextWindow: 500000}}, time.Now()) + require.True(t, changed) + require.NotContains(t, snapshot.Models, "old") + require.Equal(t, int64(500000), snapshot.Models["new"].ContextWindow) + require.Equal(t, "retained capability", snapshot.Models["new"].Description) + require.NotContains(t, snapshot.Models, "reference", "unbound legacy registry capacity is not promoted into the new snapshot") + require.Equal(t, int64(900000), account.GetUpstreamModelMetadataSnapshot().Models["old"].ContextWindow, "building does not rewrite diagnostics") +} + +func TestUpstreamMetadataSourceChangeDropsRegistryCapacityWithoutRebinding(t *testing.T) { + for _, legacy := range []bool{false, true} { + t.Run(map[bool]string{false: "old endpoint", true: "legacy unbound"}[legacy], func(t *testing.T) { + account := metadataIdentityTestAccount() + identity := UpstreamModelMetadataSourceIdentity(account) + if legacy { + identity = "" + } + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Source: "models.dev", SourceIdentity: identity, + Models: map[string]UpstreamModelMetadata{"reference": {ID: "reference", Description: "capability retained", ContextWindow: 700000, CapacitySource: ModelContextSourceRegistry}}}) + if !legacy { + account.Credentials["base_url"] = "https://changed.example/v1" + } + _, registry, _ := accountCapacityObservations(account) + require.Empty(t, registry) + metadata, exists := account.CurrentUpstreamModelMetadata("reference") + require.True(t, exists) + require.Zero(t, metadata.ContextWindow) + require.Equal(t, "capability retained", metadata.Description) + snapshot, changed := buildUpstreamCapacityObservationSnapshot(account, UpstreamModelMetadataSourceIdentity(account), map[string]ModelContextCapacity{"fresh": {ContextWindow: 300000}}, time.Now()) + require.True(t, changed) + require.Zero(t, snapshot.Models["reference"].ContextWindow) + require.Empty(t, snapshot.Models["reference"].CapacitySource) + require.Equal(t, "capability retained", snapshot.Models["reference"].Description) + require.Equal(t, int64(700000), account.GetUpstreamModelMetadataSnapshot().Models["reference"].ContextWindow) + }) + } +} + +func TestUpstreamMetadataDedupeChecksCurrentSnapshot(t *testing.T) { + for _, change := range []string{"capacity replaced", "model removed", "snapshot unbound", "source replaced"} { + t.Run(change, func(t *testing.T) { + account := metadataIdentityTestAccount() + upstreamCapacityObservationMarks.Delete(account.ID) + t.Cleanup(func() { upstreamCapacityObservationMarks.Delete(account.ID) }) + repo := &metadataCASRepo{} + body := []byte(`{"data":[{"id":"native","context_window":500000}]}`) + recordUpstreamModelCapacityObservations(context.Background(), repo, account, body) + require.Len(t, repo.revisions, 1) + snapshot := account.GetUpstreamModelMetadataSnapshot() + switch change { + case "capacity replaced": + entry := snapshot.Models["native"] + entry.ContextWindow = 900000 + snapshot.Models["native"] = entry + case "model removed": + delete(snapshot.Models, "native") + case "snapshot unbound": + snapshot.SourceIdentity = "" + case "source replaced": + snapshot.SourceIdentity = "another-source" + } + account.SetUpstreamModelMetadataSnapshot(*snapshot) + recordUpstreamModelCapacityObservations(context.Background(), repo, account, body) + require.Len(t, repo.revisions, 2, "a prior process mark cannot skip a current mismatched snapshot") + current := account.GetUpstreamModelMetadataSnapshot() + require.Equal(t, UpstreamModelMetadataSourceIdentity(account), current.SourceIdentity) + require.Equal(t, int64(500000), current.Models["native"].ContextWindow) + }) + } +} + +func TestUpstreamMetadataObservationMarksOnlyAppliedWrites(t *testing.T) { + account := metadataIdentityTestAccount() + upstreamCapacityObservationMarks.Delete(account.ID) + t.Cleanup(func() { upstreamCapacityObservationMarks.Delete(account.ID) }) + repo := &metadataCASRepo{err: errors.New("write failed")} + body := []byte(`{"data":[{"id":"native","context_window":500000}]}`) + recordUpstreamModelCapacityObservations(context.Background(), repo, account, body) + _, marked := upstreamCapacityObservationMarks.Load(account.ID) + require.False(t, marked) + repo.err = nil + recordUpstreamModelCapacityObservations(context.Background(), repo, account, body) + _, marked = upstreamCapacityObservationMarks.Load(account.ID) + require.True(t, marked) + writes := len(repo.revisions) + recordUpstreamModelCapacityObservations(context.Background(), repo, account, body) + require.Len(t, repo.revisions, writes, "identical successful observations are deduplicated") + account.Credentials["base_url"] = "https://new.example/v1" + recordUpstreamModelCapacityObservations(context.Background(), repo, account, body) + require.Len(t, repo.revisions, writes+1, "the same numbers from another source are not deduplicated") +} diff --git a/backend/internal/service/upstream_model_metadata_projection_test.go b/backend/internal/service/upstream_model_metadata_projection_test.go new file mode 100644 index 000000000000..3001526c21a3 --- /dev/null +++ b/backend/internal/service/upstream_model_metadata_projection_test.go @@ -0,0 +1,26 @@ +package service + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestStaleUpstreamCapacityIsNotProjectedIntoCodexManifest(t *testing.T) { + account := &Account{ID: 9202, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "key", "base_url": "https://old.example/v1"}} + oldIdentity := UpstreamModelMetadataSourceIdentity(account) + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{ + Source: "upstream", SourceIdentity: oldIdentity, + Models: map[string]UpstreamModelMetadata{"unknown-model": { + ID: "unknown-model", ContextWindow: 900000, MaxContextWindow: 1000000, + CapacitySource: ModelContextSourceUpstream, + }}, + }) + account.Credentials["base_url"] = "https://new.example/v1" + body, err := applySyncedAPIKeyCodexModelMetadata([]byte(`{"models":[{"slug":"unknown-model"}]}`), account, true) + require.NoError(t, err) + require.NotContains(t, strings.TrimSpace(string(body)), "context_window") + require.NotContains(t, strings.TrimSpace(string(body)), "max_context_window") +} diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 8796730b5ba3..51b948065e84 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -50,9 +50,10 @@ type UpstreamModelMetadata struct { } type UpstreamModelMetadataSnapshot struct { - Source string `json:"source"` - SyncedAt string `json:"synced_at"` - Models map[string]UpstreamModelMetadata `json:"models"` + Source string `json:"source"` + SourceIdentity string `json:"source_identity,omitempty"` + SyncedAt string `json:"synced_at"` + Models map[string]UpstreamModelMetadata `json:"models"` } type UpstreamModelCatalog struct { @@ -140,6 +141,21 @@ func (a *Account) GetUpstreamModelMetadata(modelID string) (UpstreamModelMetadat return metadata, ok } +// CurrentUpstreamModelMetadata preserves capabilities while withholding +// upstream limits from an unbound or old-endpoint snapshot. Raw getters remain +// available for admin diagnostics and serialization. +func (a *Account) CurrentUpstreamModelMetadata(modelID string) (UpstreamModelMetadata, bool) { + snapshot := a.GetUpstreamModelMetadataSnapshot() + if snapshot == nil { + return UpstreamModelMetadata{}, false + } + metadata, ok := snapshot.Models[strings.TrimSpace(modelID)] + if !upstreamModelMetadataSourceMatches(a, snapshot) { + metadata = withUpstreamModelCapacity(metadata, UpstreamModelMetadata{}) + } + return metadata, ok +} + // UpstreamModelSyncErrorKind classifies model sync failures for safe HTTP mapping. type UpstreamModelSyncErrorKind string @@ -222,6 +238,8 @@ func (s *AccountTestService) FetchUpstreamSupportedModels(ctx context.Context, a // listed model that no longer declares one falls back to the registry or to // nothing, never to an older value from a possibly different endpoint. func (s *AccountTestService) SyncUpstreamModelCatalog(ctx context.Context, account *Account) (*UpstreamModelCatalog, error) { + sourceIdentity := UpstreamModelMetadataSourceIdentity(account) + syncedAt := time.Now().UTC().Format(time.RFC3339Nano) models, body, err := s.fetchUpstreamModelList(ctx, account) liveListAvailable := err == nil if err != nil { @@ -238,7 +256,6 @@ func (s *AccountTestService) SyncUpstreamModelCatalog(ctx context.Context, accou "model_count", len(models), ) } - syncedAt := time.Now().UTC().Format(time.RFC3339) catalog := &UpstreamModelCatalog{Models: models, Metadata: make(map[string]UpstreamModelMetadata)} catalog.ModelListSource = "upstream" if !liveListAvailable { @@ -284,7 +301,34 @@ func (s *AccountTestService) SyncUpstreamModelCatalog(ctx context.Context, accou } } - previous := account.GetUpstreamModelMetadataSnapshot() + persistedCapabilities := len(completeUpstreamModelMetadataSubset(capabilityIDs, catalog.Metadata)) > 0 + if _, err := persistUpstreamModelMetadataSnapshot(ctx, s.accountRepo, account, sourceIdentity, func(current *Account) UpstreamModelMetadataSnapshot { + return buildUpstreamModelCatalogSnapshot(current, sourceIdentity, source, syncedAt, capabilityIDs, catalog.Metadata, liveListAvailable) + }); err != nil { + return nil, newUpstreamModelSyncInternalError("Failed to save upstream model metadata", err) + } + + if upstreamCatalogNeedsRegistry(capabilityIDs, catalog.Metadata) { + if persistedCapabilities { + catalog.Warnings = append(catalog.Warnings, UpstreamModelSyncWarning{ + Code: UpstreamModelMetadataPartialCode, + Message: "Some model capabilities were saved; remaining models are still incomplete.", + }) + } else { + catalog.Warnings = append(catalog.Warnings, UpstreamModelSyncWarning{ + Code: UpstreamModelMetadataIncompleteCode, + Message: "Model IDs were synced, but capability metadata is incomplete.", + }) + } + } + catalog.CapacityRows = BuildAccountModelContextCapacityRows(account, models) + return catalog, nil +} + +// buildUpstreamModelCatalogSnapshot merges only source-bound upstream values. +// Registry entries retain their provenance when a legacy snapshot is replaced. +func buildUpstreamModelCatalogSnapshot(account *Account, sourceIdentity, source, syncedAt string, capabilityIDs []string, metadata map[string]UpstreamModelMetadata, liveListAvailable bool) UpstreamModelMetadataSnapshot { + previous := upstreamMetadataForSource(account, sourceIdentity) if previous != nil { // Retained entries keep their own provenance, whatever enrichment summary // the rewritten snapshot carries. @@ -296,8 +340,7 @@ func (s *AccountTestService) SyncUpstreamModelCatalog(ctx context.Context, accou } } } - completeMetadata := completeUpstreamModelMetadataSubset(capabilityIDs, catalog.Metadata) - persistedCapabilities := len(completeMetadata) > 0 + completeMetadata := completeUpstreamModelMetadataSubset(capabilityIDs, metadata) if completeMetadata == nil { completeMetadata = make(map[string]UpstreamModelMetadata) } @@ -330,7 +373,7 @@ func (s *AccountTestService) SyncUpstreamModelCatalog(ctx context.Context, accou // one. Without one, only a declaration the live upstream list withdrew is // dropped; registry references survive a partial or failed registry refresh. for _, modelID := range capabilityIDs { - declared, ok := catalog.Metadata[modelID] + declared, ok := metadata[modelID] if !ok || !upstreamModelMetadataHasCapacity(declared) { kept, exists := completeMetadata[modelID] if _, keptSource := upstreamMetadataCapacity(source, kept); !exists || !liveListAvailable || keptSource != ModelContextSourceUpstream { @@ -348,31 +391,93 @@ func (s *AccountTestService) SyncUpstreamModelCatalog(ctx context.Context, accou delete(completeMetadata, modelID) } } - snapshot := UpstreamModelMetadataSnapshot{Source: source, SyncedAt: syncedAt, Models: completeMetadata} - if account != nil { - account.SetUpstreamModelMetadataSnapshot(snapshot) - if account.ID > 0 && s.accountRepo != nil { - if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{UpstreamModelMetadataExtraKey: snapshot}); err != nil { - return nil, newUpstreamModelSyncInternalError("Failed to save upstream model metadata", err) - } + return UpstreamModelMetadataSnapshot{Source: source, SourceIdentity: sourceIdentity, SyncedAt: syncedAt, Models: completeMetadata} +} + +// upstreamMetadataForSource returns a copy suitable for rebinding. Old capacity +// evidence is never promoted to a new identity; capability fields are retained. +func upstreamMetadataForSource(account *Account, sourceIdentity string) *UpstreamModelMetadataSnapshot { + previous := account.GetUpstreamModelMetadataSnapshot() + if previous == nil { + return nil + } + sameSource := previous.SourceIdentity != "" && previous.SourceIdentity == sourceIdentity + for id, entry := range previous.Models { + _, source := upstreamMetadataCapacity(previous.Source, entry) + if !sameSource { + // A source change invalidates every capacity value, including a + // registry-enriched value observed under the old endpoint. Capability + // fields remain useful; the raw snapshot remains available for audit. + entry = withUpstreamModelCapacity(entry, UpstreamModelMetadata{}) + } else { + entry.CapacitySource = source + } + if upstreamModelMetadataIsUseful(entry) { + previous.Models[id] = entry + } else { + delete(previous.Models, id) } } + return previous +} - if upstreamCatalogNeedsRegistry(capabilityIDs, catalog.Metadata) { - if persistedCapabilities { - catalog.Warnings = append(catalog.Warnings, UpstreamModelSyncWarning{ - Code: UpstreamModelMetadataPartialCode, - Message: "Some model capabilities were saved; remaining models are still incomplete.", - }) - } else { - catalog.Warnings = append(catalog.Warnings, UpstreamModelSyncWarning{ - Code: UpstreamModelMetadataIncompleteCode, - Message: "Model IDs were synced, but capability metadata is incomplete.", - }) +var errUpstreamModelMetadataSuperseded = errors.New("upstream model metadata observation superseded by an account or catalog edit") + +// Both catalog refresh and gateway observations use the production repository's +// existing CAS capability. Preview accounts do not perform a persistent write. +func persistUpstreamModelMetadataSnapshot(ctx context.Context, repo AccountRepository, account *Account, sourceIdentity string, build func(*Account) UpstreamModelMetadataSnapshot) (bool, error) { + if account == nil { + return false, newUpstreamModelSyncConfigError("Account is required", nil) + } + if sourceIdentity == "" || UpstreamModelMetadataSourceIdentity(account) != sourceIdentity { + return false, errUpstreamModelMetadataSuperseded + } + if account.ID <= 0 || repo == nil { + account.SetUpstreamModelMetadataSnapshot(build(account)) + return true, nil + } + writer, ok := repo.(accountExtraRevisionWriter) + if !ok { + return false, errors.New("account repository does not support revision-fenced model metadata writes") + } + current := account + // Compare the complete stored value, including an empty catalog. The + // convenient decoded getter intentionally returns nil for zero models. + baseline, err := json.Marshal(account.Extra[UpstreamModelMetadataExtraKey]) + if err != nil { + return false, err + } + for attempt := 0; attempt < 2; attempt++ { + snapshot := build(current) + applied, err := writer.UpdateExtraIfRevision(ctx, account.ID, current.UpdatedAt, map[string]any{UpstreamModelMetadataExtraKey: snapshot}) + if err != nil { + return false, err + } + if applied { + account.SetUpstreamModelMetadataSnapshot(snapshot) + return true, nil + } + if attempt == 1 { + break + } + current, err = repo.GetByID(ctx, account.ID) + if err != nil { + return false, err + } + // Retry only an unrelated account edit. If either the endpoint or its + // stored catalog changed, a late response cannot prove it is newer. + // Discard it instead of resurrecting withdrawn models or limits. + if current == nil || UpstreamModelMetadataSourceIdentity(current) != sourceIdentity || + !sameUpstreamModelMetadataSnapshot(baseline, current.Extra[UpstreamModelMetadataExtraKey]) { + return false, errUpstreamModelMetadataSuperseded } } - catalog.CapacityRows = BuildAccountModelContextCapacityRows(account, models) - return catalog, nil + return false, errUpstreamModelMetadataSuperseded +} + +func sameUpstreamModelMetadataSnapshot(baseline []byte, current any) bool { + value, err := json.Marshal(current) + return err == nil && string(baseline) == string(value) } // applyUpstreamModelCapacityDeclarations replaces the capacity of every model diff --git a/backend/internal/service/upstream_models_test.go b/backend/internal/service/upstream_models_test.go index cac2def52a76..007ca0c2d519 100644 --- a/backend/internal/service/upstream_models_test.go +++ b/backend/internal/service/upstream_models_test.go @@ -36,6 +36,13 @@ func (r *upstreamModelMetadataRepoStub) UpdateExtra(_ context.Context, id int64, return r.err } +func (r *upstreamModelMetadataRepoStub) UpdateExtraIfRevision(ctx context.Context, id int64, _ time.Time, updates map[string]any) (bool, error) { + if err := r.UpdateExtra(ctx, id, updates); err != nil { + return false, err + } + return true, nil +} + func upstreamModelSyncTestConfig() *config.Config { return &config.Config{ Security: config.SecurityConfig{ @@ -546,9 +553,10 @@ func TestSyncUpstreamModelCatalogUsesConfiguredModelsWhenListEndpointUnsupported }, } - account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ - "old-live-model": {ID: "old-live-model", ContextWindow: 256000}, - }}) + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Source: ModelContextSourceUpstream, + SourceIdentity: UpstreamModelMetadataSourceIdentity(account), Models: map[string]UpstreamModelMetadata{ + "old-live-model": {ID: "old-live-model", ContextWindow: 256000, CapacitySource: ModelContextSourceUpstream}, + }}) catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account) require.NoError(t, err) require.Contains(t, account.GetUpstreamModelMetadataSnapshot().Models, "old-live-model", "an unavailable model-list endpoint is not evidence of removal") @@ -1143,14 +1151,15 @@ func TestSyncUpstreamModelCatalogAstraPartialRefreshPreservesKnownCapabilities(t Credentials: map[string]any{"api_key": "test", "base_url": "https://api.openai.com/v1", "model_mapping": map[string]any{"public-model": "mapped-only"}}, } - old := UpstreamModelMetadata{ID: "still-listed", ContextWindow: 256000} - account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ - "still-listed": old, "removed": {ID: "removed", ContextWindow: 128000}, - "gpt-6-astra": {ID: "gpt-6-astra", CodexToolCapabilities: map[string]json.RawMessage{ - "supports_search_tool": json.RawMessage("true"), "apply_patch_tool_type": json.RawMessage(`"freeform"`), - "comp_hash": json.RawMessage(`"3000"`), - }}, - }}) + old := UpstreamModelMetadata{ID: "still-listed", ContextWindow: 256000, CapacitySource: ModelContextSourceRegistry} + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Source: "models.dev", + SourceIdentity: UpstreamModelMetadataSourceIdentity(account), Models: map[string]UpstreamModelMetadata{ + "still-listed": old, "removed": {ID: "removed", ContextWindow: 128000}, + "gpt-6-astra": {ID: "gpt-6-astra", CodexToolCapabilities: map[string]json.RawMessage{ + "supports_search_tool": json.RawMessage("true"), "apply_patch_tool_type": json.RawMessage(`"freeform"`), + "comp_hash": json.RawMessage(`"3000"`), + }}, + }}) catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account) require.NoError(t, err) diff --git a/backend/internal/setup/setup.go b/backend/internal/setup/setup.go index c10193faba49..963926fa4e02 100644 --- a/backend/internal/setup/setup.go +++ b/backend/internal/setup/setup.go @@ -495,10 +495,6 @@ func writeConfigFile(cfg *SetupConfig) error { APIKeyPrefix string `yaml:"api_key_prefix"` RateMultiplier float64 `yaml:"rate_multiplier"` } `yaml:"default"` - RateLimit struct { - RequestsPerMinute int `yaml:"requests_per_minute"` - BurstSize int `yaml:"burst_size"` - } `yaml:"rate_limit"` Timezone string `yaml:"timezone"` }{ Server: cfg.Server, @@ -522,13 +518,6 @@ func writeConfigFile(cfg *SetupConfig) error { APIKeyPrefix: "sk-", RateMultiplier: 1.0, }, - RateLimit: struct { - RequestsPerMinute int `yaml:"requests_per_minute"` - BurstSize int `yaml:"burst_size"` - }{ - RequestsPerMinute: 60, - BurstSize: 10, - }, Timezone: tz, } diff --git a/backend/internal/setup/setup_test.go b/backend/internal/setup/setup_test.go index 71abebc0c98f..7d4320ada0ae 100644 --- a/backend/internal/setup/setup_test.go +++ b/backend/internal/setup/setup_test.go @@ -11,6 +11,7 @@ import ( "github.com/DATA-DOG/go-sqlmock" "github.com/lib/pq" + "gopkg.in/yaml.v3" ) func TestDecideAdminBootstrap(t *testing.T) { @@ -231,6 +232,48 @@ func TestWriteConfigFileIncludesRedisUsername(t *testing.T) { } } +func TestWriteConfigFileOmitsObsoleteRateLimit(t *testing.T) { + t.Setenv("DATA_DIR", t.TempDir()) + + if err := writeConfigFile(&SetupConfig{ + Redis: RedisConfig{Host: "redis", Port: 6379, Username: "app-user"}, + Timezone: "UTC", + }); err != nil { + t.Fatalf("writeConfigFile() error = %v", err) + } + + data, err := os.ReadFile(GetConfigFilePath()) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + var config map[string]any + if err := yaml.Unmarshal(data, &config); err != nil { + t.Fatalf("generated config is invalid YAML: %v", err) + } + if rateLimit, exists := config["rate_limit"]; exists { + fields, ok := rateLimit.(map[string]any) + if !ok { + t.Fatalf("generated rate_limit must be a mapping: %#v", rateLimit) + } + for _, key := range []string{"requests_per_minute", "burst_size"} { + if _, present := fields[key]; present { + t.Fatalf("generated config contains obsolete rate_limit.%s: %s", key, data) + } + } + } + redis, ok := config["redis"].(map[string]any) + if !ok || redis["host"] != "redis" || redis["port"] != 6379 || redis["username"] != "app-user" { + t.Fatalf("generated config lost Redis settings: %#v", config["redis"]) + } + if config["timezone"] != "UTC" { + t.Fatalf("generated config timezone = %#v, want UTC", config["timezone"]) + } + defaults, ok := config["default"].(map[string]any) + if !ok || defaults["user_concurrency"] != defaultUserConcurrency || defaults["api_key_prefix"] != "sk-" { + t.Fatalf("generated config lost default settings: %#v", config["default"]) + } +} + func TestDatabaseConnectionDSNsUseConfiguredTargetAndLegacyBootstrapDatabase(t *testing.T) { cfg := &DatabaseConfig{ Host: "db", diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index b9eef7ccc317..28746bccabc0 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -153,17 +153,17 @@ services: restart: unless-stopped volumes: - ./redis_data:/data:Z - # The command is one quoted script for the inner `sh -c`. Compose keeps - # the newlines inside the quoted string, so every line needs a trailing - # `\` — without it, `redis-server` on the first line runs with no flags - # at all, and the --save/--appendonly/--appendfsync lines are never read. - command: > - sh -c ' - redis-server \ - --save 60 1 \ - --appendonly yes \ - --appendfsync everysec \ - ${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}' + command: + - redis-server + - --save + - "60" + - "1" + - --appendonly + - "yes" + - --appendfsync + - everysec + - --requirepass + - ${REDIS_PASSWORD:-} environment: - TZ=${TZ:-Asia/Shanghai} - REDISCLI_AUTH=${REDIS_PASSWORD:-} diff --git a/deploy/docker-compose.local.yml b/deploy/docker-compose.local.yml index b230f4a0ff9b..8e8b5cd858d4 100644 --- a/deploy/docker-compose.local.yml +++ b/deploy/docker-compose.local.yml @@ -273,17 +273,17 @@ services: volumes: # Local directory mapping for easy migration - ./redis_data:/data:Z - # The command is one quoted script for the inner `sh -c`. Compose keeps - # the newlines inside the quoted string, so every line needs a trailing - # `\` — without it, `redis-server` on the first line runs with no flags - # at all, and the --save/--appendonly/--appendfsync lines are never read. - command: > - sh -c ' - redis-server \ - --save 60 1 \ - --appendonly yes \ - --appendfsync everysec \ - ${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}' + command: + - redis-server + - --save + - "60" + - "1" + - --appendonly + - "yes" + - --appendfsync + - everysec + - --requirepass + - ${REDIS_PASSWORD:-} environment: - TZ=${TZ:-Asia/Shanghai} # REDISCLI_AUTH is used by redis-cli for authentication (safer than -a flag) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 238cfbf6104d..56804facb9f6 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -298,17 +298,17 @@ services: hard: 100000 volumes: - redis_data:/data - # The command is one quoted script for the inner `sh -c`. Compose keeps - # the newlines inside the quoted string, so every line needs a trailing - # `\` — without it, `redis-server` on the first line runs with no flags - # at all, and the --save/--appendonly/--appendfsync lines are never read. - command: > - sh -c ' - redis-server \ - --save 60 1 \ - --appendonly yes \ - --appendfsync everysec \ - ${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}' + command: + - redis-server + - --save + - "60" + - "1" + - --appendonly + - "yes" + - --appendfsync + - everysec + - --requirepass + - ${REDIS_PASSWORD:-} environment: - TZ=${TZ:-Asia/Shanghai} # REDISCLI_AUTH is used by redis-cli for authentication (safer than -a flag) diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index e18894fa9db2..7311573c9f05 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -686,7 +686,7 @@ export interface SyncUpstreamModelsResult { } /** Highest priority first; an empty source means the capacity is unknown. */ -export type ModelContextCapacitySource = 'custom' | 'upstream' | 'official' | 'registry' | '' +export type ModelContextCapacitySource = 'custom' | 'official' | 'upstream' | 'registry' | '' export interface ModelContextCapacityValues { context_window?: number @@ -701,7 +701,9 @@ export interface ModelContextCapacityRow { aliases: string[] editable: boolean upstream?: ModelContextCapacityValues & { observed_at: string } + upstream_evidence_status?: 'current' | 'unbound' | 'source_mismatch' registry?: ModelContextCapacityValues & { observed_at?: string } + registry_evidence_status?: 'current' | 'unbound' | 'source_mismatch' official?: ModelContextCapacityValues & { model_id: string aliases?: string[] diff --git a/frontend/src/api/modelPlaza.ts b/frontend/src/api/modelPlaza.ts index 5096efbe821a..ccc478d6a53b 100644 --- a/frontend/src/api/modelPlaza.ts +++ b/frontend/src/api/modelPlaza.ts @@ -73,6 +73,9 @@ export interface ModelPlazaGroup { /** 生图独立倍率:true 时图片计费模型的实付倍率取 image_rate_multiplier,不取分组/专属倍率。 */ image_rate_independent: boolean image_rate_multiplier: number + /** 视频独立倍率开启时,覆盖视频模型的分组/用户专属倍率。 */ + video_rate_independent: boolean + video_rate_multiplier: number /** 分组是否启用长上下文阶梯计费;false 时实付列只展示最低档,官方阶梯仅供参考。 */ long_context_pricing_enabled: boolean models: PlazaModel[] diff --git a/frontend/src/components/account/ModelContextCapacityField.vue b/frontend/src/components/account/ModelContextCapacityField.vue index 6596b064481a..1f94082c3eed 100644 --- a/frontend/src/components/account/ModelContextCapacityField.vue +++ b/frontend/src/components/account/ModelContextCapacityField.vue @@ -119,10 +119,28 @@ const detailsTitle = computed(() => { const row = boundRow.value const details = [t(`${key}.effective`), `${formattedValue.value} · ${sourceLabel.value}`] if (preview.value.value) details.push(t(`${key}.exactTokens`, { value: preview.value.value })) - const declaredContext = row?.upstream?.context_window || row?.upstream?.max_input_tokens || 0 - const declaredMaximum = row?.upstream?.max_context_window || 0 - const upstream = declaredMaximum >= declaredContext ? declaredMaximum : declaredContext - if (upstream && preview.value.value && preview.value.value > upstream) details.push(t(`${key}.exceedsUpstream`)) + const upstream = row?.upstream?.max_context_window || row?.upstream?.context_window || row?.upstream?.max_input_tokens || 0 + if (row?.upstream) { + details.push(t(`${key}.upstream`)) + for (const field of ['context_window', 'max_context_window', 'max_input_tokens', 'max_output_tokens'] as const) { + const value = row.upstream[field] + if (value) details.push(`${t(`${key}.${field}`)}: ${formatContextCapacity(value)} (${value})`) + } + if (row.upstream.observed_at) details.push(`${t(`${key}.observedAt`)}: ${row.upstream.observed_at}`) + if (row.upstream_evidence_status) details.push(t(`${key}.upstreamEvidence.${row.upstream_evidence_status}`)) + } + if (row?.registry) { + details.push(t(`${key}.sources.registry`)) + for (const field of ['context_window', 'max_context_window', 'max_input_tokens', 'max_output_tokens'] as const) { + const value = row.registry[field] + if (value) details.push(`${t(`${key}.${field}`)}: ${formatContextCapacity(value)} (${value})`) + } + if (row.registry.observed_at) details.push(`${t(`${key}.observedAt`)}: ${row.registry.observed_at}`) + if (row.registry_evidence_status) details.push(t(`${key}.upstreamEvidence.${row.registry_evidence_status}`)) + } + if ((!row?.upstream_evidence_status || row.upstream_evidence_status === 'current') && upstream && preview.value.value && preview.value.value > upstream) { + details.push(t(`${key}.exceedsUpstream`)) + } if (row?.official) { const official = row.official const product = ['api', 'codex_subscription'].includes(official.product) diff --git a/frontend/src/components/account/UsageProgressBar.vue b/frontend/src/components/account/UsageProgressBar.vue index 403dd200a202..439d35e79285 100644 --- a/frontend/src/components/account/UsageProgressBar.vue +++ b/frontend/src/components/account/UsageProgressBar.vue @@ -211,13 +211,11 @@ const shouldShowResetTime = computed(() => { // Format reset time const formatResetTime = computed(() => { - // For rolling windows, when utilization is 0%, treat as immediately available. - if (props.showNowWhenIdle && props.utilization <= 0) { - return t('usage.resetNow') + if (!props.resetsAt) { + // Idle rolling window without a known reset time: treat as immediately available. + return props.showNowWhenIdle && props.utilization <= 0 ? t('usage.resetNow') : '-' } - if (!props.resetsAt) return '-' - const date = new Date(props.resetsAt) const diffMs = date.getTime() - now.value.getTime() diff --git a/frontend/src/components/account/__tests__/ModelContextCapacityField.spec.ts b/frontend/src/components/account/__tests__/ModelContextCapacityField.spec.ts index d081ff31aab3..6d9a265da581 100644 --- a/frontend/src/components/account/__tests__/ModelContextCapacityField.spec.ts +++ b/frontend/src/components/account/__tests__/ModelContextCapacityField.spec.ts @@ -63,6 +63,29 @@ describe('ModelContextCapacityField', () => { expect(wrapper.get('button').attributes('title')).not.toContain('exceedsUpstream') }) + it('keeps an unbound upstream observation visible for diagnosis without treating it as current', () => { + const wrapper = mountField(capacityRow({ + upstream_evidence_status: 'source_mismatch', + effective_context_window: 400_000, + effective_source: 'official' + })) + const title = wrapper.get('button').attributes('title') + expect(title).toContain('contextCapacity.upstreamEvidence.source_mismatch') + expect(title).toContain('contextCapacity.context_window') + expect(title).not.toContain('exceedsUpstream') + }) + + it('keeps an unbound registry observation visible for diagnosis without treating it as current', () => { + const wrapper = mountField(capacityRow({ + registry: { context_window: 700_000, observed_at: '2026-09-08T00:00:00Z' }, + registry_evidence_status: 'unbound' + })) + const title = wrapper.get('button').attributes('title') + expect(title).toContain('contextCapacity.sources.registry') + expect(title).toContain('contextCapacity.upstreamEvidence.unbound') + expect(title).toContain('700000') + }) + it('shows compact capacity and its source without hover or another panel', () => { const wrapper = mountField() expect(wrapper.get('[data-testid="context-capacity-value"]').text()).toBe('[400K]') diff --git a/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts b/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts index 634342334836..0067516ec3ad 100644 --- a/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts +++ b/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts @@ -22,7 +22,7 @@ describe('UsageProgressBar', () => { vi.useRealTimers() }) - it('showNowWhenIdle=true 且利用率为 0 时显示“现在”', () => { + it('showNowWhenIdle=true 且利用率为 0 但有未来 resetsAt 时显示倒计时', () => { const wrapper = mount(UsageProgressBar, { props: { label: '5h', @@ -33,8 +33,8 @@ describe('UsageProgressBar', () => { } }) - expect(wrapper.text()).toContain('usage.resetNow') - expect(wrapper.text()).not.toContain('2h 30m') + expect(wrapper.text()).toContain('2h 30m') + expect(wrapper.text()).not.toContain('usage.resetNow') }) it('showNowWhenIdle=true 但利用率大于 0 时显示倒计时', () => { diff --git a/frontend/src/components/admin/group/GroupRPMOverridesModal.vue b/frontend/src/components/admin/group/GroupRPMOverridesModal.vue index 88bc03ed0ca1..508aad1e49fc 100644 --- a/frontend/src/components/admin/group/GroupRPMOverridesModal.vue +++ b/frontend/src/components/admin/group/GroupRPMOverridesModal.vue @@ -206,7 +206,7 @@