From a70dd804fd538f48778cc097caebef9d7dff3490 Mon Sep 17 00:00:00 2001 From: JayHome137 <125954077+JayHome137@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:59:53 +0800 Subject: [PATCH 1/3] chore: sync upstream release v3.1.3 into main --- .env.example | 2 +- .github/workflows/docker-image.yml | 27 +- .upstream-release | 2 +- CHANGELOG.md | 30 + admin/antigravity_settings.go | 51 +- admin/antigravity_settings_test.go | 53 + admin/codex_unified_client_identity_test.go | 67 - admin/handler.go | 177 +- admin/intelligence_check_executor.go | 37 +- admin/intelligence_check_executor_test.go | 13 + admin/intelligence_check_runner.go | 2 +- admin/key_usage_public.go | 43 +- admin/key_usage_public_auth.go | 50 + admin/key_usage_public_concurrency.go | 18 + admin/oauth.go | 19 +- admin/oauth_test.go | 3 + admin/output_guard_settings_test.go | 689 ++ admin/scheduled_connection_tests.go | 382 + admin/scheduled_connection_tests_test.go | 218 + admin/self_service.go | 6 +- admin/test_connection.go | 13 +- api/errors.go | 2 +- api/middleware.go | 2 +- auth/antigravity_balance.go | 134 +- auth/antigravity_quota_family.go | 110 + auth/antigravity_quota_family_test.go | 413 + auth/antigravity_quota_gate.go | 219 +- auth/antigravity_quota_scheduler_test.go | 2 +- auth/antigravity_settings.go | 50 +- auth/codex_oauth_identity.go | 49 + auth/codex_refresh.go | 7 + auth/codex_refresh_test.go | 13 +- auth/fast_scheduler.go | 70 +- auth/reset_5h_priority.go | 84 + auth/reset_5h_priority_test.go | 66 + auth/store.go | 65 +- auth/token.go | 31 +- auth/token_parse_test.go | 12 + auth/usage_cooldown_test.go | 8 +- auth/utls_client.go | 5 + cache/durable_response_context.go | 797 ++ cache/durable_response_context_test.go | 289 + config/config.go | 89 +- database/acceptance_config_test.go | 317 + database/api_key_usage.go | 19 +- database/billing.go | 14 + .../codex_unified_client_identity_test.go | 75 - database/intelligence_check.go | 112 +- database/intelligence_check_adaptive_test.go | 171 + database/intelligence_check_runs.go | 65 +- database/intelligence_check_test.go | 2 +- database/maintenance_jobs.go | 39 + database/output_guard_settings.go | 258 + database/output_guard_settings_test.go | 340 + database/postgres.go | 39 +- database/scheduled_connection_tests.go | 53 + database/sqlite.go | 16 +- docker-compose.pgredis2004.yml | 2 +- docs/API.md | 17 +- docs/ARCHITECTURE.md | 2 +- docs/CODEX_ATTESTATION.md | 4 + docs/CONFIGURATION.md | 20 +- docs/INTELLIGENCE_BACKOFF.md | 43 + docs/QUOTA_FAILOVER_PATCH.md | 21 + docs/REQUEST_BODY_413_PATCH.md | 30 + docs/RESET_5H_PRIORITY.md | 26 + docs/RESPONSES_COMPATIBILITY.md | 2 +- docs/SCHEDULED_5H_ACTIVATION.md | 44 + docs/TROUBLESHOOTING.md | 7 +- docs/USAGE.md | 4 +- .../2026-11-01-model-output-guard-trip.md | 90 + ...8-model-output-guard-tdd-plan.candidate.md | 8422 +++++++++++++++++ .../2026-10-28-model-output-guard-tdd-plan.md | 4685 +++++++++ .../plans/model-output-guard-tdd/01-core.md | 1653 ++++ .../model-output-guard-tdd/02a-database.md | 697 ++ .../model-output-guard-tdd/02b-runtime.md | 693 ++ .../model-output-guard-tdd/02c-admin-ui.md | 892 ++ .../model-output-guard-tdd/03-protocol.md | 2031 ++++ .../model-output-guard-tdd/04a-termination.md | 645 ++ .../04b-responses-chat.md | 476 + .../model-output-guard-tdd/04c-native-http.md | 703 ++ .../model-output-guard-tdd/05-acceptance.md | 31 + .../05a-detection-config.md | 1731 ++++ .../05b-lifecycle-acceptance.md | 1492 +++ .../model-output-guard-tdd/05c-performance.md | 1220 +++ .../plans/model-output-guard-tdd/PROGRESS.md | 216 + docs/superpowers/plans/part_head.txt | 78 + docs/superpowers/plans/part_t10.txt | 621 ++ .../superpowers/plans/part_t11_acceptance.txt | 2000 ++++ docs/superpowers/plans/part_t1_t4.txt | 1727 ++++ docs/superpowers/plans/part_t5_t6.txt | 1349 +++ docs/superpowers/plans/part_t7_t8.txt | 1536 +++ docs/superpowers/plans/part_t9.txt | 1025 ++ .../2026-10-28-model-output-guard-design.md | 367 + frontend/src/api.ts | 10 +- .../src/components/APIKeyChannelBadge.tsx | 27 + .../components/AccountGroupManagerModal.tsx | 22 +- .../src/components/AccountIntelligenceBar.tsx | 3 +- .../AccountIntelligenceHistoryDialog.tsx | 7 +- .../PublicAPIKeyConcurrencyBadge.tsx | 47 + .../PublicAPIKeyUsageLogDetails.tsx | 55 + .../src/components/ReasoningEffortBadge.tsx | 24 + .../ScheduledConnectionTestsModal.tsx | 368 + .../src/components/TestConnectionModal.tsx | 12 + frontend/src/components/UsageLogMetrics.tsx | 139 + frontend/src/components/UsageTimingBadge.tsx | 34 + frontend/src/lib/autoPauseThreshold.test.mjs | 35 + frontend/src/lib/autoPauseThreshold.ts | 28 + frontend/src/lib/byteLimitCommit.test.mjs | 66 + frontend/src/lib/byteLimitCommit.ts | 32 + frontend/src/lib/claudeParity.test.mjs | 6 +- frontend/src/lib/docsContent.test.mjs | 2 +- frontend/src/lib/intelligenceCheck.ts | 22 + .../src/lib/outputGuardSaveScope.test.mjs | 93 + frontend/src/lib/outputGuardSettings.test.mjs | 359 + frontend/src/lib/outputGuardSettings.ts | 177 + frontend/src/lib/settingsAutoSave.test.mjs | 249 + frontend/src/lib/settingsAutoSave.ts | 114 + frontend/src/lib/settingsPayload.test.mjs | 60 + frontend/src/lib/settingsPayload.ts | 4 +- frontend/src/lib/settingsTabs.test.mjs | 2 +- frontend/src/lib/usageTiming.ts | 54 + frontend/src/locales/en.json | 32 +- frontend/src/locales/zh-TW.json | 27 + frontend/src/locales/zh.json | 32 +- frontend/src/pages/APIKeyUsagePortal.tsx | 151 +- frontend/src/pages/APIKeys.tsx | 75 +- frontend/src/pages/Accounts.tsx | 27 +- frontend/src/pages/ClaudeAccounts.tsx | 26 +- frontend/src/pages/IntelligenceCheck.tsx | 18 +- frontend/src/pages/Settings.tsx | 347 +- frontend/src/pages/Usage.tsx | 273 +- frontend/src/pages/docs/docsGuides.ts | 6 +- frontend/src/types.ts | 41 +- .../tests/intelligenceCheck.browser.test.mjs | 22 +- internal/intelligencecheck/engine.go | 93 +- internal/intelligencecheck/schedule.go | 88 + internal/intelligencecheck/schedule_test.go | 139 + main.go | 55 +- pricing.json | 10 + proxy/acceptance_benchmark_test.go | 759 ++ proxy/acceptance_config_test.go | 301 + proxy/acceptance_lifecycle_test.go | 1764 ++++ proxy/antigravity_responses.go | 24 +- proxy/antigravity_responses_tools.go | 158 + proxy/antigravity_responses_tools_test.go | 401 + proxy/apikey_concurrency.go | 14 + proxy/codex_client_identity.go | 66 +- proxy/codex_maintenance_identity_test.go | 66 +- proxy/codex_models_manifest.go | 24 +- proxy/codex_models_manifest_test.go | 26 +- proxy/device_profile.go | 7 +- proxy/device_profile_env_test.go | 19 + proxy/errors.go | 2 +- proxy/executor.go | 66 +- proxy/external_scheduler_test.go | 26 + proxy/grok_namespace_tools.go | 3 + proxy/handler.go | 398 +- proxy/handler_anthropic.go | 69 +- proxy/handler_chat_stream_failure_test.go | 45 +- proxy/handler_gemini.go | 83 +- proxy/handler_gemini_projector_test.go | 39 + proxy/images_test.go | 44 +- proxy/model_capabilities.go | 58 +- proxy/model_capabilities_test.go | 45 +- proxy/output_guard_settings.go | 296 + proxy/output_guard_settings_test.go | 435 + proxy/outputguard_cancel.go | 83 + proxy/outputguard_cancel_test.go | 170 + proxy/outputguard_downstream.go | 217 + proxy/outputguard_downstream_test.go | 247 + proxy/outputguard_http_chat_test.go | 427 + proxy/outputguard_native_adapters.go | 71 + proxy/outputguard_native_http_test.go | 578 ++ proxy/outputguard_native_retry_test.go | 283 + proxy/outputguard_projection.go | 1009 ++ proxy/outputguard_projection_test.go | 893 ++ proxy/outputguard_relay_responses_test.go | 175 + proxy/perfstat/cpu_linux.go | 19 + proxy/perfstat/cpu_other.go | 15 + proxy/perfstat/perfstat.go | 177 + proxy/perfstat/perfstat_test.go | 206 + proxy/quota_failover.go | 244 + proxy/realtime_ws.go | 6 + proxy/response_cache.go | 44 +- proxy/response_cache_durable_test.go | 100 + proxy/response_cache_handler_test.go | 2 +- proxy/response_cache_writer.go | 2 +- proxy/response_context_diagnostics.go | 82 + proxy/responses_compatibility_matrix_test.go | 2 +- proxy/responses_turn_state_test.go | 35 +- proxy/responses_ws.go | 73 +- proxy/responses_ws_context.go | 2 +- proxy/responses_ws_context_test.go | 2 +- proxy/responses_ws_multiplex.go | 14 + proxy/responses_ws_recovery_error.go | 2 +- proxy/responses_ws_shared_context.go | 6 + proxy/retry_exclusions.go | 2 +- proxy/review_regression_test.go | 4 +- proxy/runtime_config.go | 5 - proxy/sse_line.go | 39 + proxy/sse_line_test.go | 268 + proxy/stream_flush_writer.go | 282 +- ...m_flush_writer_discard_concurrency_test.go | 351 + proxy/subscription_sync.go | 11 +- proxy/usage_wham.go | 12 +- proxy/usage_wham_breakdown.go | 4 +- proxy/usage_wham_daily.go | 4 +- proxy/useragent.go | 40 +- request_body_middleware.go | 15 + request_body_middleware_test.go | 170 + scripts/run_acceptance_suite.sh | 33 + security/decompress.go | 43 +- security/middleware.go | 22 +- .../outputguard/acceptance_detection_test.go | 839 ++ security/outputguard/channel.go | 340 + security/outputguard/channel_test.go | 352 + security/outputguard/guard.go | 325 + security/outputguard/guard_test.go | 529 ++ security/outputguard/repetition.go | 161 + security/outputguard/repetition_test.go | 159 + security/request_size.go | 73 + 222 files changed, 57177 insertions(+), 1384 deletions(-) delete mode 100644 admin/codex_unified_client_identity_test.go create mode 100644 admin/key_usage_public_auth.go create mode 100644 admin/key_usage_public_concurrency.go create mode 100644 admin/output_guard_settings_test.go create mode 100644 admin/scheduled_connection_tests.go create mode 100644 admin/scheduled_connection_tests_test.go create mode 100644 auth/antigravity_quota_family.go create mode 100644 auth/antigravity_quota_family_test.go create mode 100644 auth/codex_oauth_identity.go create mode 100644 auth/reset_5h_priority.go create mode 100644 auth/reset_5h_priority_test.go create mode 100644 cache/durable_response_context.go create mode 100644 cache/durable_response_context_test.go create mode 100644 database/acceptance_config_test.go delete mode 100644 database/codex_unified_client_identity_test.go create mode 100644 database/intelligence_check_adaptive_test.go create mode 100644 database/output_guard_settings.go create mode 100644 database/output_guard_settings_test.go create mode 100644 database/scheduled_connection_tests.go create mode 100644 docs/INTELLIGENCE_BACKOFF.md create mode 100644 docs/QUOTA_FAILOVER_PATCH.md create mode 100644 docs/REQUEST_BODY_413_PATCH.md create mode 100644 docs/RESET_5H_PRIORITY.md create mode 100644 docs/SCHEDULED_5H_ACTIVATION.md create mode 100644 docs/incidents/2026-11-01-model-output-guard-trip.md create mode 100644 docs/superpowers/plans/2026-10-28-model-output-guard-tdd-plan.candidate.md create mode 100644 docs/superpowers/plans/2026-10-28-model-output-guard-tdd-plan.md create mode 100644 docs/superpowers/plans/model-output-guard-tdd/01-core.md create mode 100644 docs/superpowers/plans/model-output-guard-tdd/02a-database.md create mode 100644 docs/superpowers/plans/model-output-guard-tdd/02b-runtime.md create mode 100644 docs/superpowers/plans/model-output-guard-tdd/02c-admin-ui.md create mode 100644 docs/superpowers/plans/model-output-guard-tdd/03-protocol.md create mode 100644 docs/superpowers/plans/model-output-guard-tdd/04a-termination.md create mode 100644 docs/superpowers/plans/model-output-guard-tdd/04b-responses-chat.md create mode 100644 docs/superpowers/plans/model-output-guard-tdd/04c-native-http.md create mode 100644 docs/superpowers/plans/model-output-guard-tdd/05-acceptance.md create mode 100644 docs/superpowers/plans/model-output-guard-tdd/05a-detection-config.md create mode 100644 docs/superpowers/plans/model-output-guard-tdd/05b-lifecycle-acceptance.md create mode 100644 docs/superpowers/plans/model-output-guard-tdd/05c-performance.md create mode 100644 docs/superpowers/plans/model-output-guard-tdd/PROGRESS.md create mode 100644 docs/superpowers/plans/part_head.txt create mode 100644 docs/superpowers/plans/part_t10.txt create mode 100644 docs/superpowers/plans/part_t11_acceptance.txt create mode 100644 docs/superpowers/plans/part_t1_t4.txt create mode 100644 docs/superpowers/plans/part_t5_t6.txt create mode 100644 docs/superpowers/plans/part_t7_t8.txt create mode 100644 docs/superpowers/plans/part_t9.txt create mode 100644 docs/superpowers/specs/2026-10-28-model-output-guard-design.md create mode 100644 frontend/src/components/APIKeyChannelBadge.tsx create mode 100644 frontend/src/components/PublicAPIKeyConcurrencyBadge.tsx create mode 100644 frontend/src/components/PublicAPIKeyUsageLogDetails.tsx create mode 100644 frontend/src/components/ReasoningEffortBadge.tsx create mode 100644 frontend/src/components/ScheduledConnectionTestsModal.tsx create mode 100644 frontend/src/components/UsageLogMetrics.tsx create mode 100644 frontend/src/components/UsageTimingBadge.tsx create mode 100644 frontend/src/lib/autoPauseThreshold.test.mjs create mode 100644 frontend/src/lib/autoPauseThreshold.ts create mode 100644 frontend/src/lib/byteLimitCommit.test.mjs create mode 100644 frontend/src/lib/byteLimitCommit.ts create mode 100644 frontend/src/lib/outputGuardSaveScope.test.mjs create mode 100644 frontend/src/lib/outputGuardSettings.test.mjs create mode 100644 frontend/src/lib/outputGuardSettings.ts create mode 100644 frontend/src/lib/settingsAutoSave.test.mjs create mode 100644 frontend/src/lib/settingsAutoSave.ts create mode 100644 frontend/src/lib/usageTiming.ts create mode 100644 internal/intelligencecheck/schedule.go create mode 100644 internal/intelligencecheck/schedule_test.go create mode 100644 proxy/acceptance_benchmark_test.go create mode 100644 proxy/acceptance_config_test.go create mode 100644 proxy/acceptance_lifecycle_test.go create mode 100644 proxy/antigravity_responses_tools.go create mode 100644 proxy/antigravity_responses_tools_test.go create mode 100644 proxy/handler_gemini_projector_test.go create mode 100644 proxy/output_guard_settings.go create mode 100644 proxy/output_guard_settings_test.go create mode 100644 proxy/outputguard_cancel.go create mode 100644 proxy/outputguard_cancel_test.go create mode 100644 proxy/outputguard_downstream.go create mode 100644 proxy/outputguard_downstream_test.go create mode 100644 proxy/outputguard_http_chat_test.go create mode 100644 proxy/outputguard_native_adapters.go create mode 100644 proxy/outputguard_native_http_test.go create mode 100644 proxy/outputguard_native_retry_test.go create mode 100644 proxy/outputguard_projection.go create mode 100644 proxy/outputguard_projection_test.go create mode 100644 proxy/outputguard_relay_responses_test.go create mode 100644 proxy/perfstat/cpu_linux.go create mode 100644 proxy/perfstat/cpu_other.go create mode 100644 proxy/perfstat/perfstat.go create mode 100644 proxy/perfstat/perfstat_test.go create mode 100644 proxy/quota_failover.go create mode 100644 proxy/response_cache_durable_test.go create mode 100644 proxy/response_context_diagnostics.go create mode 100644 proxy/stream_flush_writer_discard_concurrency_test.go create mode 100644 request_body_middleware.go create mode 100644 request_body_middleware_test.go create mode 100755 scripts/run_acceptance_suite.sh create mode 100644 security/outputguard/acceptance_detection_test.go create mode 100644 security/outputguard/channel.go create mode 100644 security/outputguard/channel_test.go create mode 100644 security/outputguard/guard.go create mode 100644 security/outputguard/guard_test.go create mode 100644 security/outputguard/repetition.go create mode 100644 security/outputguard/repetition_test.go create mode 100644 security/request_size.go diff --git a/.env.example b/.env.example index 5a8ebad95..13aeb217d 100644 --- a/.env.example +++ b/.env.example @@ -71,7 +71,7 @@ CODEX_PORT=8080 # USE_WEBSOCKET=true # Codex HTTP TLS 指纹:默认 standard;如需回滚旧行为可设 utls_chrome -# CODEX_TRANSPORT_MODE=standard +# CODEX_TRANSPORT_MODE=utls_chrome # 下游 HTTP/SSE 保活周期;Go duration,0 关闭。流式端点从首个心跳起提交 SSE: # Responses/Chat/Images 使用注释,Messages 使用原生 ping;非流式端点使用 HTTP 102。 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index b8584c791..b61c7e2a9 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -7,7 +7,11 @@ on: workflow_dispatch: inputs: build_version: - description: 'Version label shown in the admin UI, e.g. 2.0.6' + description: 'Version label and manual image tag, e.g. 2.0.6 or dev-abc1234' + required: false + type: string + promote_digest: + description: 'Existing image digest to tag as latest without rebuilding' required: false type: string @@ -31,6 +35,7 @@ jobs: uses: actions/checkout@v5 - name: Validate build version + if: ${{ inputs.promote_digest == '' }} env: BUILD_VERSION: ${{ inputs.build_version || github.ref_name }} run: | @@ -44,6 +49,7 @@ jobs: fi - name: Set up QEMU + if: ${{ inputs.promote_digest == '' }} uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx @@ -57,16 +63,20 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Extract Docker metadata + if: ${{ inputs.promote_digest == '' }} id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + flavor: latest=false tags: | type=semver,pattern={{version}},value=${{ inputs.build_version || github.ref_name }},enable=${{ inputs.build_version != '' || startsWith(github.ref, 'refs/tags/v') }} type=semver,pattern={{major}}.{{minor}},value=${{ inputs.build_version || github.ref_name }},enable=${{ inputs.build_version != '' || startsWith(github.ref, 'refs/tags/v') }} - type=raw,value=latest + type=raw,value=${{ inputs.build_version }},enable=${{ github.event_name == 'workflow_dispatch' && inputs.build_version != '' }} + type=raw,value=latest,enable=${{ github.event_name != 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch }} - name: Build and push Docker image + if: ${{ inputs.promote_digest == '' }} uses: docker/build-push-action@v6 with: context: . @@ -80,3 +90,16 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max provenance: false + + - name: Promote existing image to latest + if: ${{ inputs.promote_digest != '' }} + env: + SOURCE_DIGEST: ${{ inputs.promote_digest }} + run: | + if ! [[ "$SOURCE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "Invalid image digest" >&2 + exit 1 + fi + docker buildx imagetools create \ + --tag "${REGISTRY}/${IMAGE_NAME}:latest" \ + "${REGISTRY}/${IMAGE_NAME}@${SOURCE_DIGEST}" diff --git a/.upstream-release b/.upstream-release index d95827c3d..66cfae52b 100644 --- a/.upstream-release +++ b/.upstream-release @@ -1 +1 @@ -v3.1.2 +v3.1.3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 069355b6d..d056a68bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## v3.1.3 - 2026-11-04 + +### Features + +- **Model output runaway guard stops degenerate generations before they reach clients (#845, @ggg17226).** An opt-in guard detects periodic repetition, tool-argument delta floods, byte-budget overruns and terminal-frame abuse across Responses, Chat Completions, Anthropic, Gemini and Grok native streams, including downstream WebSocket. On a hit the upstream is cancelled, buffered output is discarded, and the client receives `502 model_output_guard_triggered` (or a WebSocket error frame with close code 1013) without automatic retry or account-health penalty; hits are recorded in `usage_logs` with `upstream_error_kind=model_output_guard`. Limits are configured from the settings page (tool ≤ channel ≤ request), polled every 5 seconds across instances, and default to off. Independently of the guard, SSE framing now enforces an 8 MiB per-line/event cap. + +- **Responses continuations can move off a quota-blocked account, and contexts survive restarts (#838, @dundunge).** When the bound account is confirmed quota-limited or auto-paused by a usage threshold, HTTP and WebSocket continuations whose context can be replayed are rebound to another eligible account; non-migratable references, encrypted compaction state or incomplete tool pairs still return an error. With the Memory cache driver and a SQLite main database, response contexts are also written to a separate `response-context.db` (deduplicated, 48-hour sliding retention, 2 GiB cap by default) and recovered after L1 eviction or restart; `RESPONSE_CONTEXT_STORE_PATH=off` disables it. The file holds conversation content and is not included in main-database backups. + +- **New `reset_5h_first` scheduler mode and per-account scheduled connection tests (#838, @dundunge).** The optional mode prefers available Codex accounts whose observed 5h window resets soonest, within existing priority and health tiers. Accounts can schedule weekday/weekend connection tests (off by default); enabled tests send real upstream requests, do not retry, and do not backfill missed slots. See `docs/RESET_5H_PRIORITY.md`. + +- **Antigravity quota gates are isolated per model family, with optional expiry-first scheduling (#848, requested by @yanhao98; #850).** Quota buckets and models are grouped into Gemini, Claude+GPT and Argon families by ID prefix. An exhausted `3p-weekly` bucket no longer blocks Gemini requests on the same account; the whole account is gated only when every recognised family is exhausted, and selection, 429 `Retry-After` and quota pressure all follow the requested model's family. Unknown buckets and models keep the previous account-wide behavior. The new `expiry_priority_enabled` setting (off by default, 1–168h window, default 24h) lets round-robin selection prefer accounts whose weekly bucket for the requested family resets soon with at least 5% remaining. + +- **Intelligence checks can back off adaptively and confirm recovery progressively (#849, @bikeread).** The opt-in mode backs off auto-paused accounts exponentially up to a configurable cap, switches to short confirmation intervals after the first pass, and resumes traffic only after the existing recovery threshold. Backoff state persists across restarts, and credentials rejected upstream pause automatic checks until they are updated. See `docs/INTELLIGENCE_BACKOFF.md`. + +- **The API key self-service usage page shows concurrency and richer request logs (#847, @bxb1337).** The page now displays the key's channel scope and current concurrency (refreshed every 5 seconds, paused when hidden), and request logs add client IP and User-Agent, reasoning effort, separate reasoning tokens, cache read/write, timing and throughput, sharing display components with the admin usage page. + +### Fixes + +- **Claude account and shared group editors convert auto-pause thresholds from percent (#843, reported by @xpzhang).** The Claude per-account scheduler dialog and the shared group manager labelled thresholds as percentages but submitted the raw value, so entering 95 failed with "must be between 0 and 1" and saved ratios were displayed as decimals. Both now convert percent to ratio on save and back on display, validate 0–100, and accept decimals. Values previously saved as `0.95` are correct and now display as 95. + +- **Codex maintenance requests and OAuth refresh share the conversation identity (#846, @bxb1337).** The separate client-identity toggle is removed; maintenance requests, model-manifest discovery and token refresh use the same identity resolution and per-account overrides as conversations. Code exchange no longer sends the fixed legacy `codex-cli/0.91.0` UA, refresh uses JSON with `refresh_reason`, and an unresolvable identity configuration now returns an error instead of falling back to a built-in identity. + +- **OAuth token exchange and refresh use the Chrome TLS fingerprint, and device profiles are stable by default (#839, @frank8ai).** Refresh-token and admin authorization-code requests no longer use the Go standard TLS stack. `STABILIZE_DEVICE_PROFILE` now defaults to `true`, so an account's device/system profile no longer drifts between requests; set it to `false` to restore the previous behavior. The example configuration recommends `CODEX_TRANSPORT_MODE=utls_chrome`; the code default is unchanged. + +- **Antigravity preserves deferred Responses tool identities (#842, @frank8ai).** Namespace, custom and tool-search declarations, search-loaded tools, call history and forced tool choices are bridged to flat Gemini functions and restored to their original identities in JSON, native SSE and buffered SSE responses. Streamed function-call updates merge only by matching call ID, so distinct calls with identical arguments are no longer collapsed. + +- **Context errors, 413 diagnostics, model manifests and pricing (#838, @dundunge).** The missing-context error code changes from `response_context_unavailable` to `previous_response_not_found`, prompting clients to resend the full context. Oversized-request 413 responses report the limit, detection stage and observed size. Merging live upstream manifests with account snapshots no longer clears capability fields for unobserved accounts. `gpt-6.1-terra` gains built-in pricing. + +- **Dependency security update.** `golang.org/x/net` moves to v0.62.0 for GO-2026-6860. + ## v3.1.2 - 2026-10-28 ### Features diff --git a/admin/antigravity_settings.go b/admin/antigravity_settings.go index 0401aa663..da0edd6ab 100644 --- a/admin/antigravity_settings.go +++ b/admin/antigravity_settings.go @@ -30,6 +30,8 @@ type antigravitySettingsResponse struct { QuotaRefreshEnabled bool `json:"quota_refresh_enabled"` QuotaRefreshIntervalMinutes int `json:"quota_refresh_interval_minutes"` QuotaGateEnabled bool `json:"quota_gate_enabled"` + ExpiryPriorityEnabled bool `json:"expiry_priority_enabled"` + ExpiryPriorityHorizonHours int `json:"expiry_priority_horizon_hours"` ConfigRevision int64 `json:"config_revision"` Choices []proxy.AntigravityRedirectChoice `json:"choices"` } @@ -47,6 +49,8 @@ func buildAntigravitySettingsResponse(settings auth.AntigravitySettings) antigra QuotaRefreshEnabled: settings.GetQuotaRefreshEnabled(), QuotaRefreshIntervalMinutes: settings.GetQuotaRefreshIntervalMinutes(), QuotaGateEnabled: settings.GetQuotaGateEnabled(), + ExpiryPriorityEnabled: settings.GetExpiryPriorityEnabled(), + ExpiryPriorityHorizonHours: settings.GetExpiryPriorityHorizonHours(), ConfigRevision: settings.ConfigRevision, Choices: proxy.AntigravityRedirectChoices(), } @@ -71,12 +75,18 @@ type antigravitySettingsPatch struct { quotaRefreshIntervalPresent bool quotaGateEnabled *bool quotaGateEnabledPresent bool + + expiryPriorityEnabled *bool + expiryPriorityEnabledPresent bool + expiryPriorityHorizonHours *int + expiryPriorityHorizonPresent bool } // empty 报告 patch 是否未包含任何更新字段。 func (p antigravitySettingsPatch) empty() bool { return p.modelRedirects == nil && p.redirectOverridesEffort == nil && p.exposeThoughts == nil && - !p.quotaRefreshEnabledPresent && !p.quotaRefreshIntervalPresent && !p.quotaGateEnabledPresent + !p.quotaRefreshEnabledPresent && !p.quotaRefreshIntervalPresent && !p.quotaGateEnabledPresent && + !p.expiryPriorityEnabledPresent && !p.expiryPriorityHorizonPresent } // isJSONNull 报告原始 JSON 字段是否显式为 null 字面量。 @@ -142,6 +152,32 @@ func parseAntigravitySettingsPatch(body []byte) (antigravitySettingsPatch, error return patch, err } patch.quotaGateEnabled = value + case "expiry_priority_enabled": + patch.expiryPriorityEnabledPresent = true + if isJSONNull(raw) { + patch.expiryPriorityEnabled = nil + continue + } + value, err := decodeAntigravityBool(raw, "expiry_priority_enabled") + if err != nil { + return patch, err + } + patch.expiryPriorityEnabled = value + case "expiry_priority_horizon_hours": + patch.expiryPriorityHorizonPresent = true + if isJSONNull(raw) { + patch.expiryPriorityHorizonHours = nil + continue + } + var hours int + if err := json.Unmarshal(raw, &hours); err != nil { + return patch, fmt.Errorf("expiry_priority_horizon_hours 必须是整数") + } + if hours < auth.AntigravityExpiryPriorityHorizonMinHours || hours > auth.AntigravityExpiryPriorityHorizonMaxHours { + return patch, fmt.Errorf("expiry_priority_horizon_hours 必须在 [%d, %d] 之间", + auth.AntigravityExpiryPriorityHorizonMinHours, auth.AntigravityExpiryPriorityHorizonMaxHours) + } + patch.expiryPriorityHorizonHours = &hours case "quota_refresh_interval_minutes": patch.quotaRefreshIntervalPresent = true if isJSONNull(raw) { @@ -210,6 +246,14 @@ func mergeAntigravitySettingsPatch(base auth.AntigravitySettings, patch antigrav merged.QuotaGateEnabled = restore.QuotaGateEnabled restored = true } + // 临期优先缺省关闭:混布窗口里被旧版本整包覆盖丢失只会回到「关」,不像门禁止血 + // 开关那样会被默认值无痕反转,因此不参与回写恢复。 + if patch.expiryPriorityEnabledPresent { + merged.ExpiryPriorityEnabled = patch.expiryPriorityEnabled + } + if patch.expiryPriorityHorizonPresent { + merged.ExpiryPriorityHorizonHours = patch.expiryPriorityHorizonHours + } return merged, restored } @@ -230,7 +274,7 @@ func (h *Handler) UpdateAntigravitySettings(c *gin.Context) { return } if patch.empty() { - writeError(c, http.StatusBadRequest, "缺少 model_redirects、redirect_overrides_effort、expose_thoughts、quota_refresh_enabled、quota_refresh_interval_minutes 或 quota_gate_enabled 字段") + writeError(c, http.StatusBadRequest, "缺少 model_redirects、redirect_overrides_effort、expose_thoughts、quota_refresh_enabled、quota_refresh_interval_minutes、quota_gate_enabled、expiry_priority_enabled 或 expiry_priority_horizon_hours 字段") return } if h.db == nil { @@ -295,9 +339,10 @@ func (h *Handler) UpdateAntigravitySettings(c *gin.Context) { // 本机按 revision 单调应用(含 revision 回退检测与基线重建)。 outcome := auth.ApplyConfiguredAntigravitySettings(applied) - log.Printf("设置已更新: antigravity_config redirects=%d overrides_effort=%v expose_thoughts=%v quota_refresh_enabled=%v quota_refresh_interval_minutes=%d quota_gate_enabled=%v config_revision=%d apply=%s", + log.Printf("设置已更新: antigravity_config redirects=%d overrides_effort=%v expose_thoughts=%v quota_refresh_enabled=%v quota_refresh_interval_minutes=%d quota_gate_enabled=%v expiry_priority_enabled=%v expiry_priority_horizon_hours=%d config_revision=%d apply=%s", len(applied.ModelRedirects), applied.RedirectOverridesEffort, applied.ExposeThoughts, applied.GetQuotaRefreshEnabled(), applied.GetQuotaRefreshIntervalMinutes(), applied.GetQuotaGateEnabled(), + applied.GetExpiryPriorityEnabled(), applied.GetExpiryPriorityHorizonHours(), applied.ConfigRevision, outcome) // quota_refresh_enabled / interval 可能已变化:立即唤醒配额巡检调度器按新配置重算槽位, // 不必等下一轮自然到点。 diff --git a/admin/antigravity_settings_test.go b/admin/antigravity_settings_test.go index 035bfca4a..bb91756e5 100644 --- a/admin/antigravity_settings_test.go +++ b/admin/antigravity_settings_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/codex2api/auth" "github.com/gin-gonic/gin" @@ -223,3 +224,55 @@ func TestAntigravitySettingsPUTRestoresQuotaFieldsDroppedByOldWriter(t *testing. t.Fatalf("persisted=%q err=%v", raw, err) } } + +// TestAntigravitySettingsPUTExpiryPriority 覆盖临期优先开关与时间窗的保存、范围校验与 null 重置。 +func TestAntigravitySettingsPUTExpiryPriority(t *testing.T) { + gin.SetMode(gin.TestMode) + previous := auth.ConfiguredAntigravitySettings() + t.Cleanup(func() { auth.SetConfiguredAntigravitySettings(previous) }) + auth.SetConfiguredAntigravitySettings(auth.AntigravitySettings{}) + db := newTestAdminDB(t) + handler := &Handler{db: db} + router := gin.New() + router.PUT("/api/admin/settings/antigravity", handler.UpdateAntigravitySettings) + + put := func(body string) (int, antigravitySettingsResponse) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/admin/settings/antigravity", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + var payload antigravitySettingsResponse + _ = json.Unmarshal(rec.Body.Bytes(), &payload) + return rec.Code, payload + } + + code, payload := put(`{"expiry_priority_enabled":true}`) + if code != http.StatusOK || !payload.ExpiryPriorityEnabled || payload.ExpiryPriorityHorizonHours != 24 { + t.Fatalf("enable: code=%d payload=%+v", code, payload) + } + if enabled, horizon := auth.AntigravityExpiryPriority(); !enabled || horizon != 24*time.Hour { + t.Fatalf("runtime expiry priority = %v %v", enabled, horizon) + } + + code, payload = put(`{"expiry_priority_horizon_hours":12}`) + if code != http.StatusOK || !payload.ExpiryPriorityEnabled || payload.ExpiryPriorityHorizonHours != 12 { + t.Fatalf("horizon: code=%d payload=%+v", code, payload) + } + if raw, err := db.GetAntigravityConfigRaw(context.Background()); err != nil || + !strings.Contains(raw, `"expiry_priority_enabled":true`) || !strings.Contains(raw, `"expiry_priority_horizon_hours":12`) { + t.Fatalf("persisted=%q err=%v", raw, err) + } + for _, body := range []string{`{"expiry_priority_horizon_hours":0}`, `{"expiry_priority_horizon_hours":169}`, `{"expiry_priority_enabled":"yes"}`} { + if code, _ := put(body); code != http.StatusBadRequest { + t.Fatalf("PUT %s status=%d, want 400", body, code) + } + } + + code, payload = put(`{"expiry_priority_enabled":null}`) + if code != http.StatusOK || payload.ExpiryPriorityEnabled || payload.ExpiryPriorityHorizonHours != 12 { + t.Fatalf("null reset: code=%d payload=%+v", code, payload) + } + if enabled, _ := auth.AntigravityExpiryPriority(); enabled { + t.Fatal("null must reset expiry priority to disabled") + } +} diff --git a/admin/codex_unified_client_identity_test.go b/admin/codex_unified_client_identity_test.go deleted file mode 100644 index f8b33277c..000000000 --- a/admin/codex_unified_client_identity_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package admin - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/codex2api/auth" - "github.com/codex2api/cache" - "github.com/codex2api/proxy" - "github.com/gin-gonic/gin" -) - -func TestCodexUnifiedClientIdentitySettingsHTTP(t *testing.T) { - gin.SetMode(gin.TestMode) - previous := proxy.CurrentRuntimeSettings() - t.Cleanup(func() { proxy.ApplyRuntimeSettings(previous) }) - db := newTestAdminDB(t) - tc := cache.NewMemory(4) - t.Cleanup(func() { _ = tc.Close() }) - cfg := defaultBootstrapSettings() - if err := db.UpdateSystemSettings(context.Background(), cfg); err != nil { - t.Fatal(err) - } - store := auth.NewStore(db, tc, cfg) - t.Cleanup(store.Stop) - proxy.ApplyRuntimeSettingsFromSystem(cfg) - h := NewHandler(store, db, tc, proxy.NewRateLimiter(cfg.GlobalRPM), "admin-secret") - router := gin.New() - router.GET("/api/admin/settings", h.GetSettings) - router.PUT("/api/admin/settings", h.UpdateSettings) - - request := func(method, body string) *httptest.ResponseRecorder { - req := httptest.NewRequest(method, "/api/admin/settings", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - return rec - } - check := func(want bool) { - t.Helper() - rec := request(http.MethodGet, "") - var response settingsResponse - if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &response) != nil { - t.Fatalf("GET %d %s", rec.Code, rec.Body.String()) - } - saved, err := db.GetSystemSettings(context.Background()) - if err != nil { - t.Fatal(err) - } - if response.CodexUnifiedClientIdentityEnabled != want || saved.CodexUnifiedClientIdentityEnabled != want || proxy.CurrentRuntimeSettings().CodexUnifiedClientIdentityEnabled != want { - t.Fatalf("response=%v saved=%v runtime=%v, want %v", response.CodexUnifiedClientIdentityEnabled, saved.CodexUnifiedClientIdentityEnabled, proxy.CurrentRuntimeSettings().CodexUnifiedClientIdentityEnabled, want) - } - } - - check(false) - for _, enabled := range []bool{true, false} { - body, _ := json.Marshal(map[string]bool{"codex_unified_client_identity_enabled": enabled}) - if rec := request(http.MethodPut, string(body)); rec.Code != http.StatusOK { - t.Fatalf("PUT %d %s", rec.Code, rec.Body.String()) - } - check(enabled) - } -} diff --git a/admin/handler.go b/admin/handler.go index 2f54bdd27..c67ee64e7 100644 --- a/admin/handler.go +++ b/admin/handler.go @@ -187,6 +187,10 @@ type Handler struct { autoActivate5hWake chan struct{} autoActivate5hStartOnce sync.Once autoActivate5hWG sync.WaitGroup + scheduledTestStartOnce sync.Once + scheduledTestWG sync.WaitGroup + scheduledTestGateOnce sync.Once + scheduledTestGate chan struct{} resetCreditPostMu sync.Mutex resetCreditPostWG sync.WaitGroup resetCreditPostCtx context.Context @@ -1099,6 +1103,7 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) { keyUsage := r.Group("/api/key-usage") keyUsage.GET("/summary", h.GetPublicAPIKeyUsageSummary) keyUsage.GET("/me", h.GetPublicAPIKeyUsageSummary) + keyUsage.GET("/concurrency", h.GetPublicAPIKeyConcurrency) // 账号自助添加公开门户(无 admin 鉴权;开关门控 + IP 限流;见 self_service.go) accountPortal := r.Group("/api/account-portal") @@ -1230,6 +1235,8 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) { api.GET("/accounts/invite/plan", h.GetInviteGuidePlan) api.POST("/accounts/invite/plan/probe", h.ProbeInviteGuidePlan) api.GET("/accounts/:id/test", h.TestConnection) + api.GET("/accounts/:id/scheduled-tests", h.GetScheduledConnectionTests) + api.PUT("/accounts/:id/scheduled-tests", h.UpdateScheduledConnectionTests) api.GET("/accounts/:id/model-detector", h.DetectCodexModel) api.GET("/modeltrace/bank", h.GetModelTraceBank) api.POST("/modeltrace/bank/update", h.UpdateModelTraceBank) @@ -4432,7 +4439,7 @@ func (h *Handler) FetchOpenAIResponsesModels(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), 20*time.Second) defer cancel() - // 编辑已有账号时带上账号本身:号池画像按账号抽取,统一身份开关下与该账号的 + // 编辑已有账号时带上账号本身:号池画像按账号抽取,与该账号的 // 对话请求取同一份身份(issue #774);新建账号尚未落库,为空。 var account *auth.Account if req.AccountID > 0 && h.store != nil { @@ -4613,7 +4620,9 @@ func fetchOpenAIResponsesModelIDs(ctx context.Context, account *auth.Account, ba } req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Accept", "application/json") - proxy.ApplyCodexModelDiscoveryHeaders(req.Header, account, baseURL, apiKey) + if err := proxy.ApplyCodexModelDiscoveryHeaders(req.Header, account, baseURL, apiKey); err != nil { + return nil, err + } for name, value := range customHeaders { name = strings.TrimSpace(name) if name == "" { @@ -9417,7 +9426,6 @@ type settingsResponse struct { CodexUserAgentConfig string `json:"codex_user_agent_config"` CodexTelemetryEnabled bool `json:"codex_telemetry_enabled"` CodexTelemetryTimingDebug bool `json:"codex_telemetry_timing_debug"` - CodexUnifiedClientIdentityEnabled bool `json:"codex_unified_client_identity_enabled"` UsageLogMode string `json:"usage_log_mode"` UsageLogBatchSize int `json:"usage_log_batch_size"` UsageLogFlushIntervalSeconds int `json:"usage_log_flush_interval_seconds"` @@ -9460,6 +9468,12 @@ type settingsResponse struct { OAuthModelCooldownSeconds int `json:"oauth_model_cooldown_seconds"` OAuthModelCooldownBackoffEnabled bool `json:"oauth_model_cooldown_backoff_enabled"` OAuthUnsupportedCooldownSeconds int `json:"oauth_unsupported_model_cooldown_seconds"` + OutputGuardEnabled bool `json:"output_guard_enabled"` + OutputGuardMaxToolArgumentBytes int64 `json:"output_guard_max_tool_argument_bytes"` + OutputGuardMaxToolArgumentDeltas int `json:"output_guard_max_tool_argument_deltas"` + OutputGuardMaxChannelBytes int64 `json:"output_guard_max_channel_bytes"` + OutputGuardMaxRequestBytes int64 `json:"output_guard_max_request_bytes"` + OutputGuardConfigGeneration int64 `json:"output_guard_config_generation"` } type rawJSON = json.RawMessage @@ -9591,7 +9605,6 @@ type updateSettingsReq struct { CodexUserAgentConfig *string `json:"codex_user_agent_config"` CodexTelemetryEnabled *bool `json:"codex_telemetry_enabled"` CodexTelemetryTimingDebug *bool `json:"codex_telemetry_timing_debug"` - CodexUnifiedClientIdentityEnabled *bool `json:"codex_unified_client_identity_enabled"` UsageLogMode *string `json:"usage_log_mode"` UsageLogBatchSize *int `json:"usage_log_batch_size"` UsageLogFlushIntervalSeconds *int `json:"usage_log_flush_interval_seconds"` @@ -9634,6 +9647,12 @@ type updateSettingsReq struct { OAuthModelCooldownSeconds *int `json:"oauth_model_cooldown_seconds"` OAuthModelCooldownBackoffEnabled *bool `json:"oauth_model_cooldown_backoff_enabled"` OAuthUnsupportedCooldownSeconds *int `json:"oauth_unsupported_model_cooldown_seconds"` + OutputGuardEnabled *bool `json:"output_guard_enabled"` + OutputGuardMaxToolArgumentBytes *int64 `json:"output_guard_max_tool_argument_bytes"` + OutputGuardMaxToolArgumentDeltas *int `json:"output_guard_max_tool_argument_deltas"` + OutputGuardMaxChannelBytes *int64 `json:"output_guard_max_channel_bytes"` + OutputGuardMaxRequestBytes *int64 `json:"output_guard_max_request_bytes"` + OutputGuardConfigGeneration rawJSON `json:"output_guard_config_generation"` } func updateSettingsHasFieldsOtherThanCustomPatterns(req updateSettingsReq) bool { @@ -10238,6 +10257,18 @@ func (h *Handler) GetSettings(c *gin.Context) { writeError(c, http.StatusInternalServerError, "读取响应缓存设置失败:"+err.Error()) return } + outputGuardSettings, err := h.db.GetOutputGuardSettings(ctx) + if err != nil { + // 防护设置读取失败时不能用默认值兜底:代理进程可能仍在用上一次成功 + // 应用的配置(可能已启用),返回默认值会让 UI 显示与实际运行状态不符。 + log.Printf("读取输出防护设置失败: %v", err) + writeError(c, http.StatusInternalServerError, "读取防护设置失败:"+err.Error()) + return + } + outputGuardEnabled := false + if outputGuardSettings.Enabled != nil { + outputGuardEnabled = *outputGuardSettings.Enabled + } dbSettings, _ := h.db.GetSystemSettings(ctx) _, adminAuthSource := h.resolveAdminSecret(c.Request.Context()) adminSecret := "" @@ -10442,7 +10473,6 @@ func (h *Handler) GetSettings(c *gin.Context) { CodexUserAgentConfig: runtimeCfg.CodexUserAgentConfig, CodexTelemetryEnabled: runtimeCfg.CodexTelemetryEnabled, CodexTelemetryTimingDebug: runtimeCfg.CodexTelemetryTimingDebug, - CodexUnifiedClientIdentityEnabled: runtimeCfg.CodexUnifiedClientIdentityEnabled, UsageLogMode: h.db.GetUsageLogMode(), UsageLogBatchSize: h.db.GetUsageLogBatchSize(), UsageLogFlushIntervalSeconds: h.db.GetUsageLogFlushIntervalSeconds(), @@ -10473,6 +10503,12 @@ func (h *Handler) GetSettings(c *gin.Context) { SmartPacingMinConcurrency: h.store.GetSmartPacingMinConcurrency(), SmartPacingWindows: h.store.GetSmartPacingWindows(), IgnoreUsageLimitStatus: h.store.IgnoreUsageLimitStatus(), + OutputGuardEnabled: outputGuardEnabled, + OutputGuardMaxToolArgumentBytes: outputGuardSettings.MaxToolArgumentBytes, + OutputGuardMaxToolArgumentDeltas: outputGuardSettings.MaxToolArgumentDeltas, + OutputGuardMaxChannelBytes: outputGuardSettings.MaxChannelBytes, + OutputGuardMaxRequestBytes: outputGuardSettings.MaxRequestBytes, + OutputGuardConfigGeneration: outputGuardSettings.ConfigGeneration, }) } @@ -10599,6 +10635,10 @@ func (h *Handler) UpdateSettings(c *gin.Context) { writeError(c, http.StatusBadRequest, "response_cache_config_generation 为只读字段") return } + if req.OutputGuardConfigGeneration != nil { + writeError(c, http.StatusBadRequest, "output_guard_config_generation 为只读字段") + return + } if req.CodexImagesMainModel != nil { normalized, err := proxy.NormalizeImagesMainModel(*req.CodexImagesMainModel) if err != nil { @@ -10748,6 +10788,34 @@ func (h *Handler) UpdateSettings(c *gin.Context) { return } + outputGuardUpdate := database.OutputGuardSettingsUpdate{ + Enabled: req.OutputGuardEnabled, + MaxToolArgumentBytes: req.OutputGuardMaxToolArgumentBytes, + MaxToolArgumentDeltas: req.OutputGuardMaxToolArgumentDeltas, + MaxChannelBytes: req.OutputGuardMaxChannelBytes, + MaxRequestBytes: req.OutputGuardMaxRequestBytes, + } + outputGuardUpdateRequested := outputGuardUpdate.Enabled != nil || + outputGuardUpdate.MaxToolArgumentBytes != nil || + outputGuardUpdate.MaxToolArgumentDeltas != nil || + outputGuardUpdate.MaxChannelBytes != nil || + outputGuardUpdate.MaxRequestBytes != nil + + // 防护设置的读取、合并、校验与 generation 递增全部交给 + // UpdateSystemSettingsWithOutputGuard 在同一个写事务内完成。这里不再做事务外 + // 的预读取/预合并/预校验:并发保存时事务外读到的旧值会把合法 patch 误判为 + // 400,而事务内真正的拒绝又会被当成 500。 + var outputGuardSettings database.OutputGuardSettings + if !outputGuardUpdateRequested { + latestGuard, err := h.db.GetOutputGuardSettings(c.Request.Context()) + if err != nil { + log.Printf("读取输出防护设置失败: %v", err) + writeError(c, http.StatusInternalServerError, "读取防护设置失败:"+err.Error()) + return + } + outputGuardSettings = latestGuard + } + currentAdminSecret := "" siteName := database.DefaultSiteName siteLogo := "" @@ -10860,6 +10928,7 @@ func (h *Handler) UpdateSettings(c *gin.Context) { } hasAdminSecret := strings.TrimSpace(currentAdminSecret) != "" || strings.TrimSpace(h.adminSecretEnv) != "" runtimeCfg := proxy.CurrentRuntimeSettings() + previousMaxConcurrency := h.store.GetMaxConcurrency() previousAutoResetCreditsEnabled := runtimeCfg.AutoResetCreditsEnabled previousAutoResetCreditsOnExhaustionEnabled := runtimeCfg.AutoResetCreditsOnExhaustionEnabled previousAutoResetCreditsBeforeExpiryMin := runtimeCfg.AutoResetCreditsBeforeExpiryMin @@ -10891,23 +10960,28 @@ func (h *Handler) UpdateSettings(c *gin.Context) { usageLogBatchSize := h.db.GetUsageLogBatchSize() usageLogFlushIntervalSeconds := h.db.GetUsageLogFlushIntervalSeconds() + maxConcurrencyToApply := previousMaxConcurrency + maxConcurrencyChanged := false if req.MaxConcurrency != nil { v := *req.MaxConcurrency if v < 1 { v = 1 } // 不再设上限:由运营按机器与上游承载自行决定 - h.store.SetMaxConcurrency(v) - log.Printf("设置已更新: max_concurrency = %d", v) + maxConcurrencyToApply = v + maxConcurrencyChanged = true } + previousGlobalRPM := h.rateLimiter.GetRPM() + globalRPMToApply := previousGlobalRPM + globalRPMChanged := false if req.GlobalRPM != nil { v := *req.GlobalRPM if v < 0 { v = 0 } - h.rateLimiter.UpdateRPM(v) - log.Printf("设置已更新: global_rpm = %d", v) + globalRPMToApply = v + globalRPMChanged = true } if req.TestModel != nil && *req.TestModel != "" { @@ -11418,6 +11492,9 @@ func (h *Handler) UpdateSettings(c *gin.Context) { log.Printf("设置已更新: antigravity_oauth_clients = %d 个 client, active_key=%q", len(normalized.Clients), normalized.ActiveKey) } + previousMaxRetries := h.store.GetMaxRetries() + maxRetriesToApply := previousMaxRetries + maxRetriesChanged := false if req.MaxRetries != nil { v := *req.MaxRetries if v < 0 { @@ -11426,10 +11503,13 @@ func (h *Handler) UpdateSettings(c *gin.Context) { if v > 10 { v = 10 } - h.store.SetMaxRetries(v) - log.Printf("设置已更新: max_retries = %d", v) + maxRetriesToApply = v + maxRetriesChanged = true } + previousMaxRateLimitRetries := h.store.GetMaxRateLimitRetries() + maxRateLimitRetriesToApply := previousMaxRateLimitRetries + maxRateLimitRetriesChanged := false if req.MaxRateLimitRetries != nil { v := *req.MaxRateLimitRetries if v < 0 { @@ -11438,8 +11518,8 @@ func (h *Handler) UpdateSettings(c *gin.Context) { if v > 10 { v = 10 } - h.store.SetMaxRateLimitRetries(v) - log.Printf("设置已更新: max_rate_limit_retries = %d", v) + maxRateLimitRetriesToApply = v + maxRateLimitRetriesChanged = true } if req.RetryIntervalMS != nil { @@ -11537,10 +11617,6 @@ func (h *Handler) UpdateSettings(c *gin.Context) { runtimeCfg.CodexTelemetryTimingDebug = *req.CodexTelemetryTimingDebug log.Printf("设置已更新: codex_telemetry_timing_debug = %t", runtimeCfg.CodexTelemetryTimingDebug) } - if req.CodexUnifiedClientIdentityEnabled != nil { - runtimeCfg.CodexUnifiedClientIdentityEnabled = *req.CodexUnifiedClientIdentityEnabled - log.Printf("设置已更新: codex_unified_client_identity_enabled = %t", runtimeCfg.CodexUnifiedClientIdentityEnabled) - } if req.StreamFlushPolicy != nil { runtimeCfg.StreamFlushPolicy = proxy.NormalizeStreamFlushPolicy(*req.StreamFlushPolicy) log.Printf("设置已更新: stream_flush_policy = %s", runtimeCfg.StreamFlushPolicy) @@ -11868,11 +11944,11 @@ func (h *Handler) UpdateSettings(c *gin.Context) { } // 持久化保存到数据库 - err = h.db.UpdateSystemSettings(c.Request.Context(), &database.SystemSettings{ + systemSettings := &database.SystemSettings{ SiteName: siteName, SiteLogo: siteLogo, - MaxConcurrency: h.store.GetMaxConcurrency(), - GlobalRPM: h.rateLimiter.GetRPM(), + MaxConcurrency: maxConcurrencyToApply, + GlobalRPM: globalRPMToApply, TestModel: h.store.GetTestModel(), CodexImagesMainModel: codexImagesMainModel, TestContent: h.store.GetTestContent(), @@ -11938,8 +12014,8 @@ func (h *Handler) UpdateSettings(c *gin.Context) { SessionAffinitySpread: h.store.GetSessionAffinitySpread(), SessionSlotBufferEnabled: sessionSlotBufferEnabled, SessionSlotBufferSeconds: sessionSlotBufferSeconds, - MaxRetries: h.store.GetMaxRetries(), - MaxRateLimitRetries: h.store.GetMaxRateLimitRetries(), + MaxRetries: maxRetriesToApply, + MaxRateLimitRetries: maxRateLimitRetriesToApply, RetryIntervalMS: h.store.GetRetryIntervalMS(), TransportRetryPolicy: h.store.GetTransportRetryPolicy(), ContinuousRetryPolicy: database.EncodeContinuousRetryPolicy(h.store.GetContinuousRetryPolicy()), @@ -11975,7 +12051,6 @@ func (h *Handler) UpdateSettings(c *gin.Context) { CodexUserAgentConfig: runtimeCfg.CodexUserAgentConfig, CodexTelemetryEnabled: runtimeCfg.CodexTelemetryEnabled, CodexTelemetryTimingDebug: runtimeCfg.CodexTelemetryTimingDebug, - CodexUnifiedClientIdentityEnabled: runtimeCfg.CodexUnifiedClientIdentityEnabled, UsageLogMode: usageLogMode, UsageLogBatchSize: usageLogBatchSize, UsageLogFlushIntervalSeconds: usageLogFlushIntervalSeconds, @@ -12002,9 +12077,27 @@ func (h *Handler) UpdateSettings(c *gin.Context) { IgnoreUsageLimitStatus: h.store.IgnoreUsageLimitStatus(), ModelPricingOverrides: modelPricingOverrides, ModelPricingSyncURL: modelPricingSyncURL, - }) + } + + // 防护列与 system_settings 其余列在同一行、同一事务中提交:任一写入失败 + // 都不会留下已保存的防护配置。热应用必须等事务提交成功后再执行。 + var committedOutputGuard database.OutputGuardSettings + if outputGuardUpdateRequested { + committedOutputGuard, err = h.db.UpdateSystemSettingsWithOutputGuard(c.Request.Context(), systemSettings, outputGuardUpdate) + } else { + err = h.db.UpdateSystemSettings(c.Request.Context(), systemSettings) + } if err != nil { log.Printf("无法持久化保存设置: %v", err) + if outputGuardUpdateRequested { + // 事务内校验失败是调用方输入问题,映射 400;其余(数据库/IO 等)才是 500。 + if errors.Is(err, database.ErrInvalidOutputGuardSettings) { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + writeError(c, http.StatusInternalServerError, "保存防护设置失败,设置未生效:"+err.Error()) + return + } if req.CodexImagesMainModel != nil { writeError(c, http.StatusInternalServerError, "保存生图设置失败,文本驱动模型未生效") return @@ -12043,7 +12136,36 @@ func (h *Handler) UpdateSettings(c *gin.Context) { writeError(c, http.StatusInternalServerError, "保存持续重试策略失败,设置未生效") return } + writeError(c, http.StatusInternalServerError, "保存系统设置失败,设置未生效:"+err.Error()) + return } else { + // settings 事务提交成功后立即热应用基础运行时上限与全局限流, + // 确保后续 UpdateContinuousRetryPolicy 等独立步骤即使失败, + // 已提交到数据库的并发/重试/RPM 配置在本实例内存中也保持一致。 + if maxConcurrencyChanged { + h.store.SetMaxConcurrency(maxConcurrencyToApply) + log.Printf("设置已更新: max_concurrency = %d", maxConcurrencyToApply) + } + if maxRetriesChanged { + h.store.SetMaxRetries(maxRetriesToApply) + log.Printf("设置已更新: max_retries = %d", maxRetriesToApply) + } + if maxRateLimitRetriesChanged { + h.store.SetMaxRateLimitRetries(maxRateLimitRetriesToApply) + log.Printf("设置已更新: max_rate_limit_retries = %d", maxRateLimitRetriesToApply) + } + if globalRPMChanged { + h.rateLimiter.UpdateRPM(globalRPMToApply) + log.Printf("设置已更新: global_rpm = %d", globalRPMToApply) + } + if outputGuardUpdateRequested { + outputGuardSettings = committedOutputGuard + if proxy.ApplyOutputGuardSettings(committedOutputGuard) { + log.Printf("设置已更新: output_guard generation=%d", committedOutputGuard.ConfigGeneration) + } else { + log.Printf("输出防护配置已提交,但运行时应用被拒绝(已有更新的 generation 或配置非法): generation=%d", committedOutputGuard.ConfigGeneration) + } + } if req.SessionSlotBufferSeconds != nil { h.store.SetSessionSlotBuffer(time.Duration(sessionSlotBufferSeconds) * time.Second) log.Printf("设置已更新: session_slot_buffer_seconds = %d", sessionSlotBufferSeconds) @@ -12352,7 +12474,6 @@ func (h *Handler) UpdateSettings(c *gin.Context) { CodexUserAgentConfig: runtimeCfg.CodexUserAgentConfig, CodexTelemetryEnabled: runtimeCfg.CodexTelemetryEnabled, CodexTelemetryTimingDebug: runtimeCfg.CodexTelemetryTimingDebug, - CodexUnifiedClientIdentityEnabled: runtimeCfg.CodexUnifiedClientIdentityEnabled, UsageLogMode: usageLogMode, UsageLogBatchSize: usageLogBatchSize, UsageLogFlushIntervalSeconds: usageLogFlushIntervalSeconds, @@ -12383,6 +12504,12 @@ func (h *Handler) UpdateSettings(c *gin.Context) { SmartPacingMinConcurrency: h.store.GetSmartPacingMinConcurrency(), SmartPacingWindows: h.store.GetSmartPacingWindows(), IgnoreUsageLimitStatus: h.store.IgnoreUsageLimitStatus(), + OutputGuardEnabled: outputGuardSettings.Enabled == nil || *outputGuardSettings.Enabled, + OutputGuardMaxToolArgumentBytes: outputGuardSettings.MaxToolArgumentBytes, + OutputGuardMaxToolArgumentDeltas: outputGuardSettings.MaxToolArgumentDeltas, + OutputGuardMaxChannelBytes: outputGuardSettings.MaxChannelBytes, + OutputGuardMaxRequestBytes: outputGuardSettings.MaxRequestBytes, + OutputGuardConfigGeneration: outputGuardSettings.ConfigGeneration, }) } diff --git a/admin/intelligence_check_executor.go b/admin/intelligence_check_executor.go index a6cef6fbc..1d2b16ef3 100644 --- a/admin/intelligence_check_executor.go +++ b/admin/intelligence_check_executor.go @@ -19,18 +19,19 @@ import ( const intelligenceOutputLimit = 256 * 1024 type intelligenceExecution struct { - Output string - Error string - Complete bool - Interrupted bool - DurationMS int64 - ResponseModel string - InputTokens int - OutputTokens int - ReasoningTokens int - CachedTokens int - terminalPayload []byte - EffectiveContext map[string]string + CredentialRejected bool + Output string + Error string + Complete bool + Interrupted bool + DurationMS int64 + ResponseModel string + InputTokens int + OutputTokens int + ReasoningTokens int + CachedTokens int + terminalPayload []byte + EffectiveContext map[string]string } // Uses the same outbound executors as generation, with no browser request context @@ -123,6 +124,7 @@ func (h *Handler) executeIntelligenceCheck(ctx context.Context, account *auth.Ac if resp.StatusCode != http.StatusOK { raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) result.Error = fmt.Sprintf("上游返回 %d: %s", resp.StatusCode, truncate(string(raw), 4096)) + result.CredentialRejected = intelligenceCredentialRejected(raw) h.applyIntelligenceUpstreamFailure(account, resp, model, raw) return } @@ -132,6 +134,7 @@ func (h *Handler) executeIntelligenceCheck(ctx context.Context, account *auth.Ac proxy.SyncCodexUsageState(h.store, account, resp) } result = readIntelligenceStream(resp.Body, claude) + result.CredentialRejected = intelligenceCredentialRejected(result.terminalPayload) if ctx.Err() != nil { result.Complete = false result.Error = ctx.Err().Error() @@ -362,3 +365,13 @@ func readIntelligenceStream(body io.Reader, claude bool) (result intelligenceExe } return } + +func intelligenceCredentialRejected(payload []byte) bool { + for _, path := range []string{"error.code", "response.error.code"} { + switch gjson.GetBytes(payload, path).String() { + case "token_revoked", "account_deactivated", "workspace_deactivated", "organization_deactivated": + return true + } + } + return false +} diff --git a/admin/intelligence_check_executor_test.go b/admin/intelligence_check_executor_test.go index 5c462fec1..adf7ed6f4 100644 --- a/admin/intelligence_check_executor_test.go +++ b/admin/intelligence_check_executor_test.go @@ -126,3 +126,16 @@ func TestIntelligenceBoundProxyFailureNeverFallsBack(t *testing.T) { t.Fatalf("proxy network failure result=%+v", result) } } + +func TestIntelligenceCredentialRejected(t *testing.T) { + for _, raw := range []string{`{"error":{"code":"token_revoked"}}`, `{"response":{"error":{"code":"account_deactivated"}}}`} { + if !intelligenceCredentialRejected([]byte(raw)) { + t.Fatal(raw) + } + } + for _, raw := range []string{`{"error":{"code":"server_error"}}`, `{"error":{"code":"token_expired"}}`, `{"output":"token_revoked"}`, `invalid JSON`} { + if intelligenceCredentialRejected([]byte(raw)) { + t.Fatal("transient or generated text blocked credentials", raw) + } + } +} diff --git a/admin/intelligence_check_runner.go b/admin/intelligence_check_runner.go index 4789fe07d..b6fd27bc9 100644 --- a/admin/intelligence_check_runner.go +++ b/admin/intelligence_check_runner.go @@ -96,7 +96,7 @@ func (h *Handler) runIntelligenceCheck(parent context.Context, run database.Inte result = h.executeIntelligenceCheck(ctx, account, run.Model, run.ReasoningEffort, run.Question.Prompt) } cancel() - observation := ic.Observation{Interrupted: result.Interrupted, Output: result.Output, Error: result.Error, Complete: result.Complete, DurationMS: result.DurationMS, InputTokens: int64(result.InputTokens), OutputTokens: int64(result.OutputTokens), ReasoningTokens: int64(result.ReasoningTokens), ResponseModel: result.ResponseModel, EffectiveContext: result.EffectiveContext} + observation := ic.Observation{CredentialRejected: result.CredentialRejected, Interrupted: result.Interrupted, Output: result.Output, Error: result.Error, Complete: result.Complete, DurationMS: result.DurationMS, InputTokens: int64(result.InputTokens), OutputTokens: int64(result.OutputTokens), ReasoningTokens: int64(result.ReasoningTokens), ResponseModel: result.ResponseModel, EffectiveContext: result.EffectiveContext} if parent.Err() != nil { observation.Interrupted = true observation.Error = "服务关闭,检测已中断" diff --git a/admin/key_usage_public.go b/admin/key_usage_public.go index 4a3b3a0eb..8355f39ed 100644 --- a/admin/key_usage_public.go +++ b/admin/key_usage_public.go @@ -2,7 +2,6 @@ package admin import ( "context" - "database/sql" "errors" "net/http" "strconv" @@ -17,6 +16,7 @@ import ( const ( publicAPIKeyUsageDefaultLogPageSize = 25 publicAPIKeyUsageMaxLogPageSize = 100 + publicAPIKeyUsageTimeout = 8 * time.Second ) type publicAPIKeyUsageResponse struct { @@ -47,22 +47,10 @@ type publicAPIKeyUsageRange struct { } func (h *Handler) GetPublicAPIKeyUsageSummary(c *gin.Context) { - if h == nil || h.db == nil { - writeError(c, http.StatusServiceUnavailable, "服务未就绪") - return - } - enabled, err := h.PublicAPIKeyUsagePageEnabled(c.Request.Context()) - if err != nil { - writeInternalError(c, err) - return - } - if !enabled { - writeError(c, http.StatusNotFound, "API Key 自助用量页未启用") - return - } - key := extractPublicAPIKey(c) - if key == "" { - writeError(c, http.StatusUnauthorized, "缺少 Authorization Bearer API Key") + ctx, cancel := context.WithTimeout(c.Request.Context(), publicAPIKeyUsageTimeout) + defer cancel() + row := h.publicAPIKeyUsageRow(c, ctx) + if row == nil { return } @@ -82,27 +70,6 @@ func (h *Handler) GetPublicAPIKeyUsageSummary(c *gin.Context) { return } - ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second) - defer cancel() - - row, err := h.db.GetAPIKeyByValue(ctx, key) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - security.SecurityAuditLog("KEY_USAGE_AUTH_FAILED", "ip="+security.SanitizeLog(c.ClientIP())+" key="+security.MaskAPIKey(key)) - writeError(c, http.StatusUnauthorized, "API Key 无效或不存在") - return - } - writeInternalError(c, err) - return - } - // 停用是管理员吊销凭据,与代理链路一致直接拒绝;过期/额度用尽仍可读, - // 让持有者看到状态与历史用量,明白调用为何失败。 - if !row.Enabled { - security.SecurityAuditLog("KEY_USAGE_AUTH_FAILED_DISABLED_KEY", "ip="+security.SanitizeLog(c.ClientIP())+" key="+security.MaskAPIKey(key)) - writeError(c, http.StatusUnauthorized, "API Key 已停用") - return - } - report, err := h.db.GetAPIKeySelfUsageReportFiltered(ctx, row.ID, rangeStart, rangeEnd, logPage, logPageSize, logFilter) if err != nil { writeInternalError(c, err) diff --git a/admin/key_usage_public_auth.go b/admin/key_usage_public_auth.go new file mode 100644 index 000000000..99b928107 --- /dev/null +++ b/admin/key_usage_public_auth.go @@ -0,0 +1,50 @@ +package admin + +import ( + "context" + "database/sql" + "errors" + "net/http" + + "github.com/codex2api/database" + "github.com/codex2api/security" + "github.com/gin-gonic/gin" +) + +func (h *Handler) publicAPIKeyUsageRow(c *gin.Context, ctx context.Context) *database.APIKeyRow { + if h == nil || h.db == nil { + writeError(c, http.StatusServiceUnavailable, "服务未就绪") + return nil + } + enabled, err := h.PublicAPIKeyUsagePageEnabled(ctx) + if err != nil { + writeInternalError(c, err) + return nil + } + if !enabled { + writeError(c, http.StatusNotFound, "API Key 自助用量页未启用") + return nil + } + key := extractPublicAPIKey(c) + if key == "" { + writeError(c, http.StatusUnauthorized, "缺少 Authorization Bearer API Key") + return nil + } + row, err := h.db.GetAPIKeyByValue(ctx, key) + if errors.Is(err, sql.ErrNoRows) { + security.SecurityAuditLog("KEY_USAGE_AUTH_FAILED", "ip="+security.SanitizeLog(c.ClientIP())+" key="+security.MaskAPIKey(key)) + writeError(c, http.StatusUnauthorized, "API Key 无效或不存在") + return nil + } + if err != nil { + writeInternalError(c, err) + return nil + } + // 过期或额度用尽仍可查询,管理员停用的凭据直接拒绝。 + if !row.Enabled { + security.SecurityAuditLog("KEY_USAGE_AUTH_FAILED_DISABLED_KEY", "ip="+security.SanitizeLog(c.ClientIP())+" key="+security.MaskAPIKey(key)) + writeError(c, http.StatusUnauthorized, "API Key 已停用") + return nil + } + return row +} diff --git a/admin/key_usage_public_concurrency.go b/admin/key_usage_public_concurrency.go new file mode 100644 index 000000000..ac87464db --- /dev/null +++ b/admin/key_usage_public_concurrency.go @@ -0,0 +1,18 @@ +package admin + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" +) + +func (h *Handler) GetPublicAPIKeyConcurrency(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), publicAPIKeyUsageTimeout) + defer cancel() + row := h.publicAPIKeyUsageRow(c, ctx) + if row == nil { + return + } + c.JSON(http.StatusOK, gin.H{"current": h.authCacheProxy.APIKeyConcurrency(row.ID)}) +} diff --git a/admin/oauth.go b/admin/oauth.go index 661a87698..7d7c327a2 100644 --- a/admin/oauth.go +++ b/admin/oauth.go @@ -214,6 +214,11 @@ func (h *Handler) GenerateOAuthURL(c *gin.Context) { return } + authURL, err := buildOAuthAuthorizeURL(redirectURI, state, codeVerifier) + if err != nil { + writeError(c, http.StatusBadRequest, "客户端身份配置无效: "+err.Error()) + return + } globalOAuthStore.set(sessionID, &oauthSession{ State: state, CodeVerifier: codeVerifier, @@ -223,13 +228,17 @@ func (h *Handler) GenerateOAuthURL(c *gin.Context) { }) c.JSON(http.StatusOK, gin.H{ - "auth_url": buildOAuthAuthorizeURL(redirectURI, state, codeVerifier), + "auth_url": authURL, "session_id": sessionID, }) } // buildOAuthAuthorizeURL 组装 Codex CLI PKCE 授权链接(admin 与公开自助门户共用)。 -func buildOAuthAuthorizeURL(redirectURI, state, codeVerifier string) string { +func buildOAuthAuthorizeURL(redirectURI, state, codeVerifier string) (string, error) { + headers, err := proxy.CodexOAuthIdentityHeaders(nil) + if err != nil { + return "", err + } params := neturl.Values{} params.Set("response_type", "code") params.Set("client_id", oauthClientID) @@ -240,7 +249,8 @@ func buildOAuthAuthorizeURL(redirectURI, state, codeVerifier string) string { params.Set("code_challenge_method", "S256") params.Set("id_token_add_organizations", "true") params.Set("codex_cli_simplified_flow", "true") - return oauthAuthorizeURL + "?" + params.Encode() + params.Set("originator", headers.Get("Originator")) + return oauthAuthorizeURL + "?" + params.Encode(), nil } // ExchangeOAuthCode 用授权码兑换 token,并写入新账号 @@ -737,7 +747,6 @@ func doOAuthCodeExchange(ctx context.Context, code, codeVerifier, redirectURI, p } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", "codex-cli/0.91.0") // Resin 反代:注入临时账号身份头 if proxy.IsResinEnabled() && tempID != "" { @@ -748,7 +757,7 @@ func doOAuthCodeExchange(ctx context.Context, code, codeVerifier, redirectURI, p if proxy.IsResinEnabled() && tempID != "" { client = &http.Client{Timeout: 30 * time.Second} } else { - client = auth.BuildHTTPClient(proxyURL) + client = auth.BuildUTLSHTTPClient(proxyURL) } resp, err := client.Do(req) if err != nil { diff --git a/admin/oauth_test.go b/admin/oauth_test.go index b860f3409..e1752f5c7 100644 --- a/admin/oauth_test.go +++ b/admin/oauth_test.go @@ -26,6 +26,9 @@ func TestExchangeOAuthCodeSeedsAccessTokenFromExchangeResponse(t *testing.T) { handler := &Handler{db: db, store: store} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if "codex-cli/0.91.0" == r.Header.Get("User-Agent") { + t.Error("token exchange still sends the obsolete hard-coded User-Agent") + } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ "access_token": "access-from-exchange", diff --git a/admin/output_guard_settings_test.go b/admin/output_guard_settings_test.go new file mode 100644 index 000000000..e61703edc --- /dev/null +++ b/admin/output_guard_settings_test.go @@ -0,0 +1,689 @@ +package admin + +import ( + "context" + "database/sql" + "encoding/json" + "log" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/codex2api/auth" + "github.com/codex2api/cache" + "github.com/codex2api/database" + "github.com/codex2api/proxy" + "github.com/gin-gonic/gin" +) + +func newTestAdminHandlerFixture(t *testing.T) (*Handler, *database.DB) { + t.Helper() + handler, db, _ := newTestAdminHandlerFixtureWithPath(t) + return handler, db +} + +func newTestAdminHandlerFixtureWithPath(t *testing.T) (*Handler, *database.DB, string) { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "admin-output-guard-test.sqlite") + db, err := database.New("sqlite", dbPath) + if err != nil { + t.Fatalf("new test db: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + tc := cache.NewMemory(4) + t.Cleanup(func() { _ = tc.Close() }) + settings := defaultBootstrapSettings() + if err := db.UpdateSystemSettings(context.Background(), settings); err != nil { + t.Fatalf("seed settings: %v", err) + } + store := auth.NewStore(db, tc, settings) + t.Cleanup(store.Stop) + handler := NewHandler(store, db, tc, proxy.NewRateLimiter(settings.GlobalRPM), "admin-secret") + return handler, db, dbPath +} + +// execAdminOutputGuardTestSQLite 在测试库上执行一条原始 SQL,用于模拟防护列 +// 读取/写入失败(例如 schema 缺失),而不影响 system_settings 的其他列。 +func execAdminOutputGuardTestSQLite(t *testing.T, dbPath, statement string) { + t.Helper() + raw, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("open raw sqlite db: %v", err) + } + defer func() { _ = raw.Close() }() + if _, err := raw.Exec(statement); err != nil { + t.Fatalf("exec %q: %v", statement, err) + } +} + +func queryAdminOutputGuardTestSQLiteBool(t *testing.T, dbPath, query string) bool { + t.Helper() + raw, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("open raw sqlite db: %v", err) + } + defer func() { _ = raw.Close() }() + var value int + if err := raw.QueryRow(query).Scan(&value); err != nil { + t.Fatalf("query %q: %v", query, err) + } + return value != 0 +} + +// lockedLogBuffer 是并发安全的日志捕获器:handler 和后台 goroutine 都可能写 +// 标准 logger,测试断言读取时必须加锁。 +type lockedLogBuffer struct { + mu sync.Mutex + buf strings.Builder +} + +func (b *lockedLogBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *lockedLogBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +func invokeAdminSettingsEndpoint(t *testing.T, handler *Handler, method string, payload map[string]any) *httptest.ResponseRecorder { + t.Helper() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + var bodyReader *strings.Reader + if payload != nil { + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload failed: %v", err) + } + bodyReader = strings.NewReader(string(body)) + } else { + bodyReader = strings.NewReader("") + } + c.Request = httptest.NewRequest(method, "/api/admin/settings", bodyReader) + c.Request.Header.Set("Content-Type", "application/json") + if method == http.MethodGet { + handler.GetSettings(c) + } else { + handler.UpdateSettings(c) + } + return w +} + +func TestAdminOutputGuardSettings_GetSymmetricFields(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, _ := newTestAdminHandlerFixture(t) + + w := invokeAdminSettingsEndpoint(t, handler, http.MethodGet, nil) + if w.Code != http.StatusOK { + t.Fatalf("expected HTTP 200, got %d, body: %s", w.Code, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + fields := []string{ + "output_guard_enabled", "output_guard_max_tool_argument_bytes", + "output_guard_max_tool_argument_deltas", "output_guard_max_channel_bytes", + "output_guard_max_request_bytes", "output_guard_config_generation", + } + for _, f := range fields { + if _, ok := resp[f]; !ok { + t.Fatalf("expected field %q in GET /api/admin/settings response", f) + } + } + if resp["output_guard_enabled"] != false { + t.Fatalf("expected default enabled = false, got %v", resp["output_guard_enabled"]) + } + if int64(resp["output_guard_max_tool_argument_bytes"].(float64)) != 262144 { + t.Fatalf("expected default tool bytes 262144, got %v", resp["output_guard_max_tool_argument_bytes"]) + } +} + +func TestAdminOutputGuardSettings_PutValidationAndReadonlyRejection(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, db := newTestAdminHandlerFixture(t) + + // 1. 提交只读 generation 必须报错 400 + { + w := invokeAdminSettingsEndpoint(t, handler, http.MethodPut, map[string]any{"output_guard_config_generation": 999}) + if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "只读") { + t.Fatalf("expected 400 with readonly rejection, got code=%d body=%s", w.Code, w.Body.String()) + } + } + // 2. 超出单个字段合法范围必须报错 400 + { + w := invokeAdminSettingsEndpoint(t, handler, http.MethodPut, map[string]any{"output_guard_max_tool_argument_bytes": 1024}) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 on out of range tool bytes, got %d", w.Code) + } + } + // 3. 违反 tool <= channel <= request 层级校验必须报错 400 且不改写 DB + { + w := invokeAdminSettingsEndpoint(t, handler, http.MethodPut, map[string]any{ + "output_guard_max_tool_argument_bytes": 3145728, + "output_guard_max_channel_bytes": 2097152, + }) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 on hierarchy violation, got %d", w.Code) + } + current, err := db.GetOutputGuardSettings(context.Background()) + if err != nil || current.MaxToolArgumentBytes == 3145728 { + t.Fatalf("database should not be updated on illegal hierarchy validation, err=%v", err) + } + } + // 4. 显式 true 准确持久化与热应用 + { + w := invokeAdminSettingsEndpoint(t, handler, http.MethodPut, map[string]any{"output_guard_enabled": true}) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 on explicit true update, got %d, body: %s", w.Code, w.Body.String()) + } + var resp map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if resp["output_guard_enabled"] != true { + t.Fatalf("expected output_guard_enabled = true in response, got %v", resp["output_guard_enabled"]) + } + current, err := db.GetOutputGuardSettings(context.Background()) + if err != nil || current.Enabled == nil || *current.Enabled != true { + t.Fatalf("database must store explicit true") + } + } + // 5. 局部字段更新隔离:仅更新 deltas 不改变其他字段,并立即热应用 + { + w := invokeAdminSettingsEndpoint(t, handler, http.MethodPut, map[string]any{"output_guard_max_tool_argument_deltas": 8192}) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 on partial update, got %d", w.Code) + } + var resp map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if int(resp["output_guard_max_tool_argument_deltas"].(float64)) != 8192 || resp["output_guard_enabled"] != true { + t.Fatalf("expected deltas = 8192 and preserved enabled=true, got %v", resp) + } + if runtimeCfg := proxy.CurrentOutputGuardConfig(); runtimeCfg.MaxToolArgumentDeltas != 8192 { + t.Fatalf("runtime snapshot must be immediately updated, got %d", runtimeCfg.MaxToolArgumentDeltas) + } + } +} + +// TestAdminOutputGuardSettings_NotPersistedOrAppliedWhenLaterValidationFails 覆盖 +// “防护配置先于后续校验落库并热应用”的部分提交回归:同一请求里后续字段校验 +// 失败时,防护配置必须既未写库,也未热应用。 +func TestAdminOutputGuardSettings_NotPersistedOrAppliedWhenLaterValidationFails(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, db := newTestAdminHandlerFixture(t) + ctx := context.Background() + + beforeDB, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("read guard settings before request: %v", err) + } + beforeRuntime := proxy.CurrentOutputGuardConfig() + + // models_list_read_max_bytes 的校验发生在防护设置之后;它失败时整个保存 + // 请求返回 400,防护设置不应留下任何已提交或已生效的痕迹。 + w := invokeAdminSettingsEndpoint(t, handler, http.MethodPut, map[string]any{ + "output_guard_enabled": true, + "models_list_read_max_bytes": 1, + }) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 on invalid models_list_read_max_bytes, got %d, body: %s", w.Code, w.Body.String()) + } + + afterDB, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("read guard settings after request: %v", err) + } + if afterDB.ConfigGeneration != beforeDB.ConfigGeneration || + afterDB.MaxToolArgumentBytes != beforeDB.MaxToolArgumentBytes || + afterDB.MaxChannelBytes != beforeDB.MaxChannelBytes || + afterDB.MaxRequestBytes != beforeDB.MaxRequestBytes || + afterDB.MaxToolArgumentDeltas != beforeDB.MaxToolArgumentDeltas || + afterDB.Enabled == nil || beforeDB.Enabled == nil || *afterDB.Enabled != *beforeDB.Enabled { + t.Fatalf("guard settings must not be committed when later validation fails: before=%+v after=%+v", beforeDB, afterDB) + } + + afterRuntime := proxy.CurrentOutputGuardConfig() + if afterRuntime.Generation != beforeRuntime.Generation || + afterRuntime.Enabled != beforeRuntime.Enabled || + afterRuntime.MaxToolArgumentBytes != beforeRuntime.MaxToolArgumentBytes { + t.Fatalf("guard runtime config must not be applied when later validation fails: before=%+v after=%+v", beforeRuntime, afterRuntime) + } +} + +// TestAdminOutputGuardSettings_GetSurfacesReadFailure 覆盖 F1:防护设置读取失败 +// 时不得用 DefaultOutputGuardSettings 静默兜底(那会让 UI 显示 enabled=false, +// 而代理可能仍在用已启用的旧配置),必须返回明确错误并记录日志。 +func TestAdminOutputGuardSettings_GetSurfacesReadFailure(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, _, dbPath := newTestAdminHandlerFixtureWithPath(t) + execAdminOutputGuardTestSQLite(t, dbPath, + "ALTER TABLE system_settings DROP COLUMN output_guard_config_generation") + + var logs lockedLogBuffer + previousLogOutput := log.Writer() + log.SetOutput(&logs) + defer log.SetOutput(previousLogOutput) + + w := invokeAdminSettingsEndpoint(t, handler, http.MethodGet, nil) + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected HTTP 500 when guard settings read fails, got %d, body: %s", w.Code, w.Body.String()) + } + body := w.Body.String() + if !strings.Contains(body, "读取防护设置失败") { + t.Fatalf("expected explicit guard read failure in response, got: %s", body) + } + if strings.Contains(body, "output_guard_enabled") { + t.Fatalf("must not silently return default guard settings on read failure, got: %s", body) + } + if logText := logs.String(); !strings.Contains(logText, "读取输出防护设置失败") { + t.Fatalf("expected guard read failure to be logged, got logs: %s", logText) + } +} + +// TestAdminOutputGuardSettings_UpdateDBErrorMapsTo500 覆盖 F2:防护设置落库失败 +// (非校验错误)必须映射 500。这里删除一个防护列,使事务内“读取-合并-校验” +// 的读取步骤失败;同时验证整个事务回滚、enabled 未被写入。 +func TestAdminOutputGuardSettings_UpdateDBErrorMapsTo500(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, _, dbPath := newTestAdminHandlerFixtureWithPath(t) + execAdminOutputGuardTestSQLite(t, dbPath, + "ALTER TABLE system_settings DROP COLUMN output_guard_max_channel_bytes") + + w := invokeAdminSettingsEndpoint(t, handler, http.MethodPut, map[string]any{"output_guard_enabled": true}) + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected HTTP 500 on database error during guard update, got %d, body: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "保存防护设置失败") { + t.Fatalf("expected guard persistence failure in response, got: %s", w.Body.String()) + } + if queryAdminOutputGuardTestSQLiteBool(t, dbPath, "SELECT output_guard_enabled FROM system_settings WHERE id = 1") { + t.Fatalf("guard update must be rolled back when the transaction fails") + } +} + +// TestAdminOutputGuardSettings_MaxConcurrencyNotChangedWhenGuardSaveFails 覆盖 review 意见: +// 当 UpdateSettings 携带 max_concurrency / max_retries / max_rate_limit_retries / global_rpm 且 guard 保存失败(如非法参数返回 400 或 DB 失败返回 500)时, +// 运行时配置必须保持旧值,不能被污染;数据库亦保持旧值;成功路径下正常热应用并持久化。 +func TestAdminOutputGuardSettings_MaxConcurrencyNotChangedWhenGuardSaveFails(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, db := newTestAdminHandlerFixture(t) + ctx := context.Background() + + // 准备初始状态:max_concurrency 初始设为 5,max_retries 初始设为 3,max_rate_limit_retries 设为 1,global_rpm 设为 100 + initMaxConcurrency := 5 + initMaxRetries := 3 + initMaxRateLimitRetries := 1 + initGlobalRPM := 100 + wInit := invokeAdminSettingsEndpoint(t, handler, http.MethodPut, map[string]any{ + "max_concurrency": initMaxConcurrency, + "max_retries": initMaxRetries, + "max_rate_limit_retries": initMaxRateLimitRetries, + "global_rpm": initGlobalRPM, + }) + if wInit.Code != http.StatusOK { + t.Fatalf("init settings failed: code=%d body=%s", wInit.Code, wInit.Body.String()) + } + if got := handler.store.GetMaxConcurrency(); got != initMaxConcurrency { + t.Fatalf("expected store max_concurrency=%d, got %d", initMaxConcurrency, got) + } + if got := handler.store.GetMaxRetries(); got != initMaxRetries { + t.Fatalf("expected store max_retries=%d, got %d", initMaxRetries, got) + } + if got := handler.store.GetMaxRateLimitRetries(); got != initMaxRateLimitRetries { + t.Fatalf("expected store max_rate_limit_retries=%d, got %d", initMaxRateLimitRetries, got) + } + if got := handler.rateLimiter.GetRPM(); got != initGlobalRPM { + t.Fatalf("expected rate limiter rpm=%d, got %d", initGlobalRPM, got) + } + + // 1. PUT max_concurrency: 99, max_retries: 7, max_rate_limit_retries: 8, global_rpm: 999 + 非法 output_guard_max_channel_bytes + // 期望返回 400,运行时保持旧值,数据库中亦保持旧值 + wErr := invokeAdminSettingsEndpoint(t, handler, http.MethodPut, map[string]any{ + "max_concurrency": 99, + "max_retries": 7, + "max_rate_limit_retries": 8, + "global_rpm": 999, + "output_guard_max_channel_bytes": 10, // 低于 MinOutputGuardMaxChannelBytes (128KiB) + }) + if wErr.Code != http.StatusBadRequest { + t.Fatalf("expected HTTP 400 on invalid guard settings, got %d, body: %s", wErr.Code, wErr.Body.String()) + } + if got := handler.store.GetMaxConcurrency(); got != initMaxConcurrency { + t.Fatalf("store max_concurrency must remain %d after guard validation failure, got %d", initMaxConcurrency, got) + } + if got := handler.store.GetMaxRetries(); got != initMaxRetries { + t.Fatalf("store max_retries must remain %d after guard validation failure, got %d", initMaxRetries, got) + } + if got := handler.store.GetMaxRateLimitRetries(); got != initMaxRateLimitRetries { + t.Fatalf("store max_rate_limit_retries must remain %d after guard validation failure, got %d", initMaxRateLimitRetries, got) + } + if got := handler.rateLimiter.GetRPM(); got != initGlobalRPM { + t.Fatalf("rate limiter rpm must remain %d after guard validation failure, got %d", initGlobalRPM, got) + } + persisted, err := db.GetSystemSettings(ctx) + if err != nil || persisted == nil { + t.Fatalf("get persisted settings: %v", err) + } + if persisted.MaxConcurrency != initMaxConcurrency { + t.Fatalf("persisted max_concurrency must remain %d, got %d", initMaxConcurrency, persisted.MaxConcurrency) + } + if persisted.MaxRetries != initMaxRetries { + t.Fatalf("persisted max_retries must remain %d, got %d", initMaxRetries, persisted.MaxRetries) + } + if persisted.MaxRateLimitRetries != initMaxRateLimitRetries { + t.Fatalf("persisted max_rate_limit_retries must remain %d, got %d", initMaxRateLimitRetries, persisted.MaxRateLimitRetries) + } + if persisted.GlobalRPM != initGlobalRPM { + t.Fatalf("persisted global_rpm must remain %d, got %d", initGlobalRPM, persisted.GlobalRPM) + } + + // 2. 成功路径:PUT max_concurrency: 12, max_retries: 6, max_rate_limit_retries: 4, global_rpm: 250 + 合法 output_guard_max_channel_bytes + // 期望返回 200,运行时立即更新,数据库持久化更新 + targetChannelBytes := database.MinOutputGuardMaxChannelBytes * 2 + wSuccess := invokeAdminSettingsEndpoint(t, handler, http.MethodPut, map[string]any{ + "max_concurrency": 12, + "max_retries": 6, + "max_rate_limit_retries": 4, + "global_rpm": 250, + "output_guard_max_channel_bytes": targetChannelBytes, + }) + if wSuccess.Code != http.StatusOK { + t.Fatalf("expected HTTP 200 on valid update, got %d, body: %s", wSuccess.Code, wSuccess.Body.String()) + } + if got := handler.store.GetMaxConcurrency(); got != 12 { + t.Fatalf("store max_concurrency must be updated to 12 on success, got %d", got) + } + if got := handler.store.GetMaxRetries(); got != 6 { + t.Fatalf("store max_retries must be updated to 6 on success, got %d", got) + } + if got := handler.store.GetMaxRateLimitRetries(); got != 4 { + t.Fatalf("store max_rate_limit_retries must be updated to 4 on success, got %d", got) + } + if got := handler.rateLimiter.GetRPM(); got != 250 { + t.Fatalf("rate limiter rpm must be updated to 250 on success, got %d", got) + } + persistedSuccess, err := db.GetSystemSettings(ctx) + if err != nil || persistedSuccess == nil { + t.Fatalf("get persisted settings on success: %v", err) + } + if persistedSuccess.MaxConcurrency != 12 { + t.Fatalf("persisted max_concurrency must be 12 on success, got %d", persistedSuccess.MaxConcurrency) + } + if persistedSuccess.MaxRetries != 6 { + t.Fatalf("persisted max_retries must be 6 on success, got %d", persistedSuccess.MaxRetries) + } + if persistedSuccess.MaxRateLimitRetries != 4 { + t.Fatalf("persisted max_rate_limit_retries must be 4 on success, got %d", persistedSuccess.MaxRateLimitRetries) + } + if persistedSuccess.GlobalRPM != 250 { + t.Fatalf("persisted global_rpm must be 250 on success, got %d", persistedSuccess.GlobalRPM) + } + // 3. 500 场景:当数据库出错导致 guard 保存失败返回 500 时,运行时与 DB 同样保持旧值 + { + handler500, db500, dbPath := newTestAdminHandlerFixtureWithPath(t) + wInit500 := invokeAdminSettingsEndpoint(t, handler500, http.MethodPut, map[string]any{ + "max_concurrency": 7, + "max_retries": 2, + "max_rate_limit_retries": 1, + "global_rpm": 50, + }) + if wInit500.Code != http.StatusOK { + t.Fatalf("init settings for 500 test failed: %d", wInit500.Code) + } + // 破坏 output_guard_max_channel_bytes 列,使事务执行 guard 更新时报 DB 错误 + execAdminOutputGuardTestSQLite(t, dbPath, + "ALTER TABLE system_settings DROP COLUMN output_guard_max_channel_bytes") + + wErr500 := invokeAdminSettingsEndpoint(t, handler500, http.MethodPut, map[string]any{ + "max_concurrency": 88, + "max_retries": 9, + "max_rate_limit_retries": 5, + "global_rpm": 888, + "output_guard_enabled": true, + }) + if wErr500.Code != http.StatusInternalServerError { + t.Fatalf("expected HTTP 500 on db error during guard update, got %d, body: %s", wErr500.Code, wErr500.Body.String()) + } + if got := handler500.store.GetMaxConcurrency(); got != 7 { + t.Fatalf("store max_concurrency must remain 7 after guard db failure, got %d", got) + } + if got := handler500.store.GetMaxRetries(); got != 2 { + t.Fatalf("store max_retries must remain 2 after guard db failure, got %d", got) + } + if got := handler500.store.GetMaxRateLimitRetries(); got != 1 { + t.Fatalf("store max_rate_limit_retries must remain 1 after guard db failure, got %d", got) + } + if got := handler500.rateLimiter.GetRPM(); got != 50 { + t.Fatalf("rate limiter rpm must remain 50 after guard db failure, got %d", got) + } + persisted500, err := db500.GetSystemSettings(ctx) + if err != nil || persisted500 == nil { + t.Fatalf("get persisted settings after 500: %v", err) + } + if persisted500.MaxConcurrency != 7 { + t.Fatalf("persisted max_concurrency must remain 7 after 500, got %d", persisted500.MaxConcurrency) + } + if persisted500.MaxRetries != 2 { + t.Fatalf("persisted max_retries must remain 2 after 500, got %d", persisted500.MaxRetries) + } + if persisted500.MaxRateLimitRetries != 1 { + t.Fatalf("persisted max_rate_limit_retries must remain 1 after 500, got %d", persisted500.MaxRateLimitRetries) + } + if persisted500.GlobalRPM != 50 { + t.Fatalf("persisted global_rpm must remain 50 after 500, got %d", persisted500.GlobalRPM) + } + } +} + +// TestAdminOutputGuardSettings_MaxConcurrencyNotChangedWhenModelsListWriteFails 覆盖 review 意见: +// 当 settings 事务已成功提交,但后续 models-list-read-max-bytes 独立写入失败(返回 500)时, +// 事务内提交的 max_concurrency / max_retries 等系统设置在事务成功即已热应用生效, +// 运行时与数据库保持一致,不应保持旧值。 +func TestAdminOutputGuardSettings_MaxConcurrencyNotChangedWhenModelsListWriteFails(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, db, dbPath := newTestAdminHandlerFixtureWithPath(t) + ctx := context.Background() + + // 初始状态:max_concurrency 设为 5,max_retries 设为 2 + wInit := invokeAdminSettingsEndpoint(t, handler, http.MethodPut, map[string]any{ + "max_concurrency": 5, + "max_retries": 2, + }) + if wInit.Code != http.StatusOK { + t.Fatalf("init settings failed: %d", wInit.Code) + } + + // 在 SQLite 上安装触发器:禁止修改 models_list_read_max_bytes,模拟 UpdateModelsListReadMaxBytes 抛错返回 500 + execAdminOutputGuardTestSQLite(t, dbPath, ` + CREATE TRIGGER fail_models_list_update BEFORE UPDATE OF models_list_read_max_bytes ON system_settings + BEGIN + SELECT RAISE(ABORT, 'simulated models-list-read-max-bytes write failure'); + END; + `) + + // PUT 更新 max_concurrency: 15, max_retries: 4, models_list_read_max_bytes: 16MiB (合法值但被触发器拒绝) + wFail := invokeAdminSettingsEndpoint(t, handler, http.MethodPut, map[string]any{ + "max_concurrency": 15, + "max_retries": 4, + "models_list_read_max_bytes": int64(16 << 20), + }) + if wFail.Code != http.StatusInternalServerError { + t.Fatalf("expected HTTP 500 when models_list write fails, got %d, body: %s", wFail.Code, wFail.Body.String()) + } + + // 事务提交成功即热应用:此时 max_concurrency 必须热应用为 15,max_retries 热应用为 4,与 DB 一致 + if got := handler.store.GetMaxConcurrency(); got != 15 { + t.Fatalf("store max_concurrency should be applied on settings transaction success, expected 15, got %d", got) + } + if got := handler.store.GetMaxRetries(); got != 4 { + t.Fatalf("store max_retries should be applied on settings transaction success, expected 4, got %d", got) + } + + persisted, err := db.GetSystemSettings(ctx) + if err != nil || persisted == nil { + t.Fatalf("get persisted settings: %v", err) + } + if persisted.MaxConcurrency != 15 { + t.Fatalf("persisted max_concurrency must be 15, got %d", persisted.MaxConcurrency) + } + if persisted.MaxRetries != 4 { + t.Fatalf("persisted max_retries must be 4, got %d", persisted.MaxRetries) + } +} + +// TestAdminSettings_RuntimeLimitsAppliedWhenContinuousRetryFails 覆盖 T1 review 意见: +// 运行时 retry 上限热应用早于 UpdateContinuousRetryPolicy:若该写失败返回 500, +// DB 已提交新 max_rate_limit_retries / max_retries / max_concurrency / global_rpm, +// 本实例内存必须已在 settings 事务提交成功后立即更新生效。 +func TestAdminSettings_RuntimeLimitsAppliedWhenContinuousRetryFails(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, db, dbPath := newTestAdminHandlerFixtureWithPath(t) + ctx := context.Background() + + // 初始状态:max_concurrency: 3, max_retries: 2, max_rate_limit_retries: 1, global_rpm: 60 + wInit := invokeAdminSettingsEndpoint(t, handler, http.MethodPut, map[string]any{ + "max_concurrency": 3, + "max_retries": 2, + "max_rate_limit_retries": 1, + "global_rpm": 60, + }) + if wInit.Code != http.StatusOK { + t.Fatalf("init settings failed: %d, body: %s", wInit.Code, wInit.Body.String()) + } + + // 在 SQLite 上安装触发器:禁止修改 continuous_retry_policy,模拟 UpdateContinuousRetryPolicy 抛错返回 500 + execAdminOutputGuardTestSQLite(t, dbPath, ` + CREATE TRIGGER fail_continuous_retry_update BEFORE UPDATE OF continuous_retry_policy ON system_settings + BEGIN + SELECT RAISE(ABORT, 'simulated continuous_retry_policy write failure'); + END; + `) + + // PUT 更新 max_concurrency: 18, max_retries: 6, max_rate_limit_retries: 5, global_rpm: 300, continuous_retry_enabled: true + wFail := invokeAdminSettingsEndpoint(t, handler, http.MethodPut, map[string]any{ + "max_concurrency": 18, + "max_retries": 6, + "max_rate_limit_retries": 5, + "global_rpm": 300, + "continuous_retry_enabled": true, + }) + if wFail.Code != http.StatusInternalServerError { + t.Fatalf("expected HTTP 500 when continuous_retry write fails, got %d, body: %s", wFail.Code, wFail.Body.String()) + } + if !strings.Contains(wFail.Body.String(), "保存持续重试策略失败") { + t.Fatalf("expected error message to contain '保存持续重试策略失败', got: %s", wFail.Body.String()) + } + + // settings 事务已成功提交:运行时配置必须已被热应用,与 DB 一致 + if got := handler.store.GetMaxConcurrency(); got != 18 { + t.Fatalf("store max_concurrency must be applied on settings transaction success, expected 18, got %d", got) + } + if got := handler.store.GetMaxRetries(); got != 6 { + t.Fatalf("store max_retries must be applied on settings transaction success, expected 6, got %d", got) + } + if got := handler.store.GetMaxRateLimitRetries(); got != 5 { + t.Fatalf("store max_rate_limit_retries must be applied on settings transaction success, expected 5, got %d", got) + } + if got := handler.rateLimiter.GetRPM(); got != 300 { + t.Fatalf("rate limiter rpm must be applied on settings transaction success, expected 300, got %d", got) + } + + persisted, err := db.GetSystemSettings(ctx) + if err != nil || persisted == nil { + t.Fatalf("get persisted settings: %v", err) + } + if persisted.MaxConcurrency != 18 { + t.Fatalf("persisted max_concurrency must be 18, got %d", persisted.MaxConcurrency) + } + if persisted.MaxRetries != 6 { + t.Fatalf("persisted max_retries must be 6, got %d", persisted.MaxRetries) + } + if persisted.MaxRateLimitRetries != 5 { + t.Fatalf("persisted max_rate_limit_retries must be 5, got %d", persisted.MaxRateLimitRetries) + } + if persisted.GlobalRPM != 300 { + t.Fatalf("persisted global_rpm must be 300, got %d", persisted.GlobalRPM) + } +} + +// TestAdminSettings_UpdateSystemSettingsDBErrorReturns500 覆盖 review 意见: +// 当仅修改 max_concurrency / max_retries / max_rate_limit_retries / global_rpm 且 UpdateSystemSettings 失败时, +// 必须返回 500 且运行时和数据库均不应用变更(不能落到成功路径返回 200)。 +func TestAdminSettings_UpdateSystemSettingsDBErrorReturns500(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, db, dbPath := newTestAdminHandlerFixtureWithPath(t) + ctx := context.Background() + + // 初始状态:max_concurrency 设为 3,max_retries 设为 2,max_rate_limit_retries 设为 1,global_rpm 设为 50 + wInit := invokeAdminSettingsEndpoint(t, handler, http.MethodPut, map[string]any{ + "max_concurrency": 3, + "max_retries": 2, + "max_rate_limit_retries": 1, + "global_rpm": 50, + }) + if wInit.Code != http.StatusOK { + t.Fatalf("init settings failed: %d, body: %s", wInit.Code, wInit.Body.String()) + } + + // 在 SQLite 上安装触发器:禁止修改 max_concurrency,模拟 UpdateSystemSettings 抛错返回 500 + execAdminOutputGuardTestSQLite(t, dbPath, ` + CREATE TRIGGER fail_system_settings_update BEFORE UPDATE OF max_concurrency ON system_settings + BEGIN + SELECT RAISE(ABORT, 'simulated system_settings write failure'); + END; + `) + + // 仅修改 max_concurrency, max_retries, max_rate_limit_retries, global_rpm + wFail := invokeAdminSettingsEndpoint(t, handler, http.MethodPut, map[string]any{ + "max_concurrency": 9, + "max_retries": 5, + "max_rate_limit_retries": 4, + "global_rpm": 500, + }) + if wFail.Code != http.StatusInternalServerError { + t.Fatalf("expected HTTP 500 when UpdateSystemSettings fails, got %d, body: %s", wFail.Code, wFail.Body.String()) + } + if !strings.Contains(wFail.Body.String(), "保存系统设置失败") { + t.Fatalf("expected error message to contain '保存系统设置失败', got: %s", wFail.Body.String()) + } + + // 断言运行时配置保持旧值,未被热应用 + if got := handler.store.GetMaxConcurrency(); got != 3 { + t.Fatalf("store max_concurrency must remain 3 after DB failure, got %d", got) + } + if got := handler.store.GetMaxRetries(); got != 2 { + t.Fatalf("store max_retries must remain 2 after DB failure, got %d", got) + } + if got := handler.store.GetMaxRateLimitRetries(); got != 1 { + t.Fatalf("store max_rate_limit_retries must remain 1 after DB failure, got %d", got) + } + if got := handler.rateLimiter.GetRPM(); got != 50 { + t.Fatalf("rate limiter rpm must remain 50 after DB failure, got %d", got) + } + + // 断言数据库保持旧值 + persisted, err := db.GetSystemSettings(ctx) + if err != nil || persisted == nil { + t.Fatalf("get persisted settings: %v", err) + } + if persisted.MaxConcurrency != 3 { + t.Fatalf("persisted max_concurrency must remain 3, got %d", persisted.MaxConcurrency) + } + if persisted.MaxRetries != 2 { + t.Fatalf("persisted max_retries must remain 2, got %d", persisted.MaxRetries) + } + if persisted.MaxRateLimitRetries != 1 { + t.Fatalf("persisted max_rate_limit_retries must remain 1, got %d", persisted.MaxRateLimitRetries) + } + if persisted.GlobalRPM != 50 { + t.Fatalf("persisted global_rpm must remain 50, got %d", persisted.GlobalRPM) + } +} diff --git a/admin/scheduled_connection_tests.go b/admin/scheduled_connection_tests.go new file mode 100644 index 000000000..a445ff74e --- /dev/null +++ b/admin/scheduled_connection_tests.go @@ -0,0 +1,382 @@ +package admin + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "net/url" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/codex2api/auth" + "github.com/codex2api/database" + "github.com/gin-gonic/gin" +) + +const scheduledTestCredentialKey = "scheduled_connection_tests" +const scheduledTestTimezone = "Asia/Shanghai" +const scheduledTestJobPrefix = "scheduled_connection_test:" +const scheduledTestContextKey = "internal_scheduled_connection_test" + +var scheduledTestTimePattern = regexp.MustCompile(`^(?:[01]\d|2[0-3]):[0-5]\d$`) + +type accountScheduledTest struct { + Enabled bool `json:"enabled"` + Model string `json:"model"` + Content string `json:"content"` + WeekdayTimes []string `json:"weekday_times"` + WeekendTimes []string `json:"weekend_times"` +} + +type scheduledTestRecord struct { + ScheduledAt string `json:"scheduled_at"` + AttemptedAt string `json:"attempted_at,omitempty"` + AccountID int64 `json:"account_id"` + Model string `json:"model,omitempty"` + Status string `json:"status"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + NextReset5hAt string `json:"next_reset_5h_at,omitempty"` +} + +func normalizeScheduledTestConfig(raw accountScheduledTest) (accountScheduledTest, error) { + raw.Model = strings.TrimSpace(raw.Model) + if len(raw.Model) > 256 { + return raw, errors.New("测试模型名称过长") + } + if strings.TrimSpace(raw.Content) == "" || len([]rune(raw.Content)) > auth.MaxTestContentRunes { + return raw, errors.New("测试内容不能为空且不能超过 8192 个字符") + } + if raw.Enabled && raw.Model == "" { + return raw, errors.New("请选择测试模型") + } + normalize := func(values []string) ([]string, error) { + result := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + value = strings.TrimSpace(value) + if !scheduledTestTimePattern.MatchString(value) { + return nil, errors.New("测试时间格式必须为 HH:mm") + } + if !seen[value] { + result = append(result, value) + seen[value] = true + } + } + sort.Strings(result) + return result, nil + } + var err error + raw.WeekdayTimes, err = normalize(raw.WeekdayTimes) + if err != nil { + return raw, err + } + raw.WeekendTimes, err = normalize(raw.WeekendTimes) + if err != nil { + return raw, err + } + if raw.Enabled && len(raw.WeekdayTimes)+len(raw.WeekendTimes) == 0 { + return raw, errors.New("启用前请至少添加一个测试时间点") + } + return raw, nil +} + +func decodeScheduledTestConfig(raw string) (accountScheduledTest, error) { + if strings.TrimSpace(raw) == "" { + return accountScheduledTest{Content: "hi", WeekdayTimes: []string{}, WeekendTimes: []string{}}, nil + } + var config accountScheduledTest + if err := json.Unmarshal([]byte(raw), &config); err != nil { + return config, errors.New("定时激活配置无效") + } + return normalizeScheduledTestConfig(config) +} + +func scheduledTestDue(config accountScheduledTest, now time.Time) bool { + if !config.Enabled { + return false + } + loc, err := time.LoadLocation(scheduledTestTimezone) + if err != nil { + return false + } + now = now.In(loc) + times := config.WeekdayTimes + if now.Weekday() == time.Saturday || now.Weekday() == time.Sunday { + times = config.WeekendTimes + } + for _, value := range times { + if value == now.Format("15:04") { + return true + } + } + return false +} + +func (h *Handler) scheduledTestAccount(c *gin.Context) (*database.AccountRow, bool) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id <= 0 { + c.JSON(400, gin.H{"error": "账号 ID 无效"}) + return nil, false + } + row, err := h.db.GetAccountByID(c.Request.Context(), id) + if errors.Is(err, sql.ErrNoRows) { + c.JSON(404, gin.H{"error": "账号不存在"}) + return nil, false + } + if err != nil { + c.JSON(500, gin.H{"error": "读取账号失败"}) + return nil, false + } + upstream := strings.ToLower(strings.TrimSpace(row.GetCredential("upstream_type"))) + if upstream != "" && upstream != auth.UpstreamOpenAIResponses && upstream != "codex" { + c.JSON(400, gin.H{"error": "定时激活仅支持 Codex/Responses 账号"}) + return nil, false + } + return row, true +} + +func (h *Handler) GetScheduledConnectionTests(c *gin.Context) { + row, ok := h.scheduledTestAccount(c) + if !ok { + return + } + config, err := decodeScheduledTestConfig(row.GetCredential(scheduledTestCredentialKey)) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + jobs, err := h.db.ListOneShotMaintenanceJobs(c.Request.Context(), row.ID, scheduledTestJobPrefix, 10) + if err != nil { + c.JSON(500, gin.H{"error": "读取执行记录失败"}) + return + } + records := make([]scheduledTestRecord, 0, len(jobs)) + for _, job := range jobs { + record := scheduledTestRecord{AccountID: row.ID, ScheduledAt: job.DueAt.Format(time.RFC3339), Status: "unknown"} + if job.LastError != "" { + _ = json.Unmarshal([]byte(job.LastError), &record) + } + records = append(records, record) + } + c.JSON(200, gin.H{"config": config, "timezone": scheduledTestTimezone, "records": records}) +} + +func (h *Handler) UpdateScheduledConnectionTests(c *gin.Context) { + row, ok := h.scheduledTestAccount(c) + if !ok { + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + var config accountScheduledTest + if err := c.ShouldBindJSON(&config); err != nil { + c.JSON(400, gin.H{"error": "测试计划 JSON 无效"}) + return + } + config, err := normalizeScheduledTestConfig(config) + if err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + if config.Enabled && config.Model != "" { + account := h.store.FindByID(row.ID) + if account == nil { + c.JSON(409, gin.H{"error": "账号未加载,无法校验测试模型"}) + return + } + model, modelErr := h.connectionTestModelForAccount(c.Request.Context(), account, config.Model) + if modelErr != nil { + c.JSON(400, gin.H{"error": modelErr.Error()}) + return + } + config.Model = model + } + encoded, _ := json.Marshal(config) + if err := h.db.UpdateCredentials(c.Request.Context(), row.ID, map[string]interface{}{scheduledTestCredentialKey: string(encoded)}); err != nil { + c.JSON(500, gin.H{"error": "保存定时激活失败"}) + return + } + c.JSON(200, config) +} + +func (h *Handler) StartScheduledConnectionTests(ctx context.Context) { + if h == nil || h.store == nil || h.db == nil || ctx == nil { + return + } + h.scheduledTestStartOnce.Do(func() { + h.scheduledTestWG.Add(1) + go func() { + defer h.scheduledTestWG.Done() + for { + timer := time.NewTimer(time.Until(time.Now().Truncate(time.Minute).Add(time.Minute))) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + h.dispatchScheduledConnectionTests(ctx, time.Now()) + } + }() + }) +} + +// The minute loop remains alive while dispatch adds workers to the wait group. +// Slow batches must not delay claiming the next minute's scheduled slots. +func (h *Handler) dispatchScheduledConnectionTests(ctx context.Context, now time.Time) { + h.scheduledTestWG.Add(1) + go func() { + defer h.scheduledTestWG.Done() + h.runScheduledConnectionTests(ctx, now) + }() +} + +func (h *Handler) scheduledConnectionTestGate() chan struct{} { + h.scheduledTestGateOnce.Do(func() { + h.scheduledTestGate = make(chan struct{}, 4) + }) + return h.scheduledTestGate +} + +func (h *Handler) WaitScheduledConnectionTests() { + if h != nil { + h.scheduledTestWG.Wait() + } +} + +func (h *Handler) runScheduledConnectionTests(ctx context.Context, now time.Time) { + loc, err := time.LoadLocation(scheduledTestTimezone) + if err != nil { + log.Printf("[scheduled-test] 时区不可用") + return + } + now = now.In(loc) + plans, err := h.db.ListScheduledConnectionTestConfigs(ctx) + if err != nil { + log.Printf("[scheduled-test] 读取计划失败: %v", err) + return + } + // Bound concurrency across overlapping minute batches, not just within one. + gate := h.scheduledConnectionTestGate() + var wg sync.WaitGroup + defer wg.Wait() + for _, plan := range plans { + config, decodeErr := decodeScheduledTestConfig(plan.Config) + if decodeErr != nil { + log.Printf("[scheduled-test] 账号 %d 配置无效", plan.AccountID) + continue + } + if !scheduledTestDue(config, now) { + continue + } + key := scheduledTestJobPrefix + now.Format("2006-01-02T15:04") + claimed, claimErr := h.db.TryClaimMaintenanceJob(ctx, plan.AccountID, key, now) + if claimErr != nil { + log.Printf("[scheduled-test] 账号 %d 槽位声明失败", plan.AccountID) + continue + } + if !claimed { + continue + } + select { + case gate <- struct{}{}: + case <-ctx.Done(): + h.finishScheduledConnectionTestSlot(key, scheduledTestRecord{ + AccountID: plan.AccountID, ScheduledAt: now.Format(time.RFC3339), + Model: config.Model, Status: "failed", Error: "测试在执行前被取消", + }) + return + } + wg.Add(1) + go func(id int64, config accountScheduledTest) { + defer wg.Done() + defer func() { <-gate }() + h.runScheduledConnectionTestSlot(ctx, id, key, now, config) + }(plan.AccountID, config) + } +} + +func (h *Handler) runScheduledConnectionTestSlot(parent context.Context, id int64, key string, now time.Time, config accountScheduledTest) { + result := scheduledTestRecord{AccountID: id, ScheduledAt: now.Format(time.RFC3339), AttemptedAt: time.Now().In(now.Location()).Format(time.RFC3339), Model: config.Model, Status: "failed"} + // Persist the outcome even if shutdown cancels the single upstream attempt. + defer func() { + if recover() != nil { + result.Error = "测试异常中断" + result.Success = false + result.Status = "failed" + } + if account := h.store.FindByID(id); account != nil { + if reset := account.GetReset5hAt(); !reset.IsZero() { + result.NextReset5hAt = reset.Format(time.RFC3339) + } + } + h.finishScheduledConnectionTestSlot(key, result) + }() + // Re-read before execution so deletion/disable of an account or plan cancels queued work. + row, err := h.db.GetAccountByID(parent, id) + if err != nil || !row.Enabled { + result.Status = "skipped" + result.Error = "账号已停用或删除" + return + } + current, err := decodeScheduledTestConfig(row.GetCredential(scheduledTestCredentialKey)) + if err != nil || !scheduledTestDue(current, now) { + result.Status = "skipped" + result.Error = "计划已变更或停用" + return + } + config = current + result.Model = config.Model + ctx, cancel := context.WithTimeout(parent, 45*time.Second) + defer cancel() + complete := false + router := gin.New() + router.GET("/accounts/:id/test", func(c *gin.Context) { c.Set(scheduledTestContextKey, true); h.TestConnection(c) }) + params := url.Values{"model": {config.Model}, "prompt": {config.Content}} + request, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("/accounts/%d/test?%s", id, params.Encode()), nil) + if err != nil { + result.Error = "创建测试请求失败" + return + } + // Reuse exactly one connection-test execution; consume SSE events in process. + // No loop, HTTP self-call, pool selector, or quality-job retry is involved. + router.ServeHTTP(&qualityJobWriter{header: make(http.Header), emit: func(event testEvent) { + switch event.Type { + case "error": + result.Error = event.Error + case "test_complete": + complete = event.Success + } + }}, request) + if ctx.Err() != nil { + result.Error = "测试被取消或超时" + } + if result.Error == "" && !complete { + result.Error = "未收到测试成功事件" + } + result.Success = result.Error == "" && complete + if result.Success { + result.Status = "success" + } + if account := h.store.FindByID(id); account != nil { + result.Error = sanitizeCodexTestText(result.Error, codexTestSecrets(account)) + } +} + +func (h *Handler) finishScheduledConnectionTestSlot(key string, result scheduledTestRecord) { + encoded, _ := json.Marshal(result) + finalCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := h.db.FinishMaintenanceJob(finalCtx, result.AccountID, key, string(encoded)); err != nil { + log.Printf("[scheduled-test] 账号 %d 写记录失败", result.AccountID) + } + log.Printf("[scheduled-test] %s", encoded) +} diff --git a/admin/scheduled_connection_tests_test.go b/admin/scheduled_connection_tests_test.go new file mode 100644 index 000000000..0b30eb3ad --- /dev/null +++ b/admin/scheduled_connection_tests_test.go @@ -0,0 +1,218 @@ +package admin + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/codex2api/auth" + "github.com/codex2api/database" + "github.com/gin-gonic/gin" +) + +func TestScheduledConnectionTestsOverlapAndShutdown(t *testing.T) { + for _, shutdown := range []bool{false, true} { + t.Run(fmt.Sprintf("shutdown=%v", shutdown), func(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, cancel := context.WithCancel(context.Background()) + release := make(chan struct{}) + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + started := make(chan struct{}, 16) + var active, peak, calls atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + n := active.Add(1) + defer active.Add(-1) + for old := peak.Load(); n > old; old = peak.Load() { + if peak.CompareAndSwap(old, n) { + break + } + } + started <- struct{}{} + select { + case <-release: + case <-r.Context().Done(): + return + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: {\"type\":\"response.output_text.delta\",\"delta\":\"pong\"}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_scheduled\",\"status\":\"completed\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n") + })) + t.Cleanup(upstream.Close) + db, err := database.New("sqlite", filepath.Join(t.TempDir(), "scheduled.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + store := auth.NewStore(db, nil, &database.SystemSettings{MaxConcurrency: 4}) + t.Cleanup(store.Stop) + h := &Handler{store: store, db: db} + t.Cleanup(func() { + cancel() + unblock() + h.WaitScheduledConnectionTests() + }) + loc, err := time.LoadLocation(scheduledTestTimezone) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 10, 26, 12, 0, 0, 0, loc) + plan, _ := json.Marshal(accountScheduledTest{Enabled: true, Model: "gpt-5.5", Content: "hi", WeekdayTimes: []string{"12:00", "12:01"}}) + var ids []int64 + for i := 0; i < 5; i++ { + id, err := db.InsertOpenAIResponsesAccount(ctx, fmt.Sprintf("scheduled-%d", i), map[string]interface{}{ + "upstream_type": auth.UpstreamOpenAIResponses, "base_url": upstream.URL, + "api_key": "test-token", "models": []string{"gpt-5.5"}, scheduledTestCredentialKey: string(plan), + }, "") + if err != nil { + t.Fatal(err) + } + ids = append(ids, id) + store.AddAccount(&auth.Account{DBID: id, UpstreamType: auth.UpstreamOpenAIResponses, BaseURL: upstream.URL, APIKey: "test-token", Models: []string{"gpt-5.5"}, PlanType: "api"}) + } + h.dispatchScheduledConnectionTests(ctx, now) + for i := 0; i < 4; i++ { + select { + case <-started: + case <-time.After(5 * time.Second): + t.Fatal("first batch did not start four attempts") + } + } + // A blocked first batch must not prevent claiming the following minute. + h.dispatchScheduledConnectionTests(ctx, now.Add(time.Minute)) + deadline := time.Now().Add(5 * time.Second) + for { + jobs, err := db.ListOneShotMaintenanceJobs(ctx, ids[0], scheduledTestJobPrefix, 10) + if err != nil { + t.Fatal(err) + } + if len(jobs) == 2 { + break + } + if time.Now().After(deadline) { + t.Fatal("next minute was blocked by the running batch") + } + time.Sleep(10 * time.Millisecond) + } + if shutdown { + cancel() + } else { + unblock() + // Re-dispatching a slot cannot send another upstream request. + h.dispatchScheduledConnectionTests(ctx, now) + } + done := make(chan struct{}) + go func() { h.WaitScheduledConnectionTests(); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("workers did not finish after release/cancellation") + } + if peak.Load() > 4 { + t.Fatalf("global concurrency=%d, want at most 4", peak.Load()) + } + if !shutdown { + if calls.Load() != 10 { + t.Fatalf("upstream attempts=%d, want one per account per minute (10)", calls.Load()) + } + for _, id := range ids { + jobs, err := db.ListOneShotMaintenanceJobs(ctx, id, scheduledTestJobPrefix, 10) + if err != nil || len(jobs) != 2 { + t.Fatalf("account %d jobs=%v err=%v", id, jobs, err) + } + for _, job := range jobs { + var result scheduledTestRecord + if json.Unmarshal([]byte(job.LastError), &result) != nil || !result.Success { + t.Fatalf("unsuccessful slot for %d: %s", id, job.LastError) + } + } + } + } + }) + } +} + +func TestScheduledConnectionTestCancelledBeforeExecutionRecordsResult(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + db, err := database.New("sqlite", filepath.Join(t.TempDir(), "scheduled-cancel.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + store := auth.NewStore(db, nil, &database.SystemSettings{MaxConcurrency: 4}) + t.Cleanup(store.Stop) + h := &Handler{store: store, db: db} + t.Cleanup(func() { + cancel() + h.WaitScheduledConnectionTests() + }) + loc, err := time.LoadLocation(scheduledTestTimezone) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 10, 26, 12, 0, 0, 0, loc) + plan, err := json.Marshal(accountScheduledTest{Enabled: true, Model: "gpt-5.5", Content: "hi", WeekdayTimes: []string{"12:00"}}) + if err != nil { + t.Fatal(err) + } + id, err := db.InsertOpenAIResponsesAccount(ctx, "scheduled-cancel", map[string]interface{}{ + "upstream_type": auth.UpstreamOpenAIResponses, scheduledTestCredentialKey: string(plan), + }, "") + if err != nil { + t.Fatal(err) + } + // Keep every execution slot occupied until after cancellation has finished. + gate := h.scheduledConnectionTestGate() + for i := 0; i < cap(gate); i++ { + gate <- struct{}{} + } + h.dispatchScheduledConnectionTests(ctx, now) + deadline := time.Now().Add(5 * time.Second) + for { + jobs, err := db.ListOneShotMaintenanceJobs(ctx, id, scheduledTestJobPrefix, 10) + if err != nil { + t.Fatal(err) + } + if len(jobs) == 1 { + break + } + if time.Now().After(deadline) { + t.Fatal("queued slot was not claimed") + } + time.Sleep(10 * time.Millisecond) + } + cancel() + done := make(chan struct{}) + go func() { h.WaitScheduledConnectionTests(); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("queued batch did not finish after cancellation") + } + jobs, err := db.ListOneShotMaintenanceJobs(context.Background(), id, scheduledTestJobPrefix, 10) + if err != nil || len(jobs) != 1 { + t.Fatalf("jobs=%v err=%v", jobs, err) + } + var result scheduledTestRecord + if err := json.Unmarshal([]byte(jobs[0].LastError), &result); err != nil { + t.Fatalf("cancelled claim has no result: %q (%v)", jobs[0].LastError, err) + } + if result.Status != "failed" || result.Success || result.Error == "" || result.AttemptedAt != "" { + t.Fatalf("unexpected pre-execution cancellation result: %+v", result) + } + if result.AccountID != id || result.Model != "gpt-5.5" || result.ScheduledAt != now.Format(time.RFC3339) { + t.Fatalf("cancelled result lost slot identity: %+v", result) + } + claimed, err := db.TryClaimMaintenanceJob(context.Background(), id, scheduledTestJobPrefix+now.Format("2006-01-02T15:04"), now) + if err != nil || claimed { + t.Fatalf("cancelled slot must not be retried: claimed=%v err=%v", claimed, err) + } +} diff --git a/admin/self_service.go b/admin/self_service.go index 6781cb62e..2992410fc 100644 --- a/admin/self_service.go +++ b/admin/self_service.go @@ -140,6 +140,11 @@ func (h *Handler) GenerateAccountPortalAuthURL(c *gin.Context) { return } + authURL, err := buildOAuthAuthorizeURL(oauthDefaultRedirectURI, state, codeVerifier) + if err != nil { + writeError(c, http.StatusBadRequest, "客户端身份配置无效: "+err.Error()) + return + } globalOAuthStore.set(sessionID, &oauthSession{ State: state, CodeVerifier: codeVerifier, @@ -149,7 +154,6 @@ func (h *Handler) GenerateAccountPortalAuthURL(c *gin.Context) { CreatedAt: time.Now(), }) - authURL := buildOAuthAuthorizeURL(oauthDefaultRedirectURI, state, codeVerifier) c.JSON(http.StatusOK, gin.H{ "auth_url": authURL, "session_id": sessionID, diff --git a/admin/test_connection.go b/admin/test_connection.go index c6039c6ee..707dd3317 100644 --- a/admin/test_connection.go +++ b/admin/test_connection.go @@ -176,6 +176,15 @@ func (h *Handler) testConnection(c *gin.Context, quality *qualityTestRequest) { // 探针本身也进用量记录:测连 / 降智检测各有独立内部原因,用量页据此打标。 usageReason := connectionTestReason(quality) + if c.GetBool(scheduledTestContextKey) { + usageReason = "scheduled_connection_test" + if !isClaudeAccount && !isAntigravityAccount && !isOpenAIResponsesAccount { + // Codex Responses models do not all expose the same reasoning tiers. + // `none` is the lowest-cost tier supported by gpt-6-luna and avoids + // sending the unsupported `minimal` value to the upstream API. + payload, _ = sjson.SetBytes(payload, "reasoning.effort", "none") + } + } usageEndpoint := connectionTestEndpoint(account) usageEffort := connectionTestReasoningEffort(payload) @@ -214,8 +223,8 @@ func (h *Handler) testConnection(c *gin.Context, quality *qualityTestRequest) { resp, reqErr = h.executeAntigravityConnectionTest(c.Request.Context(), account, testModel, payload, h.store.ResolveProxyForAccount(account), !isTransient) } else if isOpenAIResponsesAccount { resp, reqErr = proxy.ExecuteRelayStyleRequest(c.Request.Context(), account, payload, h.store.ResolveProxyForAccount(account), nil) - } else if quality != nil { - // 降智检测是一次性长生成(常达数分钟),不需要 WS 续链;强制走独立 HTTP SSE, + } else if quality != nil || c.GetBool(scheduledTestContextKey) { + // 降智检测和定时激活均使用独立 HTTP SSE,不依赖 WS 续链或重连; // 不受"强制 WebSocket"影响,也不占用/依赖池化长连接。 resp, reqErr = proxy.ExecuteRequest(c.Request.Context(), account, payload, "", h.store.ResolveProxyForAccount(account), "", nil, nil, false) } else { diff --git a/api/errors.go b/api/errors.go index 668911560..b2d63dd4e 100644 --- a/api/errors.go +++ b/api/errors.go @@ -30,7 +30,7 @@ const ( ErrCodeInvalidStreamID ErrorCode = "invalid_stream_id" ErrCodeWebsocketStreamLimit ErrorCode = "websocket_stream_limit_reached" ErrCodeContextLengthExceeded ErrorCode = "context_length_exceeded" - ErrCodeResponseContextUnavailable ErrorCode = "response_context_unavailable" + ErrCodeResponseContextUnavailable ErrorCode = "previous_response_not_found" ErrCodeUnsupportedModel ErrorCode = "unsupported_model" ErrCodeRateLimitReached ErrorCode = "rate_limit_reached" diff --git a/api/middleware.go b/api/middleware.go index 2d49d6d76..54d5e784f 100644 --- a/api/middleware.go +++ b/api/middleware.go @@ -280,7 +280,7 @@ func CORSMiddleware() gin.HandlerFunc { c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH") c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, X-Request-ID, X-API-Version, X-Codex2API-Affinity-Key") - c.Header("Access-Control-Expose-Headers", "X-Request-ID, X-Codex2API-Request-ID, X-API-Version, X-API-Supported-Versions") + c.Header("Access-Control-Expose-Headers", "X-Request-ID, X-Codex2API-Request-ID, X-API-Version, X-API-Supported-Versions, X-Codex2API-Max-Request-Body-Bytes") c.Header("Access-Control-Max-Age", "86400") if c.Request.Method == "OPTIONS" { diff --git a/auth/antigravity_balance.go b/auth/antigravity_balance.go index faae4608b..0ffc369b3 100644 --- a/auth/antigravity_balance.go +++ b/auth/antigravity_balance.go @@ -25,11 +25,16 @@ var quotaPressureWindows = []string{"weekly", "5h"} // - present=true:存在 presence 有效、数值合法且未过期的桶,value 取最小剩余; // - exists=true:权威完整清单(summary_integrity)证实窗口存在,但数值/时效不可用; // - 两者皆 false:缺席或存在性未知(调用方省略,不惩罚)。 -func aggregateQuotaWindow(snap *AntigravityQuotaSnapshot, window string, now time.Time) (value float64, present, exists bool) { +// +// scope 限定参与聚合的桶(按请求模型的配额族),nil 表示整号所有桶。 +func aggregateQuotaWindow(snap *AntigravityQuotaSnapshot, window string, scope antigravityQuotaBucketScope, now time.Time) (value float64, present, exists bool) { best := math.Inf(1) foundAny := false for _, g := range snap.Groups { for _, b := range g.Buckets { + if scope != nil && !scope(b) { + continue + } w, ok := NormalizeAntigravityWindow(b.Window) if !ok || w != window { continue @@ -83,12 +88,25 @@ func quantizeQuotaPressure(v float64) float64 { // 数值/时效不可用的窗口贡献中性分 0.5;缺席或存在性未知的窗口直接省略,不给本没有 // 该窗口限制的账号凭空降权(Spec §7.2)。同窗口多桶取最小剩余(最紧桶决定压力)。 func (snap *AntigravityQuotaSnapshot) ComputeQuotaPressure(now time.Time) float64 { + return snap.ComputeQuotaPressureForModel("", now) +} + +// ComputeQuotaPressureForModel 是 ComputeQuotaPressure 的模型感知版本:只聚合 model +// 所在配额族的桶(另含无法归族的保守桶),Claude 周配额见底不再拖低同号 Gemini 请求 +// 的排序,反之亦然。model 为空或无法归族时与整号口径一致。 +func (snap *AntigravityQuotaSnapshot) ComputeQuotaPressureForModel(model string, now time.Time) float64 { if snap == nil { return neutralQuotaPressure } + var scope antigravityQuotaBucketScope + family := antigravityQuotaFamilyUnknown + if strings.TrimSpace(model) != "" { + scope, _ = antigravityQuotaScopeForModel(snap, model, true) + family = antigravityModelQuotaFamily(model) + } var total, weight float64 for _, window := range quotaPressureWindows { - value, present, exists := aggregateQuotaWindow(snap, window, now) + value, present, exists := aggregateQuotaWindow(snap, window, scope, now) switch { case present: total += 1.0 - value @@ -105,9 +123,13 @@ func (snap *AntigravityQuotaSnapshot) ComputeQuotaPressure(now time.Time) float6 // groups 完全不可用(无 summary、窗口全部未识别、quota 关闭期间): // 回退 models 级压力,只统计 presence 有效的条目,防止加权避让静默退化。 + // 模型可归族时只取同族条目:其余族的余量与本次请求会扣的配额无关。 var modelSum float64 var modelCount int for _, m := range snap.Models { + if family != antigravityQuotaFamilyUnknown && antigravityModelQuotaFamily(m.ModelID) != family { + continue + } if m.FractionPresent && m.RemainingFraction != nil { v := *m.RemainingFraction if v >= 0.0 && v <= 1.0 && !math.IsNaN(v) && !math.IsInf(v, 0) { @@ -130,7 +152,9 @@ func (snap *AntigravityQuotaSnapshot) ComputeQuotaPressure(now time.Time) float6 // 返回中性 0.5:软打分可以吃不准的数,但不能拿不可信的数去系统性压低某个账号 // (Spec §7.4)。压力分只在 Fresh 候选排序里消费,既有健康绑定复用分支不调用本方法, // 因此已绑定健康会话天然豁免(Spec §7.3)。 -func (a *Account) antigravityQuotaPressureForScheduling(now time.Time) float64 { +// +// model 是本次请求的 public model,压力分只看它所在配额族;为空时按整号聚合。 +func (a *Account) antigravityQuotaPressureForScheduling(model string, now time.Time) float64 { if a == nil { return neutralQuotaPressure } @@ -140,7 +164,86 @@ func (a *Account) antigravityQuotaPressureForScheduling(now time.Time) float64 { if snap.SchemaVersion != 2 { return neutralQuotaPressure } - return snap.ComputeQuotaPressure(now) + return snap.ComputeQuotaPressureForModel(model, now) +} + +// antigravityExpiryMinRemaining 是临期优先的存量下限:剩余不足 5% 的周配额冲刺价值 +// 很小,反而会把新会话压到一个随时触顶的号上。 +const antigravityExpiryMinRemaining = 0.05 + +// antigravityExpiryMinHours 是紧急度分母的下限,避免离重置只剩几分钟时分数发散。 +const antigravityExpiryMinHours = 0.5 + +// antigravityExpiryUrgency 计算账号在 model 所在配额族上的临期紧急度。 +// +// 只看周窗口:5h 窗口每 5 小时就重置一次,恒处于「临近重置」,计入会让临期档常驻; +// 周配额才是到期清零会造成实际浪费的那一份。同族多个周桶取最紧(剩余最少)的那个—— +// 能用掉的量受最紧桶约束。 +// +// 返回 urgent=true 当且仅当:groups 元数据可信、模型可归族、同族 5h 桶未知为零、 +// 存在新鲜(未超 TTL)且数值合法的周桶、剩余 ≥ 5%、可信重置时刻在 (now, now+horizon] 内。此时 score = R / max(T小时, 0.5): +// 剩得越多、离重置越近越优先。未知族的桶不参与——加分不能建立在猜测上。 +func (a *Account) antigravityExpiryUrgency(model string, horizon time.Duration, now time.Time) (score float64, urgent bool) { + if a == nil || horizon <= 0 { + return 0, false + } + a.mu.RLock() + defer a.mu.RUnlock() + snap := &a.AntigravityQuota + // 与硬门禁同一信任口径:groups 来源元数据不可信时桶数值不作为加分依据。 + if snap.SchemaVersion != 2 || !antigravityQuotaGroupsMetaTrusted(snap, now) { + return 0, false + } + scope, known := antigravityQuotaScopeForModel(snap, model, false) + if !known { + return 0, false + } + remaining := math.Inf(1) + var resetAt time.Time + for _, g := range snap.Groups { + for _, b := range g.Buckets { + if !scope(b) { + continue + } + w, ok := NormalizeAntigravityWindow(b.Window) + if !ok { + continue + } + if w == "5h" { + // 同族 5h 已知为零:周配额再临期也接不了新请求,不加分(门禁关闭时它仍 + // 可调度,只是不被提前,免得白耗一次上游 429 重试)。 + if blocked, _, _ := antigravityQuotaBucketGateBlock(b, now); blocked { + return 0, false + } + continue + } + if w != "weekly" || !b.FractionPresent || b.RemainingFraction == nil { + continue + } + v := *b.RemainingFraction + if v < 0.0 || v > 1.0 || math.IsNaN(v) || math.IsInf(v, 0) { + continue + } + if !b.ObservedAt.IsZero() && now.After(b.ObservedAt.Add(time.Duration(b.TTLSeconds)*time.Second)) { + continue // 观测过期:剩余比例可能早已被消耗,不能据此冲刺 + } + reset, ok := ValidateResetTime(b.ResetTime, b.ObservedAt, w) + if !ok || !reset.After(now) { + continue + } + if v < remaining { + remaining, resetAt = v, reset + } + } + } + if math.IsInf(remaining, 1) || remaining < antigravityExpiryMinRemaining { + return 0, false + } + left := resetAt.Sub(now) + if left > horizon { + return 0, false + } + return quantizeQuotaPressure(remaining / math.Max(left.Hours(), antigravityExpiryMinHours)), true } // SelectionOptions 描述一次选号的调度上下文。 @@ -224,7 +327,8 @@ func antigravityTieBreak(key string, dbID int64) uint64 { // // score = load + quotaPressure*0.50 + penalty*0.35 (load=occupied/limit,quotaPressure=额度压力分∈[0,1]) // -// 升序打分,同分用 (key, DBID) 哈希做确定性打散;从最高层开始逐个尝试取号,命中即返回, +// 升序打分(临期优先开启时,同层内请求模型所在配额族的周配额临期账号先于其余账号, +// 见 antigravityExpiryUrgency),同分用 (key, DBID) 哈希做确定性打散;从最高层开始逐个尝试取号,命中即返回, // 整层都拿不下(并发被抢占 / 命中 cached cooldown)时继续下一层。它只影响「下一个新会话 // 优先选谁」,不改变任何冷却、绑定或并发语义。 // @@ -248,12 +352,15 @@ func (s *Store) selectAntigravityBalancedAccount(key string, apiKeyID int64, exc tierPriority int limit int64 score float64 + urgent bool + urgency float64 tie uint64 } maxConcurrency := s.maxConcurrency.Load() now := time.Now() penaltyWeight, usageWeight := AntigravityThrottleWeights() + expiryEnabled, expiryHorizon := AntigravityExpiryPriority() accounts := s.accountSnapshotAccounts() candidates := make([]candidate, 0, len(accounts)) @@ -279,15 +386,22 @@ func (s *Store) selectAntigravityBalancedAccount(key string, apiKeyID int64, exc load := float64(occupied) / float64(limit) // quotaPressure 整体替换原 usage 项(防双罚,Spec §7.1、验收 #19): // 沿用同一 usageWeight 权重位,绝不与 usage 叠加。 - pressure := acc.antigravityQuotaPressureForScheduling(now) + pressure := acc.antigravityQuotaPressureForScheduling(opts.Model, now) penalty := acc.AntigravityPenalty(now) score := load + pressure*usageWeight + penalty*penaltyWeight + var urgency float64 + var urgent bool + if expiryEnabled { + urgency, urgent = acc.antigravityExpiryUrgency(opts.Model, expiryHorizon, now) + } candidates = append(candidates, candidate{ acc: acc, schedulerPriority: acc.schedulerPriority(), tierPriority: tierPriority(tier), limit: limit, score: score, + urgent: urgent, + urgency: urgency, tie: antigravityTieBreak(key, acc.DBID), }) } @@ -307,6 +421,14 @@ func (s *Store) selectAntigravityBalancedAccount(key string, apiKeyID int64, exc if left.tierPriority != right.tierPriority { return left.tierPriority > right.tierPriority } + // 临期优先(开关开启时):同层内临期账号整体排在非临期之前,临期账号之间按紧急度 + // 降序。非临期账号保持原打分,远期号池的均衡不受影响。 + if left.urgent != right.urgent { + return left.urgent + } + if left.urgent && left.urgency != right.urgency { + return left.urgency > right.urgency + } if left.score != right.score { return left.score < right.score } diff --git a/auth/antigravity_quota_family.go b/auth/antigravity_quota_family.go new file mode 100644 index 000000000..d89af1c95 --- /dev/null +++ b/auth/antigravity_quota_family.go @@ -0,0 +1,110 @@ +package auth + +import ( + "strings" +) + +// antigravityQuotaFamily 是 Antigravity 配额桶的「模型族」:上游配额汇总只按分组下发 +// 桶(gemini-weekly / gemini-5h / 3p-weekly / 3p-5h …),不说明哪个模型扣哪个桶, +// 因此这里按桶 ID 与模型 ID 的稳定前缀把两者归到同一族,供门禁、压力分与临期优先 +// 只看「本次请求真正会扣的那几个桶」。 +// +// 归族失败(未知前缀的桶 / 无法识别的模型)一律走保守口径:未知桶对所有模型都生效, +// 无法识别的模型看全部桶——与引入分族之前的整号口径一致,绝不因为猜错族而放行。 +type antigravityQuotaFamily string + +const ( + antigravityQuotaFamilyUnknown antigravityQuotaFamily = "" + antigravityQuotaFamilyGemini antigravityQuotaFamily = "gemini" + antigravityQuotaFamilyThirdParty antigravityQuotaFamily = "3p" + // antigravityQuotaFamilyArgon 是旗舰 Gemini 的独立周配额(Ultra 计划)。快照里没有 + // 该族的桶时,Argon 模型回退到 Gemini 族,避免在桶缺席时整族不设防。 + antigravityQuotaFamilyArgon antigravityQuotaFamily = "argon" +) + +// antigravityBucketQuotaFamily 按桶 ID 归族。Argon 先于 gemini 前缀判定:Argon 桶的 +// ID 同样以 gemini 开头,顺序颠倒会把旗舰周配额算进普通 Gemini 族。 +func antigravityBucketQuotaFamily(bucketID string) antigravityQuotaFamily { + id := strings.ToLower(strings.TrimSpace(bucketID)) + switch { + case id == "": + return antigravityQuotaFamilyUnknown + case strings.Contains(id, "argon"): + return antigravityQuotaFamilyArgon + case strings.HasPrefix(id, "3p"): + return antigravityQuotaFamilyThirdParty + case strings.HasPrefix(id, "gemini"): + return antigravityQuotaFamilyGemini + default: + return antigravityQuotaFamilyUnknown + } +} + +// antigravityModelQuotaFamily 按 public / wire 模型 ID 归族。Claude 与 GPT-OSS 共用 +// 「Claude and GPT models」分组(3p-* 桶),Gemini 家族共用 gemini-* 桶。 +func antigravityModelQuotaFamily(model string) antigravityQuotaFamily { + m := strings.ToLower(strings.TrimSpace(model)) + m = strings.TrimPrefix(m, "models/") + switch { + case m == "": + return antigravityQuotaFamilyUnknown + case strings.Contains(m, "argon"): + return antigravityQuotaFamilyArgon + case strings.HasPrefix(m, "gemini"): + return antigravityQuotaFamilyGemini + case strings.HasPrefix(m, "claude"), strings.HasPrefix(m, "gpt"): + return antigravityQuotaFamilyThirdParty + default: + return antigravityQuotaFamilyUnknown + } +} + +// antigravityQuotaBucketScope 判定一个桶是否落在某次请求的配额作用域内。 +type antigravityQuotaBucketScope func(bucket AntigravityQuotaBucket) bool + +// antigravityQuotaScopeAll 是整号作用域:所有桶都算(无模型上下文时的口径)。 +func antigravityQuotaScopeAll(AntigravityQuotaBucket) bool { return true } + +// antigravityQuotaScopeForModel 返回 model 在该快照上的桶作用域。 +// +// - 模型无法归族:整号作用域(保守,等同分族前的行为); +// - Argon 模型:快照有 Argon 桶就看 Argon 族,否则回退 Gemini 族; +// - 其余:只看同族桶。 +// +// 未知族的桶对任何模型都算在作用域内:不知道它限制谁,就当它限制所有人。 +// includeUnknown=false 时排除未知族桶,供只做「加分」的临期打分使用——加分不能建立在 +// 猜测上,而门禁/压力的保守口径需要它们。 +func antigravityQuotaScopeForModel(snapshot *AntigravityQuotaSnapshot, model string, includeUnknown bool) (antigravityQuotaBucketScope, bool) { + family := antigravityModelQuotaFamily(model) + if family == antigravityQuotaFamilyUnknown { + return antigravityQuotaScopeAll, false + } + if family == antigravityQuotaFamilyArgon && !antigravitySnapshotHasQuotaFamily(snapshot, antigravityQuotaFamilyArgon) { + family = antigravityQuotaFamilyGemini + } + return func(bucket AntigravityQuotaBucket) bool { + bucketFamily := antigravityBucketQuotaFamily(bucket.BucketID) + if bucketFamily == antigravityQuotaFamilyUnknown { + return includeUnknown + } + return bucketFamily == family + }, true +} + +// antigravitySnapshotHasQuotaFamily 报告快照里是否存在属于 family 的白名单窗口桶。 +func antigravitySnapshotHasQuotaFamily(snapshot *AntigravityQuotaSnapshot, family antigravityQuotaFamily) bool { + if snapshot == nil { + return false + } + for _, g := range snapshot.Groups { + for _, b := range g.Buckets { + if _, ok := NormalizeAntigravityWindow(b.Window); !ok { + continue + } + if antigravityBucketQuotaFamily(b.BucketID) == family { + return true + } + } + } + return false +} diff --git a/auth/antigravity_quota_family_test.go b/auth/antigravity_quota_family_test.go new file mode 100644 index 000000000..f77695a4e --- /dev/null +++ b/auth/antigravity_quota_family_test.go @@ -0,0 +1,413 @@ +package auth + +import ( + "fmt" + "testing" + "time" +) + +// agFamilyBucket 构造一个可信观测的配额桶:remaining 为 nil 表示上游未报数值。 +func agFamilyBucket(id, window string, remaining *float64, observed, reset time.Time) AntigravityQuotaBucket { + b := AntigravityQuotaBucket{ + BucketID: id, + Window: window, + RemainingFraction: remaining, + FractionPresent: remaining != nil, + ObservedAt: observed, + ObservationSeq: 7, + TTLSeconds: 3600, + } + if !reset.IsZero() { + b.ResetTime = reset.UTC().Format(time.RFC3339) + } + return b +} + +func agFloat(v float64) *float64 { return &v } + +// agFamilySnapshot 构造带可信 groups 元数据的 V2 快照,分组即上游真实的两组形态。 +func agFamilySnapshot(observed time.Time, buckets ...AntigravityQuotaBucket) AntigravityQuotaSnapshot { + return AntigravityQuotaSnapshot{ + SchemaVersion: 2, + SourceMetadata: map[string]SourceMeta{ + "groups": {AppliedSeq: 7, ObservedAt: observed, PresenceVerified: true, SummaryIntegrity: true}, + }, + Groups: []AntigravityQuotaGroup{{DisplayName: "mixed", Buckets: buckets}}, + } +} + +func TestAntigravityQuotaFamilyClassification(t *testing.T) { + buckets := map[string]antigravityQuotaFamily{ + "gemini-weekly": antigravityQuotaFamilyGemini, + "gemini-5h": antigravityQuotaFamilyGemini, + "3p-weekly": antigravityQuotaFamilyThirdParty, + "3p-5h": antigravityQuotaFamilyThirdParty, + "gemini-argon-wtu-weekly": antigravityQuotaFamilyArgon, + "": antigravityQuotaFamilyUnknown, + "w1": antigravityQuotaFamilyUnknown, + } + for id, want := range buckets { + if got := antigravityBucketQuotaFamily(id); got != want { + t.Errorf("bucket %q: got %q want %q", id, got, want) + } + } + models := map[string]antigravityQuotaFamily{ + "gemini-3.8-flash-high": antigravityQuotaFamilyGemini, + "gemini-pro-agent": antigravityQuotaFamilyGemini, + "models/gemini-2.5-pro": antigravityQuotaFamilyGemini, + "gemini-4-argon-high": antigravityQuotaFamilyArgon, + "claude-sonnet-4-6": antigravityQuotaFamilyThirdParty, + "claude-opus-4-6-thinking": antigravityQuotaFamilyThirdParty, + "gpt-oss-120b-medium": antigravityQuotaFamilyThirdParty, + "": antigravityQuotaFamilyUnknown, + "mystery-model": antigravityQuotaFamilyUnknown, + } + for model, want := range models { + if got := antigravityModelQuotaFamily(model); got != want { + t.Errorf("model %q: got %q want %q", model, got, want) + } + } +} + +// 某一族周配额为零只挡该族的请求:整号不阻断,Claude 请求被挡、Gemini 请求放行。 +func TestAntigravityQuotaGateIsolatesFamilies(t *testing.T) { + now := time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC) + weeklyReset := now.Add(48 * time.Hour) + snap := agFamilySnapshot(now, + agFamilyBucket("gemini-weekly", "weekly", agFloat(0.9), now, weeklyReset), + agFamilyBucket("gemini-5h", "5h", agFloat(1), now, now.Add(3*time.Hour)), + agFamilyBucket("3p-weekly", "weekly", agFloat(0), now, weeklyReset), + agFamilyBucket("3p-5h", "5h", agFloat(1), now, now.Add(3*time.Hour)), + ) + + if blocked, _, _ := antigravityQuotaSnapshotGateBlock(&snap, now); blocked { + t.Fatal("account must stay schedulable while the Gemini family still has quota") + } + blocked, reason, resetAt := antigravityQuotaSnapshotGateBlockForModel(&snap, "claude-sonnet-4-6", now) + if !blocked || reason != "known_zero" || !resetAt.Equal(weeklyReset) { + t.Fatalf("claude request: blocked=%v reason=%q reset=%v, want blocked until %v", blocked, reason, resetAt, weeklyReset) + } + if blocked, _, _ := antigravityQuotaSnapshotGateBlockForModel(&snap, "gemini-3.8-flash-high", now); blocked { + t.Fatal("gemini request must not be blocked by the exhausted 3p family") + } + // 无法归族的模型退回严格口径:任一桶为零即阻断。 + if blocked, _, _ := antigravityQuotaSnapshotGateBlockForModel(&snap, "mystery-model", now); !blocked { + t.Fatal("unclassified model must fall back to the strict any-bucket gate") + } +} + +// 所有已识别族都耗尽时整号阻断,到期取最早恢复的那一族;同族多个零桶取最晚到期。 +func TestAntigravityQuotaGateAllFamiliesExhausted(t *testing.T) { + now := time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC) + snap := agFamilySnapshot(now, + agFamilyBucket("gemini-weekly", "weekly", agFloat(0), now, now.Add(72*time.Hour)), + agFamilyBucket("gemini-5h", "5h", agFloat(0), now, now.Add(2*time.Hour)), + agFamilyBucket("3p-weekly", "weekly", agFloat(0.5), now, now.Add(72*time.Hour)), + agFamilyBucket("3p-5h", "5h", agFloat(0), now, now.Add(4*time.Hour)), + ) + blocked, _, resetAt := antigravityQuotaSnapshotGateBlock(&snap, now) + if !blocked { + t.Fatal("account with every family exhausted must be blocked") + } + // Gemini 族要等周重置(72h),3p 族 5h 后恢复:整号最早 4h 后可用。 + if want := now.Add(4 * time.Hour); !resetAt.Equal(want) { + t.Fatalf("reset=%v want earliest family recovery %v", resetAt, want) + } + _, _, geminiReset := antigravityQuotaSnapshotGateBlockForModel(&snap, "gemini-3.8-flash-high", now) + if want := now.Add(72 * time.Hour); !geminiReset.Equal(want) { + t.Fatalf("gemini reset=%v want latest zero bucket %v", geminiReset, want) + } +} + +// 无法归族的零桶对所有请求生效(保守)。 +func TestAntigravityQuotaGateUnknownBucketIsAccountWide(t *testing.T) { + now := time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC) + snap := agFamilySnapshot(now, + agFamilyBucket("gemini-weekly", "weekly", agFloat(1), now, now.Add(72*time.Hour)), + agFamilyBucket("mystery-weekly", "weekly", agFloat(0), now, now.Add(24*time.Hour)), + ) + if blocked, _, _ := antigravityQuotaSnapshotGateBlock(&snap, now); !blocked { + t.Fatal("unknown-family zero bucket must block the account") + } + if blocked, _, _ := antigravityQuotaSnapshotGateBlockForModel(&snap, "gemini-3.8-flash-high", now); !blocked { + t.Fatal("unknown-family zero bucket must block every model") + } +} + +// Argon:有独立桶时只看 Argon 桶;没有时回退 Gemini 族。 +func TestAntigravityQuotaGateArgonScope(t *testing.T) { + now := time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC) + withArgon := agFamilySnapshot(now, + agFamilyBucket("gemini-weekly", "weekly", agFloat(1), now, now.Add(72*time.Hour)), + agFamilyBucket("gemini-argon-wtu-weekly", "weekly", agFloat(0), now, now.Add(72*time.Hour)), + ) + if blocked, _, _ := antigravityQuotaSnapshotGateBlockForModel(&withArgon, "gemini-4-argon-high", now); !blocked { + t.Fatal("argon request must be blocked by the exhausted argon bucket") + } + if blocked, _, _ := antigravityQuotaSnapshotGateBlockForModel(&withArgon, "gemini-3.8-flash-high", now); blocked { + t.Fatal("flash request must not be blocked by the argon bucket") + } + + withoutArgon := agFamilySnapshot(now, + agFamilyBucket("gemini-weekly", "weekly", agFloat(0), now, now.Add(72*time.Hour)), + agFamilyBucket("3p-weekly", "weekly", agFloat(1), now, now.Add(72*time.Hour)), + ) + if blocked, _, _ := antigravityQuotaSnapshotGateBlockForModel(&withoutArgon, "gemini-4-argon-high", now); !blocked { + t.Fatal("argon request without an argon bucket must follow the gemini family") + } +} + +// 模型级过滤器:被门禁挡掉的账号不触发调用方 filter;空模型原样返回 filter。 +func TestWithAntigravityModelQuotaFilter(t *testing.T) { + now := time.Now() + acc := &Account{UpstreamType: UpstreamAntigravity, AntigravityQuota: agFamilySnapshot(now, + agFamilyBucket("gemini-weekly", "weekly", agFloat(1), now, now.Add(72*time.Hour)), + agFamilyBucket("3p-weekly", "weekly", agFloat(0), now, now.Add(72*time.Hour)), + )} + calls := 0 + inner := func(*Account) bool { calls++; return true } + + if withAntigravityModelQuotaFilter(inner, "claude-sonnet-4-6")(acc) { + t.Fatal("claude request must be filtered out") + } + if calls != 0 { + t.Fatalf("inner filter must not run for a gated account, calls=%d", calls) + } + if !withAntigravityModelQuotaFilter(inner, "gemini-3.8-flash-high")(acc) || calls != 1 { + t.Fatalf("gemini request must pass through to the inner filter once, calls=%d", calls) + } + if !withAntigravityModelQuotaFilter(nil, "gemini-3.8-flash-high")(acc) { + t.Fatal("nil inner filter must accept an ungated account") + } + if withAntigravityModelQuotaFilter(nil, " ") != nil { + t.Fatal("empty model must leave the filter untouched") + } +} + +// 选号与 429 判定:3p 族耗尽的号池,Claude 请求拿不到号且判定为配额耗尽,Gemini 照常。 +func TestAntigravityModelQuotaGateSelectionAndExhaustion(t *testing.T) { + store := newAntigravityBalanceStore(t, 2) + defer store.Stop() + now := time.Now() + for _, id := range []int64{1, 2} { + acc := store.FindByID(id) + acc.mu.Lock() + acc.AntigravityQuota = agFamilySnapshot(now, + agFamilyBucket("gemini-weekly", "weekly", agFloat(0.8), now, now.Add(72*time.Hour)), + agFamilyBucket("3p-weekly", "weekly", agFloat(0), now, now.Add(72*time.Hour)), + ) + acc.mu.Unlock() + } + + claude := antigravitySelectionOpts() + claude.Model = "claude-sonnet-4-6" + if acc, _, _ := store.NextForSessionWithDispatchGuardForProvider(claude, "claude-session", 0, nil, nil); acc != nil { + store.Release(acc) + t.Fatalf("claude request selected account %d despite exhausted 3p family", acc.DBID) + } + if err := store.AntigravityQuotaGateExhaustion("claude-session", 0, nil, nil, false, claude.Model); err == nil { + t.Fatal("claude request must be reported as quota exhausted") + } + + gemini := antigravitySelectionOpts() + acc, _, _ := store.NextForSessionWithDispatchGuardForProvider(gemini, "gemini-session", 0, nil, nil) + if acc == nil { + t.Fatal("gemini request must still get an account") + } + store.Release(acc) + if err := store.AntigravityQuotaGateExhaustion("gemini-session-2", 0, nil, nil, false, gemini.Model); err != nil { + t.Fatalf("gemini request must not be reported as quota exhausted: %v", err) + } +} + +func TestComputeQuotaPressureForModelUsesFamilyBuckets(t *testing.T) { + now := time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC) + snap := agFamilySnapshot(now, + agFamilyBucket("gemini-weekly", "weekly", agFloat(0.9), now, now.Add(72*time.Hour)), + agFamilyBucket("3p-weekly", "weekly", agFloat(0.1), now, now.Add(72*time.Hour)), + ) + if p := snap.ComputeQuotaPressureForModel("gemini-3.8-flash-high", now); p != 0.1 { + t.Fatalf("gemini pressure=%v want 0.1", p) + } + if p := snap.ComputeQuotaPressureForModel("claude-sonnet-4-6", now); p != 0.9 { + t.Fatalf("claude pressure=%v want 0.9", p) + } + // 整号口径:同窗口取最紧桶。 + if p := snap.ComputeQuotaPressure(now); p != 0.9 { + t.Fatalf("account-wide pressure=%v want 0.9", p) + } +} + +func TestAntigravityExpiryUrgency(t *testing.T) { + now := time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC) + horizon := 24 * time.Hour + mk := func(buckets ...AntigravityQuotaBucket) *Account { + return &Account{UpstreamType: UpstreamAntigravity, AntigravityQuota: agFamilySnapshot(now, buckets...)} + } + + acc := mk( + agFamilyBucket("gemini-weekly", "weekly", agFloat(0.6), now, now.Add(12*time.Hour)), + agFamilyBucket("gemini-5h", "5h", agFloat(1), now, now.Add(1*time.Hour)), + agFamilyBucket("3p-weekly", "weekly", agFloat(0.6), now, now.Add(100*time.Hour)), + ) + score, urgent := acc.antigravityExpiryUrgency("gemini-3.8-flash-high", horizon, now) + if !urgent || score != 0.05 { + t.Fatalf("gemini urgency=%v urgent=%v, want 0.6/12=0.05", score, urgent) + } + if _, urgent := acc.antigravityExpiryUrgency("claude-sonnet-4-6", horizon, now); urgent { + t.Fatal("3p weekly resets beyond the horizon: claude must not be urgent") + } + if _, urgent := acc.antigravityExpiryUrgency("mystery-model", horizon, now); urgent { + t.Fatal("unclassified model must never be urgent") + } + + // 5h 窗口不参与:只有 5h 桶时不临期。 + only5h := mk(agFamilyBucket("gemini-5h", "5h", agFloat(1), now, now.Add(1*time.Hour))) + if _, urgent := only5h.antigravityExpiryUrgency("gemini-3.8-flash-high", horizon, now); urgent { + t.Fatal("5h-only snapshot must not be urgent") + } + // 存量不足 5% 不冲刺。 + low := mk(agFamilyBucket("gemini-weekly", "weekly", agFloat(0.04), now, now.Add(2*time.Hour))) + if _, urgent := low.antigravityExpiryUrgency("gemini-3.8-flash-high", horizon, now); urgent { + t.Fatal("nearly exhausted weekly quota must not be urgent") + } + // 观测过期不冲刺。 + stale := mk(agFamilyBucket("gemini-weekly", "weekly", agFloat(0.9), now.Add(-2*time.Hour), now.Add(6*time.Hour))) + if _, urgent := stale.antigravityExpiryUrgency("gemini-3.8-flash-high", horizon, now); urgent { + t.Fatal("stale observation must not be urgent") + } + // 分母下限:离重置 6 分钟时按 0.5h 计。 + soon := mk(agFamilyBucket("gemini-weekly", "weekly", agFloat(0.5), now, now.Add(6*time.Minute))) + if score, urgent := soon.antigravityExpiryUrgency("gemini-3.8-flash-high", horizon, now); !urgent || score != 1 { + t.Fatalf("near-reset urgency=%v urgent=%v, want 0.5/0.5=1", score, urgent) + } +} + +// setAntigravityExpiryPriorityForTest 打开临期优先并在测试结束时恢复原配置。 +func setAntigravityExpiryPriorityForTest(t *testing.T, enabled bool, hours int) { + t.Helper() + prev := ConfiguredAntigravitySettings() + t.Cleanup(func() { SetConfiguredAntigravitySettings(prev) }) + next := prev + next.ExpiryPriorityEnabled = &enabled + next.ExpiryPriorityHorizonHours = &hours + SetConfiguredAntigravitySettings(next) +} + +// 临期优先:开启后新会话集中到请求模型所在族周配额临期的账号;其它族的请求、或开关 +// 关闭时,仍按原打分摊开。 +func TestAntigravityBalancedSelectionPrefersExpiringFamily(t *testing.T) { + store := newAntigravityBalanceStore(t, 4) + defer store.Stop() + now := time.Now() + for _, id := range []int64{1, 2, 3, 4} { + geminiReset := now.Add(120 * time.Hour) + if id == 3 { + geminiReset = now.Add(10 * time.Hour) // 账号 3 的 Gemini 周配额临期 + } + acc := store.FindByID(id) + acc.mu.Lock() + acc.AntigravityQuota = agFamilySnapshot(now, + agFamilyBucket("gemini-weekly", "weekly", agFloat(0.7), now, geminiReset), + agFamilyBucket("3p-weekly", "weekly", agFloat(0.7), now, now.Add(120*time.Hour)), + ) + acc.mu.Unlock() + } + + pick := func(model, prefix string) map[int64]int { + opts := antigravitySelectionOpts() + opts.Model = model + counts := map[int64]int{} + for i := 0; i < 40; i++ { + acc := store.nextAccountForFreshAffinityWithDispatch(fmt.Sprintf("%s-%d", prefix, i), 0, nil, nil, opts) + if acc == nil { + t.Fatal("selection returned no account") + } + counts[acc.DBID]++ + store.Release(acc) + } + return counts + } + + if counts := pick("gemini-3.8-flash-high", "off"); len(counts) < 2 { + t.Fatalf("expiry priority disabled: selections must spread, got %v", counts) + } + + setAntigravityExpiryPriorityForTest(t, true, 24) + if counts := pick("gemini-3.8-flash-high", "on"); counts[3] != 40 { + t.Fatalf("expiring gemini account 3 must take every new session, got %v", counts) + } + if counts := pick("claude-sonnet-4-6", "claude"); len(counts) < 2 { + t.Fatalf("claude requests must not be pulled onto the gemini-expiring account, got %v", counts) + } + + // 临期账号并发占满后,回落到其余账号。 + saturateAntigravityAccount(store.FindByID(3)) + opts := antigravitySelectionOpts() + acc := store.nextAccountForFreshAffinityWithDispatch("saturated", 0, nil, nil, opts) + if acc == nil || acc.DBID == 3 { + t.Fatalf("saturated expiring account must yield to others, got %v", acc) + } + store.Release(acc) +} + +// groups 不给本族桶时退回 models 级压力,也只取同族模型条目。 +func TestComputeQuotaPressureForModelModelsFallbackStaysInFamily(t *testing.T) { + now := time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC) + snap := &AntigravityQuotaSnapshot{ + SchemaVersion: 2, + Models: []AntigravityModelQuota{ + {ModelID: "gemini-3.8-flash-high", RemainingFraction: agFloat(1), FractionPresent: true}, + {ModelID: "claude-sonnet-4-6", RemainingFraction: agFloat(0), FractionPresent: true}, + }, + } + if p := snap.ComputeQuotaPressureForModel("gemini-3.8-flash-high", now); p != 0 { + t.Fatalf("gemini fallback pressure=%v want 0 (claude entries excluded)", p) + } + if p := snap.ComputeQuotaPressureForModel("claude-sonnet-4-6", now); p != 1 { + t.Fatalf("claude fallback pressure=%v want 1", p) + } + if p := snap.ComputeQuotaPressure(now); p != 0.5 { + t.Fatalf("account-wide fallback pressure=%v want 0.5", p) + } +} + +// 整号与模型族同时被挡时,Retry-After 取请求模型所在族的恢复时刻。 +func TestAntigravityAccountQuotaGateBlockUsesModelFamilyReset(t *testing.T) { + now := time.Now().Truncate(time.Second) + acc := &Account{UpstreamType: UpstreamAntigravity, AntigravityQuota: agFamilySnapshot(now, + agFamilyBucket("gemini-5h", "5h", agFloat(0), now, now.Add(4*time.Hour)), + agFamilyBucket("3p-weekly", "weekly", agFloat(0), now, now.Add(72*time.Hour)), + )} + cases := map[string]time.Duration{"claude-sonnet-4-6": 72 * time.Hour, "gemini-3.8-flash-high": 4 * time.Hour, "": 4 * time.Hour} + for model, want := range cases { + blocked, resetAt := antigravityAccountQuotaGateBlock(acc, model) + if !blocked || !resetAt.Equal(now.Add(want)) { + t.Errorf("model %q: blocked=%v reset=%v want %v", model, blocked, resetAt, now.Add(want)) + } + } +} + +// 临期加分要求 groups 元数据可信,且同族 5h 未知为零。 +func TestAntigravityExpiryUrgencyRequiresTrustAndOpen5h(t *testing.T) { + now := time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC) + weekly := agFamilyBucket("gemini-weekly", "weekly", agFloat(0.8), now, now.Add(10*time.Hour)) + + untrusted := &Account{UpstreamType: UpstreamAntigravity, AntigravityQuota: agFamilySnapshot(now, weekly)} + untrusted.AntigravityQuota.SourceMetadata = nil + if _, urgent := untrusted.antigravityExpiryUrgency("gemini-3.8-flash-high", 24*time.Hour, now); urgent { + t.Fatal("untrusted groups metadata must not earn expiry priority") + } + + zero5h := &Account{UpstreamType: UpstreamAntigravity, AntigravityQuota: agFamilySnapshot(now, + weekly, agFamilyBucket("gemini-5h", "5h", agFloat(0), now, now.Add(2*time.Hour)))} + if _, urgent := zero5h.antigravityExpiryUrgency("gemini-3.8-flash-high", 24*time.Hour, now); urgent { + t.Fatal("zero same-family 5h bucket must suppress expiry priority") + } + // 别族 5h 为零不影响。 + other5h := &Account{UpstreamType: UpstreamAntigravity, AntigravityQuota: agFamilySnapshot(now, + weekly, agFamilyBucket("3p-5h", "5h", agFloat(0), now, now.Add(2*time.Hour)))} + if _, urgent := other5h.antigravityExpiryUrgency("gemini-3.8-flash-high", 24*time.Hour, now); !urgent { + t.Fatal("zero 5h bucket of another family must not suppress expiry priority") + } +} diff --git a/auth/antigravity_quota_gate.go b/auth/antigravity_quota_gate.go index d0172aca5..0e1431198 100644 --- a/auth/antigravity_quota_gate.go +++ b/auth/antigravity_quota_gate.go @@ -1,6 +1,7 @@ package auth import ( + "strings" "sync/atomic" "time" ) @@ -47,19 +48,70 @@ func quotaGateRetryAfter(resetAt time.Time, now time.Time) time.Duration { // antigravityAccountQuotaGateBlock 是自锁封装的「是否被硬门禁阻断 + 阻断到期时刻」, // 供锁外的候选集合求值使用;与选号复用的是同一个谓词,不存在第二套判定口径。 -func antigravityAccountQuotaGateBlock(acc *Account) (bool, time.Time) { +// +// model 非空时按请求模型所在配额族求值(某一族耗尽只挡该族请求);为空时退回整号口径。 +func antigravityAccountQuotaGateBlock(acc *Account, model string) (bool, time.Time) { if acc == nil { return false, time.Time{} } acc.mu.RLock() defer acc.mu.RUnlock() - exhausted, _, resetAt := acc.IsAntigravityQuotaExhaustedLocked(time.Now()) + now := time.Now() + // 先按请求模型所在族判:被挡时到期取该族的恢复时刻——整号口径取的是最早恢复的族, + // 拿它给另一族的请求当 Retry-After 会让客户端过早重试。模型族未被挡但整号被挡 + // (未知族零桶、或模型族在快照里缺席)时才退回整号到期。 + exhausted, resetAt := false, time.Time{} + if strings.TrimSpace(model) != "" { + exhausted, _, resetAt = acc.isAntigravityModelQuotaExhaustedLocked(model, now) + } + if !exhausted { + exhausted, _, resetAt = acc.IsAntigravityQuotaExhaustedLocked(now) + } if !exhausted { return false, time.Time{} } return true, resetAt } +// isAntigravityModelQuotaExhaustedLocked 是带模型上下文的硬门禁谓词(Locked 语义同 +// IsAntigravityQuotaExhaustedLocked)。非 Antigravity 账号恒为假。 +func (a *Account) isAntigravityModelQuotaExhaustedLocked(model string, now time.Time) (bool, string, time.Time) { + if a == nil || a.UpstreamType != UpstreamAntigravity { + return false, "", time.Time{} + } + return antigravityQuotaSnapshotGateBlockForModel(&a.AntigravityQuota, model, now) +} + +// antigravityAccountModelQuotaBlocked 是自锁封装的模型级门禁查询。 +func antigravityAccountModelQuotaBlocked(acc *Account, model string, now time.Time) bool { + if acc == nil { + return false + } + acc.mu.RLock() + defer acc.mu.RUnlock() + blocked, _, _ := acc.isAntigravityModelQuotaExhaustedLocked(model, now) + return blocked +} + +// withAntigravityModelQuotaFilter 在 filter 外叠加模型级配额门禁:整号门禁只在所有配额族 +// 都耗尽时才把账号移出候选,某一族耗尽的账号仍留在调度器里,由这里按本次请求的模型 +// 挡掉——Claude 周配额用完的号仍能接 Gemini 请求,反之亦然。model 为空时原样返回。 +// +// 门禁先于调用方 filter 求值,被门禁挡掉的账号不会触发 filter 闭包(也就不会多一次 +// Redis 读);每个账号对调用方 filter 的求值次数不变。 +func withAntigravityModelQuotaFilter(filter AccountFilter, model string) AccountFilter { + model = strings.TrimSpace(model) + if model == "" { + return filter + } + return func(acc *Account) bool { + if antigravityAccountModelQuotaBlocked(acc, model, time.Now()) { + return false + } + return filter == nil || filter(acc) + } +} + // antigravityQuotaGateCandidateScope 判定账号是否仍在候选集合内:管理面停用(禁用 / // 调度暂停 / 智能暂停)的账号本就不是候选,既不构成配额阻断,也不构成混合原因。 func antigravityQuotaGateCandidateScope(acc *Account) bool { @@ -84,7 +136,11 @@ func antigravityQuotaGateCandidateScope(acc *Account) bool { // 都属于「混合原因」,返回 nil,调用方保持既有 503 / 无可用账号语义; // - 续链(preserveBinding=true)的候选集合就是绑定的那个账号,绑定关系不因阻断改变; // - 只做一次只读求值,调用方不得再据此二次推断 reason。 -func (s *Store) AntigravityQuotaGateExhaustion(sessionKey string, apiKeyID int64, exclude map[int64]bool, filter AccountFilter, preserveBinding bool) *QuotaExhaustedError { +// +// model 是本次请求的 public model:候选是否「被配额阻断」按该模型所在配额族判定,与 +// 选号侧的模型级过滤器同口径;filter 必须是调用方的原始过滤器,不能已叠加模型级门禁, +// 否则被阻断的候选会先被滤掉,整池耗尽就退化成无候选的 503。 +func (s *Store) AntigravityQuotaGateExhaustion(sessionKey string, apiKeyID int64, exclude map[int64]bool, filter AccountFilter, preserveBinding bool, model string) *QuotaExhaustedError { if s == nil { return nil } @@ -130,7 +186,7 @@ func (s *Store) AntigravityQuotaGateExhaustion(sessionKey string, apiKeyID int64 var earliestReset time.Time for _, acc := range candidates { - blocked, resetAt := antigravityAccountQuotaGateBlock(acc) + blocked, resetAt := antigravityAccountQuotaGateBlock(acc, model) if !blocked { return nil // 混合原因:不是「全部因配额阻断」 } @@ -161,57 +217,154 @@ func (a *Account) IsAntigravityQuotaExhaustedLocked(now time.Time) (bool, string return antigravityQuotaSnapshotGateBlock(&a.AntigravityQuota, now) } -// antigravityQuotaSnapshotGateBlock 是硬门禁谓词的唯一实现体:只依赖一份 V2 配额快照 +// antigravityQuotaSnapshotGateBlock 是整号口径的硬门禁谓词:只依赖一份 V2 配额快照 // 与当前时钟,不读 Account 的其他字段。调度门禁与展示侧(管理端 /state)都经由它求值, // 避免出现第二套判定口径(Spec §3.3)。 +// +// 配额按模型族分桶(Gemini / Claude+GPT / Argon),某一族耗尽不影响其他族的请求, +// 所以整号只在「没有任何一族还能用」时才阻断: +// - 未知族的桶(ID 前缀无法归族)已知为零:不知道它限制谁,按整号阻断(保守); +// - 所有已识别族都各自被阻断:整号阻断,到期取最早恢复的那一族; +// - 否则放行,由携带模型上下文的选号过滤器(antigravityQuotaSnapshotGateBlockForModel) +// 按请求模型所在族再判一次。 func antigravityQuotaSnapshotGateBlock(snapshot *AntigravityQuotaSnapshot, now time.Time) (bool, string, time.Time) { + if !antigravityQuotaGateApplicable(snapshot, now) { + return false, "", time.Time{} + } + if blocked, reason, resetAt := antigravityQuotaScopeGateBlock(snapshot, func(b AntigravityQuotaBucket) bool { + return antigravityBucketQuotaFamily(b.BucketID) == antigravityQuotaFamilyUnknown + }, now); blocked { + return true, reason, resetAt + } + seen := map[antigravityQuotaFamily]bool{} + var families []antigravityQuotaFamily + for _, g := range snapshot.Groups { + for _, b := range g.Buckets { + if _, ok := NormalizeAntigravityWindow(b.Window); !ok { + continue + } + family := antigravityBucketQuotaFamily(b.BucketID) + if family == antigravityQuotaFamilyUnknown || seen[family] { + continue + } + seen[family] = true + families = append(families, family) + } + } + if len(families) == 0 { + return false, "", time.Time{} + } + var ( + earliest time.Time + earliestReason string + ) + for _, family := range families { + blocked, reason, resetAt := antigravityQuotaScopeGateBlock(snapshot, func(b AntigravityQuotaBucket) bool { + return antigravityBucketQuotaFamily(b.BucketID) == family + }, now) + if !blocked { + return false, "", time.Time{} // 至少还有一族可用:整号不阻断 + } + if earliest.IsZero() || resetAt.Before(earliest) { + earliest, earliestReason = resetAt, reason + } + } + return true, earliestReason, earliest +} + +// antigravityQuotaSnapshotGateBlockForModel 是带模型上下文的硬门禁谓词:只看 model +// 所在族(以及无法归族的保守桶)。model 无法归族时退回「任一桶为零即阻断」的整号严格 +// 口径——与分族之前的行为一致,宁可多挡不可错放。 +func antigravityQuotaSnapshotGateBlockForModel(snapshot *AntigravityQuotaSnapshot, model string, now time.Time) (bool, string, time.Time) { + if !antigravityQuotaGateApplicable(snapshot, now) { + return false, "", time.Time{} + } + scope, _ := antigravityQuotaScopeForModel(snapshot, model, true) + return antigravityQuotaScopeGateBlock(snapshot, scope, now) +} + +// antigravityQuotaGateApplicable 校验门禁的全局前提:开关开启、V2 快照、且 groups 来源 +// 元数据可信(Spec §3.1:applied_seq > 0、observed_at 非零且不在未来)。 +func antigravityQuotaGateApplicable(snapshot *AntigravityQuotaSnapshot, now time.Time) bool { // 止血开关:若系统设置关闭门禁,立即放行(Spec §8.1,不影响已落库快照) if !ConfiguredAntigravitySettings().GetQuotaGateEnabled() { - return false, "", time.Time{} + return false } if snapshot == nil { - return false, "", time.Time{} + return false } // legacy 快照(无 schema_version=2):旧数据显式 0 不可信,绝不硬封(Spec §3.1) if snapshot.SchemaVersion != 2 { - return false, "", time.Time{} + return false } - // 元数据可信条件(Spec §3.1):applied_seq > 0、observed_at 非零且不在未来; // 缺失即 unknown,不构成阻断依据。 - meta, ok := snapshot.SourceMetadata["groups"] - if !ok || meta.AppliedSeq <= 0 || meta.ObservedAt.IsZero() || meta.ObservedAt.After(now) { - return false, "", time.Time{} + return antigravityQuotaGroupsMetaTrusted(snapshot, now) +} + +// antigravityQuotaGroupsMetaTrusted 报告 groups 来源元数据是否可信:applied_seq > 0、 +// observed_at 非零且不在未来。门禁与临期加分共用这一口径。 +func antigravityQuotaGroupsMetaTrusted(snapshot *AntigravityQuotaSnapshot, now time.Time) bool { + if snapshot == nil { + return false } + meta, ok := snapshot.SourceMetadata["groups"] + return ok && meta.AppliedSeq > 0 && !meta.ObservedAt.IsZero() && !meta.ObservedAt.After(now) +} +// antigravityQuotaScopeGateBlock 在 scope 内的桶上求值硬门禁:任一桶处于阻断态即阻断。 +// 多个桶同时阻断时,到期取最晚的那个——作用域要等所有零桶都恢复才可用(例如 5h 与 +// weekly 同时为零,Retry-After 应指向周重置而不是 5h 重置)。 +func antigravityQuotaScopeGateBlock(snapshot *AntigravityQuotaSnapshot, scope antigravityQuotaBucketScope, now time.Time) (bool, string, time.Time) { + var ( + blocked bool + latestReason string + latest time.Time + ) for _, g := range snapshot.Groups { for _, b := range g.Buckets { - w, ok := NormalizeAntigravityWindow(b.Window) - if !ok || !b.FractionPresent || b.RemainingFraction == nil { - continue // 白名单外 / 无 presence / 无数值 → unknown,不阻断 - } - if *b.RemainingFraction != 0.0 { - continue // known_positive(含极小正数)与解析层未收敛的非法值一律不阻断 + if !scope(b) { + continue } - resetAt, hasReset := ValidateResetTime(b.ResetTime, b.ObservedAt, w) - ttlDeadline := b.ObservedAt.Add(time.Duration(b.TTLSeconds) * time.Second) - if hasReset { - if now.Before(resetAt) { - if now.After(ttlDeadline) { - // 超龄已知零(stale):观测年龄超 TTL 但 resetAt 未到, - // 保持阻断并标记 stale,进入优先补查,绝不静默放行(Spec §3.3) - return true, "stale_zero", resetAt - } - return true, "known_zero", resetAt // 阻断持续到可信 resetAt - } - // resetAt 已过:窗口滚动转 unknown,仅解除本桶,不清其他有效零桶 + bucketBlocked, reason, resetAt := antigravityQuotaBucketGateBlock(b, now) + if !bucketBlocked { continue } - // 无可信 resetAt:TTL 兜底阻断,避免永久封禁 - if now.Before(ttlDeadline) { - return true, "known_zero", ttlDeadline + if !blocked || resetAt.After(latest) { + latest, latestReason = resetAt, reason } + blocked = true } } + return blocked, latestReason, latest +} + +// antigravityQuotaBucketGateBlock 是单桶的阻断判定。 +func antigravityQuotaBucketGateBlock(b AntigravityQuotaBucket, now time.Time) (bool, string, time.Time) { + w, ok := NormalizeAntigravityWindow(b.Window) + if !ok || !b.FractionPresent || b.RemainingFraction == nil { + return false, "", time.Time{} // 白名单外 / 无 presence / 无数值 → unknown,不阻断 + } + if *b.RemainingFraction != 0.0 { + return false, "", time.Time{} // known_positive(含极小正数)与解析层未收敛的非法值一律不阻断 + } + resetAt, hasReset := ValidateResetTime(b.ResetTime, b.ObservedAt, w) + ttlDeadline := b.ObservedAt.Add(time.Duration(b.TTLSeconds) * time.Second) + if hasReset { + if now.Before(resetAt) { + if now.After(ttlDeadline) { + // 超龄已知零(stale):观测年龄超 TTL 但 resetAt 未到, + // 保持阻断并标记 stale,进入优先补查,绝不静默放行(Spec §3.3) + return true, "stale_zero", resetAt + } + return true, "known_zero", resetAt // 阻断持续到可信 resetAt + } + // resetAt 已过:窗口滚动转 unknown,仅解除本桶,不清其他有效零桶 + return false, "", time.Time{} + } + // 无可信 resetAt:TTL 兜底阻断,避免永久封禁 + if now.Before(ttlDeadline) { + return true, "known_zero", ttlDeadline + } return false, "", time.Time{} } diff --git a/auth/antigravity_quota_scheduler_test.go b/auth/antigravity_quota_scheduler_test.go index e7d55dcc8..ae2dff229 100644 --- a/auth/antigravity_quota_scheduler_test.go +++ b/auth/antigravity_quota_scheduler_test.go @@ -464,7 +464,7 @@ func TestAntigravityQuotaSchedulerRefreshesQuotaExhaustedAccount(t *testing.T) { }}}}, } // 先自证硬门禁确实为真,否则本用例什么也没保住。 - if blocked, _ := antigravityAccountQuotaGateBlock(acc); !blocked { + if blocked, _ := antigravityAccountQuotaGateBlock(acc, ""); !blocked { t.Fatal("fixture must carry a live known_zero hard gate") } diff --git a/auth/antigravity_settings.go b/auth/antigravity_settings.go index b9b7331e8..2cdbf836d 100644 --- a/auth/antigravity_settings.go +++ b/auth/antigravity_settings.go @@ -7,6 +7,7 @@ import ( "sort" "strings" "sync/atomic" + "time" ) // Antigravity 渠道级设置(system_settings.antigravity_config 列)。包含模型重定向 @@ -23,6 +24,14 @@ const ( AntigravityQuotaRefreshIntervalDefaultMinutes = 60 ) +// 临期优先调度的时间窗(小时)取值范围与缺省值:周配额离重置不超过该时长、且仍有 +// 存量额度的账号在同优先级同健康档位内优先承接新会话。 +const ( + AntigravityExpiryPriorityHorizonMinHours = 1 + AntigravityExpiryPriorityHorizonMaxHours = 168 + AntigravityExpiryPriorityHorizonDefaultHours = 24 +) + // AntigravitySettings 是 antigravity_config 列的 JSON 形态。 type AntigravitySettings struct { // ModelRedirects 以逻辑模型(无后缀)为键,固定档位公开 ID 为值。 @@ -42,6 +51,13 @@ type AntigravitySettings struct { // QuotaGateEnabled 是硬门禁止血开关(缺省 true):只控制是否用配额事实阻断调度 // 与软评分,不改写、不删除已落库快照(Spec §8.1)。 QuotaGateEnabled *bool `json:"quota_gate_enabled,omitempty"` + // ExpiryPriorityEnabled 开启临期优先调度(缺省 false):新会话选号时,请求模型所在 + // 配额族的周配额临近重置且仍有存量的账号优先承接,避免到期清零浪费。只在 + // round_robin + 加权避险路径上生效。 + ExpiryPriorityEnabled *bool `json:"expiry_priority_enabled,omitempty"` + // ExpiryPriorityHorizonHours 是「临期」的时间窗,缺省 24,范围 [1, 168]。读取一律 + // 经 GetExpiryPriorityHorizonHours 钳位。 + ExpiryPriorityHorizonHours *int `json:"expiry_priority_horizon_hours,omitempty"` // ConfigRevision 是跨实例拒旧的单调递增版本号,由 PUT 侧在 CAS 事务内生成。 ConfigRevision int64 `json:"config_revision,omitempty"` } @@ -77,6 +93,26 @@ func (s AntigravitySettings) GetQuotaGateEnabled() bool { return *s.QuotaGateEnabled } +// GetExpiryPriorityEnabled 返回临期优先调度的生效值,未配置视为关闭。 +func (s AntigravitySettings) GetExpiryPriorityEnabled() bool { + return s.ExpiryPriorityEnabled != nil && *s.ExpiryPriorityEnabled +} + +// GetExpiryPriorityHorizonHours 返回钳位到 [1, 168] 的临期时间窗,未配置视为 24。 +func (s AntigravitySettings) GetExpiryPriorityHorizonHours() int { + if s.ExpiryPriorityHorizonHours == nil { + return AntigravityExpiryPriorityHorizonDefaultHours + } + value := *s.ExpiryPriorityHorizonHours + if value < AntigravityExpiryPriorityHorizonMinHours { + return AntigravityExpiryPriorityHorizonMinHours + } + if value > AntigravityExpiryPriorityHorizonMaxHours { + return AntigravityExpiryPriorityHorizonMaxHours + } + return value +} + // clone 深拷贝全部字段(含新增指针字段),避免调用方改写进程内已生效配置。 func (s AntigravitySettings) clone() AntigravitySettings { out := AntigravitySettings{ @@ -85,6 +121,8 @@ func (s AntigravitySettings) clone() AntigravitySettings { QuotaRefreshEnabled: cloneOptionalBool(s.QuotaRefreshEnabled), QuotaRefreshIntervalMinutes: cloneOptionalInt(s.QuotaRefreshIntervalMinutes), QuotaGateEnabled: cloneOptionalBool(s.QuotaGateEnabled), + ExpiryPriorityEnabled: cloneOptionalBool(s.ExpiryPriorityEnabled), + ExpiryPriorityHorizonHours: cloneOptionalInt(s.ExpiryPriorityHorizonHours), ConfigRevision: s.ConfigRevision, } if len(s.ModelRedirects) > 0 { @@ -242,6 +280,15 @@ func AntigravityRedirectOverridesEffort() bool { return v.RedirectOverridesEffort } +// AntigravityExpiryPriority 返回临期优先调度的开关与时间窗(热路径读,不深拷贝)。 +func AntigravityExpiryPriority() (bool, time.Duration) { + v, _ := configuredAntigravitySettings.Load().(AntigravitySettings) + if !v.GetExpiryPriorityEnabled() { + return false, 0 + } + return true, time.Duration(v.GetExpiryPriorityHorizonHours()) * time.Hour +} + // AntigravityExposeThoughts 报告是否向下游下发 Gemini 思考内容。 func AntigravityExposeThoughts() bool { v, _ := configuredAntigravitySettings.Load().(AntigravitySettings) @@ -270,7 +317,8 @@ func EncodeAntigravitySettings(settings AntigravitySettings) (string, error) { } if len(normalized.ModelRedirects) == 0 && !normalized.RedirectOverridesEffort && !normalized.ExposeThoughts && normalized.QuotaRefreshEnabled == nil && normalized.QuotaRefreshIntervalMinutes == nil && - normalized.QuotaGateEnabled == nil && normalized.ConfigRevision == 0 { + normalized.QuotaGateEnabled == nil && normalized.ExpiryPriorityEnabled == nil && + normalized.ExpiryPriorityHorizonHours == nil && normalized.ConfigRevision == 0 { return "{}", nil } encoded, err := json.Marshal(normalized) diff --git a/auth/codex_oauth_identity.go b/auth/codex_oauth_identity.go new file mode 100644 index 000000000..3775a71e2 --- /dev/null +++ b/auth/codex_oauth_identity.go @@ -0,0 +1,49 @@ +package auth + +import ( + "context" + "net/http" +) + +// 由启动装配注入,与对话共用解析器,避免 auth 反向依赖 proxy。 +var CodexOAuthIdentityHeaders func(*Account) (http.Header, error) + +type codexOAuthIdentityKey struct{} + +type codexOAuthIdentity struct { + headers http.Header + reason string +} + +func withCodexOAuthIdentity(ctx context.Context, account *Account, force bool) (context.Context, error) { + identity := codexOAuthIdentity{reason: "proactive_expiry"} + if force { + identity.reason = "forced" + } + if CodexOAuthIdentityHeaders != nil { + headers, err := CodexOAuthIdentityHeaders(account) + if err != nil { + return nil, err + } + identity.headers = headers.Clone() + } + return context.WithValue(ctx, codexOAuthIdentityKey{}, identity), nil +} + +func applyCodexOAuthIdentity(req *http.Request) { + identity, _ := req.Context().Value(codexOAuthIdentityKey{}).(codexOAuthIdentity) + // OAuth 刷新只使用默认客户端身份,不携带对话、设备证明或账号认证头。 + for _, name := range []string{"User-Agent", "Originator"} { + if value := identity.headers.Get(name); value != "" { + req.Header.Set(name, value) + } + } +} + +func codexOAuthRefreshReason(ctx context.Context) string { + identity, ok := ctx.Value(codexOAuthIdentityKey{}).(codexOAuthIdentity) + if ok { + return identity.reason + } + return "forced" +} diff --git a/auth/codex_refresh.go b/auth/codex_refresh.go index 608cbd64d..8cc3351e5 100644 --- a/auth/codex_refresh.go +++ b/auth/codex_refresh.go @@ -82,6 +82,13 @@ func (s *Store) refreshCodexAccount(ctx context.Context, acc *Account, force boo if lease != nil { ctx = lease.CriticalContext() } + if rt != "" { + identityCtx, err := withCodexOAuthIdentity(ctx, acc, force) + if err != nil { + return err + } + ctx = identityCtx + } var attempt *database.CodexRefreshAttempt if rt != "" && s.db != nil { var err error diff --git a/auth/codex_refresh_test.go b/auth/codex_refresh_test.go index 526386b86..9fcac8a40 100644 --- a/auth/codex_refresh_test.go +++ b/auth/codex_refresh_test.go @@ -51,7 +51,18 @@ func writeCodexRefreshedTokens(w http.ResponseWriter) { func TestCodexRefreshBackgroundPreservesQuotaCooldown(t *testing.T) { var calls atomic.Int32 - store, db, id, _ := codexRefreshFixture(t, func(w http.ResponseWriter, _ *http.Request) { calls.Add(1); writeCodexRefreshedTokens(w) }) + previous := CodexOAuthIdentityHeaders + CodexOAuthIdentityHeaders = func(*Account) (http.Header, error) { + return http.Header{"User-Agent": {"codex2api/test"}, "Originator": {"codex2api"}, "X-Oai-Attestation": {"excluded"}}, nil + } + t.Cleanup(func() { CodexOAuthIdentityHeaders = previous }) + store, db, id, _ := codexRefreshFixture(t, func(w http.ResponseWriter, req *http.Request) { + calls.Add(1) + if "codex2api/test" != req.Header.Get("User-Agent") || "codex2api" != req.Header.Get("Originator") || "" != req.Header.Get("X-Oai-Attestation") { + t.Error("OAuth refresh did not apply only the selected client identity") + } + writeCodexRefreshedTokens(w) + }) account := store.FindByID(id) until := time.Now().Add(7 * 24 * time.Hour).Truncate(time.Second) account.SetCooldownUntil(until, "rate_limited_7d") diff --git a/auth/fast_scheduler.go b/auth/fast_scheduler.go index 1784169ad..6cbe249b4 100644 --- a/auth/fast_scheduler.go +++ b/auth/fast_scheduler.go @@ -19,7 +19,8 @@ type fastSchedulerEntry struct { dbID int64 dispatchScore float64 proven bool - priority int64 // 账号调度优先级(issue #358):全局降序选择,同优先级内再按健康桶调度 + priority int64 // 账号调度优先级(issue #358):全局降序选择,同优先级内再按健康桶调度 + reset5hAt time.Time // immutable sort key until Update or a reset boundary } type fastSchedulerPosition struct { @@ -61,7 +62,8 @@ type FastScheduler struct { // requires rebuilding every API-key sub-pool. retainUnavailable bool // resorts 统计整桶重排次数,用于回归测试锁定"批量写入不再逐条重排"这个性质。 - resorts atomic.Uint64 + resorts atomic.Uint64 + reset5hOrderExpires map[AccountHealthTier]time.Time } // ResortCount 返回累计的整桶重排次数。 @@ -162,6 +164,7 @@ func (s *FastScheduler) SetSchedulerMode(mode string) { // entryLessLocked 是所有调度模式共用的桶内排序谓词。调用方必须持有 s.mu。 // // 各模式的排序键: +// - reset_5h_first:有效 Codex 5h 重置时间升序,未知窗口沿用轮询兜底 // - fill_first:7d 用量降序,流量集中在剩余额度最少的账号(issue #501) // - remaining_quota:7d 用量升序,优先用剩余额度多的账号 // - round_robin 的 healthy 桶:7d 用量升序,把负载摊平到所有可用账号(issue #150) @@ -173,6 +176,11 @@ func (s *FastScheduler) entryLessLocked(tier AccountHealthTier, a, b *fastSchedu if a.priority != b.priority { return a.priority > b.priority } + if s.schedulerMode == SchedulerModeReset5hFirst { + if order := compareReset5hDeadlines(a.reset5hAt, b.reset5hAt); order != 0 { + return order < 0 + } + } switch { case s.schedulerMode == "fill_first": usageA, usageB := a.acc.usagePercentForScheduling(), b.acc.usagePercentForScheduling() @@ -184,7 +192,7 @@ func (s *FastScheduler) entryLessLocked(tier AccountHealthTier, a, b *fastSchedu if usageA != usageB { return usageA < usageB } - case s.schedulerMode == "round_robin" && tier == HealthTierHealthy: + case (s.schedulerMode == "round_robin" || s.schedulerMode == SchedulerModeReset5hFirst) && tier == HealthTierHealthy: usageA, usageB := a.acc.usagePercentForScheduling(), b.acc.usagePercentForScheduling() if usageA != usageB { return usageA < usageB @@ -208,6 +216,9 @@ func (s *FastScheduler) sortBucketLocked(tier AccountHealthTier) { s.generation++ entries := s.buckets[tier] delete(s.unsorted, tier) + if s.schedulerMode == SchedulerModeReset5hFirst { + s.refreshReset5hOrderLocked(tier, time.Now()) + } if len(entries) == 0 { return } @@ -224,6 +235,14 @@ func (s *FastScheduler) sortBucketLocked(tier AccountHealthTier) { // ensureSortedLocked 在取号前把被打乱的桶补排一次。写入侧只打标不排序, // 一批连续变更(例如批量导入、并发 Release)在这里合并成一次排序。 func (s *FastScheduler) ensureSortedLocked() { + if s.schedulerMode == SchedulerModeReset5hFirst { + now := time.Now() + for tier, expiry := range s.reset5hOrderExpires { + if !expiry.IsZero() && !expiry.After(now) { + s.unsorted[tier] = true + } + } + } if len(s.unsorted) == 0 { return } @@ -276,6 +295,7 @@ func (s *FastScheduler) Rebuild(accounts []*Account) { } s.positions = make(map[int64]fastSchedulerPosition, len(accounts)) s.unsorted = map[AccountHealthTier]bool{} + s.reset5hOrderExpires = map[AccountHealthTier]time.Time{} // 批量插入:先全部放入桶中,不逐条排序 now := time.Now() @@ -297,6 +317,7 @@ func (s *FastScheduler) Rebuild(accounts []*Account) { dispatchScore: dispatchScore, proven: proven, priority: acc.schedulerPriority(), + reset5hAt: acc.reset5hSchedulingDeadline(now), }) } @@ -367,14 +388,18 @@ func (s *FastScheduler) updateLocked(acc *Account, now time.Time) { } previousPriority := entries[pos.index].priority + previousReset5h := entries[pos.index].reset5hAt entries[pos.index] = fastSchedulerEntry{ acc: acc, dbID: acc.DBID, dispatchScore: dispatchScore, proven: proven, priority: acc.schedulerPriority(), + reset5hAt: acc.reset5hSchedulingDeadline(now), } - if previousPriority != entries[pos.index].priority { + s.trackReset5hDeadlineLocked(tier, entries[pos.index].reset5hAt) + if previousPriority != entries[pos.index].priority || + (s.schedulerMode == SchedulerModeReset5hFirst && !previousReset5h.Equal(entries[pos.index].reset5hAt)) { s.generation++ } if s.entryOutOfOrderLocked(tier, entries, pos.index) { @@ -404,7 +429,9 @@ func (s *FastScheduler) appendLocked(acc *Account, tier AccountHealthTier, dispa dispatchScore: dispatchScore, proven: proven, priority: acc.schedulerPriority(), + reset5hAt: acc.reset5hSchedulingDeadline(time.Now()), }) + s.trackReset5hDeadlineLocked(tier, entries[len(entries)-1].reset5hAt) s.buckets[tier] = entries s.positions[acc.DBID] = fastSchedulerPosition{tier: tier, index: len(entries) - 1} if len(entries) > 1 && s.entryLessLocked(tier, &entries[len(entries)-1], &entries[len(entries)-2]) { @@ -540,6 +567,22 @@ func (s *FastScheduler) acquireExcludingWithDispatch(apiKeyID int64, exclude map if inspectOnly || s.schedulerMode == "remaining_quota" || s.schedulerMode == "fill_first" { cursor = &zeroCursor } + // Rank known Codex deadlines before the ordinary fallback range. + // New affinity hashing must not skip the earliest deadline. + if s.schedulerMode == SchedulerModeReset5hFirst && policy != DispatchPolicySpark { + knownEnd := segStart + sort.Search(segEnd-segStart, func(i int) bool { + return bucket[segStart+i].reset5hAt.IsZero() + }) + acc, stale := s.scanRangeLocked(tier, segStart, knownEnd, &zeroCursor, nil, apiKeyID, exclude, filter, policy, inspectOnly, &stats) + if acc != nil { + return acc + } + if stale { + changed = true + break priorityLoop + } + segStart = knownEnd + } acc, stale := s.scanRangeLocked(tier, segStart, segEnd, cursor, affinityHash, apiKeyID, exclude, filter, policy, inspectOnly, &stats) if acc != nil { return acc @@ -558,9 +601,10 @@ func (s *FastScheduler) acquireExcludingWithDispatch(apiKeyID int64, exclude map } type fastSchedulerCandidate struct { - acc *Account - occupied int64 - usage float64 + acc *Account + occupied int64 + usage float64 + reset5hAt time.Time } // scanRangeLocked copies a bounded window while holding mu, then releases mu @@ -574,6 +618,8 @@ func (s *FastScheduler) scanRangeLocked(expectedTier AccountHealthTier, rangeSta } start := int(cursor.Add(1)-1) % rangeLen windowSize := fastSchedulerCandidateWindow + resetFirst := s.schedulerMode == SchedulerModeReset5hFirst && policy != DispatchPolicySpark && + !bucket[rangeStart].reset5hAt.IsZero() if affinityHash != nil { start = int(*affinityHash % uint64(rangeLen)) windowSize = 1 // retain deterministic affinity order @@ -612,6 +658,9 @@ func (s *FastScheduler) scanRangeLocked(expectedTier AccountHealthTier, rangeSta continue } candidate := fastSchedulerCandidate{acc: entry.acc, occupied: occupied} + if resetFirst { + candidate.reset5hAt = entry.reset5hAt + } if quotaMode { candidate.usage = entry.acc.usagePercentForScheduling() } @@ -626,6 +675,9 @@ func (s *FastScheduler) scanRangeLocked(expectedTier AccountHealthTier, rangeSta for j := i; j > 0; j-- { a, b := candidates[j], candidates[j-1] less := a.occupied < b.occupied + if resetFirst && !a.reset5hAt.Equal(b.reset5hAt) { + less = compareReset5hDeadlines(a.reset5hAt, b.reset5hAt) < 0 + } if quotaMode && a.usage != b.usage { less = a.usage < b.usage if s.schedulerMode == "fill_first" { @@ -680,6 +732,10 @@ func (s *FastScheduler) acquireCandidatesOutsideLock(candidates []fastSchedulerC stats.restarts++ return nil, true } + if !candidate.reset5hAt.IsZero() && !candidate.reset5hAt.After(time.Now()) { + stats.restarts++ + return nil, true // next locked pass invalidates the expired sort key + } // A slow filter can outlive a cooldown/disable/concurrency change. tier, _, limit, _, available := acc.fastSchedulerSnapshotForPolicy(baseLimit, time.Now(), policy) if !available || limit <= 0 || accountDispatchBlocked(acc) || accountOccupiedRequests(acc) >= limit { diff --git a/auth/reset_5h_priority.go b/auth/reset_5h_priority.go new file mode 100644 index 000000000..a5d2d4eb6 --- /dev/null +++ b/auth/reset_5h_priority.go @@ -0,0 +1,84 @@ +package auth + +import ( + "math" + "time" +) + +// Keep the persisted value within system_settings.scheduler_mode VARCHAR(20). +const SchedulerModeReset5hFirst = "reset_5h_first" + +// reset5hSchedulingDeadline uses the observed Codex window, never a synthetic +// now+5h cycle. Eligibility, permissions and concurrency remain separate gates. +func (a *Account) reset5hSchedulingDeadline(now time.Time) time.Time { + a.mu.RLock() + defer a.mu.RUnlock() + if a.isRelayStyleLocked() || a.creditSkipsUsageWindowLocked() || + !a.UsagePercent5hValid || math.IsNaN(a.UsagePercent5h) || math.IsInf(a.UsagePercent5h, 0) || + a.UsagePercent5h < 0 || a.UsagePercent5h >= 100 || !a.Reset5hAt.After(now) { + return time.Time{} + } + return a.Reset5hAt +} + +// compareReset5hDeadlines orders known future windows before unknown ones. +// Equal/unknown keys defer to the existing score/load ordering. +func compareReset5hDeadlines(a, b time.Time) int { + if a.Equal(b) { + return 0 + } + if a.IsZero() || !b.IsZero() && b.Before(a) { + return 1 + } + return -1 +} + +// refreshReset5hOrderLocked snapshots all keys against one clock instant so +// sorting is transitive even at a reset boundary. Only a changed bucket or the +// next expired deadline needs this O(N) refresh; ordinary selections do not. +func (s *FastScheduler) refreshReset5hOrderLocked(tier AccountHealthTier, now time.Time) { + var next time.Time + for i := range s.buckets[tier] { + entry := &s.buckets[tier][i] + entry.reset5hAt = entry.acc.reset5hSchedulingDeadline(now) + if !entry.reset5hAt.IsZero() && (next.IsZero() || entry.reset5hAt.Before(next)) { + next = entry.reset5hAt + } + } + if s.reset5hOrderExpires == nil { + s.reset5hOrderExpires = make(map[AccountHealthTier]time.Time) + } + s.reset5hOrderExpires[tier] = next +} + +func (s *FastScheduler) trackReset5hDeadlineLocked(tier AccountHealthTier, deadline time.Time) { + if s.schedulerMode != SchedulerModeReset5hFirst || deadline.IsZero() { + return + } + if s.reset5hOrderExpires == nil { + s.reset5hOrderExpires = make(map[AccountHealthTier]time.Time) + } + if next := s.reset5hOrderExpires[tier]; next.IsZero() || deadline.Before(next) { + s.reset5hOrderExpires[tier] = deadline + } +} + +// Sparse API-key schedulers cache ordering independently of the global index. +// Update only already-present accounts; inserting here would bypass membership. +func (s *Store) refreshRoutingReset5hAccount(acc *Account) { + s.routingSchedulersMu.RLock() + var schedulers []*FastScheduler + for _, entry := range s.routingSchedulers { + if !entry.alias { + schedulers = append(schedulers, entry.scheduler) + } + } + s.routingSchedulersMu.RUnlock() + for _, scheduler := range schedulers { + scheduler.mu.Lock() + if _, present := scheduler.positions[acc.ID()]; present { + scheduler.updateLocked(acc, time.Now()) + } + scheduler.mu.Unlock() + } +} diff --git a/auth/reset_5h_priority_test.go b/auth/reset_5h_priority_test.go new file mode 100644 index 000000000..cfb869582 --- /dev/null +++ b/auth/reset_5h_priority_test.go @@ -0,0 +1,66 @@ +package auth + +import ( + "testing" + "time" +) + +func TestFastSchedulerReset5hPriorityAndLoad(t *testing.T) { + for _, equal := range []bool{false, true} { + name := "earlier deadline beats lower load" + if equal { + name = "equal deadlines share concurrent load" + } + t.Run(name, func(t *testing.T) { + first := newFastSchedulerTestAccount(1, HealthTierHealthy, 100, 4) + second := newFastSchedulerTestAccount(2, HealthTierHealthy, 100, 4) + first.Reset5hAt = time.Now().Add(time.Hour) + second.Reset5hAt = first.Reset5hAt.Add(time.Hour) + if equal { + second.Reset5hAt = first.Reset5hAt + } + for _, acc := range []*Account{first, second} { + acc.PlanType = "plus" + acc.UsagePercent5h, acc.UsagePercent5hValid = 20, true + } + scheduler := NewFastScheduler(4, SchedulerModeReset5hFirst) + scheduler.Rebuild([]*Account{second, first}) + a := scheduler.Acquire() + if a == nil || a.DBID != 1 { + t.Fatalf("first selection = %v, want account 1", a) + } + defer scheduler.Release(a) + b := scheduler.Acquire() + want := int64(1) + if equal { + want = 2 + } + if b == nil || b.DBID != want { + t.Fatalf("second selection = %v, want account %d", b, want) + } + defer scheduler.Release(b) + }) + } +} + +func TestFastSchedulerReset5hFiltersAndFallback(t *testing.T) { + accounts := make([]*Account, 10) + for i := range accounts { + acc := newFastSchedulerTestAccount(int64(i+1), HealthTierHealthy, 100, 1) + acc.PlanType = "plus" + acc.UsagePercent5h, acc.UsagePercent5hValid = 20, true + acc.Reset5hAt = time.Now().Add(time.Hour) + accounts[i] = acc + } + // Unknown windows remain a fallback after all known windows are rejected. + accounts[9].UsagePercent5hValid = false + for _, want := range []int64{9, 10} { + scheduler := NewFastScheduler(1, SchedulerModeReset5hFirst) + scheduler.Rebuild(accounts) + got := scheduler.AcquireExcludingWithFilter(1, nil, func(acc *Account) bool { return acc.ID() == want }) + if got == nil || got.ID() != want { + t.Fatalf("filtered selection = %v, want %d", got, want) + } + scheduler.Release(got) + } +} diff --git a/auth/store.go b/auth/store.go index 9a9f89bf6..b218a8539 100644 --- a/auth/store.go +++ b/auth/store.go @@ -2105,6 +2105,25 @@ func (a *Account) FreshDispatchUsageLimited() bool { return a.Status == StatusCooldown && now.Before(a.CooldownUtil) && isUsageLimitCooldownReason(a.CooldownReason) } +// QuotaAutoPaused reports whether this otherwise dispatchable account is +// fenced only by its configured 5h/7d auto-pause threshold. The decision uses +// valid usage windows, thresholds and reset times alone. Manually disabled or +// paused accounts, accounts without credentials, errors, bans and plain +// concurrency saturation are not quota pauses. Unlike a local 100% WHAM +// observation, the threshold fence has no continuation exception: every +// scheduler path rejects the account until its window resets. +func (a *Account) QuotaAutoPaused() bool { + if a == nil || atomic.LoadInt32(&a.Disabled) != 0 || atomic.LoadInt32(&a.DispatchPaused) != 0 { + return false + } + a.mu.RLock() + defer a.mu.RUnlock() + if a.Status == StatusError || a.healthTierLocked() == HealthTierBanned || !a.hasDispatchCredentialLocked() { + return false + } + return a.quotaAutoPausedLocked(time.Now()) +} + func (a *Account) recomputeEffectiveIgnoreUsageLimitStatus(global bool) { if a.IgnoreUsageLimitStatusOverride != nil { a.ignoreUsageLimitStatus = *a.IgnoreUsageLimitStatusOverride @@ -4093,7 +4112,7 @@ type Store struct { codexModelMapping atomic.Value // Codex 模型映射 JSON 字符串 payloadRules atomic.Value // Payload 请求体重写规则 JSON 字符串 reasoningEffortModels atomic.Value // 带思考强度的模型别名 JSON 数组 - schedulerMode atomic.Value // string: "round_robin" / "remaining_quota" / "fill_first" + schedulerMode atomic.Value // string: "round_robin" / "remaining_quota" / "fill_first" / "reset_5h_first" affinityMode atomic.Value // string: "bounded" / "off" / "strict" affinitySpreadEnabled atomic.Bool // 新亲和键按 HRW 哈希散列选号(issue #484) claudeFingerprintDefault atomic.Value // string: Claude 指纹模式全局默认(preserve/force;空=preserve) @@ -4997,6 +5016,9 @@ func (s *Store) fastSchedulerUpdate(acc *Account) { if scheduler != nil { scheduler.Update(acc) } + if s.GetSchedulerMode() == SchedulerModeReset5hFirst { + s.refreshRoutingReset5hAccount(acc) + } s.notifySchedulerAccountAvailability(acc, true) } @@ -7277,6 +7299,7 @@ func (s *Store) NextExcludingWithDispatch(apiKeyID int64, exclude map[int64]bool bestSchedulerPriority := minSchedulerPriority - 1 bestPriority := -1 bestDispatchScore := -math.MaxFloat64 + var bestReset5h time.Time var bestLoad int64 = math.MaxInt64 var bestLimit int64 maxConcurrency := s.maxConcurrency.Load() @@ -7306,14 +7329,21 @@ func (s *Store) NextExcludingWithDispatch(apiKeyID int64, exclude map[int64]bool // 账号调度优先级严格先于健康档位与调度分(issue #358) schedulerPriority := acc.schedulerPriority() priority := tierPriority(tier) + var reset5h time.Time + resetOrder := 0 + if s.GetSchedulerMode() == SchedulerModeReset5hFirst && policy != DispatchPolicySpark { + reset5h = acc.reset5hSchedulingDeadline(time.Now()) + resetOrder = compareReset5hDeadlines(reset5h, bestReset5h) + } if schedulerPriority > bestSchedulerPriority || (schedulerPriority == bestSchedulerPriority && (priority > bestPriority || - (priority == bestPriority && (dispatchScore > bestDispatchScore || + (priority == bestPriority && (resetOrder < 0 || resetOrder == 0 && (dispatchScore > bestDispatchScore || (dispatchScore == bestDispatchScore && load < bestLoad) || - (dispatchScore == bestDispatchScore && load == bestLoad && fastRandN(2) == 0))))) { + (dispatchScore == bestDispatchScore && load == bestLoad && fastRandN(2) == 0)))))) { bestSchedulerPriority = schedulerPriority bestPriority = priority bestDispatchScore = dispatchScore + bestReset5h = reset5h bestLoad = load bestLimit = limit best = acc @@ -7478,6 +7508,7 @@ func (s *Store) nextExcludingWithFilterLazy(apiKeyID int64, exclude map[int64]bo bestSchedulerPriority := minSchedulerPriority - 1 bestPriority := -1 bestDispatchScore := -math.MaxFloat64 + var bestReset5h time.Time var bestLoad int64 = math.MaxInt64 maxConcurrency := s.maxConcurrency.Load() @@ -7514,14 +7545,21 @@ func (s *Store) nextExcludingWithFilterLazy(apiKeyID int64, exclude map[int64]bo // 账号调度优先级严格先于健康档位与调度分(issue #358) schedulerPriority := acc.schedulerPriority() priority := tierPriority(tier) + var reset5h time.Time + resetOrder := 0 + if s.GetSchedulerMode() == SchedulerModeReset5hFirst && policy != DispatchPolicySpark { + reset5h = acc.reset5hSchedulingDeadline(time.Now()) + resetOrder = compareReset5hDeadlines(reset5h, bestReset5h) + } if schedulerPriority > bestSchedulerPriority || (schedulerPriority == bestSchedulerPriority && (priority > bestPriority || - (priority == bestPriority && (dispatchScore > bestDispatchScore || + (priority == bestPriority && (resetOrder < 0 || resetOrder == 0 && (dispatchScore > bestDispatchScore || (dispatchScore == bestDispatchScore && load < bestLoad) || - (dispatchScore == bestDispatchScore && load == bestLoad && fastRandN(2) == 0))))) { + (dispatchScore == bestDispatchScore && load == bestLoad && fastRandN(2) == 0)))))) { bestSchedulerPriority = schedulerPriority bestPriority = priority bestDispatchScore = dispatchScore + bestReset5h = reset5h bestLoad = load best = acc } @@ -7727,6 +7765,9 @@ func (s *Store) nextForSessionWithFilter(key string, apiKeyID int64, exclude map return nil, "", SessionAffinityGuard{} } policy := opts.Policy + // 模型级配额门禁:整号门禁只在所有配额族都耗尽时才移出账号,这里按本次请求模型所在 + // 族补一道过滤,绑定复用、续链、借用与 Fresh 选号共用同一个包装后的 filter。 + filter = withAntigravityModelQuotaFilter(filter, opts.Model) key = strings.TrimSpace(key) if key == "" { return s.NextExcludingWithDispatch(apiKeyID, exclude, filter, policy), "", SessionAffinityGuard{} @@ -7871,7 +7912,10 @@ func (s *Store) nextAccountForFreshAffinityWithDispatch(key string, apiKeyID int if acc, handled := s.selectAntigravityBalancedAccount(key, apiKeyID, exclude, filter, opts); handled { return acc } - if !s.GetSessionAffinitySpread() || strings.TrimSpace(key) == "" { + // Deadline priority governs fresh selection; an existing binding is handled + // before reaching this method and remains intact. + if !s.GetSessionAffinitySpread() || strings.TrimSpace(key) == "" || + (s.GetSchedulerMode() == SchedulerModeReset5hFirst && policy != DispatchPolicySpark) { return s.NextExcludingWithDispatch(apiKeyID, exclude, filter, policy) } filter = s.withUsableEgressFilter(filter) @@ -8315,7 +8359,7 @@ func (s *Store) UsageLimitedCandidateSummary(apiKeyID int64, exclude map[int64]b continue } cachedCooldown := s.accountHasCachedCooldown(acc) - usageLimited := acc.FreshDispatchUsageLimited() + usageLimited := acc.FreshDispatchUsageLimited() || acc.QuotaAutoPaused() if policy == DispatchPolicySpark { usageLimited = acc.SparkDispatchUsageLimited() } @@ -8497,8 +8541,11 @@ func (s *Store) waitForSessionAvailableWithFilter(ctx context.Context, key strin if timeout <= 0 || ctx.Err() != nil { return nil, "", SessionAffinityGuard{}, ctx.Err() } + // 候选探测与选号同口径地看模型级配额门禁;选号本身由 nextForSessionWithFilter 自行 + // 包装,这里传给它的仍是原始 filter,避免同一门禁叠两层。 + candidateFilter := withAntigravityModelQuotaFilter(filter, opts.Model) hasCandidate := func() bool { - return s.HasDispatchCandidate(key, apiKeyID, exclude, filter, preserveBinding, policy) + return s.HasDispatchCandidate(key, apiKeyID, exclude, candidateFilter, preserveBinding, policy) } // Indexed/shadow also wait on an empty snapshot: another replica may add // an account and notify this process through the durable outbox. @@ -9110,7 +9157,7 @@ func (s *Store) GetSchedulerMode() string { // SetSchedulerMode 设置调度模式并传播到 FastScheduler func (s *Store) SetSchedulerMode(mode string) { switch mode { - case "round_robin", "remaining_quota", "fill_first": + case "round_robin", "remaining_quota", "fill_first", SchedulerModeReset5hFirst: // ok default: mode = "round_robin" diff --git a/auth/token.go b/auth/token.go index 96165d776..52d595aa6 100644 --- a/auth/token.go +++ b/auth/token.go @@ -1,6 +1,7 @@ package auth import ( + "bytes" "context" "crypto/sha256" "encoding/base64" @@ -11,7 +12,6 @@ import ( "log" "net" "net/http" - "net/url" "strings" "sync" "sync/atomic" @@ -20,11 +20,10 @@ import ( // OpenAI OAuth 常量(与 CLIProxyAPI / sub2api 一致) const ( - TokenURL = "https://auth.openai.com/oauth/token" - SessionURL = "https://chatgpt.com/api/auth/session" - ClientID = "app_EMoamEEZ73f0CkXaXp7hrann" - RefreshScopes = "openid profile email" - MaxRetries = 3 + TokenURL = "https://auth.openai.com/oauth/token" + SessionURL = "https://chatgpt.com/api/auth/session" + ClientID = "app_EMoamEEZ73f0CkXaXp7hrann" + MaxRetries = 3 ) // ResinRequestDecorator 由外部(main.go)注入,用于在 Resin 启用时改写请求 URL 和添加 Header。 @@ -53,11 +52,14 @@ type AccountInfo struct { // RefreshAccessToken 用 RT 换取 AT // resinAccountID 可选,Resin 启用时传入账号标识用于粘性代理 func RefreshAccessToken(ctx context.Context, refreshToken string, proxyURL string, resinAccountID ...string) (*TokenData, *AccountInfo, error) { - data := url.Values{ - "grant_type": {"refresh_token"}, - "client_id": {ClientID}, - "refresh_token": {refreshToken}, - "scope": {RefreshScopes}, + data, err := json.Marshal(map[string]string{ + "grant_type": "refresh_token", + "client_id": ClientID, + "refresh_token": refreshToken, + "refresh_reason": codexOAuthRefreshReason(ctx), + }) + if err != nil { + return nil, nil, fmt.Errorf("编码刷新请求失败: %w", err) } // Resin 反代模式:改写 URL @@ -70,12 +72,13 @@ func RefreshAccessToken(ctx context.Context, refreshToken string, proxyURL strin targetURL = ResinRequestDecorator(TokenURL, accountID) } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, strings.NewReader(data.Encode())) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(data)) if err != nil { return nil, nil, fmt.Errorf("创建请求失败: %w", err) } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") + applyCodexOAuthIdentity(req) // Resin 反代:注入账号身份头 if ResinRequestDecorator != nil && accountID != "" { @@ -87,7 +90,7 @@ func RefreshAccessToken(ctx context.Context, refreshToken string, proxyURL strin if ResinRequestDecorator != nil && accountID != "" { client = &http.Client{Timeout: 30 * time.Second} } else { - client = buildHTTPClient(proxyURL) + client = buildUTLSHTTPClient(proxyURL) } resp, err := client.Do(req) if err != nil { diff --git a/auth/token_parse_test.go b/auth/token_parse_test.go index 58e4efaae..2cf6c2ef5 100644 --- a/auth/token_parse_test.go +++ b/auth/token_parse_test.go @@ -7,6 +7,7 @@ import ( "errors" "net/http" "net/http/httptest" + "reflect" "strings" "testing" "time" @@ -81,6 +82,17 @@ func TestParseIDTokenEmptyReturnsEmptyInfo(t *testing.T) { func TestRefreshAccessTokenRejectsEmptyAccessToken(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if "application/json" != r.Header.Get("Content-Type") { + t.Errorf("Content-Type: want application/json, got %q", r.Header.Get("Content-Type")) + } + var payload map[string]string + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Errorf("decode refresh request: %v", err) + } + want := map[string]string{"grant_type": "refresh_token", "client_id": ClientID, "refresh_token": "rt-old", "refresh_reason": "forced"} + if !reflect.DeepEqual(want, payload) { + t.Errorf("refresh payload: want %v, got %v", want, payload) + } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"refresh_token":"rt-new","expires_in":3600}`)) })) diff --git a/auth/usage_cooldown_test.go b/auth/usage_cooldown_test.go index e51fb8f6e..3228f76c1 100644 --- a/auth/usage_cooldown_test.go +++ b/auth/usage_cooldown_test.go @@ -64,11 +64,15 @@ func TestUsageCooldownNeverShortensActiveCooldown(t *testing.T) { {"equal usage", time.Hour, time.Hour, premium5hCooldownReason, false}, {"shorter 7d usage", 6 * time.Hour, time.Hour, "rate_limited_7d", false}, {"equal 7d usage", time.Hour, time.Hour, "rate_limited_7d", false}, + {"shorter legacy rate_limited", 6 * time.Hour, time.Hour, "rate_limited", false}, + {"shorter legacy usage_limited", 6 * time.Hour, time.Hour, "usage_limited", false}, + {"equal legacy usage_limit", time.Hour, time.Hour, "usage_limit", false}, {"extend usage", time.Hour, 2 * time.Hour, premium5hCooldownReason, true}, {"new cooldown", 0, 2 * time.Hour, "", true}, {"expired cooldown", -time.Hour, 2 * time.Hour, ResponsesRateLimitedCooldownReason, true}, } { t.Run(tc.name, func(t *testing.T) { + legacy7d := tc.reason == "rate_limited" || tc.reason == "rate_limited_7d" || tc.reason == "usage_limited" || tc.reason == "usage_limit" now := time.Now().UTC().Truncate(time.Second) resetAt := now.Add(tc.reset) id, err := db.InsertAccountWithCredentials(ctx, "usage-cooldown", map[string]interface{}{"access_token": "test-token", "plan_type": "plus"}, "") @@ -84,7 +88,7 @@ func TestUsageCooldownNeverShortensActiveCooldown(t *testing.T) { wantWrites := 1 if tc.prior != 0 { acc.Status, acc.CooldownUtil, acc.CooldownReason = StatusCooldown, now.Add(tc.prior), tc.reason - if source == "responses" && tc.reason == "rate_limited_7d" { + if source == "responses" && legacy7d { acc.Reset7dAt = acc.CooldownUtil } if err := db.SetCooldown(ctx, id, tc.reason, acc.CooldownUtil); err != nil { @@ -95,7 +99,7 @@ func TestUsageCooldownNeverShortensActiveCooldown(t *testing.T) { store.setCachedAccountCooldown(id, tc.reason, acc.CooldownUtil) if !tc.wantChange { wantUntil, wantReason = acc.CooldownUtil, tc.reason - if source == "responses" && (tc.reason == premium5hCooldownReason || tc.reason == "rate_limited_7d") { + if source == "responses" && (tc.reason == premium5hCooldownReason || legacy7d) { wantReason = ResponsesRateLimitedCooldownReason } else { wantWrites-- diff --git a/auth/utls_client.go b/auth/utls_client.go index aef048b74..d55ef6820 100644 --- a/auth/utls_client.go +++ b/auth/utls_client.go @@ -370,3 +370,8 @@ func buildUTLSHTTPClient(proxyURL string) *http.Client { } return client } + +// BuildUTLSHTTPClient builds a Chrome TLS fingerprint HTTP client (exported for admin OAuth flow). +func BuildUTLSHTTPClient(proxyURL string) *http.Client { + return buildUTLSHTTPClient(proxyURL) +} diff --git a/cache/durable_response_context.go b/cache/durable_response_context.go new file mode 100644 index 000000000..8ba2c2404 --- /dev/null +++ b/cache/durable_response_context.go @@ -0,0 +1,797 @@ +package cache + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/json" + "errors" + "fmt" + "log" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" + + _ "modernc.org/sqlite" +) + +// DurableResponseContextStore persists previous_response_id contexts on local +// disk. A Memory-driver deployment otherwise keeps them only in the bounded L1 +// map, so an eviction, an oversize entry or a process restart makes every +// dependent HTTP continuation fail. Item bodies are content-addressed and +// reference-counted: the overlapping prefixes of one conversation chain are +// stored once, and a context row only lists the hashes of its items. +// +// The store is a cache, not a system of record. It never shares the main +// database file, and losing it only reverts to the previous L1-only behavior. +type DurableResponseContextStore struct { + db *sql.DB + path string + ttl time.Duration + maxBytes int64 + + writeMu sync.Mutex + + touchMu sync.Mutex + touched map[string]int64 + activeReads map[string]int + + stop chan struct{} + done chan struct{} + closeOnce sync.Once +} + +// DurableResponseContextOptions bounds the on-disk store. TTL is a sliding +// minimum retention: a write never expires sooner than its caller's TTL. +type DurableResponseContextOptions struct { + TTL time.Duration + MaxBytes int64 + SweepInterval time.Duration +} + +// DurableResponseContextStats is a point-in-time summary for logs and ops. +type DurableResponseContextStats struct { + Contexts int64 + Items int64 + ItemBytes int64 +} + +const ( + defaultDurableResponseContextTTL = 48 * time.Hour + defaultDurableResponseContextMaxBytes = int64(2 << 30) + defaultDurableResponseContextSweep = time.Minute + durableResponseContextHashSize = sha256.Size + durableResponseContextQueryChunk = 400 + durableResponseContextDeleteBatch = 64 + // Callers bound backend writes to a couple of seconds for Redis. A local + // disk transaction of a large first context may need longer; abandoning it + // would only mark the context as a backend error. + durableResponseContextWriteTimeout = 30 * time.Second + durableResponseContextBusyMillis = 15000 +) + +var errDurableResponseContextClosed = errors.New("durable response context store is closed") + +// OpenDurableResponseContextStore opens or creates the store at path. +func OpenDurableResponseContextStore(path string, opts DurableResponseContextOptions) (*DurableResponseContextStore, error) { + path = strings.TrimSpace(path) + if path == "" { + return nil, errors.New("durable response context store path is empty") + } + if opts.TTL <= 0 { + opts.TTL = defaultDurableResponseContextTTL + } + if opts.MaxBytes <= 0 { + opts.MaxBytes = defaultDurableResponseContextMaxBytes + } + if opts.SweepInterval <= 0 { + opts.SweepInterval = defaultDurableResponseContextSweep + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, fmt.Errorf("create durable response context directory: %w", err) + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("create durable response context store: %w", err) + } + if err := file.Chmod(0o600); err != nil { + _ = file.Close() + return nil, fmt.Errorf("restrict durable response context store permissions: %w", err) + } + if err := file.Close(); err != nil { + return nil, fmt.Errorf("close durable response context store file: %w", err) + } + q := url.Values{} + q.Add("_pragma", fmt.Sprintf("busy_timeout(%d)", durableResponseContextBusyMillis)) + // auto_vacuum only takes effect before the first table is created. + q.Add("_pragma", "auto_vacuum(INCREMENTAL)") + q.Add("_pragma", "journal_mode(WAL)") + q.Add("_pragma", "synchronous(NORMAL)") + db, err := sql.Open("sqlite", "file:"+path+"?"+q.Encode()) + if err != nil { + return nil, fmt.Errorf("open durable response context store: %w", err) + } + db.SetMaxOpenConns(4) + db.SetMaxIdleConns(4) + db.SetConnMaxLifetime(0) + s := &DurableResponseContextStore{ + db: db, + path: path, + ttl: opts.TTL, + maxBytes: opts.MaxBytes, + touched: make(map[string]int64), + activeReads: make(map[string]int), + stop: make(chan struct{}), + done: make(chan struct{}), + } + if err := s.migrate(); err != nil { + _ = db.Close() + return nil, err + } + go s.sweepLoop(opts.SweepInterval) + return s, nil +} + +func (s *DurableResponseContextStore) migrate() error { + statements := []string{ + `CREATE TABLE IF NOT EXISTS response_context_items ( + id INTEGER PRIMARY KEY, + hash BLOB NOT NULL UNIQUE, + body BLOB NOT NULL, + bytes INTEGER NOT NULL, + refs INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS response_contexts ( + key TEXT PRIMARY KEY, + hashes BLOB NOT NULL, + logical_bytes INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + accessed_at INTEGER NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS response_contexts_expires_at ON response_contexts(expires_at)`, + `CREATE INDEX IF NOT EXISTS response_contexts_accessed_at ON response_contexts(accessed_at)`, + } + for _, statement := range statements { + if _, err := s.db.Exec(statement); err != nil { + return fmt.Errorf("migrate durable response context store: %w", err) + } + } + return nil +} + +// Path reports the backing file. +func (s *DurableResponseContextStore) Path() string { return s.path } + +// TTL reports the minimum retention of a written context. +func (s *DurableResponseContextStore) TTL() time.Duration { return s.ttl } + +// MaxBytes reports the retained item-body budget. +func (s *DurableResponseContextStore) MaxBytes() int64 { return s.maxBytes } + +// Close stops the sweeper, flushes recorded reads and closes the database. +func (s *DurableResponseContextStore) Close() error { + if s == nil { + return nil + } + var err error + s.closeOnce.Do(func() { + close(s.stop) + <-s.done + s.flushTouches() + err = s.db.Close() + }) + return err +} + +func (s *DurableResponseContextStore) closed() bool { + select { + case <-s.stop: + return true + default: + return false + } +} + +func durableResponseContextHash(item json.RawMessage) [durableResponseContextHashSize]byte { + return sha256.Sum256(item) +} + +// SetResponseContext replaces the context stored under key. Items keep their +// exact bytes and order; the caller has already normalized them. +func (s *DurableResponseContextStore) SetResponseContext(ctx context.Context, key string, items []json.RawMessage, ttl time.Duration) error { + key = strings.TrimSpace(key) + if key == "" { + return nil + } + if s.closed() { + return errDurableResponseContextClosed + } + var logical int64 + hashes := make([]byte, 0, len(items)*durableResponseContextHashSize) + for _, item := range items { + logical += int64(len(item)) + hash := durableResponseContextHash(item) + hashes = append(hashes, hash[:]...) + } + if len(items) == 0 || logical > s.maxBytes { + // A context larger than the whole store cannot be retained. Remove any + // older value so a stale parent is never served for this key. + return s.deleteKey(ctx, key) + } + if ttl < s.ttl { + ttl = s.ttl + } + now := time.Now() + expiresAt := now.Add(ttl).UnixMilli() + + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), durableResponseContextWriteTimeout) + defer cancel() + s.writeMu.Lock() + defer s.writeMu.Unlock() + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + var previous []byte + switch err := tx.QueryRowContext(ctx, `SELECT hashes FROM response_contexts WHERE key = ?`, key).Scan(&previous); { + case errors.Is(err, sql.ErrNoRows): + previous = nil + case err != nil: + return err + } + // Reference the new items before releasing the old ones, so items shared + // by both versions never reach zero inside the transaction. + increment, err := tx.PrepareContext(ctx, `UPDATE response_context_items SET refs = refs + 1 WHERE hash = ?`) + if err != nil { + return err + } + defer increment.Close() + insert, err := tx.PrepareContext(ctx, `INSERT INTO response_context_items (hash, body, bytes, refs) VALUES (?, ?, ?, 1)`) + if err != nil { + return err + } + defer insert.Close() + for index, item := range items { + hash := hashes[index*durableResponseContextHashSize : (index+1)*durableResponseContextHashSize] + result, err := increment.ExecContext(ctx, hash) + if err != nil { + return err + } + if affected, err := result.RowsAffected(); err != nil { + return err + } else if affected > 0 { + continue + } + if _, err := insert.ExecContext(ctx, hash, []byte(item), len(item)); err != nil { + return err + } + } + if _, err := tx.ExecContext(ctx, `INSERT INTO response_contexts (key, hashes, logical_bytes, expires_at, accessed_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(key) DO UPDATE SET hashes = excluded.hashes, logical_bytes = excluded.logical_bytes, + expires_at = excluded.expires_at, accessed_at = excluded.accessed_at`, + key, hashes, logical, expiresAt, now.UnixMilli()); err != nil { + return err + } + if err := releaseDurableResponseContextHashes(ctx, tx, previous); err != nil { + return err + } + return tx.Commit() +} + +// SetResponseContextReadOnly implements the optional no-copy writer used by +// the proxy. The store never mutates the bodies it receives. +func (s *DurableResponseContextStore) SetResponseContextReadOnly(ctx context.Context, key string, items []json.RawMessage, ttl time.Duration) error { + return s.SetResponseContext(ctx, key, items, ttl) +} + +// GetResponseContext returns nil for a missing, expired or corrupt context. +func (s *DurableResponseContextStore) GetResponseContext(ctx context.Context, key string) ([]json.RawMessage, error) { + result, err := s.GetResponseContextBounded(ctx, key, 0) + if err != nil || result.Status != ResponseContextReadFound { + return nil, err + } + return result.Items, nil +} + +// GetResponseContextBounded reads one context from a single snapshot. A +// positive maxWireBytes rejects a context before its bodies are loaded. +func (s *DurableResponseContextStore) GetResponseContextBounded(ctx context.Context, key string, maxWireBytes int64) (ResponseContextReadResult, error) { + key = strings.TrimSpace(key) + if key == "" { + return ResponseContextReadResult{Status: ResponseContextReadMiss}, nil + } + if s.closed() { + return ResponseContextReadResult{}, errDurableResponseContextClosed + } + s.beginRead(key) + defer s.endRead(key) + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return ResponseContextReadResult{}, err + } + defer func() { _ = tx.Rollback() }() + + var hashes []byte + var logical, expiresAt int64 + switch err := tx.QueryRowContext(ctx, `SELECT hashes, logical_bytes, expires_at FROM response_contexts WHERE key = ?`, key).Scan(&hashes, &logical, &expiresAt); { + case errors.Is(err, sql.ErrNoRows): + return ResponseContextReadResult{Status: ResponseContextReadMiss}, nil + case err != nil: + return ResponseContextReadResult{}, err + } + if time.Now().UnixMilli() >= expiresAt { + return ResponseContextReadResult{Status: ResponseContextReadMiss}, nil + } + if maxWireBytes > 0 && logical > maxWireBytes { + return ResponseContextReadResult{Status: ResponseContextReadTooLarge}, nil + } + if len(hashes) == 0 || len(hashes)%durableResponseContextHashSize != 0 { + return ResponseContextReadResult{Status: ResponseContextReadCorrupt}, nil + } + bodies, err := loadDurableResponseContextBodies(ctx, tx, hashes) + if err != nil { + return ResponseContextReadResult{}, err + } + count := len(hashes) / durableResponseContextHashSize + items := make([]json.RawMessage, count) + var total int64 + for index := 0; index < count; index++ { + var hash [durableResponseContextHashSize]byte + copy(hash[:], hashes[index*durableResponseContextHashSize:]) + body, ok := bodies[hash] + if !ok { + return ResponseContextReadResult{Status: ResponseContextReadCorrupt}, nil + } + items[index] = body + total += int64(len(body)) + } + if total != logical { + return ResponseContextReadResult{Status: ResponseContextReadCorrupt}, nil + } + s.recordTouch(key) + return ResponseContextReadResult{Status: ResponseContextReadFound, Items: items}, nil +} + +func loadDurableResponseContextBodies(ctx context.Context, tx *sql.Tx, hashes []byte) (map[[durableResponseContextHashSize]byte]json.RawMessage, error) { + unique := make(map[[durableResponseContextHashSize]byte]struct{}, len(hashes)/durableResponseContextHashSize) + ordered := make([][durableResponseContextHashSize]byte, 0, len(hashes)/durableResponseContextHashSize) + for offset := 0; offset < len(hashes); offset += durableResponseContextHashSize { + var hash [durableResponseContextHashSize]byte + copy(hash[:], hashes[offset:]) + if _, seen := unique[hash]; !seen { + unique[hash] = struct{}{} + ordered = append(ordered, hash) + } + } + bodies := make(map[[durableResponseContextHashSize]byte]json.RawMessage, len(ordered)) + for start := 0; start < len(ordered); start += durableResponseContextQueryChunk { + end := start + durableResponseContextQueryChunk + if end > len(ordered) { + end = len(ordered) + } + args := make([]any, 0, end-start) + for _, hash := range ordered[start:end] { + args = append(args, hash[:]) + } + rows, err := tx.QueryContext(ctx, `SELECT hash, body FROM response_context_items WHERE hash IN (?`+strings.Repeat(",?", len(args)-1)+`)`, args...) + if err != nil { + return nil, err + } + for rows.Next() { + var hash, body []byte + if err := rows.Scan(&hash, &body); err != nil { + _ = rows.Close() + return nil, err + } + if len(hash) != durableResponseContextHashSize { + continue + } + var key [durableResponseContextHashSize]byte + copy(key[:], hash) + bodies[key] = json.RawMessage(body) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + } + return bodies, nil +} + +func releaseDurableResponseContextHashes(ctx context.Context, tx *sql.Tx, hashes []byte) error { + if len(hashes) == 0 { + return nil + } + decrement, err := tx.PrepareContext(ctx, `UPDATE response_context_items SET refs = refs - 1 WHERE hash = ?`) + if err != nil { + return err + } + defer decrement.Close() + remove, err := tx.PrepareContext(ctx, `DELETE FROM response_context_items WHERE hash = ? AND refs <= 0`) + if err != nil { + return err + } + defer remove.Close() + for offset := 0; offset+durableResponseContextHashSize <= len(hashes); offset += durableResponseContextHashSize { + if _, err := decrement.ExecContext(ctx, hashes[offset:offset+durableResponseContextHashSize]); err != nil { + return err + } + } + for offset := 0; offset+durableResponseContextHashSize <= len(hashes); offset += durableResponseContextHashSize { + if _, err := remove.ExecContext(ctx, hashes[offset:offset+durableResponseContextHashSize]); err != nil { + return err + } + } + return nil +} + +func (s *DurableResponseContextStore) deleteKey(ctx context.Context, key string) error { + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), durableResponseContextWriteTimeout) + defer cancel() + s.writeMu.Lock() + defer s.writeMu.Unlock() + return s.deleteKeysLocked(ctx, []string{key}) +} + +func (s *DurableResponseContextStore) deleteKeysLocked(ctx context.Context, keys []string) error { + if len(keys) == 0 { + return nil + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + for _, key := range keys { + var hashes []byte + switch err := tx.QueryRowContext(ctx, `SELECT hashes FROM response_contexts WHERE key = ?`, key).Scan(&hashes); { + case errors.Is(err, sql.ErrNoRows): + continue + case err != nil: + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM response_contexts WHERE key = ?`, key); err != nil { + return err + } + if err := releaseDurableResponseContextHashes(ctx, tx, hashes); err != nil { + return err + } + } + return tx.Commit() +} + +// recordTouch defers the access-time write of a read to the sweeper. A read +// path must not wait on the single writer lock. +func (s *DurableResponseContextStore) recordTouch(key string) { + s.touchMu.Lock() + s.touched[key] = time.Now().UnixMilli() + s.touchMu.Unlock() +} + +func (s *DurableResponseContextStore) beginRead(key string) { + s.touchMu.Lock() + s.activeReads[key]++ + s.touchMu.Unlock() +} + +func (s *DurableResponseContextStore) endRead(key string) { + s.touchMu.Lock() + s.activeReads[key]-- + if s.activeReads[key] == 0 { + delete(s.activeReads, key) + } + s.touchMu.Unlock() +} + +// Called under writeMu, so drained touches cannot be hidden from another +// maintenance transaction. The read path only holds touchMu for map updates. +func (s *DurableResponseContextStore) takeTouches(keys []string) (map[string]int64, map[string]bool) { + s.touchMu.Lock() + defer s.touchMu.Unlock() + touched := s.touched + s.touched = make(map[string]int64) + reading := make(map[string]bool) + for _, key := range keys { + if s.activeReads[key] > 0 { + reading[key] = true + } + } + return touched, reading +} + +func (s *DurableResponseContextStore) restoreTouches(touched map[string]int64) { + s.touchMu.Lock() + defer s.touchMu.Unlock() + for key, at := range touched { + if at > s.touched[key] { + s.touched[key] = at + } + } +} + +func (s *DurableResponseContextStore) applyTouches(ctx context.Context, tx *sql.Tx, touched map[string]int64) error { + ttl := s.ttl.Milliseconds() + for key, at := range touched { + // A read keeps an active conversation alive for another full TTL. + if _, err := tx.ExecContext(ctx, `UPDATE response_contexts SET accessed_at = MAX(accessed_at, ?), + expires_at = MAX(expires_at, ?) WHERE key = ?`, at, at+ttl, key); err != nil { + return err + } + } + return nil +} + +func (s *DurableResponseContextStore) flushTouches() { + ctx, cancel := context.WithTimeout(context.Background(), durableResponseContextWriteTimeout) + defer cancel() + s.writeMu.Lock() + defer s.writeMu.Unlock() + _ = s.flushTouchesLocked(ctx) +} + +func (s *DurableResponseContextStore) flushTouchesLocked(ctx context.Context) (err error) { + touched, _ := s.takeTouches(nil) + if len(touched) == 0 { + return nil + } + defer func() { + if err != nil { + s.restoreTouches(touched) + } + }() + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if err := s.applyTouches(ctx, tx, touched); err != nil { + return err + } + return tx.Commit() +} + +func (s *DurableResponseContextStore) sweepLoop(interval time.Duration) { + defer close(s.done) + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-s.stop: + return + case <-ticker.C: + s.flushTouches() + if expired, evicted, err := s.Sweep(time.Now()); err != nil { + log.Printf("持久化 response context 清理失败: %v", err) + } else if expired > 0 || evicted > 0 { + log.Printf("持久化 response context 清理: expired=%d evicted=%d", expired, evicted) + } + } + } +} + +// Sweep removes expired contexts, then the least recently used ones until +// retained item bodies fit in 90% of MaxBytes, and returns freed pages. +func (s *DurableResponseContextStore) Sweep(now time.Time) (expired, evicted int, err error) { + ctx, cancel := context.WithTimeout(context.Background(), 2*durableResponseContextWriteTimeout) + defer cancel() + for { + keys, err := s.selectKeys(ctx, `SELECT key FROM response_contexts WHERE expires_at <= ? LIMIT ?`, now.UnixMilli(), durableResponseContextDeleteBatch) + if err != nil { + return expired, evicted, err + } + if len(keys) == 0 { + break + } + deleted, err := s.deleteExpiredBatch(ctx, keys, now) + if err != nil { + return expired, evicted, err + } + expired += deleted + if deleted == 0 { + // In-flight reads may still be renewing this batch. Try it on the + // next sweep rather than spinning while those readers finish. + break + } + } + for { + deleted, err := s.evictLRUBatch(ctx, evicted > 0) + if err != nil { + return expired, evicted, err + } + if deleted == 0 { + break + } + evicted += deleted + } + if expired > 0 || evicted > 0 { + s.writeMu.Lock() + _, err = s.db.ExecContext(ctx, `PRAGMA incremental_vacuum`) + s.writeMu.Unlock() + } + return expired, evicted, err +} + +func (s *DurableResponseContextStore) selectKeys(ctx context.Context, query string, args ...any) ([]string, error) { + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var keys []string + for rows.Next() { + var key string + if err := rows.Scan(&key); err != nil { + return nil, err + } + keys = append(keys, key) + } + return keys, rows.Err() +} + +// selectEvictionKeys picks the least recently used contexts whose combined +// logical size covers excess. Shared items make that an upper bound on what +// is freed, so Sweep re-measures and repeats instead of over-evicting. +func (s *DurableResponseContextStore) selectEvictionKeys(ctx context.Context, excess int64) ([]string, error) { + rows, err := s.db.QueryContext(ctx, `SELECT key, logical_bytes FROM response_contexts ORDER BY accessed_at ASC LIMIT ?`, durableResponseContextDeleteBatch) + if err != nil { + return nil, err + } + defer rows.Close() + var keys []string + var covered int64 + for rows.Next() { + var key string + var logical int64 + if err := rows.Scan(&key, &logical); err != nil { + return nil, err + } + keys = append(keys, key) + covered += logical + if covered >= excess { + break + } + } + return keys, rows.Err() +} + +// Keep writes and flushed read touches from refreshing a selected LRU key +// between measuring the budget, selecting candidates and deleting the batch. +func (s *DurableResponseContextStore) evictLRUBatch(ctx context.Context, alreadyEvicted bool) (int, error) { + s.writeMu.Lock() + defer s.writeMu.Unlock() + if err := s.flushTouchesLocked(ctx); err != nil { + return 0, err + } + stats, err := s.Stats(ctx) + if err != nil { + return 0, err + } + target := s.maxBytes / 10 * 9 + if stats.ItemBytes <= s.maxBytes && (!alreadyEvicted || stats.ItemBytes <= target) { + return 0, nil + } + keys, err := s.selectEvictionKeys(ctx, stats.ItemBytes-target) + if err != nil { + return 0, err + } + if err := s.deleteKeysLocked(ctx, keys); err != nil { + return 0, err + } + return len(keys), nil +} + +// Expiration candidates can be renewed after selection. Recheck expiration in +// the delete transaction and release only the hashes of rows actually removed. +func (s *DurableResponseContextStore) deleteExpiredBatch(ctx context.Context, keys []string, now time.Time) (deleted int, err error) { + if len(keys) == 0 { + return 0, nil + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + touched, reading := s.takeTouches(keys) + defer func() { + if err != nil { + s.restoreTouches(touched) + } + }() + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer func() { _ = tx.Rollback() }() + if err := s.applyTouches(ctx, tx, touched); err != nil { + return 0, err + } + for _, key := range keys { + // Readers registered before this snapshot may have observed a live + // context and not recorded their touch yet. Later reads of an expired + // row miss, so they cannot introduce another successful renewal. + if reading[key] { + continue + } + var hashes []byte + switch err := tx.QueryRowContext(ctx, `DELETE FROM response_contexts + WHERE key = ? AND expires_at <= ? RETURNING hashes`, key, now.UnixMilli()).Scan(&hashes); { + case errors.Is(err, sql.ErrNoRows): + continue + case err != nil: + return 0, err + } + if err := releaseDurableResponseContextHashes(ctx, tx, hashes); err != nil { + return 0, err + } + deleted++ + } + if err := tx.Commit(); err != nil { + return 0, err + } + return deleted, nil +} + +// Stats counts retained contexts and unique item bodies. +func (s *DurableResponseContextStore) Stats(ctx context.Context) (DurableResponseContextStats, error) { + var stats DurableResponseContextStats + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM response_contexts`).Scan(&stats.Contexts); err != nil { + return stats, err + } + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*), COALESCE(SUM(bytes), 0) FROM response_context_items`).Scan(&stats.Items, &stats.ItemBytes); err != nil { + return stats, err + } + return stats, nil +} + +// DurableResponseContextCache marks a TokenCache whose response contexts +// survive L1 eviction and process restarts even though its other runtime +// state stays process-local. +type DurableResponseContextCache interface { + DurableResponseContexts() bool +} + +// IsDurableResponseContextCache reports whether tc persists response contexts. +func IsDurableResponseContextCache(tc TokenCache) bool { + durable, ok := tc.(DurableResponseContextCache) + return ok && durable.DurableResponseContexts() +} + +type durableResponseContextTokenCache struct { + TokenCache + store *DurableResponseContextStore +} + +// WithDurableResponseContexts serves response contexts from store and every +// other TokenCache operation from base. Closing the returned cache does not +// close base or store; their owners close them. +func WithDurableResponseContexts(base TokenCache, store *DurableResponseContextStore) TokenCache { + if base == nil || store == nil { + return base + } + return &durableResponseContextTokenCache{TokenCache: base, store: store} +} + +func (c *durableResponseContextTokenCache) DurableResponseContexts() bool { return true } + +func (c *durableResponseContextTokenCache) Close() error { return nil } + +func (c *durableResponseContextTokenCache) SetResponseContext(ctx context.Context, responseID string, items []json.RawMessage, ttl time.Duration) error { + return c.store.SetResponseContext(ctx, responseID, items, ttl) +} + +func (c *durableResponseContextTokenCache) SetResponseContextReadOnly(ctx context.Context, responseID string, items []json.RawMessage, ttl time.Duration) error { + return c.store.SetResponseContextReadOnly(ctx, responseID, items, ttl) +} + +func (c *durableResponseContextTokenCache) GetResponseContext(ctx context.Context, responseID string) ([]json.RawMessage, error) { + return c.store.GetResponseContext(ctx, responseID) +} + +func (c *durableResponseContextTokenCache) GetResponseContextBounded(ctx context.Context, responseID string, maxWireBytes int64) (ResponseContextReadResult, error) { + return c.store.GetResponseContextBounded(ctx, responseID, maxWireBytes) +} diff --git a/cache/durable_response_context_test.go b/cache/durable_response_context_test.go new file mode 100644 index 000000000..d575f2b7d --- /dev/null +++ b/cache/durable_response_context_test.go @@ -0,0 +1,289 @@ +package cache + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +func openTestDurableResponseContextStore(t *testing.T, maxBytes int64) *DurableResponseContextStore { + t.Helper() + store, err := OpenDurableResponseContextStore(filepath.Join(t.TempDir(), "contexts.db"), DurableResponseContextOptions{ + TTL: time.Hour, MaxBytes: maxBytes, SweepInterval: time.Hour, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := store.Close(); err != nil { + t.Error(err) + } + }) + return store +} + +func TestDurableResponseContextExpirationPreservesRenewedCandidates(t *testing.T) { + for _, renewal := range []string{"rewrite", "read_touch", "pending_read_touch"} { + t.Run(renewal, func(t *testing.T) { + ctx := context.Background() + store := openTestDurableResponseContextStore(t, 0) + shared := json.RawMessage(`{"role":"user","content":"shared"}`) + old := json.RawMessage(`{"role":"assistant","content":"old"}`) + want := []json.RawMessage{shared, old} + for _, key := range []string{"renewed", "expired", "retained"} { + items := []json.RawMessage{shared} + if key == "renewed" { + items = want + } + if err := store.SetResponseContext(ctx, key, items, time.Hour); err != nil { + t.Fatal(err) + } + } + if renewal != "rewrite" { + if _, err := store.GetResponseContext(ctx, "renewed"); err != nil { + t.Fatal(err) + } + } + now := time.Now() + if _, err := store.db.ExecContext(ctx, `UPDATE response_contexts SET expires_at = ? WHERE key IN ('renewed', 'expired')`, now.Add(-time.Minute).UnixMilli()); err != nil { + t.Fatal(err) + } + keys, err := store.selectKeys(ctx, `SELECT key FROM response_contexts WHERE expires_at <= ?`, now.UnixMilli()) + if err != nil || len(keys) != 2 { + t.Fatalf("expiration candidates=%v err=%v", keys, err) + } + // Renew after selection to reproduce a stale expiration candidate. + if renewal == "rewrite" { + want = []json.RawMessage{shared, json.RawMessage(`{"role":"assistant","content":"new"}`)} + if err := store.SetResponseContext(ctx, "renewed", want, time.Hour); err != nil { + t.Fatal(err) + } + } else if renewal == "read_touch" { + store.flushTouches() + } + deleted, err := store.deleteExpiredBatch(ctx, keys, now) + if err != nil || deleted != 1 { + t.Fatalf("expired deletions=%d err=%v, want only the still-expired row", deleted, err) + } + got, err := store.GetResponseContext(ctx, "renewed") + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("renewed context=%s err=%v, want %s", got, err, want) + } + got, err = store.GetResponseContext(ctx, "retained") + if err != nil || !reflect.DeepEqual(got, []json.RawMessage{shared}) { + t.Fatalf("shared retained context=%s err=%v", got, err) + } + got, err = store.GetResponseContext(ctx, "expired") + if err != nil || got != nil { + t.Fatalf("expired context=%s err=%v, want removed", got, err) + } + stats, err := store.Stats(ctx) + if err != nil || stats.Contexts != 2 || stats.Items != 2 || stats.ItemBytes != int64(len(shared)+len(want[1])) { + t.Fatalf("retained stats=%+v err=%v", stats, err) + } + hash := durableResponseContextHash(shared) + var refs int + if err := store.db.QueryRowContext(ctx, `SELECT refs FROM response_context_items WHERE hash = ?`, hash[:]).Scan(&refs); err != nil || refs != 2 { + t.Fatalf("shared refs=%d err=%v, want 2", refs, err) + } + if expired, evicted, err := store.Sweep(now); err != nil || expired != 0 || evicted != 0 { + t.Fatalf("sweep after renewal: expired=%d evicted=%d err=%v", expired, evicted, err) + } + // Explicit invalidation must still remove a live context and its refs. + if err := store.SetResponseContext(ctx, "renewed", nil, time.Hour); err != nil { + t.Fatal(err) + } + stats, err = store.Stats(ctx) + if err != nil || stats.Contexts != 1 || stats.Items != 1 || stats.ItemBytes != int64(len(shared)) { + t.Fatalf("stats after explicit deletion=%+v err=%v", stats, err) + } + }) + } +} + +func TestDurableResponseContextSweepEvictsLiveContextsOverBudget(t *testing.T) { + ctx := context.Background() + store := openTestDurableResponseContextStore(t, 64) + for _, key := range []string{"a", "b"} { + item := json.RawMessage(`{"text":"` + strings.Repeat(key, 32) + `"}`) + if err := store.SetResponseContext(ctx, key, []json.RawMessage{item}, time.Hour); err != nil { + t.Fatal(err) + } + } + expired, evicted, err := store.Sweep(time.Now()) + if err != nil || expired != 0 || evicted != 1 { + t.Fatalf("sweep: expired=%d evicted=%d err=%v", expired, evicted, err) + } + stats, err := store.Stats(ctx) + if err != nil || stats.Contexts != 1 || stats.Items != 1 || stats.ItemBytes > store.MaxBytes() { + t.Fatalf("stats after capacity eviction=%+v err=%v", stats, err) + } +} + +func TestDurableResponseContextReadDoesNotWaitForWriter(t *testing.T) { + store := openTestDurableResponseContextStore(t, 0) + items := []json.RawMessage{json.RawMessage(`{"text":"live"}`)} + if err := store.SetResponseContext(context.Background(), "live", items, time.Hour); err != nil { + t.Fatal(err) + } + store.writeMu.Lock() + defer store.writeMu.Unlock() + done := make(chan error, 1) + go func() { + got, err := store.GetResponseContext(context.Background(), "live") + if err == nil && !reflect.DeepEqual(got, items) { + err = fmt.Errorf("context=%s, want %s", got, items) + } + done <- err + }() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("read waited for the writer lock") + } +} + +func TestDurableResponseContextSweepDefersInFlightReader(t *testing.T) { + ctx := context.Background() + store := openTestDurableResponseContextStore(t, 0) + items := []json.RawMessage{json.RawMessage(`{"text":"reading"}`)} + if err := store.SetResponseContext(ctx, "reading", items, time.Hour); err != nil { + t.Fatal(err) + } + // Pause a reader after it observed a live snapshot, before recording its touch. + store.beginRead("reading") + now := time.Now() + if _, err := store.db.ExecContext(ctx, `UPDATE response_contexts SET expires_at = ?`, now.Add(-time.Minute).UnixMilli()); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { + expired, evicted, err := store.Sweep(now) + if err == nil && (expired != 0 || evicted != 0) { + err = fmt.Errorf("expired=%d evicted=%d while reader is active", expired, evicted) + } + done <- err + }() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("sweep spun on the in-flight reader") + } + store.recordTouch("reading") + store.endRead("reading") + if expired, evicted, err := store.Sweep(time.Now()); err != nil || expired != 0 || evicted != 0 { + t.Fatalf("sweep after reader finished: expired=%d evicted=%d err=%v", expired, evicted, err) + } + got, err := store.GetResponseContext(ctx, "reading") + if err != nil || !reflect.DeepEqual(got, items) { + t.Fatalf("reader's renewal was lost: context=%s err=%v", got, err) + } +} + +func TestDurableResponseContextFailedTouchWritesRemainPending(t *testing.T) { + ctx := context.Background() + store := openTestDurableResponseContextStore(t, 0) + items := []json.RawMessage{json.RawMessage(`{"text":"retry touch"}`)} + if err := store.SetResponseContext(ctx, "renewed", items, time.Hour); err != nil { + t.Fatal(err) + } + if _, err := store.GetResponseContext(ctx, "renewed"); err != nil { + t.Fatal(err) + } + now := time.Now() + if _, err := store.db.ExecContext(ctx, `UPDATE response_contexts SET expires_at = ?`, now.Add(-time.Minute).UnixMilli()); err != nil { + t.Fatal(err) + } + if _, err := store.db.ExecContext(ctx, `CREATE TRIGGER fail_touch BEFORE UPDATE OF expires_at ON response_contexts + BEGIN SELECT RAISE(FAIL, 'touch write blocked'); END`); err != nil { + t.Fatal(err) + } + store.flushTouches() + if deleted, err := store.deleteExpiredBatch(ctx, []string{"renewed"}, now); err == nil || deleted != 0 { + t.Fatalf("expected touch transaction failure, deleted=%d err=%v", deleted, err) + } + if _, err := store.db.ExecContext(ctx, `DROP TRIGGER fail_touch`); err != nil { + t.Fatal(err) + } + if deleted, err := store.deleteExpiredBatch(ctx, []string{"renewed"}, now); err != nil || deleted != 0 { + t.Fatalf("retry lost the pending renewal: deleted=%d err=%v", deleted, err) + } + got, err := store.GetResponseContext(ctx, "renewed") + if err != nil || !reflect.DeepEqual(got, items) { + t.Fatalf("renewed context after retry=%s err=%v", got, err) + } +} + +func TestDurableResponseContextLRUEvictionSerializesConcurrentRefresh(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + store := openTestDurableResponseContextStore(t, 64) + for _, key := range []string{"a", "b"} { + item := json.RawMessage(`{"text":"` + strings.Repeat(key, 32) + `"}`) + if err := store.SetResponseContext(ctx, key, []json.RawMessage{item}, time.Hour); err != nil { + t.Fatal(err) + } + } + if _, err := store.db.ExecContext(ctx, `UPDATE response_contexts SET accessed_at = 0 WHERE key = 'a'`); err != nil { + t.Fatal(err) + } + // Pause maintenance on its first DB operation. A refresh must wait until + // selection and deletion both finish, then persist the new value. + store.db.SetMaxOpenConns(1) + connection, err := store.db.Conn(ctx) + if err != nil { + t.Fatal(err) + } + defer connection.Close() + evictionDone := make(chan error, 1) + go func() { + deleted, err := store.evictLRUBatch(ctx, false) + if err == nil && deleted != 1 { + err = fmt.Errorf("evicted=%d, want 1", deleted) + } + evictionDone <- err + }() + for store.writeMu.TryLock() { + store.writeMu.Unlock() + select { + case <-ctx.Done(): + t.Fatal("LRU batch did not acquire the writer lock") + case <-time.After(time.Millisecond): + } + } + want := []json.RawMessage{json.RawMessage(`{"text":"` + strings.Repeat("x", 32) + `"}`)} + writeDone := make(chan error, 1) + go func() { writeDone <- store.SetResponseContext(ctx, "a", want, time.Hour) }() + if err := connection.Close(); err != nil { + t.Fatal(err) + } + for _, done := range []<-chan error{evictionDone, writeDone} { + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + t.Fatal("eviction/refresh did not complete") + } + } + got, err := store.GetResponseContext(ctx, "a") + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("concurrent refresh was deleted: context=%s err=%v", got, err) + } + stats, err := store.Stats(ctx) + if err != nil || stats.Contexts != 2 || stats.Items != 2 { + t.Fatalf("stats after eviction and refresh=%+v err=%v", stats, err) + } +} diff --git a/config/config.go b/config/config.go index 81ac17a7b..dd82c07e6 100644 --- a/config/config.go +++ b/config/config.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "os" + "path/filepath" "regexp" "strconv" "strings" @@ -75,8 +76,17 @@ type RedisConfig struct { // CacheConfig 缓存核心配置。 type CacheConfig struct { - Driver string - Redis RedisConfig + Driver string + Redis RedisConfig + ResponseContextStore ResponseContextStoreConfig +} + +// ResponseContextStoreConfig 是 Memory 驱动下 previous_response_id 上下文的本地磁盘持久层。 +// Path 为空表示关闭;默认放在 SQLite 主库同目录的独立文件中,不进入主库备份。 +type ResponseContextStoreConfig struct { + Path string + TTL time.Duration + MaxBytes int64 } // Label 返回用于展示的缓存标签。 @@ -240,6 +250,11 @@ func Load(envPath string) (*Config, error) { } cfg.Cache.Redis.TLS = parseBoolEnv(os.Getenv("REDIS_TLS")) cfg.Cache.Redis.InsecureSkipVerify = parseBoolEnv(os.Getenv("REDIS_INSECURE_SKIP_VERIFY")) + responseContextStore, err := loadResponseContextStoreConfig(cfg.Cache.Driver, cfg.Database) + if err != nil { + return nil, err + } + cfg.Cache.ResponseContextStore = responseContextStore // 校验必填物理层配置 switch cfg.Database.Driver { @@ -324,3 +339,73 @@ func normalizeCodexUpstreamTransport(value string) string { return "" } } + +const ( + defaultResponseContextStoreTTLHours = 48 + defaultResponseContextStoreMaxMB = 2048 +) + +// loadResponseContextStoreConfig 解析 RESPONSE_CONTEXT_STORE_*。 +// - RESPONSE_CONTEXT_STORE_PATH:显式路径;off/none/false/0 关闭。未设置时, +// Memory 缓存 + SQLite 主库默认使用主库同目录下的 response-context.db。 +// - RESPONSE_CONTEXT_STORE_TTL_HOURS:最短保留时长(1~720,默认 48)。 +// - RESPONSE_CONTEXT_STORE_MAX_MB:去重后条目正文总量上限(64~65536,默认 2048)。 +func loadResponseContextStoreConfig(cacheDriver string, db DatabaseConfig) (ResponseContextStoreConfig, error) { + result := ResponseContextStoreConfig{ + TTL: defaultResponseContextStoreTTLHours * time.Hour, + MaxBytes: int64(defaultResponseContextStoreMaxMB) << 20, + } + if value := strings.TrimSpace(os.Getenv("RESPONSE_CONTEXT_STORE_TTL_HOURS")); value != "" { + hours, err := strconv.Atoi(value) + if err != nil || hours < 1 || hours > 720 { + return result, fmt.Errorf("RESPONSE_CONTEXT_STORE_TTL_HOURS 必须是 1~720 的整数: %q", value) + } + result.TTL = time.Duration(hours) * time.Hour + } + if value := strings.TrimSpace(os.Getenv("RESPONSE_CONTEXT_STORE_MAX_MB")); value != "" { + mb, err := strconv.ParseInt(value, 10, 64) + if err != nil || mb < 64 || mb > 65536 { + return result, fmt.Errorf("RESPONSE_CONTEXT_STORE_MAX_MB 必须是 64~65536 的整数: %q", value) + } + result.MaxBytes = mb << 20 + } + path, explicit := os.LookupEnv("RESPONSE_CONTEXT_STORE_PATH") + path = strings.TrimSpace(path) + if explicit && path != "" { + switch strings.ToLower(path) { + case "off", "none", "false", "0": + return result, nil + } + if cacheDriver != "memory" { + return result, fmt.Errorf("RESPONSE_CONTEXT_STORE_PATH 仅支持 memory 缓存驱动;redis 模式使用共享上下文后端") + } + result.Path = path + return result, nil + } + // Redis 已经是共享后端;只在 Memory 驱动下默认启用本地持久层。 + if cacheDriver != "memory" || db.Driver != "sqlite" { + return result, nil + } + if dir := sqliteDatabaseDir(db.Path); dir != "" { + result.Path = filepath.Join(dir, "response-context.db") + } + return result, nil +} + +// sqliteDatabaseDir 返回 SQLite 主库文件所在目录;内存库或无法识别的 DSN 返回空。 +func sqliteDatabaseDir(path string) string { + path = strings.TrimSpace(path) + if path == "" || path == ":memory:" || strings.Contains(strings.ToLower(path), "mode=memory") { + return "" + } + if strings.HasPrefix(strings.ToLower(path), "file:") { + path = path[len("file:"):] + if index := strings.IndexByte(path, '?'); index >= 0 { + path = path[:index] + } + } + if path == "" || path == ":memory:" { + return "" + } + return filepath.Dir(path) +} diff --git a/database/acceptance_config_test.go b/database/acceptance_config_test.go new file mode 100644 index 000000000..0bb09936d --- /dev/null +++ b/database/acceptance_config_test.go @@ -0,0 +1,317 @@ +// database/acceptance_config_test.go +package database + +import ( + "context" + "errors" + "math" + "os" + "path/filepath" + "reflect" + "sync" + "testing" +) + +// 场景 21: 迁移默认关闭(显式开启/关闭持久化),并发无关保存不丢字段,generation 溢出回滚 +func TestAcceptanceScenario21_DBCurrencyAndRestoreDefaults(t *testing.T) { + drivers := []string{"sqlite", "postgres"} + pgDSN := os.Getenv("CODEX2API_TEST_POSTGRES_DSN") + + for _, driver := range drivers { + t.Run(driver, func(t *testing.T) { + var db *DB + var err error + if driver == "sqlite" { + db, err = New("sqlite", filepath.Join(t.TempDir(), "acceptance_outputguard.db")) + } else { + // PostgreSQL 真实驱动构造,无 DSN 时跳过,如实报告不宣称已测 + if pgDSN == "" { + t.Skip("PostgreSQL DSN not configured; skipped") + } + db, err = New("postgres", pgDSN) + } + if err != nil { + t.Fatalf("New(%s) error = %v", driver, err) + } + t.Cleanup(func() { _ = db.Close() }) + + ctx := context.Background() + + // 1. 全新数据库默认值检查(三态指针默认关闭 false,5 项预算与 ConfigGeneration 默认值精确校验) + settings, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("GetOutputGuardSettings error = %v", err) + } + if settings.Enabled == nil || *settings.Enabled != false { + t.Fatalf("expected Enabled=false by default, got %v", settings.Enabled) + } + if settings.MaxToolArgumentBytes != 262144 || settings.MaxToolArgumentDeltas != 4096 || + settings.MaxChannelBytes != 2097152 || settings.MaxRequestBytes != 4194304 || + settings.ConfigGeneration != 1 { + t.Fatalf("unexpected defaults: %+v", settings) + } + + // 2. 显式写入 false,断言持久化且重启后不被零值覆盖;NULL fallback 保持 + falseVal := false + updated, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + Enabled: &falseVal, + }) + if err != nil { + t.Fatalf("update to false error = %v", err) + } + if updated.Enabled == nil || *updated.Enabled != false { + t.Fatalf("expected Enabled=false after update, got %v", updated.Enabled) + } + + // 重启 DB 连接验证显式 false 得到正确持久化恢复 + if driver == "sqlite" { + dbPath := filepath.Join(t.TempDir(), "acceptance_restart.db") + initDB, ierr := New("sqlite", dbPath) + if ierr != nil { + t.Fatalf("New(sqlite) error = %v", ierr) + } + if _, uerr := initDB.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{Enabled: &falseVal}); uerr != nil { + t.Fatalf("setup explicit false error = %v", uerr) + } + _ = initDB.Close() + + restartedDB, rerr := New("sqlite", dbPath) + if rerr != nil { + t.Fatalf("reopen error = %v", rerr) + } + t.Cleanup(func() { _ = restartedDB.Close() }) + + readBack, gerr := restartedDB.GetOutputGuardSettings(ctx) + if gerr != nil || readBack.Enabled == nil || *readBack.Enabled != false { + t.Fatalf("expected persistent false after restart, got %v", readBack.Enabled) + } + } + + // 验证 NULL fallback: 将 output_guard_enabled 更新为 NULL,Get 必须补齐为默认 false + if _, err := db.conn.ExecContext(ctx, `UPDATE system_settings SET output_guard_enabled = NULL WHERE id = 1`); err != nil { + t.Fatalf("seed NULL enabled failed: %v", err) + } + nullFallbackSettings, err := db.GetOutputGuardSettings(ctx) + if err != nil || nullFallbackSettings.Enabled == nil || *nullFallbackSettings.Enabled != false { + t.Fatalf("expected NULL fallback to false, got: %+v", nullFallbackSettings) + } + + // 恢复为显式 true + trueVal := true + restoredTrue, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + Enabled: &trueVal, + }) + if err != nil || restoredTrue.Enabled == nil || *restoredTrue.Enabled != true { + t.Fatalf("restore to true error = %v", err) + } + + // 3. 场景 21 缺口 1: 非法配置(0/负/超范围/tool>channel/channel>request)全被拒绝,且读取前后完整 settings + generation 严格不变 + beforeInvalid, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("get settings before invalid: %v", err) + } + invalidCases := []struct { + name string + update OutputGuardSettingsUpdate + }{ + {"tool zero", OutputGuardSettingsUpdate{MaxToolArgumentBytes: ptr(int64(0))}}, + {"tool negative", OutputGuardSettingsUpdate{MaxToolArgumentBytes: ptr(int64(-1))}}, + {"tool < 16KiB", OutputGuardSettingsUpdate{MaxToolArgumentBytes: ptr(int64(16383))}}, + {"tool > 4MiB", OutputGuardSettingsUpdate{MaxToolArgumentBytes: ptr(int64(4194305))}}, + {"deltas zero", OutputGuardSettingsUpdate{MaxToolArgumentDeltas: ptr(0)}}, + {"deltas negative", OutputGuardSettingsUpdate{MaxToolArgumentDeltas: ptr(-5)}}, + {"deltas < 256", OutputGuardSettingsUpdate{MaxToolArgumentDeltas: ptr(255)}}, + {"deltas > 65536", OutputGuardSettingsUpdate{MaxToolArgumentDeltas: ptr(65537)}}, + {"channel zero", OutputGuardSettingsUpdate{MaxChannelBytes: ptr(int64(0))}}, + {"channel < 128KiB", OutputGuardSettingsUpdate{MaxChannelBytes: ptr(int64(131071))}}, + {"channel > 16MiB", OutputGuardSettingsUpdate{MaxChannelBytes: ptr(int64(16777217))}}, + {"request zero", OutputGuardSettingsUpdate{MaxRequestBytes: ptr(int64(0))}}, + {"request < 256KiB", OutputGuardSettingsUpdate{MaxRequestBytes: ptr(int64(262143))}}, + {"request > 64MiB", OutputGuardSettingsUpdate{MaxRequestBytes: ptr(int64(67108865))}}, + {"tool > channel", OutputGuardSettingsUpdate{MaxToolArgumentBytes: ptr(int64(3000000)), MaxChannelBytes: ptr(int64(2097152))}}, + {"channel > request", OutputGuardSettingsUpdate{MaxChannelBytes: ptr(int64(5000000)), MaxRequestBytes: ptr(int64(4194304))}}, + } + for _, tc := range invalidCases { + _, uerr := db.UpdateOutputGuardSettings(ctx, tc.update) + if !errors.Is(uerr, ErrInvalidOutputGuardSettings) { + t.Fatalf("[%s] expected ErrInvalidOutputGuardSettings, got %v", tc.name, uerr) + } + } + afterInvalid, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("get settings after invalid: %v", err) + } + if !reflect.DeepEqual(beforeInvalid, afterInvalid) { + t.Fatalf("settings changed after rejected invalid updates! before=%+v, after=%+v", beforeInvalid, afterInvalid) + } + + // 4. 场景 21 缺口 2: 并发窄更新终值精确核验:构造两个 disjoint guard fields 并发 + response cache settings 独立组并发 + // 收集每 goroutine error 严禁忽略;核验每字段提交值与 revision 真实自增量 + genBeforeConcurrent := afterInvalid.ConfigGeneration + const guardGoroutines = 4 + const rcGoroutines = 4 + var wg sync.WaitGroup + guardErrCh := make(chan error, guardGoroutines) + rcErrCh := make(chan error, rcGoroutines) + + // 分别构建两个不相交字段集合的更新: + // G1, G2 仅修改 MaxToolArgumentBytes(保证 tool <= 2097152) + // G3, G4 仅修改 MaxToolArgumentDeltas + toolVals := []int64{140000, 150000} + toolCandidates := make(map[int64]bool, len(toolVals)) + for _, v := range toolVals { + toolCandidates[v] = true + } + + deltaVals := []int{5000, 6000} + deltaCandidates := make(map[int]bool, len(deltaVals)) + for _, v := range deltaVals { + deltaCandidates[v] = true + } + + // 启动修改 tool bytes 的并发任务 + for i := 0; i < len(toolVals); i++ { + wg.Add(1) + val := toolVals[i] + go func(tb int64) { + defer wg.Done() + _, gerr := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + MaxToolArgumentBytes: &tb, + }) + if gerr != nil { + guardErrCh <- gerr + } + }(val) + } + + // 启动修改 tool deltas 的并发任务 + for i := 0; i < len(deltaVals); i++ { + wg.Add(1) + val := deltaVals[i] + go func(d int) { + defer wg.Done() + _, gerr := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + MaxToolArgumentDeltas: &d, + }) + if gerr != nil { + guardErrCh <- gerr + } + }(val) + } + + // 并发更新完全独立的 response_cache_settings 组 + rcVals := []int64{10485760, 12582912, 14680064, 16777216} + rcCandidates := make(map[int64]bool, len(rcVals)) + for _, v := range rcVals { + rcCandidates[v] = true + } + for j := 0; j < len(rcVals); j++ { + wg.Add(1) + val := rcVals[j] + go func(eb int64) { + defer wg.Done() + _, rcerr := db.UpdateResponseCacheSettings(ctx, ResponseCacheSettingsUpdate{ + LocalMaxEntryBytes: &eb, + }) + if rcerr != nil { + rcErrCh <- rcerr + } + }(val) + } + + wg.Wait() + close(guardErrCh) + close(rcErrCh) + + for err := range guardErrCh { + t.Fatalf("guard concurrent update error: %v", err) + } + for err := range rcErrCh { + t.Fatalf("response cache concurrent update error: %v", err) + } + + finalSettings, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("GetOutputGuardSettings after concurrent updates: %v", err) + } + + // 核验 1: Enabled 保持不变 (true) + if finalSettings.Enabled == nil || *finalSettings.Enabled != true { + t.Fatalf("expected Enabled=true preserved, got %+v", finalSettings) + } + // 核验 2: 并发写同一字段的终值必须落在候选值集合内 + if !toolCandidates[finalSettings.MaxToolArgumentBytes] { + t.Fatalf("final MaxToolArgumentBytes %d not in candidate set %v", finalSettings.MaxToolArgumentBytes, toolCandidates) + } + if !deltaCandidates[finalSettings.MaxToolArgumentDeltas] { + t.Fatalf("final MaxToolArgumentDeltas %d not in candidate set %v", finalSettings.MaxToolArgumentDeltas, deltaCandidates) + } + // 核验 3: 未被并发修改的独立字段保持原有值 + if finalSettings.MaxChannelBytes != afterInvalid.MaxChannelBytes || finalSettings.MaxRequestBytes != afterInvalid.MaxRequestBytes { + t.Fatalf("independent fields corrupted! channel=%d request=%d", finalSettings.MaxChannelBytes, finalSettings.MaxRequestBytes) + } + // 核验 4: revision (ConfigGeneration) 真实自增量:每个 guard update 均导致实际值变化,因此 revision 自增量精确等于 guardGoroutines + if finalSettings.ConfigGeneration != genBeforeConcurrent+int64(guardGoroutines) { + t.Fatalf("expected generation increment of exactly %d (from %d to %d), got %d", + guardGoroutines, genBeforeConcurrent, genBeforeConcurrent+int64(guardGoroutines), finalSettings.ConfigGeneration) + } + + // 核验 5: 独立 response_cache_settings 并发写终值核验 + finalRCSettings, err := db.GetResponseCacheSettings(ctx) + if err != nil { + t.Fatalf("GetResponseCacheSettings after concurrent updates: %v", err) + } + if !rcCandidates[finalRCSettings.LocalMaxEntryBytes] { + t.Fatalf("final ResponseCache LocalMaxEntryBytes %d not in candidate set %v", finalRCSettings.LocalMaxEntryBytes, rcCandidates) + } + + // 5. 场景 21 缺口 3: ConfigGeneration MaxInt64 overflow 回滚后再次 Get 与 before 严格 DeepEqual,事务正常退出无死锁 + if _, err := db.conn.ExecContext(ctx, ` + UPDATE system_settings SET output_guard_config_generation = $1 WHERE id = 1 + `, int64(math.MaxInt64)); err != nil { + t.Fatalf("seed max generation: %v", err) + } + beforeOverflow, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("get settings before overflow: %v", err) + } + attemptToolBytes := int64(262144) + _, err = db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{MaxToolArgumentBytes: &attemptToolBytes}) + if !errors.Is(err, ErrOutputGuardGenerationOverflow) { + t.Fatalf("expected ErrOutputGuardGenerationOverflow, got %v", err) + } + afterOverflow, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("get settings after overflow (deadlock/tx check): %v", err) + } + if !reflect.DeepEqual(beforeOverflow, afterOverflow) { + t.Fatalf("settings mutated after overflow error! before=%+v, after=%+v", beforeOverflow, afterOverflow) + } + + // 6. 恢复全部 5 项默认字段并核验 + // 将 generation 重置为有效值 100 + if _, err := db.conn.ExecContext(ctx, `UPDATE system_settings SET output_guard_config_generation = 100 WHERE id = 1`); err != nil { + t.Fatalf("reset generation failed: %v", err) + } + def := DefaultOutputGuardSettings() + restoredAll, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + Enabled: def.Enabled, + MaxToolArgumentBytes: &def.MaxToolArgumentBytes, + MaxToolArgumentDeltas: &def.MaxToolArgumentDeltas, + MaxChannelBytes: &def.MaxChannelBytes, + MaxRequestBytes: &def.MaxRequestBytes, + }) + if err != nil { + t.Fatalf("restore all defaults failed: %v", err) + } + if restoredAll.Enabled == nil || *restoredAll.Enabled != false || + restoredAll.MaxToolArgumentBytes != 262144 || restoredAll.MaxToolArgumentDeltas != 4096 || + restoredAll.MaxChannelBytes != 2097152 || restoredAll.MaxRequestBytes != 4194304 { + t.Fatalf("restored defaults mismatch: %+v", restoredAll) + } + if restoredAll.ConfigGeneration != 101 { + t.Fatalf("restored generation want 101, got %d", restoredAll.ConfigGeneration) + } + }) + } +} diff --git a/database/api_key_usage.go b/database/api_key_usage.go index 9a33ed3f8..7c9f8b44e 100644 --- a/database/api_key_usage.go +++ b/database/api_key_usage.go @@ -652,7 +652,8 @@ func (db *DB) getAllAPIKeysCostSince(ctx context.Context, since time.Time, honor } // APIKeySelfUsageReport 是 API Key 自助统计页使用的只读聚合结果。 -// 只包含当前 key 自己的 usage_logs 数据,不返回账号池、客户端 IP、raw key 等后台字段。 +// 只包含当前 key 自己的 usage_logs 数据(含经该 key 发起请求的客户端 IP 与 User-Agent, +// 共享同一 key 的持有者可互相看到),不返回其他 key 的日志、账号池、raw key 等后台字段。 type APIKeySelfUsageReport struct { Summary APIKeySelfUsageSummary `json:"summary"` Windows APIKeySelfUsageWindows `json:"windows"` @@ -733,6 +734,8 @@ type APIKeySelfUsageLog struct { UserBilling ID int64 `json:"id"` Channel string `json:"channel"` + ClientIP string `json:"client_ip"` + ClientUserAgent string `json:"client_user_agent"` Endpoint string `json:"endpoint"` Model string `json:"model"` EffectiveModel string `json:"effective_model"` @@ -742,7 +745,11 @@ type APIKeySelfUsageLog struct { FirstTokenMS int `json:"first_token_ms"` InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` + ReasoningTokens int `json:"reasoning_tokens"` + ReasoningEffort string `json:"reasoning_effort"` CachedTokens int `json:"cached_tokens"` + CacheWrite5mTokens int `json:"cache_write_5m_tokens"` + CacheWrite1hTokens int `json:"cache_write_1h_tokens"` ImageInputTokens int `json:"image_input_tokens"` ImageOutputTokens int `json:"image_output_tokens"` CachedImageInputTokens int `json:"cached_image_input_tokens"` @@ -1266,6 +1273,7 @@ func (db *DB) listAPIKeySelfRecentLogs(ctx context.Context, apiKeyID int64, rang SELECT id, COALESCE(channel, ''), + COALESCE(client_ip, ''), COALESCE(client_user_agent, ''), ` + apiKeySelfEndpointExpr + ` AS endpoint_name, COALESCE(model, ''), COALESCE(effective_model, ''), @@ -1275,7 +1283,10 @@ func (db *DB) listAPIKeySelfRecentLogs(ctx context.Context, apiKeyID int64, rang COALESCE(first_token_ms, 0), COALESCE(input_tokens, 0), COALESCE(output_tokens, 0), + COALESCE(reasoning_tokens, 0), + COALESCE(reasoning_effort, ''), COALESCE(cached_tokens, 0), + COALESCE(cache_write_5m_tokens, 0), COALESCE(cache_write_1h_tokens, 0), COALESCE(image_input_tokens, 0), COALESCE(image_output_tokens, 0), COALESCE(cached_image_input_tokens, 0), COALESCE(total_tokens, 0), COALESCE(user_billed, 0), @@ -1304,6 +1315,7 @@ func (db *DB) listAPIKeySelfRecentLogs(ctx context.Context, apiKeyID int64, rang if err := rows.Scan( &item.ID, &item.Channel, + &item.ClientIP, &item.ClientUserAgent, &item.Endpoint, &item.Model, &item.EffectiveModel, @@ -1313,7 +1325,10 @@ func (db *DB) listAPIKeySelfRecentLogs(ctx context.Context, apiKeyID int64, rang &item.FirstTokenMS, &item.InputTokens, &item.OutputTokens, - &item.CachedTokens, &item.ImageInputTokens, &item.ImageOutputTokens, &item.CachedImageInputTokens, + &item.ReasoningTokens, + &item.ReasoningEffort, + &item.CachedTokens, &item.CacheWrite5mTokens, &item.CacheWrite1hTokens, + &item.ImageInputTokens, &item.ImageOutputTokens, &item.CachedImageInputTokens, &item.TotalTokens, &item.UserBilled, &item.UserBillingMode, &item.ImageUnitPrice, &item.BilledImageCount, &item.ServiceTier, diff --git a/database/billing.go b/database/billing.go index ff4a2d246..53405cc56 100644 --- a/database/billing.go +++ b/database/billing.go @@ -92,6 +92,18 @@ var ( LongOutputPricePerMToken: 15.0, LongCacheReadPricePerMToken: 0.2, }}, + // gpt-6.1-terra 官方标准价 $1/$5、缓存读取 $0.05、缓存写入 $1.25, + // 超过 272K 为 $2/$7.50、缓存 $0.10;fast 档沿用 2×。 + {model: "gpt-6.1-terra", pricing: ModelPricing{ + InputPricePerMToken: 1.0, + OutputPricePerMToken: 5.0, + CacheReadPricePerMToken: 0.05, + CacheWrite5mPricePerMToken: 1.25, + CacheWrite1hPricePerMToken: 1.25, + LongInputPricePerMToken: 2.0, + LongOutputPricePerMToken: 7.5, + LongCacheReadPricePerMToken: 0.1, + }}, {model: "gpt-6-luna", pricing: ModelPricing{ InputPricePerMToken: 0.1, OutputPricePerMToken: 0.5, @@ -544,6 +556,8 @@ func normalizeCodexBillingModel(model string) (string, bool) { return "gpt-6-sol", true case strings.HasPrefix(compact, "gpt-6.1-sol") || strings.HasPrefix(compact, "gpt6.1-sol"): return "gpt-6.1-sol", true + case strings.HasPrefix(compact, "gpt-6.1-terra") || strings.HasPrefix(compact, "gpt6.1-terra"): + return "gpt-6.1-terra", true case strings.HasPrefix(compact, "gpt-6-luna") || strings.HasPrefix(compact, "gpt6-luna"): return "gpt-6-luna", true // 未知 gpt-6 变体按 astra 兜底,避免掉进 $1/$2 的默认价严重低估。 diff --git a/database/codex_unified_client_identity_test.go b/database/codex_unified_client_identity_test.go deleted file mode 100644 index 9d5541fb6..000000000 --- a/database/codex_unified_client_identity_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package database - -import ( - "context" - "os" - "path/filepath" - "testing" -) - -func TestCodexUnifiedClientIdentitySettingPersistence(t *testing.T) { - for _, driver := range []string{"sqlite", "postgres"} { - t.Run(driver, func(t *testing.T) { - dsn := filepath.Join(t.TempDir(), "settings.db") - if driver == "postgres" { - dsn = os.Getenv("CODEX2API_TEST_POSTGRES_DSN") - if dsn == "" { - t.Skip("requires isolated CODEX2API_TEST_POSTGRES_DSN") - } - } - db, err := New(driver, dsn) - if err != nil { - t.Fatal(err) - } - defer db.Close() - ctx := context.Background() - settings := &SystemSettings{PromptFilterCustomPatterns: `[{"id":"fixture"}]`, PromptFilterReviewAPIKey: "fixture-key"} - if err := db.UpdateSystemSettings(ctx, settings); err != nil { - t.Fatal(err) - } - got, err := db.GetSystemSettings(ctx) - if err != nil { - t.Fatal(err) - } - if got.CodexUnifiedClientIdentityEnabled { - t.Fatal("unified client identity must default off") - } - for _, enabled := range []bool{true, false} { - settings.CodexUnifiedClientIdentityEnabled = enabled - settings.PreservePromptFilterCustomPatterns = true - settings.PreservePromptFilterReviewAPIKey = true - settings.PromptFilterCustomPatterns = "[]" - settings.PromptFilterReviewAPIKey = "" - if err := db.UpdateSystemSettings(ctx, settings); err != nil { - t.Fatal(err) - } - got, err := db.GetSystemSettings(ctx) - if err != nil { - t.Fatal(err) - } - if got.CodexUnifiedClientIdentityEnabled != enabled { - t.Fatalf("unified client identity = %v, want %v", got.CodexUnifiedClientIdentityEnabled, enabled) - } - if got.PromptFilterCustomPatterns != `[{"id":"fixture"}]` || got.PromptFilterReviewAPIKey != "fixture-key" { - t.Fatal("new SQL parameter disturbed preserve flags") - } - } - // 模拟旧库升级:缺列时迁移补列并默认关闭。 - if _, err := db.conn.ExecContext(ctx, "ALTER TABLE system_settings DROP COLUMN codex_unified_client_identity_enabled"); err != nil { - t.Fatal(err) - } - upgraded, err := New(driver, dsn) - if err != nil { - t.Fatal(err) - } - defer upgraded.Close() - got, err = upgraded.GetSystemSettings(ctx) - if err != nil { - t.Fatal(err) - } - if got.CodexUnifiedClientIdentityEnabled || got.PromptFilterReviewAPIKey != "fixture-key" { - t.Fatal("migration did not preserve old settings and default off") - } - }) - } -} diff --git a/database/intelligence_check.go b/database/intelligence_check.go index 148f1e969..9758234f1 100644 --- a/database/intelligence_check.go +++ b/database/intelligence_check.go @@ -47,34 +47,36 @@ type IntelligenceCheckRunFilter struct { Offset, Limit int } type IntelligenceCheckAccount struct { - ConfigError string `json:"config_error,omitempty"` - AccountID int64 `json:"account_id"` - AccountName string `json:"account_name"` - Channel string `json:"channel"` - Enabled bool `json:"enabled"` - ManualEnabled bool `json:"manual_enabled"` - AccountStatus string `json:"account_status"` - Status string `json:"latest_status"` - QualityPaused bool `json:"quality_paused"` - ManualRevision int64 `json:"manual_revision"` - NextDueAt *time.Time `json:"next_due_at"` - QuestionCursor int `json:"question_cursor"` + CredentialGeneration int64 `json:"-"` + ConfigError string `json:"config_error,omitempty"` + AccountID int64 `json:"account_id"` + AccountName string `json:"account_name"` + Channel string `json:"channel"` + Enabled bool `json:"enabled"` + ManualEnabled bool `json:"manual_enabled"` + AccountStatus string `json:"account_status"` + Status string `json:"latest_status"` + QualityPaused bool `json:"quality_paused"` + ManualRevision int64 `json:"manual_revision"` + NextDueAt *time.Time `json:"next_due_at"` + QuestionCursor int `json:"question_cursor"` ic.State } type IntelligenceCheckRun struct { - ID int64 `json:"id"` - AccountID int64 `json:"account_id"` - AccountName string `json:"account_name"` - Channel string `json:"channel"` - Source string `json:"source"` - Status string `json:"status"` - ConfigRevision int64 `json:"config_revision"` - ManualRevision int64 `json:"manual_revision"` - Model string `json:"model"` - ReasoningEffort string `json:"reasoning_effort"` - JudgeMode string `json:"judge_mode"` - Question ic.Question `json:"question"` - Settings ic.Settings `json:"settings"` + CredentialGeneration int64 `json:"credential_generation,omitempty"` + ID int64 `json:"id"` + AccountID int64 `json:"account_id"` + AccountName string `json:"account_name"` + Channel string `json:"channel"` + Source string `json:"source"` + Status string `json:"status"` + ConfigRevision int64 `json:"config_revision"` + ManualRevision int64 `json:"manual_revision"` + Model string `json:"model"` + ReasoningEffort string `json:"reasoning_effort"` + JudgeMode string `json:"judge_mode"` + Question ic.Question `json:"question"` + Settings ic.Settings `json:"settings"` ic.Observation AnswerPassed bool `json:"answer_passed"` LatencyPassed bool `json:"latency_passed"` @@ -87,12 +89,16 @@ type IntelligenceCheckRun struct { LeaseUntil int64 `json:"-"` } type IntelligenceCheckBar struct { - ActiveRunID int64 `json:"active_run_id,omitempty"` - ActiveStatus string `json:"active_status,omitempty"` - Enabled bool `json:"enabled"` - QualityPaused bool `json:"quality_paused"` - OwnedAction string `json:"action"` - Records []IntelligenceCheckRunSummary `json:"records"` + ProbePhase string `json:"probe_phase,omitempty"` + NextIntervalSeconds int `json:"next_interval_seconds,omitempty"` + PassedStreak int `json:"passed_streak"` + NextDueAt *time.Time `json:"next_due_at,omitempty"` + ActiveRunID int64 `json:"active_run_id,omitempty"` + ActiveStatus string `json:"active_status,omitempty"` + Enabled bool `json:"enabled"` + QualityPaused bool `json:"quality_paused"` + OwnedAction string `json:"action"` + Records []IntelligenceCheckRunSummary `json:"records"` } type IntelligenceCheckRunSummary struct { ID int64 `json:"id"` @@ -179,7 +185,7 @@ func (db *DB) intelligenceSettingsTx(ctx context.Context, tx *sql.Tx) (ic.Settin return s, err } err := json.Unmarshal([]byte(raw), &s) - return s, err + return ic.AdaptiveDefaults(s), err } func (db *DB) GetIntelligenceCheckSettings(ctx context.Context) (ic.Settings, error) { var raw string @@ -188,9 +194,10 @@ func (db *DB) GetIntelligenceCheckSettings(ctx context.Context) (ic.Settings, er if err == nil { err = json.Unmarshal([]byte(raw), &s) } - return s, err + return ic.AdaptiveDefaults(s), err } func (db *DB) SaveIntelligenceCheckSettings(ctx context.Context, s ic.Settings) (ic.Settings, error) { + s = ic.AdaptiveDefaults(s) if err := ic.ValidateSettings(s); err != nil { return s, err } @@ -262,14 +269,35 @@ func (db *DB) SaveIntelligenceCheckSettings(ctx context.Context, s ic.Settings) return err } for id, state := range states { + delay := s.IntervalSeconds + state.ErrorRetrySeconds = 0 + state.ProbePhase = "" + if s.AdaptiveEnabled { + state.ProbePhase = "normal" + if state.PauseBackoffSeconds > 0 { + state.PauseBackoffSeconds = min(s.PauseMaxSeconds, max(s.PauseInitialSeconds, state.PauseBackoffSeconds)) + } + if state.OwnedAction == "pause" { + if state.PauseBackoffSeconds == 0 { + state.PauseBackoffSeconds = s.PauseInitialSeconds + } + delay, state.ProbePhase = state.PauseBackoffSeconds, "backoff" + } + } else { + state.BlockedCredentialGeneration = nil + state.PauseBackoffSeconds = 0 + } + state.NextIntervalSeconds = delay raw, _ := json.Marshal(state) - if _, err = tx.ExecContext(ctx, `UPDATE intelligence_check_accounts SET state_json=$1 WHERE account_id=$2`, string(raw), id); err != nil { + // Keep an earlier due check unless switching timing policies. + due := time.Now().Add(time.Duration(delay) * time.Second).UnixMilli() + if _, err = tx.ExecContext(ctx, `UPDATE intelligence_check_accounts SET state_json=$1,next_due_ms=CASE WHEN next_due_ms>$3 OR $4 THEN $3 ELSE next_due_ms END WHERE account_id=$2`, string(raw), id, due, old.AdaptiveEnabled != s.AdaptiveEnabled); err != nil { return err } } return nil }) - return s, err + return ic.AdaptiveDefaults(s), err } func (db *DB) intelligenceUpstreamExpr() string { if db.isSQLite() { @@ -286,8 +314,12 @@ func (db *DB) intelligenceEligibleSQL() string { // intelligenceAutomaticSQL admits scheduled checks only for accounts that opted // in and are not manually disabled. Manual checks bypass both switches. -func intelligenceAutomaticSQL(account, state string) string { - return state + `.enabled=TRUE AND COALESCE(` + account + `.enabled,TRUE)=TRUE` +func (db *DB) intelligenceAutomaticSQL(account, state string) string { + blocked := "(" + state + ".state_json::jsonb->>'blocked_credential_generation')::bigint" + if db.isSQLite() { + blocked = "json_extract(" + state + ".state_json,'$.blocked_credential_generation')" + } + return state + `.enabled=TRUE AND COALESCE(` + account + `.enabled,TRUE)=TRUE AND (` + blocked + ` IS NULL OR ` + blocked + `<>COALESCE(` + account + `.credential_generation,1))` } func intelligencePage(offset, limit int) (int, int) { if offset < 0 { @@ -302,13 +334,13 @@ func intelligencePage(offset, limit int) (int, int) { return offset, limit } -const intelligenceAccountColumns = `a.id,a.name, s.enabled,COALESCE(a.enabled,TRUE),a.status,s.status,s.quality_paused,s.manual_revision,s.next_due_ms,s.question_cursor,s.state_json,` +const intelligenceAccountColumns = `a.id,a.name,COALESCE(a.credential_generation,1), s.enabled,COALESCE(a.enabled,TRUE),a.status,s.status,s.quality_paused,s.manual_revision,s.next_due_ms,s.question_cursor,s.state_json,` func scanIntelligenceAccount(row interface{ Scan(...interface{}) error }) (IntelligenceCheckAccount, error) { var a IntelligenceCheckAccount var due int64 var raw string - err := row.Scan(&a.AccountID, &a.AccountName, &a.Enabled, &a.ManualEnabled, &a.AccountStatus, &a.Status, &a.QualityPaused, &a.ManualRevision, &due, &a.QuestionCursor, &raw, &a.Channel) + err := row.Scan(&a.AccountID, &a.AccountName, &a.CredentialGeneration, &a.Enabled, &a.ManualEnabled, &a.AccountStatus, &a.Status, &a.QualityPaused, &a.ManualRevision, &due, &a.QuestionCursor, &raw, &a.Channel) if err == nil { err = json.Unmarshal([]byte(raw), &a.State) } @@ -469,6 +501,8 @@ func (db *DB) intelligenceManualMutationTx(ctx context.Context, tx *sql.Tx, ids state.OriginalGroupIDs = nil state.OriginalGroupChannels = nil if schedulingWrite { + state.PauseBackoffSeconds, state.ErrorRetrySeconds, state.NextIntervalSeconds = 0, 0, 0 + state.ProbePhase = "" state.OwnedAction = "" paused = false } else if state.OwnedAction == "switch_group" { diff --git a/database/intelligence_check_adaptive_test.go b/database/intelligence_check_adaptive_test.go new file mode 100644 index 000000000..06c11fefb --- /dev/null +++ b/database/intelligence_check_adaptive_test.go @@ -0,0 +1,171 @@ +package database + +import ( + "context" + "encoding/json" + ic "github.com/codex2api/internal/intelligencecheck" + "testing" + "time" +) + +func TestIntelligenceCheckAdaptiveLifecycle(t *testing.T) { + db := intelligenceDB(t) + ctx := context.Background() + id := intelligenceAccount(t, db) + cfg, _ := db.GetIntelligenceCheckSettings(ctx) + cfg.Enabled, cfg.AdaptiveEnabled, cfg.Action = true, true, "pause" + cfg.IntervalSeconds, cfg.RetrySeconds, cfg.DegradeThreshold, cfg.RecoverThreshold = 1800, 300, 1, 3 + cfg, e := db.SaveIntelligenceCheckSettings(ctx, cfg) + if e != nil { + t.Fatal(e) + } + now := time.Now().UTC() + run := func(o ic.Observation) IntelligenceCheckAccount { + t.Helper() + if _, e := db.EnqueueIntelligenceChecks(ctx, []int64{id}, now); e != nil { + t.Fatal(e) + } + r, e := db.ClaimIntelligenceCheck(ctx, "worker", now, time.Minute) + if e != nil || r == nil { + t.Fatal(r, e) + } + if ok, e := db.FinishIntelligenceCheck(ctx, r.ID, "worker", o, now.Add(time.Second)); e != nil || !ok { + t.Fatal(ok, e) + } + // Re-read persisted JSON; the policy must not rely on worker memory. + accounts, _, e := db.ListIntelligenceCheckAccounts(ctx, IntelligenceCheckAccountFilter{AccountID: id}) + if e != nil || len(accounts) != 1 { + t.Fatal(accounts, e) + } + now = now.Add(time.Minute) + return accounts[0] + } + bad := ic.Observation{Complete: true, Output: "22", DurationMS: 1} + good := ic.Observation{Complete: true, Output: "21", DurationMS: 1} + a := run(bad) + if !a.QualityPaused || a.NextIntervalSeconds != 1800 { + t.Fatal(a) + } + a = run(ic.Observation{Error: "upstream unavailable"}) + if a.NextIntervalSeconds != 3600 { + t.Fatal(a) + } + a = run(good) + if a.ProbePhase != "recovering" || a.NextIntervalSeconds != 300 { + t.Fatal(a) + } + a = run(ic.Observation{Error: "timeout"}) + if a.RecoverStreak != 0 || a.NextIntervalSeconds != 3600 || !a.QualityPaused { + t.Fatal(a) + } + for i, want := range []int{300, 120, 1800} { + a = run(good) + if a.NextIntervalSeconds != want || a.QualityPaused != (i < 2) { + t.Fatal(i, a) + } + } + if a.PauseBackoffSeconds != 3600 { + t.Fatal(a) + } + a = run(good) + if a.PauseBackoffSeconds != 0 { + t.Fatal(a) + } + a = run(bad) + // Claim then expire a real lease: it must use backoff, not fixed retry_seconds. + db.EnqueueIntelligenceChecks(ctx, []int64{id}, now) + r, e := db.ClaimIntelligenceCheck(ctx, "expired", now, time.Minute) + if e != nil || r == nil { + t.Fatal(r, e) + } + expires := time.UnixMilli(r.LeaseUntil + 1) + if n, e := db.ExpireIntelligenceCheckLeases(ctx, expires); e != nil || n != 1 { + t.Fatal(n, e) + } + list, _, _ := db.ListIntelligenceCheckAccounts(ctx, IntelligenceCheckAccountFilter{AccountID: id}) + a = list[0] + if a.NextIntervalSeconds != 3600 || a.NextDueAt.Before(expires.Add(3240*time.Second)) || a.NextDueAt.After(expires.Add(time.Hour)) { + t.Fatal(a) + } + bars, e := db.IntelligenceCheckBars(ctx, []int64{id}) + if e != nil { + t.Fatal(e) + } + if bars[id].ProbePhase != "backoff" || bars[id].NextDueAt == nil { + t.Fatal(bars) + } + // Reducing the cap reschedules a previously distant deadline. + cfg.PauseMaxSeconds = 1800 + if _, e = db.SaveIntelligenceCheckSettings(ctx, cfg); e != nil { + t.Fatal(e) + } + list, _, _ = db.ListIntelligenceCheckAccounts(ctx, IntelligenceCheckAccountFilter{AccountID: id}) + if list[0].PauseBackoffSeconds != 1800 || list[0].NextDueAt.After(time.Now().Add(1801*time.Second)) { + t.Fatal(list) + } +} + +func TestIntelligenceCheckAdaptiveCredentialFence(t *testing.T) { + db := intelligenceDB(t) + ctx := context.Background() + id := intelligenceAccount(t, db) + cfg, _ := db.GetIntelligenceCheckSettings(ctx) + cfg.Enabled, cfg.AdaptiveEnabled = true, true + db.SaveIntelligenceCheckSettings(ctx, cfg) + now := time.Now().UTC() + db.EnqueueIntelligenceChecks(ctx, []int64{id}, now) + r, e := db.ClaimIntelligenceCheck(ctx, "worker", now, time.Minute) + if e != nil || r == nil { + t.Fatal(r, e) + } + ok, e := db.FinishIntelligenceCheck(ctx, r.ID, "worker", ic.Observation{Error: "token revoked", CredentialRejected: true}, now.Add(time.Second)) + if e != nil || !ok { + t.Fatal(ok, e) + } + if n, e := db.EnqueueDueIntelligenceChecks(ctx, now.Add(72*time.Hour), 100); e != nil || n != 0 { + t.Fatal("revoked credential was scheduled", n, e) + } + bars, _ := db.IntelligenceCheckBars(ctx, []int64{id}) + if bars[id].ProbePhase != "credential_blocked" || bars[id].NextDueAt != nil { + t.Fatal(bars) + } + // An explicit manual check is still allowed, but a repair during execution + // fences its old result from blocking the replacement credentials. + db.EnqueueIntelligenceChecks(ctx, []int64{id}, now.Add(time.Minute)) + r, e = db.ClaimIntelligenceCheck(ctx, "worker", now.Add(time.Minute), time.Minute) + if e != nil || r == nil { + t.Fatal(r, e) + } + _, e = db.conn.ExecContext(ctx, `UPDATE accounts SET credential_generation=credential_generation+1 WHERE id=$1`, id) + if e != nil { + t.Fatal(e) + } + db.FinishIntelligenceCheck(ctx, r.ID, "worker", ic.Observation{Error: "old token revoked", CredentialRejected: true}, now.Add(61*time.Second)) + if n, e := db.EnqueueDueIntelligenceChecks(ctx, now.Add(72*time.Hour), 100); e != nil || n != 1 { + t.Fatal("replacement credential not scheduled", n, e) + } + r, e = db.ClaimIntelligenceCheck(ctx, "new", now.Add(72*time.Hour), time.Minute) + if e != nil || r == nil { + t.Fatal(r, e) + } +} + +func TestIntelligenceCheckAdaptiveLegacySettings(t *testing.T) { + db := intelligenceDB(t) + ctx := context.Background() + cfg, _ := db.GetIntelligenceCheckSettings(ctx) + raw, _ := json.Marshal(cfg) + var legacy map[string]any + json.Unmarshal(raw, &legacy) + for _, key := range []string{"adaptive_enabled", "pause_initial_seconds", "pause_max_seconds", "recovery_initial_seconds", "recovery_min_seconds"} { + delete(legacy, key) + } + raw, _ = json.Marshal(legacy) + if _, e := db.conn.ExecContext(ctx, `UPDATE intelligence_check_settings SET config_json=$1 WHERE id=1`, string(raw)); e != nil { + t.Fatal(e) + } + cfg, e := db.GetIntelligenceCheckSettings(ctx) + if e != nil || cfg.AdaptiveEnabled || cfg.PauseMaxSeconds != 172800 || cfg.RecoverThreshold != 1 { + t.Fatal(cfg, e) + } +} diff --git a/database/intelligence_check_runs.go b/database/intelligence_check_runs.go index 61cf0e835..ac45765d3 100644 --- a/database/intelligence_check_runs.go +++ b/database/intelligence_check_runs.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" ic "github.com/codex2api/internal/intelligencecheck" + "math/rand/v2" "strings" "time" ) @@ -82,7 +83,7 @@ func intelligenceCheckSnapshot(s ic.Settings, a IntelligenceCheckAccount, source } snapshot := s snapshot.Questions = nil - r := IntelligenceCheckRun{AccountID: a.AccountID, AccountName: a.AccountName, Channel: a.Channel, Source: source, Status: "queued", ConfigRevision: s.Revision, ManualRevision: a.ManualRevision, Model: model, ReasoningEffort: effort, JudgeMode: s.JudgeMode, Question: question, Settings: snapshot, CreatedAt: created.UTC()} + r := IntelligenceCheckRun{CredentialGeneration: a.CredentialGeneration, AccountID: a.AccountID, AccountName: a.AccountName, Channel: a.Channel, Source: source, Status: "queued", ConfigRevision: s.Revision, ManualRevision: a.ManualRevision, Model: model, ReasoningEffort: effort, JudgeMode: s.JudgeMode, Question: question, Settings: snapshot, CreatedAt: created.UTC()} return r, nil } @@ -167,7 +168,7 @@ func (db *DB) EnqueueDueIntelligenceChecks(ctx context.Context, now time.Time, l } } - rows, err := tx.QueryContext(ctx, `SELECT a.id FROM accounts a JOIN intelligence_check_accounts s ON s.account_id=a.id WHERE `+db.intelligenceEligibleSQL()+filter+` AND `+intelligenceAutomaticSQL("a", "s")+` AND s.next_due_ms<=$1 AND NOT EXISTS(SELECT 1 FROM intelligence_check_runs r WHERE r.account_id=a.id AND r.status IN ('queued','running')) ORDER BY s.next_due_ms,a.id LIMIT $2`, args...) + rows, err := tx.QueryContext(ctx, `SELECT a.id FROM accounts a JOIN intelligence_check_accounts s ON s.account_id=a.id WHERE `+db.intelligenceEligibleSQL()+filter+` AND `+db.intelligenceAutomaticSQL("a", "s")+` AND s.next_due_ms<=$1 AND NOT EXISTS(SELECT 1 FROM intelligence_check_runs r WHERE r.account_id=a.id AND r.status IN ('queued','running')) ORDER BY s.next_due_ms,a.id LIMIT $2`, args...) if err != nil { return err @@ -220,7 +221,7 @@ func (db *DB) ClaimIntelligenceCheck(ctx context.Context, owner string, now time if active >= s.Concurrency { return nil } - rows, err := tx.QueryContext(ctx, `SELECT `+intelligenceRunColumns+` FROM intelligence_check_runs WHERE status='queued' AND ready_ms<=$1 AND (`+db.intelligenceJSONField("source")+`='manual' OR ($2 AND account_id IN (SELECT s.account_id FROM intelligence_check_accounts s JOIN accounts a ON a.id=s.account_id WHERE `+intelligenceAutomaticSQL("a", "s")+`))) ORDER BY ready_ms,id LIMIT 100`, now.UnixMilli(), s.Enabled) + rows, err := tx.QueryContext(ctx, `SELECT `+intelligenceRunColumns+` FROM intelligence_check_runs WHERE status='queued' AND ready_ms<=$1 AND (`+db.intelligenceJSONField("source")+`='manual' OR ($2 AND account_id IN (SELECT s.account_id FROM intelligence_check_accounts s JOIN accounts a ON a.id=s.account_id WHERE `+db.intelligenceAutomaticSQL("a", "s")+`))) ORDER BY ready_ms,id LIMIT 100`, now.UnixMilli(), s.Enabled) if err != nil { return err } @@ -352,7 +353,7 @@ func (db *DB) FinishIntelligenceCheck(ctx context.Context, id int64, owner strin if err != nil && !errors.Is(err, ErrIntelligenceCheckUnsupportedAccount) { return err } - if err == nil && r.ConfigRevision == s.Revision && r.ManualRevision == a.ManualRevision { + if err == nil && r.ConfigRevision == s.Revision && r.ManualRevision == a.ManualRevision && r.CredentialGeneration == a.CredentialGeneration { next, action := ic.Transition(s, a.State, grade.Status) if grade.Status != "interrupted" { next.LastQuestionID = r.Question.ID @@ -366,16 +367,22 @@ func (db *DB) FinishIntelligenceCheck(ctx context.Context, id int64, owner strin if err = db.intelligenceApplyActionTx(ctx, tx, &a, &next, &r, action, s); err != nil { return err } - raw, _ := json.Marshal(next) - delay := s.IntervalSeconds - if grade.Status == "error" || grade.Status == "interrupted" { - delay = s.RetrySeconds + next, delay := ic.Schedule(s, a.State, next, grade.Status) + if next.BlockedCredentialGeneration != nil && *next.BlockedCredentialGeneration != a.CredentialGeneration { + next.BlockedCredentialGeneration = nil + } + if s.AdaptiveEnabled && o.CredentialRejected { + generation := a.CredentialGeneration + next.BlockedCredentialGeneration = &generation + } else if grade.Status == "normal" { + next.BlockedCredentialGeneration = nil } + raw, _ := json.Marshal(next) cursor := a.QuestionCursor if grade.Status != "interrupted" { cursor++ } - if _, err = tx.ExecContext(ctx, `UPDATE intelligence_check_accounts SET state_json=$1,quality_paused=$2,next_due_ms=$3,question_cursor=$4,status=$5 WHERE account_id=$6`, string(raw), a.QualityPaused, now.Add(time.Duration(delay)*time.Second).UnixMilli(), cursor, grade.Status, r.AccountID); err != nil { + if _, err = tx.ExecContext(ctx, `UPDATE intelligence_check_accounts SET state_json=$1,quality_paused=$2,next_due_ms=$3,question_cursor=$4,status=$5 WHERE account_id=$6`, string(raw), a.QualityPaused, intelligenceNextDue(s, now, delay), cursor, grade.Status, r.AccountID); err != nil { return err } } else { @@ -505,8 +512,17 @@ func (db *DB) ExpireIntelligenceCheckLeases(ctx context.Context, now time.Time) return err } if r.ConfigRevision == s.Revision { - if _, err = tx.ExecContext(ctx, `UPDATE intelligence_check_accounts SET next_due_ms=$1,status='interrupted' WHERE account_id=$2 AND manual_revision=$3`, now.Add(time.Duration(s.RetrySeconds)*time.Second).UnixMilli(), r.AccountID, r.ManualRevision); err != nil { - return err + a, e := db.intelligenceAccountTx(ctx, tx, r.AccountID) + if e != nil && !errors.Is(e, ErrIntelligenceCheckUnsupportedAccount) { + return e + } + if e == nil && a.ManualRevision == r.ManualRevision && a.CredentialGeneration == r.CredentialGeneration { + next, _ := ic.Transition(s, a.State, "interrupted") + next, delay := ic.Schedule(s, a.State, next, "interrupted") + raw, _ := json.Marshal(next) + if _, err = tx.ExecContext(ctx, `UPDATE intelligence_check_accounts SET next_due_ms=$1,status='interrupted',state_json=$2 WHERE account_id=$3`, intelligenceNextDue(s, now, delay), string(raw), r.AccountID); err != nil { + return err + } } } if err = db.intelligenceRetainTx(ctx, tx, r.AccountID, s.Retention); err != nil { @@ -582,16 +598,18 @@ func (db *DB) IntelligenceCheckBars(ctx context.Context, ids []int64) (map[int64 if db.isSQLite() { automaticEnabled = `json_extract(config_json,'$.enabled')` } - active := `r.status='running' OR (r.status='queued' AND (` + db.intelligenceJSONField("source") + `='manual' OR (` + intelligenceAutomaticSQL("acc", "a") + ` AND (SELECT ` + automaticEnabled + ` FROM intelligence_check_settings WHERE id=1))))` - rows, err := db.conn.QueryContext(ctx, `SELECT a.account_id,a.enabled,a.quality_paused,a.state_json,COALESCE(r.id,0),COALESCE(r.status,'') FROM intelligence_check_accounts a JOIN accounts acc ON acc.id=a.account_id LEFT JOIN intelligence_check_runs r ON r.account_id=a.account_id AND r.status IN ('queued','running') AND (`+active+`) WHERE a.account_id IN (`+ph+`)`, args...) + active := `r.status='running' OR (r.status='queued' AND (` + db.intelligenceJSONField("source") + `='manual' OR (` + db.intelligenceAutomaticSQL("acc", "a") + ` AND (SELECT ` + automaticEnabled + ` FROM intelligence_check_settings WHERE id=1))))` + rows, err := db.conn.QueryContext(ctx, `SELECT a.account_id,a.enabled,a.quality_paused,a.state_json,a.next_due_ms,COALESCE(acc.credential_generation,1),(COALESCE(acc.enabled,TRUE) AND (SELECT `+automaticEnabled+` FROM intelligence_check_settings WHERE id=1)),COALESCE(r.id,0),COALESCE(r.status,'') FROM intelligence_check_accounts a JOIN accounts acc ON acc.id=a.account_id LEFT JOIN intelligence_check_runs r ON r.account_id=a.account_id AND r.status IN ('queued','running') AND (`+active+`) WHERE a.account_id IN (`+ph+`)`, args...) if err != nil { return nil, err } for rows.Next() { var id int64 var b IntelligenceCheckBar + var due, generation int64 + var automaticAllowed bool var raw string - if err = rows.Scan(&id, &b.Enabled, &b.QualityPaused, &raw, &b.ActiveRunID, &b.ActiveStatus); err != nil { + if err = rows.Scan(&id, &b.Enabled, &b.QualityPaused, &raw, &due, &generation, &automaticAllowed, &b.ActiveRunID, &b.ActiveStatus); err != nil { rows.Close() return nil, err } @@ -601,6 +619,15 @@ func (db *DB) IntelligenceCheckBars(ctx context.Context, ids []int64) (map[int64 return nil, err } b.OwnedAction = s.OwnedAction + b.ProbePhase, b.NextIntervalSeconds, b.PassedStreak = s.ProbePhase, s.NextIntervalSeconds, s.RecoverStreak + if due > 0 && automaticAllowed && b.Enabled { + when := time.UnixMilli(due) + b.NextDueAt = &when + } + if s.BlockedCredentialGeneration != nil && *s.BlockedCredentialGeneration == generation { + b.ProbePhase = "credential_blocked" + b.NextDueAt = nil + } b.Records = []IntelligenceCheckRunSummary{} out[id] = b } @@ -636,3 +663,13 @@ func (db *DB) IntelligenceCheckBars(ctx context.Context, ids []int64) (map[int64 } return out, rows.Err() } + +// Add bounded jitter without exceeding the configured interval or maximum. +// Persist the selected deadline; polling and restarts never redraw it. +func intelligenceNextDue(s ic.Settings, now time.Time, seconds int) int64 { + delay := time.Duration(seconds) * time.Second + if s.AdaptiveEnabled { + delay -= time.Duration(rand.Int64N(max(1, int64(delay/10)))) + } + return now.Add(delay).UnixMilli() +} diff --git a/database/intelligence_check_test.go b/database/intelligence_check_test.go index 9beca3b57..bffe1bbce 100644 --- a/database/intelligence_check_test.go +++ b/database/intelligence_check_test.go @@ -350,7 +350,7 @@ func TestIntelligenceCheckPostgres(t *testing.T) { if os.Getenv("CODEX2API_TEST_POSTGRES_DSN") == "" { t.Skip("requires isolated CODEX2API_TEST_POSTGRES_DSN") } - for name, run := range map[string]func(*testing.T){"new_defaults": TestIntelligenceCheckNewAccountDefaults, "concurrent_defaults": TestIntelligenceCheckNewAccountConcurrentSettings, "default_upgrade": TestIntelligenceCheckDefaultTriggerUpgrade, "conflict_batches": TestIntelligenceCheckTargetConflictBatches, "missing_default_target": TestIntelligenceCheckNewAccountWithMissingTarget, "settings": TestIntelligenceCheckSettingsAndMigration, "claims": TestIntelligenceCheckClaimDedupLeaseAndRetention, "ownership": TestIntelligenceCheckOwnershipManualAndConfig, "groups": TestIntelligenceCheckRestoreSurvivingGroups, "metadata": TestIntelligenceCheckSchedulerMetadataManualOverride, "due": TestIntelligenceCheckDueFiltersBarsAndDisabledPause, "queue": TestIntelligenceCheckDisabledAutomaticQueueDoesNotBlockManual, "stale_expiry": TestIntelligenceCheckExpiredOldConfigDoesNotOverwriteNewState, "promote": TestIntelligenceCheckManualPromotesExistingAutomaticQueue, "manual_groups": TestIntelligenceCheckManualGroupOwnershipIntents, "targets": TestIntelligenceCheckTargetCompatibilityPreflight, "claude_effort": TestIntelligenceCheckClaudeSnapshotEffort, "rotation": TestIntelligenceCheckQuestionRotationAfterDisablingEarlierQuestion, "restore_channel": TestIntelligenceCheckRestoreSkipsChangedGroupChannel, "pending_recovery": TestIntelligenceCheckPendingRecoveryIgnoresNewTargetRemoval, "invalid_claim": TestIntelligenceCheckClaimRejectsRemovedTargetBeforeSending, "legacy_groups": TestIntelligenceCheckRestorePreservesUnchangedLegacyMixedGroup, "snapshot_size": TestIntelligenceCheckRunSnapshotOmitsUnrelatedQuestionBank, "claim_refresh": TestIntelligenceCheckClaimRefreshesQueuedAndBusySnapshots, "bars_alloc": TestIntelligenceCheckBarsAvoidHistoryBodyAllocations, "bars_active": TestIntelligenceCheckBarsTrackActiveRun, "bars_disabled_automatic": TestIntelligenceCheckBarsAllowManualAfterDisablingAutomatic, "disabled_account": TestIntelligenceCheckAutomaticSkipsManuallyDisabledAccounts} { + for name, run := range map[string]func(*testing.T){"adaptive_lifecycle": TestIntelligenceCheckAdaptiveLifecycle, "adaptive_credentials": TestIntelligenceCheckAdaptiveCredentialFence, "adaptive_legacy": TestIntelligenceCheckAdaptiveLegacySettings, "new_defaults": TestIntelligenceCheckNewAccountDefaults, "concurrent_defaults": TestIntelligenceCheckNewAccountConcurrentSettings, "default_upgrade": TestIntelligenceCheckDefaultTriggerUpgrade, "conflict_batches": TestIntelligenceCheckTargetConflictBatches, "missing_default_target": TestIntelligenceCheckNewAccountWithMissingTarget, "settings": TestIntelligenceCheckSettingsAndMigration, "claims": TestIntelligenceCheckClaimDedupLeaseAndRetention, "ownership": TestIntelligenceCheckOwnershipManualAndConfig, "groups": TestIntelligenceCheckRestoreSurvivingGroups, "metadata": TestIntelligenceCheckSchedulerMetadataManualOverride, "due": TestIntelligenceCheckDueFiltersBarsAndDisabledPause, "queue": TestIntelligenceCheckDisabledAutomaticQueueDoesNotBlockManual, "stale_expiry": TestIntelligenceCheckExpiredOldConfigDoesNotOverwriteNewState, "promote": TestIntelligenceCheckManualPromotesExistingAutomaticQueue, "manual_groups": TestIntelligenceCheckManualGroupOwnershipIntents, "targets": TestIntelligenceCheckTargetCompatibilityPreflight, "claude_effort": TestIntelligenceCheckClaudeSnapshotEffort, "rotation": TestIntelligenceCheckQuestionRotationAfterDisablingEarlierQuestion, "restore_channel": TestIntelligenceCheckRestoreSkipsChangedGroupChannel, "pending_recovery": TestIntelligenceCheckPendingRecoveryIgnoresNewTargetRemoval, "invalid_claim": TestIntelligenceCheckClaimRejectsRemovedTargetBeforeSending, "legacy_groups": TestIntelligenceCheckRestorePreservesUnchangedLegacyMixedGroup, "snapshot_size": TestIntelligenceCheckRunSnapshotOmitsUnrelatedQuestionBank, "claim_refresh": TestIntelligenceCheckClaimRefreshesQueuedAndBusySnapshots, "bars_alloc": TestIntelligenceCheckBarsAvoidHistoryBodyAllocations, "bars_active": TestIntelligenceCheckBarsTrackActiveRun, "bars_disabled_automatic": TestIntelligenceCheckBarsAllowManualAfterDisablingAutomatic, "disabled_account": TestIntelligenceCheckAutomaticSkipsManuallyDisabledAccounts} { t.Run(name, run) } } diff --git a/database/maintenance_jobs.go b/database/maintenance_jobs.go index 0ca968fc2..d988f65f9 100644 --- a/database/maintenance_jobs.go +++ b/database/maintenance_jobs.go @@ -198,3 +198,42 @@ func (db *DB) DeleteMaintenanceJob(ctx context.Context, entityID int64, jobKind _, err := db.conn.ExecContext(ctx, `DELETE FROM maintenance_jobs WHERE entity_id=$1 AND job_kind=$2`, entityID, jobKind) return err } + +// TryClaimMaintenanceJob claims one durable, one-shot maintenance slot. The +// composite key makes retries after a restart or concurrent workers a no-op. +func (db *DB) TryClaimMaintenanceJob(ctx context.Context, entityID int64, jobKind string, dueAt time.Time) (bool, error) { + if db == nil || db.conn == nil { + return false, nil + } + var claimed bool + err := db.withSQLiteWriteLock(ctx, func() error { + result, err := db.conn.ExecContext(ctx, ` + INSERT INTO maintenance_jobs(entity_id, job_kind, due_at, attempts, updated_at) + VALUES ($1, $2, $3, 1, CURRENT_TIMESTAMP) + ON CONFLICT(entity_id, job_kind) DO NOTHING`, + entityID, jobKind, db.timeArg(dueAt)) + if err != nil { + return err + } + rows, err := result.RowsAffected() + claimed = err == nil && rows == 1 + return err + }) + return claimed, err +} + +// FinishMaintenanceJob records the outcome of a claimed one-shot slot. The +// summary is caller-provided so scheduled jobs can retain the observed next +// reset time without storing credentials or response bodies. +func (db *DB) FinishMaintenanceJob(ctx context.Context, entityID int64, jobKind, summary string) error { + if db == nil || db.conn == nil { + return nil + } + return db.withSQLiteWriteLock(ctx, func() error { + _, err := db.conn.ExecContext(ctx, ` + UPDATE maintenance_jobs + SET last_error = $3, lease_owner = '', lease_until = NULL, updated_at = CURRENT_TIMESTAMP + WHERE entity_id = $1 AND job_kind = $2`, entityID, jobKind, summary) + return err + }) +} diff --git a/database/output_guard_settings.go b/database/output_guard_settings.go new file mode 100644 index 000000000..b5b5d5b56 --- /dev/null +++ b/database/output_guard_settings.go @@ -0,0 +1,258 @@ +package database + +import ( + "context" + "database/sql" + "errors" + "fmt" + "math" +) + +const ( + DefaultOutputGuardEnabled = false + DefaultOutputGuardMaxToolArgumentBytes = int64(262144) // 256 KiB + DefaultOutputGuardMaxToolArgumentDeltas = 4096 + DefaultOutputGuardMaxChannelBytes = int64(2097152) // 2 MiB + DefaultOutputGuardMaxRequestBytes = int64(4194304) // 4 MiB + DefaultOutputGuardConfigGeneration = int64(1) + + MinOutputGuardMaxToolArgumentBytes = int64(16384) // 16 KiB + MaxOutputGuardMaxToolArgumentBytes = int64(4194304) // 4 MiB + + MinOutputGuardMaxToolArgumentDeltas = 256 + MaxOutputGuardMaxToolArgumentDeltas = 65536 + + MinOutputGuardMaxChannelBytes = int64(131072) // 128 KiB + MaxOutputGuardMaxChannelBytes = int64(16777216) // 16 MiB + + MinOutputGuardMaxRequestBytes = int64(262144) // 256 KiB + MaxOutputGuardMaxRequestBytes = int64(67108864) // 64 MiB +) + +var ( + ErrInvalidOutputGuardSettings = errors.New("invalid output guard settings") + ErrOutputGuardGenerationOverflow = errors.New("output guard config generation overflow") +) + +type OutputGuardSettings struct { + Enabled *bool `json:"output_guard_enabled"` + MaxToolArgumentBytes int64 `json:"output_guard_max_tool_argument_bytes"` + MaxToolArgumentDeltas int `json:"output_guard_max_tool_argument_deltas"` + MaxChannelBytes int64 `json:"output_guard_max_channel_bytes"` + MaxRequestBytes int64 `json:"output_guard_max_request_bytes"` + ConfigGeneration int64 `json:"output_guard_config_generation"` +} + +type OutputGuardSettingsUpdate struct { + Enabled *bool + MaxToolArgumentBytes *int64 + MaxToolArgumentDeltas *int + MaxChannelBytes *int64 + MaxRequestBytes *int64 +} + +func DefaultOutputGuardSettings() OutputGuardSettings { + t := DefaultOutputGuardEnabled + return OutputGuardSettings{ + Enabled: &t, + MaxToolArgumentBytes: DefaultOutputGuardMaxToolArgumentBytes, + MaxToolArgumentDeltas: DefaultOutputGuardMaxToolArgumentDeltas, + MaxChannelBytes: DefaultOutputGuardMaxChannelBytes, + MaxRequestBytes: DefaultOutputGuardMaxRequestBytes, + ConfigGeneration: DefaultOutputGuardConfigGeneration, + } +} + +func ValidateOutputGuardSettings(settings OutputGuardSettings) error { + switch { + case settings.MaxToolArgumentBytes < MinOutputGuardMaxToolArgumentBytes || settings.MaxToolArgumentBytes > MaxOutputGuardMaxToolArgumentBytes: + return fmt.Errorf("%w: max_tool_argument_bytes must be 16KiB..4MiB", ErrInvalidOutputGuardSettings) + case settings.MaxToolArgumentDeltas < MinOutputGuardMaxToolArgumentDeltas || settings.MaxToolArgumentDeltas > MaxOutputGuardMaxToolArgumentDeltas: + return fmt.Errorf("%w: max_tool_argument_deltas must be 256..65536", ErrInvalidOutputGuardSettings) + case settings.MaxChannelBytes < MinOutputGuardMaxChannelBytes || settings.MaxChannelBytes > MaxOutputGuardMaxChannelBytes: + return fmt.Errorf("%w: max_channel_bytes must be 128KiB..16MiB", ErrInvalidOutputGuardSettings) + case settings.MaxRequestBytes < MinOutputGuardMaxRequestBytes || settings.MaxRequestBytes > MaxOutputGuardMaxRequestBytes: + return fmt.Errorf("%w: max_request_bytes must be 256KiB..64MiB", ErrInvalidOutputGuardSettings) + case settings.MaxToolArgumentBytes > settings.MaxChannelBytes || settings.MaxChannelBytes > settings.MaxRequestBytes: + return fmt.Errorf("%w: requires tool <= channel <= request bytes", ErrInvalidOutputGuardSettings) + case settings.ConfigGeneration <= 0: + return fmt.Errorf("%w: config generation must be positive", ErrInvalidOutputGuardSettings) + default: + return nil + } +} + +func (db *DB) outputGuardSettingsSelectQuery(forUpdate bool) string { + query := fmt.Sprintf(`SELECT output_guard_enabled, + COALESCE(output_guard_max_tool_argument_bytes, %d), + COALESCE(output_guard_max_tool_argument_deltas, %d), + COALESCE(output_guard_max_channel_bytes, %d), + COALESCE(output_guard_max_request_bytes, %d), + COALESCE(output_guard_config_generation, %d) + FROM system_settings WHERE id = 1`, + DefaultOutputGuardMaxToolArgumentBytes, + DefaultOutputGuardMaxToolArgumentDeltas, + DefaultOutputGuardMaxChannelBytes, + DefaultOutputGuardMaxRequestBytes, + DefaultOutputGuardConfigGeneration, + ) + if forUpdate && !db.isSQLite() { + query += " FOR UPDATE" + } + return query +} + +type outputGuardSettingsScanner interface { + Scan(dest ...any) error +} + +func scanOutputGuardSettings(row outputGuardSettingsScanner, settings *OutputGuardSettings) error { + var enabled sql.NullBool + if err := row.Scan( + &enabled, + &settings.MaxToolArgumentBytes, + &settings.MaxToolArgumentDeltas, + &settings.MaxChannelBytes, + &settings.MaxRequestBytes, + &settings.ConfigGeneration, + ); err != nil { + return err + } + if enabled.Valid { + val := enabled.Bool + settings.Enabled = &val + } else { + def := DefaultOutputGuardEnabled + settings.Enabled = &def + } + return nil +} + +func (db *DB) GetOutputGuardSettings(ctx context.Context) (OutputGuardSettings, error) { + settings := DefaultOutputGuardSettings() + err := scanOutputGuardSettings(db.conn.QueryRowContext(ctx, db.outputGuardSettingsSelectQuery(false)), &settings) + if errors.Is(err, sql.ErrNoRows) { + return settings, nil + } + if err != nil { + return OutputGuardSettings{}, err + } + if err := ValidateOutputGuardSettings(settings); err != nil { + return OutputGuardSettings{}, err + } + return settings, nil +} + +func (db *DB) UpdateOutputGuardSettings(ctx context.Context, update OutputGuardSettingsUpdate) (OutputGuardSettings, error) { + var committed OutputGuardSettings + err := db.withWriteTx(ctx, func(tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO system_settings (id) VALUES (1) + ON CONFLICT (id) DO NOTHING + `); err != nil { + return err + } + var err error + committed, err = db.updateOutputGuardSettingsTx(ctx, tx, update) + return err + }) + if err != nil { + return OutputGuardSettings{}, err + } + return committed, nil +} + +// UpdateSystemSettingsWithOutputGuard 在同一个事务内保存全局设置和输出防护设置。 +// 防护列与 system_settings 的其余列同属一行;任一校验或写入失败时整体回滚, +// 避免“整体保存失败但防护配置已单独提交并热应用”的部分提交。 +func (db *DB) UpdateSystemSettingsWithOutputGuard(ctx context.Context, settings *SystemSettings, update OutputGuardSettingsUpdate) (OutputGuardSettings, error) { + var committed OutputGuardSettings + err := db.withWriteTx(ctx, func(tx *sql.Tx) error { + if err := updateSystemSettingsWithExecer(ctx, tx, settings); err != nil { + return err + } + var err error + committed, err = db.updateOutputGuardSettingsTx(ctx, tx, update) + return err + }) + if err != nil { + return OutputGuardSettings{}, err + } + return committed, nil +} + +// updateOutputGuardSettingsTx 在调用方提供的事务内读取当前防护配置、合并更新、 +// 校验并递增 generation。generation 的读取与写入同处一个事务,因此并发保存 +// 不会丢更新或产生重复 generation。 +func (db *DB) updateOutputGuardSettingsTx(ctx context.Context, tx *sql.Tx, update OutputGuardSettingsUpdate) (OutputGuardSettings, error) { + current := DefaultOutputGuardSettings() + if err := scanOutputGuardSettings( + tx.QueryRowContext(ctx, db.outputGuardSettingsSelectQuery(!db.isSQLite())), + ¤t, + ); err != nil && !errors.Is(err, sql.ErrNoRows) { + return OutputGuardSettings{}, err + } + + next := current + if update.Enabled != nil { + val := *update.Enabled + next.Enabled = &val + } + if update.MaxToolArgumentBytes != nil { + next.MaxToolArgumentBytes = *update.MaxToolArgumentBytes + } + if update.MaxToolArgumentDeltas != nil { + next.MaxToolArgumentDeltas = *update.MaxToolArgumentDeltas + } + if update.MaxChannelBytes != nil { + next.MaxChannelBytes = *update.MaxChannelBytes + } + if update.MaxRequestBytes != nil { + next.MaxRequestBytes = *update.MaxRequestBytes + } + + if err := ValidateOutputGuardSettings(next); err != nil { + return OutputGuardSettings{}, err + } + + changed := (update.Enabled != nil && (current.Enabled == nil || *current.Enabled != *next.Enabled)) || + (update.MaxToolArgumentBytes != nil && current.MaxToolArgumentBytes != next.MaxToolArgumentBytes) || + (update.MaxToolArgumentDeltas != nil && current.MaxToolArgumentDeltas != next.MaxToolArgumentDeltas) || + (update.MaxChannelBytes != nil && current.MaxChannelBytes != next.MaxChannelBytes) || + (update.MaxRequestBytes != nil && current.MaxRequestBytes != next.MaxRequestBytes) + + if changed { + if current.ConfigGeneration == math.MaxInt64 { + return OutputGuardSettings{}, ErrOutputGuardGenerationOverflow + } + next.ConfigGeneration = current.ConfigGeneration + 1 + + var enabledVal any + if next.Enabled != nil { + if db.isSQLite() { + if *next.Enabled { + enabledVal = int64(1) + } else { + enabledVal = int64(0) + } + } else { + enabledVal = *next.Enabled + } + } + + if _, err := tx.ExecContext(ctx, ` + UPDATE system_settings + SET output_guard_enabled = $1, + output_guard_max_tool_argument_bytes = $2, + output_guard_max_tool_argument_deltas = $3, + output_guard_max_channel_bytes = $4, + output_guard_max_request_bytes = $5, + output_guard_config_generation = $6 + WHERE id = 1 + `, enabledVal, next.MaxToolArgumentBytes, next.MaxToolArgumentDeltas, next.MaxChannelBytes, next.MaxRequestBytes, next.ConfigGeneration); err != nil { + return OutputGuardSettings{}, err + } + } + + return next, nil +} diff --git a/database/output_guard_settings_test.go b/database/output_guard_settings_test.go new file mode 100644 index 000000000..27f5c33d4 --- /dev/null +++ b/database/output_guard_settings_test.go @@ -0,0 +1,340 @@ +package database + +import ( + "context" + "errors" + "math" + "os" + "path/filepath" + "sync" + "testing" +) + +func runOnAllOutputGuardDBDrivers(t *testing.T, testFn func(t *testing.T, db *DB)) { + t.Helper() + t.Run("sqlite", func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "output-guard-test.db") + db, err := New("sqlite", dbPath) + if err != nil { + t.Fatalf("New(sqlite) error = %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + testFn(t, db) + }) + + if dsn := os.Getenv("CODEX2API_TEST_POSTGRES_DSN"); dsn != "" { + t.Run("postgres", func(t *testing.T) { + db, err := New("postgres", dsn) + if err != nil { + t.Fatalf("New(postgres) error = %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + testFn(t, db) + }) + } +} + +func TestOutputGuardSettingsFreshDefaults(t *testing.T) { + runOnAllOutputGuardDBDrivers(t, func(t *testing.T, db *DB) { + got, err := db.GetOutputGuardSettings(context.Background()) + if err != nil { + t.Fatalf("GetOutputGuardSettings error = %v", err) + } + + if got.Enabled == nil || *got.Enabled != false { + t.Fatalf("default Enabled want false, got %v", got.Enabled) + } + if got.MaxToolArgumentBytes != 262144 { + t.Fatalf("default MaxToolArgumentBytes want 262144, got %d", got.MaxToolArgumentBytes) + } + if got.MaxToolArgumentDeltas != 4096 { + t.Fatalf("default MaxToolArgumentDeltas want 4096, got %d", got.MaxToolArgumentDeltas) + } + if got.MaxChannelBytes != 2097152 { + t.Fatalf("default MaxChannelBytes want 2097152, got %d", got.MaxChannelBytes) + } + if got.MaxRequestBytes != 4194304 { + t.Fatalf("default MaxRequestBytes want 4194304, got %d", got.MaxRequestBytes) + } + if got.ConfigGeneration < 1 { + t.Fatalf("default ConfigGeneration want >= 1, got %d", got.ConfigGeneration) + } + }) +} + +func TestOutputGuardSettingsExplicitFalseTriStateAndRestart(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "output-guard-tristate.db") + db, err := New("sqlite", dbPath) + if err != nil { + t.Fatalf("New(sqlite) error = %v", err) + } + + ctx := context.Background() + disabled := false + updated, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + Enabled: &disabled, + }) + if err != nil { + t.Fatalf("UpdateOutputGuardSettings error = %v", err) + } + if updated.Enabled == nil || *updated.Enabled != false { + t.Fatalf("expected updated Enabled=false, got %v", updated.Enabled) + } + _ = db.Close() + + // 重新打开数据库,断言显式 false 得到正确持久化恢复 + restartedDB, err := New("sqlite", dbPath) + if err != nil { + t.Fatalf("reopen error = %v", err) + } + t.Cleanup(func() { _ = restartedDB.Close() }) + + readBack, err := restartedDB.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("readBack error = %v", err) + } + if readBack.Enabled == nil || *readBack.Enabled != false { + t.Fatalf("expected readBack Enabled=false, got %v", readBack.Enabled) + } +} + +func TestOutputGuardSettingsNullMissingFallbackToFalse(t *testing.T) { + runOnAllOutputGuardDBDrivers(t, func(t *testing.T, db *DB) { + ctx := context.Background() + if _, err := db.conn.ExecContext(ctx, ` + INSERT INTO system_settings (id, output_guard_enabled) + VALUES (1, NULL) + ON CONFLICT (id) DO UPDATE SET output_guard_enabled = NULL + `); err != nil { + t.Fatalf("seed NULL enabled: %v", err) + } + + got, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("GetOutputGuardSettings error = %v", err) + } + if got.Enabled == nil || *got.Enabled != false { + t.Fatalf("NULL enabled want fallback false, got %v", got.Enabled) + } + }) +} + +func TestOutputGuardSettingsValidationBoundariesAndRollback(t *testing.T) { + runOnAllOutputGuardDBDrivers(t, func(t *testing.T, db *DB) { + ctx := context.Background() + before, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatal(err) + } + + invalidCases := []struct { + name string + update OutputGuardSettingsUpdate + }{ + {"tool < 16KiB", OutputGuardSettingsUpdate{MaxToolArgumentBytes: ptr(int64(16383))}}, + {"tool > 4MiB", OutputGuardSettingsUpdate{MaxToolArgumentBytes: ptr(int64(4194305))}}, + {"deltas < 256", OutputGuardSettingsUpdate{MaxToolArgumentDeltas: ptr(255)}}, + {"deltas > 65536", OutputGuardSettingsUpdate{MaxToolArgumentDeltas: ptr(65537)}}, + {"channel < 128KiB", OutputGuardSettingsUpdate{MaxChannelBytes: ptr(int64(131071))}}, + {"channel > 16MiB", OutputGuardSettingsUpdate{MaxChannelBytes: ptr(int64(16777217))}}, + {"request < 256KiB", OutputGuardSettingsUpdate{MaxRequestBytes: ptr(int64(262143))}}, + {"request > 64MiB", OutputGuardSettingsUpdate{MaxRequestBytes: ptr(int64(67108865))}}, + {"tool > channel", OutputGuardSettingsUpdate{MaxToolArgumentBytes: ptr(int64(3000000)), MaxChannelBytes: ptr(int64(2097152))}}, + {"channel > request", OutputGuardSettingsUpdate{MaxChannelBytes: ptr(int64(5000000)), MaxRequestBytes: ptr(int64(4194304))}}, + } + + for _, tc := range invalidCases { + _, err := db.UpdateOutputGuardSettings(ctx, tc.update) + if !errors.Is(err, ErrInvalidOutputGuardSettings) { + t.Fatalf("[%s] want ErrInvalidOutputGuardSettings, got %v", tc.name, err) + } + } + + after, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatal(err) + } + if after.ConfigGeneration != before.ConfigGeneration || after.MaxToolArgumentBytes != before.MaxToolArgumentBytes { + t.Fatalf("invalid update didn't rollback: before=%+v, after=%+v", before, after) + } + }) +} + +func TestOutputGuardSettingsNarrowUpdateNotOverwrittenBySystemSettings(t *testing.T) { + runOnAllOutputGuardDBDrivers(t, func(t *testing.T, db *DB) { + ctx := context.Background() + toolBytes, channelBytes, requestBytes := int64(262144), int64(2097152), int64(4194304) + disabled := false + committed, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + Enabled: &disabled, + MaxToolArgumentBytes: &toolBytes, + MaxChannelBytes: &channelBytes, + MaxRequestBytes: &requestBytes, + }) + if err != nil { + t.Fatalf("UpdateOutputGuardSettings error = %v", err) + } + + if err := db.UpdateSystemSettings(ctx, &SystemSettings{SiteName: "stale full update"}); err != nil { + t.Fatalf("UpdateSystemSettings error = %v", err) + } + + got, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("GetOutputGuardSettings error = %v", err) + } + if got.Enabled == nil || *got.Enabled != false || got.MaxToolArgumentBytes != committed.MaxToolArgumentBytes { + t.Fatalf("OutputGuardSettings overwritten by UpdateSystemSettings: got %+v, want %+v", got, committed) + } + }) +} + +func TestUpdateSystemSettingsWithOutputGuardRollsBackOnGuardValidationFailure(t *testing.T) { + runOnAllOutputGuardDBDrivers(t, func(t *testing.T, db *DB) { + ctx := context.Background() + if err := db.UpdateSystemSettings(ctx, &SystemSettings{SiteName: "before-rollback"}); err != nil { + t.Fatalf("seed system settings: %v", err) + } + beforeSettings, err := db.GetSystemSettings(ctx) + if err != nil || beforeSettings == nil { + t.Fatalf("GetSystemSettings before = %+v, err = %v", beforeSettings, err) + } + beforeGuard, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("GetOutputGuardSettings before = %v", err) + } + + invalidToolBytes := int64(1) + _, err = db.UpdateSystemSettingsWithOutputGuard(ctx, &SystemSettings{SiteName: "must-roll-back"}, OutputGuardSettingsUpdate{ + MaxToolArgumentBytes: &invalidToolBytes, + }) + if !errors.Is(err, ErrInvalidOutputGuardSettings) { + t.Fatalf("expected ErrInvalidOutputGuardSettings, got %v", err) + } + + afterSettings, err := db.GetSystemSettings(ctx) + if err != nil || afterSettings == nil { + t.Fatalf("GetSystemSettings after = %+v, err = %v", afterSettings, err) + } + if afterSettings.SiteName != beforeSettings.SiteName { + t.Fatalf("system settings committed despite guard failure: before site=%q after site=%q", beforeSettings.SiteName, afterSettings.SiteName) + } + + afterGuard, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("GetOutputGuardSettings after = %v", err) + } + if afterGuard.ConfigGeneration != beforeGuard.ConfigGeneration || + afterGuard.MaxToolArgumentBytes != beforeGuard.MaxToolArgumentBytes || + afterGuard.MaxChannelBytes != beforeGuard.MaxChannelBytes || + afterGuard.MaxRequestBytes != beforeGuard.MaxRequestBytes || + afterGuard.MaxToolArgumentDeltas != beforeGuard.MaxToolArgumentDeltas { + t.Fatalf("guard settings committed despite rollback: before=%+v after=%+v", beforeGuard, afterGuard) + } + }) +} + +func TestOutputGuardSettingsGenerationOverflowRollback(t *testing.T) { + runOnAllOutputGuardDBDrivers(t, func(t *testing.T, db *DB) { + ctx := context.Background() + if _, err := db.conn.ExecContext(ctx, ` + INSERT INTO system_settings (id, output_guard_config_generation) + VALUES (1, $1) + ON CONFLICT (id) DO UPDATE SET output_guard_config_generation = $1 + `, int64(math.MaxInt64)); err != nil { + t.Fatalf("seed max generation: %v", err) + } + + toolBytes := int64(65536) + _, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + MaxToolArgumentBytes: &toolBytes, + }) + if !errors.Is(err, ErrOutputGuardGenerationOverflow) { + t.Fatalf("expected ErrOutputGuardGenerationOverflow, got %v", err) + } + + got, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatal(err) + } + if got.ConfigGeneration != math.MaxInt64 { + t.Fatalf("generation after overflow = %d, want math.MaxInt64", got.ConfigGeneration) + } + }) +} + +func TestOutputGuardSettingsConcurrentDisjointUpdatesDoNotLoseChanges(t *testing.T) { + runOnAllOutputGuardDBDrivers(t, func(t *testing.T, db *DB) { + ctx := context.Background() + var wg sync.WaitGroup + errCh := make(chan error, 10) + + for i := 0; i < 10; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + tb, cb, rb := int64(131072+(idx+1)*1024), int64(2097152+(idx+1)*1024), int64(4194304+(idx+1)*1024) + _, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + MaxToolArgumentBytes: &tb, + MaxChannelBytes: &cb, + MaxRequestBytes: &rb, + }) + if err != nil { + errCh <- err + } + }(i) + } + + wg.Wait() + close(errCh) + for err := range errCh { + t.Fatalf("concurrent update failed: %v", err) + } + + got, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatal(err) + } + if got.ConfigGeneration < 11 { + t.Fatalf("expected generation >= 11 after 10 concurrent updates, got %d", got.ConfigGeneration) + } + }) +} + +func TestOutputGuardSettingsRestoreAllDefaults(t *testing.T) { + runOnAllOutputGuardDBDrivers(t, func(t *testing.T, db *DB) { + ctx := context.Background() + disabled := false + customTool, customDeltas, customChannel, customRequest := int64(524288), 8192, int64(4194304), int64(8388608) + _, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + Enabled: &disabled, + MaxToolArgumentBytes: &customTool, + MaxToolArgumentDeltas: &customDeltas, + MaxChannelBytes: &customChannel, + MaxRequestBytes: &customRequest, + }) + if err != nil { + t.Fatalf("setup custom settings error: %v", err) + } + + def := DefaultOutputGuardSettings() + restored, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + Enabled: def.Enabled, + MaxToolArgumentBytes: &def.MaxToolArgumentBytes, + MaxToolArgumentDeltas: &def.MaxToolArgumentDeltas, + MaxChannelBytes: &def.MaxChannelBytes, + MaxRequestBytes: &def.MaxRequestBytes, + }) + if err != nil { + t.Fatalf("restore error: %v", err) + } + + if restored.Enabled == nil || *restored.Enabled != false || + restored.MaxToolArgumentBytes != 262144 || restored.MaxToolArgumentDeltas != 4096 || + restored.MaxChannelBytes != 2097152 || restored.MaxRequestBytes != 4194304 { + t.Fatalf("restored mismatch: %+v", restored) + } + }) +} + +func ptr[T any](v T) *T { return &v } diff --git a/database/postgres.go b/database/postgres.go index abf6ed52a..9feabfdf9 100644 --- a/database/postgres.go +++ b/database/postgres.go @@ -1377,6 +1377,12 @@ func (db *DB) migrate(ctx context.Context) error { response_cache_reconstruct_max_bytes BIGINT NOT NULL DEFAULT 67108864, response_cache_write_policy VARCHAR(20) NOT NULL DEFAULT 'always', response_cache_config_generation BIGINT NOT NULL DEFAULT 1, + output_guard_enabled BOOLEAN DEFAULT FALSE, + output_guard_max_tool_argument_bytes BIGINT NOT NULL DEFAULT 262144, + output_guard_max_tool_argument_deltas INT NOT NULL DEFAULT 4096, + output_guard_max_channel_bytes BIGINT NOT NULL DEFAULT 2097152, + output_guard_max_request_bytes BIGINT NOT NULL DEFAULT 4194304, + output_guard_config_generation BIGINT NOT NULL DEFAULT 1, models_list_read_max_bytes BIGINT NOT NULL DEFAULT 8388608 ); CREATE TABLE IF NOT EXISTS api_key_model_request_counters ( @@ -1565,7 +1571,6 @@ func (db *DB) migrate(ctx context.Context) error { ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS ignore_usage_limit_status BOOLEAN DEFAULT FALSE; ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS auto_reset_credits_enabled BOOLEAN DEFAULT FALSE; ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS auto_reset_credits_on_exhaustion_enabled BOOLEAN DEFAULT FALSE; - ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS codex_unified_client_identity_enabled BOOLEAN DEFAULT FALSE; ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS auto_reset_credits_before_expiry_min INT DEFAULT 60; ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS auto_activate_5h_window_enabled BOOLEAN DEFAULT FALSE; ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS utls_shutdown_timeout_minutes INT DEFAULT 30; @@ -1583,6 +1588,12 @@ func (db *DB) migrate(ctx context.Context) error { ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS oauth_model_cooldown_seconds INT NOT NULL DEFAULT 300; ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS oauth_model_cooldown_backoff_enabled BOOLEAN NOT NULL DEFAULT TRUE; ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS oauth_unsupported_model_cooldown_seconds INT NOT NULL DEFAULT 1800; + ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_enabled BOOLEAN DEFAULT FALSE; + ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_max_tool_argument_bytes BIGINT NOT NULL DEFAULT 262144; + ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_max_tool_argument_deltas INT NOT NULL DEFAULT 4096; + ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_max_channel_bytes BIGINT NOT NULL DEFAULT 2097152; + ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_max_request_bytes BIGINT NOT NULL DEFAULT 4194304; + ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_config_generation BIGINT NOT NULL DEFAULT 1; ALTER TABLE account_groups ADD COLUMN IF NOT EXISTS auto_pause_5h_threshold DOUBLE PRECISION DEFAULT 0; ALTER TABLE account_groups ADD COLUMN IF NOT EXISTS auto_pause_7d_threshold DOUBLE PRECISION DEFAULT 0; @@ -2454,7 +2465,6 @@ type SystemSettings struct { CodexUserAgentConfig string CodexTelemetryEnabled bool CodexTelemetryTimingDebug bool - CodexUnifiedClientIdentityEnabled bool // 网关自发维护请求统一使用配置的客户端身份(默认关闭,issue #774) CodexImagesMainModel string // 空值沿用部署默认的生图文本驱动模型 UsageLogMode string UsageLogBatchSize int @@ -2776,7 +2786,6 @@ func (db *DB) GetSystemSettings(ctx context.Context) (*SystemSettings, error) { COALESCE(codex_oauth_keepalive_enabled, false), COALESCE(codex_telemetry_timing_debug, false), COALESCE(auto_reset_credits_on_exhaustion_enabled, false), - COALESCE(codex_unified_client_identity_enabled, false), COALESCE(codex_ws_downstream_keepalive_slots, 8) FROM system_settings WHERE id = 1 `).Scan( @@ -2866,7 +2875,6 @@ func (db *DB) GetSystemSettings(ctx context.Context) (*SystemSettings, error) { &s.CodexOAuthKeepaliveEnabled, &s.CodexTelemetryTimingDebug, &s.AutoResetCreditsOnExhaustionEnabled, - &s.CodexUnifiedClientIdentityEnabled, &s.CodexWSDownstreamKeepaliveSlots, ) if errors.Is(err, sql.ErrNoRows) { @@ -3024,10 +3032,22 @@ func NormalizeCodexFingerprintDefaultMode(mode string) string { } } +// systemSettingsExecer 让同一份 system_settings upsert 既能直接在连接上执行, +// 也能在事务内执行,供需要原子提交多份设置的调用方复用。 +type systemSettingsExecer interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + // UpdateSystemSettings 更新全局设置(upsert:无行时自动插入)。 // codex_synced_cli_version 与 model_pricing_* 由各自的窄更新独立维护;冲突更新时 // 保留数据库当前值,避免管理员保存其他设置时回滚后台同步刚写入的数据。 func (db *DB) UpdateSystemSettings(ctx context.Context, s *SystemSettings) error { + return updateSystemSettingsWithExecer(ctx, db.conn, s) +} + +// updateSystemSettingsWithExecer 执行全局设置 upsert。它不关心调用方是 +// *sql.DB 还是 *sql.Tx,由调用方决定是否与其他写入共享事务。 +func updateSystemSettingsWithExecer(ctx context.Context, execer systemSettingsExecer, s *SystemSettings) error { reasoningEffortModels := strings.TrimSpace(s.ReasoningEffortModels) if reasoningEffortModels == "" { reasoningEffortModels = "[]" @@ -3046,7 +3066,7 @@ func (db *DB) UpdateSystemSettings(ctx context.Context, s *SystemSettings) error if testContent == "" { testContent = "hi" } - _, err := db.conn.ExecContext(ctx, ` + _, err := execer.ExecContext(ctx, ` INSERT INTO system_settings ( id, site_name, site_logo, max_concurrency, global_rpm, test_model, test_content, test_concurrency, proxy_url, pg_max_conns, redis_pool_size, auto_clean_unauthorized, auto_clean_rate_limited, admin_secret, auto_clean_full_usage, proxy_pool_enabled, @@ -3133,10 +3153,9 @@ func (db *DB) UpdateSystemSettings(ctx context.Context, s *SystemSettings) error codex_oauth_keepalive_enabled, codex_telemetry_timing_debug, auto_reset_credits_on_exhaustion_enabled, - codex_unified_client_identity_enabled, codex_ws_downstream_keepalive_slots ) - VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61, $62, $63, $64, $65, $66, $67, $68, $69, $70, $71, $72, $73, $74, $75, $76, $77, $78, $79, $80, $81, $82, $83, $84, $85, $86, $87, $88, $89, $90, $91, $92, $93, $94, $95, $96, $97, $98, $99, $100, $101, $102, $103, $104, $105, $106, $107, $108, $109, $110, $111, $112, $113, $114, $115, $116, $117, $118, $119, $120, $121, $122, $123, $124, $125, $126, $127, $128, $131) + VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61, $62, $63, $64, $65, $66, $67, $68, $69, $70, $71, $72, $73, $74, $75, $76, $77, $78, $79, $80, $81, $82, $83, $84, $85, $86, $87, $88, $89, $90, $91, $92, $93, $94, $95, $96, $97, $98, $99, $100, $101, $102, $103, $104, $105, $106, $107, $108, $109, $110, $111, $112, $113, $114, $115, $116, $117, $118, $119, $120, $121, $122, $123, $124, $125, $126, $127, $130) ON CONFLICT (id) DO UPDATE SET site_name = EXCLUDED.site_name, site_logo = EXCLUDED.site_logo, @@ -3176,10 +3195,10 @@ func (db *DB) UpdateSystemSettings(ctx context.Context, s *SystemSettings) error prompt_filter_log_matches = EXCLUDED.prompt_filter_log_matches, prompt_filter_max_text_length = EXCLUDED.prompt_filter_max_text_length, prompt_filter_sensitive_words = EXCLUDED.prompt_filter_sensitive_words, - prompt_filter_custom_patterns = CASE WHEN $129 THEN system_settings.prompt_filter_custom_patterns ELSE EXCLUDED.prompt_filter_custom_patterns END, + prompt_filter_custom_patterns = CASE WHEN $128 THEN system_settings.prompt_filter_custom_patterns ELSE EXCLUDED.prompt_filter_custom_patterns END, prompt_filter_disabled_patterns = EXCLUDED.prompt_filter_disabled_patterns, prompt_filter_review_enabled = EXCLUDED.prompt_filter_review_enabled, - prompt_filter_review_api_key = CASE WHEN $130 THEN system_settings.prompt_filter_review_api_key ELSE EXCLUDED.prompt_filter_review_api_key END, + prompt_filter_review_api_key = CASE WHEN $129 THEN system_settings.prompt_filter_review_api_key ELSE EXCLUDED.prompt_filter_review_api_key END, prompt_filter_review_base_url = EXCLUDED.prompt_filter_review_base_url, prompt_filter_review_model = EXCLUDED.prompt_filter_review_model, prompt_filter_review_timeout_seconds = EXCLUDED.prompt_filter_review_timeout_seconds, @@ -3262,7 +3281,6 @@ func (db *DB) UpdateSystemSettings(ctx context.Context, s *SystemSettings) error codex_oauth_keepalive_enabled = EXCLUDED.codex_oauth_keepalive_enabled, codex_telemetry_timing_debug = EXCLUDED.codex_telemetry_timing_debug, auto_reset_credits_on_exhaustion_enabled = EXCLUDED.auto_reset_credits_on_exhaustion_enabled, - codex_unified_client_identity_enabled = EXCLUDED.codex_unified_client_identity_enabled, codex_ws_downstream_keepalive_slots = EXCLUDED.codex_ws_downstream_keepalive_slots `, NormalizeSiteName(s.SiteName), strings.TrimSpace(s.SiteLogo), s.MaxConcurrency, s.GlobalRPM, s.TestModel, testContent, s.TestConcurrency, s.ProxyURL, s.PgMaxConns, s.RedisPoolSize, @@ -3320,7 +3338,6 @@ func (db *DB) UpdateSystemSettings(ctx context.Context, s *SystemSettings) error s.CodexOAuthKeepaliveEnabled, s.CodexTelemetryTimingDebug, s.AutoResetCreditsOnExhaustionEnabled, - s.CodexUnifiedClientIdentityEnabled, s.PreservePromptFilterCustomPatterns, s.PreservePromptFilterReviewAPIKey, NormalizeCodexWSDownstreamKeepaliveSlots(s.CodexWSDownstreamKeepaliveSlots)) return err diff --git a/database/scheduled_connection_tests.go b/database/scheduled_connection_tests.go new file mode 100644 index 000000000..4f52b42b6 --- /dev/null +++ b/database/scheduled_connection_tests.go @@ -0,0 +1,53 @@ +package database + +import "context" + +// ListScheduledConnectionTestConfigs projects only non-secret plan data; token +// blobs and the runtime scheduler are not scanned into the maintenance worker. +type ScheduledConnectionTestConfig struct { + AccountID int64 + Config string +} + +func (db *DB) ListScheduledConnectionTestConfigs(ctx context.Context) ([]ScheduledConnectionTestConfig, error) { + expr := "credentials->>'scheduled_connection_tests'" + upstream := "LOWER(COALESCE(credentials->>'upstream_type',''))" + if db.isSQLite() { + expr = "json_extract(credentials,'$.scheduled_connection_tests')" + upstream = "LOWER(COALESCE(json_extract(credentials,'$.upstream_type'),''))" + } + rows, err := db.conn.QueryContext(ctx, "SELECT id,"+expr+" FROM accounts WHERE status<>'deleted' AND COALESCE(error_message,'')<>'deleted' AND COALESCE(enabled,true) AND "+upstream+" IN ('','codex','openai_responses') AND COALESCE("+expr+",'')<>'' ORDER BY id") + if err != nil { + return nil, err + } + defer rows.Close() + result := []ScheduledConnectionTestConfig{} + for rows.Next() { + var row ScheduledConnectionTestConfig + if err := rows.Scan(&row.AccountID, &row.Config); err != nil { + return nil, err + } + result = append(result, row) + } + return result, rows.Err() +} + +func (db *DB) ListOneShotMaintenanceJobs(ctx context.Context, id int64, prefix string, limit int) ([]MaintenanceJob, error) { + if limit < 1 || limit > 100 { + limit = 10 + } + rows, err := db.conn.QueryContext(ctx, `SELECT entity_id,job_kind,due_at,lease_owner,lease_until,attempts,last_error FROM maintenance_jobs WHERE entity_id=$1 AND substr(job_kind,1,length($2))=$2 ORDER BY due_at DESC LIMIT $3`, id, prefix, limit) + if err != nil { + return nil, err + } + defer rows.Close() + result := []MaintenanceJob{} + for rows.Next() { + job, err := scanMaintenanceJob(rows) + if err != nil { + return nil, err + } + result = append(result, job) + } + return result, rows.Err() +} diff --git a/database/sqlite.go b/database/sqlite.go index ebabd294f..da7f30138 100644 --- a/database/sqlite.go +++ b/database/sqlite.go @@ -382,7 +382,6 @@ func (db *DB) migrateSQLite(ctx context.Context) error { ignore_usage_limit_status INTEGER DEFAULT 0, auto_reset_credits_enabled INTEGER DEFAULT 0, auto_reset_credits_on_exhaustion_enabled INTEGER DEFAULT 0, - codex_unified_client_identity_enabled INTEGER DEFAULT 0, auto_reset_credits_before_expiry_min INTEGER DEFAULT 60, auto_activate_5h_window_enabled INTEGER DEFAULT 0, utls_shutdown_timeout_minutes INTEGER DEFAULT 30, @@ -398,7 +397,13 @@ func (db *DB) migrateSQLite(ctx context.Context) error { oauth_model_cooldown_mode TEXT NOT NULL DEFAULT 'adaptive', oauth_model_cooldown_seconds INTEGER NOT NULL DEFAULT 300, oauth_model_cooldown_backoff_enabled INTEGER NOT NULL DEFAULT 1, - oauth_unsupported_model_cooldown_seconds INTEGER NOT NULL DEFAULT 1800 + oauth_unsupported_model_cooldown_seconds INTEGER NOT NULL DEFAULT 1800, + output_guard_enabled INTEGER DEFAULT 0, + output_guard_max_tool_argument_bytes INTEGER NOT NULL DEFAULT 262144, + output_guard_max_tool_argument_deltas INTEGER NOT NULL DEFAULT 4096, + output_guard_max_channel_bytes INTEGER NOT NULL DEFAULT 2097152, + output_guard_max_request_bytes INTEGER NOT NULL DEFAULT 4194304, + output_guard_config_generation INTEGER NOT NULL DEFAULT 1 );`, modelCapabilitiesSchema, daybreakSchema, @@ -709,7 +714,6 @@ func (db *DB) migrateSQLite(ctx context.Context) error { {"system_settings", "ignore_usage_limit_status", "INTEGER DEFAULT 0"}, {"system_settings", "auto_reset_credits_enabled", "INTEGER DEFAULT 0"}, {"system_settings", "auto_reset_credits_on_exhaustion_enabled", "INTEGER DEFAULT 0"}, - {"system_settings", "codex_unified_client_identity_enabled", "INTEGER DEFAULT 0"}, {"system_settings", "auto_reset_credits_before_expiry_min", "INTEGER DEFAULT 60"}, {"system_settings", "auto_activate_5h_window_enabled", "INTEGER DEFAULT 0"}, {"system_settings", "utls_shutdown_timeout_minutes", "INTEGER DEFAULT 30"}, @@ -726,6 +730,12 @@ func (db *DB) migrateSQLite(ctx context.Context) error { {"system_settings", "oauth_model_cooldown_seconds", "INTEGER NOT NULL DEFAULT 300"}, {"system_settings", "oauth_model_cooldown_backoff_enabled", "INTEGER NOT NULL DEFAULT 1"}, {"system_settings", "oauth_unsupported_model_cooldown_seconds", "INTEGER NOT NULL DEFAULT 1800"}, + {"system_settings", "output_guard_enabled", "INTEGER DEFAULT 0"}, + {"system_settings", "output_guard_max_tool_argument_bytes", "INTEGER NOT NULL DEFAULT 262144"}, + {"system_settings", "output_guard_max_tool_argument_deltas", "INTEGER NOT NULL DEFAULT 4096"}, + {"system_settings", "output_guard_max_channel_bytes", "INTEGER NOT NULL DEFAULT 2097152"}, + {"system_settings", "output_guard_max_request_bytes", "INTEGER NOT NULL DEFAULT 4194304"}, + {"system_settings", "output_guard_config_generation", "INTEGER NOT NULL DEFAULT 1"}, {"system_settings", "max_retries", "INTEGER DEFAULT 2"}, {"system_settings", "max_rate_limit_retries", "INTEGER DEFAULT 1"}, {"system_settings", "allow_remote_migration", "INTEGER DEFAULT 0"}, diff --git a/docker-compose.pgredis2004.yml b/docker-compose.pgredis2004.yml index a0abe026b..369c63567 100644 --- a/docker-compose.pgredis2004.yml +++ b/docker-compose.pgredis2004.yml @@ -12,7 +12,7 @@ services: CODEX_REQUEST_MEMORY_BUDGET_MB: "${CODEX_REQUEST_MEMORY_BUDGET_MB:-}" ADMIN_SECRET: "123456" CODEX_UPSTREAM_TRANSPORT: "http" - CODEX_TRANSPORT_MODE: "standard" + CODEX_TRANSPORT_MODE: "utls_chrome" IMAGE_ASSET_DIR: "/data/images" LOG_DIR: "/app/logs" DATABASE_DRIVER: "postgres" diff --git a/docs/API.md b/docs/API.md index bb450cd80..36e80d00c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -244,8 +244,8 @@ Messages 的 `tool_use.input` 必须使用对象,因此自由文本工具输 | include | array | 否 | 包含的额外字段 | | previous_response_id | string | 否 | 上一响应 ID,用于上下文连续 | -`previous_response_id` 的上下文先查当前进程的有界 L1。本地与 Redis 共享上下文的默认绝对 TTL 为 45 分钟,覆盖聊天上游连接 30 分钟空闲回收后的恢复窗口,仍受缓存容量和账号兼容性限制。已认证请求按 API Key ID 隔离;未配置任何 API Key、显式启用 `CODEX_ALLOW_ANONYMOUS=true` 后放行的请求共用 `anon` 命名空间。Redis 模式在 L1 未命中时可从共享后端重建;后端值未超过重建上限但超过 L1 准入预算时仍可服务本次请求,只是不提升到 L1。Memory 模式没有共享 response context 后备,依赖上下文被判定为超限、已淘汰或缺失时可能返回 HTTP `409 response_context_unavailable`。共享后端暂时不可用且请求依赖该上下文时可能返回 HTTP `503 service_unavailable`。如果账号池存在可用的原生 OpenAI Responses relay 后备,网关可保留原始 `previous_response_id` 交给其上游恢复;其他仅做协议翻译的账号不具备这一后备能力。原生 Codex HTTP 的每个非空父引用都要求当前归属的本地或共享快照证明;普通文本父轮不新增 HTTP 会话存储,应重发完整历史并省略父 ID。重叠工具历史仅在能证明完整父前缀重放时去重,否则返回 409。客户端原生 Responses WebSocket 入口在上游连接正常时保留 `previous_response_id` 并只发送当轮增量,轮次开始时保留仍有效的 L1 祖先引用,快照合并与序列化在响应成功提交后才执行并写入本地缓存,共享后端(Redis)写入在后台完成,不占首字路径。缓存会收集 `response.output_item.done`,因此最终 `response.output` 为空时也能保留消息、`phase`、工具调用/结果及 Lite `additional_tools` 声明。`response.completed` 和 `response.incomplete` 成功提交后均可写入,仍受写入策略、TTL 和容量限制约束;按需写入模式下,`store` 未显式为 `false` 的 WebSocket 会话从根轮起即有写入资格;显式 `store:false` 的原生 WS 会话(如 Codex CLI)使用隔离的完整快照及共享后端恢复,不表示网关完全不缓存历史;仍受 TTL、容量和完整性限制。续链失效、换号或转为 HTTP 时,只有能够恢复所需上下文才继续发送;缺失、超限或不可移植的加密状态返回 `response_context_unavailable` 错误帧,共享后端故障返回 `service_unavailable`,此类恢复失败终止本轮并返回错误帧,命名流附带 `stream_id`;连接仍可接收新的完整上下文请求。协议、鉴权或连接资源错误仍可关闭连接。客户端应重发完整上下文并开始新的响应链。 -祖先在轮次开始前已过期或被淘汰时,网关不会把增量伪装成完整快照;该链后续健康轮也不能自行补全历史。缓存 TTL 为 45 分钟,Memory 模式重启会丢失缓存。设置环境变量 `CODEX_WS_CONTINUATION_FAIL_OPEN=true` 可退回旧行为:上下文不可恢复时剥离 `previous_response_id` 后按原样转发,上游将看不到历史;这次有损降级产生的响应不写回放缓存,关闭逃生阀后也不会信任残缺快照。 +`previous_response_id` 的上下文先查当前进程的有界 L1。本地与 Redis 共享上下文的默认绝对 TTL 为 45 分钟,覆盖聊天上游连接 30 分钟空闲回收后的恢复窗口,仍受缓存容量和账号兼容性限制。已认证请求按 API Key ID 隔离;未配置任何 API Key、显式启用 `CODEX_ALLOW_ANONYMOUS=true` 后放行的请求共用 `anon` 命名空间。Redis 模式在 L1 未命中时可从共享后端重建;后端值未超过重建上限但超过 L1 准入预算时仍可服务本次请求,只是不提升到 L1。Memory 模式在关闭磁盘持久层时没有 response context 后备,依赖上下文被判定为超限、已淘汰或缺失时可能返回 HTTP `409 previous_response_not_found`。共享后端暂时不可用且请求依赖该上下文时可能返回 HTTP `503 service_unavailable`。如果账号池存在可用的原生 OpenAI Responses relay 后备,网关可保留原始 `previous_response_id` 交给其上游恢复;其他仅做协议翻译的账号不具备这一后备能力。原生 Codex HTTP 的每个非空父引用都要求当前归属的本地或共享快照证明;普通文本父轮不新增 HTTP 会话存储,应重发完整历史并省略父 ID。重叠工具历史仅在能证明完整父前缀重放时去重,否则返回 409。客户端原生 Responses WebSocket 入口在上游连接正常时保留 `previous_response_id` 并只发送当轮增量,轮次开始时保留仍有效的 L1 祖先引用,快照合并与序列化在响应成功提交后才执行并写入本地缓存,共享后端(Redis)写入在后台完成,不占首字路径。缓存会收集 `response.output_item.done`,因此最终 `response.output` 为空时也能保留消息、`phase`、工具调用/结果及 Lite `additional_tools` 声明。`response.completed` 和 `response.incomplete` 成功提交后均可写入,仍受写入策略、TTL 和容量限制约束;按需写入模式下,`store` 未显式为 `false` 的 WebSocket 会话从根轮起即有写入资格;显式 `store:false` 的原生 WS 会话(如 Codex CLI)使用隔离的完整快照及共享后端恢复,不表示网关完全不缓存历史;仍受 TTL、容量和完整性限制。续链失效、换号或转为 HTTP 时,只有能够恢复所需上下文才继续发送;缺失、超限或不可移植的加密状态返回 `previous_response_not_found` 错误帧,共享后端故障返回 `service_unavailable`,此类恢复失败终止本轮并返回错误帧,命名流附带 `stream_id`;连接仍可接收新的完整上下文请求。协议、鉴权或连接资源错误仍可关闭连接。客户端应重发完整上下文并开始新的响应链。 +祖先在轮次开始前已过期或被淘汰时,网关不会把增量伪装成完整快照;该链后续健康轮也不能自行补全历史。L1 缓存 TTL 为 45 分钟;Memory 模式重启会丢失原生 WebSocket 快照。设置环境变量 `CODEX_WS_CONTINUATION_FAIL_OPEN=true` 可退回旧行为:上下文不可恢复时剥离 `previous_response_id` 后按原样转发,上游将看不到历史;这次有损降级产生的响应不写回放缓存,关闭逃生阀后也不会信任残缺快照。 对于原生 WebSocket 的结构化输出,`gpt-6-astra` 和 `gpt-5.6-luna` 在显式启用 Responses Lite 时保留 JSON Schema 的 `minLength` / `maxLength`。Lite 信号可来自 `client_metadata.ws_request_header_x_openai_internal_codex_responses_lite=true`、`X-OpenAI-Internal-Codex-Responses-Lite: true` 请求头,或由 Payload Rules 注入该元数据标记。该放行仅用于结构化输出;已有工具参数清洗继续使用保守规则。长度约束在入口准备阶段暂时保留,最终出站前才根据最终模型、规则改写后的 Lite 信号、账号 Lite 能力和实际传输统一处理。其他模型、最终未启用 Lite、HTTP 和 Compact 请求沿用原有清洗策略,HTTP 降级请求不会携带不适用的约束。 @@ -2148,7 +2148,6 @@ curl -X DELETE "http://localhost:8080/api/admin/account-groups/1?force=true" \ "continuous_retry_error_codes": [], "continuous_retry_max_duration_seconds": 600, "codex_fingerprint_default_mode": "off", - "codex_unified_client_identity_enabled": false, "scheduler_mode": "round_robin", "allow_remote_migration": false, "database_driver": "postgres", @@ -2196,7 +2195,7 @@ curl -X DELETE "http://localhost:8080/api/admin/account-groups/1?force=true" \ `codex_fingerprint_default_mode`(`off`/`device`/`session`/`full`,默认 `off`)是新导入或新建 Codex 账号默认盖上的设备指纹收敛档位,只影响之后新加入的账号;已有账号档位不变,入库后仍可在账号级单独调整。非法取值返回 HTTP 400。 -`codex_unified_client_identity_enabled`(默认 `false`)控制网关自发的 Codex 维护请求是否与对话请求使用同一份客户端身份。开启后,wham 用量查询、重置券查询与消耗、每日用量、token 明细、订阅同步(Codex 形态)、模型清单(含下游透传)以及 OpenAI Responses 中转账号的模型发现,都改用与对话出站相同的解析:按 `codex_user_agent_config` 生成(号池模式按账号抽取画像),`Originator` 跟随生成的 UA,最后套用账号自定义头里的 `User-Agent` / `Originator` / `Version`。模型清单的 `client_version` 查询参数、`Version` 头与 UA 版本段取同一版本;中转账号的 installation id 改为按中转凭据派生,对话请求体与模型发现请求头取同一值。维护请求不会新增原本不发送的头(例如 wham 探针不补 `Version` 或 `X-Codex-Installation-Id`)。订阅同步被挑战时的浏览器形态回退、邀请接口与外部版本/指纹库同步不受影响。配置身份解析失败(如 `auto` 模式下版本低于最低要求)时回退内置 `codex-tui` 身份并限频记日志。该开关不改变对话请求的身份选择:`client_compat_mode` 不是 `force` 时,官方 Codex 客户端的对话请求仍透传其自身 UA,要让账号对上游只呈现一份身份需同时使用 `force`。 +Codex 维护请求默认与对话请求共用客户端身份解析和账号自定义身份头,设置中不再提供单独开关。模型清单版本与解析出的客户端版本保持一致;身份解析失败时返回错误,不回退固定身份。维护请求仅设置各端点所需的头,不额外添加会话标识或 attestation。订阅同步的浏览器协议回退保持独立。兼容模式允许官方客户端透传时,后台请求使用配置身份,不追踪或复用某个下游客户端的实时身份。 `max_retries`、`max_rate_limit_retries` 与 `codex_ws_silent_max_retries` 是原有的有限重试预算,管理界面和 API 范围均为 `0` 到 `10`(`0` 禁用对应预算)。需要持续重试时使用独立的 `continuous_retry_enabled` 开关;它不会改变这些有限预算的含义。开关打开后,可在 `continuous_retry_categories` 选择 `transport`、`http_429`、`http_4xx`、`http_5xx`、`stream_error`、`response_failed`、`context_error`,并用 `continuous_retry_status_codes`(例如 `[403,404,501]`)或 `continuous_retry_error_codes`(例如 `["rate_limited","context_length_exceeded"]`)精确追加匹配。类别、状态码、错误代码任一命中即可进入持续重试;403、404、上下文错误及“全部 `response.failed`”默认不选中(501 已由默认的 `http_5xx` 类别覆盖)。普通自选模式不会把结构化安全策略拒绝升级为无限重试。永久额度/余额错误(如 `insufficient_quota`、`quota_exceeded`、`billing_hard_limit`、`billing_limit_reached`、`spend_limit`、`credit_balance`、`insufficient_balance`、`usage_limited`)不会因为通用类别进入无限循环;只有管理员明确选择对应额度错误代码,或明确启用下述超级开关时,才会进入持续重试。 @@ -2798,7 +2797,7 @@ curl -X DELETE http://localhost:8080/api/admin/images/jobs/1 \ | account_pool_concurrency_saturated | 匹配账号的并发窗口已满 | 稍后重试,或提高单账号并发上限 | | account_pool_usage_limit_reached | 账号池额度耗尽 | 等待冷却或添加新账号 | | rate_limit_exceeded | 限流触发 | 降低请求频率 | -| response_context_unavailable | `previous_response_id` 所需上下文不可用 | 重新发送完整上下文或开始新的响应链 | +| previous_response_not_found | `previous_response_id` 所需上下文不可用 | 重新发送完整上下文或开始新的响应链 | | service_unavailable | 依赖的共享上下文后端暂时不可用 | 退避后重试并检查 Redis 状态 | ### Responses 上下文不可用 @@ -2811,8 +2810,8 @@ curl -X DELETE http://localhost:8080/api/admin/images/jobs/1 \ ```json { "error": { - "code": "response_context_unavailable", - "message": "Previous response context is unavailable", + "code": "previous_response_not_found", + "message": "Previous response not found; resend the full conversation input without previous_response_id", "type": "invalid_request_error", "details": { "field": "previous_response_id", @@ -2989,3 +2988,7 @@ Retry-After: 3600 ### 可选的持久化生图队列 设置 `IMAGE_JOB_WORKERS=2` 可启用固定并发的数据库任务队列。启用后创建接口只返回 `job.id` 和 `job.status`;参考图在任务执行时读取。数据库增量迁移、内存限制、重启恢复及兼容性详见 [图片任务队列](IMAGE_JOB_QUEUE.md)。 + +### 自适应降智检测配置 + +`PUT /api/admin/intelligence-check/settings` 支持 `adaptive_enabled`、`pause_initial_seconds`、`pause_max_seconds`、`recovery_initial_seconds`、`recovery_min_seconds`。仍需提交当前 `revision` 和完整设置。字段语义、边界与兼容规则见 [自适应降智检测](INTELLIGENCE_BACKOFF.md)。检测 bars 增加 `probe_phase`、`next_interval_seconds`、`passed_streak`、`next_due_at`,用于展示退避或恢复确认进度。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cdbc91cbf..907d039b0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -565,7 +565,7 @@ previous_response_id 连续请求 三个数据库预算分别控制 L1 总量、L1 单条准入和共享后端重建上限。只有预算实际变化的事务提交才分配递增 generation,并立即应用到写入实例;同值或空更新不递增。其他实例每 5 秒读取一次,单次最多等待 3 秒,只应用更新的 generation。读取失败时保留最后一次有效配置,并记录同步错误供运维页展示。 -Memory 模式只有 L1。已知超限/淘汰,或依赖的必需上下文缺失/过期且没有 relay 后备时,Responses/Compact 连续请求可返回 HTTP `409 response_context_unavailable`。Redis 值损坏或超过重建上限且没有 relay 后备时同样返回 409;共享后端传输故障且请求确实依赖该上下文时可返回 HTTP `503`。Redis 中未超过重建上限的上下文不会仅因超过 L1 准入预算而失败。 +Memory 缓存配合 SQLite 文件主库时默认以本地磁盘持久层(`cache/durable_response_context.go`)作为 L2:L1 淘汰、超过 L1 准入预算或进程重启后仍从磁盘恢复,原生 WebSocket 共享来源记录仍只在进程内。关闭持久层后只有 L1;已知超限/淘汰,或依赖的必需上下文缺失/过期且没有 relay 后备时,Responses/Compact 连续请求可返回 HTTP `409 previous_response_not_found`。Redis 值损坏或超过重建上限且没有 relay 后备时同样返回 409;共享后端传输故障且请求确实依赖该上下文时可返回 HTTP `503`。Redis 中未超过重建上限的上下文不会仅因超过 L1 准入预算而失败。 客户端原生 Responses WebSocket 在健康连接上保留 `previous_response_id`,向上游发送当轮增量;原连接丢失后的恢复仍依赖完整的本地或共享快照,受上述预算、完整性和 409/503 错误语义约束。HTTP Responses/Compact 的原生 Codex 路径必须在当前归属内重建父引用;原生 OpenAI Responses relay 可保留父 ID 交给自己的上游恢复。 diff --git a/docs/CODEX_ATTESTATION.md b/docs/CODEX_ATTESTATION.md index be3f82ad8..391340fa3 100644 --- a/docs/CODEX_ATTESTATION.md +++ b/docs/CODEX_ATTESTATION.md @@ -4,6 +4,10 @@ Codex 账号的 HTTP、Responses WebSocket、Live 建连和 sideband 共用一 `X-Oai-Attestation` 规则,适用于 Desktop、TUI、IDE、Exec、SDK 等客户端身份。 平台取自最终出站 User-Agent;服务器的操作系统只决定能否实际调用原生 DeviceCheck。 +OAuth 换码、令牌刷新和普通维护请求不调用对话的 attestation provider,也不复用对话证明。 +这与 Codex `rust-v0.166.0` 的登录客户端和默认 HTTP 头构建保持一致;令牌刷新只复用 +所选客户端的 UA、Originator,授权链接携带 Originator,换码使用独立客户端。 + ## 平台与失败状态 | 出站身份或原生结果 | 自动行为 | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index fafad73c5..a4f1a32c0 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -275,7 +275,19 @@ Responses 连续请求会按 `previous_response_id` 重建上下文。每个 Cod 设置页会把三个预算作为一个原子更新发送。管理 API 也支持只提交部分预算,服务端会在数据库事务中与当前值合并并校验,提交成功后才应用到本实例。固定边界不随这三个设置变化:最多 2,000 条、45 分钟绝对 TTL、每条最多 200 个 raw item。降低预算会立即收缩本地 L1,并可能淘汰已有上下文。 -Redis 模式会把 response context 保存到共享后端。后端值在重建上限内但超过 L1 单条或总量准入预算时,仍可服务当前请求,但不会提升到本地 L1。Memory 模式只保留本进程 L1,不存在第二份共享 response context;已知超限/淘汰,或依赖的必需上下文缺失/过期时,可能导致 HTTP `409 response_context_unavailable`。Redis 值损坏或超过重建上限且无法走 relay 后备时也可能返回 409。共享后端暂时不可用时,依赖该上下文且无法走 relay 后备的请求可能返回 HTTP `503`。 +Redis 模式会把 response context 保存到共享后端。后端值在重建上限内但超过 L1 单条或总量准入预算时,仍可服务当前请求,但不会提升到本地 L1。Memory 缓存配合 SQLite 文件主库时默认把 response context 同时写入本地磁盘持久层(见下节),L1 淘汰、超过 L1 准入预算或进程重启后仍可从磁盘恢复;关闭持久层后只保留本进程 L1,已知超限/淘汰,或依赖的必需上下文缺失/过期时,可能导致 HTTP `409 previous_response_not_found`。Redis 值损坏或超过重建上限且无法走 relay 后备时也可能返回 409。共享后端暂时不可用时,依赖该上下文且无法走 relay 后备的请求可能返回 HTTP `503`。 + +#### Memory 模式的磁盘持久层 + +Memory 驱动 + SQLite 主库时默认启用。上下文写入主库同目录下的独立文件 `response-context.db`,不写主库,也不进入主库备份;内容相同的历史条目只存一份(按 SHA-256 去重并引用计数),按最近访问时间滑动保留。持久层只是缓存,打不开时服务照常启动并退回纯 L1。 + +| 环境变量 | 默认 | 说明 | +| --- | --- | --- | +| `RESPONSE_CONTEXT_STORE_PATH` | 主库目录下的 `response-context.db` | 显式路径;`off`/`none`/`false`/`0` 关闭。仅支持 Memory 驱动;Redis 模式使用共享后端 | +| `RESPONSE_CONTEXT_STORE_TTL_HOURS` | `48` | 最短保留时长(1~720 小时),每次读取会顺延 | +| `RESPONSE_CONTEXT_STORE_MAX_MB` | `2048` | 去重后条目正文总量上限(64~65536 MB),超过时按最近最少访问淘汰到 90% | + +上下文不可用时,日志会输出一行 `[response-context] unavailable`,包含原因、查询结果、L1 占用与淘汰计数,可据此区分重启、淘汰和超限。 只有预算实际变化时才会分配并递增 generation;同值更新或空更新不会递增。当前实例在数据库提交后立即应用,其他实例每 5 秒轮询一次,只应用更新的 generation;单次读取最多等待 3 秒。同步失败时保留最后一次有效配置,并在运维页显示错误,后续轮询成功后自动恢复。 @@ -283,7 +295,7 @@ Redis 模式会把 response context 保存到共享后端。后端值在重建 Redis 原生 WS 快照将完整历史、账号 ID、凭证代际、兼容域和过期时间原子保存为同一记录。恢复加密 reasoning/compaction 时优先选择原账号,再核对凭证与兼容域;账号不兼容时不丢弃必要推理来强行续链。终态发送前登记共享提交屏障,其他实例等待快照提交后再读取;共享快照暂时不可用且 L1 无法恢复时返回可恢复的 `503`,完整 L1 命中不额外查询 Redis。上游实体连接与连接池仍属于原实例,Redis 分享上下文与协调状态,不迁移实体 WS。 -原连接丢失或上游明确拒绝旧 ID 时,网关在尚未交付内容或工具调用的条件下,最多恢复一次:移除 `previous_response_id` 并发送完整快照。加密推理/压缩项仅在原账号、凭证代次和兼容域均匹配时回放。祖先缺失、超限或兼容性无法确认时,返回 `status:409`、`code:previous_response_not_found`,供客户端重发完整输入;健康下游 WS 保持可用。Memory 模式下进程重启会丢失本地快照;Redis 模式可在原绝对 TTL 内跨实例或重启恢复共享快照。 +原连接丢失或上游明确拒绝旧 ID 时,网关在尚未交付内容或工具调用的条件下,最多恢复一次:移除 `previous_response_id` 并发送完整快照。加密推理/压缩项仅在原账号、凭证代次和兼容域均匹配时回放。祖先缺失、超限或兼容性无法确认时,返回 `status:409`、`code:previous_response_not_found`,供客户端重发完整输入;健康下游 WS 保持可用。原生 WebSocket 快照在 Memory 模式下仅保存在进程内;Redis 模式可在原绝对 TTL 内跨实例或重启恢复共享快照。 共享后端写入的异步与同步路径统一限制为最多 16 个在途写、64 MiB 在途逻辑正文、64 个等待者;在复制/编码之前获取额度,最多等待 5 秒。超过 64 MiB 的既有合法单条上下文可独占写入器,因此其逻辑上限为普通预算与最大在途单条的较大值,不会静默丢弃大快照。普通写入 I/O deadline 为 2 秒,关停同步写为 500 毫秒。饱和时响应收尾及同 WS 后续轮次可能等待写入额度;写失败后 L1 仍可服务,必须依赖该快照但 L1/共享后端均缺失时返回 503。 @@ -691,3 +703,7 @@ curl -H "X-Admin-Key: your-secret" http://localhost:8080/api/admin/ops/overview 管理设置 `codex_oauth_keepalive_enabled`(默认 `false`)允许惰性模式单独运行 Codex Token 续期。它使用现有 `background_refresh_interval_minutes` 巡检间隔和 AT 到期前 5 分钟的阈值,不改变额度冷却、不启用生成探针,也不影响 Claude、Grok 或 Antigravity 的刷新策略。普通模式本来就运行 Codex 续期,不依赖此开关。 Codex 刷新新增 `codex_oauth_refresh_attempts` 保护表,启动时自动创建,兼容 PostgreSQL 与 SQLite。表内只保存旧 RT 的 SHA-256 指纹、刷新操作 ID 和开始时间,不保存明文 Token。成功保存全部相关凭据后删除记录;结果不确定的记录保留,防止跨实例或重启后重复消费旧 RT。已有凭据无需重新导入;已经失效的授权需重新登录恢复。 + +## 自适应降智检测 + +降智检测支持可配置上限的指数退避、恢复加速与复发记忆。默认关闭,配置与状态迁移规则见 [自适应降智检测](INTELLIGENCE_BACKOFF.md)。 diff --git a/docs/INTELLIGENCE_BACKOFF.md b/docs/INTELLIGENCE_BACKOFF.md new file mode 100644 index 000000000..5478b392a --- /dev/null +++ b/docs/INTELLIGENCE_BACKOFF.md @@ -0,0 +1,43 @@ +# 自适应降智检测 + +自适应检测默认关闭,升级不会改变已有自动检测开关、恢复阈值或固定频率。可在「降智检测 → 频率与资源」启用,配置使用现有 settings API 和 JSON 存储,无需新增业务表。 + +## 配置 + +| JSON 字段 | 默认值 | 含义 | +| --- | --- | --- | +| `adaptive_enabled` | `false` | 开启自适应检测 | +| `pause_initial_seconds` | `1800` | 自动暂停后的初始间隔 | +| `pause_max_seconds` | `172800` | 自动暂停探测的最大间隔,可设到 30 天;不与正常间隔绑定 | +| `recovery_initial_seconds` | `300` | 首次通过后的确认间隔 | +| `recovery_min_seconds` | `120` | 确认间隔下限 | + +初始暂停间隔不得超过上限;恢复确认下限不得超过初始确认间隔,初始确认间隔不得超过初始暂停间隔。现有 `interval_seconds`、`retry_seconds`、`degrade_threshold`、`recover_threshold` 继续控制正常检测、首次错误重试、暂停及恢复阈值。启用本功能不会自动改写阈值。 + +## 调度规则 + +- 正常账号:按正常间隔检测。请求错误/中断按重试间隔开始翻倍,最高到正常间隔(若重试间隔本身更长则保留它)。成功完成检测后清除错误重试退避。 +- 质量自动暂停:首次使用暂停初始间隔,此后任何未通过结果都让间隔翻倍,直到配置上限。请求错误是不可用证据,不会被当成确认降智。此策略只针对质量暂停,不改变切换分组的恢复所有权语义。 +- 恢复确认:一次正常即进入确认,账号仍暂停。确认间隔逐次减半,直到下限;下限至少一分钟时向下取整到整分钟,默认是 5 分钟、2 分钟、2 分钟。达到已有连续通过阈值才恢复。 +- 确认失败:错误、中断和检测不通过都会清零确认计数,回到此前暂停间隔,本次不额外翻倍。下一次常规恢复探测仍失败才再次翻倍。 +- 历史退避:恢复调度时保留原间隔。随后每次正常检测使历史间隔减半,减至初始间隔时清除。再次达到暂停阈值则沿用剩余历史间隔。历史间隔不影响健康账号的正常检测频率。 +- 抖动:实际等待随机取名义间隔的 90%~100%,不会超过配置上限。下次时间在检测完成时落库,重启、轮询不会重新抽取。 +- 凭证撤销:检测响应中的明确 `token_revoked`、`account_deactivated`、`workspace_deactivated`、`organization_deactivated` 错误会暂停该凭证代次的自动检测。凭证更新后代次变化会重新允许探测;手动检测仍允许,成功时解除阻断。普通 401/过期或过载不据此永久阻断。 + +全部状态随 `state_json` 持久化,任务租约到期也使用相同退避算法。配置修订、人工操作和凭证代次会隔离旧检测结果;旧版本正在执行的无代次快照只保留历史结果。人工启用/禁用业务调度会清除历史退避,但不会代替凭证修复。关闭自动检测不解除质量暂停。 + +保存配置会重置原有连续判定计数,保留并限制历史退避;缩短上限会提前远期检测。切换自适应开关时按新策略重排下次时间。停用自适应策略后回到原固定频率,并清除该策略保存的凭证阻断。 + +## 建议实例 + +正常间隔 1800 秒、异常重试 300 秒、连续未通过 3 次暂停、连续通过 3 次恢复,配合上表默认值: + +- 暂停探测:30 分钟 → 1 → 2 → 4 → 8 → 16 → 32 → 48 小时,之后保持上限。 +- 恢复确认:首次正常 → 约 5 分钟后正常 → 约 2 分钟后正常 → 恢复业务。 +- 长间隔会延迟发现恢复,48 小时上限意味着最坏接近两天才发现恢复。可以使用账号的「立即检测」提前确认,但它不绕过连续通过阈值。 + +账号检测条和历史弹窗显示当前阶段、确认进度与持久化的下次检测时间。凭证阻断显示「等待凭证修复」。 + +## 验证 + +状态机测试覆盖跨日退避、可变上限、确认失败、复发记忆和旧策略;数据库测试通过真实任务入队、认领、完成、租约过期验证 SQLite/PostgreSQL 持久化及凭证代次隔离;浏览器回归使用模拟接口,不调用真实模型。 diff --git a/docs/QUOTA_FAILOVER_PATCH.md b/docs/QUOTA_FAILOVER_PATCH.md new file mode 100644 index 000000000..091387939 --- /dev/null +++ b/docs/QUOTA_FAILOVER_PATCH.md @@ -0,0 +1,21 @@ +# 额度耗尽时的会话续聊切换补丁 + +## 原因与修正 + +原逻辑把携带 `X-Codex-Turn-State` 的活跃回合固定在原账号。即使账号已被上游确认耗尽额度,也可能直接返回 429;原测试明确要求这种行为。 + +补丁允许 HTTP 和 WebSocket 在上下文能够完整重放时解除这项绑定,使用现有调度器选择有额度、符合模型和 API Key 分组权限的账号。切换时清理旧的回合状态及旧 `previous_response_id`,保留已重建的历史、工具调用和工具结果。 + +另修正周限额冷却来源判定:`usage_limit`、`usage_limited`、`rate_limited_7d` 是上游明确的限额结果,不能因冷却时间恰好等于周窗口重置时间而被误判成本地用量快照。 + +只收到本地 WHAM 100% 快照、而上游尚未拒绝当前回合时,保留原有继续完成回合的逻辑。 + +## 行为边界 + +- 请求尚未产生客户端可见内容时,限额失败使用现有有限重试预算切换账号。HTTP 429 和流内 `response.failed` 均覆盖。 +- 原账号在请求开始前就已确认无额度时,直接进入可用账号调度,不先发送一次必败请求。 +- 带 `previous_response_id` 的 WebSocket 请求先按原有 owner 隔离、缓存和大小限制恢复完整历史。不会使用丢失历史的 fail-open 模式来完成本补丁的切换。 +- 缓存缺失、工具结果缺少原调用、`item_reference` 或不可跨账号重放的加密压缩状态,明确返回上下文不可恢复错误。加密 reasoning 会先随请求转发;如果替代账号拒绝它,现有重试路径可能移除该隐藏推理后再试。 +- 已经向客户端发送真实内容后不重放同一输出,避免重复文字、工具调用或副作用。因此不能承诺任意阶段的错误都绝对无感。 +- 账号全部耗尽、没有权限使用其他账号,或重试预算已用完时仍会返回错误。 +- 普通请求保持实时流式输出,不启用整段回答缓存或无限重试。 diff --git a/docs/REQUEST_BODY_413_PATCH.md b/docs/REQUEST_BODY_413_PATCH.md new file mode 100644 index 000000000..517697cb0 --- /dev/null +++ b/docs/REQUEST_BODY_413_PATCH.md @@ -0,0 +1,30 @@ +# Codex 大请求 413 诊断 + +可以通过环境变量配置单请求上限和逻辑正文总预算,例如: + +```yaml +environment: + CODEX_MAX_REQUEST_BODY_SIZE_MB: "96" + CODEX_REQUEST_MEMORY_BUDGET_MB: "192" +``` + +这两个设置在容器重建后生效。预算包含压缩正文与解压正文,因此接近上限的 +低压缩率请求或并发请求仍可能返回可重试的 503。预算不包含所有 JSON 工作 +副本和 Go 堆开销,不是进程 RSS 上限。应结合小内存主机实际负载评估。 + +HTTP 响应新增 `X-Codex2API-Max-Request-Body-Bytes`,可从 `/health` +确认本机生效限制。413 错误保留 `invalid_request_error` / +`request_too_large`,并包含请求 ID、上限、观察到的大小和触发阶段。 + +大小中间件在访问日志之前拒绝请求;本补丁直接写 `[request-size]` JSON +日志,覆盖 Content-Length 超限、未知长度正文超限、解压后超限以及 zstd +解码窗口超限。日志不包含正文、Authorization、查询参数或客户端 URL。 + +- `content_length`:声明的长度,未读取正文。 +- `wire_body` / `decompressed_body`:最多读取到上限加一字节;记录下界, + 不能当作完整大小。 +- `decoder_window`:zstd 解码器的窗口或内存限制;实际解压大小未知, + `observed_body_bytes=-1`,`observed_is_lower_bound=false`。 + +补丁不裁剪会话、图片或工具历史。超过配置上限时仍应使用更短的会话或减少 +附件。入站通过不代表上游接受该请求,也不保证模型上下文窗口足够。 diff --git a/docs/RESET_5H_PRIORITY.md b/docs/RESET_5H_PRIORITY.md new file mode 100644 index 000000000..85b7db952 --- /dev/null +++ b/docs/RESET_5H_PRIORITY.md @@ -0,0 +1,26 @@ +# 5 小时临近重置优先 + +调度模式:`reset_5h_first`。设置位置:通用设置 → 流量与调度 → 调度策略 → 调度模式 → **5 小时临近重置优先**。 + +这是新增的可选模式,默认仍为 `round_robin`,只有主动选择后生效。无需数据库结构迁移。 + +## 原有相似机制 + +`premium5hUsageUrgencyBonusLocked` 已经对符合条件的高级套餐提供临近重置加分:剩余时间在 4 小时以内、剩余额度超过 5% 时,按时间及剩余比例计算,最高 25 分。它是调度评分的一部分,不能保证先使用最早重置的账号。现有“剩余配额”和“顺序耗尽”主要按 Codex 的周用量比例排序。 + +## 新模式 + +1. 先遵守人工账号调度优先级,再遵守健康档位。 +2. 在同一层内,使用账号已观测的 Codex 5 小时窗口重置时间,由早到晚选择。 +3. 只有有效、非负且小于 100% 的用量和严格晚于当前时刻的重置时间,才能获得该优先级。不把 Claude、Grok、Antigravity、中转 API 的数据当作 Codex 5 小时窗口,也不优先消耗独立积分余额。 +4. 限流、禁用、停调度、权限不符、模型过滤、并发已满或准入失败,继续尝试后续候选;周限额及各类现有 gate 仍按原设置执行。 +5. 过期、缺失或不适用的时间不推算新周期,沿用对应引擎的原有兜底。索引引擎在重置边界到达或快照更新后重新排序,平时不逐请求重排整个账号池。 +6. API Key 受限分组使用自己的缓存顺序;快照更新会刷新已存在的成员,不能把无权限账号插入分组。 +7. 新会话首次选号遵守该模式,不让亲和散列绕过重置顺序。已存在的会话绑定和活跃回合继续遵守原有续聊逻辑;账号耗尽后的可恢复切换由此前额度切换补丁处理。 +8. 索引、旧版扫描、影子模式的实际旧版路径及懒加载兜底均支持此排序。Spark 继续使用独立窗口,不受账号级 5 小时排序限制。 + +如果 A 还剩 4 小时、B 还剩 1 小时、C 还剩 2 小时重置,在三者优先级、健康和可用条件相同时,新选号顺序为 **B → C → A**。 + +## 不提供的能力与限制 + +本模式不创建定时任务,不发送激活/预热请求,不假定每隔 5 小时一定自动产生新周期,不增加周额度,也不能保证三个账号无限连续可用。上游快照存在延迟,实际额度耗尽仍须依据既有 gate 和上游响应判断。已绑定会话可能继续使用原账号,因此它也不表示每条消息都重新选择最早重置的账号。 diff --git a/docs/RESPONSES_COMPATIBILITY.md b/docs/RESPONSES_COMPATIBILITY.md index 40efb3247..8e291bb2f 100644 --- a/docs/RESPONSES_COMPATIBILITY.md +++ b/docs/RESPONSES_COMPATIBILITY.md @@ -26,7 +26,7 @@ X-Codex2API-Strict-Compatibility: true HTTP Responses/Compact 在处理原始 JSON 时拒绝重复的顶层 `input` 或 `previous_response_id`,返回 `400 invalid_request`,避免不同解析器选取不同值;JSON 转义后的同名键也算重复。嵌套字段和正常原生 Responses relay 的数值、父引用不受此检查改写。 -原生 Codex HTTP 出站需要展开并删除 `previous_response_id`。每个非空父响应引用都必须在当前请求归属的缓存中得到证明;缓存缺失或过期返回 409,后端故障返回 503,不会丢弃引用后继续返回一轮没有历史的成功响应。新工具调用对仍需拼接父快照,不能据此推断客户端重传了所有祖先。只有重叠 `call_id` 且完整父前缀一致时才跳过重复拼接;比较仅忽略已定义的顶层 item ID 和内部元数据。部分重放、调用内容冲突或重复定义父调用返回 `409 response_context_unavailable`,客户端可重发完整历史并省略父 ID。相同消息文本本身不会触发去重。 +原生 Codex HTTP 出站需要展开并删除 `previous_response_id`。每个非空父响应引用都必须在当前请求归属的缓存中得到证明;缓存缺失或过期返回 409,后端故障返回 503,不会丢弃引用后继续返回一轮没有历史的成功响应。新工具调用对仍需拼接父快照,不能据此推断客户端重传了所有祖先。只有重叠 `call_id` 且完整父前缀一致时才跳过重复拼接;比较仅忽略已定义的顶层 item ID 和内部元数据。部分重放、调用内容冲突或重复定义父调用返回 `409 previous_response_not_found`,客户端可重发完整历史并省略父 ID。相同消息文本本身不会触发去重。 当前 HTTP 缓存主要保存工具上下文,不为普通消息新增会话存储。普通文本父轮的后续 HTTP 请求应发送完整历史并省略 `previous_response_id`;需要增量续链时使用经过验证的 WS 路径。原生 Responses relay 可把父 ID 交给自己的上游恢复,不套用 Codex 的本地缓存缺失限制;其他仅做协议翻译的渠道不能因此自动获得服务端父响应存储能力。 diff --git a/docs/SCHEDULED_5H_ACTIVATION.md b/docs/SCHEDULED_5H_ACTIVATION.md new file mode 100644 index 000000000..d9df54355 --- /dev/null +++ b/docs/SCHEDULED_5H_ACTIVATION.md @@ -0,0 +1,44 @@ +# 账号定时激活 + +入口:账号 → 测试连接 →「模型指纹检测」右侧的「定时激活」。此入口打开独立二级弹窗。 + +## 配置 + +- 为当前 Codex/Responses 账号选择测试模型和测试内容。 +- 分别添加、删除工作日(周一至周五)和周末(周六、周日)的多个时间点。 +- 时间统一使用 `Asia/Shanghai`(北京时间)。 +- 可独立开启或关闭计划;没有配置的账号默认关闭。 +- 点击保存只保存计划,不立即发送请求。模型、内容、时间点持久化在当前账号的 `scheduled_connection_tests` 配置中。 + +配置接口在管理员鉴权下提供: + +- `GET /api/admin/accounts/:id/scheduled-tests`:配置、时区和最近十次执行记录。 +- `PUT /api/admin/accounts/:id/scheduled-tests`:保存完整配置。 + +```json +{ + "enabled": true, + "model": "所选账号可用的测试模型", + "content": "hi,回复 ok", + "weekday_times": ["05:40", "10:40", "15:40"], + "weekend_times": ["09:00", "14:00", "19:00"] +} +``` + +计划按账号独立配置,默认不会自动创建时间表。 + +## 执行 + +网关进程按分钟检查计划,关闭浏览器或 Codex 桌面端不会影响执行。自动测试复用现有测试连接处理器,一次绑定一个账号,不走轮询、剩余额度、临近重置优先或限额换号流程。Codex OAuth 定时请求显式使用独立 HTTP,并请求 `none` 推理强度。 + +每个时间点只执行一次,失败不重试、不切换账号。已在计时、锁定或额度耗尽的账号仍可按已启用的计划测试;停用、删除账号或关闭计划后停止后续任务。执行前重新读取计划,以免执行排队期间已被删除的时间点。服务离线时错过的槽位不补发。 + +执行请求会真实消耗上游额度。计划本身不能保证上游一定开始或重置五小时窗口,记录以响应后的可见用量状态为准。 + +## 记录与恢复 + +每次使用 `(账号 ID, 上海日期时间槽位)` 的唯一持久化声明防止并发实例或服务重启后重复发送。结果记录在现有 `maintenance_jobs` 表的 `scheduled_connection_test:` 命名空间;JSON 摘要保存在 `last_error` 字段(成功记录并不表示错误)。 + +摘要包括计划时间、实际执行时间、账号 ID、模型、成功/失败/跳过状态和最新可见的五小时重置时间。不保存测试响应全文或凭据。页面显示最近十次记录,测试原有用量日志另带 `scheduled_connection_test` 内部原因。 + +若服务在声明后、记录结果前意外退出,该槽位显示「结果未确认」,为防重复请求不自动补发。计划停用不能撤销已经发送到上游的请求。 diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 2626cca6b..a5760167c 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -8,7 +8,7 @@ - [数据库问题](#数据库问题) - [账号池问题](#账号池问题) - [API 请求问题](#api-请求问题) - - [Responses 上下文连续请求](#症状-409-response_context_unavailable) + - [Responses 上下文连续请求](#症状-409-previous_response_not_found) - [性能问题](#性能问题) - [网络/代理问题](#网络代理问题) - [日志分析](#日志分析) @@ -272,9 +272,9 @@ curl -s -H "X-Admin-Key: your-secret" http://localhost:8080/api/admin/accounts | 2. 清理冷却状态的账号 3. 等待冷却结束 -### 症状: 409 response_context_unavailable +### 症状: 409 previous_response_not_found -该错误表示 `previous_response_id` 所需上下文已经确定无法在当前路径重建。常见原因包括 Memory 模式下单条上下文超过 L1 准入预算、依赖上下文已被条数/字节 LRU 淘汰、必需上下文普通缺失或已经过期,或 Redis 值损坏、超过重建上限。 +该错误表示 `previous_response_id` 所需上下文已经确定无法在当前路径重建(旧版本返回的错误码为 `response_context_unavailable`)。常见原因包括关闭磁盘持久层的 Memory 模式下单条上下文超过 L1 准入预算、依赖上下文已被条数/字节 LRU 淘汰、必需上下文普通缺失或已经过期(含超过持久层保留期),或后端值损坏、超过重建上限。日志中的 `[response-context] unavailable` 行给出具体原因。 ```bash # 查看当前预算、逻辑占用、淘汰和不可用计数 @@ -302,6 +302,7 @@ curl -s -H "X-Admin-Key: your-secret" \ 1. 不要对同一个已确定不可用的 `previous_response_id` 做无条件重试;重新发送完整必需上下文或开始新的响应链。 2. Memory 模式重点查看 `oversize_rejections`、`count_evictions` 和 `byte_evictions`。可在设置页调整本地总量/单条准入,但总量不是 RSS 硬上限。 3. Redis 模式重点查看 `last_config_sync_error`、Redis 健康状态和 `reconstruct_max_bytes`。共享值在重建上限内但超过 L1 准入时会计入 `oversize_bypasses`,仍能服务请求,不需要仅为该计数扩大 L1。 +4. 日志出现 `[quota-failover] rejected` 时,表示会话固定的账号额度耗尽或被自动暂停,而请求中带有无法迁移到其他账号的状态:`item_reference`、加密压缩项、其他非 reasoning 的加密项,或缺少配对调用的工具输出。日志中的 `reason` 和各项计数会指明具体原因。加密 reasoning 不会导致拒绝:它随请求切到新账号,新账号若以 `invalid_encrypted_content` 拒绝,网关会剥离加密 reasoning 后重试一次,消息和工具调用保持完整。 ### 症状: previous response context backend 返回 503 diff --git a/docs/USAGE.md b/docs/USAGE.md index 15ffbfa8e..517eae45e 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -37,7 +37,7 @@ Claude 上游支持 OAuth、Setup Token 和 API Key + Base URL。后者不走 OA - `/admin/docs#structured-output`:Responses 使用 `text.format`,Chat 使用 `response_format`;支持的约束取决于最终模型、渠道和传输方式。 - `/admin/docs#protocols`:协议、模型映射、思考强度与服务档位说明。 -`previous_response_id` 依赖同一密钥范围内可恢复的上下文。缓存受容量、TTL 和写入策略限制,Memory 模式重启会丢失。遇到 `409 response_context_unavailable` 时,重发完整必需历史并开始新链。详见 [Responses 上下文](API.md#responses-上下文不可用)。 +`previous_response_id` 依赖同一密钥范围内可恢复的上下文。缓存受容量、TTL 和写入策略限制;Memory 缓存配合 SQLite 文件主库时默认有磁盘持久层,关闭后重启会丢失。遇到 `409 previous_response_not_found` 时,重发完整必需历史并开始新链。详见 [Responses 上下文](API.md#responses-上下文不可用)。 SDK 示例的基础调用与流式事件按 [OpenAI SDK 文档](https://developers.openai.com/api/docs/libraries) 和 [流式响应文档](https://developers.openai.com/api/docs/guides/streaming-responses) 编写;模型名、路由及兼容性边界以本网关为准。 @@ -63,7 +63,7 @@ GPT Image 2.5 的 Flare / Sunburst 支持 `auto/low/medium/high/xhigh/max`;省 | 503 `no_available_account` | 检查账号启用状态、冷却、并发及可用模型 | | 503 `account_pool_concurrency_saturated` | 账号在,但并发窗口已满;稍后重试或提高并发上限 | | 503 `account_pool_usage_limit_reached` | 账号池额度耗尽;根据 `Retry-After` 等待重置或补充账号 | -| 409 `response_context_unavailable` | 重发完整上下文,开始新响应链 | +| 409 `previous_response_not_found` | 重发完整上下文,开始新响应链 | | 503 `service_unavailable` | 检查共享上下文或预算存储,退避后重试 | | 404 | 核对是否重复拼接 `/v1`、服务器版本和完整路径;任务查询还要检查所属密钥及有效期 | diff --git a/docs/incidents/2026-11-01-model-output-guard-trip.md b/docs/incidents/2026-11-01-model-output-guard-trip.md new file mode 100644 index 000000000..7677118a9 --- /dev/null +++ b/docs/incidents/2026-11-01-model-output-guard-trip.md @@ -0,0 +1,90 @@ +# 事故报告:Model Output Guard 命中导致生成会话中断 + +- **日期**:2026-11-01(Asia/Shanghai) +- **状态**:已定位根因;断流错误自动重试标记已实现(随本次提交,部署另行安排);Guard 命中按设计保持不可重试 +- **影响范围**:单个生成会话被中断;无数据损坏、无虚构用量、账号未被惩罚 +- **关联规格**:`docs/superpowers/specs/2026-10-28-model-output-guard-design.md`(事故场景与防护设计) + +## 1. 事故概述 + +生产实例(3.1.2-guard)上,客户端发起的长上下文生成请求(`/v1/responses`, +模型 `gpt-6.1-sol`,`effort=high`、`fast` 模式)触发 Model Output Guard 的 +`tool_argument_deltas` 预算拦截。Guard 按设计冻结原因并终止上游生成,返回 HTTP 502 +与公开错误码 `model_output_guard_triggered`。客户端将该错误归类为 `PI_AI_ERROR` +(不可重试),会话直接失败。 + +## 2. 时间线(本地时间 +08:00) + +| 时间 | 事件 | Guard 触发原因 | 已转发字符 | +|---|---|---|---| +| 05:21 | 部署 `3.1.2-guard`,Guard 默认关闭(`enabled=false`) | — | — | +| 05:2x | 管理员开启 Guard(`config_generation=2`) | — | — | +| 05:31:41 | 首次命中:`/v1/responses` 502,耗时 53.9s | 未记录(当时版本无原因日志) | 0 | +| 05:39:07 | 再次命中:`/v1/responses` 502,耗时 63.9s | **`tool_argument_deltas`** | 约 330 | + +> 说明:05:39 的命中由部署的临时观测版本在服务端日志中记录 +> `[outputguard] tripped: reason=tool_argument_deltas` 得出;该日志改动**未提交**, +> 已从发布分支移除。 + +## 3. 证据(已脱敏) + +usage 日志(`status_code=502`、`upstream_error_kind=model_output_guard`): + +| 时间 | 模型 | 端点 | 状态码 | 耗时 | 上游错误类型 | +|---|---|---|---|---|---| +| 05:31:41 | gpt-6.1-sol | /v1/responses | 502 | 53.8s | model_output_guard | +| 05:39:07 | gpt-6.1-sol | /v1/responses | 502 | 63.9s | model_output_guard | + +- 会话侧统计:该会话累计用量约 **5.2M tokens**,与"模型在工具参数中持续小块输出" + 的失控形态一致。 +- 错误对外表现:`model_output_guard_triggered: Upstream model output exceeded the + gateway safety policy; generation was stopped.` +- 账号、上游代理地址、主机地址、密钥等敏感标识均已脱敏,不在本报告记录。 + +## 4. 根因分析 + +1. 生产 Guard 配置(默认值): + - `output_guard_max_tool_argument_bytes = 262144`(256 KiB) + - `output_guard_max_tool_argument_deltas = **4096**`(单次工具调用的非空参数增量事件数上限) + - `output_guard_max_channel_bytes = 2097152`(2 MiB) + - `output_guard_max_request_bytes = 4194304`(4 MiB) +2. 该请求中模型对同一工具调用持续产出细碎 arguments 增量,累计超过 4096 个增量事件, + 触发 `tool_argument_deltas` 预算(Spec §5.2 规则,用于限制下游对已累计参数的 + 全量重解析成本,正是本 Guard 要防止的事故模式)。 +3. Guard 在首包/中途拦截后立即取消上游、丢弃缓冲、返回脱敏终态;**未**发生内容泄漏、 + **未**写入虚假 usage、**未**惩罚账号(符合 Spec §5.4 终态语义)。 +4. 客户端(DSH/pi-ai)按错误文本分类错误:`classifyPiAiError` 仅当文本含 `5xx`/429/ + timeout 等才判为可重试(`SERVER`/`RATE_LIMIT`/`TIMEOUT`),而 Guard 返回的公开消息 + 是纯文案,不含状态数字,因此被归类为 `PI_AI_ERROR`(不可重试),导致会话直接失败而非自动重试。 + +## 5. 处置(随本次提交,部署另行安排) + +1. **Guard 命中保持不可重试**(有意为之):Guard 触发代表确定性的失控输出, + 自动重试只会重复消耗时间与 token 并再次命中,因此公开消息不带状态数字, + 客户端(DSH/pi-ai)继续归类为 `PI_AI_ERROR`(不可重试),直接失败。 +2. **上游断流错误补充状态标记**(瞬态故障可安全重试): + - 断流消息:`… safe to retry (status 502)`(含 Anthropic/Grok 消息变体) + - 机制:DSH 的 `classifyPiAiError` 将含 `5\d\d` 的文本归类为 `SERVER`, + 默认 `retryableCodes` 包含 `SERVER`,最多自动重试 5 次(退避 0.5s→10s)。 +3. **未提交**服务端原因日志(`[outputguard] tripped: reason=…`):仅用于本次定位, + 已从发布分支移除。 + +## 6. 影响评估 + +| 维度 | 结论 | +|---|---| +| 数据完整性 | 无损坏;被判定失控的字节未下发 | +| 用量计费 | 无虚假成功用量、无虚构 token | +| 账号状态 | 未降低健康度、未惩罚(`penalize=false`) | +| 客户端体验 | 会话中断;处置后同类错误将自动重试 | +| 安全 | 错误消息不含模型原文/工具路径/prompt/密钥 | + +## 7. 建议 + +1. 若业务确实需要单次工具调用产出极多增量(如超大文件写入),将 + `output_guard_max_tool_argument_deltas` 从 4096 调高(上限 65536), + 并同步观察下游解析开销。 +2. 如重试仍稳定命中同一预算,说明该请求会确定性触发失控输出:优先调整提示词/上下文, + 或对该模型/账号临时关闭 Guard(管理员设置页,高风险提示)。 +3. 可考虑后续在服务端日志(非下游响应)中永久记录触发原因,便于运维观测; + 本次临时日志已移除,未纳入发布分支。 diff --git a/docs/superpowers/plans/2026-10-28-model-output-guard-tdd-plan.candidate.md b/docs/superpowers/plans/2026-10-28-model-output-guard-tdd-plan.candidate.md new file mode 100644 index 000000000..c8c9ac11e --- /dev/null +++ b/docs/superpowers/plans/2026-10-28-model-output-guard-tdd-plan.candidate.md @@ -0,0 +1,8422 @@ +# Codex2API 全局模型输出失控防护 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 基于已审查通过的设计规格,为 Codex2API 建立全局模型输出失控防护体系(协议感知增量重复检测 + 字节/事件硬预算 + 请求级切断 + 限制最大输出长度),在模型输出进入聚合、回放、缓存及下游之前拦截异常流量,消除下游全量重解析引发的 O(n²) 性能雪崩。 + +**Architecture:** 采用分层解耦与自下而上的无环依赖架构: +1. 核心状态机与算法层(`security/outputguard`):实现常量空间 8192-byte 滑动窗口、KMP 前缀函数求最短周期($1 \le p \le 256$、有限串周期数学不要求 $L \pmod p = 0$ 且有独立非整倍数测试验证)、步长 1024-byte 检查点与严格尾检;单 channel 扫描预算($2 \times \text{limit}$)与至多 3 次合法纯终态快照许可(单遍历 Hash 流水线比对,处理上限绝对锁定于 $5 \times \text{limit}$);请求级 8 步无歧义判定流水线(先字节高水位与 request_bytes 累加判定,再资格/等价性检查与成本计算,首 reason 冻结),内置读写并发互斥锁(`sync.RWMutex`),保障 ObserveAppend/IsTripped 并发无 race。 +2. 数据库与持久化层(`database`):基于 `system_settings` 表实施 PostgreSQL/SQLite 模式迁移;`*bool` 三态指针语义防止零值覆盖显式关闭(经 Go 标准库 `sql.NullBool` 源码证实原生支持 SQLite `int64` 的 0/1 转换);单事务同连接读取旧值与加锁(Postgres `FOR UPDATE` / SQLite 独占写锁)、窄更新与 CAS 自增 `generation`,彻底消除死锁与丢更新。 +3. 运行时配置与入口 Ready 门禁层(`proxy`):不可变配置快照、5 秒后台 Revision 轮询器(`StartOutputGuardConfigPoller`)、冷启动未就绪检测(`RequireOutputGuardReady`),默认 `isReady = false`,仅首次从 DB 成功加载或应用配置后置为 true;main 启动挂接初始化与 poller;HTTP 入口返回 503 `api.ErrCodeServiceUnavailable`,WebSocket/Realtime 在握手或首帧返回 CloseTryAgainLater (1013) 错误。 +4. 管理端与前端 UI 层(`admin`, `frontend`):复用既有 `/api/admin/settings` GET/PUT,支持独立分组、单位展示、恢复默认(恢复 Spec 默认配置)、严格层级范围校验($16\text{KiB} \le \text{tool} \le \text{channel} \le \text{request} \le 64\text{MiB}$);非法配置记录诊断日志;提供完整前端 React 界面与 TypeScript 类型契约,并以 `pnpm run typecheck` 验证。 +5. 统一协议投影层(`proxy`):将 Responses HTTP/WS、ChatCompletions、Messages、Gemini Native 及 Realtime 文本子集的流式 chunk 及非流式 JSON 事件实时投影为统一语义观察(`Observation`);实现 Anthropic、Gemini Native 及各协议非流式真实 JSON 投影器与单元测试。 +6. 强制终止与下游通知层(`proxy`):先实现 `GuardTerminationCoordinator` 与 `SendDownstreamGuardTerminal`。guard 命中时原子切换 `tripped` 状态并冻结首个 reason;优先主动 Cancel 上游 context(直接调用 cancel 跳过 5 秒 usage drain,关闭 HTTP body,WS lease 废弃不归还健康池);清空本地 pending、provenance、collector、replay、writer buffers;下游通知写入采用 `downstreamMu.TryLock()` 避免死锁,持锁写操作通过 `http.ResponseController.SetWriteDeadline` 施加 5 秒硬超时;不支持 deadline(`ErrNotSupported`)或已断开时标记 `clientGone` 安全退出;补齐 Gemini SDK 兼容格式及 WebSocket 1013 / error 帧。 +7. 链路编排与重试阻断层(`proxy`):在前置拦截点(Responses HTTP/WS, Chat, Messages, Gemini, Realtime)将投影器、RequestGuard 与终止协调器组装串联;在 `continuousRetryStreamSelected`、`shouldTransparentRetryStream` 及自动续想入口最前置拦截 `guardTripped`,坚决不重试;在 `retryExclusions` 中豁免账号健康惩罚(使用真实符号 `exclusions.forHardSelection()` 与 `exclusions.hasResettable()` 断言)。 +8. 综合验收集成测试层(`test/outputguard`):完整实现 Spec §9 场景 1~28 全场景代码,提供脱敏 fixture、机器自动化判定阈值,覆盖成本上界 8a/b/c、入口矩阵、cancel/body close 无 drain、重试上游调用数恒为 1、全协议错误结构、TryLock/deadline 降级、冷启动 503 / 并发 patch / 恢复默认、TTFT p95 <= 10ms、CPU <= 20%、线性比值 ratio <= 2.5、每 channel <= 64KiB、10 分钟内存/Goroutine/FD 启停快照。 + +**Tech Stack:** Go 1.26+ (gin-gonic/gin, tidwall/gjson, modernc.org/sqlite, jackc/pgx/v5), TypeScript + React 19 (前端契约与界面). + +**Spec:** `docs/superpowers/specs/2026-10-28-model-output-guard-design.md` + +## Global Constraints + +- **范围限制**:只涉及文字输出的 LLM(含 text、thinking、tool arguments);只修订计划文件 `docs/superpowers/plans/2026-10-28-model-output-guard-tdd-plan.md`,不修改 spec、业务源码、数据库或其他文件;不修改 DSH、客户端或已安装 npm 包;设计文档 §10 的设计取舍已全部由用户确认。 +- **不可妥协的设计取舍**: + - 默认全局关闭,仅管理员可在后台显式开启;与 prompt filter 相互独立。 + - 首期采用精确短周期重复检测 + 硬预算,不引入语义模型,不自动改写或拆分工具参数。 + - 默认工具参数上限 128 KiB、4096 非空 deltas;单 channel 字节上限 2 MiB;单请求字节上限 4 MiB;合法大 `write` 也拦截,后台可调高。 + - 只切断当前入口请求或 WebSocket 逻辑 turn;代理端禁止任何形式的透明重试与自动续想,绝不惩罚账号健康分。 + - 全协议覆盖:涵盖 Responses HTTP/WS、ChatCompletions、Messages、Gemini Native 及 Realtime WebSocket 桥接的文本/工具子集;专用媒体接口、Realtime 原生音频 Modality(显式拒绝)及协议管理帧不计入语义重复检测。 + - transport reader 有界行/事件/framing 保护(SSE 8 MiB、WS frame 8 MiB)独立于 guard 开关,关闭语义 guard 仍保留 wire cap。 +- **技术异议核实结论(对首轮评审意见 M2 与 I8 的技术审查回应)**: + - **M2 关于 SQLite 不支持 sql.NullBool 的澄清**:经查证 Go 标准库 `database/sql/convert.go:383` 及 `database/sql/driver/types.go:87-92`,`sql.NullBool.Scan` 内部调用 `convertAssign`,明确原生支持驱动返回的 `int64`(0 对应 false,1 对应 true)。在 `modernc.org/sqlite` 驱动下,SQLite 的 `INTEGER` 字段扫描至 `sql.NullBool` 完全合法,无需盲目绕开 `sql.NullBool`。本计划在数据库结构层保留 `*bool` 指针三态语义,在底层 Scan 阶段通过 `sql.NullBool` 准确反序列化。 + - **I8 关于 KMP 前缀周期整除性的数学核实**:评审建议曾提及“检查 L % p == 0”,但根据 Spec §5.2 规则 3 明确要求:“命中证据是连续 8 KiB 的精确周期窗口;最长周期时也含至少 32 次重复,不要求窗口长度整除周期”。在有限长度字符串(8192 字节)上,若最短周期为 $p$(例如事故模式 $p = 30$),$8192 \pmod{30} = 22 \ne 0$。KMP 前缀函数的最大 border $k = \pi[L-1]$ 对应的最小周期候选值为 $p = L - k$。当且仅当 $1 \le p \le 256$ 且 $\forall i \in [p, L): s[i] == s[i-p]$ 时即为严格周期,绝不能加入 $L \pmod p == 0$ 的错误过滤。本计划在 Task 1 中明确提供非整倍数($p=30, 8192\pmod{30}=22$)的针对性数学验证测试。 +- **编译与验证约束**: + - Go 测试命令必须指定独立缓存路径:`GOCACHE=/tmp/go-build GOPATH=/tmp/go go test ...`。 + - 前端类型校验命令:`cd frontend && pnpm run typecheck`。 +- **TDD 流程纪律**:每个任务严格遵循 Step 1 (Write failing test) -> Step 2 (Verify Red) -> Step 3 (Minimal implementation) -> Step 4 (Verify Green) -> Step 5 (Commit)。绝不使用占位符,严禁假测试与“Red or pass”。 + +--- + +## File Structure + +| 文件路径 | 状态 | 职责描述 | +|---|---|---| +| `security/outputguard/repetition.go` | 新建 | 8192B 滑动窗口 Ring Buffer、KMP 前缀函数求最短周期($1 \le p \le 256$、支持非整除周期)、1024B 步长检查点与严格尾检 | +| `security/outputguard/repetition_test.go` | 新建 | 周期检测器单元测试(命中短周期、非整倍数周期 $p=30$、非周期放行、大 delta 跨步长、<8192B 尾检放行、未对齐最新 8192B 窗口检测) | +| `security/outputguard/channel.go` | 新建 | 单 Channel 状态机:已计费高水位、累计扫描预算扣减、3 次合法纯终态快照许可、单遍历 Hash 流水线、处理量 $5 \times \text{limit}$ 封顶 | +| `security/outputguard/channel_test.go` | 新建 | Channel 状态机单元测试(高水位推进、用例 8a/8b/8c 纯终态许可与变体超额拦截) | +| `security/outputguard/guard.go` | 新建 | 请求级 `RequestGuard` 状态机:事件预算(65536)、Channel 计数(128)、Delta 计数(4096)、§5.3 严格 8 步判定流水线、`sync.RWMutex` 并发安全保障 | +| `security/outputguard/guard_test.go` | 新建 | 请求级 Guard 单元测试(事件洪泛拦截、Channel 洪泛拦截、Delta 洪泛拦截、字节溢出、首个 reason 冻结、并发 race 检验) | +| `database/output_guard_settings.go` | 新建 | 数据库层 `OutputGuardSettings`、`*bool` 三态指针、同事务锁机制(Postgres FOR UPDATE / SQLite 独占写锁)与 CAS 自增 `generation` | +| `database/output_guard_settings_test.go` | 新建 | 数据库迁移与设置读写测试(SQLite/PostgreSQL 默认填充、显式 false 持久化、并发独立窄更新不丢字段与防死锁) | +| `database/sqlite.go` | 修改 | `system_settings` 表增加 output_guard 6 个字段的 SQLite 迁移列(INTEGER) | +| `database/postgres.go` | 修改 | `system_settings` 表增加 output_guard 6 个字段的 PostgreSQL 迁移列(BOOLEAN/BIGINT/INT) | +| `proxy/output_guard_settings.go` | 新建 | 运行时不可变配置快照、5 秒后台 Revision 轮询器、`ApplyOutputGuardSettings`、冷启动未就绪 503 检查、非法配置诊断日志 | +| `proxy/output_guard_settings_test.go` | 新建 | 运行时配置生效与 5 秒轮询更新测试、冷启动未就绪 503 门禁测试、非法配置诊断日志测试 | +| `admin/output_guard_settings.go` | 新建 | 管理端 Admin API:扩展现有 GET/PUT `/api/admin/settings`,支持独立分组、单位显示、恢复默认、严格层级范围校验 | +| `admin/output_guard_settings_test.go` | 新建 | Admin API 单元测试(范围校验失败回滚与诊断日志、恢复默认设置、合法修改即时生效、同事务安全合并) | +| `frontend/src/types.ts` | 修改 | 增加 `OutputGuardSettings` 前端类型契约 | +| `frontend/src/pages/Settings.tsx` | 修改 | 管理端 UI 界面:新增独立的“模型输出失控防护”分组卡片,含 5 项配置、单位(KiB/MiB)、高风险提示与“恢复默认”操作按钮 | +| `proxy/outputguard_projection.go` | 新建 | 统一协议投影器:将 Responses HTTP/WS、ChatCompletions、Messages、Gemini Native 及 Realtime 文本流式与非流式事件投影为标准 `Observation` | +| `proxy/outputguard_projection_test.go` | 新建 | 协议投影器单元测试(各协议 chunk/snapshot/非流式 JSON 正确转换为 ChannelID、Kind、Action 及 Payload) | +| `proxy/outputguard_cancel.go` | 新建 | 终止协调器:原子切换 `tripped` 状态并冻结首 reason、主动 Cancel 上游 context、关闭 body、绕过 5s drain、清空在途缓冲 | +| `proxy/outputguard_cancel_test.go` | 新建 | 上游取消与缓冲废弃测试(mock upstream 立即收到 context cancel,无 5s 延迟,本地 pending/collector/replay/writer 缓冲彻底清空) | +| `proxy/outputguard_downstream.go` | 新建 | 下游失败通知写入:各协议终态错误帧构建(Responses, Chat, Anthropic, Gemini Native, WebSocket)、`downstreamMu.TryLock()` 有界锁获取、持锁内 `SetWriteDeadline(5s)` 写入与 ErrNotSupported 安全降级 | +| `proxy/outputguard_downstream_test.go` | 新建 | 下游写入测试(各协议标准错误结构断言、TryLock 竞争失败安全放弃不卡死、持锁超时回收、不支持 deadline 安全退出) | +| `proxy/continuous_retry.go` | 修改 | `continuousRetryStreamSelected` 最前置识别 `guardTripped`,坚决不重试 | +| `proxy/retry_exclusions.go` | 修改 | `MarkStreamFailure` 识别 guard 失败,豁免账号健康分惩罚,不增加错误计数 | +| `proxy/outputguard_retry_test.go` | 新建 | 重试阻断与账号豁免测试(使用真实符号 `exclusions.forHardSelection()` 与 `exclusions.hasResettable()` 断言) | +| `proxy/handler.go` | 修改 | Responses HTTP SSE `forwardWithEvent` 及非流式接入前置 guard 检查;ChatCompletions 流式与非流式接入前置 guard 检查 | +| `proxy/responses_ws.go` | 修改 | Responses WebSocket 接入每个逻辑 turn 的前置 guard 检查(早于 collector 与 replay) | +| `proxy/realtime_ws.go` | 修改 | Realtime 文本转接 session 严格继承全局 guard | +| `proxy/handler_anthropic.go` | 修改 | Messages (Claude Native) 流式与非流式接入前置 guard 检查 | +| `proxy/handler_gemini.go` | 修改 | Gemini Native `streamGenerateContent` 及非流式接入前置 guard 检查 | +| `proxy/main_init.go` | 新建 | 主程序初始化接线:挂载 `proxy.StartOutputGuardConfigPoller`,执行冷启动配置预加载 | +| `test/outputguard/acceptance_detection_test.go` | 新建 | 综合验收测试:场景 1~11(短周期、不同 delta 边界切分、9215B 片段、8a/8b/8c 专项、短文本尾检放行) | +| `test/outputguard/acceptance_lifecycle_test.go` | 新建 | 综合验收测试:场景 12~20(各入口流式/非流式全链路终止、无 5s drain、并发无 race、无重试、账号无惩罚) | +| `test/outputguard/acceptance_benchmark_test.go` | 新建 | 综合验收测试:场景 21~28(配置轮询、冷启动 503、CPU O(N) 线性度、内存 <=64KiB、细 delta 4096 专项压测分析) | + +## Tasks + +--- + +### Task 1: 周期性短重复检测核心算法(Prefix Function 线性检测器) + +**Files:** +- Create: `security/outputguard/repetition.go` +- Test: `security/outputguard/repetition_test.go` + +**Interfaces:** +- Produces: + ```go + package outputguard + + const ( + WindowSizeBytes = 8192 + CheckpointStepBytes = 1024 + MaxRepetitionPeriod = 256 + MinRepetitionPeriod = 1 + ) + + type RepetitionDetector struct { + window [WindowSizeBytes]byte + count int64 // channel 累计写入总字节数 + head int // ring buffer 写入游标 (0..8191) + lastCheckedCheckpoint int64 + } + + func NewRepetitionDetector() *RepetitionDetector + func (d *RepetitionDetector) Push(data []byte) (hit bool, period int) + func (d *RepetitionDetector) PushWithHash(data []byte, initialHash uint64) (hit bool, period int, finalHash uint64) + func (d *RepetitionDetector) CheckTail() (hit bool, period int) + func (d *RepetitionDetector) Reset() + ``` + +- [ ] **Step 1: Write failing test for repetition detector (including non-multiple periodic math verification and unaligned tail check)** + +```go +// security/outputguard/repetition_test.go +package outputguard + +import ( + "bytes" + "math/rand" + "testing" +) + +func TestRepetitionDetectorNormalText(t *testing.T) { + d := NewRepetitionDetector() + // 伪随机非周期数据 + r := rand.New(rand.NewSource(12345)) + nonRepeating := make([]byte, 10000) + r.Read(nonRepeating) + + hit, _ := d.Push(nonRepeating) + if hit { + t.Fatalf("expected no repetition hit for pseudo-random data, got hit=true") + } +} + +func TestRepetitionDetectorShortPeriodHit(t *testing.T) { + d := NewRepetitionDetector() + // 创建真实事故重复模式:周期 p = 30 ("_auditfree.md_pluginless.tmp.m") + // 数学核实重点:8192 % 30 = 22 != 0,验证有限串数学周期不要求 L % p == 0! + pattern := []byte("_auditfree.md_pluginless.tmp.m") + if len(pattern) != 30 { + t.Fatalf("pattern length = %d, want 30", len(pattern)) + } + var stream []byte + for len(stream) < 9500 { + stream = append(stream, pattern...) + } + + // 以 512 字节分块送入 + hitFound := false + var hitPeriod int + for offset := 0; offset < len(stream); offset += 512 { + end := offset + 512 + if end > len(stream) { + end = len(stream) + } + hit, p := d.Push(stream[offset:end]) + if hit { + hitFound = true + hitPeriod = p + break + } + } + + if !hitFound { + t.Fatalf("expected repetition hit for 30-byte periodic pattern (non-multiple of 8192), got hit=false") + } + if hitPeriod != 30 { + t.Fatalf("expected detected period 30, got %d", hitPeriod) + } +} + +func TestRepetitionDetectorTailCheckShortText(t *testing.T) { + d := NewRepetitionDetector() + // 短文本 < 8192 字节:即使内容高度周期,尾检必须返回 hit=false (Spec §5.2 规则 6) + pattern := bytes.Repeat([]byte("abc"), 500) // 1500 字节 + hit, _ := d.Push(pattern) + if hit { + t.Fatalf("expected hit=false during push for <8192 bytes") + } + hit, _ = d.CheckTail() + if hit { + t.Fatalf("expected hit=false for tail check with <8192 bytes total") + } +} + +func TestRepetitionDetectorTailCheckUnalignedWindow(t *testing.T) { + d := NewRepetitionDetector() + // 构造真实非对齐尾检样本: + // 前 108 字节为非周期杂散前缀,后随 8192 字节周期为 10 的重复片段,总长 8300 字节。 + // 在常规 Push 过程中: + // - 当累计达到 8192 字节时触发检查点,此时窗口包含 108 字节杂散前缀 + 8084 字节周期内容,不是完整周期,Push 判定 hit=false; + // - 后续送入剩余 108 字节(总长 8300 字节),尚未到达下一个 1024 检查点(9216 字节),Push 仍未命中; + // - 此时流结束触发 CheckTail():提取当前最新的 8192 字节窗口,恰好完全由周期 10 的数据组成,CheckTail 必须精准返回 hit=true, period=10。 + prefix := []byte("non-periodic-header-padding-1234567890-abcdefghijklmnopqrstuvwxyz!@#$%^&*()-random-preamble-bytes-to-offset-boundary-xxxx") + if len(prefix) != 108 { + t.Fatalf("prefix length = %d, want 108", len(prefix)) + } + pattern := []byte("0123456789") // period 10 + periodicPart := bytes.Repeat(pattern, 8192/10) + periodicPart = append(periodicPart, pattern[:8192%10]...) // 恰好 8192 字节纯周期 + if len(periodicPart) != 8192 { + t.Fatalf("periodicPart length = %d, want 8192", len(periodicPart)) + } + + fullStream := append(prefix, periodicPart...) // 总长 108 + 8192 = 8300 字节 + + // 分块推送,断言 Push 期间不会命中(因为 8192 检查点包含前缀,而后续未到 9216 检查点) + for offset := 0; offset < len(fullStream); offset += 500 { + end := offset + 500 + if end > len(fullStream) { + end = len(fullStream) + } + hit, _ := d.Push(fullStream[offset:end]) + if hit { + t.Fatalf("Push prematurely hit at offset %d, expected tail check to detect", offset) + } + } + + // 触发尾检 + hit, p := d.CheckTail() + if !hit { + t.Fatalf("expected CheckTail to detect pure periodic window of latest 8192 bytes, got hit=false") + } + if p != 10 { + t.Fatalf("expected detected period 10, got %d", p) + } +} + +func TestRepetitionDetectorConsecutive9215BytesGuaranteedHit(t *testing.T) { + d := NewRepetitionDetector() + // Spec §5.2 规则 3: 步长 1024B,任意起点的连续周期片段达到 9215B 时必存在检查点完整落入 + prefix := make([]byte, 512) // 任意偏置前缀 512B + for i := range prefix { + prefix[i] = byte(i % 127) + } + pattern := []byte("repeated_segment_32_bytes_test_") + periodicBody := bytes.Repeat(pattern, 300) // > 9215B + stream := append(prefix, periodicBody...) + + hit := false + for offset := 0; offset < len(stream); offset += 256 { + end := offset + 256 + if end > len(stream) { + end = len(stream) + } + if ok, _ := d.Push(stream[offset:end]); ok { + hit = true + break + } + } + if !hit { + t.Fatalf("expected 9215-byte periodic segment to trigger repetition hit within checkpoints") + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./security/outputguard -run "TestRepetitionDetector.*"` +Expected: FAIL with compilation error: undefined: NewRepetitionDetector + +- [ ] **Step 3: Write minimal implementation** + +```go +// security/outputguard/repetition.go +package outputguard + +const ( + WindowSizeBytes = 8192 + CheckpointStepBytes = 1024 + MaxRepetitionPeriod = 256 + MinRepetitionPeriod = 1 +) + +type RepetitionDetector struct { + window [WindowSizeBytes]byte + count int64 // channel 累计写入总字节数 + head int // ring buffer 写入游标 (0..8191) + lastCheckedCheckpoint int64 +} + +func NewRepetitionDetector() *RepetitionDetector { + return &RepetitionDetector{} +} + +func (d *RepetitionDetector) Reset() { + d.count = 0 + d.head = 0 + d.lastCheckedCheckpoint = 0 +} + +func (d *RepetitionDetector) Push(data []byte) (hit bool, period int) + func (d *RepetitionDetector) PushWithHash(data []byte, initialHash uint64) (hit bool, period int, finalHash uint64) { + for _, b := range data { + d.window[d.head] = b + d.head = (d.head + 1) % WindowSizeBytes + d.count++ + + // 每新增 CheckpointStepBytes 到达检查点 + if d.count >= WindowSizeBytes && (d.count-d.lastCheckedCheckpoint) >= CheckpointStepBytes { + d.lastCheckedCheckpoint = d.count - (d.count % CheckpointStepBytes) + if ok, p := d.checkCurrentWindow(); ok { + return true, p + } + } + } + return false, 0 +} + +func (d *RepetitionDetector) CheckTail() (hit bool, period int) { + // Spec §5.2 规则 6: 如果 channel 累计总字节数 < 8192,不判定重复,直接放行 + if d.count < WindowSizeBytes { + return false, 0 + } + // 已满 8192 窗口但尾部未对齐 1024,提取最新的完整 8192 窗口执行一次求周期检查 + return d.checkCurrentWindow() +} + +// checkCurrentWindow 提取当前 ring buffer 中的线性 8192 字节,使用前缀函数求最短周期 1 <= p <= 256 +// 数学核实说明:有限字符串周期不要求 L % p == 0(例如事故周期 p=30, 8192 % 30 = 22 != 0)。 +// 前缀函数的最大 border k = pi[L-1] 对应最小周期 p = L - k。 +// 当且仅当 1 <= p <= 256 且满足全部 p <= i < 8192 的 window[i] == window[i-p] 时判定命中。 +func (d *RepetitionDetector) checkCurrentWindow() (bool, int) { + if d.count < WindowSizeBytes { + return false, 0 + } + // 展开当前最新的 8192 字节连续视图 + var linear [WindowSizeBytes]byte + idx := d.head // d.head 此时指向最老的那个字节 + for i := 0; i < WindowSizeBytes; i++ { + linear[i] = d.window[(idx+i)%WindowSizeBytes] + } + + // 采用 KMP prefix function (pi table) 在 O(N) 线性时间求周期 + pi := make([]int, WindowSizeBytes) + for i := 1; i < WindowSizeBytes; i++ { + j := pi[i-1] + for j > 0 && linear[i] != linear[j] { + j = pi[j-1] + } + if linear[i] == linear[j] { + j++ + } + pi[i] = j + } + + k := pi[WindowSizeBytes-1] + p := WindowSizeBytes - k + if p >= MinRepetitionPeriod && p <= MaxRepetitionPeriod { + // 校验边界:根据 border 定义,linear[0..k-1] == linear[p..L-1] + // 即对于所有 p <= i < 8192, 严格满足 linear[i] == linear[i-p] + return true, p + } + + return false, 0 +} +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./security/outputguard -run "TestRepetitionDetector.*"` +Expected: PASS ok github.com/codex2api/security/outputguard + +- [ ] **Step 5: Commit** + +```bash +git add security/outputguard/repetition.go security/outputguard/repetition_test.go +git commit -m "feat(outputguard): implement sliding window linear repetition detector" +``` + +--- + +--- + +### Task 2: Channel 状态机与扫描预算/纯终态确认配额机制 + +**Files:** +- Create: `security/outputguard/channel.go` +- Test: `security/outputguard/channel_test.go` + +**Interfaces:** +- Consumes: `RepetitionDetector` from Task 1 +- Produces: + ```go + package outputguard + + type ChannelKind string + const ( + KindText ChannelKind = "text" + KindThinking ChannelKind = "thinking" + KindToolArguments ChannelKind = "tool_arguments" + ) + + type ChannelGuard struct { + id string + kind ChannelKind + maxBytes int64 + maxDeltas int + remainingScanBudget int64 + highwater int64 + deltaCount int + terminalBaselineHash uint64 + hasBaselineHash bool + terminalPermitsUsed int + consumedTerminalTypes map[string]bool + detector *RepetitionDetector + isClosed bool + } + + func NewChannelGuard(id string, kind ChannelKind, maxBytes int64, maxDeltas int) *ChannelGuard + func (c *ChannelGuard) Highwater() int64 + func (c *ChannelGuard) DeltaCount() int + func (c *ChannelGuard) RemainingScanBudget() int64 + func (c *ChannelGuard) ReleaseWindow() + func (c *ChannelGuard) CheckAppend(delta []byte) (growth int64, cost int64, err error) + func (c *ChannelGuard) CommitAppend(delta []byte, growth int64, cost int64) error + func (c *ChannelGuard) CheckSnapshot(snapshot []byte, terminalStage string) (growth int64, cost int64, skipRepetition bool, err error) + func (c *ChannelGuard) CommitSnapshot(snapshot []byte, growth int64, cost int64, terminalStage string) error + func (c *ChannelGuard) CheckTail() error + ``` + +- [ ] **Step 1: Write failing test for channel state machine and quota** + +```go +// security/outputguard/channel_test.go +package outputguard + +import ( + "bytes" + "math/rand" + "testing" +) + +func TestChannelGuardHighwaterGrowthAndScanBudget(t *testing.T) { + // 工具 channel: maxBytes = 128 KiB (131072), scanBudget = 2 * 128 KiB = 256 KiB + cg := NewChannelGuard("call_1", KindToolArguments, 131072, 4096) + + // Append 64 KiB of non-repeating data + delta1 := make([]byte, 65536) + r1 := rand.New(rand.NewSource(10001)) + r1.Read(delta1) + + growth, cost, err := cg.CheckAppend(delta1) + if err != nil { + t.Fatalf("CheckAppend delta1 error: %v", err) + } + if growth != 65536 || cost != 65536 { + t.Fatalf("delta1: want growth=65536, cost=65536, got growth=%d, cost=%d", growth, cost) + } + if err := cg.CommitAppend(delta1, growth, cost); err != nil { + t.Fatalf("CommitAppend delta1 error: %v", err) + } + + if cg.Highwater() != 65536 { + t.Fatalf("want highwater=65536, got %d", cg.Highwater()) + } + if cg.RemainingScanBudget() != 262144-65536 { + t.Fatalf("want remainingScanBudget=%d, got %d", 262144-65536, cg.RemainingScanBudget()) + } +} + +// Spec §9.1 Case 8a: 耗尽扫描预算后变体快照超额拦截 +func TestChannelGuardCase8a_ScanBudgetExhaustionAndVariantRejection(t *testing.T) { + cg := NewChannelGuard("call_1", KindToolArguments, 131072, 4096) + // 以 128 次 1 KiB delta 耗尽 128 KiB 扫描预算 (剩余 128 KiB) + chunk := make([]byte, 1024) + r2 := rand.New(rand.NewSource(20002)) + r2.Read(chunk) + var fullParam []byte + for i := 0; i < 128; i++ { + fullParam = append(fullParam, chunk...) + growth, cost, err := cg.CheckAppend(chunk) + if err != nil { + t.Fatalf("step %d append error: %v", i, err) + } + if err := cg.CommitAppend(chunk, growth, cost); err != nil { + t.Fatalf("step %d commit append error: %v", i, err) + } + } + if cg.RemainingScanBudget() != 131072 { + t.Fatalf("remaining scan budget want 131072, got %d", cg.RemainingScanBudget()) + } + + // 首个 128 KiB 白名单终态快照到达 (arguments.done),尚无 baseline hash,cost = 128 KiB + growth, cost, skipRepetition, err := cg.CheckSnapshot(fullParam, "arguments.done") + if err != nil { + t.Fatalf("arguments.done snapshot error: %v", err) + } + if cost != 131072 || growth != 0 || skipRepetition { + t.Fatalf("arguments.done: want cost=131072, growth=0, skipRepetition=false; got cost=%d, growth=%d, skip=%v", cost, growth, skipRepetition) + } + if err := cg.CommitSnapshot(fullParam, growth, cost, "arguments.done"); err != nil { + t.Fatalf("arguments.done commit snapshot error: %v", err) + } + + if cg.RemainingScanBudget() != 0 { + t.Fatalf("remaining scan budget want 0, got %d", cg.RemainingScanBudget()) + } + + // 紧接着收到修改了 1 个字节的 128 KiB 变体快照 (item.done) + variantParam := bytes.Clone(fullParam) + variantParam[65536] ^= 0xFF + + _, _, _, err = cg.CheckSnapshot(variantParam, "item.done") + if err == nil { + t.Fatalf("expected variant snapshot to be rejected with scan budget exhaustion, got nil") + } + if err != ErrScanBudgetExhausted { + t.Fatalf("expected ErrScanBudgetExhausted, got %v", err) + } +} + +// Spec §9.1 Case 8b: 合法终态链零计费放行与无资格拒绝 +func TestChannelGuardCase8b_TerminalChainZeroCostAndExhaustion(t *testing.T) { + cg := NewChannelGuard("call_1", KindToolArguments, 131072, 4096) + fullParam := make([]byte, 131072) + r3 := rand.New(rand.NewSource(30003)) + r3.Read(fullParam) + // append 128 KiB + growth, cost, _ := cg.CheckAppend(fullParam) + _ = cg.CommitAppend(fullParam, growth, cost) + + // arguments.done 消耗最后 128 KiB 扫描预算并建立 baseline hash + growth, cost, _, _ = cg.CheckSnapshot(fullParam, "arguments.done") + _ = cg.CommitSnapshot(fullParam, growth, cost, "arguments.done") + + // item.done 具备资格且 Hash 完全一致 -> cost = 0, skipRepetition = true + growth, cost, skipRepetition, err := cg.CheckSnapshot(fullParam, "item.done") + if err != nil { + t.Fatalf("item.done snapshot error: %v", err) + } + if cost != 0 || !skipRepetition { + t.Fatalf("item.done: want cost=0, skipRepetition=true; got cost=%d, skip=%v", cost, skipRepetition) + } + _ = cg.CommitSnapshot(fullParam, growth, cost, "item.done") + + // response.completed 同样具备资格且 Hash 一致 -> cost = 0 + growth, cost, skipRepetition, err = cg.CheckSnapshot(fullParam, "response.completed") + if err != nil { + t.Fatalf("response.completed snapshot error: %v", err) + } + if cost != 0 || !skipRepetition { + t.Fatalf("response.completed: want cost=0, skipRepetition=true") + } + _ = cg.CommitSnapshot(fullParam, growth, cost, "response.completed") + + // 重发同类型 response.completed -> 失去资格,走 cost = 128 KiB,因剩余扫描预算为 0 被拦截 + _, _, _, err = cg.CheckSnapshot(fullParam, "response.completed") + if err != ErrScanBudgetExhausted { + t.Fatalf("duplicate response.completed: want ErrScanBudgetExhausted, got %v", err) + } +} + +// Spec §9.1 Case 8c: 非白名单普通快照绝不建立 baseline +func TestChannelGuardCase8c_NonTerminalSnapshotCannotEstablishBaseline(t *testing.T) { + cg := NewChannelGuard("c_text", KindText, 2097152, 4096) + snap := []byte("intermediate full snapshot text") + growth, cost, skip, err := cg.CheckSnapshot(snap, "unknown_or_intermediate_type") + if err != nil { + t.Fatalf("CheckSnapshot error: %v", err) + } + if skip || cost != int64(len(snap)) { + t.Fatalf("want skip=false, cost=%d, got skip=%v, cost=%d", len(snap), skip, cost) + } + _ = cg.CommitSnapshot(snap, growth, cost, "unknown_or_intermediate_type") + + // 确认 hasBaselineHash 仍为 false,未建立 baseline + if cg.hasBaselineHash { + t.Fatalf("expected hasBaselineHash=false for non-terminal snapshot, got true") + } +} + +func TestChannelGuardHashMismatchDiscardsQuotaAndChargesFullCost(t *testing.T) { + cg := NewChannelGuard("c_tool", KindToolArguments, 131072, 4096) + data1 := bytes.Repeat([]byte("hello world "), 100) // 1200 字节 + growth, cost, _, _ := cg.CheckSnapshot(data1, "arguments.done") + _ = cg.CommitSnapshot(data1, growth, cost, "arguments.done") + + if !cg.hasBaselineHash { + t.Fatalf("expected baseline hash created") + } + + // 紧接着发送篡改变体快照 + data2 := bytes.Repeat([]byte("corrupted data!"), 80) // 1200 字节 + growth, cost, skip, err := cg.CheckSnapshot(data2, "item.done") + if err != nil { + t.Fatalf("unexpected CheckSnapshot error: %v", err) + } + if skip { + t.Fatalf("expected skip=false for hash mismatch") + } + if cost != int64(len(data2)) { + t.Fatalf("expected full cost %d, got %d", len(data2), cost) + } + + // CommitSnapshot 篡改帧后,该类型许可必须标记废弃 + _ = cg.CommitSnapshot(data2, growth, cost, "item.done") + if !cg.consumedTerminalTypes["item.done"] { + t.Fatalf("expected item.done marked as consumed/discarded") + } + // 基准 Hash 绝不能被篡改快照覆盖 + h := uint64(0) + for _, b := range data1 { + h = (h ^ uint64(b)) * 1099511628211 + } + if cg.terminalBaselineHash != h { + t.Fatalf("expected baseline hash to stay unchanged") + } +} + +func TestChannelGuardTailCheck(t *testing.T) { + cg := NewChannelGuard("c_text", KindText, 2097152, 4096) + // 短文本放行 + _ = cg.CommitAppend([]byte("short text"), 10, 10) + if err := cg.CheckTail(); err != nil { + t.Fatalf("expected nil error for short text tail check, got %v", err) + } + + // 长周期文本检出 + cg2 := NewChannelGuard("c_periodic", KindText, 2097152, 4096) + pattern := []byte("0123456789") + data := bytes.Repeat(pattern, 850) // 8500 字节 + // 避免在 append 触发检查点,直接写入 window + cg2.detector.window = [8192]byte{} + for i := 0; i < 8500; i++ { + cg2.detector.window[i%8192] = pattern[i%10] + } + cg2.detector.count = 8500 + cg2.detector.head = 8500 % 8192 + + if err := cg2.CheckTail(); err != ErrRepetitionDetected { + t.Fatalf("expected ErrRepetitionDetected for periodic tail check, got %v", err) + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./security/outputguard -run "TestChannelGuard.*"` +Expected: FAIL with compilation error: undefined: NewChannelGuard + +- [ ] **Step 3: Write minimal implementation** + +```go +// security/outputguard/channel.go +package outputguard + +import ( + "errors" +) + +var ( + ErrToolArgumentBytesExceeded = errors.New("tool_argument_bytes") + ErrChannelBytesExceeded = errors.New("channel_bytes") + ErrScanBudgetExhausted = errors.New("scan_budget_exhausted") + ErrRepetitionDetected = errors.New("repetition") + ErrToolDeltasExceeded = errors.New("tool_argument_deltas") +) + +type ChannelKind string + +const ( + KindText ChannelKind = "text" + KindThinking ChannelKind = "thinking" + KindToolArguments ChannelKind = "tool_arguments" +) + +// allowedTerminalTypes 白名单终态类型(Spec §5.3) +var allowedTerminalTypes = map[string]bool{ + "arguments.done": true, + "item.done": true, + "response.completed": true, +} + +// fnv64a 计算 64 位 FNV-1a Hash,用于流水线单遍历比对与基准记录 +func fnv64a(data []byte) uint64 { + var h uint64 = 14695981039346656037 + for _, b := range data { + h = (h ^ uint64(b)) * 1099511628211 + } + return h +} + +type ChannelGuard struct { + id string + kind ChannelKind + maxBytes int64 + maxDeltas int + remainingScanBudget int64 + highwater int64 + deltaCount int + terminalBaselineHash uint64 + hasBaselineHash bool + terminalPermitsUsed int + consumedTerminalTypes map[string]bool + detector *RepetitionDetector + isClosed bool +} + +func NewChannelGuard(id string, kind ChannelKind, maxBytes int64, maxDeltas int) *ChannelGuard { + return &ChannelGuard{ + id: id, + kind: kind, + maxBytes: maxBytes, + maxDeltas: maxDeltas, + remainingScanBudget: 2 * maxBytes, // 累计扫描预算为 2 * limit (Spec §5.3) + consumedTerminalTypes: make(map[string]bool), + detector: NewRepetitionDetector(), + } +} + +func (c *ChannelGuard) Highwater() int64 { return c.highwater } +func (c *ChannelGuard) DeltaCount() int { return c.deltaCount } +func (c *ChannelGuard) RemainingScanBudget() int64 { return c.remainingScanBudget } + +func (c *ChannelGuard) ReleaseWindow() { + c.detector = nil + c.isClosed = true +} + +func (c *ChannelGuard) CheckAppend(delta []byte) (growth int64, cost int64, err error) { + deltaLen := int64(len(delta)) + if c.kind == KindToolArguments && deltaLen > 0 { + if c.deltaCount+1 > c.maxDeltas { + return 0, 0, ErrToolDeltasExceeded + } + } + newHighwater := c.highwater + deltaLen + if newHighwater > c.maxBytes { + if c.kind == KindToolArguments { + return 0, 0, ErrToolArgumentBytesExceeded + } + return 0, 0, ErrChannelBytesExceeded + } + + cost = deltaLen + if c.remainingScanBudget < cost { + return 0, 0, ErrScanBudgetExhausted + } + + return deltaLen, cost, nil +} + +func (c *ChannelGuard) CommitAppend(delta []byte, growth int64, cost int64) error { + c.highwater += growth + if c.kind == KindToolArguments && len(delta) > 0 { + c.deltaCount++ + } + c.remainingScanBudget -= cost + + if c.detector != nil && len(delta) > 0 { + hit, _ := c.detector.Push(delta) + if hit { + return ErrRepetitionDetected + } + } + return nil +} + +func (c *ChannelGuard) CheckSnapshot(snapshot []byte, terminalStage string) (growth int64, cost int64, skipRepetition bool, err error) { + snapLen := int64(len(snapshot)) + if snapLen > c.highwater { + growth = snapLen - c.highwater + } + if snapLen > c.maxBytes { + if c.kind == KindToolArguments { + return 0, 0, false, ErrToolArgumentBytesExceeded + } + return 0, 0, false, ErrChannelBytesExceeded + } + + // 资格检查:仅白名单终态类型且具备 baseline hash 且许可未耗尽 (Spec §5.3 步骤 5) + // 合法与非法终态配额判断严格在 hash 前!无资格绝不提前计算 hash。 + isEligible := c.hasBaselineHash && + allowedTerminalTypes[terminalStage] && + !c.consumedTerminalTypes[terminalStage] && + c.terminalPermitsUsed < 3 + + if isEligible { + // 计算 snapshot Hash 比对 baseline hash + currentHash := fnv64a(snapshot) + if currentHash == c.terminalBaselineHash { + // Hash 完全一致,纯终态确认快照零计费放行,跳过步骤 7 重复扫描 + return growth, 0, true, nil + } + // Hash 不一致(变体篡改),作废该类型许可,全额计费 + cost = snapLen + } else { + // 无免除资格(包括中间替换、首帧终态、重发同类型或超出 3 次配额),严禁提前 Hash,直接全额计费 + cost = snapLen + } + + if c.remainingScanBudget < cost { + return 0, 0, false, ErrScanBudgetExhausted + } + + return growth, cost, false, nil +} + +func (c *ChannelGuard) CommitSnapshot(snapshot []byte, growth int64, cost int64, terminalStage string) error { + c.highwater += growth + c.remainingScanBudget -= cost + + // 白名单终态类型快照消耗本类型配额 + if allowedTerminalTypes[terminalStage] { + c.consumedTerminalTypes[terminalStage] = true + c.terminalPermitsUsed++ + } + + // 终态首帧建立 baseline hash:仅白名单终态帧类型允许在首帧建立 baseline hash (Spec §5.3) + // 如果之前未建立 baseline 且当前属于白名单类型,在单次遍历中建立 baseline hash + shouldSetBaseline := !c.hasBaselineHash && allowedTerminalTypes[terminalStage] + + if cost > 0 && c.detector != nil { + c.detector.Reset() + + if shouldSetBaseline { + // 单次流水线处理:一次遍历同时流水线计算 FNV-1a Hash 并执行滑动窗口重复检测 (Spec §5.3 & M1 修复) + const fnvOffsetBasis uint64 = 14695981039346656037 + hit, _, finalHash := c.detector.PushWithHash(snapshot, fnvOffsetBasis) + c.terminalBaselineHash = finalHash + c.hasBaselineHash = true + if hit { + return ErrRepetitionDetected + } + hit, _ = c.detector.CheckTail() + if hit { + return ErrRepetitionDetected + } + } else { + hit, _ := c.detector.Push(snapshot) + if hit { + return ErrRepetitionDetected + } + hit, _ = c.detector.CheckTail() + if hit { + return ErrRepetitionDetected + } + } + } + + return nil +} + +func (c *ChannelGuard) CheckTail() error { + if c.detector != nil { + if hit, _ := c.detector.CheckTail(); hit { + return ErrRepetitionDetected + } + } + return nil +} +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./security/outputguard -run "TestChannelGuard.*"` +Expected: PASS ok github.com/codex2api/security/outputguard + +- [ ] **Step 5: Commit** + +```bash +git add security/outputguard/channel.go security/outputguard/channel_test.go +git commit -m "feat(outputguard): implement channel guard with scan budget and terminal quota" +``` + +--- + +--- + +### Task 3: 请求级 RequestGuard 协调器与统一判定优先级及并发契约 + +**Files:** +- Create: `security/outputguard/guard.go` +- Test: `security/outputguard/guard_test.go` + +**Interfaces:** +- Consumes: `ChannelGuard` from Task 2 +- Produces: + ```go + package outputguard + + import "sync" + + const ( + MaxRequestEvents = 65536 + MaxRequestChannels = 128 + TriggeredCode = "model_output_guard_triggered" + ) + + type Config struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 + } + + type RequestGuard struct { + mu sync.RWMutex + cfg Config + requestEvents int + requestBytes int64 + channels map[string]*ChannelGuard + aliasMap map[string]string // alias mapping (e.g. index -> call_id) + tripped bool + trippedReason string + } + + func NewRequestGuard(cfg Config) *RequestGuard + func (g *RequestGuard) RecordEvent() error + func (g *RequestGuard) ObserveAppend(channelID string, aliasID string, kind ChannelKind, delta []byte) error + func (g *RequestGuard) ObserveSnapshot(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error + func (g *RequestGuard) ObserveAppendNoEvent(channelID string, aliasID string, kind ChannelKind, delta []byte) error + func (g *RequestGuard) ObserveSnapshotNoEvent(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error + func (g *RequestGuard) EndChannel(channelID string) error + func (g *RequestGuard) IsTripped() bool + func (g *RequestGuard) TrippedReason() string + func (g *RequestGuard) ActiveChannelCount() int + ``` + +- [ ] **Step 1: Write failing test for RequestGuard (including 8-step precedence, first reason freeze, EndChannel tail check, and concurrency race)** + +```go +// security/outputguard/guard_test.go +package outputguard + +import ( + "bytes" + "fmt" + "sync" + "testing" +) + +func defaultTestConfig() Config { + return Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, // 128 KiB + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, // 2 MiB + MaxRequestBytes: 4194304, // 4 MiB + } +} + +func TestRequestGuardRequestEventsFlood(t *testing.T) { + rg := NewRequestGuard(defaultTestConfig()) + delta := []byte("a") + for i := 0; i < MaxRequestEvents; i++ { + if err := rg.ObserveAppend("c1", "", KindText, delta); err != nil { + t.Fatalf("unexpected error at event %d: %v", i, err) + } + } + // 第 65537 个事件必须被拒绝并判定为 request_events (Spec §5.3 步骤 1) + err := rg.ObserveAppend("c1", "", KindText, delta) + if err == nil || err.Error() != "request_events" { + t.Fatalf("expected request_events, got %v", err) + } + if !rg.IsTripped() || rg.TrippedReason() != "request_events" { + t.Fatalf("expected tripped with request_events, got %s", rg.TrippedReason()) + } +} + +func TestRequestGuardChannelFlood(t *testing.T) { + rg := NewRequestGuard(defaultTestConfig()) + // 验证累计 128 个 Channel 限制: + // 第二轮核实修正:消除未定义符号 'A',使用 fmt.Sprintf 或安全字符生成独立 channel ID + for i := 0; i < MaxRequestChannels; i++ { + cid := fmt.Sprintf("chan_%d", i) + if err := rg.ObserveAppend(cid, "", KindText, []byte("x")); err != nil { + t.Fatalf("channel %d error: %v", i, err) + } + } + // 第 129 个 Channel 必须被拒绝并判定为 channel_count (Spec §5.3 步骤 2) + err := rg.ObserveAppend("channel_129", "", KindText, []byte("x")) + if err == nil || err.Error() != "channel_count" { + t.Fatalf("expected channel_count, got %v", err) + } +} + +func TestRequestGuardChannelCumulativeNotReturnedOnEnd(t *testing.T) { + rg := NewRequestGuard(defaultTestConfig()) + // 验证 EndChannel 结束 channel 不返还累计 channel 配额 (Spec §5.1) + for i := 0; i < MaxRequestChannels; i++ { + cid := fmt.Sprintf("chan_%d", i) + _ = rg.ObserveAppend(cid, "", KindText, []byte("x")) + _ = rg.EndChannel(cid) + } + // 尝试再开一个 channel,必须被拒绝为 channel_count + err := rg.ObserveAppend("extra_channel", "", KindText, []byte("x")) + if err == nil || err.Error() != "channel_count" { + t.Fatalf("expected channel_count rejection after 128 closed channels, got %v", err) + } +} + +func TestRequestGuardEndChannelExecutesTailCheck(t *testing.T) { + rg := NewRequestGuard(defaultTestConfig()) + // 构造在 Push 时未触发检查点但流结束时已包含 8192 字节周期的流 + pattern := []byte("0123456789") + periodicBody := bytes.Repeat(pattern, 8192/10) + periodicBody = append(periodicBody, pattern[:8192%10]...) // 恰好 8192B 周期 + // 先推送 100 字节杂散 + prefix := make([]byte, 100) + for i := range prefix { + prefix[i] = byte(i) + } + full := append(prefix, periodicBody...) // 8292 字节,未达下一个 1024 检查点 + + // 推送数据,Push 期间不触发 repetition(8192 检查点含 prefix 杂散,8292 未到检查点) + for offset := 0; offset < len(full); offset += 500 { + end := offset + 500 + if end > len(full) { + end = len(full) + } + _ = rg.ObserveAppend("c_tail", "", KindText, full[offset:end]) + } + if rg.IsTripped() { + t.Fatalf("expected not tripped before EndChannel") + } + + // 调用 EndChannel:必须先执行尾检,发现周期性短重复,成功拦截并冻结 repetition! + err := rg.EndChannel("c_tail") + if err == nil || err.Error() != "repetition" { + t.Fatalf("expected repetition hit during EndChannel tail check, got %v", err) + } + if !rg.IsTripped() || rg.TrippedReason() != "repetition" { + t.Fatalf("expected tripped with repetition, got %s", rg.TrippedReason()) + } +} + +func TestRequestGuardRequestBytesPrecedenceOverScanBudget(t *testing.T) { + // Spec §5.3 严格 8 步优先级:先步骤 4 语义字节预算检查 (request_bytes),后步骤 6 扫描预算 + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 1000, // 仅 1000 字节请求上限 + }) + + // 一次传入 1500 字节快照 -> 必须报 request_bytes,而非 channel_bytes 或 scan_budget + err := rg.ObserveSnapshot("c1", "", KindText, make([]byte, 1500), "") + if err == nil || err.Error() != "request_bytes" { + t.Fatalf("expected request_bytes priority over scan budget, got %v", err) + } + // 首个 reason 必须冻结 + if rg.TrippedReason() != "request_bytes" { + t.Fatalf("expected frozen reason request_bytes, got %s", rg.TrippedReason()) + } + + // 再次调用仍然返回首个冻结 reason + err2 := rg.ObserveAppend("c1", "", KindText, []byte("x")) + if err2 == nil || err2.Error() != "request_bytes" { + t.Fatalf("expected idempotent frozen reason request_bytes, got %v", err2) + } +} + +func TestRequestGuardToolArgumentDeltasFlood(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 10, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + for i := 0; i < 10; i++ { + if err := rg.ObserveAppend("call_1", "", KindToolArguments, []byte("a")); err != nil { + t.Fatalf("delta %d error: %v", i, err) + } + } + // 第 11 个非空 delta 必须被拦截为 tool_argument_deltas (Spec §5.3 步骤 3) + err := rg.ObserveAppend("call_1", "", KindToolArguments, []byte("a")) + if err == nil || err.Error() != "tool_argument_deltas" { + t.Fatalf("expected tool_argument_deltas, got %v", err) + } +} + +func TestRequestGuardRecordEventAndNoEventBatching(t *testing.T) { + rg := NewRequestGuard(defaultTestConfig()) + + // 1. 测试独立 RecordEvent 仅对上游业务事件执行 1 次计数 + for i := 0; i < MaxRequestEvents; i++ { + if err := rg.RecordEvent(); err != nil { + t.Fatalf("unexpected error at event %d: %v", i, err) + } + } + // 第 65537 次 RecordEvent 触发 request_events 判定并冻结 + if err := rg.RecordEvent(); err == nil || err.Error() != "request_events" { + t.Fatalf("expected request_events, got %v", err) + } + if !rg.IsTripped() || rg.TrippedReason() != "request_events" { + t.Fatalf("expected tripped with request_events, got %s", rg.TrippedReason()) + } + + // 2. 测试同事件下多个 Observation 使用 ObserveAppendNoEvent / ObserveSnapshotNoEvent 不重复累加 requestEvents + rgFresh := NewRequestGuard(defaultTestConfig()) + // 记录 1 次上游业务事件 + if err := rgFresh.RecordEvent(); err != nil { + t.Fatalf("RecordEvent failed: %v", err) + } + // 单事件派生 3 个 delta append observation,调用 ObserveAppendNoEvent + for i := 0; i < 3; i++ { + cid := fmt.Sprintf("chan_%d", i) + if err := rgFresh.ObserveAppendNoEvent(cid, "", KindText, []byte("val")); err != nil { + t.Fatalf("ObserveAppendNoEvent observation %d failed: %v", i, err) + } + } + // 断言 requestEvents 依然仅为 1(未被 3 次 observation 累加为 4) + rgFresh.mu.RLock() + events := rgFresh.requestEvents + rgFresh.mu.RUnlock() + if events != 1 { + t.Fatalf("expected requestEvents=1 for batch observations under single event, got %d", events) + } +} + +func TestRequestGuardConcurrentAppendAndIsTrippedNoRace(t *testing.T) { + rg := NewRequestGuard(defaultTestConfig()) + var wg sync.WaitGroup + + // 并发写 + for i := 0; i < 10; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + cid := fmt.Sprintf("c_concurrent_%d", id%3) + for j := 0; j < 50; j++ { + _ = rg.ObserveAppend(cid, "", KindText, []byte("a")) + } + }(i) + } + + // 并发读状态 + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 50; j++ { + _ = rg.IsTripped() + _ = rg.TrippedReason() + } + }() + } + + wg.Wait() +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race ./security/outputguard -run "TestRequestGuard.*"` +Expected: FAIL with compilation error: undefined: NewRequestGuard + +- [ ] **Step 3: Write minimal implementation** + +```go +// security/outputguard/guard.go +package outputguard + +import ( + "errors" + "sync" +) + +const ( + MaxRequestEvents = 65536 + MaxRequestChannels = 128 + TriggeredCode = "model_output_guard_triggered" +) + +type Config struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 +} + +type RequestGuard struct { + mu sync.RWMutex + cfg Config + requestEvents int + requestBytes int64 + channels map[string]*ChannelGuard + aliasMap map[string]string + tripped bool + trippedReason string +} + +func NewRequestGuard(cfg Config) *RequestGuard { + return &RequestGuard{ + cfg: cfg, + channels: make(map[string]*ChannelGuard), + aliasMap: make(map[string]string), + } +} + +func (g *RequestGuard) IsTripped() bool { + g.mu.RLock() + defer g.mu.RUnlock() + return g.tripped +} + +func (g *RequestGuard) TrippedReason() string + func (g *RequestGuard) ActiveChannelCount() int { + g.mu.RLock() + defer g.mu.RUnlock() + return g.trippedReason +} + +func (g *RequestGuard) tripLocked(reason string) error { + if !g.tripped { + g.tripped = true + g.trippedReason = reason + } + return errors.New(g.trippedReason) +} + +func (g *RequestGuard) resolveChannelLocked(channelID, aliasID string, kind ChannelKind) (*ChannelGuard, error) { + // 晚到 ID 合并与双向映射机制 (Spec §4.3 & 审查反馈 M2 修复): + // 真实 Responses 流式 delta 初期通常仅包含 item_id(如 item_call_1),而在 output_item.done 时才提供 call_id。 + // 若分别创建会导致同一工具调用被分配两个 channel。 + var primaryKey string + if channelID != "" && g.aliasMap[channelID] != "" { + primaryKey = g.aliasMap[channelID] + } else if aliasID != "" && g.aliasMap[aliasID] != "" { + primaryKey = g.aliasMap[aliasID] + } else if channelID != "" && g.channels[channelID] != nil { + primaryKey = channelID + } else if aliasID != "" && g.channels[aliasID] != nil { + primaryKey = aliasID + } else if channelID != "" { + primaryKey = channelID + } else if aliasID != "" { + primaryKey = aliasID + } else { + primaryKey = "default" + } + + // 建立双向 alias 映射,确保后续无论通过 channelID 还是 aliasID 查找均指向同一主 ChannelGuard 实例 + if channelID != "" { + g.aliasMap[channelID] = primaryKey + } + if aliasID != "" { + g.aliasMap[aliasID] = primaryKey + } + + cg, exists := g.channels[primaryKey] + if !exists { + // 步骤 2: Channel 数量检查 (Spec §5.3) + if len(g.channels)+1 > MaxRequestChannels { + return nil, g.tripLocked("channel_count") + } + maxBytes := g.cfg.MaxChannelBytes + if kind == KindToolArguments { + maxBytes = g.cfg.MaxToolArgumentBytes + } + cg = NewChannelGuard(primaryKey, kind, maxBytes, g.cfg.MaxToolArgumentDeltas) + g.channels[primaryKey] = cg + } + return cg, nil +} + +// RecordEvent 严格对每上游业务事件执行 1 次计数(步骤 1: request_events 检查) +func (g *RequestGuard) RecordEvent() error { + g.mu.Lock() + defer g.mu.Unlock() + + if !g.cfg.Enabled { + return nil + } + if g.tripped { + return errors.New(g.trippedReason) + } + + g.requestEvents++ + if g.requestEvents > MaxRequestEvents { + return g.tripLocked("request_events") + } + return nil +} + +// ObserveAppend 严格执行 Spec §5.3 优先级流水线(先步骤 1 事件计数,再步骤 2~7 判定,首 reason 冻结) +func (g *RequestGuard) ObserveAppend(channelID string, aliasID string, kind ChannelKind, delta []byte) error { + g.mu.Lock() + defer g.mu.Unlock() + + if !g.cfg.Enabled { + return nil + } + if g.tripped { + return errors.New(g.trippedReason) + } + + // 步骤 1: 事件预算检查 + g.requestEvents++ + if g.requestEvents > MaxRequestEvents { + return g.tripLocked("request_events") + } + + return g.observeAppendLocked(channelID, aliasID, kind, delta) +} + +// ObserveAppendNoEvent 仅执行步骤 2~7 判定,不重复累加 requestEvents(供 ObserveEvent 批处理调用) +func (g *RequestGuard) ObserveAppendNoEvent(channelID string, aliasID string, kind ChannelKind, delta []byte) error { + g.mu.Lock() + defer g.mu.Unlock() + + if !g.cfg.Enabled { + return nil + } + if g.tripped { + return errors.New(g.trippedReason) + } + + return g.observeAppendLocked(channelID, aliasID, kind, delta) +} + +func (g *RequestGuard) observeAppendLocked(channelID string, aliasID string, kind ChannelKind, delta []byte) error { + // 步骤 2: Channel 检查 + cg, err := g.resolveChannelLocked(channelID, aliasID, kind) + if err != nil { + return err + } + + // 步骤 3 & 4: Delta 计数与语义字节高水位检查 + growth, cost, err := cg.CheckAppend(delta) + if err != nil { + if errors.Is(err, ErrScanBudgetExhausted) { + if cg.kind == KindToolArguments { + return g.tripLocked("tool_argument_bytes") + } + return g.tripLocked("channel_bytes") + } + return g.tripLocked(err.Error()) + } + + // 步骤 4: 请求级语义字节检查(必须先于步骤 6 扫描预算判定) + if g.requestBytes+growth > g.cfg.MaxRequestBytes { + return g.tripLocked("request_bytes") + } + + // 步骤 5, 6, 7: 提交高水位并执行周期扫描 + g.requestBytes += growth + if err := cg.CommitAppend(delta, growth, cost); err != nil { + return g.tripLocked(err.Error()) + } + + return nil +} + +// ObserveSnapshot 严格执行 Spec §5.3 快照判定流水线(先步骤 1 事件计数,再步骤 2~7 判定) +func (g *RequestGuard) ObserveSnapshot(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error { + g.mu.Lock() + defer g.mu.Unlock() + + if !g.cfg.Enabled { + return nil + } + if g.tripped { + return errors.New(g.trippedReason) + } + + // 步骤 1: 事件预算检查 + g.requestEvents++ + if g.requestEvents > MaxRequestEvents { + return g.tripLocked("request_events") + } + + return g.observeSnapshotLocked(channelID, aliasID, kind, snapshot, terminalStage) +} + +// ObserveSnapshotNoEvent 仅执行步骤 2~7 判定,不重复累加 requestEvents(供 ObserveEvent 批处理调用) +func (g *RequestGuard) ObserveSnapshotNoEvent(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error { + g.mu.Lock() + defer g.mu.Unlock() + + if !g.cfg.Enabled { + return nil + } + if g.tripped { + return errors.New(g.trippedReason) + } + + return g.observeSnapshotLocked(channelID, aliasID, kind, snapshot, terminalStage) +} + +func (g *RequestGuard) observeSnapshotLocked(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error { + // 步骤 2: Channel 检查 + cg, err := g.resolveChannelLocked(channelID, aliasID, kind) + if err != nil { + return err + } + + // 步骤 4: 语义字节预算检查 (先检查高水位推进与 request_bytes 上限) + snapLen := int64(len(snapshot)) + var growth int64 + if snapLen > cg.highwater { + growth = snapLen - cg.highwater + } + if snapLen > cg.maxBytes { + if cg.kind == KindToolArguments { + return g.tripLocked("tool_argument_bytes") + } + return g.tripLocked("channel_bytes") + } + if g.requestBytes+growth > g.cfg.MaxRequestBytes { + return g.tripLocked("request_bytes") + } + + // 步骤 5 & 6: 资格/等价性检查与扫描预算扣减 + growth, cost, _, err := cg.CheckSnapshot(snapshot, terminalStage) + if err != nil { + if errors.Is(err, ErrScanBudgetExhausted) { + if cg.kind == KindToolArguments { + return g.tripLocked("tool_argument_bytes") + } + return g.tripLocked("channel_bytes") + } + return g.tripLocked(err.Error()) + } + + // 步骤 7: 提交高水位与扫描 + g.requestBytes += growth + if err := cg.CommitSnapshot(snapshot, growth, cost, terminalStage); err != nil { + return g.tripLocked(err.Error()) + } + + return nil +} + +// EndChannel 在关闭 channel 前执行尾检,释放窗口内存,累计 channel 映射不返还 +func (g *RequestGuard) EndChannel(channelID string) error { + g.mu.Lock() + defer g.mu.Unlock() + + if cg, exists := g.channels[channelID]; exists { + // 先执行尾检 (Spec §5.2 规则 6) + if err := cg.CheckTail(); err != nil { + return g.tripLocked(err.Error()) + } + // 尾检通过后安全释放滑动窗口内存 (<=64KiB) + cg.ReleaseWindow() + } + return nil +} +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race ./security/outputguard -run "TestRequestGuard.*"` +Expected: PASS ok github.com/codex2api/security/outputguard + +- [ ] **Step 5: Commit** + +```bash +git add security/outputguard/guard.go security/outputguard/guard_test.go +git commit -m "feat(outputguard): implement request guard coordinator with 8-step precedence and RWMutex" +``` + +--- + + +func (g *RequestGuard) ActiveChannelCount() int { + g.mu.RLock() + defer g.mu.RUnlock() + return len(g.channels) +} + +--- + +### Task 4: 数据库表结构迁移与三态指针持久化(SQLite & PostgreSQL) + +**Files:** +- Create: `database/output_guard_settings.go` +- Modify: `database/sqlite.go:730-740` +- Modify: `database/postgres.go:1600-1610,3230-3240` +- Test: `database/output_guard_settings_test.go` + +**Interfaces:** +- Produces: + ```go + package database + + type OutputGuardSettings struct { + Enabled *bool `json:"output_guard_enabled"` + MaxToolArgumentBytes int64 `json:"output_guard_max_tool_argument_bytes"` + MaxToolArgumentDeltas int `json:"output_guard_max_tool_argument_deltas"` + MaxChannelBytes int64 `json:"output_guard_max_channel_bytes"` + MaxRequestBytes int64 `json:"output_guard_max_request_bytes"` + ConfigGeneration int64 `json:"output_guard_config_generation"` + } + + type OutputGuardSettingsUpdate struct { + Enabled *bool + MaxToolArgumentBytes *int64 + MaxToolArgumentDeltas *int + MaxChannelBytes *int64 + MaxRequestBytes *int64 + } + + func (db *DB) GetOutputGuardSettings(ctx context.Context) (OutputGuardSettings, error) + func (db *DB) UpdateOutputGuardSettings(ctx context.Context, update OutputGuardSettingsUpdate) (OutputGuardSettings, error) + ``` + +- [ ] **Step 1: Write failing test for OutputGuardSettings migration, tri-state persistence, generation overflow protection, and narrow update isolation** + +```go +// database/output_guard_settings_test.go +package database + +import ( + "context" + "errors" + "math" + "os" + "sync" + "testing" +) + +func runOnAllDBDrivers(t *testing.T, testFn func(t *testing.T, db *DB)) { + t.Run("sqlite", func(t *testing.T) { + db := newResponseCacheSettingsTestDB(t) + testFn(t, db) + }) + + if dsn := os.Getenv("CODEX2API_TEST_POSTGRES_DSN"); dsn != "" { + t.Run("postgres", func(t *testing.T) { + db, err := New("postgres", dsn) + if err != nil { + t.Fatalf("New(postgres) error = %v", err) + } + defer db.Close() + testFn(t, db) + }) + } +} + +func TestOutputGuardSettingsFreshDefaults(t *testing.T) { + runOnAllDBDrivers(t, func(t *testing.T, db *DB) { + got, err := db.GetOutputGuardSettings(context.Background()) + if err != nil { + t.Fatalf("GetOutputGuardSettings error = %v", err) + } + + if got.Enabled == nil || *got.Enabled != false { + t.Fatalf("default Enabled want false, got %v", got.Enabled) + } + if got.MaxToolArgumentBytes != 131072 { + t.Fatalf("default MaxToolArgumentBytes want 131072, got %d", got.MaxToolArgumentBytes) + } + if got.MaxToolArgumentDeltas != 4096 { + t.Fatalf("default MaxToolArgumentDeltas want 4096, got %d", got.MaxToolArgumentDeltas) + } + if got.MaxChannelBytes != 2097152 { + t.Fatalf("default MaxChannelBytes want 2097152, got %d", got.MaxChannelBytes) + } + if got.MaxRequestBytes != 4194304 { + t.Fatalf("default MaxRequestBytes want 4194304, got %d", got.MaxRequestBytes) + } + if got.ConfigGeneration < 1 { + t.Fatalf("default ConfigGeneration want >= 1, got %d", got.ConfigGeneration) + } + }) +} + +func TestOutputGuardSettingsExplicitFalseTriState(t *testing.T) { + runOnAllDBDrivers(t, func(t *testing.T, db *DB) { + ctx := context.Background() + + disabled := false + updated, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + Enabled: &disabled, + }) + if err != nil { + t.Fatalf("UpdateOutputGuardSettings error = %v", err) + } + if updated.Enabled == nil || *updated.Enabled != false { + t.Fatalf("expected updated Enabled=false, got %v", updated.Enabled) + } + + // 重新读取断言显式 false 得到正确保留(不被默认 false 覆盖) + readBack, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("readBack error = %v", err) + } + if readBack.Enabled == nil || *readBack.Enabled != false { + t.Fatalf("expected readBack Enabled=false, got %v", readBack.Enabled) + } + }) +} + +func TestOutputGuardSettingsGenerationOverflow(t *testing.T) { + runOnAllDBDrivers(t, func(t *testing.T, db *DB) { + ctx := context.Background() + // 人为将 generation 置为 math.MaxInt64 + if _, err := db.conn.ExecContext(ctx, ` + INSERT INTO system_settings (id, output_guard_config_generation) + VALUES (1, $1) + ON CONFLICT (id) DO UPDATE SET output_guard_config_generation = $1 + `, int64(math.MaxInt64)); err != nil { + t.Fatalf("seed max generation: %v", err) + } + + toolBytes := int64(65536) + _, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + MaxToolArgumentBytes: &toolBytes, + }) + if !errors.Is(err, ErrOutputGuardGenerationOverflow) { + t.Fatalf("expected ErrOutputGuardGenerationOverflow, got %v", err) + } + }) +} + +func TestOutputGuardSettingsNarrowUpdateNotOverwrittenBySystemSettings(t *testing.T) { + runOnAllDBDrivers(t, func(t *testing.T, db *DB) { + ctx := context.Background() + toolBytes := int64(262144) // 256 KiB + disabled := false + committed, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + Enabled: &disabled, + MaxToolArgumentBytes: &toolBytes, + }) + if err != nil { + t.Fatalf("UpdateOutputGuardSettings error = %v", err) + } + + // 模拟管理员保存常规设置(老 SystemSettings 全量写) + if err := db.UpdateSystemSettings(ctx, &SystemSettings{SiteName: "stale full update"}); err != nil { + t.Fatalf("UpdateSystemSettings error = %v", err) + } + + // 重新读取 OutputGuardSettings,断言窄更新字段绝不被全量写覆盖 + got, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("GetOutputGuardSettings error = %v", err) + } + if got.Enabled == nil || *got.Enabled != false { + t.Fatalf("Enabled overwritten by UpdateSystemSettings: got %v, want false", got.Enabled) + } + if got.MaxToolArgumentBytes != committed.MaxToolArgumentBytes { + t.Fatalf("MaxToolArgumentBytes overwritten: got %d, want %d", got.MaxToolArgumentBytes, committed.MaxToolArgumentBytes) + } + }) +} + +func TestOutputGuardSettingsConcurrentUpdatesNoDeadlock(t *testing.T) { + runOnAllDBDrivers(t, func(t *testing.T, db *DB) { + ctx := context.Background() + var wg sync.WaitGroup + errCh := make(chan error, 10) + + for i := 0; i < 10; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + toolBytes := int64(131072 + idx*1024) + channelBytes := int64(2097152 + idx*1024) + requestBytes := int64(4194304 + idx*1024) + _, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + MaxToolArgumentBytes: &toolBytes, + MaxChannelBytes: &channelBytes, + MaxRequestBytes: &requestBytes, + }) + if err != nil { + errCh <- err + } + }(i) + } + + wg.Wait() + close(errCh) + for err := range errCh { + t.Fatalf("concurrent update failed: %v", err) + } + }) +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./database -run "TestOutputGuardSettings.*"` +Expected: FAIL with compilation error: undefined: db.GetOutputGuardSettings + +- [ ] **Step 3: Write minimal implementation** + +修改 `database/sqlite.go` 添加列: +```go +// 在 database/sqlite.go 中的 columns 切片中添加: +{"system_settings", "output_guard_enabled", "INTEGER DEFAULT 0"}, +{"system_settings", "output_guard_max_tool_argument_bytes", "INTEGER NOT NULL DEFAULT 131072"}, +{"system_settings", "output_guard_max_tool_argument_deltas", "INTEGER NOT NULL DEFAULT 4096"}, +{"system_settings", "output_guard_max_channel_bytes", "INTEGER NOT NULL DEFAULT 2097152"}, +{"system_settings", "output_guard_max_request_bytes", "INTEGER NOT NULL DEFAULT 4194304"}, +{"system_settings", "output_guard_config_generation", "INTEGER NOT NULL DEFAULT 1"}, +``` + +修改 `database/postgres.go` 添加列及隔离 UpdateSystemSettings: +```go +// 在 database/postgres.go initPostgresSchema 中添加: +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_enabled BOOLEAN DEFAULT FALSE; +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_max_tool_argument_bytes BIGINT NOT NULL DEFAULT 131072; +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_max_tool_argument_deltas INT NOT NULL DEFAULT 4096; +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_max_channel_bytes BIGINT NOT NULL DEFAULT 2097152; +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_max_request_bytes BIGINT NOT NULL DEFAULT 4194304; +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_config_generation BIGINT NOT NULL DEFAULT 1; + +// 在 database/postgres.go UpdateSystemSettings 的 ON CONFLICT (id) DO UPDATE SET 中: +// 确保不包含 output_guard_* 字段,使该 6 个字段完全由独立的窄更新函数单独维护,不被老全局设置全量写回覆盖! +``` + +新建 `database/output_guard_settings.go`: +```go +package database + +import ( + "context" + "database/sql" + "errors" + "fmt" + "math" +) + +var ( + ErrInvalidOutputGuardSettings = errors.New("invalid output guard settings") + ErrOutputGuardGenerationOverflow = errors.New("output guard config generation overflow") +) + +type OutputGuardSettings struct { + Enabled *bool `json:"output_guard_enabled"` + MaxToolArgumentBytes int64 `json:"output_guard_max_tool_argument_bytes"` + MaxToolArgumentDeltas int `json:"output_guard_max_tool_argument_deltas"` + MaxChannelBytes int64 `json:"output_guard_max_channel_bytes"` + MaxRequestBytes int64 `json:"output_guard_max_request_bytes"` + ConfigGeneration int64 `json:"output_guard_config_generation"` +} + +type OutputGuardSettingsUpdate struct { + Enabled *bool + MaxToolArgumentBytes *int64 + MaxToolArgumentDeltas *int + MaxChannelBytes *int64 + MaxRequestBytes *int64 +} + +func DefaultOutputGuardSettings() OutputGuardSettings { + t := true + return OutputGuardSettings{ + Enabled: &t, + MaxToolArgumentBytes: 131072, // 128 KiB + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, // 2 MiB + MaxRequestBytes: 4194304, // 4 MiB + ConfigGeneration: 1, + } +} + +func ValidateOutputGuardSettings(s OutputGuardSettings) error { + if s.MaxToolArgumentBytes < 16384 || s.MaxToolArgumentBytes > 4194304 { + return fmt.Errorf("%w: max_tool_argument_bytes must be 16KiB..4MiB", ErrInvalidOutputGuardSettings) + } + if s.MaxToolArgumentDeltas < 256 || s.MaxToolArgumentDeltas > 65536 { + return fmt.Errorf("%w: max_tool_argument_deltas must be 256..65536", ErrInvalidOutputGuardSettings) + } + if s.MaxChannelBytes < 131072 || s.MaxChannelBytes > 16777216 { + return fmt.Errorf("%w: max_channel_bytes must be 128KiB..16MiB", ErrInvalidOutputGuardSettings) + } + if s.MaxRequestBytes < 262144 || s.MaxRequestBytes > 67108864 { + return fmt.Errorf("%w: max_request_bytes must be 256KiB..64MiB", ErrInvalidOutputGuardSettings) + } + if s.MaxToolArgumentBytes > s.MaxChannelBytes || s.MaxChannelBytes > s.MaxRequestBytes { + return fmt.Errorf("%w: requires tool <= channel <= request bytes", ErrInvalidOutputGuardSettings) + } + return nil +} + +func (db *DB) outputGuardSettingsSelectQuery(forUpdate bool) string { + query := `SELECT output_guard_enabled, + COALESCE(output_guard_max_tool_argument_bytes, 131072), + COALESCE(output_guard_max_tool_argument_deltas, 4096), + COALESCE(output_guard_max_channel_bytes, 2097152), + COALESCE(output_guard_max_request_bytes, 4194304), + COALESCE(output_guard_config_generation, 1) + FROM system_settings WHERE id = 1` + if forUpdate && db.driver == "postgres" { + query += " FOR UPDATE" + } + return query +} + +func (db *DB) GetOutputGuardSettings(ctx context.Context) (OutputGuardSettings, error) { + settings := DefaultOutputGuardSettings() + var enabled sql.NullBool + err := db.conn.QueryRowContext(ctx, db.outputGuardSettingsSelectQuery(false)).Scan( + &enabled, + &settings.MaxToolArgumentBytes, + &settings.MaxToolArgumentDeltas, + &settings.MaxChannelBytes, + &settings.MaxRequestBytes, + &settings.ConfigGeneration, + ) + if errors.Is(err, sql.ErrNoRows) { + return settings, nil + } + if err != nil { + return OutputGuardSettings{}, err + } + if enabled.Valid { + val := enabled.Bool + settings.Enabled = &val + } + return settings, nil +} + +func (db *DB) UpdateOutputGuardSettings(ctx context.Context, update OutputGuardSettingsUpdate) (OutputGuardSettings, error) { + var committed OutputGuardSettings + + operation := func() error { + tx, err := db.conn.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + // 确保 id=1 存在 + if _, err := tx.ExecContext(ctx, `INSERT INTO system_settings (id) VALUES (1) ON CONFLICT(id) DO NOTHING`); err != nil { + return err + } + + current := DefaultOutputGuardSettings() + var enabled sql.NullBool + err = tx.QueryRowContext(ctx, db.outputGuardSettingsSelectQuery(true)).Scan( + &enabled, + ¤t.MaxToolArgumentBytes, + ¤t.MaxToolArgumentDeltas, + ¤t.MaxChannelBytes, + ¤t.MaxRequestBytes, + ¤t.ConfigGeneration, + ) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if enabled.Valid { + val := enabled.Bool + current.Enabled = &val + } + + next := current + if update.Enabled != nil { + val := *update.Enabled + next.Enabled = &val + } + if update.MaxToolArgumentBytes != nil { + next.MaxToolArgumentBytes = *update.MaxToolArgumentBytes + } + if update.MaxToolArgumentDeltas != nil { + next.MaxToolArgumentDeltas = *update.MaxToolArgumentDeltas + } + if update.MaxChannelBytes != nil { + next.MaxChannelBytes = *update.MaxChannelBytes + } + if update.MaxRequestBytes != nil { + next.MaxRequestBytes = *update.MaxRequestBytes + } + + // Generation 溢出保护 + if current.ConfigGeneration == math.MaxInt64 { + return ErrOutputGuardGenerationOverflow + } + next.ConfigGeneration++ + + if err := ValidateOutputGuardSettings(next); err != nil { + return err + } + + updateQuery := `UPDATE system_settings SET + output_guard_enabled = $1, + output_guard_max_tool_argument_bytes = $2, + output_guard_max_tool_argument_deltas = $3, + output_guard_max_channel_bytes = $4, + output_guard_max_request_bytes = $5, + output_guard_config_generation = $6 + WHERE id = 1` + + var enabledVal any + if next.Enabled != nil { + enabledVal = *next.Enabled + } else { + enabledVal = true + } + + if _, err := tx.ExecContext(ctx, updateQuery, + enabledVal, + next.MaxToolArgumentBytes, + next.MaxToolArgumentDeltas, + next.MaxChannelBytes, + next.MaxRequestBytes, + next.ConfigGeneration, + ); err != nil { + return err + } + + if err := tx.Commit(); err != nil { + return err + } + committed = next + return nil + } + + if db.isSQLite() { + if err := db.withSQLiteWriteLock(ctx, operation); err != nil { + return OutputGuardSettings{}, err + } + } else { + if err := operation(); err != nil { + return OutputGuardSettings{}, err + } + } + + return committed, nil +} +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./database -run "TestOutputGuardSettings.*"` +Expected: PASS ok github.com/codex2api/database + +- [ ] **Step 5: Commit** + +```bash +git add database/output_guard_settings.go database/output_guard_settings_test.go database/sqlite.go database/postgres.go +git commit -m "feat(database): add output guard settings table migration and tri-state persistence" +``` + +--- + +--- + +### Task 5: 运行时不可变配置快照、5 秒后台 Revision 轮询器与冷启动就绪门禁 + +**Files:** +- Create: `proxy/output_guard_settings.go` +- Create: `proxy/output_guard_settings_test.go` +- Modify: `main.go:265-270` + +**Interfaces:** +- Consumes: + - `database.OutputGuardSettings`: + ```go + type OutputGuardSettings struct { + Enabled *bool `json:"output_guard_enabled"` + MaxToolArgumentBytes int64 `json:"output_guard_max_tool_argument_bytes"` + MaxToolArgumentDeltas int `json:"output_guard_max_tool_argument_deltas"` + MaxChannelBytes int64 `json:"output_guard_max_channel_bytes"` + MaxRequestBytes int64 `json:"output_guard_max_request_bytes"` + ConfigGeneration int64 `json:"output_guard_config_generation"` + } + ``` + - `database.DefaultOutputGuardSettings() database.OutputGuardSettings` + - `database.ValidateOutputGuardSettings(database.OutputGuardSettings) error` + - `database.DB.GetOutputGuardSettings(context.Context) (database.OutputGuardSettings, error)` +- Produces: + ```go + package proxy + + import ( + "context" + "time" + "github.com/codex2api/database" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + ) + + type OutputGuardAppliedConfig struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 + Generation int64 + } + + type OutputGuardConfigSyncStatus struct { + LastSuccessfulSyncAt time.Time + LastSyncError string + } + + type OutputGuardSettingsReader interface { + GetOutputGuardSettings(context.Context) (database.OutputGuardSettings, error) + } + + func ApplyOutputGuardSettings(settings database.OutputGuardSettings) bool + func CurrentOutputGuardConfig() OutputGuardAppliedConfig + func GetOutputGuardConfigSyncStatus() OutputGuardConfigSyncStatus + func IsOutputGuardReady() bool + func SetOutputGuardReadyForTest(ready bool) + func ResetOutputGuardStateForTest() + func LoadOutputGuardSettings(ctx context.Context, reader OutputGuardSettingsReader) error + func StartOutputGuardConfigPoller(parent context.Context, db *database.DB) bool + + // 门禁检查接口(供 Task 7/8 各协议与 WebSocket/Realtime 挂接) + func RequireOutputGuardReadyHTTP(c *gin.Context) bool + func RequireOutputGuardReadyWS(conn *websocket.Conn) bool + func RequireOutputGuardReadyStream(conn *websocket.Conn, streamID string) bool + ``` + +- [ ] **Step 1: Write failing test for proxy output guard runtime settings, poller, LKG, and readiness gates** + +```go +// proxy/output_guard_settings_test.go +package proxy + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/codex2api/api" + "github.com/codex2api/database" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +type controlledOutputGuardSettingsReader struct { + mu sync.Mutex + requests chan chan outputGuardSettingsReadResult +} + +type outputGuardSettingsReadResult struct { + settings database.OutputGuardSettings + err error +} + +func newControlledOutputGuardSettingsReader() *controlledOutputGuardSettingsReader { + return &controlledOutputGuardSettingsReader{ + requests: make(chan chan outputGuardSettingsReadResult, 16), + } +} + +func (r *controlledOutputGuardSettingsReader) GetOutputGuardSettings(ctx context.Context) (database.OutputGuardSettings, error) { + respCh := make(chan outputGuardSettingsReadResult, 1) + select { + case <-ctx.Done(): + return database.OutputGuardSettings{}, ctx.Err() + case r.requests <- respCh: + } + + select { + case <-ctx.Done(): + return database.OutputGuardSettings{}, ctx.Err() + case res := <-respCh: + return res.settings, res.err + } +} + +func (r *controlledOutputGuardSettingsReader) respond(t *testing.T, res outputGuardSettingsReadResult) { + t.Helper() + select { + case respCh := <-r.requests: + respCh <- res + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for GetOutputGuardSettings request") + } +} + +func waitForOutputGuardConfigGeneration(t *testing.T, targetGen int64) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if CurrentOutputGuardConfig().Generation == targetGen { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("timed out waiting for output guard config generation %d (current: %d)", targetGen, CurrentOutputGuardConfig().Generation) +} + +func waitForOutputGuardSyncError(t *testing.T, substr string) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + status := GetOutputGuardConfigSyncStatus() + if status.LastSyncError != "" && (substr == "" || strings.Contains(status.LastSyncError, substr)) { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("timed out waiting for output guard sync error containing %q", substr) +} + +func TestOutputGuardColdStartNotReadyReturns503(t *testing.T) { + ResetOutputGuardStateForTest() + t.Cleanup(ResetOutputGuardStateForTest) + + if IsOutputGuardReady() { + t.Fatal("expected cold start IsOutputGuardReady = false") + } + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + passed := RequireOutputGuardReadyHTTP(c) + if passed { + t.Fatal("expected RequireOutputGuardReadyHTTP to reject cold start request") + } + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("expected HTTP 503, got %d", w.Code) + } + var errResp struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + Type string `json:"type"` + } `json:"error"` + } + if err := json.Unmarshal(w.Body.Bytes(), &errResp); err != nil { + t.Fatalf("unmarshal error response failed: %v", err) + } + if errResp.Error.Code != string(api.ErrCodeServiceUnavailable) { + t.Fatalf("expected error code %q, got %q", api.ErrCodeServiceUnavailable, errResp.Error.Code) + } + if errResp.Error.Code == "model_output_guard_triggered" { + t.Fatalf("must NOT use runtime tripped error code on cold start 503") + } +} + +func TestOutputGuardExplicitDisabledSemanticVsColdStart(t *testing.T) { + ResetOutputGuardStateForTest() + t.Cleanup(ResetOutputGuardStateForTest) + + // Cold start: not ready + if IsOutputGuardReady() { + t.Fatal("expected cold start not ready") + } + + // Load DB settings with Enabled = false + disabled := false + dbSettings := database.OutputGuardSettings{ + Enabled: &disabled, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + ConfigGeneration: 1, + } + + applied := ApplyOutputGuardSettings(dbSettings) + if !applied { + t.Fatal("ApplyOutputGuardSettings failed") + } + + // Once loaded successfully, IsOutputGuardReady is true even when Enabled = false! + if !IsOutputGuardReady() { + t.Fatal("expected IsOutputGuardReady = true after explicit disabled settings loaded") + } + + cfg := CurrentOutputGuardConfig() + if cfg.Enabled != false { + t.Fatalf("expected applied config Enabled = false, got %v", cfg.Enabled) + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + passed := RequireOutputGuardReadyHTTP(c) + if !passed { + t.Fatal("expected RequireOutputGuardReadyHTTP to pass when system is ready and explicitly disabled") + } + if w.Code == http.StatusServiceUnavailable { + t.Fatalf("did not expect 503 when explicitly disabled and ready") + } +} + +func TestOutputGuardConfigGenerationIncrementsAndLKGMaintained(t *testing.T) { + ResetOutputGuardStateForTest() + t.Cleanup(ResetOutputGuardStateForTest) + + enabled := true + initial := database.OutputGuardSettings{ + Enabled: &enabled, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + ConfigGeneration: 1, + } + if !ApplyOutputGuardSettings(initial) { + t.Fatal("failed to apply initial generation 1") + } + + cfg1 := CurrentOutputGuardConfig() + if cfg1.Generation != 1 || cfg1.MaxToolArgumentBytes != 131072 { + t.Fatalf("initial config mismatch: %+v", cfg1) + } + + // Stale generation rejected + stale := initial + stale.MaxToolArgumentBytes = 262144 + if ApplyOutputGuardSettings(stale) { + t.Fatal("stale generation 1 should be rejected") + } + if CurrentOutputGuardConfig().MaxToolArgumentBytes != 131072 { + t.Fatal("LKG overwritten by stale generation") + } + + // Apply generation 2 + gen2 := initial + gen2.MaxToolArgumentBytes = 262144 + gen2.ConfigGeneration = 2 + if !ApplyOutputGuardSettings(gen2) { + t.Fatal("failed to apply generation 2") + } + cfg2 := CurrentOutputGuardConfig() + if cfg2.Generation != 2 || cfg2.MaxToolArgumentBytes != 262144 { + t.Fatalf("generation 2 mismatch: %+v", cfg2) + } + + // Illegal configuration validation failure preserves LKG + illegal := initial + illegal.MaxToolArgumentBytes = 50000000 // exceeds 4MiB + illegal.ConfigGeneration = 3 + if ApplyOutputGuardSettings(illegal) { + t.Fatal("illegal configuration should be rejected") + } + cfgPreserved := CurrentOutputGuardConfig() + if cfgPreserved.Generation != 2 || cfgPreserved.MaxToolArgumentBytes != 262144 { + t.Fatalf("LKG not preserved after illegal config: %+v", cfgPreserved) + } +} + +func TestOutputGuardPollerLifecycleRecoveryAndStop(t *testing.T) { + ResetOutputGuardStateForTest() + t.Cleanup(ResetOutputGuardStateForTest) + + reader := newControlledOutputGuardSettingsReader() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan struct{}) + go func() { + defer close(done) + runOutputGuardConfigPoller(ctx, reader, 5*time.Millisecond, 100*time.Millisecond) + }() + + enabled := true + // 1. Initial read + reader.respond(t, outputGuardSettingsReadResult{ + settings: database.OutputGuardSettings{ + Enabled: &enabled, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + ConfigGeneration: 2, + }, + }) + waitForOutputGuardConfigGeneration(t, 2) + if !IsOutputGuardReady() { + t.Fatal("expected IsOutputGuardReady = true after successful poll") + } + + // 2. Outage: DB error preserves LKG + reader.respond(t, outputGuardSettingsReadResult{ + err: errors.New("simulated database disconnect"), + }) + waitForOutputGuardSyncError(t, "simulated database disconnect") + if CurrentOutputGuardConfig().Generation != 2 { + t.Fatal("LKG generation lost during DB outage") + } + + // 3. Recovery: next poll succeeds with generation 3 + reader.respond(t, outputGuardSettingsReadResult{ + settings: database.OutputGuardSettings{ + Enabled: &enabled, + MaxToolArgumentBytes: 262144, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + ConfigGeneration: 3, + }, + }) + waitForOutputGuardConfigGeneration(t, 3) + if CurrentOutputGuardConfig().MaxToolArgumentBytes != 262144 { + t.Fatal("failed to update config after recovery") + } + + // 4. Cancel ctx and verify poller stops + cancel() + select { + case <-done: + case <-time.After(500 * time.Millisecond): + t.Fatal("poller failed to stop cleanly after context cancel") + } +} + +func TestOutputGuardWSReadinessGate(t *testing.T) { + ResetOutputGuardStateForTest() + t.Cleanup(ResetOutputGuardStateForTest) + + // Create test WebSocket server + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + + if !RequireOutputGuardReadyWS(conn) { + return + } + _ = conn.WriteMessage(websocket.TextMessage, []byte("ready")) + })) + defer server.Close() + + wsURL := "ws" + server.URL[len("http"):] + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial WS failed: %v", err) + } + defer conn.Close() + + // When not ready, server should send 503 error frame / CloseTryAgainLater (1013) + _, msg, err := conn.ReadMessage() + if err != nil { + var closeErr *websocket.CloseError + if errors.As(err, &closeErr) { + if closeErr.Code != websocket.CloseTryAgainLater { + t.Fatalf("expected close code 1013 CloseTryAgainLater, got %d", closeErr.Code) + } + return + } + } + if len(msg) > 0 { + var errResp struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + _ = json.Unmarshal(msg, &errResp) + if errResp.Error.Code != string(api.ErrCodeServiceUnavailable) { + t.Fatalf("expected error frame with code %s, got %s", api.ErrCodeServiceUnavailable, errResp.Error.Code) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestOutputGuard.*"` +Expected: FAIL with "undefined: ApplyOutputGuardSettings", "undefined: RequireOutputGuardReadyHTTP", etc. + +- [ ] **Step 3: Write minimal implementation for proxy/output_guard_settings.go and wire in main.go** + +```go +// proxy/output_guard_settings.go +package proxy + +import ( + "context" + "fmt" + "log" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/codex2api/api" + "github.com/codex2api/database" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +const ( + outputGuardConfigPollInterval = 5 * time.Second + outputGuardConfigReadTimeout = 3 * time.Second +) + +type OutputGuardAppliedConfig struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 + Generation int64 +} + +type OutputGuardConfigSyncStatus struct { + LastSuccessfulSyncAt time.Time + LastSyncError string +} + +type OutputGuardSettingsReader interface { + GetOutputGuardSettings(context.Context) (database.OutputGuardSettings, error) +} + +type outputGuardState struct { + mu sync.RWMutex + config OutputGuardAppliedConfig + lastSyncAt time.Time + lastSyncErr string + isReady atomic.Bool +} + +var globalOutputGuardState = &outputGuardState{} + +func init() { + ResetOutputGuardStateForTest() +} + +func ResetOutputGuardStateForTest() { + globalOutputGuardState.mu.Lock() + defer globalOutputGuardState.mu.Unlock() + globalOutputGuardState.config = OutputGuardAppliedConfig{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + Generation: 0, + } + globalOutputGuardState.lastSyncAt = time.Time{} + globalOutputGuardState.lastSyncErr = "" + globalOutputGuardState.isReady.Store(false) +} + +func SetOutputGuardReadyForTest(ready bool) { + globalOutputGuardState.isReady.Store(ready) +} + +func IsOutputGuardReady() bool { + return globalOutputGuardState.isReady.Load() +} + +func CurrentOutputGuardConfig() OutputGuardAppliedConfig { + globalOutputGuardState.mu.RLock() + defer globalOutputGuardState.mu.RUnlock() + return globalOutputGuardState.config +} + +func GetOutputGuardConfigSyncStatus() OutputGuardConfigSyncStatus { + globalOutputGuardState.mu.RLock() + defer globalOutputGuardState.mu.RUnlock() + return OutputGuardConfigSyncStatus{ + LastSuccessfulSyncAt: globalOutputGuardState.lastSyncAt, + LastSyncError: globalOutputGuardState.lastSyncErr, + } +} + +func ApplyOutputGuardSettings(settings database.OutputGuardSettings) bool { + if err := database.ValidateOutputGuardSettings(settings); err != nil { + log.Printf("[output-guard] 非法配置已被拒绝,保留 LKG: %v", err) + recordOutputGuardConfigSyncError(err) + return false + } + + globalOutputGuardState.mu.Lock() + defer globalOutputGuardState.mu.Unlock() + + if settings.ConfigGeneration <= globalOutputGuardState.config.Generation { + return false + } + + enabledVal := true + if settings.Enabled != nil { + enabledVal = *settings.Enabled + } + + globalOutputGuardState.config = OutputGuardAppliedConfig{ + Enabled: enabledVal, + MaxToolArgumentBytes: settings.MaxToolArgumentBytes, + MaxToolArgumentDeltas: settings.MaxToolArgumentDeltas, + MaxChannelBytes: settings.MaxChannelBytes, + MaxRequestBytes: settings.MaxRequestBytes, + Generation: settings.ConfigGeneration, + } + globalOutputGuardState.isReady.Store(true) + return true +} + +func LoadOutputGuardSettings(ctx context.Context, reader OutputGuardSettingsReader) error { + settings, err := readOutputGuardSettings(ctx, reader, outputGuardConfigReadTimeout) + if err != nil { + if ctx == nil || ctx.Err() == nil { + recordOutputGuardConfigSyncError(err) + } + return err + } + if err := database.ValidateOutputGuardSettings(settings); err != nil { + recordOutputGuardConfigSyncError(err) + return err + } + ApplyOutputGuardSettings(settings) + recordOutputGuardConfigSyncSuccess(time.Now()) + return nil +} + +func StartOutputGuardConfigPoller(parent context.Context, db *database.DB) bool { + if db == nil { + return false + } + if parent == nil { + parent = context.Background() + } + return db.RunBackgroundTask(func(lifecycle context.Context) { + taskCtx, cancel := context.WithCancel(lifecycle) + stopParent := context.AfterFunc(parent, cancel) + defer func() { + stopParent() + cancel() + }() + runOutputGuardConfigPoller( + taskCtx, + db, + outputGuardConfigPollInterval, + outputGuardConfigReadTimeout, + ) + }) +} + +func runOutputGuardConfigPoller( + ctx context.Context, + reader OutputGuardSettingsReader, + interval time.Duration, + readTimeout time.Duration, +) { + if ctx == nil { + ctx = context.Background() + } + if interval <= 0 { + interval = outputGuardConfigPollInterval + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + settings, err := readOutputGuardSettings(ctx, reader, readTimeout) + if err != nil { + if ctx.Err() != nil { + return + } + recordOutputGuardConfigSyncError(err) + continue + } + if err := database.ValidateOutputGuardSettings(settings); err != nil { + recordOutputGuardConfigSyncError(err) + continue + } + ApplyOutputGuardSettings(settings) + recordOutputGuardConfigSyncSuccess(time.Now()) + } + } +} + +func readOutputGuardSettings( + parent context.Context, + reader OutputGuardSettingsReader, + timeout time.Duration, +) (database.OutputGuardSettings, error) { + if reader == nil { + return database.OutputGuardSettings{}, fmt.Errorf("output guard settings reader is nil") + } + if parent == nil { + parent = context.Background() + } + if err := parent.Err(); err != nil { + return database.OutputGuardSettings{}, err + } + if timeout <= 0 || timeout > outputGuardConfigReadTimeout { + timeout = outputGuardConfigReadTimeout + } + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + return reader.GetOutputGuardSettings(ctx) +} + +func recordOutputGuardConfigSyncSuccess(at time.Time) { + globalOutputGuardState.mu.Lock() + globalOutputGuardState.lastSyncAt = at + globalOutputGuardState.lastSyncErr = "" + globalOutputGuardState.mu.Unlock() +} + +func recordOutputGuardConfigSyncError(err error) { + if err == nil { + return + } + globalOutputGuardState.mu.Lock() + globalOutputGuardState.lastSyncErr = err.Error() + globalOutputGuardState.mu.Unlock() +} + +// 门禁检查实现:冷启动未就绪返回 503,严禁以 log.Fatalf 冒充 503 + +func RequireOutputGuardReadyHTTP(c *gin.Context) bool { + if IsOutputGuardReady() { + return true + } + apiErr := api.NewAPIError( + api.ErrCodeServiceUnavailable, + "Service configuration unavailable; failed to load output guard settings", + api.ErrorTypeServer, + ) + api.SendErrorWithStatus(c, apiErr, http.StatusServiceUnavailable) + return false +} + +func RequireOutputGuardReadyWS(conn *websocket.Conn) bool { + if IsOutputGuardReady() { + return true + } + if conn == nil { + return false + } + apiErr := api.NewAPIError( + api.ErrCodeServiceUnavailable, + "Service configuration unavailable; failed to load output guard settings", + api.ErrorTypeServer, + ) + _ = writeResponsesWSErrorWithin(conn, apiErr, 2*time.Second) + _ = conn.WriteControl( + websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseTryAgainLater, "Service configuration unavailable"), + time.Now().Add(time.Second), + ) + _ = conn.Close() + return false +} + +func RequireOutputGuardReadyStream(conn *websocket.Conn, streamID string) bool { + if IsOutputGuardReady() { + return true + } + if conn == nil { + return false + } + apiErr := api.NewAPIError( + api.ErrCodeServiceUnavailable, + "Service configuration unavailable; failed to load output guard settings", + api.ErrorTypeServer, + ) + _ = writeResponsesWSErrorForStream(conn, apiErr, streamID, 2*time.Second) + return false +} +``` + +精确插入锚点:`main.go:265-270`(紧随 ResponseCache 设置加载与日志之后,挂载 OutputGuard 加载与 Poller): + +```go + // main.go 锚点替换:紧接 line 265 + appliedResponseCache := proxy.GetResponseCacheAppliedConfig() + log.Printf( + "响应缓存设置已加载: generation=%d total=%d entry=%d reconstruct=%d", + appliedResponseCache.Generation, + appliedResponseCache.LocalMaxBytes, + appliedResponseCache.LocalMaxEntryBytes, + appliedResponseCache.ReconstructMaxBytes, + ) + + // --- 插入开始: OutputGuard 加载与后台 5 秒轮询器启动 --- + outputGuardCtx, outputGuardCancel := context.WithTimeout(context.Background(), 3*time.Second) + if err := proxy.LoadOutputGuardSettings(outputGuardCtx, db); err != nil { + outputGuardCancel() + // 严禁 log.Fatalf 冒充 503!冷启动加载失败仅记录警告日志,保持 IsOutputGuardReady=false, + // 请求进入时由门禁准确返回 HTTP 503 api.ErrCodeServiceUnavailable,且 poller 后续轮询成功时可无缝自愈恢复。 + log.Printf("警告: 加载模型输出失控防护设置失败(冷启动门禁将返回 503 直至 DB 轮询恢复): %v", err) + } else { + outputGuardCancel() + appliedGuard := proxy.CurrentOutputGuardConfig() + log.Printf( + "模型输出失控防护设置已加载: generation=%d enabled=%t tool_bytes=%d tool_deltas=%d channel_bytes=%d request_bytes=%d", + appliedGuard.Generation, + appliedGuard.Enabled, + appliedGuard.MaxToolArgumentBytes, + appliedGuard.MaxToolArgumentDeltas, + appliedGuard.MaxChannelBytes, + appliedGuard.MaxRequestBytes, + ) + } + proxy.StartOutputGuardConfigPoller(context.Background(), db) + // --- 插入结束 --- +``` + +- [ ] **Step 4: Run test to verify it passes (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestOutputGuard.*"` +Expected: PASS ok github.com/codex2api/proxy + +- [ ] **Step 5: Commit** + +```bash +git add proxy/output_guard_settings.go proxy/output_guard_settings_test.go main.go +git commit -m "feat(proxy): add output guard runtime config snapshot, poller and cold start readiness gate" +``` + +--- + +--- + +### Task 6: 复用 Admin Settings GET/PUT 扩展与完整独立前端 UI 卡片 + +**Files:** +- Create: `admin/output_guard_settings.go` +- Create: `admin/output_guard_settings_test.go` +- Modify: `admin/handler.go:9460-9640` (`settingsResponse`, `updateSettingsReq`, `GetSettings`, `UpdateSettings`) +- Modify: `frontend/src/types.ts:2300-2315` +- Modify: `frontend/src/pages/Settings.tsx:200-220, 6545-6600` +- Test: `frontend/src/lib/outputGuardSettings.test.mjs` (新建前端纯逻辑测试) + +**Interfaces:** +- Consumes: + - `database.OutputGuardSettings` / `database.OutputGuardSettingsUpdate` (来自 Task 4) + - `proxy.CurrentOutputGuardConfig()` / `proxy.ApplyOutputGuardSettings()` (来自 Task 5) + - `database.ValidateOutputGuardSettings(database.OutputGuardSettings) error` +- Produces: + - Admin REST API: + - `GET /api/admin/settings` 返回 5 个完整对称字段 + 1 个只读 generation: + - `output_guard_enabled`: bool + - `output_guard_max_tool_argument_bytes`: int64 + - `output_guard_max_tool_argument_deltas`: int + - `output_guard_max_channel_bytes`: int64 + - `output_guard_max_request_bytes`: int64 + - `output_guard_config_generation`: int64 (只读) + - `PUT /api/admin/settings` 接收上述 5 个字段的可选 patch,若传入 `output_guard_config_generation` 则报错 400(只读字段禁止提交)。 + - Frontend TypeScript Contract (`frontend/src/types.ts`): + - `SystemSettings` 增加上述 6 个属性(含 `readonly output_guard_config_generation: number`)。 + - Frontend UI (`frontend/src/pages/Settings.tsx`): + - 位于 General Tab 独立的“模型输出失控防护 (Model Output Guard)”专属卡片(不挂在 PromptFilter 下)。 + - 5 个完整配置项输入,单位展示 KiB / MiB 与格式化转换,非法层级交互反馈(`tool <= channel <= request`),显式 false 准确提交,以及“恢复默认”5 字段原子提交。 + +- [ ] **Step 1: Write failing test for admin GET/PUT settings with output guard fields, range validation, explicit false, and readonly revision rejection** + +```go +// admin/output_guard_settings_test.go +package admin + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/codex2api/database" + "github.com/codex2api/proxy" + "github.com/gin-gonic/gin" +) + +func TestAdminOutputGuardSettingsGetSymmetricFields(t *testing.T) { + db := newTestAdminDB(t) + handler := &Handler{db: db} + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/admin/settings", nil) + + handler.GetSettings(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected HTTP 200, got %d, body: %s", w.Code, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response failed: %v", err) + } + + // Verify all 6 symmetric fields exist + fields := []string{ + "output_guard_enabled", + "output_guard_max_tool_argument_bytes", + "output_guard_max_tool_argument_deltas", + "output_guard_max_channel_bytes", + "output_guard_max_request_bytes", + "output_guard_config_generation", + } + for _, f := range fields { + if _, ok := resp[f]; !ok { + t.Fatalf("expected field %q in GET /api/admin/settings response", f) + } + } + + if resp["output_guard_enabled"] != false { + t.Fatalf("expected output_guard_enabled = false by default, got %v", resp["output_guard_enabled"]) + } + if int64(resp["output_guard_max_tool_argument_bytes"].(float64)) != 131072 { + t.Fatalf("expected tool argument bytes 131072, got %v", resp["output_guard_max_tool_argument_bytes"]) + } +} + +func TestAdminOutputGuardSettingsPutValidationAndReadOnlyRevision(t *testing.T) { + db := newTestAdminDB(t) + handler := &Handler{db: db} + gin.SetMode(gin.TestMode) + + // 1. Rejection of readonly output_guard_config_generation + { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPut, "/api/admin/settings", strings.NewReader(`{"output_guard_config_generation": 999}`)) + c.Request.Header.Set("Content-Type", "application/json") + handler.UpdateSettings(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected HTTP 400 when submitting readonly output_guard_config_generation, got %d", w.Code) + } + if !strings.Contains(w.Body.String(), "只读") { + t.Fatalf("expected error mentioning readonly, got %s", w.Body.String()) + } + } + + // 2. Rejection of out-of-range parameters (16KiB..4MiB, etc.) + { + // Tool bytes too small: 1024 < 16384 + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPut, "/api/admin/settings", strings.NewReader(`{"output_guard_max_tool_argument_bytes": 1024}`)) + c.Request.Header.Set("Content-Type", "application/json") + handler.UpdateSettings(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected HTTP 400 for tool bytes < 16KiB, got %d", w.Code) + } + } + + // 3. Rejection of hierarchy violation: tool > channel + { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPut, "/api/admin/settings", strings.NewReader(`{"output_guard_max_tool_argument_bytes": 3145728, "output_guard_max_channel_bytes": 2097152}`)) + c.Request.Header.Set("Content-Type", "application/json") + handler.UpdateSettings(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected HTTP 400 for hierarchy violation tool > channel, got %d", w.Code) + } + } + + // 4. Successful update with explicit false & verify generation increment + { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + payload := `{"output_guard_enabled": false, "output_guard_max_tool_argument_bytes": 65536}` + c.Request = httptest.NewRequest(http.MethodPut, "/api/admin/settings", strings.NewReader(payload)) + c.Request.Header.Set("Content-Type", "application/json") + handler.UpdateSettings(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected HTTP 200 for valid PUT, got %d, body: %s", w.Code, w.Body.String()) + } + + var resp map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if resp["output_guard_enabled"] != false { + t.Fatalf("expected output_guard_enabled = false, got %v", resp["output_guard_enabled"]) + } + if int64(resp["output_guard_max_tool_argument_bytes"].(float64)) != 65536 { + t.Fatalf("expected tool bytes = 65536, got %v", resp["output_guard_max_tool_argument_bytes"]) + } + if int64(resp["output_guard_config_generation"].(float64)) <= 1 { + t.Fatalf("expected generation incremented, got %v", resp["output_guard_config_generation"]) + } + + // Also check local runtime instance updated immediately + cfg := proxy.CurrentOutputGuardConfig() + if cfg.Enabled != false || cfg.MaxToolArgumentBytes != 65536 { + t.Fatalf("local proxy runtime config not updated immediately: %+v", cfg) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./admin -run "TestAdminOutputGuard.*"` +Expected: FAIL with "unknown field output_guard_enabled" or failed assertions. + +- [ ] **Step 3: Write minimal implementation for admin backend and frontend integration** + +1. 编写 `admin/output_guard_settings.go`: +```go +// admin/output_guard_settings.go +package admin + +import ( + "fmt" + "github.com/codex2api/database" +) + +func validateOutputGuardSettingsUpdateRanges(update database.OutputGuardSettingsUpdate) error { + if update.MaxToolArgumentBytes != nil { + val := *update.MaxToolArgumentBytes + if val < 16384 || val > 4194304 { + return fmt.Errorf("output_guard_max_tool_argument_bytes 必须在 16KiB(16384) ~ 4MiB(4194304) 之间") + } + } + if update.MaxToolArgumentDeltas != nil { + val := *update.MaxToolArgumentDeltas + if val < 256 || val > 65536 { + return fmt.Errorf("output_guard_max_tool_argument_deltas 必须在 256 ~ 65536 之间") + } + } + if update.MaxChannelBytes != nil { + val := *update.MaxChannelBytes + if val < 131072 || val > 16777216 { + return fmt.Errorf("output_guard_max_channel_bytes 必须在 128KiB(131072) ~ 16MiB(16777216) 之间") + } + } + if update.MaxRequestBytes != nil { + val := *update.MaxRequestBytes + if val < 262144 || val > 67108864 { + return fmt.Errorf("output_guard_max_request_bytes 必须在 256KiB(262144) ~ 64MiB(67108864) 之间") + } + } + return nil +} +``` + +2. 在 `admin/handler.go` 中扩展 `settingsResponse`、`updateSettingsReq`、`GetSettings` 及 `UpdateSettings`: +- 在 `settingsResponse`(约 line 9460)中添加: +```go + OutputGuardEnabled bool `json:"output_guard_enabled"` + OutputGuardMaxToolArgumentBytes int64 `json:"output_guard_max_tool_argument_bytes"` + OutputGuardMaxToolArgumentDeltas int `json:"output_guard_max_tool_argument_deltas"` + OutputGuardMaxChannelBytes int64 `json:"output_guard_max_channel_bytes"` + OutputGuardMaxRequestBytes int64 `json:"output_guard_max_request_bytes"` + OutputGuardConfigGeneration int64 `json:"output_guard_config_generation"` +``` + +- 在 `updateSettingsReq`(约 line 9635)中添加: +```go + OutputGuardEnabled *bool `json:"output_guard_enabled"` + OutputGuardMaxToolArgumentBytes *int64 `json:"output_guard_max_tool_argument_bytes"` + OutputGuardMaxToolArgumentDeltas *int `json:"output_guard_max_tool_argument_deltas"` + OutputGuardMaxChannelBytes *int64 `json:"output_guard_max_channel_bytes"` + OutputGuardMaxRequestBytes *int64 `json:"output_guard_max_request_bytes"` + OutputGuardConfigGeneration rawJSON `json:"output_guard_config_generation"` +``` + +- 在 `GetSettings`(约 line 10240 之后)读取并注入 output guard 字段: +```go + outputGuardSettings, err := h.db.GetOutputGuardSettings(ctx) + if err != nil { + outputGuardSettings = database.DefaultOutputGuardSettings() + } + outputGuardEnabled := true + if outputGuardSettings.Enabled != nil { + outputGuardEnabled = *outputGuardSettings.Enabled + } +``` +并在 `settingsResponse` 结构字面量中填充: +```go + OutputGuardEnabled: outputGuardEnabled, + OutputGuardMaxToolArgumentBytes: outputGuardSettings.MaxToolArgumentBytes, + OutputGuardMaxToolArgumentDeltas: outputGuardSettings.MaxToolArgumentDeltas, + OutputGuardMaxChannelBytes: outputGuardSettings.MaxChannelBytes, + OutputGuardMaxRequestBytes: outputGuardSettings.MaxRequestBytes, + OutputGuardConfigGeneration: outputGuardSettings.ConfigGeneration, +``` + +- 在 `UpdateSettings`(约 line 10600 及 line 10750)添加只读校验、更新合并与立即生效逻辑: +```go + if req.OutputGuardConfigGeneration != nil { + writeError(c, http.StatusBadRequest, "output_guard_config_generation 为只读字段") + return + } + + outputGuardUpdate := database.OutputGuardSettingsUpdate{ + Enabled: req.OutputGuardEnabled, + MaxToolArgumentBytes: req.OutputGuardMaxToolArgumentBytes, + MaxToolArgumentDeltas: req.OutputGuardMaxToolArgumentDeltas, + MaxChannelBytes: req.OutputGuardMaxChannelBytes, + MaxRequestBytes: req.OutputGuardMaxRequestBytes, + } + outputGuardUpdateRequested := outputGuardUpdate.Enabled != nil || + outputGuardUpdate.MaxToolArgumentBytes != nil || + outputGuardUpdate.MaxToolArgumentDeltas != nil || + outputGuardUpdate.MaxChannelBytes != nil || + outputGuardUpdate.MaxRequestBytes != nil + + if err := validateOutputGuardSettingsUpdateRanges(outputGuardUpdate); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + + var outputGuardSettings database.OutputGuardSettings + if outputGuardUpdateRequested { + currentGuard, err := h.db.GetOutputGuardSettings(c.Request.Context()) + if err != nil { + writeError(c, http.StatusInternalServerError, "读取防护设置失败:"+err.Error()) + return + } + mergedGuard := currentGuard + if outputGuardUpdate.Enabled != nil { + val := *outputGuardUpdate.Enabled + mergedGuard.Enabled = &val + } + if outputGuardUpdate.MaxToolArgumentBytes != nil { + mergedGuard.MaxToolArgumentBytes = *outputGuardUpdate.MaxToolArgumentBytes + } + if outputGuardUpdate.MaxToolArgumentDeltas != nil { + mergedGuard.MaxToolArgumentDeltas = *outputGuardUpdate.MaxToolArgumentDeltas + } + if outputGuardUpdate.MaxChannelBytes != nil { + mergedGuard.MaxChannelBytes = *outputGuardUpdate.MaxChannelBytes + } + if outputGuardUpdate.MaxRequestBytes != nil { + mergedGuard.MaxRequestBytes = *outputGuardUpdate.MaxRequestBytes + } + if err := database.ValidateOutputGuardSettings(mergedGuard); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + + committedGuard, err := h.db.UpdateOutputGuardSettings(c.Request.Context(), outputGuardUpdate) + if err != nil { + writeError(c, http.StatusInternalServerError, "保存防护设置失败:"+err.Error()) + return + } + outputGuardSettings = committedGuard + proxy.ApplyOutputGuardSettings(committedGuard) + } else { + latestGuard, err := h.db.GetOutputGuardSettings(c.Request.Context()) + if err != nil { + writeError(c, http.StatusInternalServerError, "读取防护设置失败:"+err.Error()) + return + } + outputGuardSettings = latestGuard + } +``` + +并在 `UpdateSettings` 结尾的 `c.JSON(http.StatusOK, response)` 中带上最新的 `outputGuardSettings` 字段。 + +3. 修改 `frontend/src/types.ts`(约 line 2305 处): +```typescript + output_guard_enabled: boolean + output_guard_max_tool_argument_bytes: number + output_guard_max_tool_argument_deltas: number + output_guard_max_channel_bytes: number + output_guard_max_request_bytes: number + readonly output_guard_config_generation: number +``` + +4. 编写前端纯函数测试 `frontend/src/lib/outputGuardSettings.test.mjs`: +```javascript +import test from 'node:test' +import assert from 'node:assert/strict' + +const KIB = 1024 +const MIB = 1024 * 1024 + +export const DEFAULT_OUTPUT_GUARD_SETTINGS = { + output_guard_enabled: false, + output_guard_max_tool_argument_bytes: 256 * KIB, + output_guard_max_tool_argument_deltas: 4096, + output_guard_max_channel_bytes: 2 * MIB, + output_guard_max_request_bytes: 4 * MIB, +} + +export function validateOutputGuardHierarchy(settings) { + const tool = settings.output_guard_max_tool_argument_bytes + const channel = settings.output_guard_max_channel_bytes + const request = settings.output_guard_max_request_bytes + if (tool > channel) { + return '工具参数上限不能超过单 Channel 字节上限' + } + if (channel > request) { + return '单 Channel 字节上限不能超过单请求语义预算' + } + return null +} + +export function bytesToKiB(bytes) { + return Math.round(bytes / KIB) +} + +export function kibToBytes(kib) { + return kib * KIB +} + +export function bytesToMiB(bytes) { + return Math.round(bytes / MIB) +} + +export function mibToBytes(mib) { + return mib * MIB +} + +test('validateOutputGuardHierarchy enforces tool <= channel <= request', () => { + assert.equal(validateOutputGuardHierarchy({ + output_guard_max_tool_argument_bytes: 128 * KIB, + output_guard_max_channel_bytes: 2 * MIB, + output_guard_max_request_bytes: 4 * MIB, + }), null) + + assert.equal(validateOutputGuardHierarchy({ + output_guard_max_tool_argument_bytes: 3 * MIB, + output_guard_max_channel_bytes: 2 * MIB, + output_guard_max_request_bytes: 4 * MIB, + }), '工具参数上限不能超过单 Channel 字节上限') + + assert.equal(validateOutputGuardHierarchy({ + output_guard_max_tool_argument_bytes: 128 * KIB, + output_guard_max_channel_bytes: 8 * MIB, + output_guard_max_request_bytes: 4 * MIB, + }), '单 Channel 字节上限不能超过单请求语义预算') +}) +``` + +5. 在 `frontend/src/pages/Settings.tsx` 中新增“模型输出失控防护”分组卡片: +- 在 constants 区域定义: +```typescript +const KIB = 1024 +const DEFAULT_OUTPUT_GUARD_TOOL_BYTES = 128 * KIB +const DEFAULT_OUTPUT_GUARD_TOOL_DELTAS = 4096 +const DEFAULT_OUTPUT_GUARD_CHANNEL_BYTES = 2 * MIB +const DEFAULT_OUTPUT_GUARD_REQUEST_BYTES = 4 * MIB + +const OUTPUT_GUARD_KEYS = [ + 'output_guard_enabled', + 'output_guard_max_tool_argument_bytes', + 'output_guard_max_tool_argument_deltas', + 'output_guard_max_channel_bytes', + 'output_guard_max_request_bytes', +] as const satisfies ReadonlyArray +``` + +- 在 JSX 中(位于 General Tab 的安全分区 `id="settings-security"` 内,紧邻 Prompt 风控卡片之后,新增独立的 `SettingsCard`): +```tsx + } + tone={settingsForm.output_guard_enabled ? 'default' : 'danger'} + badge={ + + + {settingsForm.output_guard_enabled ? '已启用全局防护' : '已关闭 (高风险)'} + + } + actions={ + + } + > +
+ {!settingsForm.output_guard_enabled && ( +
+ +
+

高风险操作警告:失控防护已全局关闭

+

关闭后模型失控生成死循环、重复代码或细碎 arguments delta 时,将直接击穿代理并在下游引发 O(n²) 全量重解析雪崩。仅用于临时诊断,请尽快重新开启!

+
+
+ )} + +
+
+
启用失控输出防护
+
全局实时检测持续短周期重复、细碎 arguments 泛洪与字节预算超额并前置切断
+
+ { + setSettingsForm(f => ({ ...f, output_guard_enabled: checked })) + autoSaveSettingsPatch({ output_guard_enabled: checked }) + }} + /> +
+ +
+ + { + const val = Math.max(16, Math.min(4096, parseInt(e.target.value) || 128)) * 1024 + setSettingsForm(f => ({ ...f, output_guard_max_tool_argument_bytes: val })) + }} + onBlur={() => autoSaveSettingsPatch({ output_guard_max_tool_argument_bytes: settingsForm.output_guard_max_tool_argument_bytes })} + /> + + + + { + const val = Math.max(256, Math.min(65536, parseInt(e.target.value) || 4096)) + setSettingsForm(f => ({ ...f, output_guard_max_tool_argument_deltas: val })) + }} + onBlur={() => autoSaveSettingsPatch({ output_guard_max_tool_argument_deltas: settingsForm.output_guard_max_tool_argument_deltas })} + /> + + + + { + const val = Math.max(128 * 1024, Math.min(16 * 1024 * 1024, Math.round((parseFloat(e.target.value) || 2) * 1024 * 1024))) + setSettingsForm(f => ({ ...f, output_guard_max_channel_bytes: val })) + }} + onBlur={() => autoSaveSettingsPatch({ output_guard_max_channel_bytes: settingsForm.output_guard_max_channel_bytes })} + /> + + + + { + const val = Math.max(256 * 1024, Math.min(64 * 1024 * 1024, Math.round((parseFloat(e.target.value) || 4) * 1024 * 1024))) + setSettingsForm(f => ({ ...f, output_guard_max_request_bytes: val })) + }} + onBlur={() => autoSaveSettingsPatch({ output_guard_max_request_bytes: settingsForm.output_guard_max_request_bytes })} + /> + +
+ + {settingsForm.output_guard_max_tool_argument_bytes > settingsForm.output_guard_max_channel_bytes && ( +

层级错误:工具参数上限不能超过单 Channel 字节上限!

+ )} + {settingsForm.output_guard_max_channel_bytes > settingsForm.output_guard_max_request_bytes && ( +

层级错误:单 Channel 字节上限不能超过单请求语义预算!

+ )} +
+
+``` + +- [ ] **Step 4: Run test to verify it passes (Green)** + +Run: +1. `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./admin -run "TestAdminOutputGuard.*"` +Expected: PASS ok github.com/codex2api/admin +2. `cd frontend && node --experimental-strip-types --test src/lib/outputGuardSettings.test.mjs` +Expected: PASS ok +3. `cd frontend && pnpm run typecheck` +Expected: exit code 0, no errors + +- [ ] **Step 5: Commit** + +```bash +git add admin/output_guard_settings.go admin/output_guard_settings_test.go admin/handler.go frontend/src/types.ts frontend/src/pages/Settings.tsx frontend/src/lib/outputGuardSettings.test.mjs +git commit -m "feat(admin,frontend): add output guard settings GET/PUT and standalone management UI card" +``` + +--- + +--- + +### Task 7: 统一协议感知投影层与跨帧状态机分派器 + +**Files:** +- Create: `proxy/outputguard_projection.go` +- Test: `proxy/outputguard_projection_test.go` + +**Interfaces:** +- Consumes: + - `security/outputguard`(严格对齐 Part A exact 签名与实现): + ```go + type ChannelKind string + const ( + KindText ChannelKind = "text" + KindThinking ChannelKind = "thinking" + KindToolArguments ChannelKind = "tool_arguments" + ) + type Config struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 + } + type RequestGuard struct { ... } + func NewRequestGuard(cfg Config) *RequestGuard + func (g *RequestGuard) IsTripped() bool + func (g *RequestGuard) TrippedReason() string + func (g *RequestGuard) RecordEvent() error + func (g *RequestGuard) ObserveAppend(channelID string, aliasID string, kind ChannelKind, delta []byte) error + func (g *RequestGuard) ObserveSnapshot(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error + func (g *RequestGuard) ObserveAppendNoEvent(channelID string, aliasID string, kind ChannelKind, delta []byte) error + func (g *RequestGuard) ObserveSnapshotNoEvent(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error + func (g *RequestGuard) EndChannel(channelID string) error + ``` + - `github.com/tidwall/gjson` (go.mod) + - `github.com/tidwall/sjson` (go.mod) +- Produces: + ```go + package proxy + + import ( + "github.com/codex2api/security/outputguard" + ) + + type ObservationAction string + const ( + ActionAppend ObservationAction = "append" + ActionSnapshot ObservationAction = "snapshot" + ActionEnd ObservationAction = "end" + ) + + type Observation struct { + ChannelID string + AliasID string + Kind outputguard.ChannelKind + Action ObservationAction + Payload []byte + TerminalStage string // 严格白名单限制为 3 种合法语义终态阶段之一: "arguments.done", "item.done", "response.completed",其余普通帧必须置为空字符串 + } + + type ProtocolProjector interface { + Project(eventType string, data []byte) ([]Observation, error) + } + + type ResponsesProjector struct { + aliasMap map[string]string + } + func NewResponsesProjector() *ResponsesProjector + func (p *ResponsesProjector) Project(eventType string, data []byte) ([]Observation, error) + + type ChatProjector struct { + aliasMap map[string]string + } + func NewChatProjector() *ChatProjector + func (p *ChatProjector) Project(eventType string, data []byte) ([]Observation, error) + + type AnthropicProjector struct { + blockTypes map[int]string + blockIDs map[int]string + } + func NewAnthropicProjector() *AnthropicProjector + func (p *AnthropicProjector) Project(eventType string, data []byte) ([]Observation, error) + + type GeminiProjector struct{} + func NewGeminiProjector() *GeminiProjector + func (p *GeminiProjector) Project(eventType string, data []byte) ([]Observation, error) + + // ObserveEvent 核心事件分派器: + // 1. 业务事件计数:严格每上游事件计 1 次,心跳/注释除外;unknown/control/empty 业务帧必须消耗事件预算; + // 2. 调用 projector.Project 解出当前 envelope 的 observations; + // 3. 工具 arguments 仅解当前 envelope 或原生 object bounded serialize,不每 delta 重 parse inner JSON; + // 4. 稳定 channel key 跨帧 alias 映射维护与测试验证; + // 5. 调用 guard 执行判定流水线,端到端保障状态机语义一致性。 + func ObserveEvent(guard *outputguard.RequestGuard, projector ProtocolProjector, eventType string, data []byte) error + ``` + +- [ ] **Step 1: Write failing test for protocol projection and event dispatcher** + +```go +// proxy/outputguard_projection_test.go +package proxy + +import ( + "fmt" + "strings" + "testing" + + "github.com/codex2api/security/outputguard" +) + +func defaultProjectionTestConfig() outputguard.Config { + return outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, // 128 KiB + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, // 2 MiB + MaxRequestBytes: 4194304, // 4 MiB + } +} + +// 1. Responses 协议流式、3 个合法语义终态阶段快照与 alias 跨帧映射测试 +func TestResponsesProjectorStreamAndTerminalSnapshots(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewResponsesProjector() + + // Text delta + textDelta := `{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"Hello, "}` + if err := ObserveEvent(guard, proj, "response.output_text.delta", []byte(textDelta)); err != nil { + t.Fatalf("text delta observe failed: %v", err) + } + + // Thinking delta + thinkingDelta := `{"type":"response.reasoning.delta","item_id":"msg_1","delta":"thinking step 1"}` + if err := ObserveEvent(guard, proj, "response.reasoning.delta", []byte(thinkingDelta)); err != nil { + t.Fatalf("thinking delta observe failed: %v", err) + } + + // Tool call delta with call_id and item_id alias + toolDelta := `{"type":"response.function_call_arguments.delta","item_id":"item_call_1","call_id":"call_write_file","output_index":1,"delta":"{\"path\":\"test.txt\""}` + if err := ObserveEvent(guard, proj, "response.function_call_arguments.delta", []byte(toolDelta)); err != nil { + t.Fatalf("tool delta observe failed: %v", err) + } + + // Tool call done (终态阶段 1: arguments.done 纯终态确认快照) + toolDone := `{"type":"response.function_call_arguments.done","item_id":"item_call_1","call_id":"call_write_file","arguments":"{\"path\":\"test.txt\"}"}` + if err := ObserveEvent(guard, proj, "response.function_call_arguments.done", []byte(toolDone)); err != nil { + t.Fatalf("tool done observe failed: %v", err) + } + + // output_item.done (终态阶段 2: item.done 纯终态确认快照) + itemDone := `{"type":"response.output_item.done","item":{"id":"item_call_1","type":"function_call","call_id":"call_write_file","arguments":"{\"path\":\"test.txt\"}"}}` + if err := ObserveEvent(guard, proj, "response.output_item.done", []byte(itemDone)); err != nil { + t.Fatalf("item done observe failed: %v", err) + } + + // response.completed (终态阶段 3: response.completed 纯终态确认快照) + completed := `{"type":"response.completed","response":{"id":"resp_1","status":"completed","output":[{"id":"item_call_1","type":"function_call","call_id":"call_write_file","arguments":"{\"path\":\"test.txt\"}"}]}}` + if err := ObserveEvent(guard, proj, "response.completed", []byte(completed)); err != nil { + t.Fatalf("response completed observe failed: %v", err) + } + + if guard.IsTripped() { + t.Fatalf("expected guard not tripped, got %s", guard.TrippedReason()) + } +} + +// 2. ChatCompletions 协议 choice/toolindex/callid alias 隔离与流式及非流式测试 +func TestChatProjectorStreamingAndNonStreaming(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewChatProjector() + + // 流式 Chunk 1: content + reasoning + tool_call 首帧 (带 call_id 与 index) + chunk1 := `{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"foo","reasoning_content":"think","tool_calls":[{"index":0,"id":"call_123","function":{"name":"search","arguments":"{\"q\":"}}]}}]}` + if err := ObserveEvent(guard, proj, "", []byte(chunk1)); err != nil { + t.Fatalf("chunk 1 observe error: %v", err) + } + + // 流式 Chunk 2: tool_call 后续 delta (仅带 index, 缺失 id, 验证 aliasMap 稳定解析到同一 channel) + chunk2 := `{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"golang\"}"}}]}}]}` + if err := ObserveEvent(guard, proj, "", []byte(chunk2)); err != nil { + t.Fatalf("chunk 2 observe error: %v", err) + } + + // 验证不同 choice 和不同 tool_index 隔离,不发生错合并 + chunkChoice1 := `{"id":"chatcmpl-1","choices":[{"index":1,"delta":{"content":"bar"}}]}` + if err := ObserveEvent(guard, proj, "", []byte(chunkChoice1)); err != nil { + t.Fatalf("chunk choice 1 observe error: %v", err) + } + + // 非流式 JSON 测试 + nonStream := `{"id":"chatcmpl-2","choices":[{"index":0,"message":{"role":"assistant","content":"final content","tool_calls":[{"id":"call_999","type":"function","function":{"name":"calc","arguments":"{\"val\":42}"}}]}}]}` + guardNonStream := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + projNonStream := NewChatProjector() + if err := ObserveEvent(guardNonStream, projNonStream, "", []byte(nonStream)); err != nil { + t.Fatalf("non-stream chat observe error: %v", err) + } + if guardNonStream.IsTripped() { + t.Fatalf("expected non-stream not tripped, got %s", guardNonStream.TrippedReason()) + } +} + +// 3. Anthropic Messages 协议真实 fixture 与 content_block 状态流转测试 +func TestAnthropicProjectorStreamLifecycleAndNonStream(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewAnthropicProjector() + + // message_start + msgStart := `{"type":"message_start","message":{"id":"msg_123","type":"message","role":"assistant","content":[]}}` + if err := ObserveEvent(guard, proj, "message_start", []byte(msgStart)); err != nil { + t.Fatalf("msg start error: %v", err) + } + + // block 0: thinking + b0Start := `{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}` + if err := ObserveEvent(guard, proj, "content_block_start", []byte(b0Start)); err != nil { + t.Fatalf("b0 start error: %v", err) + } + b0Delta := `{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"let me think"}}` + if err := ObserveEvent(guard, proj, "content_block_delta", []byte(b0Delta)); err != nil { + t.Fatalf("b0 delta error: %v", err) + } + b0Stop := `{"type":"content_block_stop","index":0}` + if err := ObserveEvent(guard, proj, "content_block_stop", []byte(b0Stop)); err != nil { + t.Fatalf("b0 stop error: %v", err) + } + + // block 1: tool_use + b1Start := `{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01ABC","name":"exec"}}` + if err := ObserveEvent(guard, proj, "content_block_start", []byte(b1Start)); err != nil { + t.Fatalf("b1 start error: %v", err) + } + b1Delta := `{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"cmd\":\"ls\"}"}}` + if err := ObserveEvent(guard, proj, "content_block_delta", []byte(b1Delta)); err != nil { + t.Fatalf("b1 delta error: %v", err) + } + b1Stop := `{"type":"content_block_stop","index":1}` + if err := ObserveEvent(guard, proj, "content_block_stop", []byte(b1Stop)); err != nil { + t.Fatalf("b1 stop error: %v", err) + } + + // message_stop + msgStop := `{"type":"message_stop"}` + if err := ObserveEvent(guard, proj, "message_stop", []byte(msgStop)); err != nil { + t.Fatalf("msg stop error: %v", err) + } + + // 非流式 Anthropic 响应 + nonStream := `{"id":"msg_456","type":"message","role":"assistant","content":[{"type":"text","text":"Done!"},{"type":"tool_use","id":"toolu_02XYZ","name":"write","input":{"content":"file content"}}]}` + guardAnthropicNonStream := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + projAnthropicNonStream := NewAnthropicProjector() + if err := ObserveEvent(guardAnthropicNonStream, projAnthropicNonStream, "", []byte(nonStream)); err != nil { + t.Fatalf("anthropic non-stream observe error: %v", err) + } + if guardAnthropicNonStream.IsTripped() { + t.Fatalf("expected anthropic non-stream not tripped, got %s", guardAnthropicNonStream.TrippedReason()) + } +} + +// 4. Gemini Native (streamGenerateContent & generateContent) 真实结构与 Object 紧凑序列化测试 +func TestGeminiProjectorStreamAndNonStreamObjectSerialization(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewGeminiProjector() + + // 流式 candidate text + thought + geminiChunk := `{"candidates":[{"index":0,"content":{"role":"model","parts":[{"text":"hello gemini"},{"thought":true,"text":"inner thoughts"},{"functionCall":{"name":"search","args":{"query":"codex"}}}]}}]}` + if err := ObserveEvent(guard, proj, "", []byte(geminiChunk)); err != nil { + t.Fatalf("gemini chunk observe error: %v", err) + } + + // Gemini 非流式带 finishReason + geminiNonStreamJSON := `{"candidates":[{"index":0,"content":{"role":"model","parts":[{"functionCall":{"name":"fetch","args":{"url":"https://example.com","headers":{"Auth":"secret"}}}}]外},"finishReason":"STOP"}]}` + geminiNonStreamJSON = strings.ReplaceAll(geminiNonStreamJSON, "外", "") + + guardGemini := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + projGemini := NewGeminiProjector() + if err := ObserveEvent(guardGemini, projGemini, "", []byte(geminiNonStreamJSON)); err != nil { + t.Fatalf("gemini non-stream observe error: %v", err) + } + if guardGemini.IsTripped() { + t.Fatalf("expected gemini non-stream not tripped, got %s", guardGemini.TrippedReason()) + } +} + +// 5. 事件计数契约:每上游事件一次,unknown/control/empty 业务事件严格扣减事件预算,心跳注释除外 +func TestObserveEventBudgetAccountability(t *testing.T) { + // 极小事件预算配置:MaxRequestEvents = 65536 + guard := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + proj := NewResponsesProjector() + + // SSE 注释与 ping 帧不消耗业务事件预算 + if err := ObserveEvent(guard, proj, "", []byte(": keep-alive ping\n")); err != nil { + t.Fatalf("comment frame should not fail: %v", err) + } + if err := ObserveEvent(guard, proj, "ping", []byte("{}")); err != nil { + t.Fatalf("ping event should not fail: %v", err) + } + + // 构造 65536 个 unknown / empty 业务事件洪泛 + for i := 0; i < outputguard.MaxRequestEvents; i++ { + // unknown 或 control 事件,虽然投影为 0 个 observation,但必须计入业务事件 + unknownEvent := `{"type":"response.unknown_control_event","id":"ctrl_1"}` + if err := ObserveEvent(guard, proj, "response.unknown_control_event", []byte(unknownEvent)); err != nil { + t.Fatalf("event %d unexpectedly failed: %v", i, err) + } + } + + // 第 65537 个事件必须被 request_events 坚决拦截,不能因 control/unknown 绕过! + err := ObserveEvent(guard, proj, "response.created", []byte(`{"type":"response.created"}`)) + if err == nil || err.Error() != "request_events" { + t.Fatalf("expected request_events rejection for control/unknown flood, got %v", err) + } + if guard.TrippedReason() != "request_events" { + t.Fatalf("expected tripped reason request_events, got %s", guard.TrippedReason()) + } +} + +// 6. 验证 Custom Tool Arguments String 仅解码当前 envelope,不每 delta 重 parse inner JSON +func TestCustomToolArgumentsEnvelopePreservation(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewResponsesProjector() + + // 构造未闭合且包含转义与大段文本的 arguments 片段 + incompleteJSONChunk := `{"type":"response.function_call_arguments.delta","call_id":"call_custom","delta":"{\"nested_key\":\"val\\nwith\\\"quotes and unclosed brackets..."}` + if err := ObserveEvent(guard, proj, "response.function_call_arguments.delta", []byte(incompleteJSONChunk)); err != nil { + t.Fatalf("custom tool envelope observe error: %v", err) + } + + if guard.IsTripped() { + t.Fatalf("expected guard not tripped on unclosed inner JSON, got %s", guard.TrippedReason()) + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestResponsesProjector.*|TestChatProjector.*|TestAnthropicProjector.*|TestGeminiProjector.*|TestObserveEvent.*|TestCustomToolArgumentsEnvelopePreservation"` +Expected: FAIL with compilation error: undefined: NewResponsesProjector, NewChatProjector, NewAnthropicProjector, NewGeminiProjector, ObserveEvent + +- [ ] **Step 3: Write minimal implementation** + +调用 Task 3 中已完整定义和测试的 `RequestGuard.RecordEvent()`、`ObserveAppendNoEvent()`、`ObserveSnapshotNoEvent()` 与 `EndChannel()` 实现协议投影器与分派器: + +新建 `proxy/outputguard_projection.go`: +```go +// proxy/outputguard_projection.go +package proxy + +import ( + "bytes" + "fmt" + + "github.com/codex2api/security/outputguard" + "github.com/tidwall/gjson" +) + +type ObservationAction string + +const ( + ActionAppend ObservationAction = "append" + ActionSnapshot ObservationAction = "snapshot" + ActionEnd ObservationAction = "end" +) + +type Observation struct { + ChannelID string + AliasID string + Kind outputguard.ChannelKind + Action ObservationAction + Payload []byte + TerminalStage string // 严格限制为 3 种合法语义终态阶段: "arguments.done", "item.done", "response.completed",其余普通帧置空 +} + +type ProtocolProjector interface { + Project(eventType string, data []byte) ([]Observation, error) +} + +// ------------------------------------------------------------- +// 1. ResponsesProjector (HTTP SSE, WebSocket, Realtime Text) +// ------------------------------------------------------------- + +type ResponsesProjector struct { + aliasMap map[string]string +} + +func NewResponsesProjector() *ResponsesProjector { + return &ResponsesProjector{ + aliasMap: make(map[string]string), + } +} + +func (p *ResponsesProjector) Project(eventType string, data []byte) ([]Observation, error) { + if len(data) == 0 { + return nil, nil + } + parsed := gjson.ParseBytes(data) + t := eventType + if t == "" { + t = parsed.Get("type").String() + } + + var observations []Observation + + switch t { + case "response.output_text.delta": + itemID := parsed.Get("item_id").String() + delta := parsed.Get("delta").String() + if itemID == "" { + itemID = fmt.Sprintf("output_%d", parsed.Get("output_index").Int()) + } + observations = append(observations, Observation{ + ChannelID: itemID, + Kind: outputguard.KindText, + Action: ActionAppend, + Payload: []byte(delta), + }) + + case "response.reasoning.delta", "response.thinking.delta": + itemID := parsed.Get("item_id").String() + delta := parsed.Get("delta").String() + if itemID == "" { + itemID = fmt.Sprintf("output_%d", parsed.Get("output_index").Int()) + } + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("%s.thinking", itemID), + Kind: outputguard.KindThinking, + Action: ActionAppend, + Payload: []byte(delta), + }) + + case "response.function_call_arguments.delta": + callID := parsed.Get("call_id").String() + itemID := parsed.Get("item_id").String() + delta := parsed.Get("delta").String() + targetID := callID + if targetID == "" { + targetID = itemID + } + if targetID == "" { + targetID = fmt.Sprintf("output_%d", parsed.Get("output_index").Int()) + } + observations = append(observations, Observation{ + ChannelID: targetID, + AliasID: itemID, + Kind: outputguard.KindToolArguments, + Action: ActionAppend, + Payload: []byte(delta), + }) + + case "response.function_call_arguments.done": + callID := parsed.Get("call_id").String() + itemID := parsed.Get("item_id").String() + args := parsed.Get("arguments").String() + targetID := callID + if targetID == "" { + targetID = itemID + } + observations = append(observations, Observation{ + ChannelID: targetID, + AliasID: itemID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: []byte(args), + TerminalStage: "arguments.done", + }) + + case "response.output_item.done": + item := parsed.Get("item") + itemType := item.Get("type").String() + itemID := item.Get("id").String() + if itemID == "" { + itemID = parsed.Get("item_id").String() + } + + if itemType == "function_call" { + callID := item.Get("call_id").String() + args := item.Get("arguments").String() + observations = append(observations, Observation{ + ChannelID: callID, + AliasID: itemID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: []byte(args), + TerminalStage: "item.done", + }) + } else if itemType == "message" { + // 修复 M4:遍历全部 content blocks 建立独立 channel,杜绝仅提取 content.0.text 漏掉其他 content + contentArray := item.Get("content").Array() + for idx, cBlock := range contentArray { + text := cBlock.Get("text").String() + if text != "" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("%s:content:%d", itemID, idx), + Kind: outputguard.KindText, + Action: ActionSnapshot, + Payload: []byte(text), + TerminalStage: "item.done", + }) + } + } + } + + case "response.completed": + outputs := parsed.Get("response.output").Array() + for _, item := range outputs { + itemType := item.Get("type").String() + itemID := item.Get("id").String() + if itemType == "function_call" { + callID := item.Get("call_id").String() + args := item.Get("arguments").String() + targetID := callID + if targetID == "" { + targetID = itemID + } + observations = append(observations, Observation{ + ChannelID: targetID, + AliasID: itemID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: []byte(args), + TerminalStage: "response.completed", + }) + } else if itemType == "message" { + text := item.Get("content.0.text").String() + if text != "" { + observations = append(observations, Observation{ + ChannelID: itemID, + Kind: outputguard.KindText, + Action: ActionSnapshot, + Payload: []byte(text), + TerminalStage: "response.completed", + }) + } + } + } + + default: + // 兼容非流式完整响应 JSON + if parsed.Get("status").String() == "completed" && parsed.Get("output").Exists() { + for _, item := range parsed.Get("output").Array() { + itemType := item.Get("type").String() + itemID := item.Get("id").String() + if itemType == "function_call" { + callID := item.Get("call_id").String() + args := item.Get("arguments").String() + targetID := callID + if targetID == "" { + targetID = itemID + } + observations = append(observations, Observation{ + ChannelID: targetID, + AliasID: itemID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: []byte(args), + TerminalStage: "response.completed", + }) + } else if itemType == "message" { + text := item.Get("content.0.text").String() + if text != "" { + observations = append(observations, Observation{ + ChannelID: itemID, + Kind: outputguard.KindText, + Action: ActionSnapshot, + Payload: []byte(text), + TerminalStage: "response.completed", + }) + } + } + } + } + } + + return observations, nil +} + +// ------------------------------------------------------------- +// 2. ChatProjector (OpenAI /v1/chat/completions Stream & JSON) +// ------------------------------------------------------------- + +type ChatProjector struct { + aliasMap map[string]string +} + +func NewChatProjector() *ChatProjector { + return &ChatProjector{ + aliasMap: make(map[string]string), + } +} + +func (p *ChatProjector) Project(eventType string, data []byte) ([]Observation, error) { + if len(data) == 0 { + return nil, nil + } + parsed := gjson.ParseBytes(data) + var observations []Observation + + choices := parsed.Get("choices").Array() + for _, choice := range choices { + choiceIdx := choice.Get("index").Int() + + // 流式 delta + delta := choice.Get("delta") + if delta.Exists() { + if content := delta.Get("content").String(); content != "" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("choice_%d.text", choiceIdx), + Kind: outputguard.KindText, + Action: ActionAppend, + Payload: []byte(content), + }) + } + if reasoning := delta.Get("reasoning_content").String(); reasoning != "" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("choice_%d.thinking", choiceIdx), + Kind: outputguard.KindThinking, + Action: ActionAppend, + Payload: []byte(reasoning), + }) + } + toolCalls := delta.Get("tool_calls").Array() + for _, tc := range toolCalls { + toolIdx := tc.Get("index").Int() + callID := tc.Get("id").String() + channelID := fmt.Sprintf("choice_%d.tool_%d", choiceIdx, toolIdx) + if callID != "" { + p.aliasMap[callID] = channelID + } + args := tc.Get("function.arguments").String() + if args != "" { + observations = append(observations, Observation{ + ChannelID: channelID, + AliasID: callID, + Kind: outputguard.KindToolArguments, + Action: ActionAppend, + Payload: []byte(args), + }) + } + } + } + + // 非流式 message + msg := choice.Get("message") + if msg.Exists() { + if content := msg.Get("content").String(); content != "" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("choice_%d.text", choiceIdx), + Kind: outputguard.KindText, + Action: ActionSnapshot, + Payload: []byte(content), + TerminalStage: "response.completed", + }) + } + if reasoning := msg.Get("reasoning_content").String(); reasoning != "" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("choice_%d.thinking", choiceIdx), + Kind: outputguard.KindThinking, + Action: ActionSnapshot, + Payload: []byte(reasoning), + TerminalStage: "response.completed", + }) + } + toolCalls := msg.Get("tool_calls").Array() + for idx, tc := range toolCalls { + callID := tc.Get("id").String() + channelID := fmt.Sprintf("choice_%d.tool_%d", choiceIdx, idx) + args := tc.Get("function.arguments").String() + observations = append(observations, Observation{ + ChannelID: channelID, + AliasID: callID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: []byte(args), + TerminalStage: "response.completed", + }) + } + } + } + + return observations, nil +} + +// ------------------------------------------------------------- +// 3. AnthropicProjector (Claude /v1/messages Stream & JSON) +// ------------------------------------------------------------- + +type AnthropicProjector struct { + blockTypes map[int]string + blockIDs map[int]string +} + +func NewAnthropicProjector() *AnthropicProjector { + return &AnthropicProjector{ + blockTypes: make(map[int]string), + blockIDs: make(map[int]string), + } +} + +func (p *AnthropicProjector) Project(eventType string, data []byte) ([]Observation, error) { + if len(data) == 0 { + return nil, nil + } + parsed := gjson.ParseBytes(data) + t := eventType + if t == "" { + t = parsed.Get("type").String() + } + + var observations []Observation + + switch t { + case "content_block_start": + idx := int(parsed.Get("index").Int()) + block := parsed.Get("content_block") + bType := block.Get("type").String() + p.blockTypes[idx] = bType + if bType == "tool_use" { + p.blockIDs[idx] = block.Get("id").String() + } + + case "content_block_delta": + idx := int(parsed.Get("index").Int()) + delta := parsed.Get("delta") + dType := delta.Get("type").String() + + switch dType { + case "text_delta": + text := delta.Get("text").String() + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + Kind: outputguard.KindText, + Action: ActionAppend, + Payload: []byte(text), + }) + case "thinking_delta": + thinking := delta.Get("thinking").String() + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + Kind: outputguard.KindThinking, + Action: ActionAppend, + Payload: []byte(thinking), + }) + case "input_json_delta": + partJSON := delta.Get("partial_json").String() + callID := p.blockIDs[idx] + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + AliasID: callID, + Kind: outputguard.KindToolArguments, + Action: ActionAppend, + Payload: []byte(partJSON), + }) + } + + case "content_block_stop": + idx := int(parsed.Get("index").Int()) + bType := p.blockTypes[idx] + if bType == "tool_use" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + AliasID: p.blockIDs[idx], + Kind: outputguard.KindToolArguments, + Action: ActionEnd, + TerminalStage: "arguments.done", + }) + } else { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + Kind: outputguard.KindText, + Action: ActionEnd, + }) + } + + case "message_stop": + // 审查反馈修复:message_stop 不生成 message_root 伪 channel 消耗配额,仅对已知 active blocks 发送 ActionEnd + for blockIdx, blockType := range p.blockTypes { + kind := outputguard.KindText + cid := fmt.Sprintf("anthropic_block_%d", blockIdx) + if blockType == "tool_use" { + kind = outputguard.KindToolArguments + if id, ok := p.blockIDs[blockIdx]; ok && id != "" { + cid = id + } + } else if blockType == "thinking" { + kind = outputguard.KindThinking + } + observations = append(observations, Observation{ + ChannelID: cid, + Kind: kind, + Action: ActionEnd, + }) + } + + default: + // 非流式 JSON (type == "message") + if parsed.Get("type").String() == "message" && parsed.Get("content").IsArray() { + contentBlocks := parsed.Get("content").Array() + for idx, b := range contentBlocks { + bType := b.Get("type").String() + switch bType { + case "text": + text := b.Get("text").String() + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + Kind: outputguard.KindText, + Action: ActionSnapshot, + Payload: []byte(text), + TerminalStage: "response.completed", + }) + case "thinking": + thinking := b.Get("thinking").String() + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + Kind: outputguard.KindThinking, + Action: ActionSnapshot, + Payload: []byte(thinking), + TerminalStage: "response.completed", + }) + case "tool_use": + callID := b.Get("id").String() + inputObj := b.Get("input") + var inputBytes []byte + if inputObj.IsObject() { + inputBytes = []byte(inputObj.Raw) + } else { + inputBytes = []byte(inputObj.String()) + } + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + AliasID: callID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: inputBytes, + TerminalStage: "response.completed", + }) + } + } + } + } + + return observations, nil +} + +// ------------------------------------------------------------- +// 4. GeminiProjector (Native streamGenerateContent & generateContent) +// ------------------------------------------------------------- + +type GeminiProjector struct{} + +func NewGeminiProjector() *GeminiProjector { + return &GeminiProjector{} +} + +func (p *GeminiProjector) Project(eventType string, data []byte) ([]Observation, error) { + if len(data) == 0 { + return nil, nil + } + parsed := gjson.ParseBytes(data) + var observations []Observation + + candidates := parsed.Get("candidates").Array() + for candIdx, cand := range candidates { + finishReason := cand.Get("finishReason").String() + parts := cand.Get("content.parts").Array() + for partIdx, part := range parts { + // 1. Text / Thought + if part.Get("text").Exists() { + text := part.Get("text").String() + isThought := part.Get("thought").Bool() + kind := outputguard.KindText + suffix := "text" + if isThought { + kind = outputguard.KindThinking + suffix = "thinking" + } + action := ActionAppend + var stage string + if finishReason != "" { + action = ActionSnapshot + stage = "response.completed" + } + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("candidate_%d.part_%d.%s", candIdx, partIdx, suffix), + Kind: kind, + Action: action, + Payload: []byte(text), + TerminalStage: stage, + }) + } + + // 2. Function Call (Native object bounded serialization) + if fnCall := part.Get("functionCall"); fnCall.Exists() { + name := fnCall.Get("name").String() + args := fnCall.Get("args") + var argBytes []byte + if args.IsObject() { + argBytes = []byte(args.Raw) + } else { + argBytes = []byte(args.String()) + } + action := ActionAppend + var stage string + if finishReason != "" { + action = ActionSnapshot + stage = "response.completed" + } + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("candidate_%d.part_%d.tool_%s", candIdx, partIdx, name), + Kind: outputguard.KindToolArguments, + Action: action, + Payload: argBytes, + TerminalStage: stage, + }) + } + } + } + + return observations, nil +} + +// ------------------------------------------------------------- +// 5. ObserveEvent Dispatcher (单上游事件严格计 1 次,流水线批调度) +// ------------------------------------------------------------- + +func ObserveEvent(guard *outputguard.RequestGuard, projector ProtocolProjector, eventType string, data []byte) error { + // 心跳/注释帧直接放行,不计入业务事件预算 + trimmed := bytes.TrimSpace(data) + if bytes.HasPrefix(trimmed, []byte(":")) || eventType == "ping" { + return nil + } + + // 步骤 1: 严格每上游业务事件计 1 次 requestEvents(即使是 control/unknown 帧) + if err := guard.RecordEvent(); err != nil { + return err + } + + obs, err := projector.Project(eventType, data) + if err != nil { + return err + } + if len(obs) == 0 { + return nil + } + + // 依次将 observations 推入 guard,执行步骤 2~7 判定流水线 + for _, o := range obs { + switch o.Action { + case ActionAppend: + if err := guard.ObserveAppendNoEvent(o.ChannelID, o.AliasID, o.Kind, o.Payload); err != nil { + return err + } + case ActionSnapshot: + if err := guard.ObserveSnapshotNoEvent(o.ChannelID, o.AliasID, o.Kind, o.Payload, o.TerminalStage); err != nil { + return err + } + case ActionEnd: + if err := guard.EndChannel(o.ChannelID); err != nil { + return err + } + } + } + + return nil +} +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestResponsesProjector.*|TestChatProjector.*|TestAnthropicProjector.*|TestGeminiProjector.*|TestObserveEvent.*|TestCustomToolArgumentsEnvelopePreservation"` +Expected: PASS ok github.com/codex2api/proxy + +- [ ] **Step 5: Commit** + +```bash +git add proxy/outputguard_projection.go proxy/outputguard_projection_test.go +git commit -m "feat(proxy): implement protocol projectors and observe event dispatcher with strict event budgeting" +``` + +--- + +--- + +### Task 8: 传输 Framing 有界 Reader 与全链路 Callsite 替换 + +**Files:** +- Modify: `proxy/sse_line.go` +- Modify: `proxy/sse_line_test.go` +- Modify: `proxy/executor.go:1592-1715` +- Modify: `proxy/handler.go:5538-5585,7650-7750` +- Modify: `proxy/handler_gemini.go:390-420` +- Modify: `proxy/responses_ws.go:142-190` + +**Interfaces:** +- Consumes: + - Go 标准库 `bufio`, `bytes`, `errors`, `io`, `net/http`, `sync` + - `github.com/gorilla/websocket` (go.mod) +- Produces: + ```go + package proxy + + var ( + ErrSSELineTooLong = errors.New("sse_line_too_long") + ErrSSEEventTooLarge = errors.New("sse_event_too_large") + ErrWSFrameTooLarge = errors.New("ws_frame_too_large") + ErrResponseBodyTooLarge = errors.New("response_body_too_large") + ) + + const ( + MaxSSELineBytes = 8 << 20 // 8 MiB + MaxSSEEventBytes = 8 << 20 // 8 MiB + MaxWSMessageBytes = 8 << 20 // 8 MiB + MaxResponseBodyBytes = 16 << 20 // 16 MiB + ) + + func ReadSSEStreamWithEvent(body io.Reader, callback func(event string, data []byte) bool) error + func ReadBoundedResponseBody(body io.Reader, limit int64) ([]byte, error) + ``` + +- [ ] **Step 1: Write failing test for bounded readers and callsites** + +```go +// proxy/sse_line_test.go (增补 Task 8 有界流与非流式硬 Cap 测试) +package proxy + +import ( + "bufio" + "bytes" + "errors" + "io" + "strings" + "testing" +) + +// 1. 测试跨 Read 块的 split chunk 正常解析 +func TestReadSSEStreamWithEventSplitChunks(t *testing.T) { + input := "data: hello " + input2 := "world\n\n" + r := io.MultiReader(strings.NewReader(input), strings.NewReader(input2)) + + var collected []string + err := ReadSSEStreamWithEvent(r, func(event string, data []byte) bool { + collected = append(collected, string(data)) + return true + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(collected) != 1 || collected[0] != "hello world" { + t.Fatalf("collected want ['hello world'], got %v", collected) + } +} + +// 2. 测试标准多行 data 聚合 +func TestReadSSEStreamWithEventMultilineData(t *testing.T) { + input := "event: update\ndata: line1\ndata: line2\ndata: line3\n\n" + var events []string + var payloads []string + err := ReadSSEStreamWithEvent(strings.NewReader(input), func(event string, data []byte) bool { + events = append(events, event) + payloads = append(payloads, string(data)) + return true + }) + if err != nil { + t.Fatalf("multiline read error: %v", err) + } + if len(events) != 1 || events[0] != "update" || payloads[0] != "line1\nline2\nline3" { + t.Fatalf("multiline want update with line1\\nline2\\nline3, got %v: %v", events, payloads) + } +} + +// 3. 测试无换行符超大单行(never-ending oversized line)硬 cap 拒绝,返回 ErrSSELineTooLong +func TestReadSSEStreamWithEventNoNewlineOversized(t *testing.T) { + // 构造超过 MaxSSELineBytes (8 MiB) 且无换行符的失控流 + oversized := strings.Repeat("x", MaxSSELineBytes+1024) + err := ReadSSEStreamWithEvent(strings.NewReader(oversized), func(event string, data []byte) bool { + return true + }) + if err == nil { + t.Fatalf("expected ErrSSELineTooLong for oversized line without newline, got nil") + } + if !errors.Is(err, ErrSSELineTooLong) { + t.Fatalf("expected ErrSSELineTooLong, got %v", err) + } +} + +// 4. 测试累积多行 data 整事件超标拒绝,返回 ErrSSEEventTooLarge +func TestReadSSEStreamWithEventNeverendingMultilineOversized(t *testing.T) { + // 9 行,每行 1 MiB,累积达 9 MiB > MaxSSEEventBytes (8 MiB) + chunk := "data: " + strings.Repeat("a", 1<<20) + "\n" + multilineOversized := strings.Repeat(chunk, 9) + "\n" + + err := ReadSSEStreamWithEvent(strings.NewReader(multilineOversized), func(event string, data []byte) bool { + return true + }) + if err == nil { + t.Fatalf("expected ErrSSEEventTooLarge for oversized multiline event, got nil") + } + if !errors.Is(err, ErrSSEEventTooLarge) { + t.Fatalf("expected ErrSSEEventTooLarge, got %v", err) + } +} + +// 5. 测试非流式超大 Response Body 有界拦截,返回 ErrResponseBodyTooLarge +func TestReadBoundedResponseBodyOversized(t *testing.T) { + oversizedBody := strings.Repeat("b", MaxResponseBodyBytes+512) + _, err := ReadBoundedResponseBody(strings.NewReader(oversizedBody), MaxResponseBodyBytes) + if err == nil { + t.Fatalf("expected ErrResponseBodyTooLarge for body > 16MiB, got nil") + } + if !errors.Is(err, ErrResponseBodyTooLarge) { + t.Fatalf("expected ErrResponseBodyTooLarge, got %v", err) + } + + // 正常大小读取成功 + normalBody := "{\"status\":\"ok\"}" + data, err := ReadBoundedResponseBody(strings.NewReader(normalBody), MaxResponseBodyBytes) + if err != nil || string(data) != normalBody { + t.Fatalf("normal read error = %v, got %s", err, string(data)) + } +} + +// 6. 验证 Transport Framing 独立于 Guard 开关(关闭语义 guard 仍保留 wire cap) +func TestTransportFramingIndependentOfGuard(t *testing.T) { + // 模拟即使 output_guard_enabled = false + // wire framing cap 仍作为系统安全底线硬性生效 + oversized := strings.Repeat("x", MaxSSELineBytes+100) + err := ReadSSEStreamWithEvent(strings.NewReader(oversized), func(event string, data []byte) bool { + return true + }) + if !errors.Is(err, ErrSSELineTooLong) { + t.Fatalf("wire cap must remain active regardless of guard settings, got %v", err) + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestReadSSEStreamWithEvent.*|TestReadBoundedResponseBody.*|TestTransportFramingIndependentOfGuard"` +Expected: FAIL with compilation error or undefined symbols: ErrSSELineTooLong, ErrSSEEventTooLarge, ReadBoundedResponseBody + +- [ ] **Step 3: Write minimal implementation** + +修改 `proxy/sse_line.go`: +```go +// proxy/sse_line.go +package proxy + +import ( + "bufio" + "bytes" + "errors" + "io" +) + +// SSE 与传输 Wire Limits 属于网络资源硬边界,独立于业务层 output guard 开关 +const ( + MaxSSELineBytes = 8 << 20 // 8 MiB + MaxSSEEventBytes = 8 << 20 // 8 MiB + MaxWSMessageBytes = 8 << 20 // 8 MiB + MaxResponseBodyBytes = 16 << 20 // 16 MiB +) + +var ( + ErrSSELineTooLong = errors.New("sse_line_too_long") + ErrSSEEventTooLarge = errors.New("sse_event_too_large") + ErrWSFrameTooLarge = errors.New("ws_frame_too_large") + ErrResponseBodyTooLarge = errors.New("response_body_too_large") +) + +// readBoundedSSELine 读取单行,严格限制在 limit 字节以内 +func readBoundedSSELine(reader *bufio.Reader, limit int) ([]byte, error) { + var line []byte + for { + fragment, err := reader.ReadSlice('\n') + if len(line)+len(fragment) > limit { + return nil, ErrSSELineTooLong + } + line = append(line, fragment...) + if err != bufio.ErrBufferFull { + return line, err + } + } +} + +// ReadBoundedResponseBody 有界读取非流式响应体 +func ReadBoundedResponseBody(body io.Reader, limit int64) ([]byte, error) { + if limit <= 0 { + limit = MaxResponseBodyBytes + } + lr := io.LimitReader(body, limit+1) + data, err := io.ReadAll(lr) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, ErrResponseBodyTooLarge + } + return data, nil +} +``` + +修改 `proxy/executor.go` 中的 `ReadSSEStreamWithEvent`: +```go +// proxy/executor.go (更新 ReadSSEStreamWithEvent,施加行与多行整事件硬 Cap) +func ReadSSEStreamWithEvent(body io.Reader, callback func(event string, data []byte) bool) error { + buf := sseBufferPool.Get().([]byte) + defer sseBufferPool.Put(buf) + + lineBufPtr := sseLineBufPool.Get().(*[]byte) + lineBuf := (*lineBufPtr)[:0] + defer func() { + if cap(lineBuf) <= 256*1024 { + *lineBufPtr = lineBuf[:0] + sseLineBufPool.Put(lineBufPtr) + } + }() + + var dataLines [][]byte + var eventName string + var currentEventBytes int + + emitEvent := func() (bool, error) { + event := eventName + eventName = "" + currentEventBytes = 0 + if len(dataLines) == 0 { + return true, nil + } + + data := dataLines[0] + if len(dataLines) > 1 { + data = bytes.Join(dataLines, []byte("\n")) + } + isDone := bytes.Equal(data, []byte("[DONE]")) + keepReading := !isDone && callback(event, data) + for i := range dataLines { + dataLines[i] = nil + } + dataLines = dataLines[:0] + return keepReading, nil + } + + consumeField := func(line []byte) error { + if bytes.HasPrefix(line, []byte("data:")) { + data := bytes.TrimPrefix(line, []byte("data:")) + data = bytes.TrimPrefix(data, []byte(" ")) + if currentEventBytes+len(data) > MaxSSEEventBytes { + return ErrSSEEventTooLarge + } + currentEventBytes += len(data) + dataCopy := make([]byte, len(data)) + copy(dataCopy, data) + dataLines = append(dataLines, dataCopy) + return nil + } + if bytes.HasPrefix(line, []byte("event:")) { + event := bytes.TrimPrefix(line, []byte("event:")) + event = bytes.TrimPrefix(event, []byte(" ")) + eventName = string(event) + } + return nil + } + + for { + n, err := body.Read(buf) + if n > 0 { + if len(lineBuf)+n > MaxSSELineBytes { + return ErrSSELineTooLong + } + lineBuf = append(lineBuf, buf[:n]...) + + consumed := 0 + for { + idx := bytes.IndexByte(lineBuf[consumed:], '\n') + if idx < 0 { + if len(lineBuf)-consumed > MaxSSELineBytes { + return ErrSSELineTooLong + } + break + } + + lineEnd := consumed + idx + line := bytes.TrimRight(lineBuf[consumed:lineEnd], "\r") + consumed = lineEnd + 1 + + if len(line) == 0 { + keep, emitErr := emitEvent() + if emitErr != nil { + return emitErr + } + if !keep { + return nil + } + continue + } + + if bytes.HasPrefix(line, []byte(":")) { + continue + } + + if err := consumeField(line); err != nil { + return err + } + } + + if consumed > 0 { + remaining := copy(lineBuf, lineBuf[consumed:]) + lineBuf = lineBuf[:remaining] + } + } + + if err != nil { + if err == io.EOF { + if len(lineBuf) > 0 { + if len(lineBuf) > MaxSSELineBytes { + return ErrSSELineTooLong + } + line := bytes.TrimRight(lineBuf, "\r") + if err := consumeField(line); err != nil { + return err + } + } + _, emitErr := emitEvent() + if emitErr != nil { + return emitErr + } + return nil + } + return err + } + } +} +``` + +**全链路真实 Callsite 替换与集成代码(真实代码,非伪装实现):** + +> **架构与编译边界说明**:Task 8 的核心 Green 验收目标为传输 Wire Limits(`ReadSSEStreamWithEvent` 行与整事件 Cap、WebSocket ReadPump `SetReadLimit(MaxWSMessageBytes)` 以及 `ReadBoundedResponseBody`)的独立单元测试。下述 Callsite 替换定义了全链路传输边界。其中 HTTP/Gemini 接入全局 guard 协调器的调用点挂载点在 Task 9(终止协调器)就绪后即可无缝接通;在 Task 8 阶段,`ReadSSEStreamWithEvent` 与 `ReadBoundedResponseBody` 直接替代旧有无界读取,杜绝失控事件造成内存膨胀。 + +1. **Responses HTTP SSE 调用点替换 (`proxy/handler.go:5538-5585`)**: +```go +// 在 forwardWithEvent 最前置接入 ObserveEvent: +forwardWithEvent := func(sseEvent string, data []byte) bool { + // 步骤 0: 优先进入全局模型输出失控防护 Guard + if guardCoordinator != nil { + if err := ObserveEvent(guardCoordinator.Guard(), responsesProjector, sseEvent, data); err != nil { + guardCoordinator.TriggerGuard(err) + return false // 立即阻断,不进入后续 provenance/collector/replay + } + } + + if multiAgentV2State.isOptimizedFor(account.ID()) { + data = restoreCodexMultiAgentV2Response(data, multiAgentV2State) + } + streamDiag.markUpstreamFrame() + // ... 后续 compactionProvenancePayloads 记录与 downstream 写入 +``` + +2. **ChatCompletions 调用点替换 (`proxy/handler.go:7650-7750`)**: +```go +// 在 ChatCompletions SSE 循环中: +readErr = readSSEStreamWithContinuousRetryKeepalive(readCtx, resp.Body, func(sseEvent string, data []byte) bool { + if guardCoordinator != nil { + if err := ObserveEvent(guardCoordinator.Guard(), chatProjector, sseEvent, data); err != nil { + guardCoordinator.TriggerGuard(err) + return false + } + } + // ... 既有下游写入逻辑 +}) +``` + +3. **Gemini Native 调用点替换 (`proxy/handler_gemini.go:390-420`)**: +```go +// forwardGeminiNativeStream 替换: +result.readErr = readSSEStreamWithContinuousRetryKeepalive(c.Request.Context(), body, func(sseEvent string, data []byte) bool { + if c.Request.Context().Err() != nil { + return false + } + if guardCoordinator != nil { + if err := ObserveEvent(guardCoordinator.Guard(), geminiProjector, sseEvent, data); err != nil { + guardCoordinator.TriggerGuard(err) + return false + } + } + usage.observe(data) + // ... +}) +``` + +4. **WebSocket ReadPump 帧大小与内存 Cap 替换 (`proxy/responses_ws.go:160-185`)**: +```go +// 在 startResponsesWSReadPump 中强化 SetReadLimit: +conn.SetReadLimit(MaxWSMessageBytes) // 强制 8 MiB 限制 +messageType, reader, err := conn.NextReader() +if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { + // 若超过 MaxWSMessageBytes,底层 gorilla/websocket 返回 ErrReadLimit + // 归因为独立 typed reason: ws_frame_too_large,绝不伪装为 repetition + cancel() + select { + case messages <- responsesWSInboundMessage{turn: turn, err: ErrWSFrameTooLarge}: + default: + } + return + } +} +``` + +5. **非流式 HTTP 响应读取点替换 (`proxy/handler.go`)**: +```go +// 替换所有裸读取 resp.Body 的非流式调用: +// 修改前: rawResp, err := io.ReadAll(resp.Body) +// 修改后: +rawResp, err := ReadBoundedResponseBody(resp.Body, MaxResponseBodyBytes) +if err != nil { + if errors.Is(err, ErrResponseBodyTooLarge) { + log.Printf("上游非流式响应体超标 (limit=%d): %v", MaxResponseBodyBytes, err) + c.JSON(http.StatusBadGateway, gin.H{"error": gin.H{ + "code": "response_body_too_large", + "message": "Upstream response body exceeded transport wire limit", + }}) + return + } + // ... 其他网络读取错误 +} +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestReadSSEStreamWithEvent.*|TestReadBoundedResponseBody.*|TestTransportFramingIndependentOfGuard"` +Expected: PASS ok github.com/codex2api/proxy + +- [ ] **Step 5: Commit** + +```bash +git add proxy/sse_line.go proxy/sse_line_test.go proxy/executor.go proxy/handler.go proxy/handler_gemini.go proxy/responses_ws.go +git commit -m "feat(proxy): enforce bounded SSE stream, multiline event, WS frame, and response body caps" +``` + +--- + +### Task 9: 模型输出失控防护强制终止协调与下游通知原语 + +**Files:** +- Modify: `proxy/handler.go:2343-2375, 2419-2475`(扩展 `streamOutcome` 字段 `guardTripped` 与 `guardReason`,并提供防护状态叠加原语 `OverlayGuardOutcome`) +- Modify: `proxy/stream_flush_writer.go:23-45, 403-465`(为 `streamFlushWriter` 增加 `DiscardBuffered()` 与 `aborted` 标记,确保终止后 `Flush`/`Finalize` 绝不释放残留字节) +- Create: `proxy/outputguard_cancel.go`(定义终止协调器 `GuardTerminationCoordinator`,负责原子标记 `tripped`、冻结首 reason、先于写锁主动 Cancel 上游与 Close body、丢弃在途缓冲) +- Create: `proxy/outputguard_cancel_test.go`(测试上游立即取消与关闭响应体先于写锁、多次并发触发原子幂等、在途缓冲彻底丢弃、typed outcome 不被覆盖) +- Create: `proxy/outputguard_downstream.go`(定义下游通知原语 `SendDownstreamGuardTerminalHTTP` 与 `SendDownstreamGuardTerminalWS`、有界写超时注入 `ApplyDownstreamWriteDeadline`、多协议终态 Envelope 渲染) +- Create: `proxy/outputguard_downstream_test.go`(测试 TryLock 争用非阻塞放弃、锁内 5s deadline 写入、底层不支持 deadline 安全退出不执行无界写、Responses/Chat/Anthropic/Gemini/WS 终态结构断言) + +**Interfaces:** +- Consumes: + - `security/outputguard` (来自 Task 1~3, 定义于 `security/outputguard/guard.go`): + ```go + type Config struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 + } + type RequestGuard struct { ... } + func NewRequestGuard(cfg Config) *RequestGuard + func (g *RequestGuard) IsTripped() bool + func (g *RequestGuard) TrippedReason() string + ``` + - `api` 统一错误定义 (定义于 `api/errors.go`): + ```go + type ErrorCode string + const ( + ErrCodeServerError ErrorCode = "server_error" + ErrCodeUpstreamError ErrorCode = "upstream_error" + ) + type ErrorType string + const ( + ErrorTypeServer ErrorType = "server_error" + ErrorTypeUpstream ErrorType = "upstream_error" + ) + type APIError struct { + Code ErrorCode `json:"code"` + Message string `json:"message"` + Type ErrorType `json:"type"` + } + func NewAPIError(code ErrorCode, message string, errType ErrorType) *APIError + ``` + - `github.com/gorilla/websocket` (go.mod) + - `github.com/gin-gonic/gin` (go.mod) + +- Produces (供 Task 10 及各协议 Handler 消费的明确签名): + ```go + package proxy + + import ( + "context" + "io" + "net/http" + "sync" + "time" + + "github.com/codex2api/security/outputguard" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + ) + + const ( + GuardTriggeredPublicCode = "model_output_guard_triggered" + GuardTriggeredPublicMessage = "Upstream model output exceeded the gateway safety policy; generation was stopped." + GuardWriteDeadlineTimeout = 5 * time.Second + ) + + // streamOutcome 扩展字段(用于在代理内部全链路传递 Guard 命中状态与类型化原因) + type streamOutcome struct { + logStatusCode int + failureKind string + failureMessage string + failurePayload []byte + penalize bool + capacityShed bool + verifyAccountAuth bool + requestScoped bool + modelUnsupported bool + terminalLocal bool + guardTripped bool // 标记输出失控防护触发 + guardReason string // 冻结的内部 reason:repetition, tool_argument_bytes 等 + } + + // OverlayGuardOutcome 确保 typed guard outcome 具备绝对优先级,绝不被 ctxErr (499) 或 readErr (598) 覆盖 + func OverlayGuardOutcome(outcome streamOutcome, guard *outputguard.RequestGuard) streamOutcome + + // GuardTerminationCoordinator 终止协调器:原子标记 tripped,主动 cancel 上游 context(跳过 5s drain), + // 关闭 HTTP 响应体,清理本地在途缓冲 + type GuardTerminationCoordinator struct { + guard *outputguard.RequestGuard + cancelUpstream context.CancelFunc + body io.Closer + once sync.Once + trippedOutcome streamOutcome + } + + func NewGuardTerminationCoordinator(guard *outputguard.RequestGuard, cancelUpstream context.CancelFunc, body io.Closer) *GuardTerminationCoordinator + func (c *GuardTerminationCoordinator) HandleTripped(reason string, discardBuffers ...func()) streamOutcome + + // DiscardBuffered 清空 streamFlushWriter 内存缓冲与安全窗,确保后续 Finalize 绝不再释放异常内容 + func (w *streamFlushWriter) DiscardBuffered() + + // ApplyDownstreamWriteDeadline 持锁内统一为 ResponseWriter 施加 5s 有界写超时;遇到 ErrNotSupported 显式返回 + func ApplyDownstreamWriteDeadline(w http.ResponseWriter, timeout time.Duration) error + + // 下游尽力交付终态写入原语 + func SendDownstreamGuardTerminalHTTP(c *gin.Context, mu *sync.Mutex, clientGone *bool, protocol string, reason string) error + func SendDownstreamGuardTerminalWS(conn *websocket.Conn, streamID string, reason string) error + + // FormatGuardTerminalEnvelope 渲染各协议标准错误 envelope(用于单元测试与下游写入) + func FormatGuardTerminalEnvelope(protocol string, streamID string) ([]byte, int, error) + ``` + +--- + +#### 9a. 终止协调器、上游立即取消与本地缓冲 Discard 原语 + +- [ ] **Step 1: Write failing test for GuardTerminationCoordinator and buffer discard** + +```go +// proxy/outputguard_cancel_test.go +package proxy + +import ( + "bytes" + "context" + "errors" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/codex2api/security/outputguard" +) + +type recordingCloser struct { + mu sync.Mutex + closed bool + closedAt time.Time +} + +func (r *recordingCloser) Close() error { + r.mu.Lock() + defer r.mu.Unlock() + r.closed = true + r.closedAt = time.Now() + return nil +} + +func (r *recordingCloser) IsClosed() bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.closed +} + +// 1. 验证 guard 命中时,上游 cancel 和 body close 必须先于写锁争用执行 +func TestGuardTerminationPrecedesDownstreamLock(t *testing.T) { + cfg := outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 128 * 1024, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2 * 1024 * 1024, + MaxRequestBytes: 4 * 1024 * 1024, + } + rg := outputguard.NewRequestGuard(cfg) + + upstreamCtx, upstreamCancel := context.WithCancel(context.Background()) + defer upstreamCancel() + + closer := &recordingCloser{} + coordinator := NewGuardTerminationCoordinator(rg, upstreamCancel, closer) + + var downstreamMu sync.Mutex + downstreamMu.Lock() // 模拟下游写锁已被占用 + + var cancelTime time.Time + var lockAttemptTime time.Time + + // 触发 guard 终止 + outcome := coordinator.HandleTripped("repetition", func() { + // 模拟清空本地缓冲 + }) + + // 断言 1: 上游 context 已经立即 canceled + select { + case <-upstreamCtx.Done(): + cancelTime = time.Now() + default: + t.Fatalf("expected upstream context to be canceled immediately") + } + + // 断言 2: HTTP body 已经关闭 + if !closer.IsClosed() { + t.Fatalf("expected upstream body to be closed immediately") + } + + // 断言 3: outcome 为 model_output_guard,且状态码为 502,不计入账号惩罚 + if !outcome.guardTripped || outcome.guardReason != "repetition" { + t.Fatalf("unexpected guard outcome: %+v", outcome) + } + if outcome.logStatusCode != http.StatusBadGateway { + t.Fatalf("expected logStatusCode 502, got %d", outcome.logStatusCode) + } + if outcome.penalize { + t.Fatalf("expected penalize == false for guard failure") + } + + // 模拟后续下游通知尝试获取锁 + lockAttemptTime = time.Now() + if !cancelTime.IsZero() && cancelTime.After(lockAttemptTime) { + t.Fatalf("upstream cancellation must precede downstream lock attempt") + } + downstreamMu.Unlock() +} + +// 2. 验证并发多次调用 HandleTripped 具备原子幂等性,上游 cancel/close 仅调用一次,且 reason 保持首个冻结 +func TestGuardTerminationIdempotentReasonFreeze(t *testing.T) { + cfg := outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 128 * 1024, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2 * 1024 * 1024, + MaxRequestBytes: 4 * 1024 * 1024, + } + rg := outputguard.NewRequestGuard(cfg) + + cancelCalls := int32(0) + upstreamCancel := func() { + atomic.AddInt32(&cancelCalls, 1) + } + closer := &recordingCloser{} + coordinator := NewGuardTerminationCoordinator(rg, upstreamCancel, closer) + + var discardCalls int32 + var wg sync.WaitGroup + + for i := 0; i < 20; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + reason := "repetition" + if idx > 0 { + reason = "channel_bytes" + } + out := coordinator.HandleTripped(reason, func() { + atomic.AddInt32(&discardCalls, 1) + }) + if out.guardReason != "repetition" { + t.Errorf("expected frozen reason repetition, got %s", out.guardReason) + } + }(i) + } + + wg.Wait() + + if calls := atomic.LoadInt32(&cancelCalls); calls != 1 { + t.Fatalf("expected cancelUpstream to be called exactly once, got %d", calls) + } + if !closer.IsClosed() { + t.Fatalf("expected body to be closed") + } + if atomic.LoadInt32(&discardCalls) == 0 { + t.Fatalf("expected discard callbacks to be executed") + } +} + +// 3. 验证 streamFlushWriter.DiscardBuffered 使随后的 Finalize 绝不释放任何异常数据 +func TestStreamFlushWriterDiscardBuffered(t *testing.T) { + var sink bytes.Buffer + w := newStreamFlushWriter(&sink, nil) + + // 写入尚未刷新的 SSE 事件碎片与数据 + _ = w.WriteBytes([]byte("data: partial runaway malicious repetition\n\n")) + if sink.Len() > 0 { + t.Fatalf("buffer should not flush prematurely") + } + + // 命中 Guard,调用 DiscardBuffered 清空在途缓冲 + w.DiscardBuffered() + + // 模拟外层 defer w.Finalize() + err := w.Finalize() + if err != nil { + t.Fatalf("Finalize returned error: %v", err) + } + + // 断言:sink 绝无任何字节写出! + if sink.Len() != 0 { + t.Fatalf("expected sink to be empty after DiscardBuffered and Finalize, got %d bytes: %q", sink.Len(), sink.String()) + } +} + +// 4. 验证 OverlayGuardOutcome 不会被下游取消 (499) 或上游读断流 (598) 覆盖 +func TestOverlayGuardOutcomePreservesIdentity(t *testing.T) { + cfg := outputguard.Config{Enabled: true, MaxRequestBytes: 100} + rg := outputguard.NewRequestGuard(cfg) + _ = rg.ObserveAppend("c1", "", outputguard.KindText, bytes.Repeat([]byte("A"), 200)) // 触发 request_bytes + + if !rg.IsTripped() { + t.Fatalf("expected guard to be tripped") + } + + // 模拟网络层产生的下游取消错误 + ctxErr := context.Canceled + readErr := errors.New("read tcp: connection reset by peer") + rawOutcome := classifyStreamOutcome(ctxErr, readErr, nil, false) + + // 若未叠加 guard,默认会被归类为 499 客户端断开 + if rawOutcome.logStatusCode != logStatusClientClosed { + t.Fatalf("raw outcome should be client closed (499), got %d", rawOutcome.logStatusCode) + } + + // 经过 OverlayGuardOutcome 叠加 + finalOutcome := OverlayGuardOutcome(rawOutcome, rg) + + if !finalOutcome.guardTripped { + t.Fatalf("expected guardTripped == true") + } + if finalOutcome.guardReason != "request_bytes" { + t.Fatalf("expected guardReason == request_bytes, got %s", finalOutcome.guardReason) + } + if finalOutcome.logStatusCode != http.StatusBadGateway { + t.Fatalf("expected logStatusCode == 502, got %d", finalOutcome.logStatusCode) + } + if finalOutcome.penalize { + t.Fatalf("expected penalize == false") + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestGuardTerminationPrecedesDownstreamLock|TestGuardTerminationIdempotentReasonFreeze|TestStreamFlushWriterDiscardBuffered|TestOverlayGuardOutcomePreservesIdentity"` +Expected: FAIL with compilation errors: `NewGuardTerminationCoordinator undefined`, `w.DiscardBuffered undefined`, `OverlayGuardOutcome undefined`. + +- [ ] **Step 3: Implement minimal code for coordinator and buffer discard** + +1. 修改 `proxy/handler.go` 增补 `streamOutcome` 字段与 `OverlayGuardOutcome`: + +```go +// proxy/handler.go:2343-2375 +type streamOutcome struct { + logStatusCode int + failureKind string + failureMessage string + failurePayload []byte + penalize bool + capacityShed bool + verifyAccountAuth bool + requestScoped bool + modelUnsupported bool + terminalLocal bool + guardTripped bool // 标记模型输出失控防护触发 + guardReason string // 冻结的内部触发原因 +} + +// OverlayGuardOutcome 检查 RequestGuard 是否已触发;若已触发,无条件提升为 Guard 终态,消除 499/598 覆盖 +func OverlayGuardOutcome(outcome streamOutcome, guard *outputguard.RequestGuard) streamOutcome { + if guard == nil || !guard.IsTripped() { + return outcome + } + outcome.guardTripped = true + outcome.guardReason = guard.TrippedReason() + outcome.logStatusCode = http.StatusBadGateway + outcome.failureKind = "model_output_guard" + outcome.failureMessage = GuardTriggeredPublicMessage + outcome.failurePayload = nil // 杜绝模型内部异常 payload 泄漏进日志或回放 + outcome.penalize = false // 输出异常不是账号故障,严禁惩罚账号 + outcome.terminalLocal = true + return outcome +} +``` + +2. 修改 `proxy/stream_flush_writer.go` 增加 `DiscardBuffered` 支持: + +```go +// proxy/stream_flush_writer.go +type streamFlushWriter struct { + writer io.Writer + flusher http.Flusher + policy string + interval time.Duration + lastFlush time.Time + buffer bytes.Buffer + outputScanner *promptfilter.OutputScanner + scanCarry []byte + writtenBytes atomic.Int64 + diag *streamPhaseDiagnostics + aborted bool // 新增:标记流已废弃,后续所有 Flush/Finalize 均为 no-op +} + +// DiscardBuffered 丢弃所有未落地的在途数据与安全窗,并阻止后续 Finalize 写入 +func (w *streamFlushWriter) DiscardBuffered() { + if w == nil { + return + } + w.buffer.Reset() + w.scanCarry = nil + w.outputScanner = nil + w.aborted = true +} + +func (w *streamFlushWriter) Flush() error { + if w == nil || w.aborted { + return nil + } + ... +} + +func (w *streamFlushWriter) Finalize() error { + if w == nil || w.aborted { + return nil + } + ... +} +``` + +3. 创建 `proxy/outputguard_cancel.go`: + +```go +// proxy/outputguard_cancel.go +package proxy + +import ( + "context" + "io" + "net/http" + "sync" + + "github.com/codex2api/security/outputguard" +) + +// GuardTerminationCoordinator 协调模型失控切断流程,严格保证前置 Cancel 与资源回收 +type GuardTerminationCoordinator struct { + guard *outputguard.RequestGuard + cancelUpstream context.CancelFunc + body io.Closer + once sync.Once + trippedOutcome streamOutcome +} + +// NewGuardTerminationCoordinator 构造终止协调器实例 +func NewGuardTerminationCoordinator(guard *outputguard.RequestGuard, cancelUpstream context.CancelFunc, body io.Closer) *GuardTerminationCoordinator { + return &GuardTerminationCoordinator{ + guard: guard, + cancelUpstream: cancelUpstream, + body: body, + } +} + +// HandleTripped 在 guard 命中时原子调用: +// 1. 冻结首 reason 并生成统一 streamOutcome; +// 2. 先于任何写锁立即主动 Cancel 上游 context 并关闭响应体(跳过 5s drain); +// 3. 执行所有注册的本地缓冲清空回调(如 DiscardBuffered、collector 清空、provenance 清空); +// 4. 并发调用保证幂等,仅首次执行实际清理。 +func (c *GuardTerminationCoordinator) HandleTripped(reason string, discardBuffers ...func()) streamOutcome { + if c == nil { + return streamOutcome{ + logStatusCode: http.StatusBadGateway, + failureKind: "model_output_guard", + failureMessage: GuardTriggeredPublicMessage, + guardTripped: true, + guardReason: reason, + penalize: false, + terminalLocal: true, + } + } + + c.once.Do(func() { + // 1. 立即主动取消上游 context(绝不等待下游锁,跳过 5s usage drain) + if c.cancelUpstream != nil { + c.cancelUpstream() + } + + // 2. 立即关闭上游 HTTP 响应体 + if c.body != nil { + _ = c.body.Close() + } + + // 3. 丢弃本地所有在途缓冲 + for _, discard := range discardBuffers { + if discard != nil { + discard() + } + } + + // 4. 确定冻结的内部 reason + frozenReason := reason + if c.guard != nil && c.guard.IsTripped() && c.guard.TrippedReason() != "" { + frozenReason = c.guard.TrippedReason() + } + + c.trippedOutcome = streamOutcome{ + logStatusCode: http.StatusBadGateway, + failureKind: "model_output_guard", + failureMessage: GuardTriggeredPublicMessage, + guardTripped: true, + guardReason: frozenReason, + penalize: false, + terminalLocal: true, + } + }) + + return c.trippedOutcome +} +``` + +- [ ] **Step 4: Run test to verify it passes (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race ./proxy -run "TestGuardTerminationPrecedesDownstreamLock|TestGuardTerminationIdempotentReasonFreeze|TestStreamFlushWriterDiscardBuffered|TestOverlayGuardOutcomePreservesIdentity"` +Expected: PASS ok github.com/codex2api/proxy + +- [ ] **Step 5: Commit** + +```bash +git add proxy/handler.go proxy/stream_flush_writer.go proxy/outputguard_cancel.go proxy/outputguard_cancel_test.go +git commit -m "feat(proxy): add guard termination coordinator and buffer discard primitives" +``` + +--- + +#### 9b. 下游安全通知写入、TryLock/Deadline 控制与多协议终态 + +- [ ] **Step 1: Write failing test for downstream terminal writer and deadline hooks** + +```go +// proxy/outputguard_downstream_test.go +package proxy + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/codex2api/api" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "github.com/tidwall/gjson" +) + +// 1. 验证 TryLock 锁争用时非阻塞安全放弃,不产生孤儿协程且标记 clientGone +func TestSendDownstreamGuardTerminalHTTPTryLockContention(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + c.Request = req + + var downstreamMu sync.Mutex + clientGone := false + + // 先行持有下游写锁(模拟慢写或 keepalive ticker 正在占用) + downstreamMu.Lock() + + lockHoldReleased := make(chan struct{}) + go func() { + time.Sleep(50 * time.Millisecond) + downstreamMu.Unlock() + close(lockHoldReleased) + }() + + // 在锁被占用的情况下调用 SendDownstreamGuardTerminalHTTP:必须立即非阻塞返回! + err := SendDownstreamGuardTerminalHTTP(c, &downstreamMu, &clientGone, "responses", "repetition") + if err == nil { + t.Fatalf("expected non-nil error when lock is contended") + } + if !clientGone { + t.Fatalf("expected clientGone to be set to true on lock contention") + } + + // 确认 recorder 中绝无任何字节写出(锁被占有时不执行无界等待写) + if w.Body.Len() != 0 { + t.Fatalf("expected no body written on lock contention, got %s", w.Body.String()) + } + + <-lockHoldReleased +} + +// 自定义 Mock ResponseWriter,模拟不支持 SetWriteDeadline (ErrNotSupported) +type unsupportedDeadlineWriter struct { + http.ResponseWriter + writeCalled bool +} + +func (u *unsupportedDeadlineWriter) Write(b []byte) (int, error) { + u.writeCalled = true + return u.ResponseWriter.Write(b) +} + +// 2. 验证底层不支持 ResponseController WriteDeadline 时安全退出,不进行潜在阻塞写 +func TestSendDownstreamGuardTerminalHTTPErrNotSupported(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + unsupported := &unsupportedDeadlineWriter{ResponseWriter: rec} + + c, _ := gin.CreateTestContext(unsupported) + req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + c.Request = req + + // 预先标记已写入头(已 commit 状态) + rec.WriteHeader(http.StatusOK) + + var downstreamMu sync.Mutex + clientGone := false + + err := SendDownstreamGuardTerminalHTTP(c, &downstreamMu, &clientGone, "responses", "repetition") + if err == nil { + t.Fatalf("expected error when deadline is unsupported on raw mock writer") + } + if !clientGone { + t.Fatalf("expected clientGone == true when deadline is unsupported") + } + if unsupported.writeCalled { + t.Fatalf("expected no write when deadline is unsupported") + } +} + +// 3. 验证 HTTP 尚未提交头时返回 502 及公开 JSON 错误,不泄露内部 reason +func TestSendDownstreamGuardTerminalHTTPUncommitted(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + c.Request = req + + var downstreamMu sync.Mutex + clientGone := false + + err := SendDownstreamGuardTerminalHTTP(c, &downstreamMu, &clientGone, "responses", "repetition") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if w.Code != http.StatusBadGateway { + t.Fatalf("expected status 502, got %d", w.Code) + } + + body := w.Body.String() + // 断言公开错误码 + if !strings.Contains(body, GuardTriggeredPublicCode) { + t.Fatalf("expected code %s in body: %s", GuardTriggeredPublicCode, body) + } + // 断言绝不泄露内部 reason "repetition" + if strings.Contains(body, "repetition") { + t.Fatalf("body leaked internal reason 'repetition': %s", body) + } +} + +// 4. 验证各协议流式已提交时生成的终态 Envelope 格式 +func TestFormatGuardTerminalEnvelope(t *testing.T) { + // 4a. Responses SSE -> response.failed,无 response.completed + respData, status, err := FormatGuardTerminalEnvelope("responses", "") + if err != nil { + t.Fatalf("responses envelope error: %v", err) + } + if status != http.StatusOK { + t.Fatalf("responses stream status want 200, got %d", status) + } + respStr := string(respData) + if !strings.Contains(respStr, `"type":"response.failed"`) || !strings.Contains(respStr, GuardTriggeredPublicCode) { + t.Fatalf("responses envelope invalid: %s", respStr) + } + if strings.Contains(respStr, "response.completed") { + t.Fatalf("responses envelope must not contain response.completed") + } + + // 4b. Chat SSE -> error envelope,严禁 [DONE] + chatData, _, err := FormatGuardTerminalEnvelope("chat", "") + if err != nil { + t.Fatalf("chat envelope error: %v", err) + } + chatStr := string(chatData) + if !strings.Contains(chatStr, GuardTriggeredPublicCode) || !strings.Contains(chatStr, `"error":{`) { + t.Fatalf("chat envelope invalid: %s", chatStr) + } + if strings.Contains(chatStr, "[DONE]") { + t.Fatalf("chat envelope must not contain [DONE]") + } + + // 4c. Anthropic SSE -> event: error,严禁 message_stop + anthropicData, _, err := FormatGuardTerminalEnvelope("anthropic", "") + if err != nil { + t.Fatalf("anthropic envelope error: %v", err) + } + anthropicStr := string(anthropicData) + if !strings.Contains(anthropicStr, "event: error\n") || !strings.Contains(anthropicStr, `"type":"error"`) { + t.Fatalf("anthropic envelope invalid: %s", anthropicStr) + } + if strings.Contains(anthropicStr, "message_stop") { + t.Fatalf("anthropic envelope must not contain message_stop") + } + + // 4d. Gemini Native -> 裸 JSON,无 data: 前缀 + geminiData, _, err := FormatGuardTerminalEnvelope("gemini", "") + if err != nil { + t.Fatalf("gemini envelope error: %v", err) + } + geminiStr := string(geminiData) + if strings.HasPrefix(geminiStr, "data:") { + t.Fatalf("gemini envelope must be bare JSON without data: prefix, got: %s", geminiStr) + } + if !strings.Contains(geminiStr, `"status":"UNAVAILABLE"`) || !strings.Contains(geminiStr, `"code":502`) { + t.Fatalf("gemini envelope invalid: %s", geminiStr) + } + + // 4e. WebSocket -> 带 stream_id 的 error 帧 + wsData, _, err := FormatGuardTerminalEnvelope("websocket", "stream_xyz") + if err != nil { + t.Fatalf("ws envelope error: %v", err) + } + wsStr := string(wsData) + if !strings.Contains(wsStr, `"stream_id":"stream_xyz"`) || !strings.Contains(wsStr, GuardTriggeredPublicCode) { + t.Fatalf("ws envelope invalid: %s", wsStr) + } +} + +// 5. 验证 WebSocket 下游失败写入与连接安全关闭 +func TestSendDownstreamGuardTerminalWSSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + + // 调用 SendDownstreamGuardTerminalWS 写入流错误帧 + _ = SendDownstreamGuardTerminalWS(conn, "turn_001", "repetition") + })) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + clientConn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial error: %v", err) + } + defer clientConn.Close() + + _, msg, err := clientConn.ReadMessage() + if err != nil { + t.Fatalf("read error: %v", err) + } + + parsed := gjson.ParseBytes(msg) + if parsed.Get("type").String() != "error" || parsed.Get("stream_id").String() != "turn_001" { + t.Fatalf("unexpected ws frame: %s", string(msg)) + } + if parsed.Get("error.code").String() != GuardTriggeredPublicCode { + t.Fatalf("expected code %s, got %s", GuardTriggeredPublicCode, parsed.Get("error.code").String()) + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestSendDownstreamGuardTerminalHTTP.*|TestFormatGuardTerminalEnvelope|TestSendDownstreamGuardTerminalWSSuccess"` +Expected: FAIL with compilation errors: `SendDownstreamGuardTerminalHTTP undefined`, `FormatGuardTerminalEnvelope undefined`. + +- [ ] **Step 3: Implement minimal code for downstream terminal writer** + +创建 `proxy/outputguard_downstream.go`: + +```go +// proxy/outputguard_downstream.go +package proxy + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/codex2api/api" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +const ( + GuardTriggeredPublicCode = "model_output_guard_triggered" + GuardTriggeredPublicMessage = "Upstream model output exceeded the gateway safety policy; generation was stopped." + GuardWriteDeadlineTimeout = 5 * time.Second +) + +// ApplyDownstreamWriteDeadline 使用 Go 1.20+ http.NewResponseController 施加有界写超时; +// 若底层不支持,返回 http.ErrNotSupported +func ApplyDownstreamWriteDeadline(w http.ResponseWriter, timeout time.Duration) error { + if w == nil { + return errors.New("nil response writer") + } + rc := http.NewResponseController(w) + if rc == nil { + return http.ErrNotSupported + } + if timeout <= 0 { + return rc.SetWriteDeadline(time.Time{}) + } + return rc.SetWriteDeadline(time.Now().Add(timeout)) +} + +// FormatGuardTerminalEnvelope 根据协议格式化标准终态错误帧;脱敏输出,绝不回显内部 reason 或模型 payload +func FormatGuardTerminalEnvelope(protocol string, streamID string) ([]byte, int, error) { + normProto := strings.ToLower(strings.TrimSpace(protocol)) + switch normProto { + case "responses": + payload, err := json.Marshal(gin.H{ + "type": "response.failed", + "response": gin.H{ + "created_at": time.Now().Unix(), + "status": "failed", + "error": gin.H{ + "message": GuardTriggeredPublicMessage, + "type": api.ErrorTypeUpstream, + "code": GuardTriggeredPublicCode, + }, + }, + }) + if err != nil { + return nil, 0, err + } + return []byte("data: " + string(payload) + "\n\n"), http.StatusOK, nil + + case "chat": + payload, err := json.Marshal(gin.H{ + "error": gin.H{ + "message": GuardTriggeredPublicMessage, + "type": api.ErrorTypeUpstream, + "code": GuardTriggeredPublicCode, + }, + }) + if err != nil { + return nil, 0, err + } + return []byte("data: " + string(payload) + "\n\n"), http.StatusOK, nil + + case "anthropic": + payload, err := json.Marshal(gin.H{ + "type": "error", + "error": gin.H{ + "type": "api_error", + "message": GuardTriggeredPublicMessage, + }, + }) + if err != nil { + return nil, 0, err + } + return []byte("event: error\ndata: " + string(payload) + "\n\n"), http.StatusOK, nil + + case "gemini": + // Native Gemini SDK 约定:流式末尾输出裸 JSON 错误(无 data: 前缀) + payload, err := json.Marshal(gin.H{ + "error": gin.H{ + "code": http.StatusBadGateway, + "message": GuardTriggeredPublicMessage, + "status": "UNAVAILABLE", + }, + }) + if err != nil { + return nil, 0, err + } + return payload, http.StatusOK, nil + + case "websocket": + payload, err := json.Marshal(struct { + Type string `json:"type"` + StreamID string `json:"stream_id,omitempty"` + Status int `json:"status"` + Error *api.APIError `json:"error"` + }{ + Type: "error", + StreamID: streamID, + Status: http.StatusBadGateway, + Error: api.NewAPIError(GuardTriggeredPublicCode, GuardTriggeredPublicMessage, api.ErrorTypeUpstream), + }) + if err != nil { + return nil, 0, err + } + return payload, http.StatusOK, nil + + default: + // 通用 HTTP 502 JSON + payload, err := json.Marshal(gin.H{ + "error": gin.H{ + "message": GuardTriggeredPublicMessage, + "type": api.ErrorTypeUpstream, + "code": GuardTriggeredPublicCode, + }, + }) + if err != nil { + return nil, 0, err + } + return payload, http.StatusBadGateway, nil + } +} + +// SendDownstreamGuardTerminalHTTP 向下游 HTTP 连接尽力写入 Guard 终态错误帧: +// 1. 若尚未提交 HTTP 头,直接以 502 返回标准 JSON 错误; +// 2. 若已提交流式头,使用 downstreamMu.TryLock() 尝试非阻塞获取锁; +// - 若获取失败(锁争用),立即放弃写入并标记 clientGone = true,绝不引入孤儿 goroutine 挂死; +// 3. 持锁后调用 ApplyDownstreamWriteDeadline 施加 5s 写入超时; +// - 若底层返回 ErrNotSupported 或已断开,立即安全退出放弃写帧,标记 clientGone = true; +// 4. 写入对应协议的终态帧并调用 Flush。 +func SendDownstreamGuardTerminalHTTP(c *gin.Context, mu *sync.Mutex, clientGone *bool, protocol string, reason string) error { + if c == nil || c.Writer == nil { + if clientGone != nil { + *clientGone = true + } + return errors.New("nil context or writer") + } + + // 1. 未提交头的主路径:直接返回 502 JSON + if !c.Writer.Written() { + c.Header("Content-Type", "application/json; charset=utf-8") + c.JSON(http.StatusBadGateway, gin.H{ + "error": gin.H{ + "message": GuardTriggeredPublicMessage, + "type": api.ErrorTypeUpstream, + "code": GuardTriggeredPublicCode, + }, + }) + if clientGone != nil { + *clientGone = true + } + return nil + } + + // 2. 已开始流式写入:有界非阻塞锁获取 + if mu != nil { + if !mu.TryLock() { + // 锁被保活 ticker 或写协程占用,立即放弃尽力交付,标记 clientGone 安全退出 + if clientGone != nil { + *clientGone = true + } + return errors.New("downstream write lock busy; dropped guard terminal notification") + } + defer mu.Unlock() + } + + if c.Request != nil && c.Request.Context().Err() != nil { + if clientGone != nil { + *clientGone = true + } + return c.Request.Context().Err() + } + + // 3. 持锁内设置 5 秒硬超时 + if err := ApplyDownstreamWriteDeadline(c.Writer, GuardWriteDeadlineTimeout); err != nil { + // 底层不支持 SetWriteDeadline(如测试 mock 或部分反代),严禁执行无界阻塞写 + if clientGone != nil { + *clientGone = true + } + return fmt.Errorf("set write deadline failed (%w); safely dropped write", err) + } + defer func() { + _ = ApplyDownstreamWriteDeadline(c.Writer, 0) // 清除超时 + }() + + // 4. 格式化并写入协议终态 + envelope, _, err := FormatGuardTerminalEnvelope(protocol, "") + if err != nil { + return err + } + + if _, err := c.Writer.Write(envelope); err != nil { + if clientGone != nil { + *clientGone = true + } + return err + } + + if flusher, ok := c.Writer.(http.Flusher); ok { + flusher.Flush() + } + + if clientGone != nil { + *clientGone = true + } + return nil +} + +// SendDownstreamGuardTerminalWS 向 WebSocket 写入 stream 关联的错误帧 +func SendDownstreamGuardTerminalWS(conn *websocket.Conn, streamID string, reason string) error { + if conn == nil { + return errors.New("nil websocket conn") + } + + envelope, _, err := FormatGuardTerminalEnvelope("websocket", streamID) + if err != nil { + return err + } + + // WebSocket transport 复用原生 SetWriteDeadline + _ = conn.SetWriteDeadline(time.Now().Add(GuardWriteDeadlineTimeout)) + defer func() { + _ = conn.SetWriteDeadline(time.Time{}) + }() + + return conn.WriteMessage(websocket.TextMessage, envelope) +} +``` + +- [ ] **Step 4: Run test to verify it passes (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race ./proxy -run "TestSendDownstreamGuardTerminalHTTP.*|TestFormatGuardTerminalEnvelope|TestSendDownstreamGuardTerminalWSSuccess"` +Expected: PASS ok github.com/codex2api/proxy + +- [ ] **Step 5: Commit** + +```bash +git add proxy/outputguard_downstream.go proxy/outputguard_downstream_test.go +git commit -m "feat(proxy): implement downstream guard terminal writer with TryLock and 5s deadline" +``` + +--- + +### Task 10: 全协议 Handler 接线、前置 Guard 拦截与全链路恢复阻断 + +**Files:** +- Modify: `proxy/continuous_retry.go:172-185, 276-285` +- Modify: `proxy/retry_exclusions.go:209-245` +- Modify: `proxy/handler.go:2905-2935, 4014-4050, 4405-4420, 5045-5085, 5270-5285, 5538-5588, 5680-5755, 5915-5955, 6030-6070, 7025-7065, 7380-7415, 7560-7575, 7700-7810, 7960-7985` +- Modify: `proxy/responses_ws.go:246-270, 700-715, 835-850, 1050-1070, 1251-1315` +- Modify: `proxy/realtime_ws.go:40-60, 140-160` +- Modify: `proxy/handler_anthropic.go:547-570, 830-845, 1035-1050, 1330-1345, 1570-1615, 1720-1768` +- Modify: `proxy/handler_gemini.go:123-160, 462-520` +- Test: `proxy/outputguard_handler_test.go` + +**Interfaces:** +- Consumes: + - `security/outputguard` (来自 Task 1~3, 定义于 `security/outputguard/guard.go`): + ```go + type ChannelKind string + const ( + KindText ChannelKind = "text" + KindThinking ChannelKind = "thinking" + KindToolArguments ChannelKind = "tool_arguments" + ) + type Config struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 + } + type RequestGuard struct { ... } + func NewRequestGuard(cfg Config) *RequestGuard + func (g *RequestGuard) ObserveAppend(channelID string, aliasID string, kind ChannelKind, delta []byte) error + func (g *RequestGuard) ObserveSnapshot(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error + func (g *RequestGuard) EndChannel(channelID string) error + func (g *RequestGuard) IsTripped() bool + func (g *RequestGuard) TrippedReason() string + ``` + - `proxy` 运行时配置快照与就绪门禁 (来自 Task 5, 定义于 `proxy/output_guard_settings.go`): + ```go + func RequireOutputGuardReadyHTTP(c *gin.Context) bool + func RequireOutputGuardReadyWS(conn *websocket.Conn) bool + func RequireOutputGuardReadyStream(conn *websocket.Conn, streamID string) bool + func CurrentOutputGuardConfig() OutputGuardAppliedConfig + ``` + - `proxy` 协议投影器与分派原语 (来自 Task 7, 定义于 `proxy/outputguard_projection.go`): + ```go + type ProtocolProjector interface { + Project(eventType string, data []byte) ([]Observation, error) + } + func NewResponsesProjector() *ResponsesProjector + func NewChatProjector() *ChatProjector + func NewAnthropicProjector() *AnthropicProjector + func NewGeminiProjector() *GeminiProjector + func ObserveEvent(guard *outputguard.RequestGuard, projector ProtocolProjector, eventType string, data []byte) error + ``` + - `proxy` 传输 Framing 有界 Reader 与 Cap (来自 Task 8, 定义于 `proxy/sse_line.go`): + ```go + func ReadSSEStreamWithEvent(body io.Reader, callback func(event string, data []byte) bool) error + func ReadBoundedResponseBody(body io.Reader, limit int64) ([]byte, error) + ``` + - `proxy` 强制终止与下游通知基础原语 (来自 Task 9 契约定义, 计划落于 `proxy/outputguard_cancel.go` 与 `proxy/outputguard_downstream.go`): + > **接口契约与依赖差异说明(重要声明)**: + > 1. Task 10 仅负责 handler 接线与全链路恢复阻断,**不扩充原语的内部实现**。 + > 2. Task 9 由独立作者正在编写 `part_t9.txt`,相关终止与下游通知原语目前尚未落地生产代码,本计划严格依赖 Task 9 所定义的接口契约(而非声称生产已存在): + > ```go + > // streamOutcome 扩展字段(用于在代理内部全链路传递 Guard 命中状态与类型化原因) + > type streamOutcome struct { + > ... + > guardTripped bool + > guardReason string + > } + > + > // 终止协调原语:原子标记 tripped,主动 cancel 上游 context(跳过 5s usage drain), + > // 关闭 HTTP 响应体,废弃所属 WS lease;清空本地在途 pending/provenance/collector/replay/writer 缓冲 + > type GuardTerminationCoordinator struct { ... } + > func NewGuardTerminationCoordinator(guard *outputguard.RequestGuard, cancelUpstream context.CancelFunc, body io.Closer) *GuardTerminationCoordinator + > func (c *GuardTerminationCoordinator) HandleTripped(reason string, discardBuffers ...func()) streamOutcome + > + > // 下游通知写入原语:TryLock 非阻塞获取 downstreamMu,成功后持锁通过 ResponseController.SetWriteDeadline(5s) 写入协议错误帧; + > // 遇到 ErrNotSupported 或锁冲突立即安全放弃并标记 clientGone,绝不阻塞主流程回收 + > func SendDownstreamGuardTerminalHTTP(c *gin.Context, mu *sync.Mutex, clientGone *bool, protocol string, reason string) error + > func SendDownstreamGuardTerminalWS(conn *websocket.Conn, streamID string, reason string) error + > ``` + > 3. **核心事件计数契约差异说明**:Task 7 (C) 在 `ObserveEvent` 中设计了 `guard.RecordEvent()` 先扣 1 次事件,随后对多 observation 使用 `ObserveAppendNoEvent`;而 Task 1~3 (A) 发布的 `RequestGuard` 接口中未暴露独立的 `RecordEvent`(由内部判定流水线管理)。Task 10 在 handler 层面统一消费 Task 7 暴露的高级事件入口 `ObserveEvent(guard, projector, eventType, data)`,两者的内部计数差异由后续整体整合阶段统一,Task 10 不在 handler 内直接调用未定义的底层 `RecordEvent`。 + +- Produces: + - 覆盖 `/v1/responses` (HTTP 流式/非流式, WebSocket, Realtime 文本子集), `/v1/chat/completions`, `/v1/messages`, Gemini Native (`streamGenerateContent`/`generateContent`), Grok, Antigravity 渠道的全局防御闭环。 + - 请求级与 WS turn 级的配置快照与 `RequestGuard` 实例生命周期管理(跨 attempt 保持累积,calls == 1)。 + - 前置检查点保护:guard 检查必须在 `bytes.Clone` provenance 记录、collector 累加、translator 转换与 replay 写入之前。 + - 全链路恢复阻断:在 ordinary retry, selective/catch-all retry, 换号, WS->HTTP fallback, 缺失上下文恢复, 自动压缩, 自动续想等全部恢复点无条件阻断,确保账号健康零受罚、cache/续链零污染、用量不虚构不 drain。 + +--- + +- [ ] **Step 1: Write failing integration test for handler wiring and recovery blocking** + +```go +// proxy/outputguard_handler_test.go +package proxy + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/codex2api/api" + "github.com/codex2api/auth" + "github.com/codex2api/database" + "github.com/codex2api/security/outputguard" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "github.com/tidwall/gjson" +) + +// 辅助函数:构造测试用的 Handler +func setupTestProxyHandlerWithUpstream(t *testing.T, upstreamURL string) (*Handler, *gin.Engine) { + gin.SetMode(gin.TestMode) + db := newTestModelRegistryDB(t) + store := auth.NewStore(db, nil, &database.SystemSettings{MaxConcurrency: 2}) + + account := &auth.Account{ + Type: auth.AccountTypeCodexOAuth, + Status: auth.AccountStatusActive, + Provider: "openai", + BaseURL: upstreamURL, // 审查反馈 F7 修复:将 mock 上游 URL 准确注入 account.BaseURL,确保集成测试真实调用 mock! + } + account.SetID(101) + store.AddAccount(account) + + h := &Handler{ + db: db, + store: store, + } + + router := gin.New() + router.POST("/v1/responses", h.Responses) + router.POST("/v1/chat/completions", h.ChatCompletions) + router.POST("/v1/messages", h.Messages) + router.POST("/v1beta/models/:action", h.GeminiModelsAction) + router.GET("/v1/responses", h.ResponsesWebSocket) + router.GET("/v1/realtime", h.RealtimeWebSocket) + + return h, router +} + +// 1. 验证 Responses HTTP 流式上游失控输出被前置拦截,不进入 provenance/collector,立即 cancel 上游且阻断重试 +func TestResponsesHTTPStreamGuardTrippedAndRecoveryBlocked(t *testing.T) { + gin.SetMode(gin.TestMode) + SetOutputGuardReadyForTest(true) + defer ResetOutputGuardStateForTest() + + // 模拟上游返回持续短周期重复的流式 arguments + upstreamCalls := int32(0) + upstreamCanceled := make(chan struct{}) + var cancelOnce sync.Once + + upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&upstreamCalls, 1) + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + + // 监听 client cancel + notify := r.Context().Done() + go func() { + <-notify + cancelOnce.Do(func() { + close(upstreamCanceled) + }) + }() + + // 发送合法开始事件 + _, _ = fmt.Fprintf(w, "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n") + flusher.Flush() + + // 持续发送短周期重复 delta (p=10, 8192B 以上) + pattern := "0123456789" + for i := 0; i < 900; i++ { + chunk := fmt.Sprintf("event: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"call_id\":\"call_1\",\"delta\":%q}\n\n", pattern) + _, err := fmt.Fprint(w, chunk) + if err != nil { + return // 客户端已主动断开 + } + flusher.Flush() + time.Sleep(1 * time.Millisecond) + } + })) + defer upstreamServer.Close() + + // 配置 Guard 阈值 (使用 Spec 默认值) + ApplyOutputGuardSettings(database.OutputGuardSettings{ + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + _, router := setupTestProxyHandlerWithUpstream(t, upstreamServer.URL) + + reqBody := `{"model":"gpt-5.4","stream":true,"input":[{"role":"user","content":"run"}]}` + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + // 断言 1:上游只被调用了 1 次(禁止任何透明重试、换号重试或 catch-all 循环) + if calls := atomic.LoadInt32(&upstreamCalls); calls != 1 { + t.Fatalf("expected exactly 1 upstream call, got %d", calls) + } + + // 断言 2:上游 context 被主动 cancel(跳过了 5s usage drain) + select { + case <-upstreamCanceled: + // 正常收到上游取消 + case <-time.After(2 * time.Second): + t.Fatalf("expected upstream context to be canceled immediately, timed out") + } + + respBody := w.Body.String() + // 断言 3:下游收到标准的 response.failed 终态,且错误码为 model_output_guard_triggered + if !strings.Contains(respBody, "model_output_guard_triggered") { + t.Fatalf("expected response.failed with model_output_guard_triggered, got: %s", respBody) + } + // 断言 4:下游严禁收到完成帧 response.completed + if strings.Contains(respBody, "response.completed") { + t.Fatalf("downstream received response.completed after guard tripped!") + } +} + +// 2. 验证 continuousRetryStreamSelected 与 shouldTransparentRetryStream 坚决阻断 guardTripped +func TestRecoveryPointsBlockGuardTripped(t *testing.T) { + guardOutcome := streamOutcome{ + logStatusCode: http.StatusOK, + failureKind: "model_output_guard", + failureMessage: "Upstream model output exceeded safety policy", + guardTripped: true, + guardReason: "repetition", + penalize: false, + } + + catchAllPolicy := database.ContinuousRetryPolicy{ + Enabled: true, + MaxRetries: 5, + Categories: []string{"all"}, + } + + // 1) continuousRetryStreamSelected 必须返回 false + if continuousRetryStreamSelected(guardOutcome, nil, "response.failed", catchAllPolicy) { + t.Fatalf("continuousRetryStreamSelected must return false for guardTripped") + } + + // 2) continuousRetryStreamFailureSelected 必须返回 false + if continuousRetryStreamFailureSelected(guardOutcome, nil, "response.failed", catchAllPolicy) { + t.Fatalf("continuousRetryStreamFailureSelected must return false for guardTripped") + } + + // 3) shouldTransparentRetryStreamWithBudgets 必须返回 false + general := 0 + rate := 0 + if shouldTransparentRetryStreamWithBudgets(guardOutcome, &general, &rate, 3, 3, false, nil, nil, catchAllPolicy) { + t.Fatalf("shouldTransparentRetryStreamWithBudgets must return false for guardTripped") + } +} + +// 3. 验证 retryExclusions 对 guardTripped 豁免健康度惩罚 (使用真实方法 forHardSelection / hasResettable) +func TestRetryExclusionsZeroPenaltyForGuardTripped(t *testing.T) { + exclusions := newRetryAccountExclusions() + accountID := int64(1001) + + guardOutcome := streamOutcome{ + logStatusCode: http.StatusBadGateway, + failureKind: "model_output_guard", + guardTripped: true, + guardReason: "tool_argument_bytes", + penalize: false, + } + + exclusions.MarkStreamFailure(accountID, guardOutcome, 3, 3) + + // 断言:账号没有被放入 hard 或 transient 排除列表 + if exclusions.hasResettable() { + t.Fatalf("exclusions.hasResettable() should be false for guard failure") + } + hard := exclusions.forHardSelection() + if hard != nil && hard[accountID] { + t.Fatalf("account %d was penalized in hard exclusions for guard failure", accountID) + } +} + +// 4. 验证 WebSocket 入口 Ready 检查未就绪返回 1013 Close 帧,已就绪则正常 turn 检测 +func TestResponsesWebSocketReadinessAndGuardTripped(t *testing.T) { + SetOutputGuardReadyForTest(false) + defer ResetOutputGuardStateForTest() + + // 冷启动未就绪时建立 WebSocket + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upgrader := websocket.Upgrader{} + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + // 消费 Task 5 门禁:未就绪先发 503 协议错误帧,随后发送 1013 Close 帧 + if !RequireOutputGuardReadyWS(conn) { + return + } + })) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err == nil { + defer conn.Close() + // 审查反馈 F6b 修复:WS 未就绪时服务端先发 503 error 帧,紧接着发送 1013 Close 帧。 + // 循环读取直到捕获 1013 错误,断言协议关闭码为 CloseTryAgainLater (1013) + var closeErr *websocket.CloseError + for i := 0; i < 2; i++ { + _, msg, readErr := conn.ReadMessage() + if readErr != nil { + if errors.As(readErr, &closeErr) { + break + } + } + if len(msg) > 0 && strings.Contains(string(msg), "service_unavailable") { + // 成功读取到 503 协议错误帧 + } + } + if closeErr == nil || closeErr.Code != websocket.CloseTryAgainLater { + t.Fatalf("expected websocket close code 1013 (CloseTryAgainLater), got: %v", closeErr) + } + } else if resp != nil && resp.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("expected 503 or close 1013 on unready WS, got status: %d", resp.StatusCode) + } +} + +// 5. 验证锁内 SetWriteDeadline 与 TryLock 终态写入非阻塞 +func TestDownstreamWriteWithDeadlineAndTryLock(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + // 审查反馈 F6c 修复:使用真实已提交响应头的 gin.Context 测试 TryLock 争用与安全退出分支 + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.WriteHeader(http.StatusOK) + c.Writer.WriteHeaderNow() + _, _ = c.Writer.Write([]byte("event: response.created +data: {} + +")) + + var mu sync.Mutex + clientGone := false + + // 模拟已持锁的耗时写协程 + mu.Lock() + lockReleased := make(chan struct{}) + go func() { + time.Sleep(100 * time.Millisecond) + mu.Unlock() + close(lockReleased) + }() + + // 终态写入尝试 TryLock:若锁被占用应立即非阻塞放弃,绝不挂起,标记 clientGone = true + err := SendDownstreamGuardTerminalHTTP(c, &mu, &clientGone, "responses", "repetition") + if err == nil { + t.Fatalf("expected error or early return when downstream lock is busy") + } + if !clientGone { + t.Fatalf("expected clientGone to be set true upon TryLock contention") + } + + <-lockReleased +} + +// 6. 验证 Chat Completions 流式失控输出拦截:不追加 [DONE],返回合法 error 帧 +func TestChatCompletionsStreamGuardTrippedNoDone(t *testing.T) { + gin.SetMode(gin.TestMode) + SetOutputGuardReadyForTest(true) + defer ResetOutputGuardStateForTest() + + upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + + // 持续发送超大文本 delta (触发 channel_bytes) + bigChunk := strings.Repeat("A", 100000) + for i := 0; i < 25; i++ { // > 2.5 MiB + payload := fmt.Sprintf("data: {\"choices\":[{\"delta\":{\"content\":%q}}]}\n\n", bigChunk) + _, _ = fmt.Fprint(w, payload) + flusher.Flush() + } + })) + defer upstreamServer.Close() + + _, router := setupTestProxyHandlerWithUpstream(t, upstreamServer.URL) + + reqBody := `{"model":"gpt-4","stream":true,"messages":[{"role":"user","content":"generate"}]}` + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + respBody := w.Body.String() + // 下游严禁出现 [DONE] 标记 + if strings.Contains(respBody, "[DONE]") { + t.Fatalf("Chat Completions stream contained [DONE] after guard tripped: %s", respBody) + } + // 下游必须出现 model_output_guard_triggered 错误码 + if !strings.Contains(respBody, "model_output_guard_triggered") { + t.Fatalf("expected model_output_guard_triggered in chat stream, got: %s", respBody) + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestResponsesHTTPStreamGuardTrippedAndRecoveryBlocked|TestRecoveryPointsBlockGuardTripped|TestRetryExclusionsZeroPenaltyForGuardTripped|TestChatCompletionsStreamGuardTrippedNoDone"` +Expected: FAIL with compilation/runtime errors: `streamOutcome.guardTripped undefined` or `upstreamCalls > 1`. + +- [ ] **Step 3: Implement minimal code for Handler wiring and recovery blocking** + +#### 3.1 恢复与重试机制最前置阻断 (`proxy/continuous_retry.go`, `proxy/retry_exclusions.go`, `proxy/handler.go`) + +在 `proxy/continuous_retry.go` 的 `continuousRetryStreamSelected` 和 `continuousRetryStreamFailureSelected` 最开头插入 guardTripped 快速短路: + +```go +// proxy/continuous_retry.go:172 +func continuousRetryStreamSelected(outcome streamOutcome, payload []byte, eventType string, policies ...database.ContinuousRetryPolicy) bool { + // Guard 命中是请求级硬终态,绝不允许任何形式的 continuous retry 重新拉起! + if outcome.guardTripped { + return false + } + policy := continuousRetryPolicyForCall(policies) + if !policy.Enabled || outcome.terminalLocal { + return false + } +... +``` + +```go +// proxy/continuous_retry.go:276 +func continuousRetryStreamFailureSelected(outcome streamOutcome, payload []byte, eventType string, policies ...database.ContinuousRetryPolicy) bool { + if outcome.guardTripped || outcome.terminalLocal || strings.EqualFold(strings.TrimSpace(outcome.failureKind), "continuous_retry_timeout") { + return false + } +... +``` + +在 `proxy/retry_exclusions.go` 的 `MarkStreamFailureForEvent` 中增加对 `guardTripped` 的免罚判断: + +```go +// proxy/retry_exclusions.go:209 +func (r *retryAccountExclusions) MarkStreamFailureForEvent(accountID int64, outcome streamOutcome, eventType string, generalLimit, rateLimit int, policies ...database.ContinuousRetryPolicy) { + // 模型输出失控属于模型生成异常,不是账号故障的充分证据,严禁对账号施加健康度惩罚! + if outcome.guardTripped || strings.EqualFold(strings.TrimSpace(outcome.failureKind), "model_output_guard") { + return + } + failureKind := strings.ToLower(strings.TrimSpace(outcome.failureKind)) +... +``` + +在 `proxy/handler.go` 的 `shouldTransparentRetryStreamEventWithBudgets` 最开头增加 guardTripped 拦截: + +```go +// proxy/handler.go:2909 +func shouldTransparentRetryStreamEventWithBudgets(outcome streamOutcome, eventType string, generalRetries, rateLimitRetries *int, maxGeneralRetries, maxRateLimitRetries int, wroteAnyBody bool, ctxErr, writeErr error, policies ...database.ContinuousRetryPolicy) bool { + if outcome.guardTripped { + return false + } + if outcome.terminalLocal || wroteAnyBody || ctxErr != nil || writeErr != nil || outcome.logStatusCode == logStatusClientClosed { + return false + } +... +``` + +#### 3.2 `/v1/responses` HTTP 流式与非流式接线 (`proxy/handler.go`) + +在 `Responses` 处理函数中: +1. 入口检查门禁:`if !RequireOutputGuardReadyHTTP(c) { return }` +2. 初始化逻辑请求级 Guard 快照(跨 attempt 共享,单次请求 `reqGuard` 仅创建 1 次,不随 attempt 重新创建): + ```go + appliedGuardCfg := CurrentOutputGuardConfig() + reqGuard := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: appliedGuardCfg.Enabled, + MaxToolArgumentBytes: appliedGuardCfg.MaxToolArgumentBytes, + MaxToolArgumentDeltas: appliedGuardCfg.MaxToolArgumentDeltas, + MaxChannelBytes: appliedGuardCfg.MaxChannelBytes, + MaxRequestBytes: appliedGuardCfg.MaxRequestBytes, + }) + responsesProjector := NewResponsesProjector() + ``` +3. 在流式透传 `forwardWithEvent` 最开头(**必须早于 `bytes.Clone` provenance 记录与 `outputCollector.Add`**)插入 Guard 检查: + ```go + // proxy/handler.go:5538 + forwardWithEvent := func(sseEvent string, data []byte) bool { + if multiAgentV2State.isOptimizedFor(account.ID()) { + data = restoreCodexMultiAgentV2Response(data, multiAgentV2State) + } + streamDiag.markUpstreamFrame() + + // Output Guard 前置检查:必须在 bytes.Clone 记录 provenance、collector 累积及下游写入之前执行! + if err := ObserveEvent(reqGuard, responsesProjector, sseEvent, data); err != nil { + // Guard 命中!立即执行强制终止契约: + // 1. 取消上游 context(直接调用 cancel 跳过 5s usage drain) + if lastUpstreamCancel != nil { + lastUpstreamCancel() + } + // 2. 丢弃未提交的在途缓冲(provenance, collector, replay, pending buffers) + compactionProvenancePayloads = nil + outputCollector.clearOverflow() + pendingFirstTokenEvents.Reset() + if streamWriter != nil { + streamWriter.DiscardBuffered() + } + // 3. 标记 outcome 为 guardTripped,保留 typed reason + outcome = streamOutcome{ + logStatusCode: http.StatusBadGateway, + failureKind: "model_output_guard", + failureMessage: "Upstream model output exceeded safety policy; generation was stopped.", + guardTripped: true, + guardReason: reqGuard.TrippedReason(), + penalize: false, + } + // 4. 下游尽力交付写入错误帧(TryLock + SetWriteDeadline 5s,防挂死) + _ = SendDownstreamGuardTerminalHTTP(c, &downstreamMu, &clientGone, "responses", reqGuard.TrippedReason()) + return false + } + + if continuousRetryBuffersAttempts(continuousRetryPolicy) { + compactionProvenancePayloads = append(compactionProvenancePayloads, bytes.Clone(data)) + } else { + h.recordCompactionProvenanceFromPayload(context.Background(), account, data) + } + downstreamMu.Lock() + defer downstreamMu.Unlock() + ... + ``` +4. 在锁内所有常规写操作(包括数据帧写和注释保活写)之前,统一调用 `rc.SetWriteDeadline(time.Now().Add(5 * time.Second))`: + ```go + if rc := http.NewResponseController(c.Writer); rc != nil { + if err := rc.SetWriteDeadline(time.Now().Add(5 * time.Second)); err != nil { + if errors.Is(err, http.ErrNotSupported) { + // 不支持 Deadline 控制的底层,严禁无界阻塞同步写,安全退出 + clientGone = true + return false + } + } + } + ``` +5. 在恢复选择点(WebSocket 1009 降级 HTTP、首包前透明重试、自动续想、自动压缩等)前置阻断: + ```go + // proxy/handler.go:5915 + if outcome.guardTripped { + _ = streamAttempt.Close() + resp.Body.Close() + // 审查反馈 M7 修复:Guard 命中属于安全终止,必须立即执行且仅执行一次 Release(account); + // 严禁复用 WS messageTooBig 的 Retain 租约逻辑,严禁将异常连接归还健康池! + h.store.Release(account) + // 严格不重试、不降级、不写成功缓存 + return + } + ``` +6. 非流式响应处理:在读取完整响应体 `ReadBoundedResponseBody` 后,通过 `responsesProjector` 进行一次性完整快照观察;若触发 guard,清空本地缓冲并返回 502 错误,不返回半截 JSON。 + +#### 3.3 `/v1/responses` WebSocket 与 `/v1/realtime` 接线 (`proxy/responses_ws.go`, `proxy/realtime_ws.go`) + +1. 在 `ResponsesWebSocket` 握手阶段与 `RealtimeWebSocket` 建立阶段消费门禁: + ```go + if !RequireOutputGuardReadyWS(conn) { + return + } + ``` +2. 对每个 WebSocket 逻辑 turn 创建独立的 `RequestGuard` 实例(与逻辑 turn 生命周期绑定): + ```go + // proxy/responses_ws.go + turnGuard := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: appliedGuardCfg.Enabled, + MaxToolArgumentBytes: appliedGuardCfg.MaxToolArgumentBytes, + MaxToolArgumentDeltas: appliedGuardCfg.MaxToolArgumentDeltas, + MaxChannelBytes: appliedGuardCfg.MaxChannelBytes, + MaxRequestBytes: appliedGuardCfg.MaxRequestBytes, + }) + turnProjector := NewResponsesProjector() + ``` +3. 在 `readSSEStreamWithContinuousRetryKeepalive` 回调中最前置插入 `ObserveEvent`: + - 命中时立即废弃上游 WS lease(不归还健康池),关闭上游 body; + - 丢弃 collector、replay、pendingFirstTokenMessages; + - 标记 `turnOutcome.guardTripped = true`; + - 经由 `SendDownstreamGuardTerminalWS` 仅向当前 stream 发送失败帧,不影响无故障的复用流; + - 阻断 `wsHTTPFallback` 记录与重试。 +4. 在 `realtime_ws.go` 中,`normalizeRealtimeTextClientEvent` 显式拒绝音频 Modality;转发到 Responses WS 的文本 turn 自动继承上述 `turnGuard` 约束。 + +#### 3.4 `/v1/chat/completions` 接线 (`proxy/handler.go`) + +1. 入口检查:`if !RequireOutputGuardReadyHTTP(c) { return }` +2. 初始化 `chatProjector := NewChatProjector()` 与单请求级 `reqGuard`。 +3. 在流式转发回调中,于 `TranslateStreamChunkResult` 之前执行 `ObserveEvent`: + - 命中时主动 cancel 上游 context,清空 `pendingFirstTokenChunks`; + - 经由 `SendDownstreamGuardTerminalHTTP(..., "chat", ...)` 写入 OpenAI 格式错误包; + - 坚决**不追加 `data: [DONE]\n\n`**; + - 阻断后续 attempt,豁免账号惩罚。 + +#### 3.5 `/v1/messages` (Anthropic) 接线 (`proxy/handler_anthropic.go`) + +1. 入口检查:`if !RequireOutputGuardReadyHTTP(c) { return }` +2. 初始化 `anthropicProjector := NewAnthropicProjector()` 与请求级 `reqGuard`。 +3. 在流式循环(line 1575 之前)执行 `ObserveEvent`: + - 必须早于 `translator.translateEvent(data)`; + - 命中时 cancel 上游 context,清空 `pendingFirstTokenEvents`; + - 发送 Anthropic 标准 `event: error` 结构(带 `model_output_guard_triggered` code); + - 坚决**不伪造 `message_stop` 或 tool input 完成**; + - 阻断重试并安全退出。 + +#### 3.6 Gemini Native 接线 (`proxy/handler_gemini.go`) + +1. 在 `GeminiModelsAction` 与 `handleGeminiGenerateContent` 最前置检查门禁: + `if !RequireOutputGuardReadyHTTP(c) { return }` +2. 初始化 `geminiProjector := NewGeminiProjector()` 与请求级 `reqGuard`。 +3. 在流式读取块回调中最前置投影检查: + - 命中时主动 cancel 上游; + - 采用 Gemini SDK 兼容的错误输出结构(复用已有的 `sendUpstreamError` 或原生 error payload,不伪造不支持的 `event: error` 导致客户端崩溃); + - 阻断重试循环。 + +#### 3.7 Grok 与 Antigravity 原生渠道透传校验 + +在 Grok native passthrough 以及 Antigravity 专属通道中: +- 即使协议为 native 转发,数据流在到达下游 ResponseWriter 或 attempt writer 之前,一律经由 `ObserveEvent` 进行语义投影与预算/周期检查; +- 命中时坚决终止本次 dispatch,不让原生流绕过防护。 + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race ./proxy -run "TestResponsesHTTPStreamGuardTrippedAndRecoveryBlocked|TestRecoveryPointsBlockGuardTripped|TestRetryExclusionsZeroPenaltyForGuardTripped|TestResponsesWebSocketReadinessAndGuardTripped|TestDownstreamWriteWithDeadlineAndTryLock|TestChatCompletionsStreamGuardTrippedNoDone"` +Expected: PASS ok github.com/codex2api/proxy + +- [ ] **Step 5: Commit** + +```bash +git add proxy/continuous_retry.go proxy/retry_exclusions.go proxy/handler.go proxy/responses_ws.go proxy/realtime_ws.go proxy/handler_anthropic.go proxy/handler_gemini.go proxy/outputguard_handler_test.go +git commit -m "feat(proxy): wire model output guard across all protocols with complete recovery blocking" +``` + +--- + +### Task 11: 综合验收测试套件、性能指标工具与 Spec §9 完整覆盖矩阵 + +**Files:** +- Create: `test/outputguard/helpers.go`(脱敏合成 Fixture、真进程 CPU 计时器、高周期 PRNG、P95/中位数统计学分析函数及机器可判验证器) +- Create: `test/outputguard/helpers_test.go`(Helper 基础设施自验证测试:验证真实 RUSAGE 进程 CPU 测量、PRNG 无短周期、P95 算法及红灯故意失败捕获机制) +- Create: `security/outputguard/acceptance_detection_test.go`(检测正确性验收:场景 1~11,含 chunk-invariance、9215B 片段、8a/8b/8c 成本上界、尾检放行) +- Create: `proxy/acceptance_lifecycle_test.go`(全协议终止与生命周期验收:场景 12~20,含全协议 Handler 矩阵、无 5s drain、并发无 race、重试阻断 calls==1、多协议终态格式、TryLock/Deadline 降级、无账号惩罚与租约隔离) +- Create: `proxy/acceptance_config_test.go`(持久化与门禁验收:场景 21~23,含 SQLite/PostgreSQL 并发更新与恢复默认、冷启动 503 与 5s 轮询、Framing GuardOff 依然有界) +- Create: `test/outputguard/acceptance_benchmark_test.go`(性能与基准验收:场景 24~28,含 TTFT p95 <=10ms/CPU <=20%、1/2/4MiB ratio <=2.5、内存/FD/Goroutine 10分钟长跑门禁、细 delta 4096 deltas 专项压测、脱敏日志检查) +- Create: `scripts/run_acceptance_suite.sh`(全套验收与性能门禁自动化执行脚本) + +**Interfaces:** +- Consumes: + - `security/outputguard`(已在主 Plan Task 1~3 整合交付): + - `NewRepetitionDetector() *RepetitionDetector` + - `(d *RepetitionDetector) Push(data []byte) (bool, int)` + - `(d *RepetitionDetector) CheckTail() (bool, int)` + - `(d *RepetitionDetector) Reset()` + - `NewChannelGuard(id string, kind ChannelKind, maxBytes int64, maxDeltas int) *ChannelGuard` + - `(c *ChannelGuard) CheckAppend(delta []byte) (int64, int64, error)` + - `(c *ChannelGuard) CommitAppend(delta []byte, growth int64, cost int64) error` + - `(c *ChannelGuard) CheckSnapshot(snapshot []byte, terminalStage string) (int64, int64, bool, error)` + - `(c *ChannelGuard) CommitSnapshot(snapshot []byte, growth int64, cost int64, terminalStage string) error` + - `(c *ChannelGuard) RemainingScanBudget() int64` + - `(c *ChannelGuard) Highwater() int64` + - `(c *ChannelGuard) DeltaCount() int` + - `(c *ChannelGuard) ReleaseWindow()` + - `NewRequestGuard(cfg Config) *RequestGuard` + - `(g *RequestGuard) RecordEvent() error` + - `(g *RequestGuard) ObserveAppend(channelID string, aliasID string, kind ChannelKind, delta []byte) error` + - `(g *RequestGuard) ObserveSnapshot(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error` + - `(g *RequestGuard) ObserveAppendNoEvent(channelID string, aliasID string, kind ChannelKind, delta []byte) error` + - `(g *RequestGuard) ObserveSnapshotNoEvent(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error` + - `(g *RequestGuard) EndChannel(channelID string) error` + - `(g *RequestGuard) IsTripped() bool` + - `(g *RequestGuard) TrippedReason() string` + - `database`(已在主 Plan Task 4 整合交付): + - `DB.GetOutputGuardSettings(context.Context) (OutputGuardSettings, error)` + - `DB.UpdateOutputGuardSettings(context.Context, OutputGuardSettingsUpdate) (OutputGuardSettings, error)` + - `DefaultOutputGuardSettings() OutputGuardSettings` + - `ValidateOutputGuardSettings(OutputGuardSettings) error` + - `proxy`(已在主 Plan Task 5, 7, 8 及独立 Task 9, 10 交付): + - `ApplyOutputGuardSettings(database.OutputGuardSettings) bool` + - `CurrentOutputGuardConfig() OutputGuardAppliedConfig` + - `IsOutputGuardReady() bool` + - `SetOutputGuardReadyForTest(bool)` + - `ResetOutputGuardStateForTest()` + - `LoadOutputGuardSettings(context.Context, OutputGuardSettingsReader) error` + - `StartOutputGuardConfigPoller(context.Context, *database.DB) bool` + - `RequireOutputGuardReadyHTTP(*gin.Context) bool` + - `RequireOutputGuardReadyWS(*websocket.Conn) bool` + - `RequireOutputGuardReadyStream(*websocket.Conn, string) bool` + - `NewResponsesProjector() *ResponsesProjector` + - `NewChatProjector() *ChatProjector` + - `NewAnthropicProjector() *AnthropicProjector` + - `NewGeminiProjector() *GeminiProjector` + - `ObserveEvent(guard *outputguard.RequestGuard, projector ProtocolProjector, eventType string, data []byte) error` + - `ReadSSEStreamWithEvent(io.Reader, func(event string, data []byte) bool) error` + - `ReadBoundedResponseBody(io.Reader, int64) ([]byte, error)` + - `NewGuardTerminationCoordinator(guard *outputguard.RequestGuard, cancel context.CancelFunc, body io.Closer) *GuardTerminationCoordinator` + - `(c *GuardTerminationCoordinator) HandleTripped(reason string, discardBuffers ...func()) streamOutcome` + - `ApplyDownstreamWriteDeadline(w http.ResponseWriter, timeout time.Duration) error` + - `SendDownstreamGuardTerminalHTTP(c *gin.Context, mu *sync.Mutex, clientGone *bool, protocol string, reason string) error` + - `SendDownstreamGuardTerminalWS(conn *websocket.Conn, streamID string, reason string) error` + - `FormatGuardTerminalEnvelope(protocol string, streamID string) ([]byte, int, error)` + - `OverlayGuardOutcome(outcome streamOutcome, guard *outputguard.RequestGuard) streamOutcome` + - `(w *streamFlushWriter) DiscardBuffered()` +- Produces: + - 机器可判定的 Spec §9 场景 1~28 全量自动化验收套件与覆盖矩阵; + - 严格消除伪基准与伪函数的统计学验证库; + - 具备 15 分钟长跑泄漏门禁与环境门控标志的压测脚本。 + +--- + +### Task 11.1: 验收测试基础设施与统计学分析库 (Helpers, Process CPU Timer, PRNG, Validators) + +**Files:** +- Create: `test/outputguard/helpers.go` +- Create: `test/outputguard/helpers_test.go` + +**Interfaces:** +- Produces: + ```go + package testoutputguard + + import ( + "math/rand" + "syscall" + "time" + ) + + type ProcessCPUTimer struct { + startUsage syscall.Rusage + startTime time.Time + } + + func StartProcessCPUTimer() *ProcessCPUTimer + func (t *ProcessCPUTimer) ElapsedProcessCPU() time.Duration + func (t *ProcessCPUTimer) ElapsedWallTime() time.Duration + + type NonRepeatingPRNG struct { + r *rand.Rand + } + func NewNonRepeatingPRNG(seed int64) *NonRepeatingPRNG + func (p *NonRepeatingPRNG) Fill(buf []byte) + func (p *NonRepeatingPRNG) GenerateASCII(length int) []byte + + func CalculatePercentile(samples []float64, percentile float64) float64 + func CalculateMedian(samples []float64) float64 + + type PerformanceBenchmarkResult struct { + BaselineP95TTFTMs float64 + GuardP95TTFTMs float64 + TTFTDeltaMs float64 + BaselineCPUTimeMs float64 + GuardCPUTimeMs float64 + CPUIncreaseRatio float64 + } + + func ValidateP95AndCPUIncrease(res PerformanceBenchmarkResult) error + func ValidateLinearScalingRatio(cpu1, cpu2, cpu4 float64) error + ``` + +- [ ] **Step 1: Write failing test for benchmark helpers and statistical validators** + +```go +// test/outputguard/helpers_test.go +package testoutputguard + +import ( + "strings" + "testing" + "time" +) + +func TestProcessCPUTimerMeasuresActualWork(t *testing.T) { + timer := StartProcessCPUTimer() + // 执行一段纯 CPU 计算密集型循环,确保产生可测量的 Process CPU 纳秒 + sum := int64(0) + for i := int64(0); i < 10000000; i++ { + sum += (i * 3) ^ (i >> 1) + } + _ = sum + + cpuElapsed := timer.ElapsedProcessCPU() + wallElapsed := timer.ElapsedWallTime() + + if cpuElapsed <= 0 { + t.Fatalf("expected positive process CPU elapsed, got %v", cpuElapsed) + } + if wallElapsed <= 0 { + t.Fatalf("expected positive wall elapsed, got %v", wallElapsed) + } + // 在单核密集计算下,CPU 耗时应小于等于 Wall 耗时(外加误差窗) + if cpuElapsed > wallElapsed+50*time.Millisecond { + t.Fatalf("process CPU elapsed (%v) exceeded wall elapsed (%v)", cpuElapsed, wallElapsed) + } +} + +func TestNonRepeatingPRNGHasNoShortCycle(t *testing.T) { + prng := NewNonRepeatingPRNG(987654321) + data := prng.GenerateASCII(32768) // 32 KiB + + // 验证绝不使用短周期 mod 251 算法,连续 16 字节在 32 KiB 内无任何简单重复片段 + seen16 := make(map[string]bool) + duplicates := 0 + for i := 0; i <= len(data)-16; i += 16 { + block := string(data[i : i+16]) + if seen16[block] { + duplicates++ + } + seen16[block] = true + } + if duplicates > 0 { + t.Fatalf("PRNG generated %d duplicate 16-byte blocks, expected pseudo-random text without short cycle", duplicates) + } +} + +func TestCalculatePercentileAndMedian(t *testing.T) { + samples := []float64{10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0} + p95 := CalculatePercentile(samples, 0.95) + if p95 < 94.0 || p95 > 96.0 { + t.Fatalf("want p95 around 95.0, got %f", p95) + } + + median := CalculateMedian(samples) + if median != 55.0 { + t.Fatalf("want median 55.0, got %f", median) + } +} + +// 验证 TDD Red 门禁:对故意不达标的输入,验证器必须返回明确的门禁失败,绝不放行 +func TestPerformanceValidatorRejectsUnqualifiedResults(t *testing.T) { + // 1. TTFT 增量超标(目标 <= 10ms,实际 15ms) + unqualifiedTTFT := PerformanceBenchmarkResult{ + BaselineP95TTFTMs: 50.0, + GuardP95TTFTMs: 65.0, + TTFTDeltaMs: 15.0, + BaselineCPUTimeMs: 10.0, + GuardCPUTimeMs: 11.0, + CPUIncreaseRatio: 0.10, + } + if err := ValidateP95AndCPUIncrease(unqualifiedTTFT); err == nil || !strings.Contains(err.Error(), "TTFT delta") { + t.Fatalf("expected error on TTFT delta > 10ms, got %v", err) + } + + // 2. CPU 增量超标(目标 <= 20%,实际 25%) + unqualifiedCPU := PerformanceBenchmarkResult{ + BaselineP95TTFTMs: 50.0, + GuardP95TTFTMs: 55.0, + TTFTDeltaMs: 5.0, + BaselineCPUTimeMs: 10.0, + GuardCPUTimeMs: 12.5, + CPUIncreaseRatio: 0.25, + } + if err := ValidateP95AndCPUIncrease(unqualifiedCPU); err == nil || !strings.Contains(err.Error(), "CPU increase ratio") { + t.Fatalf("expected error on CPU increase > 20%%, got %v", err) + } + + // 3. 非线性比值超标(目标 <= 2.5,实际 3.0) + if err := ValidateLinearScalingRatio(10.0, 22.0, 66.0); err == nil || !strings.Contains(err.Error(), "scaling ratio") { + t.Fatalf("expected error on scaling ratio > 2.5, got %v", err) + } + + // 4. 合格结果顺利通过 + qualified := PerformanceBenchmarkResult{ + BaselineP95TTFTMs: 50.0, + GuardP95TTFTMs: 56.0, + TTFTDeltaMs: 6.0, + BaselineCPUTimeMs: 10.0, + GuardCPUTimeMs: 11.2, + CPUIncreaseRatio: 0.12, + } + if err := ValidateP95AndCPUIncrease(qualified); err != nil { + t.Fatalf("expected qualified result to pass, got: %v", err) + } + if err := ValidateLinearScalingRatio(10.0, 20.0, 42.0); err != nil { + t.Fatalf("expected qualified scaling to pass, got: %v", err) + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./test/outputguard -run "TestProcessCPUTimer.*"` +Expected: FAIL with compilation error: package testoutputguard is not in GOROOT or GOPATH / undefined: StartProcessCPUTimer + +- [ ] **Step 3: Write minimal implementation for test helpers** + +```go +// test/outputguard/helpers.go +package testoutputguard + +import ( + "fmt" + "math" + "math/rand" + "sort" + "syscall" + "time" +) + +type ProcessCPUTimer struct { + startUsage syscall.Rusage + startTime time.Time +} + +func StartProcessCPUTimer() *ProcessCPUTimer { + var usage syscall.Rusage + _ = syscall.Getrusage(syscall.RUSAGE_SELF, &usage) + return &ProcessCPUTimer{ + startUsage: usage, + startTime: time.Now(), + } +} + +func (t *ProcessCPUTimer) ElapsedProcessCPU() time.Duration { + var current syscall.Rusage + _ = syscall.Getrusage(syscall.RUSAGE_SELF, ¤t) + + userSec := current.Utime.Sec - t.startUsage.Utime.Sec + userUsec := current.Utime.Usec - t.startUsage.Utime.Usec + sysSec := current.Stime.Sec - t.startUsage.Stime.Sec + sysUsec := current.Stime.Usec - t.startUsage.Stime.Usec + + totalMicros := (userSec+sysSec)*1000000 + (userUsec + sysUsec) + if totalMicros < 0 { + totalMicros = 0 + } + return time.Duration(totalMicros) * time.Microsecond +} + +func (t *ProcessCPUTimer) ElapsedWallTime() time.Duration { + return time.Since(t.startTime) +} + +type NonRepeatingPRNG struct { + r *rand.Rand +} + +func NewNonRepeatingPRNG(seed int64) *NonRepeatingPRNG { + return &NonRepeatingPRNG{r: rand.New(rand.NewSource(seed))} +} + +func (p *NonRepeatingPRNG) Fill(buf []byte) { + _, _ = p.r.Read(buf) +} + +// GenerateASCII 生成指定长度的非周期可打印 ASCII 文本,基于高混淆字典,避免短周期 +func (p *NonRepeatingPRNG) GenerateASCII(length int) []byte { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_/.,~+!@#$%^&*() " + buf := make([]byte, length) + for i := 0; i < length; i++ { + buf[i] = charset[p.r.Intn(len(charset))] + } + return buf +} + +func CalculatePercentile(samples []float64, percentile float64) float64 { + if len(samples) == 0 { + return 0 + } + sorted := make([]float64, len(samples)) + copy(sorted, samples) + sort.Float64s(sorted) + + index := percentile * float64(len(sorted)-1) + lower := int(math.Floor(index)) + upper := int(math.Ceil(index)) + weight := index - float64(lower) + + if lower == upper { + return sorted[lower] + } + return sorted[lower]*(1.0-weight) + sorted[upper]*weight +} + +func CalculateMedian(samples []float64) float64 { + return CalculatePercentile(samples, 0.50) +} + +type PerformanceBenchmarkResult struct { + BaselineP95TTFTMs float64 + GuardP95TTFTMs float64 + TTFTDeltaMs float64 + BaselineCPUTimeMs float64 + GuardCPUTimeMs float64 + CPUIncreaseRatio float64 +} + +func ValidateP95AndCPUIncrease(res PerformanceBenchmarkResult) error { + const maxTTFTDeltaMs = 10.0 + const maxCPUIncreaseRatio = 0.20 + + if res.TTFTDeltaMs > maxTTFTDeltaMs { + return fmt.Errorf("TTFT delta %.2f ms exceeds threshold %.2f ms (baseline=%.2f, guard=%.2f)", + res.TTFTDeltaMs, maxTTFTDeltaMs, res.BaselineP95TTFTMs, res.GuardP95TTFTMs) + } + if res.CPUIncreaseRatio > maxCPUIncreaseRatio { + return fmt.Errorf("CPU increase ratio %.2f%% exceeds threshold %.2f%% (baseline=%.2fms, guard=%.2fms)", + res.CPUIncreaseRatio*100, maxCPUIncreaseRatio*100, res.BaselineCPUTimeMs, res.GuardCPUTimeMs) + } + return nil +} + +func ValidateLinearScalingRatio(cpu1, cpu2, cpu4 float64) error { + const maxRatio = 2.5 + if cpu1 <= 0 || cpu2 <= 0 || cpu4 <= 0 { + return fmt.Errorf("invalid non-positive CPU timings: cpu1=%.2f, cpu2=%.2f, cpu4=%.2f", cpu1, cpu2, cpu4) + } + ratio12 := cpu2 / cpu1 + ratio24 := cpu4 / cpu2 + + if ratio12 > maxRatio { + return fmt.Errorf("scaling ratio 2MiB/1MiB %.2f exceeds threshold %.2f (cpu1=%.2f, cpu2=%.2f)", + ratio12, maxRatio, cpu1, cpu2) + } + if ratio24 > maxRatio { + return fmt.Errorf("scaling ratio 4MiB/2MiB %.2f exceeds threshold %.2f (cpu2=%.2f, cpu4=%.2f)", + ratio24, maxRatio, cpu2, cpu4) + } + return nil +} +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./test/outputguard -run "Test.*"` +Expected: PASS ok github.com/codex2api/test/outputguard + +- [ ] **Step 5: Commit** + +```bash +git add test/outputguard/helpers.go test/outputguard/helpers_test.go +git commit -m "test(outputguard): implement benchmark helpers, process CPU timer and validators" +``` + +--- + +### Task 11.2: 检测层规格验收套件(Spec §9.1 场景 1~11) + +**Files:** +- Create: `security/outputguard/acceptance_detection_test.go` + +**Interfaces:** +- Consumes: + - `security/outputguard.NewRepetitionDetector()` + - `security/outputguard.NewChannelGuard()` + - `security/outputguard.NewRequestGuard()` + - `security/outputguard.KindText`, `KindToolArguments`, `KindThinking` +- Produces: + - 场景 1~11 完整端到端黑盒覆盖与可执行断言。 + +- [ ] **Step 1: Write failing test for Spec §9.1 detection scenarios 1-11** + +```go +// security/outputguard/acceptance_detection_test.go +package outputguard + +import ( + "bytes" + "fmt" + "math/rand" + "strings" + "testing" +) + +// 场景 1: 有效 outer SSE JSON、未闭合 inner arguments、持续事故样式短周期重复:在重复阈值附近触发,不等到 128 KiB +func TestAcceptanceScenario1_AccidentPatternEarlyTrigger(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, // 128 KiB + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + // 事故短周期模式 p = 30 ("_auditfree.md_pluginless.tmp.m") + pattern := []byte("_auditfree.md_pluginless.tmp.m") + chunk := bytes.Repeat(pattern, 10) // 300 字节 + totalPushed := 0 + trippedAt := 0 + + // 逐步输入,未闭合 inner arguments + for i := 0; i < 50; i++ { + err := rg.ObserveAppend("call_write", "alias_write", KindToolArguments, chunk) + totalPushed += len(chunk) + if err != nil { + trippedAt = totalPushed + if err.Error() != "repetition" { + t.Fatalf("expected repetition error, got %v", err) + } + break + } + } + + if trippedAt == 0 { + t.Fatalf("expected guard to trip, but reached totalPushed=%d", totalPushed) + } + // 断言:在 8192 ~ 10240 字节(第一个对齐检查点)附近立即触发,绝不等 128 KiB (131072) + if trippedAt > 10240 { + t.Fatalf("expected early trigger near 8192-10240 bytes, but triggered at %d bytes", trippedAt) + } + if !rg.IsTripped() || rg.TrippedReason() != "repetition" { + t.Fatalf("expected tripped with repetition, got %s", rg.TrippedReason()) + } +} + +// 场景 2: 同语义流按 1-byte、小/大 delta、随机边界分割均可检测,切分不能让重复消失 +func TestAcceptanceScenario2_ChunkInvariance(t *testing.T) { + pattern := []byte("defensive_pipeline_token_replay_") // 32 bytes + var fullStream []byte + for len(fullStream) < 12000 { + fullStream = append(fullStream, pattern...) + } + + chunkSizes := []int{1, 7, 64, 512, 1024, 2048} + for _, size := range chunkSizes { + t.Run(fmt.Sprintf("FixedChunkSize_%d", size), func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 65536, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + hit := false + for offset := 0; offset < len(fullStream); offset += size { + end := offset + size + if end > len(fullStream) { + end = len(fullStream) + } + err := rg.ObserveAppend("c_inv", "", KindText, fullStream[offset:end]) + if err != nil { + if err.Error() == "repetition" { + hit = true + break + } + t.Fatalf("unexpected error: %v", err) + } + } + if !hit { + t.Fatalf("chunk size %d failed to detect repetition across boundaries", size) + } + }) + } + + // 随机边界切分测试 + t.Run("RandomChunkBoundaries", func(t *testing.T) { + r := rand.New(rand.NewSource(42)) + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 65536, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + offset := 0 + hit := false + for offset < len(fullStream) { + sz := r.Intn(256) + 1 + end := offset + sz + if end > len(fullStream) { + end = len(fullStream) + } + err := rg.ObserveAppend("c_rand", "", KindText, fullStream[offset:end]) + if err != nil { + if err.Error() == "repetition" { + hit = true + break + } + t.Fatalf("unexpected error: %v", err) + } + offset = end + } + if !hit { + t.Fatalf("random chunk boundaries failed to detect repetition") + } + }) +} + +// 场景 3: 单大 delta 中间有任意起点、长度至少 9215 bytes 的精确短周期片段,尾部正常仍触发,整事件下游不可见 +// 另测恰好 8192 bytes 的检查点对齐与非对齐边界 +func TestAcceptanceScenario3_Embedded9215BytesAnd8192Boundary(t *testing.T) { + // 1. 中间嵌入精确 9215 字节短周期:在线触发 (I1 修复) + rg := outputguard.NewRequestGuard(acceptanceDefaultConfig()) + prefix := []byte("Initial unique introduction text... ") + suffix := []byte(" trailing normal markdown data.") + pattern := []byte("0123456789abcdef0123") // 20 bytes + var repData []byte + for len(repData) < 9215 { + repData = append(repData, pattern...) + } + repData = repData[:9215] // 严格精确 9215 字节 + + fullStream := append(prefix, append(repData, suffix...)...) + err := rg.ObserveAppend("c_embed", "", outputguard.KindText, fullStream) + if err == nil || err.Error() != "repetition" { + t.Fatalf("expected repetition hit on embedded 9215-byte periodic segment, got: %v", err) + } + + // 2. 尾检规则验证 (I2 修复:前置 108 字节杂散数据 + 8192 字节周期数据,累计 8300 字节非 1024 对齐,且生成过程中由于杂散前缀导致滑动检查点不周期,仅在流结束 EndChannel 提取最新完整 8192 窗口时精准命中) + rgTail := outputguard.NewRequestGuard(acceptanceDefaultConfig()) + unalignedPrefix := bytes.Repeat([]byte("alpha_beta_"), 10) // 110 bytes + var windowPeriod []byte + for len(windowPeriod) < 8192 { + windowPeriod = append(windowPeriod, pattern...) + } + windowPeriod = windowPeriod[:8192] + tailData := append(unalignedPrefix, windowPeriod...) + + // 分块推送,中间由于未对齐检查点不命中 + for off := 0; off < len(tailData); off += 512 { + end := off + 512 + if end > len(tailData) { + end = len(tailData) + } + _ = rgTail.ObserveAppend("c_tail", "", outputguard.KindText, tailData[off:end]) + } + // 尾检 EndChannel 提取最新 8192 字节严格周期窗口,断言精准触发重复! + err = rgTail.EndChannel("c_tail") + if err == nil || err.Error() != "repetition" { + t.Fatalf("expected repetition hit in EndChannel unaligned tail check, got: %v", err) + } +} + +// 场景 4: 多 tool/text/thinking 交错不互相污染,ID/index 补发不重置计数 +func TestAcceptanceScenario4_InterleavedChannelsAndNoReset(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + textData := []byte("Normal text message token. ") + thinkingData := []byte("Deep reflection thoughts. ") + toolData := []byte(`{"arg":"value_`) + + for i := 0; i < 50; i++ { + if err := rg.ObserveAppend("c_text", "", KindText, textData); err != nil { + t.Fatalf("text append failed: %v", err) + } + if err := rg.ObserveAppend("c_think", "", KindThinking, thinkingData); err != nil { + t.Fatalf("thinking append failed: %v", err) + } + if err := rg.ObserveAppend("c_tool", "alias_tool_0", KindToolArguments, toolData); err != nil { + t.Fatalf("tool append failed: %v", err) + } + } + + // 补发已存在的 ID/alias,断言不重置计数(高水位和 deltaCount 保持累加) + cg := rg.channels["c_tool"] + if cg == nil { + t.Fatalf("channel c_tool not found") + } + initialDeltas := cg.DeltaCount() + if initialDeltas != 50 { + t.Fatalf("want deltaCount=50, got %d", initialDeltas) + } + + // 再次通过 alias 补发 + if err := rg.ObserveAppend("c_tool", "alias_tool_0", KindToolArguments, []byte(`"}`)); err != nil { + t.Fatalf("subsequent append failed: %v", err) + } + if cg.DeltaCount() != 51 { + t.Fatalf("delta count was reset! want 51, got %d", cg.DeltaCount()) + } +} + +// 场景 5: 非重复参数超 bytes、细碎参数超 delta、跨 channel 绕单项上限、空 delta 洪泛、新 channel 洪泛 +func TestAcceptanceScenario5_BudgetInterception(t *testing.T) { + // 5a: 非重复参数超 bytes (128 KiB) + t.Run("ToolBytesBudget", func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 65536, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + prng := rand.New(rand.NewSource(2001)) + data := make([]byte, 131073) // 超出 1 字节 + prng.Read(data) + err := rg.ObserveAppend("c_tool", "", KindToolArguments, data) + if err == nil || err.Error() != "tool_argument_bytes" { + t.Fatalf("expected tool_argument_bytes error, got %v", err) + } + }) + + // 5b: 细碎参数超 delta (4096 deltas) + t.Run("ToolDeltasBudget", func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + for i := 0; i < 4096; i++ { + if err := rg.ObserveAppend("c_tool", "", KindToolArguments, []byte("x")); err != nil { + t.Fatalf("unexpected err at %d: %v", i, err) + } + } + // 第 4097 次 delta 必须被拦截 + err := rg.ObserveAppend("c_tool", "", KindToolArguments, []byte("x")) + if err == nil || err.Error() != "tool_argument_deltas" { + t.Fatalf("expected tool_argument_deltas error, got %v", err) + } + }) + + // 5c: 跨 channel 累积绕过单项上限,被 MaxRequestBytes 拦截 + t.Run("CrossChannelRequestBytesBudget", func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 1048576, // 1 MiB + MaxRequestBytes: 2097152, // 2 MiB + }) + prng := rand.New(rand.NewSource(2002)) + chunk := make([]byte, 2097150) // 2 * 2097150 = 4194300 bytes, 距离 4194304 预算仅差 4 字节 + prng.Read(chunk) + + // channel 1 消耗 ~1 MiB + if err := rg.ObserveAppend("ch1", "", KindText, chunk); err != nil { + t.Fatalf("ch1 failed: %v", err) + } + // channel 2 消耗 ~1 MiB + if err := rg.ObserveAppend("ch2", "", KindText, chunk); err != nil { + t.Fatalf("ch2 failed: %v", err) + } + // channel 3 超过总请求 2 MiB 预算 + err := rg.ObserveAppend("ch3", "", KindText, []byte("overflow")) + if err == nil || err.Error() != "request_bytes" { + t.Fatalf("expected request_bytes error, got %v", err) + } + }) + + // 5d: 空 delta 洪泛(由 request_events 拦截) + t.Run("EmptyDeltaFlood", func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + for i := 0; i < MaxRequestEvents; i++ { + if err := rg.ObserveAppend("ch_empty", "", KindText, nil); err != nil { + t.Fatalf("empty delta at %d error: %v", i, err) + } + } + err := rg.ObserveAppend("ch_empty", "", KindText, nil) + if err == nil || err.Error() != "request_events" { + t.Fatalf("expected request_events error on empty delta flood, got %v", err) + } + }) + + // 5e: 新 channel 洪泛(超出 128 个) + t.Run("ChannelFlood", func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + for i := 0; i < MaxRequestChannels; i++ { + id := fmt.Sprintf("chan_%d", i) + if err := rg.ObserveAppend(id, "", KindText, []byte("ok")); err != nil { + t.Fatalf("chan %d error: %v", i, err) + } + } + err := rg.ObserveAppend("chan_129", "", KindText, []byte("overflow")) + if err == nil || err.Error() != "channel_count" { // Spec §5.3 reason 固定为 channel_count (C14 修复) + t.Fatalf("expected request_channels error, got %v", err) + } + }) +} + +// 场景 6: 等值允许、超一拒绝;中文、emoji、escapes、int64 overflow 均测试 +func TestAcceptanceScenario6_BoundaryValuesAndUTF8(t *testing.T) { + // 恰好 131072 字节放行,131073 字节拒绝 + t.Run("ExactBoundary", func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + prng := rand.New(rand.NewSource(3001)) + exactData := make([]byte, 131072) + prng.Read(exactData) + if err := rg.ObserveAppend("c_exact", "", KindToolArguments, exactData); err != nil { + t.Fatalf("expected exact 131072 bytes allowed, got %v", err) + } + + rgOver := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + overData := make([]byte, 131073) + prng.Read(overData) + if err := rgOver.ObserveAppend("c_over", "", KindToolArguments, overData); err == nil { + t.Fatalf("expected 131073 bytes rejected, got nil error") + } + }) + + // 中文、Emoji (4 字节)、转义字符 + t.Run("UTF8AndEscapes", func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + chineseText := strings.Repeat("深度思考模型安全防护策略,", 100) // ~3600 bytes + emojiText := strings.Repeat("🛡️🚀🌟💻🔥", 100) // ~2400 bytes + escapes := strings.Repeat(`\n\t\"\\\/`, 200) + + if err := rg.ObserveAppend("c_utf8", "", KindText, []byte(chineseText)); err != nil { + t.Fatalf("chinese text failed: %v", err) + } + if err := rg.ObserveAppend("c_utf8", "", KindText, []byte(emojiText)); err != nil { + t.Fatalf("emoji text failed: %v", err) + } + if err := rg.ObserveAppend("c_utf8", "", KindText, []byte(escapes)); err != nil { + t.Fatalf("escapes failed: %v", err) + } + if rg.IsTripped() { + t.Fatalf("expected not tripped on normal UTF-8, got %s", rg.TrippedReason()) + } + }) +} + +// 场景 7: done-only、terminal-only、native object、非流式、更大/变化的最终快照不能绕过 +func TestAcceptanceScenario7_SnapshotsCannotBypass(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + // 构造超限的非流式 / terminal 快照 (140 KiB) + prng := rand.New(rand.NewSource(4001)) + oversizedSnapshot := make([]byte, 143360) + prng.Read(oversizedSnapshot) + + // 非流式快照直接送入 + err := rg.ObserveSnapshot("call_final", "", KindToolArguments, oversizedSnapshot, "arguments.done") + if err == nil || err.Error() != "tool_argument_bytes" { + t.Fatalf("expected tool_argument_bytes on oversized snapshot, got %v", err) + } +} + +// 场景 8: 成本上界 8a/8b/8c 专项黑盒验收测试 +func TestAcceptanceScenario8_BudgetScanPermitsAndBoundingCases(t *testing.T) { + // 8a: 恰好耗尽与超额拒绝(审查反馈 C3/C4/mod251 彻底修复:采用 NonRepeatingPRNG 避免周期重复干扰,严格验证 128 KiB delta + 128 KiB done 恰好耗尽 256 KiB 扫描预算) + t.Run("Case8a_ScanBudgetExhaustionAndVariantRejection", func(t *testing.T) { + cg := NewChannelGuard("call_8a", KindToolArguments, 131072, 4096) + prng := testoutputguard.NewNonRepeatingPRNG(8001) + + var fullParam []byte + // 128 次 1 KiB delta 增量写入,累计消耗 128 KiB 扫描预算(剩余 128 KiB) + for i := 0; i < 128; i++ { + chunk := prng.GenerateASCII(1024) + fullParam = append(fullParam, chunk...) + growth, cost, err := cg.CheckAppend(chunk) + if err != nil { + t.Fatalf("step %d CheckAppend failed: %v", i, err) + } + if err := cg.CommitAppend(chunk, growth, cost); err != nil { + t.Fatalf("step %d CommitAppend failed: %v", i, err) + } + } + if cg.RemainingScanBudget() != 131072 { + t.Fatalf("want remainingScanBudget=131072, got %d", cg.RemainingScanBudget()) + } + + // 首个 128 KiB 快照到达,成本恰为 128 KiB,扣减后剩余预算降为 0 并建立基准 Hash + growth, cost, skip, err := cg.CheckSnapshot(fullParam, "arguments.done") + if err != nil || cost != 131072 || skip { + t.Fatalf("baseline snapshot: err=%v, cost=%d, skip=%v", err, cost, skip) + } + if err := cg.CommitSnapshot(fullParam, growth, cost, "arguments.done"); err != nil { + t.Fatalf("baseline CommitSnapshot failed: %v", err) + } + if cg.RemainingScanBudget() != 0 { + t.Fatalf("want remainingScanBudget=0, got %d", cg.RemainingScanBudget()) + } + + // 微小篡改变体快照到达(修改中部 1 字节),Hash 不匹配废弃许可,cost=128KiB > 剩余预算(0),在 ChannelGuard 层返回 ErrScanBudgetExhausted (C3 修复) + tampered := make([]byte, len(fullParam)) + copy(tampered, fullParam) + tampered[65536] ^= 0xFF + _, _, _, err = cg.CheckSnapshot(tampered, "arguments.done") + if err == nil || !errors.Is(err, ErrScanBudgetExhausted) { + t.Fatalf("expected ErrScanBudgetExhausted on variant snapshot exceeding scan budget, got %v", err) + } + }) + + // 8b: 合法终态链有资格零计费放行与无资格拒绝(最多 3 次许可,C4 补足 delta 前置耗预算前提) + t.Run("Case8b_PureTerminalPermitsAndCap", func(t *testing.T) { + cg := NewChannelGuard("call_8b", KindToolArguments, 131072, 4096) + prng := testoutputguard.NewNonRepeatingPRNG(8002) + + // 1. 先通过 128 次 1 KiB delta 增量写入消耗 128 KiB 扫描预算(剩余 128 KiB) + var fullData []byte + for i := 0; i < 128; i++ { + chunk := prng.GenerateASCII(1024) + fullData = append(fullData, chunk...) + growth, cost, err := cg.CheckAppend(chunk) + if err != nil { + t.Fatalf("step %d CheckAppend failed: %v", i, err) + } + if err := cg.CommitAppend(chunk, growth, cost); err != nil { + t.Fatalf("step %d CommitAppend failed: %v", i, err) + } + } + + // 2. 首个终态快照 arguments.done 到达,建立基准 Hash,耗尽剩余 128 KiB 扫描预算(剩余预算降为 0) + growth, cost, _, err := cg.CheckSnapshot(fullData, "arguments.done") + if err != nil { + t.Fatalf("initial snapshot failed: %v", err) + } + if err := cg.CommitSnapshot(fullData, growth, cost, "arguments.done"); err != nil { + t.Fatalf("initial CommitSnapshot failed: %v", err) + } + if cg.RemainingScanBudget() != 0 { + t.Fatalf("want remainingScanBudget=0, got %d", cg.RemainingScanBudget()) + } + + // 许可 2: output_item.done 零计费放行(cost=0, skip=true) + growth, cost, skip, err := cg.CheckSnapshot(fullData, "item.done") + if err != nil || cost != 0 || !skip { + t.Fatalf("permit 2 failed: cost=%d, skip=%v, err=%v", cost, skip, err) + } + if err := cg.CommitSnapshot(fullData, growth, cost, "item.done"); err != nil { + t.Fatalf("permit 2 CommitSnapshot failed: %v", err) + } + + // 许可 3: response.completed 零计费放行(cost=0, skip=true) + growth, cost, skip, err = cg.CheckSnapshot(fullData, "response.completed") + if err != nil || cost != 0 || !skip { + t.Fatalf("permit 3 failed: cost=%d, skip=%v, err=%v", cost, skip, err) + } + if err := cg.CommitSnapshot(fullData, growth, cost, "response.completed"); err != nil { + t.Fatalf("permit 3 CommitSnapshot failed: %v", err) + } + + // 第 4 次终态确认快照:超出最多 3 次许可,直接视为普通快照走全量计费 cost=128KiB,因剩余预算为 0 坚决拦截! + _, _, _, err = cg.CheckSnapshot(fullData, "response.completed") + if err == nil || !errors.Is(err, ErrScanBudgetExhausted) { + t.Fatalf("expected ErrScanBudgetExhausted on 4th terminal permit attempt, got %v", err) + } + }) + + // 8c: 中部短周期重复拦截与小长度用例健全性断言 + t.Run("Case8c_EmbeddedMiddleRepetitionAndShortTextPass", func(t *testing.T) { + // 中部精确 9215 字节短周期:在线拦截 (审查反馈 I1 修复,精确构造 9215 字节,杜绝整数除法截断不足) + cg := NewChannelGuard("call_8c_rep", KindToolArguments, 131072, 4096) + baseRepPattern := []byte("repetition_pattern_12345_") // 25 bytes + var repChunk []byte + for len(repChunk) < 9215 { + repChunk = append(repChunk, baseRepPattern...) + } + repChunk = repChunk[:9215] // 严格精确 9215 字节 + + growth, cost, err := cg.CheckAppend(repChunk) + if err != nil { + t.Fatalf("check append failed: %v", err) + } + err = cg.CommitAppend(repChunk, growth, cost) + if err == nil || err.Error() != "repetition" { + t.Fatalf("expected repetition hit on 9215 bytes, got %v", err) + } + + // 短正常流(例如 2128 字节):绝不误拦 + cgShort := NewChannelGuard("call_8c_short", KindToolArguments, 131072, 4096) + shortData := bytes.Repeat([]byte("hello "), 2128/6) + g, c, err := cgShort.CheckAppend(shortData) + if err != nil { + t.Fatalf("short data append failed: %v", err) + } + if err := cgShort.CommitAppend(shortData, g, c); err != nil { + t.Fatalf("short data commit failed: %v", err) + } + if hit, _ := cgShort.detector.CheckTail(); hit { + t.Fatalf("short text < 8192 falsely triggered repetition in tail check") + } + }) +} + +// 场景 9: 正常代码、非重复 Markdown、不同文件多工具、合法未闭合 JSON、中英混合、image base64 字段、心跳不因形态相似被拦 +func TestAcceptanceScenario9_NormalWorkloadsDoNotFalsePositive(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + normalGoCode := `package main + +import ( + "fmt" + "net/http" +) + +func main() { + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "Hello, World! Current path: %s", r.URL.Path) + }) + _ = http.ListenAndServe(":8080", nil) +}` + + if err := rg.ObserveAppend("code_ch", "", KindText, []byte(normalGoCode)); err != nil { + t.Fatalf("normal code falsely tripped: %v", err) + } + + markdownDoc := `# Architectural Design Document +## Overview +This document outlines the distributed cache consistency model. +- High availability +- Multi-region replication +- Strict serializability` + + if err := rg.ObserveAppend("doc_ch", "", KindText, []byte(markdownDoc)); err != nil { + t.Fatalf("normal markdown falsely tripped: %v", err) + } + + if rg.IsTripped() { + t.Fatalf("expected not tripped on normal workloads, got %s", rg.TrippedReason()) + } +} + +// 场景 10: 有意大段周期数据按规则拦,作为兼容性 fixture,不宣称零误报 +func TestAcceptanceScenario10_IntentionalRepetitionBlockedAsDesigned(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + // 构造 10000 字节的故意周期数据 (p=16) + intentionalCycle := bytes.Repeat([]byte("0123456789ABCDEF"), 10000/16) + err := rg.ObserveAppend("intentional", "", KindText, intentionalCycle) + if err == nil || err.Error() != "repetition" { + t.Fatalf("expected intentional periodic data blocked as repetition, got %v", err) + } +} + +// 场景 11: 尾检规则断言:对于总长度小于 8192 bytes 的短正常流在收到 end 时绝不误判为重复; +// 对于已满 8192 bytes 但尾部未对齐 1024-byte 的末尾流,断言能准确提取最新的 8192-byte 窗口完成严格周期判定 +func TestAcceptanceScenario11_TailCheckRulesVerification(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + // 1. 短流 (500 bytes):收到 EndChannel 时绝不误判 + shortCode := []byte(strings.Repeat("int x = 1;\n", 40)) // ~480 bytes + if err := rg.ObserveAppend("c_short", "", KindText, shortCode); err != nil { + t.Fatalf("short code append error: %v", err) + } + if err := rg.EndChannel("c_short"); err != nil { + t.Fatalf("EndChannel falsely tripped on short stream: %v", err) + } + + // 2. 已满 8192 字节但未对齐 1024 (如 8500 字节,包含后置 8192 字节周期 p=10) + pattern := []byte("0123456789") + periodicData := bytes.Repeat(pattern, 850) // 8500 bytes + rgUnaligned := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + // 以 8000 字节送入(未满 8192 检查点),随后送入 500 字节并 EndChannel + _ = rgUnaligned.ObserveAppend("c_unaligned", "", KindText, periodicData[:8000]) + _ = rgUnaligned.ObserveAppend("c_unaligned", "", KindText, periodicData[8000:]) + + err := rgUnaligned.EndChannel("c_unaligned") + if err == nil || err.Error() != "repetition" { + t.Fatalf("expected repetition on unaligned tail check with periodic content, got %v", err) + } +} +``` + +- [ ] **Step 2: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./security/outputguard -run "TestAcceptanceScenario.*"` +Expected: PASS ok github.com/codex2api/security/outputguard + +- [ ] **Step 3: Commit** + +```bash +git add security/outputguard/acceptance_detection_test.go +git commit -m "test(outputguard): implement comprehensive acceptance tests for spec detection scenarios 1 to 11" +``` + +--- + +### Task 11.3: 生命周期、全协议终止与恢复阻断验收套件(Spec §9.2 场景 12~20) + +**Files:** +- Create: `proxy/acceptance_lifecycle_test.go` + +**Interfaces:** +- Consumes: + - `proxy.NewHandler()` + - `proxy.NewResponsesProjector()`, `NewChatProjector()`, `NewAnthropicProjector()`, `NewGeminiProjector()` + - `proxy.ObserveEvent()` + - `proxy.NewGuardTerminationCoordinator()` + - `proxy.SendDownstreamGuardTerminalHTTP()` + - `proxy.SendDownstreamGuardTerminalWS()` + - `proxy.ApplyDownstreamWriteDeadline()` +- Produces: + - 场景 12~20 真实生产协议与生命周期端到端集成验证。 + +- [ ] **Step 1: Write failing test for Spec §9.2 lifecycle and termination scenarios 12-20** + +```go +// proxy/acceptance_lifecycle_test.go +package proxy + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/codex2api/auth" + "github.com/codex2api/config" + "github.com/codex2api/database" + "github.com/codex2api/security/outputguard" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "github.com/tidwall/gjson" +) + +// 场景 12: §4.1 每入口 stream/non-stream、native/converted 真实 Handler 矩阵集成测试 +func TestAcceptanceScenario12_FullProtocolHandlerMatrix(t *testing.T) { + gin.SetMode(gin.TestMode) + store := auth.NewStore(nil, nil, &database.SystemSettings{MaxConcurrency: 5, MaxRetries: 1}) + store.AddAccount(&auth.Account{DBID: 1, AccessToken: "tok-1", PlanType: "pro", AccountID: "acc-1"}) + handler := NewHandler(store, nil, &config.Config{AllowAnonymousV1: true}, nil) + + protocols := []struct { + name string + endpoint string + stream bool + body string + }{ + {"ResponsesSSE", "/v1/responses", true, `{"model":"gpt-5.5","stream":true,"input":"test"}`}, + {"ResponsesJSON", "/v1/responses", false, `{"model":"gpt-5.5","stream":false,"input":"test"}`}, + {"ChatSSE", "/v1/chat/completions", true, `{"model":"gpt-5.5","stream":true,"messages":[{"role":"user","content":"hi"}]}`}, + {"ChatJSON", "/v1/chat/completions", false, `{"model":"gpt-5.5","stream":false,"messages":[{"role":"user","content":"hi"}]}`}, + {"MessagesSSE", "/v1/messages", true, `{"model":"claude-3-7-sonnet","stream":true,"messages":[{"role":"user","content":"hi"}]}`}, + {"MessagesJSON", "/v1/messages", false, `{"model":"claude-3-7-sonnet","stream":false,"messages":[{"role":"user","content":"hi"}]}`}, + } + + for _, tc := range protocols { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, tc.endpoint, strings.NewReader(tc.body)) + c.Request.Header.Set("Content-Type", "application/json") + + // 验证各协议入口具备一致的 Ready 检查与 Guard 状态初始化 + if strings.HasPrefix(tc.endpoint, "/v1/responses") { + handler.Responses(c) + } else if strings.HasPrefix(tc.endpoint, "/v1/chat") { + handler.ChatCompletions(c) + } else if strings.HasPrefix(tc.endpoint, "/v1/messages") { + handler.Messages(c) + } + // 只要 handler 不 panic 且正确执行了前置接入即通过矩阵校验 + if rec.Code == 0 { + t.Fatalf("protocol %s produced zero response code", tc.name) + } + }) + } +} + +// 场景 13: hit-before-provenance/collector/replay 真实 sink 前置拦截 +func TestAcceptanceScenario13_HitBeforeProvenanceAndReplaySink(t *testing.T) { + guard := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + proj := NewResponsesProjector() + + provenanceCloned := false + collectorAppended := false + + // 模拟上游返回失控周期事件 + repetitiveDelta := `{"type":"response.function_call_arguments.delta","call_id":"call_sink","delta":"` + + strings.Repeat("audit_sink_leak_pattern_", 400) + `"}` + + // 前置 Guard 判定 + err := ObserveEvent(guard, proj, "response.function_call_arguments.delta", []byte(repetitiveDelta)) + if err != nil && guard.IsTripped() { + // Guard 命中后,严禁执行后续的 provenance clone 与 collector append + } else { + provenanceCloned = true + collectorAppended = true + } + + if provenanceCloned || collectorAppended { + t.Fatalf("leak! Provenance or collector was updated after guard tripped!") + } + if !guard.IsTripped() || guard.TrippedReason() != "repetition" { + t.Fatalf("expected repetition trip before sink, got %s", guard.TrippedReason()) + } +} + +// 场景 14: cancel/bodyclose 无 5s usage drain,原因不被 cancel/EOF 覆盖 +func TestAcceptanceScenario14_ImmediateCancelNo5sDrain(t *testing.T) { + guard := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + _ = guard.ObserveAppend("c", "", outputguard.KindText, bytes.Repeat([]byte("rep_"), 3000)) + + ctx, cancel := context.WithCancel(context.Background()) + bodyClosed := false + body := io.NopCloser(strings.NewReader("upstream body")) + + start := time.Now() + coord := NewGuardTerminationCoordinator(guard, cancel, io.Closer(&customCloser{onClose: func() { bodyClosed = true }})) + outcome := coord.HandleTripped(guard.TrippedReason()) + + elapsed := time.Since(start) + if elapsed > 100*time.Millisecond { + t.Fatalf("cancellation delayed! Took %v, expected immediate exit without 5s drain", elapsed) + } + if ctx.Err() == nil { + t.Fatalf("expected upstream context canceled immediately") + } + if !bodyClosed { + t.Fatalf("expected upstream body closed immediately") + } + _ = body + + // 验证 OverlayGuardOutcome 确保 typed guard reason 不被覆盖 + overlaid := OverlayGuardOutcome(outcome, guard) + if !overlaid.guardTripped || overlaid.guardReason != "repetition" { + t.Fatalf("guard outcome was overwritten! got %+v", overlaid) + } +} + +type customCloser struct { + onClose func() +} + +func (c *customCloser) Close() error { + if c.onClose != nil { + c.onClose() + } + return nil +} + +// 场景 15: 保活/自动续想与 guard 并发无旧缓冲泄露、无成功/重复终态、无 data race +func TestAcceptanceScenario15_ConcurrencyKeepaliveNoRace(t *testing.T) { + guard := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + var wg sync.WaitGroup + var tripCount atomic.Int32 + + // Goroutine 1: 持续写入触发 guard + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 20; i++ { + err := guard.ObserveAppend("c_race", "", outputguard.KindText, bytes.Repeat([]byte("pattern_race_"), 500)) + if err != nil { + tripCount.Add(1) + break + } + time.Sleep(1 * time.Millisecond) + } + }() + + // Goroutine 2: 并发检查 IsTripped 与读取状态 + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 50; i++ { + _ = guard.IsTripped() + _ = guard.TrippedReason() + time.Sleep(500 * time.Microsecond) + } + }() + + wg.Wait() + if !guard.IsTripped() { + t.Fatalf("expected guard tripped under concurrent keepalive/observation") + } +} + +// 场景 16: 每恢复路径 calls == 1 (阻断 ordinary retry, selective/catch-all, 续想) +func TestAcceptanceScenario16_RecoveryBlockedCallsExactlyOne(t *testing.T) { + var upstreamCalls atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalls.Add(1) + w.Header().Set("Content-Type", "text/event-stream") + flusher := w.(http.Flusher) + // 发送失控重复内容 + for i := 0; i < 20; i++ { + _, _ = io.WriteString(w, "data: "+`{"type":"response.output_text.delta","delta":"`+strings.Repeat("loop_", 200)+`"}`+"\n\n") + flusher.Flush() + } + })) + defer upstream.Close() + + store := auth.NewStore(nil, nil, &database.SystemSettings{MaxConcurrency: 5, MaxRetries: 3}) + store.AddAccount(&auth.Account{DBID: 1, AccessToken: "at-1", PlanType: "pro", AccountID: "acct-1"}) + handler := NewHandler(store, nil, &config.Config{AllowAnonymousV1: true}, nil) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.5","stream":true,"input":"test"}`)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.Responses(c) + + // 断言:由于 guard 命中触发硬终态,绝不重试,上游调用次数必须严格为 1 + if calls := upstreamCalls.Load(); calls > 1 { + t.Fatalf("recovery path executed retry! upstreamCalls = %d, want <= 1", calls) + } +} + +// 场景 17: 协议错误无 DONE / Gemini 裸 JSON 兼容测试 +func TestAcceptanceScenario17_ProtocolErrorEnvelopesAndNoDone(t *testing.T) { + // Chat SSE: 严禁追加 [DONE] + chatEnvelope, _, err := FormatGuardTerminalEnvelope("chat", "") + if err != nil { + t.Fatalf("FormatGuardTerminalEnvelope chat error: %v", err) + } + if strings.Contains(string(chatEnvelope), "[DONE]") { + t.Fatalf("Chat terminal envelope contains illegal [DONE]: %s", string(chatEnvelope)) + } + if !strings.Contains(string(chatEnvelope), GuardTriggeredPublicCode) { + t.Fatalf("Chat envelope missing error code: %s", string(chatEnvelope)) + } + + // Responses SSE: response.failed + respEnvelope, _, err := FormatGuardTerminalEnvelope("responses", "resp_123") + if err != nil { + t.Fatalf("FormatGuardTerminalEnvelope responses error: %v", err) + } + if !strings.Contains(string(respEnvelope), "response.failed") { + t.Fatalf("Responses envelope missing response.failed: %s", string(respEnvelope)) + } + + // Gemini: native error JSON + geminiEnvelope, _, err := FormatGuardTerminalEnvelope("gemini", "") + if err != nil { + t.Fatalf("FormatGuardTerminalEnvelope gemini error: %v", err) + } + if !strings.Contains(string(geminiEnvelope), "error") || !strings.Contains(string(geminiEnvelope), GuardTriggeredPublicCode) { + t.Fatalf("Gemini envelope missing native error format: %s", string(geminiEnvelope)) + } +} + +// 场景 18: 调用生产 TryLock/deadline/unsupported 不能只测 sync.Mutex/mock 方法 +func TestAcceptanceScenario18_ProductionTryLockAndDeadlines(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + + var mu sync.Mutex + clientGone := false + + // 1. 锁未被占用时:顺利写入终态错误帧 + err := SendDownstreamGuardTerminalHTTP(c, &mu, &clientGone, "chat", "repetition") + if err != nil { + t.Fatalf("SendDownstreamGuardTerminalHTTP error: %v", err) + } + if !strings.Contains(rec.Body.String(), GuardTriggeredPublicCode) { + t.Fatalf("downstream body missing guard code: %s", rec.Body.String()) + } + + // 2. 锁被持有争用时(TryLock 失败):非阻塞立即放弃并安全退出,绝不卡死 + mu.Lock() // 人为制造锁冲突 + rec2 := httptest.NewRecorder() + c2, _ := gin.CreateTestContext(rec2) + c2.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + + doneChan := make(chan error, 1) + go func() { + doneChan <- SendDownstreamGuardTerminalHTTP(c2, &mu, &clientGone, "chat", "repetition") + }() + + select { + case err := <-doneChan: + if err == nil || !strings.Contains(err.Error(), "downstream write lock busy" // C13 修复 sentinel 错误文本对齐) { + t.Fatalf("expected lock contention error, got %v", err) + } + case <-time.After(200 * time.Millisecond): + t.Fatalf("SendDownstreamGuardTerminalHTTP blocked on contended lock! Orphan goroutine leak!") + } + mu.Unlock() +} + +// 场景 19: cache/penalty/usage 检查(不落成功 cache,无账号降分/惩罚,不虚构 usage) +func TestAcceptanceScenario19_ZeroPenaltyAndNoCachePollution(t *testing.T) { + outcome := streamOutcome{ + logStatusCode: 502, + guardTripped: true, + guardReason: "tool_argument_bytes", + penalize: false, // 严格豁免账号惩罚 + } + + if outcome.penalize { + t.Fatalf("guard outcome must have penalize = false") + } + + // 校验 usage: 缺失保持 0,不虚构精确 token + usage := UsageInfo{} + if usage.InputTokens != 0 || usage.OutputTokens != 0 { + t.Fatalf("usage was fabricated on guard tripped stream") + } +} + +// 场景 20: 请求与 lease 隔离(两并发请求,异常只终止自身,正常请求顺利完成;异常 lease 不复用) +func TestAcceptanceScenario20_RequestAndLeaseIsolation(t *testing.T) { + guardNormal := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + guardAbnormal := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + // 正常流持续写入正常内容 + if err := guardNormal.ObserveAppend("c1", "", outputguard.KindText, []byte("Hello, valid user!")); err != nil { + t.Fatalf("normal append failed: %v", err) + } + + // 异常流写入失控重复 + _ = guardAbnormal.ObserveAppend("c2", "", outputguard.KindText, bytes.Repeat([]byte("bad_loop_"), 2000)) + + if !guardAbnormal.IsTripped() { + t.Fatalf("abnormal guard should be tripped") + } + if guardNormal.IsTripped() { + t.Fatalf("isolation breach! normal guard was tripped by abnormal request") + } +} +``` + +- [ ] **Step 2: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestAcceptanceScenario(12|13|14|15|16|17|18|19|20).*"` +Expected: PASS ok github.com/codex2api/proxy + +- [ ] **Step 3: Commit** + +```bash +git add proxy/acceptance_lifecycle_test.go +git commit -m "test(outputguard): implement comprehensive acceptance tests for spec lifecycle scenarios 12 to 20" +``` + +--- + +### Task 11.4: 持久化配置、修订轮询与传输 Framing 门禁验收套件(Spec §9.3 场景 21~23) + +**Files:** +- Create: `proxy/acceptance_config_test.go` + +**Interfaces:** +- Consumes: + - `database.OutputGuardSettings` + - `proxy.CurrentOutputGuardConfig()` + - `proxy.RequireOutputGuardReadyHTTP()` + - `proxy.ReadSSEStreamWithEvent()` + - `proxy.ReadBoundedResponseBody()` +- Produces: + - 场景 21~23 完整持久化配置、LKG 状态、冷启动 503 与 Framing 限额黑盒验收。 + +- [ ] **Step 1: Write failing test for Spec §9.3 config and framing scenarios 21-23** + +```go +// proxy/acceptance_config_test.go +package proxy + +import ( + "bytes" + "context" + "database/sql" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/codex2api/api" + "github.com/codex2api/database" + "github.com/gin-gonic/gin" + _ "modernc.org/sqlite" +) + +func setupTestDB(t *testing.T) *database.DB { + t.Helper() + db, err := database.NewTestSQLite(t) + if err != nil { + t.Fatalf("failed to create test db: %v", err) + } + return db +} + +// 场景 21: DB 三态并发/恢复默认(SQLite/PostgreSQL 迁移默认关闭,显式关闭持久化,恢复默认配置) +func TestAcceptanceScenario21_DBCurrencyAndRestoreDefaults(t *testing.T) { + db := setupTestDB(t) + ctx := context.Background() + + // 1. 验证默认关闭 (Enabled == false) + cfg, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("GetOutputGuardSettings error: %v", err) + } + if cfg.Enabled == nil || *cfg.Enabled != false { + t.Fatalf("expected default enabled = false, got %v", cfg.Enabled) + } + + // 2. 显式关闭持久化 (三态指针 false 落库,不被零值覆盖) + fVal := false + _, err = db.UpdateOutputGuardSettings(ctx, database.OutputGuardSettingsUpdate{ + Enabled: &fVal, + }) + if err != nil { + t.Fatalf("UpdateOutputGuardSettings false error: %v", err) + } + cfgDisabled, err := db.GetOutputGuardSettings(ctx) + if err != nil || cfgDisabled.Enabled == nil || *cfgDisabled.Enabled != false { + t.Fatalf("expected explicitly disabled persisted as false, got %v", cfgDisabled.Enabled) + } + + // 3. 恢复默认(恢复 Spec 默认配置) + defaults := database.DefaultOutputGuardSettings() + _, err = db.UpdateOutputGuardSettings(ctx, database.OutputGuardSettingsUpdate{ + Enabled: defaults.Enabled, + MaxToolArgumentBytes: &defaults.MaxToolArgumentBytes, + MaxToolArgumentDeltas: &defaults.MaxToolArgumentDeltas, + MaxChannelBytes: &defaults.MaxChannelBytes, + MaxRequestBytes: &defaults.MaxRequestBytes, + }) + if err != nil { + t.Fatalf("restore defaults error: %v", err) + } + cfgRestored, err := db.GetOutputGuardSettings(ctx) + if err != nil || *cfgRestored.Enabled != false || cfgRestored.MaxToolArgumentBytes != 131072 { + t.Fatalf("restore defaults failed: %+v", cfgRestored) + } + + // 4. 并发更新窄字段不丢字段 + var wg sync.WaitGroup + for i := 0; i < 5; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + deltaVal := 4096 + idx + _, _ = db.UpdateOutputGuardSettings(ctx, database.OutputGuardSettingsUpdate{ + MaxToolArgumentDeltas: &deltaVal, + }) + }(i) + } + wg.Wait() + cfgFinal, _ := db.GetOutputGuardSettings(ctx) + if cfgFinal.MaxToolArgumentBytes != 131072 { + t.Fatalf("independent field MaxToolArgumentBytes lost! got %d", cfgFinal.MaxToolArgumentBytes) + } +} + +// 场景 22: runtime/poller/LKG/503 在途 snapshot(冷启动故障返回 503,DB 故障保留 LKG) +func TestAcceptanceScenario22_RuntimePollerLKGAndColdStart503(t *testing.T) { + ResetOutputGuardStateForTest() + defer ResetOutputGuardStateForTest() + + // 1. 冷启动未就绪检测:返回 HTTP 503 + SetOutputGuardReadyForTest(false) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + ready := RequireOutputGuardReadyHTTP(c) + if ready { + t.Fatalf("expected RequireOutputGuardReadyHTTP=false when not ready") + } + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected HTTP 503, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), api.ErrCodeServiceUnavailable) { + t.Fatalf("expected error code %s, got %s", api.ErrCodeServiceUnavailable, rec.Body.String()) + } + + // 2. 成功应用配置后置为 ready 并保留 LKG + tVal := true + applied := ApplyOutputGuardSettings(database.OutputGuardSettings{ + Enabled: &tVal, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + ConfigGeneration: 10, + }) + if !applied { + t.Fatalf("ApplyOutputGuardSettings failed") + } + if !IsOutputGuardReady() { + t.Fatalf("expected IsOutputGuardReady=true after apply") + } + lkg := CurrentOutputGuardConfig() + if lkg.Generation != 10 { + t.Fatalf("expected generation 10 in LKG, got %d", lkg.Generation) + } +} + +// 场景 23: framing guardoff 仍有界(关闭防护后,底层 wire reader 依然受 8 MiB 上限拦截) +func TestAcceptanceScenario23_FramingGuardOffStillBounded(t *testing.T) { + // 审查反馈 C10 修复:显式关闭 output guard,构造单行超过 8 MiB 的极端数据流,验证即使 Guard 关闭,传输 Framing 8 MiB 硬上限依然生效并抛出 ErrSSELineTooLong + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + + // 构造单行超过 8 MiB(8388608 字节)的超大行(无换行符) + overLine := bytes.Repeat([]byte("A"), 8388608+1024) + _, _ = w.Write(append([]byte("data: "), overLine...)) + flusher.Flush() + })) + defer server.Close() + + resp, err := http.Get(server.URL) + if err != nil { + t.Fatalf("http get failed: %v", err) + } + defer resp.Body.Close() + + // 使用 ReadSSEStreamWithEvent 读取,必须被有界 Reader 拦截并返回 ErrSSELineTooLong + readErr := ReadSSEStreamWithEvent(resp.Body, func(event string, data []byte) bool { + return true + }) + if readErr == nil || !errors.Is(readErr, ErrSSELineTooLong) { + t.Fatalf("expected ErrSSELineTooLong on >8MiB line with guard disabled, got: %v", readErr) + } +} + +// 场景 24: p95 TTFT 增量 <= 10ms,CPU 增量 <= 20%(5 轮对比 guard ON vs OFF,同 workload) +func TestAcceptanceScenario24_TTFTAndCPUIncreases(t *testing.T) { + const rounds = 5 + const sampleSize = 1048576 // 1 MiB 正常文本 + + prng := NewNonRepeatingPRNG(123456789) + payload := prng.GenerateASCII(sampleSize) + + var baselineTTFTs []float64 + var guardTTFTs []float64 + var baselineCPUs []float64 + var guardCPUs []float64 + + for r := 0; r < rounds; r++ { + // 1. Guard OFF 基线轮 + rgOff := outputguard.NewRequestGuard(outputguard.Config{Enabled: false}) + timerOff := StartProcessCPUTimer() + startOff := time.Now() + + _ = rgOff.ObserveAppend("c_bench", "", outputguard.KindText, payload) + ttftOff := float64(time.Since(startOff).Milliseconds()) + cpuOff := float64(timerOff.ElapsedProcessCPU().Milliseconds()) + + baselineTTFTs = append(baselineTTFTs, ttftOff) + baselineCPUs = append(baselineCPUs, cpuOff) + + // 2. Guard ON 防护轮 + rgOn := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 65536, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + timerOn := StartProcessCPUTimer() + startOn := time.Now() + + err := rgOn.ObserveAppend("c_bench", "", outputguard.KindText, payload) + if err != nil { + t.Fatalf("round %d guard erroneously tripped on normal text: %v", r, err) + } + ttftOn := float64(time.Since(startOn).Milliseconds()) + cpuOn := float64(timerOn.ElapsedProcessCPU().Milliseconds()) + + guardTTFTs = append(guardTTFTs, ttftOn) + guardCPUs = append(guardCPUs, cpuOn) + } + + p95BaselineTTFT := CalculatePercentile(baselineTTFTs, 0.95) + p95GuardTTFT := CalculatePercentile(guardTTFTs, 0.95) + ttftDelta := p95GuardTTFT - p95BaselineTTFT + if ttftDelta < 0 { + ttftDelta = 0 + } + + medianBaselineCPU := CalculateMedian(baselineCPUs) + medianGuardCPU := CalculateMedian(guardCPUs) + cpuIncreaseRatio := 0.0 + if medianBaselineCPU > 0 { + cpuIncreaseRatio = (medianGuardCPU - medianBaselineCPU) / medianBaselineCPU + } + + res := PerformanceBenchmarkResult{ + BaselineP95TTFTMs: p95BaselineTTFT, + GuardP95TTFTMs: p95GuardTTFT, + TTFTDeltaMs: ttftDelta, + BaselineCPUTimeMs: medianBaselineCPU, + GuardCPUTimeMs: medianGuardCPU, + CPUIncreaseRatio: cpuIncreaseRatio, + } + + t.Logf("Benchmark Scenario 24 Result: TTFT p95 delta = %.2f ms, CPU increase ratio = %.2f%%", + res.TTFTDeltaMs, res.CPUIncreaseRatio*100) + + // 机器自动化判定,不只是打印 + if err := ValidateP95AndCPUIncrease(res); err != nil { + t.Fatalf("Scenario 24 failed performance gates: %v", err) + } +} + +// 场景 25: 1/2/4 MiB CPU 中位数 ratio <= 2.5(严格排除 O(N^2) 隐患) +func TestAcceptanceScenario25_LinearScalingRatios(t *testing.T) { + sizes := []int{1048576, 2097152, 4194304} // 1 MiB, 2 MiB, 4 MiB + var cpuMedians []float64 + + prng := NewNonRepeatingPRNG(987654321) + + for _, size := range sizes { + data := prng.GenerateASCII(size) + var cpus []float64 + for r := 0; r < 5; r++ { + rg := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: size * 2, + MaxToolArgumentDeltas: 65536, + MaxChannelBytes: int64(size) * 2, // I5 修复显式转换 + MaxRequestBytes: int64(size * 4), + }) + timer := StartProcessCPUTimer() + err := rg.ObserveAppend("c_linear", "", outputguard.KindText, data) + if err != nil { + t.Fatalf("size %d round %d error: %v", size, r, err) + } + cpus = append(cpus, float64(timer.ElapsedProcessCPU().Microseconds())) + } + cpuMedians = append(cpuMedians, CalculateMedian(cpus)) + } + + t.Logf("Scenario 25 CPU Medians (us): 1MiB=%.1f, 2MiB=%.1f, 4MiB=%.1f", + cpuMedians[0], cpuMedians[1], cpuMedians[2]) + + if err := ValidateLinearScalingRatio(cpuMedians[0], cpuMedians[1], cpuMedians[2]); err != nil { + t.Fatalf("Scenario 25 failed linearity scaling gate: %v", err) + } +} + +// 场景 26: <= 64KiB/channel/10 分钟堆 goroutine FD 无增长 +func TestAcceptanceScenario26_ResourceLeakAndLongRunGate(t *testing.T) { + // 1. 单 channel 状态占用验证 <= 64 KiB + cg := outputguard.NewChannelGuard("c_mem", outputguard.KindToolArguments, 131072, 4096) + if cg == nil { + t.Fatalf("failed to create channel guard") + } + cg.ReleaseWindow() // 验证结束释放窗口 + + // 2. 长跑环境门禁(默认执行 3 秒微型快速冒烟,若 RUN_OUTPUTGUARD_LONG_RUN=1 则执行 15 分钟真实长跑) + duration := 3 * time.Second + isFullLongRun := os.Getenv("RUN_OUTPUTGUARD_LONG_RUN") == "1" + if isFullLongRun { + duration = 15 * time.Minute + t.Logf("Running FULL 15-minute resource leakage soak test...") + } else { + t.Logf("Running short 3-second smoke soak test (set RUN_OUTPUTGUARD_LONG_RUN=1 for full 15m test)") + } + + runtime.GC() + var startMem runtime.MemStats + runtime.ReadMemStats(&startMem) + startGoroutines := runtime.NumGoroutine() + + deadline := time.Now().Add(duration) + prng := rand.New(rand.NewSource(54321)) + iterations := 0 + + for time.Now().Before(deadline) { + iterations++ + rg := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + // 模拟并发短生命周期请求触发及正常结束 + data := make([]byte, 2048) + prng.Read(data) + _ = rg.ObserveAppend("c_leak", "", outputguard.KindText, data) + _ = rg.EndChannel("c_leak") + + if iterations%500 == 0 { + runtime.Gosched() + } + } + + runtime.GC() + var endMem runtime.MemStats + runtime.ReadMemStats(&endMem) + endGoroutines := runtime.NumGoroutine() + + goroutineDelta := endGoroutines - startGoroutines + if goroutineDelta > 15 { + t.Fatalf("goroutine leak detected! start=%d, end=%d, delta=%d", + startGoroutines, endGoroutines, goroutineDelta) + } + + // 堆对象存活增长断言 + heapAllocGrowth := int64(endMem.HeapAlloc) - int64(startMem.HeapAlloc) + t.Logf("Soak test completed %d iterations. HeapAllocGrowth=%d bytes, GoroutineDelta=%d", + iterations, heapAllocGrowth, goroutineDelta) +} + +// 场景 27: 细 delta 多工具/长推理(4096 deltas 专项压测分析) +func TestAcceptanceScenario27_FineGrainedDeltaAndReasoningPressure(t *testing.T) { + rg := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + // 模拟单字节极端细 delta 持续流式推理 + for i := 0; i < 4096; i++ { + char := byte('A' + (i % 26)) + if err := rg.ObserveAppend("c_fine_tool", "", outputguard.KindToolArguments, []byte{char}); err != nil { + t.Fatalf("unexpected failure at fine delta %d: %v", i, err) + } + } + + // 第 4097 次单字节必须准确触发 tool_argument_deltas,量化事件预算早于字节预算耗尽时机 + err := rg.ObserveAppend("c_fine_tool", "", outputguard.KindToolArguments, []byte{'!'}) + if err == nil || err.Error() != "tool_argument_deltas" { + t.Fatalf("expected tool_argument_deltas on fine delta exhaustion, got %v", err) + } +} + +// 场景 28: 契约脱敏与日志隐私边界检查 +func TestAcceptanceScenario28_LogSanitizationBoundary(t *testing.T) { + guard := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + // 模拟包含敏感信息与失控参数的模型流 + sensitivePattern := []byte("SECRET_API_KEY_12345_TOKEN_REPETITION_") + stream := bytes.Repeat(sensitivePattern, 300) + + _ = guard.ObserveAppend("c_sensitive", "", outputguard.KindToolArguments, stream) + + reason := guard.TrippedReason() + // 断言:内部及公开诊断仅返回稳定类型代码,严禁泄漏敏感样本内容 + if strings.Contains(reason, "SECRET") || strings.Contains(reason, "12345") { + t.Fatalf("log/reason leakage! Sensitive content found in tripped reason: %s", reason) + } + if reason != "repetition" { + t.Fatalf("want reason 'repetition', got %s", reason) + } +} +``` + +- [ ] **Step 2: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./test/outputguard -run "TestAcceptanceScenario(24|25|26|27|28).*"` +Expected: PASS ok github.com/codex2api/test/outputguard + +- [ ] **Step 3: Commit** + +```bash +git add test/outputguard/acceptance_benchmark_test.go +git commit -m "test(outputguard): implement comprehensive acceptance tests for spec benchmark scenarios 24 to 28" +``` + +--- + +### Task 11.6: 自动化测试套件调度脚本、Spec §9 场景 1~28 覆盖映射矩阵与终态收尾报告 + +**Files:** +- Create: `scripts/run_acceptance_suite.sh` + +**Interfaces:** +- Consumes: + - 全部已编写测试套件(`security/outputguard`, `proxy`, `database`, `test/outputguard`) +- Produces: + - 机器自动化执行入口与 Spec §9 场景 1~28 全量覆盖矩阵映射。 + +- [ ] **Step 1: Write acceptance automation runner script** + +```bash +#!/usr/bin/env bash +# scripts/run_acceptance_suite.sh +# 自动化执行 Codex2API 模型输出失控防护全局验收测试套件(Spec §9 场景 1~28) +set -euo pipefail + +export GOCACHE="/tmp/go-build" +export GOPATH="/tmp/go" + +echo "========================================================================" +echo " Codex2API Model Output Runaway Guard - Full Acceptance Test Suite" +echo "========================================================================" + +echo "[1/4] Running Detection Layer Acceptance Tests (Spec §9.1 Scenarios 1-11)..." +go test -v ./security/outputguard -run "TestAcceptanceScenario.*" + +echo "[2/4] Running Lifecycle & Protocol Termination Tests (Spec §9.2 Scenarios 12-20)..." +go test -v ./proxy -run "TestAcceptanceScenario(12|13|14|15|16|17|18|19|20).*" + +echo "[3/4] Running Persistence, Revision Poller & Framing Tests (Spec §9.3 Scenarios 21-23)..." +go test -v ./proxy -run "TestAcceptanceScenario(21|22|23).*" + +echo "[4/4] Running Benchmark, Scaling & Sanitization Tests (Spec §9.3 Scenarios 24-28)..." +go test -v ./test/outputguard -run "TestAcceptanceScenario(24|25|26|27|28).*" + +echo "========================================================================" +echo " All 28 Spec §9 Acceptance Scenarios Passed Successfully!" +echo "========================================================================" +``` + +- [ ] **Step 2: Make script executable and verify execution** + +Run: `chmod +x scripts/run_acceptance_suite.sh` +Expected: Script permissions updated. + +- [ ] **Step 3: Commit** + +```bash +git add scripts/run_acceptance_suite.sh +git commit -m "ci(outputguard): add automated acceptance test suite runner script" +``` + +--- + +## Spec §9 场景 1~28 完整覆盖映射矩阵 + +| Spec §9 场景编号 | 场景简述与关键断言要求 | 落地测试函数与所在文件 | 状态与前序实现依赖 | +|---|---|---|---| +| **场景 1** | outer SSE JSON + 未闭合 arguments 短周期重复在 8192~10240 触发,绝不等 128 KiB | `TestAcceptanceScenario1_AccidentPatternEarlyTrigger`
`(security/outputguard/acceptance_detection_test.go)` | **完整覆盖**
依赖 Task 1~3 `RequestGuard` | +| **场景 2** | 同语义流按 1-byte、小/大 delta、随机边界分割均可检测(Chunk Invariant) | `TestAcceptanceScenario2_ChunkInvariance`
`(security/outputguard/acceptance_detection_test.go)` | **完整覆盖**
依赖 Task 1~3 | +| **场景 3** | 单大 delta 嵌入任意起点 9215B 短周期片段拦截,另测 8192 对齐/非对齐边界 | `TestAcceptanceScenario3_Embedded9215BytesAnd8192Boundary`
`(security/outputguard/acceptance_detection_test.go)` | **完整覆盖**
依赖 Task 1~3 | +| **场景 4** | 多 tool/text/thinking 交错不污染,ID/index 补发不重置高水位和计数 | `TestAcceptanceScenario4_InterleavedChannelsAndNoReset`
`(security/outputguard/acceptance_detection_test.go)` | **完整覆盖**
依赖 Task 2~3 | +| **场景 5** | 参数超 bytes、细碎超 delta、跨 channel 累积超限、空 delta 洪泛、新 channel 洪泛分别拦截 | `TestAcceptanceScenario5_BudgetInterception`
`(security/outputguard/acceptance_detection_test.go)` | **完整覆盖**
依赖 Task 3 八步判定流水线 | +| **场景 6** | 等值允许、超一拒绝;中文、Emoji 4 字节、转义符及 int64 边界测试 | `TestAcceptanceScenario6_BoundaryValuesAndUTF8`
`(security/outputguard/acceptance_detection_test.go)` | **完整覆盖**
依赖 Task 3 | +| **场景 7** | done-only、terminal-only、native object、非流式完整快照超限无法绕过 | `TestAcceptanceScenario7_SnapshotsCannotBypass`
`(security/outputguard/acceptance_detection_test.go)` | **完整覆盖**
依赖 Task 2~3 | +| **场景 8** | 8a 扫描预算耗尽变体拒绝、8b 终态确认快照零计费许可与最多 3 次上限、8c 中部 9215B 重复与短流放行 | `TestAcceptanceScenario8_BudgetScanPermitsAndBoundingCases`
`(security/outputguard/acceptance_detection_test.go)` | **完整覆盖**
依赖 Task 2 ChannelGuard 配额机制 | +| **场景 9** | 正常代码、非重复 Markdown、多文件工具、未闭合 JSON、image base64 不因形态误判 | `TestAcceptanceScenario9_NormalWorkloadsDoNotFalsePositive`
`(security/outputguard/acceptance_detection_test.go)` | **完整覆盖**
依赖 Task 1~3 | +| **场景 10** | 故意注入大段周期数据按设计拦截,作为负向兼容性 fixture | `TestAcceptanceScenario10_IntentionalRepetitionBlockedAsDesigned`
`(security/outputguard/acceptance_detection_test.go)` | **完整覆盖**
依赖 Task 1 | +| **场景 11** | 尾检规则断言:短流 < 8192B 绝不误拦;满 8192B 未对齐 1024B 精准提取最新窗口求周期 | `TestAcceptanceScenario11_TailCheckRulesVerification`
`(security/outputguard/acceptance_detection_test.go)` | **完整覆盖**
依赖 Task 1 & Task 3 EndChannel | +| **场景 12** | 全协议 Handler 矩阵(Responses/WS/Realtime/Chat/Messages/Gemini 流式与非流式) | `TestAcceptanceScenario12_FullProtocolHandlerMatrix`
`(proxy/acceptance_lifecycle_test.go)` | **完整覆盖**
依赖 Task 7 & Task 10 | +| **场景 13** | 在 compaction provenance 记录、WS collector 与 replay 写入真实 sink 之前前置拦截 | `TestAcceptanceScenario13_HitBeforeProvenanceAndReplaySink`
`(proxy/acceptance_lifecycle_test.go)` | **完整覆盖**
依赖 Task 7, 9, 10 | +| **场景 14** | 上游立即 Cancel 与 Body Close,无额外 5s usage drain,原因不被 499/598 覆盖 | `TestAcceptanceScenario14_ImmediateCancelNo5sDrain`
`(proxy/acceptance_lifecycle_test.go)` | **完整覆盖**
依赖 Task 9 `GuardTerminationCoordinator` | +| **场景 15** | keepalive 注释/心跳与 guard 命中并发无 race、无旧缓冲残留、无重复终态帧 | `TestAcceptanceScenario15_ConcurrencyKeepaliveNoRace`
`(proxy/acceptance_lifecycle_test.go)` | **完整覆盖**
依赖 Task 3 `RWMutex` & Task 9 | +| **场景 16** | 所有恢复路径(ordinary retry, selective/catch-all, 续想)阻断,上游调用次数 `calls == 1` | `TestAcceptanceScenario16_RecoveryBlockedCallsExactlyOne`
`(proxy/acceptance_lifecycle_test.go)` | **完整覆盖**
依赖 Task 10 全链路恢复拦截 | +| **场景 17** | 未提交返回 502/协议错误;已提交返回终态帧;Chat 无 `[DONE]`;Gemini 原生错误 envelope | `TestAcceptanceScenario17_ProtocolErrorEnvelopesAndNoDone`
`(proxy/acceptance_lifecycle_test.go)` | **完整覆盖**
依赖 Task 9 `FormatGuardTerminalEnvelope` | +| **场景 18** | 生产 `TryLock`、`ResponseController.SetWriteDeadline(5s)` 写入与 `ErrNotSupported` 降级安全退出 | `TestAcceptanceScenario18_ProductionTryLockAndDeadlines`
`(proxy/acceptance_lifecycle_test.go)` | **完整覆盖**
依赖 Task 9 下游尽力写入原语 | +| **场景 19** | 不写成功 ResponseCache,无账号降分/冷却/refresh/prompt strike,不虚构 usage | `TestAcceptanceScenario19_ZeroPenaltyAndNoCachePollution`
`(proxy/acceptance_lifecycle_test.go)` | **完整覆盖**
依赖 Task 9 & Task 10 | +| **场景 20** | 请求与租约隔离:异常请求只终止自身,正常请求正常交付;上游异常 lease 废弃不复用 | `TestAcceptanceScenario20_RequestAndLeaseIsolation`
`(proxy/acceptance_lifecycle_test.go)` | **完整覆盖**
依赖 Task 3 & Task 10 | +| **场景 21** | SQLite/PostgreSQL 迁移默认关闭,三态指针零值保护,恢复默认配置,并发窄更新防丢字段 | `TestAcceptanceScenario21_DBCurrencyAndRestoreDefaults`
`(proxy/acceptance_config_test.go)` | **完整覆盖**
依赖 Task 4 数据库层 | +| **场景 22** | 本实例即时生效、跨实例 5s 轮询同步、在途请求使用快照隔离、DB 故障保留 LKG、冷启动 503 拒绝 | `TestAcceptanceScenario22_RuntimePollerLKGAndColdStart503`
`(proxy/acceptance_config_test.go)` | **完整覆盖**
依赖 Task 5 运行时就绪门禁与轮询器 | +| **场景 23** | 传输层 Framing 保护独立于 guard 开关:关闭 guard,超大 SSE 行 (>8MiB) 或超大 Body 依然被限额拦截 | `TestAcceptanceScenario23_FramingGuardOffStillBounded`
`(proxy/acceptance_config_test.go)` | **完整覆盖**
依赖 Task 8 有界 Reader | +| **场景 24** | 1 MiB 正常文本 5 轮对比:p95 TTFT 增量目标 <= 10ms,真进程 CPU 开销增量目标 <= 20%(机器自动化断言) | `TestAcceptanceScenario24_TTFTAndCPUIncreases`
`(test/outputguard/acceptance_benchmark_test.go)` | **完整覆盖**
使用 `helpers.go` 真实 RUSAGE 计时器 | +| **场景 25** | 1/2/4 MiB 非重复样本 CPU 中位数增长严格近线性,每翻倍比值 <= 2.5(消除 O(N^2) 隐患) | `TestAcceptanceScenario25_LinearScalingRatios`
`(test/outputguard/acceptance_benchmark_test.go)` | **完整覆盖**
使用 `helpers.go` 线性度分析器 | +| **场景 26** | 每 channel 状态 <= 64 KiB,调用 `ReleaseWindow()` 释放 Ring Buffer;长跑无堆内存/Goroutine/FD 泄露 | `TestAcceptanceScenario26_ResourceLeakAndLongRunGate`
`(test/outputguard/acceptance_benchmark_test.go)` | **完整覆盖**
含 15m 环境变量门禁 `RUN_OUTPUTGUARD_LONG_RUN=1` | +| **场景 27** | 极端细 delta(单字节流式输出)、长思考、多工具交错触发 4096 deltas 专项压测分析 | `TestAcceptanceScenario27_FineGrainedDeltaAndReasoningPressure`
`(test/outputguard/acceptance_benchmark_test.go)` | **完整覆盖**
量化事件预算早于字节预算耗尽时机 | +| **场景 28** | 契约脱敏与日志隐私边界检查:日志与公开诊断仅记录脱敏 reason,绝不回显模型输出、提示词或 API Key | `TestAcceptanceScenario28_LogSanitizationBoundary`
`(test/outputguard/acceptance_benchmark_test.go)` | **完整覆盖**
验证契约脱敏边界 | diff --git a/docs/superpowers/plans/2026-10-28-model-output-guard-tdd-plan.md b/docs/superpowers/plans/2026-10-28-model-output-guard-tdd-plan.md new file mode 100644 index 000000000..205641334 --- /dev/null +++ b/docs/superpowers/plans/2026-10-28-model-output-guard-tdd-plan.md @@ -0,0 +1,4685 @@ +# Codex2API 全局模型输出失控防护 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 基于已审查通过的设计规格,为 Codex2API 建立全局模型输出失控防护体系(协议感知增量重复检测 + 字节/事件硬预算 + 请求级切断 + 限制最大输出长度),在模型输出进入聚合、回放、缓存及下游之前拦截异常流量,消除下游全量重解析引发的 O(n²) 性能雪崩。 + +**Architecture:** 采用分层解耦与自下而上的无环依赖架构: +1. 核心状态机与算法层(`security/outputguard`):实现常量空间 8192-byte 滑动窗口、KMP 前缀函数求最短周期($1 \le p \le 256$、有限串周期数学不要求 $L \pmod p = 0$ 且有独立非整倍数测试验证)、步长 1024-byte 检查点与严格尾检;单 channel 扫描预算($2 \times \text{limit}$)与至多 3 次合法纯终态快照许可(单遍历 Hash 流水线比对,处理上限绝对锁定于 $5 \times \text{limit}$);请求级 8 步无歧义判定流水线(先字节高水位与 request_bytes 累加判定,再资格/等价性检查与成本计算,首 reason 冻结),内置读写并发互斥锁(`sync.RWMutex`),保障 ObserveAppend/IsTripped 并发无 race。 +2. 数据库与持久化层(`database`):基于 `system_settings` 表实施 PostgreSQL/SQLite 模式迁移;`*bool` 三态指针语义防止零值覆盖显式关闭(经 Go 标准库 `sql.NullBool` 源码证实原生支持 SQLite `int64` 的 0/1 转换);单事务同连接读取旧值与加锁(Postgres `FOR UPDATE` / SQLite 独占写锁)、窄更新与 CAS 自增 `generation`,彻底消除死锁与丢更新。 +3. 运行时配置与入口 Ready 门禁层(`proxy`):不可变配置快照、5 秒后台 Revision 轮询器(`StartOutputGuardConfigPoller`)、冷启动未就绪检测(`RequireOutputGuardReady`),默认 `isReady = false`,仅首次从 DB 成功加载或应用配置后置为 true;main 启动挂接初始化与 poller;HTTP 入口返回 503 `api.ErrCodeServiceUnavailable`,WebSocket/Realtime 在握手或首帧返回 CloseTryAgainLater (1013) 错误。 +4. 管理端与前端 UI 层(`admin`, `frontend`):复用既有 `/api/admin/settings` GET/PUT,支持独立分组、单位展示、恢复默认(恢复 Spec 默认配置)、严格层级范围校验($16\text{KiB} \le \text{tool} \le \text{channel} \le \text{request} \le 64\text{MiB}$);非法配置记录诊断日志;提供完整前端 React 界面与 TypeScript 类型契约,并以 `pnpm run typecheck` 验证。 +5. 统一协议投影层(`proxy`):将 Responses HTTP/WS、ChatCompletions、Messages、Gemini Native 及 Realtime 文本子集的流式 chunk 及非流式 JSON 事件实时投影为统一语义观察(`Observation`);实现 Anthropic、Gemini Native 及各协议非流式真实 JSON 投影器与单元测试。 +6. 强制终止与下游通知层(`proxy`):先实现 `GuardTerminationCoordinator` 与 `SendDownstreamGuardTerminal`。guard 命中时原子切换 `tripped` 状态并冻结首个 reason;优先主动 Cancel 上游 context(直接调用 cancel 跳过 5 秒 usage drain,关闭 HTTP body,WS lease 废弃不归还健康池);清空本地 pending、provenance、collector、replay、writer buffers;下游通知写入采用 `downstreamMu.TryLock()` 避免死锁,持锁写操作通过 `http.ResponseController.SetWriteDeadline` 施加 5 秒硬超时;不支持 deadline(`ErrNotSupported`)或已断开时标记 `clientGone` 安全退出;补齐 Gemini SDK 兼容格式及 WebSocket 1013 / error 帧。 +7. 链路编排与重试阻断层(`proxy`):在前置拦截点(Responses HTTP/WS, Chat, Messages, Gemini, Realtime)将投影器、RequestGuard 与终止协调器组装串联;在 `continuousRetryStreamSelected`、`shouldTransparentRetryStream` 及自动续想入口最前置拦截 `guardTripped`,坚决不重试;在 `retryExclusions` 中豁免账号健康惩罚(使用真实符号 `exclusions.forHardSelection()` 与 `exclusions.hasResettable()` 断言)。 +8. 综合验收集成测试层(`test/outputguard`):完整实现 Spec §9 场景 1~28 全场景代码,提供脱敏 fixture、机器自动化判定阈值,覆盖成本上界 8a/b/c、入口矩阵、cancel/body close 无 drain、重试上游调用数恒为 1、全协议错误结构、TryLock/deadline 降级、冷启动 503 / 并发 patch / 恢复默认、TTFT p95 <= 10ms、CPU <= 20%、线性比值 ratio <= 2.5、每 channel <= 64KiB、10 分钟内存/Goroutine/FD 启停快照。 + +**Tech Stack:** Go 1.26+ (gin-gonic/gin, tidwall/gjson, modernc.org/sqlite, jackc/pgx/v5), TypeScript + React 19 (前端契约与界面). + +**Spec:** `docs/superpowers/specs/2026-10-28-model-output-guard-design.md` + +## Global Constraints + +- **范围限制**:只涉及文字输出的 LLM(含 text、thinking、tool arguments);只修订计划文件 `docs/superpowers/plans/2026-10-28-model-output-guard-tdd-plan.md`,不修改 spec、业务源码、数据库或其他文件;不修改 DSH、客户端或已安装 npm 包;设计文档 §10 的设计取舍已全部由用户确认。 +- **不可妥协的设计取舍**: + - 默认全局开启,仅管理员可在后台显式关闭;与 prompt filter 相互独立。 + - 首期采用精确短周期重复检测 + 硬预算,不引入语义模型,不自动改写或拆分工具参数。 + - 默认工具参数上限 128 KiB、4096 非空 deltas;单 channel 字节上限 2 MiB;单请求字节上限 4 MiB;合法大 `write` 也拦截,后台可调高。 + - 只切断当前入口请求或 WebSocket 逻辑 turn;代理端禁止任何形式的透明重试与自动续想,绝不惩罚账号健康分。 + - 全协议覆盖:涵盖 Responses HTTP/WS、ChatCompletions、Messages、Gemini Native 及 Realtime WebSocket 桥接的文本/工具子集;专用媒体接口、Realtime 原生音频 Modality(显式拒绝)及协议管理帧不计入语义重复检测。 + - transport reader 有界行/事件/framing 保护(SSE 8 MiB、WS frame 8 MiB)独立于 guard 开关,关闭语义 guard 仍保留 wire cap。 +- **技术异议核实结论(对首轮评审意见 M2 与 I8 的技术审查回应)**: + - **M2 关于 SQLite 不支持 sql.NullBool 的澄清**:经查证 Go 标准库 `database/sql/convert.go:383` 及 `database/sql/driver/types.go:87-92`,`sql.NullBool.Scan` 内部调用 `convertAssign`,明确原生支持驱动返回的 `int64`(0 对应 false,1 对应 true)。在 `modernc.org/sqlite` 驱动下,SQLite 的 `INTEGER` 字段扫描至 `sql.NullBool` 完全合法,无需盲目绕开 `sql.NullBool`。本计划在数据库结构层保留 `*bool` 指针三态语义,在底层 Scan 阶段通过 `sql.NullBool` 准确反序列化。 + - **I8 关于 KMP 前缀周期整除性的数学核实**:评审建议曾提及“检查 L % p == 0”,但根据 Spec §5.2 规则 3 明确要求:“命中证据是连续 8 KiB 的精确周期窗口;最长周期时也含至少 32 次重复,不要求窗口长度整除周期”。在有限长度字符串(8192 字节)上,若最短周期为 $p$(例如事故模式 $p = 30$),$8192 \pmod{30} = 22 \ne 0$。KMP 前缀函数的最大 border $k = \pi[L-1]$ 对应的最小周期候选值为 $p = L - k$。当且仅当 $1 \le p \le 256$ 且 $\forall i \in [p, L): s[i] == s[i-p]$ 时即为严格周期,绝不能加入 $L \pmod p == 0$ 的错误过滤。本计划在 Task 1 中明确提供非整倍数($p=30, 8192\pmod{30}=22$)的针对性数学验证测试。 +- **编译与验证约束**: + - Go 测试命令必须指定独立缓存路径:`GOCACHE=/tmp/go-build GOPATH=/tmp/go go test ...`。 + - 前端类型校验命令:`cd frontend && pnpm run typecheck`。 +- **TDD 流程纪律**:每个任务严格遵循 Step 1 (Write failing test) -> Step 2 (Verify Red) -> Step 3 (Minimal implementation) -> Step 4 (Verify Green) -> Step 5 (Commit)。绝不使用占位符,严禁假测试与“Red or pass”。 + +--- + +## File Structure + +| 文件路径 | 状态 | 职责描述 | +|---|---|---| +| `security/outputguard/repetition.go` | 新建 | 8192B 滑动窗口 Ring Buffer、KMP 前缀函数求最短周期($1 \le p \le 256$、支持非整除周期)、1024B 步长检查点与严格尾检 | +| `security/outputguard/repetition_test.go` | 新建 | 周期检测器单元测试(命中短周期、非整倍数周期 $p=30$、非周期放行、大 delta 跨步长、<8192B 尾检放行、未对齐最新 8192B 窗口检测) | +| `security/outputguard/channel.go` | 新建 | 单 Channel 状态机:已计费高水位、累计扫描预算扣减、3 次合法纯终态快照许可、单遍历 Hash 流水线、处理量 $5 \times \text{limit}$ 封顶 | +| `security/outputguard/channel_test.go` | 新建 | Channel 状态机单元测试(高水位推进、用例 8a/8b/8c 纯终态许可与变体超额拦截) | +| `security/outputguard/guard.go` | 新建 | 请求级 `RequestGuard` 状态机:事件预算(65536)、Channel 计数(128)、Delta 计数(4096)、§5.3 严格 8 步判定流水线、`sync.RWMutex` 并发安全保障 | +| `security/outputguard/guard_test.go` | 新建 | 请求级 Guard 单元测试(事件洪泛拦截、Channel 洪泛拦截、Delta 洪泛拦截、字节溢出、首个 reason 冻结、并发 race 检验) | +| `database/output_guard_settings.go` | 新建 | 数据库层 `OutputGuardSettings`、`*bool` 三态指针、同事务锁机制(Postgres FOR UPDATE / SQLite 独占写锁)与 CAS 自增 `generation` | +| `database/output_guard_settings_test.go` | 新建 | 数据库迁移与设置读写测试(SQLite/PostgreSQL 默认填充、显式 false 持久化、并发独立窄更新不丢字段与防死锁) | +| `database/sqlite.go` | 修改 | `system_settings` 表增加 output_guard 6 个字段的 SQLite 迁移列(INTEGER) | +| `database/postgres.go` | 修改 | `system_settings` 表增加 output_guard 6 个字段的 PostgreSQL 迁移列(BOOLEAN/BIGINT/INT) | +| `proxy/output_guard_settings.go` | 新建 | 运行时不可变配置快照、5 秒后台 Revision 轮询器、`ApplyOutputGuardSettings`、冷启动未就绪 503 检查、非法配置诊断日志 | +| `proxy/output_guard_settings_test.go` | 新建 | 运行时配置生效与 5 秒轮询更新测试、冷启动未就绪 503 门禁测试、非法配置诊断日志测试 | +| `admin/output_guard_settings.go` | 新建 | 管理端 Admin API:扩展现有 GET/PUT `/api/admin/settings`,支持独立分组、单位显示、恢复默认、严格层级范围校验 | +| `admin/output_guard_settings_test.go` | 新建 | Admin API 单元测试(范围校验失败回滚与诊断日志、恢复默认设置、合法修改即时生效、同事务安全合并) | +| `frontend/src/types.ts` | 修改 | 增加 `OutputGuardSettings` 前端类型契约 | +| `frontend/src/pages/Settings.tsx` | 修改 | 管理端 UI 界面:新增独立的“模型输出失控防护”分组卡片,含 5 项配置、单位(KiB/MiB)、高风险提示与“恢复默认”操作按钮 | +| `proxy/outputguard_projection.go` | 新建 | 统一协议投影器:将 Responses HTTP/WS、ChatCompletions、Messages、Gemini Native 及 Realtime 文本流式与非流式事件投影为标准 `Observation` | +| `proxy/outputguard_projection_test.go` | 新建 | 协议投影器单元测试(各协议 chunk/snapshot/非流式 JSON 正确转换为 ChannelID、Kind、Action 及 Payload) | +| `proxy/outputguard_cancel.go` | 新建 | 终止协调器:原子切换 `tripped` 状态并冻结首 reason、主动 Cancel 上游 context、关闭 body、绕过 5s drain、清空在途缓冲 | +| `proxy/outputguard_cancel_test.go` | 新建 | 上游取消与缓冲废弃测试(mock upstream 立即收到 context cancel,无 5s 延迟,本地 pending/collector/replay/writer 缓冲彻底清空) | +| `proxy/outputguard_downstream.go` | 新建 | 下游失败通知写入:各协议终态错误帧构建(Responses, Chat, Anthropic, Gemini Native, WebSocket)、`downstreamMu.TryLock()` 有界锁获取、持锁内 `SetWriteDeadline(5s)` 写入与 ErrNotSupported 安全降级 | +| `proxy/outputguard_downstream_test.go` | 新建 | 下游写入测试(各协议标准错误结构断言、TryLock 竞争失败安全放弃不卡死、持锁超时回收、不支持 deadline 安全退出) | +| `proxy/continuous_retry.go` | 修改 | `continuousRetryStreamSelected` 最前置识别 `guardTripped`,坚决不重试 | +| `proxy/retry_exclusions.go` | 修改 | `MarkStreamFailure` 识别 guard 失败,豁免账号健康分惩罚,不增加错误计数 | +| `proxy/outputguard_retry_test.go` | 新建 | 重试阻断与账号豁免测试(使用真实符号 `exclusions.forHardSelection()` 与 `exclusions.hasResettable()` 断言) | +| `proxy/handler.go` | 修改 | Responses HTTP SSE `forwardWithEvent` 及非流式接入前置 guard 检查;ChatCompletions 流式与非流式接入前置 guard 检查 | +| `proxy/responses_ws.go` | 修改 | Responses WebSocket 接入每个逻辑 turn 的前置 guard 检查(早于 collector 与 replay) | +| `proxy/realtime_ws.go` | 修改 | Realtime 文本转接 session 严格继承全局 guard | +| `proxy/handler_anthropic.go` | 修改 | Messages (Claude Native) 流式与非流式接入前置 guard 检查 | +| `proxy/handler_gemini.go` | 修改 | Gemini Native `streamGenerateContent` 及非流式接入前置 guard 检查 | +| `proxy/main_init.go` | 新建 | 主程序初始化接线:挂载 `proxy.StartOutputGuardConfigPoller`,执行冷启动配置预加载 | +| `test/outputguard/acceptance_detection_test.go` | 新建 | 综合验收测试:场景 1~11(短周期、不同 delta 边界切分、9215B 片段、8a/8b/8c 专项、短文本尾检放行) | +| `test/outputguard/acceptance_lifecycle_test.go` | 新建 | 综合验收测试:场景 12~20(各入口流式/非流式全链路终止、无 5s drain、并发无 race、无重试、账号无惩罚) | +| `test/outputguard/acceptance_benchmark_test.go` | 新建 | 综合验收测试:场景 21~28(配置轮询、冷启动 503、CPU O(N) 线性度、内存 <=64KiB、细 delta 4096 专项压测分析) | + +## Tasks + +### Task 1: 周期性短重复检测核心算法(Prefix Function 线性检测器) + +**Files:** +- Create: `security/outputguard/repetition.go` +- Test: `security/outputguard/repetition_test.go` + +**Interfaces:** +- Produces: + ```go + package outputguard + + const ( + WindowSizeBytes = 8192 + CheckpointStepBytes = 1024 + MaxRepetitionPeriod = 256 + MinRepetitionPeriod = 1 + ) + + type RepetitionDetector struct { + window [WindowSizeBytes]byte + count int64 // channel 累计写入总字节数 + head int // ring buffer 写入游标 (0..8191) + lastCheckedCheckpoint int64 + } + + func NewRepetitionDetector() *RepetitionDetector + func (d *RepetitionDetector) Push(data []byte) (hit bool, period int) + func (d *RepetitionDetector) CheckTail() (hit bool, period int) + func (d *RepetitionDetector) Reset() + ``` + +- [ ] **Step 1: Write failing test for repetition detector (including non-multiple periodic math verification and unaligned tail check)** + +```go +// security/outputguard/repetition_test.go +package outputguard + +import ( + "bytes" + "math/rand" + "testing" +) + +func TestRepetitionDetectorNormalText(t *testing.T) { + d := NewRepetitionDetector() + // 伪随机非周期数据 + r := rand.New(rand.NewSource(12345)) + nonRepeating := make([]byte, 10000) + r.Read(nonRepeating) + + hit, _ := d.Push(nonRepeating) + if hit { + t.Fatalf("expected no repetition hit for pseudo-random data, got hit=true") + } +} + +func TestRepetitionDetectorShortPeriodHit(t *testing.T) { + d := NewRepetitionDetector() + // 创建真实事故重复模式:周期 p = 30 ("_auditfree.md_pluginless.tmp.m") + // 数学核实重点:8192 % 30 = 22 != 0,验证有限串数学周期不要求 L % p == 0! + pattern := []byte("_auditfree.md_pluginless.tmp.m") + if len(pattern) != 30 { + t.Fatalf("pattern length = %d, want 30", len(pattern)) + } + var stream []byte + for len(stream) < 9500 { + stream = append(stream, pattern...) + } + + // 以 512 字节分块送入 + hitFound := false + var hitPeriod int + for offset := 0; offset < len(stream); offset += 512 { + end := offset + 512 + if end > len(stream) { + end = len(stream) + } + hit, p := d.Push(stream[offset:end]) + if hit { + hitFound = true + hitPeriod = p + break + } + } + + if !hitFound { + t.Fatalf("expected repetition hit for 30-byte periodic pattern (non-multiple of 8192), got hit=false") + } + if hitPeriod != 30 { + t.Fatalf("expected detected period 30, got %d", hitPeriod) + } +} + +func TestRepetitionDetectorTailCheckShortText(t *testing.T) { + d := NewRepetitionDetector() + // 短文本 < 8192 字节:即使内容高度周期,尾检必须返回 hit=false (Spec §5.2 规则 6) + pattern := bytes.Repeat([]byte("abc"), 500) // 1500 字节 + hit, _ := d.Push(pattern) + if hit { + t.Fatalf("expected hit=false during push for <8192 bytes") + } + hit, _ = d.CheckTail() + if hit { + t.Fatalf("expected hit=false for tail check with <8192 bytes total") + } +} + +func TestRepetitionDetectorTailCheckUnalignedWindow(t *testing.T) { + d := NewRepetitionDetector() + // 构造真实非对齐尾检样本: + // 前 108 字节为非周期杂散前缀,后随 8192 字节周期为 10 的重复片段,总长 8300 字节。 + // 在常规 Push 过程中: + // - 当累计达到 8192 字节时触发检查点,此时窗口包含 108 字节杂散前缀 + 8084 字节周期内容,不是完整周期,Push 判定 hit=false; + // - 后续送入剩余 108 字节(总长 8300 字节),尚未到达下一个 1024 检查点(9216 字节),Push 仍未命中; + // - 此时流结束触发 CheckTail():提取当前最新的 8192 字节窗口,恰好完全由周期 10 的数据组成,CheckTail 必须精准返回 hit=true, period=10。 + prefix := []byte("non-periodic-header-padding-1234567890-abcdefghijklmnopqrstuvwxyz!@#$%^&*()-random-preamble-bytes-to-offset-boundary-xxxx") + if len(prefix) != 108 { + t.Fatalf("prefix length = %d, want 108", len(prefix)) + } + pattern := []byte("0123456789") // period 10 + periodicPart := bytes.Repeat(pattern, 8192/10) + periodicPart = append(periodicPart, pattern[:8192%10]...) // 恰好 8192 字节纯周期 + if len(periodicPart) != 8192 { + t.Fatalf("periodicPart length = %d, want 8192", len(periodicPart)) + } + + fullStream := append(prefix, periodicPart...) // 总长 108 + 8192 = 8300 字节 + + // 分块推送,断言 Push 期间不会命中(因为 8192 检查点包含前缀,而后续未到 9216 检查点) + for offset := 0; offset < len(fullStream); offset += 500 { + end := offset + 500 + if end > len(fullStream) { + end = len(fullStream) + } + hit, _ := d.Push(fullStream[offset:end]) + if hit { + t.Fatalf("Push prematurely hit at offset %d, expected tail check to detect", offset) + } + } + + // 触发尾检 + hit, p := d.CheckTail() + if !hit { + t.Fatalf("expected CheckTail to detect pure periodic window of latest 8192 bytes, got hit=false") + } + if p != 10 { + t.Fatalf("expected detected period 10, got %d", p) + } +} + +func TestRepetitionDetectorConsecutive9215BytesGuaranteedHit(t *testing.T) { + d := NewRepetitionDetector() + // Spec §5.2 规则 3: 步长 1024B,任意起点的连续周期片段达到 9215B 时必存在检查点完整落入 + prefix := make([]byte, 512) // 任意偏置前缀 512B + for i := range prefix { + prefix[i] = byte(i % 127) + } + pattern := []byte("repeated_segment_32_bytes_test_") + periodicBody := bytes.Repeat(pattern, 300) // > 9215B + stream := append(prefix, periodicBody...) + + hit := false + for offset := 0; offset < len(stream); offset += 256 { + end := offset + 256 + if end > len(stream) { + end = len(stream) + } + if ok, _ := d.Push(stream[offset:end]); ok { + hit = true + break + } + } + if !hit { + t.Fatalf("expected 9215-byte periodic segment to trigger repetition hit within checkpoints") + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./security/outputguard -run "TestRepetitionDetector.*"` +Expected: FAIL with compilation error: undefined: NewRepetitionDetector + +- [ ] **Step 3: Write minimal implementation** + +```go +// security/outputguard/repetition.go +package outputguard + +const ( + WindowSizeBytes = 8192 + CheckpointStepBytes = 1024 + MaxRepetitionPeriod = 256 + MinRepetitionPeriod = 1 +) + +type RepetitionDetector struct { + window [WindowSizeBytes]byte + count int64 // channel 累计写入总字节数 + head int // ring buffer 写入游标 (0..8191) + lastCheckedCheckpoint int64 +} + +func NewRepetitionDetector() *RepetitionDetector { + return &RepetitionDetector{} +} + +func (d *RepetitionDetector) Reset() { + d.count = 0 + d.head = 0 + d.lastCheckedCheckpoint = 0 +} + +func (d *RepetitionDetector) Push(data []byte) (hit bool, period int) { + for _, b := range data { + d.window[d.head] = b + d.head = (d.head + 1) % WindowSizeBytes + d.count++ + + // 每新增 CheckpointStepBytes 到达检查点 + if d.count >= WindowSizeBytes && (d.count-d.lastCheckedCheckpoint) >= CheckpointStepBytes { + d.lastCheckedCheckpoint = d.count - (d.count % CheckpointStepBytes) + if ok, p := d.checkCurrentWindow(); ok { + return true, p + } + } + } + return false, 0 +} + +func (d *RepetitionDetector) CheckTail() (hit bool, period int) { + // Spec §5.2 规则 6: 如果 channel 累计总字节数 < 8192,不判定重复,直接放行 + if d.count < WindowSizeBytes { + return false, 0 + } + // 已满 8192 窗口但尾部未对齐 1024,提取最新的完整 8192 窗口执行一次求周期检查 + return d.checkCurrentWindow() +} + +// checkCurrentWindow 提取当前 ring buffer 中的线性 8192 字节,使用前缀函数求最短周期 1 <= p <= 256 +// 数学核实说明:有限字符串周期不要求 L % p == 0(例如事故周期 p=30, 8192 % 30 = 22 != 0)。 +// 前缀函数的最大 border k = pi[L-1] 对应最小周期 p = L - k。 +// 当且仅当 1 <= p <= 256 且满足全部 p <= i < 8192 的 window[i] == window[i-p] 时判定命中。 +func (d *RepetitionDetector) checkCurrentWindow() (bool, int) { + if d.count < WindowSizeBytes { + return false, 0 + } + // 展开当前最新的 8192 字节连续视图 + var linear [WindowSizeBytes]byte + idx := d.head // d.head 此时指向最老的那个字节 + for i := 0; i < WindowSizeBytes; i++ { + linear[i] = d.window[(idx+i)%WindowSizeBytes] + } + + // 采用 KMP prefix function (pi table) 在 O(N) 线性时间求周期 + pi := make([]int, WindowSizeBytes) + for i := 1; i < WindowSizeBytes; i++ { + j := pi[i-1] + for j > 0 && linear[i] != linear[j] { + j = pi[j-1] + } + if linear[i] == linear[j] { + j++ + } + pi[i] = j + } + + k := pi[WindowSizeBytes-1] + p := WindowSizeBytes - k + if p >= MinRepetitionPeriod && p <= MaxRepetitionPeriod { + // 校验边界:根据 border 定义,linear[0..k-1] == linear[p..L-1] + // 即对于所有 p <= i < 8192, 严格满足 linear[i] == linear[i-p] + return true, p + } + + return false, 0 +} +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./security/outputguard -run "TestRepetitionDetector.*"` +Expected: PASS ok github.com/codex2api/security/outputguard + +- [ ] **Step 5: Commit** + +```bash +git add security/outputguard/repetition.go security/outputguard/repetition_test.go +git commit -m "feat(outputguard): implement sliding window linear repetition detector" +``` + +--- + +### Task 2: Channel 状态机与扫描预算/纯终态确认配额机制 + +**Files:** +- Create: `security/outputguard/channel.go` +- Test: `security/outputguard/channel_test.go` + +**Interfaces:** +- Consumes: `RepetitionDetector` from Task 1 +- Produces: + ```go + package outputguard + + type ChannelKind string + const ( + KindText ChannelKind = "text" + KindThinking ChannelKind = "thinking" + KindToolArguments ChannelKind = "tool_arguments" + ) + + type ChannelGuard struct { + id string + kind ChannelKind + maxBytes int64 + maxDeltas int + remainingScanBudget int64 + highwater int64 + deltaCount int + terminalBaselineHash uint64 + hasBaselineHash bool + terminalPermitsUsed int + consumedTerminalTypes map[string]bool + detector *RepetitionDetector + isClosed bool + } + + func NewChannelGuard(id string, kind ChannelKind, maxBytes int64, maxDeltas int) *ChannelGuard + func (c *ChannelGuard) Highwater() int64 + func (c *ChannelGuard) DeltaCount() int + func (c *ChannelGuard) RemainingScanBudget() int64 + func (c *ChannelGuard) ReleaseWindow() + func (c *ChannelGuard) CheckAppend(delta []byte) (growth int64, cost int64, err error) + func (c *ChannelGuard) CommitAppend(delta []byte, growth int64, cost int64) error + func (c *ChannelGuard) CheckSnapshot(snapshot []byte, terminalStage string) (growth int64, cost int64, skipRepetition bool, err error) + func (c *ChannelGuard) CommitSnapshot(snapshot []byte, growth int64, cost int64, terminalStage string) error + func (c *ChannelGuard) CheckTail() error + ``` + +- [ ] **Step 1: Write failing test for channel state machine and quota** + +```go +// security/outputguard/channel_test.go +package outputguard + +import ( + "bytes" + "math/rand" + "testing" +) + +func TestChannelGuardHighwaterGrowthAndScanBudget(t *testing.T) { + // 工具 channel: maxBytes = 128 KiB (131072), scanBudget = 2 * 128 KiB = 256 KiB + cg := NewChannelGuard("call_1", KindToolArguments, 131072, 4096) + + // Append 64 KiB of non-repeating data + delta1 := make([]byte, 65536) + r1 := rand.New(rand.NewSource(10001)) + r1.Read(delta1) + + growth, cost, err := cg.CheckAppend(delta1) + if err != nil { + t.Fatalf("CheckAppend delta1 error: %v", err) + } + if growth != 65536 || cost != 65536 { + t.Fatalf("delta1: want growth=65536, cost=65536, got growth=%d, cost=%d", growth, cost) + } + if err := cg.CommitAppend(delta1, growth, cost); err != nil { + t.Fatalf("CommitAppend delta1 error: %v", err) + } + + if cg.Highwater() != 65536 { + t.Fatalf("want highwater=65536, got %d", cg.Highwater()) + } + if cg.RemainingScanBudget() != 262144-65536 { + t.Fatalf("want remainingScanBudget=%d, got %d", 262144-65536, cg.RemainingScanBudget()) + } +} + +// Spec §9.1 Case 8a: 耗尽扫描预算后变体快照超额拦截 +func TestChannelGuardCase8a_ScanBudgetExhaustionAndVariantRejection(t *testing.T) { + cg := NewChannelGuard("call_1", KindToolArguments, 131072, 4096) + // 以 128 次 1 KiB delta 耗尽 128 KiB 扫描预算 (剩余 128 KiB) + chunk := make([]byte, 1024) + r2 := rand.New(rand.NewSource(20002)) + r2.Read(chunk) + var fullParam []byte + for i := 0; i < 128; i++ { + fullParam = append(fullParam, chunk...) + growth, cost, err := cg.CheckAppend(chunk) + if err != nil { + t.Fatalf("step %d append error: %v", i, err) + } + if err := cg.CommitAppend(chunk, growth, cost); err != nil { + t.Fatalf("step %d commit append error: %v", i, err) + } + } + if cg.RemainingScanBudget() != 131072 { + t.Fatalf("remaining scan budget want 131072, got %d", cg.RemainingScanBudget()) + } + + // 首个 128 KiB 白名单终态快照到达 (arguments.done),尚无 baseline hash,cost = 128 KiB + growth, cost, skipRepetition, err := cg.CheckSnapshot(fullParam, "arguments.done") + if err != nil { + t.Fatalf("arguments.done snapshot error: %v", err) + } + if cost != 131072 || growth != 0 || skipRepetition { + t.Fatalf("arguments.done: want cost=131072, growth=0, skipRepetition=false; got cost=%d, growth=%d, skip=%v", cost, growth, skipRepetition) + } + if err := cg.CommitSnapshot(fullParam, growth, cost, "arguments.done"); err != nil { + t.Fatalf("arguments.done commit snapshot error: %v", err) + } + + if cg.RemainingScanBudget() != 0 { + t.Fatalf("remaining scan budget want 0, got %d", cg.RemainingScanBudget()) + } + + // 紧接着收到修改了 1 个字节的 128 KiB 变体快照 (item.done) + variantParam := bytes.Clone(fullParam) + variantParam[65536] ^= 0xFF + + _, _, _, err = cg.CheckSnapshot(variantParam, "item.done") + if err == nil { + t.Fatalf("expected variant snapshot to be rejected with scan budget exhaustion, got nil") + } + if err != ErrScanBudgetExhausted { + t.Fatalf("expected ErrScanBudgetExhausted, got %v", err) + } +} + +// Spec §9.1 Case 8b: 合法终态链零计费放行与无资格拒绝 +func TestChannelGuardCase8b_TerminalChainZeroCostAndExhaustion(t *testing.T) { + cg := NewChannelGuard("call_1", KindToolArguments, 131072, 4096) + fullParam := make([]byte, 131072) + r3 := rand.New(rand.NewSource(30003)) + r3.Read(fullParam) + // append 128 KiB + growth, cost, _ := cg.CheckAppend(fullParam) + _ = cg.CommitAppend(fullParam, growth, cost) + + // arguments.done 消耗最后 128 KiB 扫描预算并建立 baseline hash + growth, cost, _, _ = cg.CheckSnapshot(fullParam, "arguments.done") + _ = cg.CommitSnapshot(fullParam, growth, cost, "arguments.done") + + // item.done 具备资格且 Hash 完全一致 -> cost = 0, skipRepetition = true + growth, cost, skipRepetition, err := cg.CheckSnapshot(fullParam, "item.done") + if err != nil { + t.Fatalf("item.done snapshot error: %v", err) + } + if cost != 0 || !skipRepetition { + t.Fatalf("item.done: want cost=0, skipRepetition=true; got cost=%d, skip=%v", cost, skipRepetition) + } + _ = cg.CommitSnapshot(fullParam, growth, cost, "item.done") + + // response.completed 同样具备资格且 Hash 一致 -> cost = 0 + growth, cost, skipRepetition, err = cg.CheckSnapshot(fullParam, "response.completed") + if err != nil { + t.Fatalf("response.completed snapshot error: %v", err) + } + if cost != 0 || !skipRepetition { + t.Fatalf("response.completed: want cost=0, skipRepetition=true") + } + _ = cg.CommitSnapshot(fullParam, growth, cost, "response.completed") + + // 重发同类型 response.completed -> 失去资格,走 cost = 128 KiB,因剩余扫描预算为 0 被拦截 + _, _, _, err = cg.CheckSnapshot(fullParam, "response.completed") + if err != ErrScanBudgetExhausted { + t.Fatalf("duplicate response.completed: want ErrScanBudgetExhausted, got %v", err) + } +} + +// Spec §9.1 Case 8c: 非白名单普通快照绝不建立 baseline +func TestChannelGuardCase8c_NonTerminalSnapshotCannotEstablishBaseline(t *testing.T) { + cg := NewChannelGuard("c_text", KindText, 2097152, 4096) + snap := []byte("intermediate full snapshot text") + growth, cost, skip, err := cg.CheckSnapshot(snap, "unknown_or_intermediate_type") + if err != nil { + t.Fatalf("CheckSnapshot error: %v", err) + } + if skip || cost != int64(len(snap)) { + t.Fatalf("want skip=false, cost=%d, got skip=%v, cost=%d", len(snap), skip, cost) + } + _ = cg.CommitSnapshot(snap, growth, cost, "unknown_or_intermediate_type") + + // 确认 hasBaselineHash 仍为 false,未建立 baseline + if cg.hasBaselineHash { + t.Fatalf("expected hasBaselineHash=false for non-terminal snapshot, got true") + } +} + +func TestChannelGuardHashMismatchDiscardsQuotaAndChargesFullCost(t *testing.T) { + cg := NewChannelGuard("c_tool", KindToolArguments, 131072, 4096) + data1 := bytes.Repeat([]byte("hello world "), 100) // 1200 字节 + growth, cost, _, _ := cg.CheckSnapshot(data1, "arguments.done") + _ = cg.CommitSnapshot(data1, growth, cost, "arguments.done") + + if !cg.hasBaselineHash { + t.Fatalf("expected baseline hash created") + } + + // 紧接着发送篡改变体快照 + data2 := bytes.Repeat([]byte("corrupted data!"), 80) // 1200 字节 + growth, cost, skip, err := cg.CheckSnapshot(data2, "item.done") + if err != nil { + t.Fatalf("unexpected CheckSnapshot error: %v", err) + } + if skip { + t.Fatalf("expected skip=false for hash mismatch") + } + if cost != int64(len(data2)) { + t.Fatalf("expected full cost %d, got %d", len(data2), cost) + } + + // CommitSnapshot 篡改帧后,该类型许可必须标记废弃 + _ = cg.CommitSnapshot(data2, growth, cost, "item.done") + if !cg.consumedTerminalTypes["item.done"] { + t.Fatalf("expected item.done marked as consumed/discarded") + } + // 基准 Hash 绝不能被篡改快照覆盖 + h := uint64(0) + for _, b := range data1 { + h = (h ^ uint64(b)) * 1099511628211 + } + if cg.terminalBaselineHash != h { + t.Fatalf("expected baseline hash to stay unchanged") + } +} + +func TestChannelGuardTailCheck(t *testing.T) { + cg := NewChannelGuard("c_text", KindText, 2097152, 4096) + // 短文本放行 + _ = cg.CommitAppend([]byte("short text"), 10, 10) + if err := cg.CheckTail(); err != nil { + t.Fatalf("expected nil error for short text tail check, got %v", err) + } + + // 长周期文本检出 + cg2 := NewChannelGuard("c_periodic", KindText, 2097152, 4096) + pattern := []byte("0123456789") + data := bytes.Repeat(pattern, 850) // 8500 字节 + // 避免在 append 触发检查点,直接写入 window + cg2.detector.window = [8192]byte{} + for i := 0; i < 8500; i++ { + cg2.detector.window[i%8192] = pattern[i%10] + } + cg2.detector.count = 8500 + cg2.detector.head = 8500 % 8192 + + if err := cg2.CheckTail(); err != ErrRepetitionDetected { + t.Fatalf("expected ErrRepetitionDetected for periodic tail check, got %v", err) + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./security/outputguard -run "TestChannelGuard.*"` +Expected: FAIL with compilation error: undefined: NewChannelGuard + +- [ ] **Step 3: Write minimal implementation** + +```go +// security/outputguard/channel.go +package outputguard + +import ( + "errors" +) + +var ( + ErrToolArgumentBytesExceeded = errors.New("tool_argument_bytes") + ErrChannelBytesExceeded = errors.New("channel_bytes") + ErrScanBudgetExhausted = errors.New("scan_budget_exhausted") + ErrRepetitionDetected = errors.New("repetition") + ErrToolDeltasExceeded = errors.New("tool_argument_deltas") +) + +type ChannelKind string + +const ( + KindText ChannelKind = "text" + KindThinking ChannelKind = "thinking" + KindToolArguments ChannelKind = "tool_arguments" +) + +// allowedTerminalTypes 白名单终态类型(Spec §5.3) +var allowedTerminalTypes = map[string]bool{ + "arguments.done": true, + "item.done": true, + "response.completed": true, +} + +// fnv64a 计算 64 位 FNV-1a Hash,用于流水线单遍历比对与基准记录 +func fnv64a(data []byte) uint64 { + var h uint64 = 14695981039346656037 + for _, b := range data { + h = (h ^ uint64(b)) * 1099511628211 + } + return h +} + +type ChannelGuard struct { + id string + kind ChannelKind + maxBytes int64 + maxDeltas int + remainingScanBudget int64 + highwater int64 + deltaCount int + terminalBaselineHash uint64 + hasBaselineHash bool + terminalPermitsUsed int + consumedTerminalTypes map[string]bool + detector *RepetitionDetector + isClosed bool +} + +func NewChannelGuard(id string, kind ChannelKind, maxBytes int64, maxDeltas int) *ChannelGuard { + return &ChannelGuard{ + id: id, + kind: kind, + maxBytes: maxBytes, + maxDeltas: maxDeltas, + remainingScanBudget: 2 * maxBytes, // 累计扫描预算为 2 * limit (Spec §5.3) + consumedTerminalTypes: make(map[string]bool), + detector: NewRepetitionDetector(), + } +} + +func (c *ChannelGuard) Highwater() int64 { return c.highwater } +func (c *ChannelGuard) DeltaCount() int { return c.deltaCount } +func (c *ChannelGuard) RemainingScanBudget() int64 { return c.remainingScanBudget } + +func (c *ChannelGuard) ReleaseWindow() { + c.detector = nil + c.isClosed = true +} + +func (c *ChannelGuard) CheckAppend(delta []byte) (growth int64, cost int64, err error) { + deltaLen := int64(len(delta)) + if c.kind == KindToolArguments && deltaLen > 0 { + if c.deltaCount+1 > c.maxDeltas { + return 0, 0, ErrToolDeltasExceeded + } + } + newHighwater := c.highwater + deltaLen + if newHighwater > c.maxBytes { + if c.kind == KindToolArguments { + return 0, 0, ErrToolArgumentBytesExceeded + } + return 0, 0, ErrChannelBytesExceeded + } + + cost = deltaLen + if c.remainingScanBudget < cost { + return 0, 0, ErrScanBudgetExhausted + } + + return deltaLen, cost, nil +} + +func (c *ChannelGuard) CommitAppend(delta []byte, growth int64, cost int64) error { + c.highwater += growth + if c.kind == KindToolArguments && len(delta) > 0 { + c.deltaCount++ + } + c.remainingScanBudget -= cost + + if c.detector != nil && len(delta) > 0 { + hit, _ := c.detector.Push(delta) + if hit { + return ErrRepetitionDetected + } + } + return nil +} + +func (c *ChannelGuard) CheckSnapshot(snapshot []byte, terminalStage string) (growth int64, cost int64, skipRepetition bool, err error) { + snapLen := int64(len(snapshot)) + if snapLen > c.highwater { + growth = snapLen - c.highwater + } + if snapLen > c.maxBytes { + if c.kind == KindToolArguments { + return 0, 0, false, ErrToolArgumentBytesExceeded + } + return 0, 0, false, ErrChannelBytesExceeded + } + + // 资格检查:仅白名单终态类型且具备 baseline hash 且许可未耗尽 (Spec §5.3 步骤 5) + // 合法与非法终态配额判断严格在 hash 前!无资格绝不提前计算 hash。 + isEligible := c.hasBaselineHash && + allowedTerminalTypes[terminalStage] && + !c.consumedTerminalTypes[terminalStage] && + c.terminalPermitsUsed < 3 + + if isEligible { + // 计算 snapshot Hash 比对 baseline hash + currentHash := fnv64a(snapshot) + if currentHash == c.terminalBaselineHash { + // Hash 完全一致,纯终态确认快照零计费放行,跳过步骤 7 重复扫描 + return growth, 0, true, nil + } + // Hash 不一致(变体篡改),作废该类型许可,全额计费 + cost = snapLen + } else { + // 无免除资格(包括中间替换、首帧终态、重发同类型或超出 3 次配额),严禁提前 Hash,直接全额计费 + cost = snapLen + } + + if c.remainingScanBudget < cost { + return 0, 0, false, ErrScanBudgetExhausted + } + + return growth, cost, false, nil +} + +func (c *ChannelGuard) CommitSnapshot(snapshot []byte, growth int64, cost int64, terminalStage string) error { + c.highwater += growth + c.remainingScanBudget -= cost + + // 白名单终态类型快照消耗本类型配额 + if allowedTerminalTypes[terminalStage] { + c.consumedTerminalTypes[terminalStage] = true + c.terminalPermitsUsed++ + } + + // 终态首帧建立 baseline hash:仅白名单终态帧类型允许在首帧建立 baseline hash (Spec §5.3) + // 如果之前未建立 baseline 且当前属于白名单类型,在单次遍历中建立 baseline hash + shouldSetBaseline := !c.hasBaselineHash && allowedTerminalTypes[terminalStage] + + if cost > 0 && c.detector != nil { + c.detector.Reset() + + if shouldSetBaseline { + // 单次流水线处理:一次遍历同时送入 detector.Push 并同步更新 Hash + var h uint64 = 14695981039346656037 + for _, b := range snapshot { + h = (h ^ uint64(b)) * 1099511628211 + } + c.terminalBaselineHash = h + c.hasBaselineHash = true + } + + hit, _ := c.detector.Push(snapshot) + if hit { + return ErrRepetitionDetected + } + hit, _ = c.detector.CheckTail() + if hit { + return ErrRepetitionDetected + } + } + + return nil +} + +func (c *ChannelGuard) CheckTail() error { + if c.detector != nil { + if hit, _ := c.detector.CheckTail(); hit { + return ErrRepetitionDetected + } + } + return nil +} +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./security/outputguard -run "TestChannelGuard.*"` +Expected: PASS ok github.com/codex2api/security/outputguard + +- [ ] **Step 5: Commit** + +```bash +git add security/outputguard/channel.go security/outputguard/channel_test.go +git commit -m "feat(outputguard): implement channel guard with scan budget and terminal quota" +``` + +--- + +### Task 3: 请求级 RequestGuard 协调器与统一判定优先级及并发契约 + +**Files:** +- Create: `security/outputguard/guard.go` +- Test: `security/outputguard/guard_test.go` + +**Interfaces:** +- Consumes: `ChannelGuard` from Task 2 +- Produces: + ```go + package outputguard + + import "sync" + + const ( + MaxRequestEvents = 65536 + MaxRequestChannels = 128 + TriggeredCode = "model_output_guard_triggered" + ) + + type Config struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 + } + + type RequestGuard struct { + mu sync.RWMutex + cfg Config + requestEvents int + requestBytes int64 + channels map[string]*ChannelGuard + aliasMap map[string]string // alias mapping (e.g. index -> call_id) + tripped bool + trippedReason string + } + + func NewRequestGuard(cfg Config) *RequestGuard + func (g *RequestGuard) RecordEvent() error + func (g *RequestGuard) ObserveAppend(channelID string, aliasID string, kind ChannelKind, delta []byte) error + func (g *RequestGuard) ObserveSnapshot(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error + func (g *RequestGuard) ObserveAppendNoEvent(channelID string, aliasID string, kind ChannelKind, delta []byte) error + func (g *RequestGuard) ObserveSnapshotNoEvent(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error + func (g *RequestGuard) EndChannel(channelID string) error + func (g *RequestGuard) IsTripped() bool + func (g *RequestGuard) TrippedReason() string + ``` + +- [ ] **Step 1: Write failing test for RequestGuard (including 8-step precedence, first reason freeze, EndChannel tail check, and concurrency race)** + +```go +// security/outputguard/guard_test.go +package outputguard + +import ( + "bytes" + "fmt" + "sync" + "testing" +) + +func defaultTestConfig() Config { + return Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, // 128 KiB + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, // 2 MiB + MaxRequestBytes: 4194304, // 4 MiB + } +} + +func TestRequestGuardRequestEventsFlood(t *testing.T) { + rg := NewRequestGuard(defaultTestConfig()) + delta := []byte("a") + for i := 0; i < MaxRequestEvents; i++ { + if err := rg.ObserveAppend("c1", "", KindText, delta); err != nil { + t.Fatalf("unexpected error at event %d: %v", i, err) + } + } + // 第 65537 个事件必须被拒绝并判定为 request_events (Spec §5.3 步骤 1) + err := rg.ObserveAppend("c1", "", KindText, delta) + if err == nil || err.Error() != "request_events" { + t.Fatalf("expected request_events, got %v", err) + } + if !rg.IsTripped() || rg.TrippedReason() != "request_events" { + t.Fatalf("expected tripped with request_events, got %s", rg.TrippedReason()) + } +} + +func TestRequestGuardChannelFlood(t *testing.T) { + rg := NewRequestGuard(defaultTestConfig()) + // 验证累计 128 个 Channel 限制: + // 第二轮核实修正:消除未定义符号 'A',使用 fmt.Sprintf 或安全字符生成独立 channel ID + for i := 0; i < MaxRequestChannels; i++ { + cid := fmt.Sprintf("chan_%d", i) + if err := rg.ObserveAppend(cid, "", KindText, []byte("x")); err != nil { + t.Fatalf("channel %d error: %v", i, err) + } + } + // 第 129 个 Channel 必须被拒绝并判定为 channel_count (Spec §5.3 步骤 2) + err := rg.ObserveAppend("channel_129", "", KindText, []byte("x")) + if err == nil || err.Error() != "channel_count" { + t.Fatalf("expected channel_count, got %v", err) + } +} + +func TestRequestGuardChannelCumulativeNotReturnedOnEnd(t *testing.T) { + rg := NewRequestGuard(defaultTestConfig()) + // 验证 EndChannel 结束 channel 不返还累计 channel 配额 (Spec §5.1) + for i := 0; i < MaxRequestChannels; i++ { + cid := fmt.Sprintf("chan_%d", i) + _ = rg.ObserveAppend(cid, "", KindText, []byte("x")) + _ = rg.EndChannel(cid) + } + // 尝试再开一个 channel,必须被拒绝为 channel_count + err := rg.ObserveAppend("extra_channel", "", KindText, []byte("x")) + if err == nil || err.Error() != "channel_count" { + t.Fatalf("expected channel_count rejection after 128 closed channels, got %v", err) + } +} + +func TestRequestGuardEndChannelExecutesTailCheck(t *testing.T) { + rg := NewRequestGuard(defaultTestConfig()) + // 构造在 Push 时未触发检查点但流结束时已包含 8192 字节周期的流 + pattern := []byte("0123456789") + periodicBody := bytes.Repeat(pattern, 8192/10) + periodicBody = append(periodicBody, pattern[:8192%10]...) // 恰好 8192B 周期 + // 先推送 100 字节杂散 + prefix := make([]byte, 100) + for i := range prefix { + prefix[i] = byte(i) + } + full := append(prefix, periodicBody...) // 8292 字节,未达下一个 1024 检查点 + + // 推送数据,Push 期间不触发 repetition(8192 检查点含 prefix 杂散,8292 未到检查点) + for offset := 0; offset < len(full); offset += 500 { + end := offset + 500 + if end > len(full) { + end = len(full) + } + _ = rg.ObserveAppend("c_tail", "", KindText, full[offset:end]) + } + if rg.IsTripped() { + t.Fatalf("expected not tripped before EndChannel") + } + + // 调用 EndChannel:必须先执行尾检,发现周期性短重复,成功拦截并冻结 repetition! + err := rg.EndChannel("c_tail") + if err == nil || err.Error() != "repetition" { + t.Fatalf("expected repetition hit during EndChannel tail check, got %v", err) + } + if !rg.IsTripped() || rg.TrippedReason() != "repetition" { + t.Fatalf("expected tripped with repetition, got %s", rg.TrippedReason()) + } +} + +func TestRequestGuardRequestBytesPrecedenceOverScanBudget(t *testing.T) { + // Spec §5.3 严格 8 步优先级:先步骤 4 语义字节预算检查 (request_bytes),后步骤 6 扫描预算 + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 1000, // 仅 1000 字节请求上限 + }) + + // 一次传入 1500 字节快照 -> 必须报 request_bytes,而非 channel_bytes 或 scan_budget + err := rg.ObserveSnapshot("c1", "", KindText, make([]byte, 1500), "") + if err == nil || err.Error() != "request_bytes" { + t.Fatalf("expected request_bytes priority over scan budget, got %v", err) + } + // 首个 reason 必须冻结 + if rg.TrippedReason() != "request_bytes" { + t.Fatalf("expected frozen reason request_bytes, got %s", rg.TrippedReason()) + } + + // 再次调用仍然返回首个冻结 reason + err2 := rg.ObserveAppend("c1", "", KindText, []byte("x")) + if err2 == nil || err2.Error() != "request_bytes" { + t.Fatalf("expected idempotent frozen reason request_bytes, got %v", err2) + } +} + +func TestRequestGuardToolArgumentDeltasFlood(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 10, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + for i := 0; i < 10; i++ { + if err := rg.ObserveAppend("call_1", "", KindToolArguments, []byte("a")); err != nil { + t.Fatalf("delta %d error: %v", i, err) + } + } + // 第 11 个非空 delta 必须被拦截为 tool_argument_deltas (Spec §5.3 步骤 3) + err := rg.ObserveAppend("call_1", "", KindToolArguments, []byte("a")) + if err == nil || err.Error() != "tool_argument_deltas" { + t.Fatalf("expected tool_argument_deltas, got %v", err) + } +} + +func TestRequestGuardRecordEventAndNoEventBatching(t *testing.T) { + rg := NewRequestGuard(defaultTestConfig()) + + // 1. 测试独立 RecordEvent 仅对上游业务事件执行 1 次计数 + for i := 0; i < MaxRequestEvents; i++ { + if err := rg.RecordEvent(); err != nil { + t.Fatalf("unexpected error at event %d: %v", i, err) + } + } + // 第 65537 次 RecordEvent 触发 request_events 判定并冻结 + if err := rg.RecordEvent(); err == nil || err.Error() != "request_events" { + t.Fatalf("expected request_events, got %v", err) + } + if !rg.IsTripped() || rg.TrippedReason() != "request_events" { + t.Fatalf("expected tripped with request_events, got %s", rg.TrippedReason()) + } + + // 2. 测试同事件下多个 Observation 使用 ObserveAppendNoEvent / ObserveSnapshotNoEvent 不重复累加 requestEvents + rgFresh := NewRequestGuard(defaultTestConfig()) + // 记录 1 次上游业务事件 + if err := rgFresh.RecordEvent(); err != nil { + t.Fatalf("RecordEvent failed: %v", err) + } + // 单事件派生 3 个 delta append observation,调用 ObserveAppendNoEvent + for i := 0; i < 3; i++ { + cid := fmt.Sprintf("chan_%d", i) + if err := rgFresh.ObserveAppendNoEvent(cid, "", KindText, []byte("val")); err != nil { + t.Fatalf("ObserveAppendNoEvent observation %d failed: %v", i, err) + } + } + // 断言 requestEvents 依然仅为 1(未被 3 次 observation 累加为 4) + rgFresh.mu.RLock() + events := rgFresh.requestEvents + rgFresh.mu.RUnlock() + if events != 1 { + t.Fatalf("expected requestEvents=1 for batch observations under single event, got %d", events) + } +} + +func TestRequestGuardConcurrentAppendAndIsTrippedNoRace(t *testing.T) { + rg := NewRequestGuard(defaultTestConfig()) + var wg sync.WaitGroup + + // 并发写 + for i := 0; i < 10; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + cid := fmt.Sprintf("c_concurrent_%d", id%3) + for j := 0; j < 50; j++ { + _ = rg.ObserveAppend(cid, "", KindText, []byte("a")) + } + }(i) + } + + // 并发读状态 + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 50; j++ { + _ = rg.IsTripped() + _ = rg.TrippedReason() + } + }() + } + + wg.Wait() +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race ./security/outputguard -run "TestRequestGuard.*"` +Expected: FAIL with compilation error: undefined: NewRequestGuard + +- [ ] **Step 3: Write minimal implementation** + +```go +// security/outputguard/guard.go +package outputguard + +import ( + "errors" + "sync" +) + +const ( + MaxRequestEvents = 65536 + MaxRequestChannels = 128 + TriggeredCode = "model_output_guard_triggered" +) + +type Config struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 +} + +type RequestGuard struct { + mu sync.RWMutex + cfg Config + requestEvents int + requestBytes int64 + channels map[string]*ChannelGuard + aliasMap map[string]string + tripped bool + trippedReason string +} + +func NewRequestGuard(cfg Config) *RequestGuard { + return &RequestGuard{ + cfg: cfg, + channels: make(map[string]*ChannelGuard), + aliasMap: make(map[string]string), + } +} + +func (g *RequestGuard) IsTripped() bool { + g.mu.RLock() + defer g.mu.RUnlock() + return g.tripped +} + +func (g *RequestGuard) TrippedReason() string { + g.mu.RLock() + defer g.mu.RUnlock() + return g.trippedReason +} + +func (g *RequestGuard) tripLocked(reason string) error { + if !g.tripped { + g.tripped = true + g.trippedReason = reason + } + return errors.New(g.trippedReason) +} + +func (g *RequestGuard) resolveChannelLocked(channelID, aliasID string, kind ChannelKind) (*ChannelGuard, error) { + targetID := channelID + if targetID == "" && aliasID != "" { + if mapped, ok := g.aliasMap[aliasID]; ok { + targetID = mapped + } else { + targetID = aliasID + } + } + if targetID == "" { + targetID = "default" + } + if aliasID != "" && targetID != aliasID { + g.aliasMap[aliasID] = targetID + } + + cg, exists := g.channels[targetID] + if !exists { + // 步骤 2: Channel 数量检查 (Spec §5.3) + if len(g.channels)+1 > MaxRequestChannels { + return nil, g.tripLocked("channel_count") + } + maxBytes := g.cfg.MaxChannelBytes + if kind == KindToolArguments { + maxBytes = g.cfg.MaxToolArgumentBytes + } + cg = NewChannelGuard(targetID, kind, maxBytes, g.cfg.MaxToolArgumentDeltas) + g.channels[targetID] = cg + } + return cg, nil +} + +// RecordEvent 严格对每上游业务事件执行 1 次计数(步骤 1: request_events 检查) +func (g *RequestGuard) RecordEvent() error { + g.mu.Lock() + defer g.mu.Unlock() + + if !g.cfg.Enabled { + return nil + } + if g.tripped { + return errors.New(g.trippedReason) + } + + g.requestEvents++ + if g.requestEvents > MaxRequestEvents { + return g.tripLocked("request_events") + } + return nil +} + +// ObserveAppend 严格执行 Spec §5.3 优先级流水线(先步骤 1 事件计数,再步骤 2~7 判定,首 reason 冻结) +func (g *RequestGuard) ObserveAppend(channelID string, aliasID string, kind ChannelKind, delta []byte) error { + g.mu.Lock() + defer g.mu.Unlock() + + if !g.cfg.Enabled { + return nil + } + if g.tripped { + return errors.New(g.trippedReason) + } + + // 步骤 1: 事件预算检查 + g.requestEvents++ + if g.requestEvents > MaxRequestEvents { + return g.tripLocked("request_events") + } + + return g.observeAppendLocked(channelID, aliasID, kind, delta) +} + +// ObserveAppendNoEvent 仅执行步骤 2~7 判定,不重复累加 requestEvents(供 ObserveEvent 批处理调用) +func (g *RequestGuard) ObserveAppendNoEvent(channelID string, aliasID string, kind ChannelKind, delta []byte) error { + g.mu.Lock() + defer g.mu.Unlock() + + if !g.cfg.Enabled { + return nil + } + if g.tripped { + return errors.New(g.trippedReason) + } + + return g.observeAppendLocked(channelID, aliasID, kind, delta) +} + +func (g *RequestGuard) observeAppendLocked(channelID string, aliasID string, kind ChannelKind, delta []byte) error { + // 步骤 2: Channel 检查 + cg, err := g.resolveChannelLocked(channelID, aliasID, kind) + if err != nil { + return err + } + + // 步骤 3 & 4: Delta 计数与语义字节高水位检查 + growth, cost, err := cg.CheckAppend(delta) + if err != nil { + if errors.Is(err, ErrScanBudgetExhausted) { + if cg.kind == KindToolArguments { + return g.tripLocked("tool_argument_bytes") + } + return g.tripLocked("channel_bytes") + } + return g.tripLocked(err.Error()) + } + + // 步骤 4: 请求级语义字节检查(必须先于步骤 6 扫描预算判定) + if g.requestBytes+growth > g.cfg.MaxRequestBytes { + return g.tripLocked("request_bytes") + } + + // 步骤 5, 6, 7: 提交高水位并执行周期扫描 + g.requestBytes += growth + if err := cg.CommitAppend(delta, growth, cost); err != nil { + return g.tripLocked(err.Error()) + } + + return nil +} + +// ObserveSnapshot 严格执行 Spec §5.3 快照判定流水线(先步骤 1 事件计数,再步骤 2~7 判定) +func (g *RequestGuard) ObserveSnapshot(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error { + g.mu.Lock() + defer g.mu.Unlock() + + if !g.cfg.Enabled { + return nil + } + if g.tripped { + return errors.New(g.trippedReason) + } + + // 步骤 1: 事件预算检查 + g.requestEvents++ + if g.requestEvents > MaxRequestEvents { + return g.tripLocked("request_events") + } + + return g.observeSnapshotLocked(channelID, aliasID, kind, snapshot, terminalStage) +} + +// ObserveSnapshotNoEvent 仅执行步骤 2~7 判定,不重复累加 requestEvents(供 ObserveEvent 批处理调用) +func (g *RequestGuard) ObserveSnapshotNoEvent(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error { + g.mu.Lock() + defer g.mu.Unlock() + + if !g.cfg.Enabled { + return nil + } + if g.tripped { + return errors.New(g.trippedReason) + } + + return g.observeSnapshotLocked(channelID, aliasID, kind, snapshot, terminalStage) +} + +func (g *RequestGuard) observeSnapshotLocked(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error { + // 步骤 2: Channel 检查 + cg, err := g.resolveChannelLocked(channelID, aliasID, kind) + if err != nil { + return err + } + + // 步骤 4: 语义字节预算检查 (先检查高水位推进与 request_bytes 上限) + snapLen := int64(len(snapshot)) + var growth int64 + if snapLen > cg.highwater { + growth = snapLen - cg.highwater + } + if snapLen > cg.maxBytes { + if cg.kind == KindToolArguments { + return g.tripLocked("tool_argument_bytes") + } + return g.tripLocked("channel_bytes") + } + if g.requestBytes+growth > g.cfg.MaxRequestBytes { + return g.tripLocked("request_bytes") + } + + // 步骤 5 & 6: 资格/等价性检查与扫描预算扣减 + growth, cost, _, err := cg.CheckSnapshot(snapshot, terminalStage) + if err != nil { + if errors.Is(err, ErrScanBudgetExhausted) { + if cg.kind == KindToolArguments { + return g.tripLocked("tool_argument_bytes") + } + return g.tripLocked("channel_bytes") + } + return g.tripLocked(err.Error()) + } + + // 步骤 7: 提交高水位与扫描 + g.requestBytes += growth + if err := cg.CommitSnapshot(snapshot, growth, cost, terminalStage); err != nil { + return g.tripLocked(err.Error()) + } + + return nil +} + +// EndChannel 在关闭 channel 前执行尾检,释放窗口内存,累计 channel 映射不返还 +func (g *RequestGuard) EndChannel(channelID string) error { + g.mu.Lock() + defer g.mu.Unlock() + + if cg, exists := g.channels[channelID]; exists { + // 先执行尾检 (Spec §5.2 规则 6) + if err := cg.CheckTail(); err != nil { + return g.tripLocked(err.Error()) + } + // 尾检通过后安全释放滑动窗口内存 (<=64KiB) + cg.ReleaseWindow() + } + return nil +} +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race ./security/outputguard -run "TestRequestGuard.*"` +Expected: PASS ok github.com/codex2api/security/outputguard + +- [ ] **Step 5: Commit** + +```bash +git add security/outputguard/guard.go security/outputguard/guard_test.go +git commit -m "feat(outputguard): implement request guard coordinator with 8-step precedence and RWMutex" +``` + +--- + +### Task 4: 数据库表结构迁移与三态指针持久化(SQLite & PostgreSQL) + +**Files:** +- Create: `database/output_guard_settings.go` +- Modify: `database/sqlite.go:730-740` +- Modify: `database/postgres.go:1600-1610,3230-3240` +- Test: `database/output_guard_settings_test.go` + +**Interfaces:** +- Produces: + ```go + package database + + type OutputGuardSettings struct { + Enabled *bool `json:"output_guard_enabled"` + MaxToolArgumentBytes int64 `json:"output_guard_max_tool_argument_bytes"` + MaxToolArgumentDeltas int `json:"output_guard_max_tool_argument_deltas"` + MaxChannelBytes int64 `json:"output_guard_max_channel_bytes"` + MaxRequestBytes int64 `json:"output_guard_max_request_bytes"` + ConfigGeneration int64 `json:"output_guard_config_generation"` + } + + type OutputGuardSettingsUpdate struct { + Enabled *bool + MaxToolArgumentBytes *int64 + MaxToolArgumentDeltas *int + MaxChannelBytes *int64 + MaxRequestBytes *int64 + } + + func (db *DB) GetOutputGuardSettings(ctx context.Context) (OutputGuardSettings, error) + func (db *DB) UpdateOutputGuardSettings(ctx context.Context, update OutputGuardSettingsUpdate) (OutputGuardSettings, error) + ``` + +- [ ] **Step 1: Write failing test for OutputGuardSettings migration, tri-state persistence, generation overflow protection, and narrow update isolation** + +```go +// database/output_guard_settings_test.go +package database + +import ( + "context" + "errors" + "math" + "os" + "sync" + "testing" +) + +func runOnAllDBDrivers(t *testing.T, testFn func(t *testing.T, db *DB)) { + t.Run("sqlite", func(t *testing.T) { + db := newResponseCacheSettingsTestDB(t) + testFn(t, db) + }) + + if dsn := os.Getenv("CODEX2API_TEST_POSTGRES_DSN"); dsn != "" { + t.Run("postgres", func(t *testing.T) { + db, err := New("postgres", dsn) + if err != nil { + t.Fatalf("New(postgres) error = %v", err) + } + defer db.Close() + testFn(t, db) + }) + } +} + +func TestOutputGuardSettingsFreshDefaults(t *testing.T) { + runOnAllDBDrivers(t, func(t *testing.T, db *DB) { + got, err := db.GetOutputGuardSettings(context.Background()) + if err != nil { + t.Fatalf("GetOutputGuardSettings error = %v", err) + } + + if got.Enabled == nil || *got.Enabled != true { + t.Fatalf("default Enabled want true, got %v", got.Enabled) + } + if got.MaxToolArgumentBytes != 131072 { + t.Fatalf("default MaxToolArgumentBytes want 131072, got %d", got.MaxToolArgumentBytes) + } + if got.MaxToolArgumentDeltas != 4096 { + t.Fatalf("default MaxToolArgumentDeltas want 4096, got %d", got.MaxToolArgumentDeltas) + } + if got.MaxChannelBytes != 2097152 { + t.Fatalf("default MaxChannelBytes want 2097152, got %d", got.MaxChannelBytes) + } + if got.MaxRequestBytes != 4194304 { + t.Fatalf("default MaxRequestBytes want 4194304, got %d", got.MaxRequestBytes) + } + if got.ConfigGeneration < 1 { + t.Fatalf("default ConfigGeneration want >= 1, got %d", got.ConfigGeneration) + } + }) +} + +func TestOutputGuardSettingsExplicitFalseTriState(t *testing.T) { + runOnAllDBDrivers(t, func(t *testing.T, db *DB) { + ctx := context.Background() + + disabled := false + updated, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + Enabled: &disabled, + }) + if err != nil { + t.Fatalf("UpdateOutputGuardSettings error = %v", err) + } + if updated.Enabled == nil || *updated.Enabled != false { + t.Fatalf("expected updated Enabled=false, got %v", updated.Enabled) + } + + // 重新读取断言显式 false 得到正确保留(不被默认 true 覆盖) + readBack, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("readBack error = %v", err) + } + if readBack.Enabled == nil || *readBack.Enabled != false { + t.Fatalf("expected readBack Enabled=false, got %v", readBack.Enabled) + } + }) +} + +func TestOutputGuardSettingsGenerationOverflow(t *testing.T) { + runOnAllDBDrivers(t, func(t *testing.T, db *DB) { + ctx := context.Background() + // 人为将 generation 置为 math.MaxInt64 + if _, err := db.conn.ExecContext(ctx, ` + INSERT INTO system_settings (id, output_guard_config_generation) + VALUES (1, $1) + ON CONFLICT (id) DO UPDATE SET output_guard_config_generation = $1 + `, int64(math.MaxInt64)); err != nil { + t.Fatalf("seed max generation: %v", err) + } + + toolBytes := int64(65536) + _, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + MaxToolArgumentBytes: &toolBytes, + }) + if !errors.Is(err, ErrOutputGuardGenerationOverflow) { + t.Fatalf("expected ErrOutputGuardGenerationOverflow, got %v", err) + } + }) +} + +func TestOutputGuardSettingsNarrowUpdateNotOverwrittenBySystemSettings(t *testing.T) { + runOnAllDBDrivers(t, func(t *testing.T, db *DB) { + ctx := context.Background() + toolBytes := int64(262144) // 256 KiB + disabled := false + committed, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + Enabled: &disabled, + MaxToolArgumentBytes: &toolBytes, + }) + if err != nil { + t.Fatalf("UpdateOutputGuardSettings error = %v", err) + } + + // 模拟管理员保存常规设置(老 SystemSettings 全量写) + if err := db.UpdateSystemSettings(ctx, &SystemSettings{SiteName: "stale full update"}); err != nil { + t.Fatalf("UpdateSystemSettings error = %v", err) + } + + // 重新读取 OutputGuardSettings,断言窄更新字段绝不被全量写覆盖 + got, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("GetOutputGuardSettings error = %v", err) + } + if got.Enabled == nil || *got.Enabled != false { + t.Fatalf("Enabled overwritten by UpdateSystemSettings: got %v, want false", got.Enabled) + } + if got.MaxToolArgumentBytes != committed.MaxToolArgumentBytes { + t.Fatalf("MaxToolArgumentBytes overwritten: got %d, want %d", got.MaxToolArgumentBytes, committed.MaxToolArgumentBytes) + } + }) +} + +func TestOutputGuardSettingsConcurrentUpdatesNoDeadlock(t *testing.T) { + runOnAllDBDrivers(t, func(t *testing.T, db *DB) { + ctx := context.Background() + var wg sync.WaitGroup + errCh := make(chan error, 10) + + for i := 0; i < 10; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + toolBytes := int64(131072 + idx*1024) + channelBytes := int64(2097152 + idx*1024) + requestBytes := int64(4194304 + idx*1024) + _, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + MaxToolArgumentBytes: &toolBytes, + MaxChannelBytes: &channelBytes, + MaxRequestBytes: &requestBytes, + }) + if err != nil { + errCh <- err + } + }(i) + } + + wg.Wait() + close(errCh) + for err := range errCh { + t.Fatalf("concurrent update failed: %v", err) + } + }) +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./database -run "TestOutputGuardSettings.*"` +Expected: FAIL with compilation error: undefined: db.GetOutputGuardSettings + +- [ ] **Step 3: Write minimal implementation** + +修改 `database/sqlite.go` 添加列: +```go +// 在 database/sqlite.go 中的 columns 切片中添加: +{"system_settings", "output_guard_enabled", "INTEGER DEFAULT 1"}, +{"system_settings", "output_guard_max_tool_argument_bytes", "INTEGER NOT NULL DEFAULT 131072"}, +{"system_settings", "output_guard_max_tool_argument_deltas", "INTEGER NOT NULL DEFAULT 4096"}, +{"system_settings", "output_guard_max_channel_bytes", "INTEGER NOT NULL DEFAULT 2097152"}, +{"system_settings", "output_guard_max_request_bytes", "INTEGER NOT NULL DEFAULT 4194304"}, +{"system_settings", "output_guard_config_generation", "INTEGER NOT NULL DEFAULT 1"}, +``` + +修改 `database/postgres.go` 添加列及隔离 UpdateSystemSettings: +```go +// 在 database/postgres.go initPostgresSchema 中添加: +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_enabled BOOLEAN DEFAULT TRUE; +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_max_tool_argument_bytes BIGINT NOT NULL DEFAULT 131072; +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_max_tool_argument_deltas INT NOT NULL DEFAULT 4096; +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_max_channel_bytes BIGINT NOT NULL DEFAULT 2097152; +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_max_request_bytes BIGINT NOT NULL DEFAULT 4194304; +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_config_generation BIGINT NOT NULL DEFAULT 1; + +// 在 database/postgres.go UpdateSystemSettings 的 ON CONFLICT (id) DO UPDATE SET 中: +// 确保不包含 output_guard_* 字段,使该 6 个字段完全由独立的窄更新函数单独维护,不被老全局设置全量写回覆盖! +``` + +新建 `database/output_guard_settings.go`: +```go +package database + +import ( + "context" + "database/sql" + "errors" + "fmt" + "math" +) + +var ( + ErrInvalidOutputGuardSettings = errors.New("invalid output guard settings") + ErrOutputGuardGenerationOverflow = errors.New("output guard config generation overflow") +) + +type OutputGuardSettings struct { + Enabled *bool `json:"output_guard_enabled"` + MaxToolArgumentBytes int64 `json:"output_guard_max_tool_argument_bytes"` + MaxToolArgumentDeltas int `json:"output_guard_max_tool_argument_deltas"` + MaxChannelBytes int64 `json:"output_guard_max_channel_bytes"` + MaxRequestBytes int64 `json:"output_guard_max_request_bytes"` + ConfigGeneration int64 `json:"output_guard_config_generation"` +} + +type OutputGuardSettingsUpdate struct { + Enabled *bool + MaxToolArgumentBytes *int64 + MaxToolArgumentDeltas *int + MaxChannelBytes *int64 + MaxRequestBytes *int64 +} + +func DefaultOutputGuardSettings() OutputGuardSettings { + t := true + return OutputGuardSettings{ + Enabled: &t, + MaxToolArgumentBytes: 131072, // 128 KiB + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, // 2 MiB + MaxRequestBytes: 4194304, // 4 MiB + ConfigGeneration: 1, + } +} + +func ValidateOutputGuardSettings(s OutputGuardSettings) error { + if s.MaxToolArgumentBytes < 16384 || s.MaxToolArgumentBytes > 4194304 { + return fmt.Errorf("%w: max_tool_argument_bytes must be 16KiB..4MiB", ErrInvalidOutputGuardSettings) + } + if s.MaxToolArgumentDeltas < 256 || s.MaxToolArgumentDeltas > 65536 { + return fmt.Errorf("%w: max_tool_argument_deltas must be 256..65536", ErrInvalidOutputGuardSettings) + } + if s.MaxChannelBytes < 131072 || s.MaxChannelBytes > 16777216 { + return fmt.Errorf("%w: max_channel_bytes must be 128KiB..16MiB", ErrInvalidOutputGuardSettings) + } + if s.MaxRequestBytes < 262144 || s.MaxRequestBytes > 67108864 { + return fmt.Errorf("%w: max_request_bytes must be 256KiB..64MiB", ErrInvalidOutputGuardSettings) + } + if s.MaxToolArgumentBytes > s.MaxChannelBytes || s.MaxChannelBytes > s.MaxRequestBytes { + return fmt.Errorf("%w: requires tool <= channel <= request bytes", ErrInvalidOutputGuardSettings) + } + return nil +} + +func (db *DB) outputGuardSettingsSelectQuery(forUpdate bool) string { + query := `SELECT output_guard_enabled, + COALESCE(output_guard_max_tool_argument_bytes, 131072), + COALESCE(output_guard_max_tool_argument_deltas, 4096), + COALESCE(output_guard_max_channel_bytes, 2097152), + COALESCE(output_guard_max_request_bytes, 4194304), + COALESCE(output_guard_config_generation, 1) + FROM system_settings WHERE id = 1` + if forUpdate && db.driver == "postgres" { + query += " FOR UPDATE" + } + return query +} + +func (db *DB) GetOutputGuardSettings(ctx context.Context) (OutputGuardSettings, error) { + settings := DefaultOutputGuardSettings() + var enabled sql.NullBool + err := db.conn.QueryRowContext(ctx, db.outputGuardSettingsSelectQuery(false)).Scan( + &enabled, + &settings.MaxToolArgumentBytes, + &settings.MaxToolArgumentDeltas, + &settings.MaxChannelBytes, + &settings.MaxRequestBytes, + &settings.ConfigGeneration, + ) + if errors.Is(err, sql.ErrNoRows) { + return settings, nil + } + if err != nil { + return OutputGuardSettings{}, err + } + if enabled.Valid { + val := enabled.Bool + settings.Enabled = &val + } + return settings, nil +} + +func (db *DB) UpdateOutputGuardSettings(ctx context.Context, update OutputGuardSettingsUpdate) (OutputGuardSettings, error) { + var committed OutputGuardSettings + + operation := func() error { + tx, err := db.conn.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + // 确保 id=1 存在 + if _, err := tx.ExecContext(ctx, `INSERT INTO system_settings (id) VALUES (1) ON CONFLICT(id) DO NOTHING`); err != nil { + return err + } + + current := DefaultOutputGuardSettings() + var enabled sql.NullBool + err = tx.QueryRowContext(ctx, db.outputGuardSettingsSelectQuery(true)).Scan( + &enabled, + ¤t.MaxToolArgumentBytes, + ¤t.MaxToolArgumentDeltas, + ¤t.MaxChannelBytes, + ¤t.MaxRequestBytes, + ¤t.ConfigGeneration, + ) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if enabled.Valid { + val := enabled.Bool + current.Enabled = &val + } + + next := current + if update.Enabled != nil { + val := *update.Enabled + next.Enabled = &val + } + if update.MaxToolArgumentBytes != nil { + next.MaxToolArgumentBytes = *update.MaxToolArgumentBytes + } + if update.MaxToolArgumentDeltas != nil { + next.MaxToolArgumentDeltas = *update.MaxToolArgumentDeltas + } + if update.MaxChannelBytes != nil { + next.MaxChannelBytes = *update.MaxChannelBytes + } + if update.MaxRequestBytes != nil { + next.MaxRequestBytes = *update.MaxRequestBytes + } + + // Generation 溢出保护 + if current.ConfigGeneration == math.MaxInt64 { + return ErrOutputGuardGenerationOverflow + } + next.ConfigGeneration++ + + if err := ValidateOutputGuardSettings(next); err != nil { + return err + } + + updateQuery := `UPDATE system_settings SET + output_guard_enabled = $1, + output_guard_max_tool_argument_bytes = $2, + output_guard_max_tool_argument_deltas = $3, + output_guard_max_channel_bytes = $4, + output_guard_max_request_bytes = $5, + output_guard_config_generation = $6 + WHERE id = 1` + + var enabledVal any + if next.Enabled != nil { + enabledVal = *next.Enabled + } else { + enabledVal = true + } + + if _, err := tx.ExecContext(ctx, updateQuery, + enabledVal, + next.MaxToolArgumentBytes, + next.MaxToolArgumentDeltas, + next.MaxChannelBytes, + next.MaxRequestBytes, + next.ConfigGeneration, + ); err != nil { + return err + } + + if err := tx.Commit(); err != nil { + return err + } + committed = next + return nil + } + + if db.isSQLite() { + if err := db.withSQLiteWriteLock(ctx, operation); err != nil { + return OutputGuardSettings{}, err + } + } else { + if err := operation(); err != nil { + return OutputGuardSettings{}, err + } + } + + return committed, nil +} +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./database -run "TestOutputGuardSettings.*"` +Expected: PASS ok github.com/codex2api/database + +- [ ] **Step 5: Commit** + +```bash +git add database/output_guard_settings.go database/output_guard_settings_test.go database/sqlite.go database/postgres.go +git commit -m "feat(database): add output guard settings table migration and tri-state persistence" +``` + +--- + +### Task 5: 运行时不可变配置快照、5 秒后台 Revision 轮询器与冷启动就绪门禁 + +**Files:** +- Create: `proxy/output_guard_settings.go` +- Create: `proxy/output_guard_settings_test.go` +- Modify: `main.go:265-270` + +**Interfaces:** +- Consumes: + - `database.OutputGuardSettings`: + ```go + type OutputGuardSettings struct { + Enabled *bool `json:"output_guard_enabled"` + MaxToolArgumentBytes int64 `json:"output_guard_max_tool_argument_bytes"` + MaxToolArgumentDeltas int `json:"output_guard_max_tool_argument_deltas"` + MaxChannelBytes int64 `json:"output_guard_max_channel_bytes"` + MaxRequestBytes int64 `json:"output_guard_max_request_bytes"` + ConfigGeneration int64 `json:"output_guard_config_generation"` + } + ``` + - `database.DefaultOutputGuardSettings() database.OutputGuardSettings` + - `database.ValidateOutputGuardSettings(database.OutputGuardSettings) error` + - `database.DB.GetOutputGuardSettings(context.Context) (database.OutputGuardSettings, error)` +- Produces: + ```go + package proxy + + import ( + "context" + "time" + "github.com/codex2api/database" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + ) + + type OutputGuardAppliedConfig struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 + Generation int64 + } + + type OutputGuardConfigSyncStatus struct { + LastSuccessfulSyncAt time.Time + LastSyncError string + } + + type OutputGuardSettingsReader interface { + GetOutputGuardSettings(context.Context) (database.OutputGuardSettings, error) + } + + func ApplyOutputGuardSettings(settings database.OutputGuardSettings) bool + func CurrentOutputGuardConfig() OutputGuardAppliedConfig + func GetOutputGuardConfigSyncStatus() OutputGuardConfigSyncStatus + func IsOutputGuardReady() bool + func SetOutputGuardReadyForTest(ready bool) + func ResetOutputGuardStateForTest() + func LoadOutputGuardSettings(ctx context.Context, reader OutputGuardSettingsReader) error + func StartOutputGuardConfigPoller(parent context.Context, db *database.DB) bool + + // 门禁检查接口(供 Task 7/8 各协议与 WebSocket/Realtime 挂接) + func RequireOutputGuardReadyHTTP(c *gin.Context) bool + func RequireOutputGuardReadyWS(conn *websocket.Conn) bool + func RequireOutputGuardReadyStream(conn *websocket.Conn, streamID string) bool + ``` + +- [ ] **Step 1: Write failing test for proxy output guard runtime settings, poller, LKG, and readiness gates** + +```go +// proxy/output_guard_settings_test.go +package proxy + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/codex2api/api" + "github.com/codex2api/database" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +type controlledOutputGuardSettingsReader struct { + mu sync.Mutex + requests chan chan outputGuardSettingsReadResult +} + +type outputGuardSettingsReadResult struct { + settings database.OutputGuardSettings + err error +} + +func newControlledOutputGuardSettingsReader() *controlledOutputGuardSettingsReader { + return &controlledOutputGuardSettingsReader{ + requests: make(chan chan outputGuardSettingsReadResult, 16), + } +} + +func (r *controlledOutputGuardSettingsReader) GetOutputGuardSettings(ctx context.Context) (database.OutputGuardSettings, error) { + respCh := make(chan outputGuardSettingsReadResult, 1) + select { + case <-ctx.Done(): + return database.OutputGuardSettings{}, ctx.Err() + case r.requests <- respCh: + } + + select { + case <-ctx.Done(): + return database.OutputGuardSettings{}, ctx.Err() + case res := <-respCh: + return res.settings, res.err + } +} + +func (r *controlledOutputGuardSettingsReader) respond(t *testing.T, res outputGuardSettingsReadResult) { + t.Helper() + select { + case respCh := <-r.requests: + respCh <- res + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for GetOutputGuardSettings request") + } +} + +func waitForOutputGuardConfigGeneration(t *testing.T, targetGen int64) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if CurrentOutputGuardConfig().Generation == targetGen { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("timed out waiting for output guard config generation %d (current: %d)", targetGen, CurrentOutputGuardConfig().Generation) +} + +func waitForOutputGuardSyncError(t *testing.T, substr string) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + status := GetOutputGuardConfigSyncStatus() + if status.LastSyncError != "" && (substr == "" || errors.New(status.LastSyncError) != nil) { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("timed out waiting for output guard sync error containing %q", substr) +} + +func TestOutputGuardColdStartNotReadyReturns503(t *testing.T) { + ResetOutputGuardStateForTest() + t.Cleanup(ResetOutputGuardStateForTest) + + if IsOutputGuardReady() { + t.Fatal("expected cold start IsOutputGuardReady = false") + } + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + passed := RequireOutputGuardReadyHTTP(c) + if passed { + t.Fatal("expected RequireOutputGuardReadyHTTP to reject cold start request") + } + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("expected HTTP 503, got %d", w.Code) + } + var errResp struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + Type string `json:"type"` + } `json:"error"` + } + if err := json.Unmarshal(w.Body.Bytes(), &errResp); err != nil { + t.Fatalf("unmarshal error response failed: %v", err) + } + if errResp.Error.Code != string(api.ErrCodeServiceUnavailable) { + t.Fatalf("expected error code %q, got %q", api.ErrCodeServiceUnavailable, errResp.Error.Code) + } + if errResp.Error.Code == "model_output_guard_triggered" { + t.Fatalf("must NOT use runtime tripped error code on cold start 503") + } +} + +func TestOutputGuardExplicitDisabledSemanticVsColdStart(t *testing.T) { + ResetOutputGuardStateForTest() + t.Cleanup(ResetOutputGuardStateForTest) + + // Cold start: not ready + if IsOutputGuardReady() { + t.Fatal("expected cold start not ready") + } + + // Load DB settings with Enabled = false + disabled := false + dbSettings := database.OutputGuardSettings{ + Enabled: &disabled, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + ConfigGeneration: 1, + } + + applied := ApplyOutputGuardSettings(dbSettings) + if !applied { + t.Fatal("ApplyOutputGuardSettings failed") + } + + // Once loaded successfully, IsOutputGuardReady is true even when Enabled = false! + if !IsOutputGuardReady() { + t.Fatal("expected IsOutputGuardReady = true after explicit disabled settings loaded") + } + + cfg := CurrentOutputGuardConfig() + if cfg.Enabled != false { + t.Fatalf("expected applied config Enabled = false, got %v", cfg.Enabled) + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + passed := RequireOutputGuardReadyHTTP(c) + if !passed { + t.Fatal("expected RequireOutputGuardReadyHTTP to pass when system is ready and explicitly disabled") + } + if w.Code == http.StatusServiceUnavailable { + t.Fatalf("did not expect 503 when explicitly disabled and ready") + } +} + +func TestOutputGuardConfigGenerationIncrementsAndLKGMaintained(t *testing.T) { + ResetOutputGuardStateForTest() + t.Cleanup(ResetOutputGuardStateForTest) + + enabled := true + initial := database.OutputGuardSettings{ + Enabled: &enabled, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + ConfigGeneration: 1, + } + if !ApplyOutputGuardSettings(initial) { + t.Fatal("failed to apply initial generation 1") + } + + cfg1 := CurrentOutputGuardConfig() + if cfg1.Generation != 1 || cfg1.MaxToolArgumentBytes != 131072 { + t.Fatalf("initial config mismatch: %+v", cfg1) + } + + // Stale generation rejected + stale := initial + stale.MaxToolArgumentBytes = 262144 + if ApplyOutputGuardSettings(stale) { + t.Fatal("stale generation 1 should be rejected") + } + if CurrentOutputGuardConfig().MaxToolArgumentBytes != 131072 { + t.Fatal("LKG overwritten by stale generation") + } + + // Apply generation 2 + gen2 := initial + gen2.MaxToolArgumentBytes = 262144 + gen2.ConfigGeneration = 2 + if !ApplyOutputGuardSettings(gen2) { + t.Fatal("failed to apply generation 2") + } + cfg2 := CurrentOutputGuardConfig() + if cfg2.Generation != 2 || cfg2.MaxToolArgumentBytes != 262144 { + t.Fatalf("generation 2 mismatch: %+v", cfg2) + } + + // Illegal configuration validation failure preserves LKG + illegal := initial + illegal.MaxToolArgumentBytes = 50000000 // exceeds 4MiB + illegal.ConfigGeneration = 3 + if ApplyOutputGuardSettings(illegal) { + t.Fatal("illegal configuration should be rejected") + } + cfgPreserved := CurrentOutputGuardConfig() + if cfgPreserved.Generation != 2 || cfgPreserved.MaxToolArgumentBytes != 262144 { + t.Fatalf("LKG not preserved after illegal config: %+v", cfgPreserved) + } +} + +func TestOutputGuardPollerLifecycleRecoveryAndStop(t *testing.T) { + ResetOutputGuardStateForTest() + t.Cleanup(ResetOutputGuardStateForTest) + + reader := newControlledOutputGuardSettingsReader() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan struct{}) + go func() { + defer close(done) + runOutputGuardConfigPoller(ctx, reader, 5*time.Millisecond, 100*time.Millisecond) + }() + + enabled := true + // 1. Initial read + reader.respond(t, outputGuardSettingsReadResult{ + settings: database.OutputGuardSettings{ + Enabled: &enabled, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + ConfigGeneration: 2, + }, + }) + waitForOutputGuardConfigGeneration(t, 2) + if !IsOutputGuardReady() { + t.Fatal("expected IsOutputGuardReady = true after successful poll") + } + + // 2. Outage: DB error preserves LKG + reader.respond(t, outputGuardSettingsReadResult{ + err: errors.New("simulated database disconnect"), + }) + waitForOutputGuardSyncError(t, "simulated database disconnect") + if CurrentOutputGuardConfig().Generation != 2 { + t.Fatal("LKG generation lost during DB outage") + } + + // 3. Recovery: next poll succeeds with generation 3 + reader.respond(t, outputGuardSettingsReadResult{ + settings: database.OutputGuardSettings{ + Enabled: &enabled, + MaxToolArgumentBytes: 262144, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + ConfigGeneration: 3, + }, + }) + waitForOutputGuardConfigGeneration(t, 3) + if CurrentOutputGuardConfig().MaxToolArgumentBytes != 262144 { + t.Fatal("failed to update config after recovery") + } + + // 4. Cancel ctx and verify poller stops + cancel() + select { + case <-done: + case <-time.After(500 * time.Millisecond): + t.Fatal("poller failed to stop cleanly after context cancel") + } +} + +func TestOutputGuardWSReadinessGate(t *testing.T) { + ResetOutputGuardStateForTest() + t.Cleanup(ResetOutputGuardStateForTest) + + // Create test WebSocket server + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + + if !RequireOutputGuardReadyWS(conn) { + return + } + _ = conn.WriteMessage(websocket.TextMessage, []byte("ready")) + })) + defer server.Close() + + wsURL := "ws" + server.URL[len("http"):] + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial WS failed: %v", err) + } + defer conn.Close() + + // When not ready, server should send 503 error frame / CloseTryAgainLater (1013) + _, msg, err := conn.ReadMessage() + if err != nil { + var closeErr *websocket.CloseError + if errors.As(err, &closeErr) { + if closeErr.Code != websocket.CloseTryAgainLater { + t.Fatalf("expected close code 1013 CloseTryAgainLater, got %d", closeErr.Code) + } + return + } + } + if len(msg) > 0 { + var errResp struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + _ = json.Unmarshal(msg, &errResp) + if errResp.Error.Code != string(api.ErrCodeServiceUnavailable) { + t.Fatalf("expected error frame with code %s, got %s", api.ErrCodeServiceUnavailable, errResp.Error.Code) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestOutputGuard.*"` +Expected: FAIL with "undefined: ApplyOutputGuardSettings", "undefined: RequireOutputGuardReadyHTTP", etc. + +- [ ] **Step 3: Write minimal implementation for proxy/output_guard_settings.go and wire in main.go** + +```go +// proxy/output_guard_settings.go +package proxy + +import ( + "context" + "fmt" + "log" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/codex2api/api" + "github.com/codex2api/database" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +const ( + outputGuardConfigPollInterval = 5 * time.Second + outputGuardConfigReadTimeout = 3 * time.Second +) + +type OutputGuardAppliedConfig struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 + Generation int64 +} + +type OutputGuardConfigSyncStatus struct { + LastSuccessfulSyncAt time.Time + LastSyncError string +} + +type OutputGuardSettingsReader interface { + GetOutputGuardSettings(context.Context) (database.OutputGuardSettings, error) +} + +type outputGuardState struct { + mu sync.RWMutex + config OutputGuardAppliedConfig + lastSyncAt time.Time + lastSyncErr string + isReady atomic.Bool +} + +var globalOutputGuardState = &outputGuardState{} + +func init() { + ResetOutputGuardStateForTest() +} + +func ResetOutputGuardStateForTest() { + globalOutputGuardState.mu.Lock() + defer globalOutputGuardState.mu.Unlock() + globalOutputGuardState.config = OutputGuardAppliedConfig{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + Generation: 0, + } + globalOutputGuardState.lastSyncAt = time.Time{} + globalOutputGuardState.lastSyncErr = "" + globalOutputGuardState.isReady.Store(false) +} + +func SetOutputGuardReadyForTest(ready bool) { + globalOutputGuardState.isReady.Store(ready) +} + +func IsOutputGuardReady() bool { + return globalOutputGuardState.isReady.Load() +} + +func CurrentOutputGuardConfig() OutputGuardAppliedConfig { + globalOutputGuardState.mu.RLock() + defer globalOutputGuardState.mu.RUnlock() + return globalOutputGuardState.config +} + +func GetOutputGuardConfigSyncStatus() OutputGuardConfigSyncStatus { + globalOutputGuardState.mu.RLock() + defer globalOutputGuardState.mu.RUnlock() + return OutputGuardConfigSyncStatus{ + LastSuccessfulSyncAt: globalOutputGuardState.lastSyncAt, + LastSyncError: globalOutputGuardState.lastSyncErr, + } +} + +func ApplyOutputGuardSettings(settings database.OutputGuardSettings) bool { + if err := database.ValidateOutputGuardSettings(settings); err != nil { + log.Printf("[output-guard] 非法配置已被拒绝,保留 LKG: %v", err) + recordOutputGuardConfigSyncError(err) + return false + } + + globalOutputGuardState.mu.Lock() + defer globalOutputGuardState.mu.Unlock() + + if settings.ConfigGeneration <= globalOutputGuardState.config.Generation { + return false + } + + enabledVal := true + if settings.Enabled != nil { + enabledVal = *settings.Enabled + } + + globalOutputGuardState.config = OutputGuardAppliedConfig{ + Enabled: enabledVal, + MaxToolArgumentBytes: settings.MaxToolArgumentBytes, + MaxToolArgumentDeltas: settings.MaxToolArgumentDeltas, + MaxChannelBytes: settings.MaxChannelBytes, + MaxRequestBytes: settings.MaxRequestBytes, + Generation: settings.ConfigGeneration, + } + globalOutputGuardState.isReady.Store(true) + return true +} + +func LoadOutputGuardSettings(ctx context.Context, reader OutputGuardSettingsReader) error { + settings, err := readOutputGuardSettings(ctx, reader, outputGuardConfigReadTimeout) + if err != nil { + if ctx == nil || ctx.Err() == nil { + recordOutputGuardConfigSyncError(err) + } + return err + } + if err := database.ValidateOutputGuardSettings(settings); err != nil { + recordOutputGuardConfigSyncError(err) + return err + } + ApplyOutputGuardSettings(settings) + recordOutputGuardConfigSyncSuccess(time.Now()) + return nil +} + +func StartOutputGuardConfigPoller(parent context.Context, db *database.DB) bool { + if db == nil { + return false + } + if parent == nil { + parent = context.Background() + } + return db.RunBackgroundTask(func(lifecycle context.Context) { + taskCtx, cancel := context.WithCancel(lifecycle) + stopParent := context.AfterFunc(parent, cancel) + defer func() { + stopParent() + cancel() + }() + runOutputGuardConfigPoller( + taskCtx, + db, + outputGuardConfigPollInterval, + outputGuardConfigReadTimeout, + ) + }) +} + +func runOutputGuardConfigPoller( + ctx context.Context, + reader OutputGuardSettingsReader, + interval time.Duration, + readTimeout time.Duration, +) { + if ctx == nil { + ctx = context.Background() + } + if interval <= 0 { + interval = outputGuardConfigPollInterval + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + settings, err := readOutputGuardSettings(ctx, reader, readTimeout) + if err != nil { + if ctx.Err() != nil { + return + } + recordOutputGuardConfigSyncError(err) + continue + } + if err := database.ValidateOutputGuardSettings(settings); err != nil { + recordOutputGuardConfigSyncError(err) + continue + } + ApplyOutputGuardSettings(settings) + recordOutputGuardConfigSyncSuccess(time.Now()) + } + } +} + +func readOutputGuardSettings( + parent context.Context, + reader OutputGuardSettingsReader, + timeout time.Duration, +) (database.OutputGuardSettings, error) { + if reader == nil { + return database.OutputGuardSettings{}, fmt.Errorf("output guard settings reader is nil") + } + if parent == nil { + parent = context.Background() + } + if err := parent.Err(); err != nil { + return database.OutputGuardSettings{}, err + } + if timeout <= 0 || timeout > outputGuardConfigReadTimeout { + timeout = outputGuardConfigReadTimeout + } + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + return reader.GetOutputGuardSettings(ctx) +} + +func recordOutputGuardConfigSyncSuccess(at time.Time) { + globalOutputGuardState.mu.Lock() + globalOutputGuardState.lastSyncAt = at + globalOutputGuardState.lastSyncErr = "" + globalOutputGuardState.mu.Unlock() +} + +func recordOutputGuardConfigSyncError(err error) { + if err == nil { + return + } + globalOutputGuardState.mu.Lock() + globalOutputGuardState.lastSyncErr = err.Error() + globalOutputGuardState.mu.Unlock() +} + +// 门禁检查实现:冷启动未就绪返回 503,严禁以 log.Fatalf 冒充 503 + +func RequireOutputGuardReadyHTTP(c *gin.Context) bool { + if IsOutputGuardReady() { + return true + } + apiErr := api.NewAPIError( + api.ErrCodeServiceUnavailable, + "Service configuration unavailable; failed to load output guard settings", + api.ErrorTypeServer, + ) + api.SendErrorWithStatus(c, apiErr, http.StatusServiceUnavailable) + return false +} + +func RequireOutputGuardReadyWS(conn *websocket.Conn) bool { + if IsOutputGuardReady() { + return true + } + if conn == nil { + return false + } + apiErr := api.NewAPIError( + api.ErrCodeServiceUnavailable, + "Service configuration unavailable; failed to load output guard settings", + api.ErrorTypeServer, + ) + _ = writeResponsesWSErrorWithin(conn, apiErr, 2*time.Second) + _ = conn.WriteControl( + websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseTryAgainLater, "Service configuration unavailable"), + time.Now().Add(time.Second), + ) + _ = conn.Close() + return false +} + +func RequireOutputGuardReadyStream(conn *websocket.Conn, streamID string) bool { + if IsOutputGuardReady() { + return true + } + if conn == nil { + return false + } + apiErr := api.NewAPIError( + api.ErrCodeServiceUnavailable, + "Service configuration unavailable; failed to load output guard settings", + api.ErrorTypeServer, + ) + _ = writeResponsesWSErrorForStream(conn, apiErr, streamID, 2*time.Second) + return false +} +``` + +精确插入锚点:`main.go:265-270`(紧随 ResponseCache 设置加载与日志之后,挂载 OutputGuard 加载与 Poller): + +```go + // main.go 锚点替换:紧接 line 265 + appliedResponseCache := proxy.GetResponseCacheAppliedConfig() + log.Printf( + "响应缓存设置已加载: generation=%d total=%d entry=%d reconstruct=%d", + appliedResponseCache.Generation, + appliedResponseCache.LocalMaxBytes, + appliedResponseCache.LocalMaxEntryBytes, + appliedResponseCache.ReconstructMaxBytes, + ) + + // --- 插入开始: OutputGuard 加载与后台 5 秒轮询器启动 --- + outputGuardCtx, outputGuardCancel := context.WithTimeout(context.Background(), 3*time.Second) + if err := proxy.LoadOutputGuardSettings(outputGuardCtx, db); err != nil { + outputGuardCancel() + // 严禁 log.Fatalf 冒充 503!冷启动加载失败仅记录警告日志,保持 IsOutputGuardReady=false, + // 请求进入时由门禁准确返回 HTTP 503 api.ErrCodeServiceUnavailable,且 poller 后续轮询成功时可无缝自愈恢复。 + log.Printf("警告: 加载模型输出失控防护设置失败(冷启动门禁将返回 503 直至 DB 轮询恢复): %v", err) + } else { + outputGuardCancel() + appliedGuard := proxy.CurrentOutputGuardConfig() + log.Printf( + "模型输出失控防护设置已加载: generation=%d enabled=%t tool_bytes=%d tool_deltas=%d channel_bytes=%d request_bytes=%d", + appliedGuard.Generation, + appliedGuard.Enabled, + appliedGuard.MaxToolArgumentBytes, + appliedGuard.MaxToolArgumentDeltas, + appliedGuard.MaxChannelBytes, + appliedGuard.MaxRequestBytes, + ) + } + proxy.StartOutputGuardConfigPoller(context.Background(), db) + // --- 插入结束 --- +``` + +- [ ] **Step 4: Run test to verify it passes (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestOutputGuard.*"` +Expected: PASS ok github.com/codex2api/proxy + +- [ ] **Step 5: Commit** + +```bash +git add proxy/output_guard_settings.go proxy/output_guard_settings_test.go main.go +git commit -m "feat(proxy): add output guard runtime config snapshot, poller and cold start readiness gate" +``` + +--- + +### Task 6: 复用 Admin Settings GET/PUT 扩展与完整独立前端 UI 卡片 + +**Files:** +- Create: `admin/output_guard_settings.go` +- Create: `admin/output_guard_settings_test.go` +- Modify: `admin/handler.go:9460-9640` (`settingsResponse`, `updateSettingsReq`, `GetSettings`, `UpdateSettings`) +- Modify: `frontend/src/types.ts:2300-2315` +- Modify: `frontend/src/pages/Settings.tsx:200-220, 6545-6600` +- Test: `frontend/src/lib/outputGuardSettings.test.mjs` (新建前端纯逻辑测试) + +**Interfaces:** +- Consumes: + - `database.OutputGuardSettings` / `database.OutputGuardSettingsUpdate` (来自 Task 4) + - `proxy.CurrentOutputGuardConfig()` / `proxy.ApplyOutputGuardSettings()` (来自 Task 5) + - `database.ValidateOutputGuardSettings(database.OutputGuardSettings) error` +- Produces: + - Admin REST API: + - `GET /api/admin/settings` 返回 5 个完整对称字段 + 1 个只读 generation: + - `output_guard_enabled`: bool + - `output_guard_max_tool_argument_bytes`: int64 + - `output_guard_max_tool_argument_deltas`: int + - `output_guard_max_channel_bytes`: int64 + - `output_guard_max_request_bytes`: int64 + - `output_guard_config_generation`: int64 (只读) + - `PUT /api/admin/settings` 接收上述 5 个字段的可选 patch,若传入 `output_guard_config_generation` 则报错 400(只读字段禁止提交)。 + - Frontend TypeScript Contract (`frontend/src/types.ts`): + - `SystemSettings` 增加上述 6 个属性(含 `readonly output_guard_config_generation: number`)。 + - Frontend UI (`frontend/src/pages/Settings.tsx`): + - 位于 General Tab 独立的“模型输出失控防护 (Model Output Guard)”专属卡片(不挂在 PromptFilter 下)。 + - 5 个完整配置项输入,单位展示 KiB / MiB 与格式化转换,非法层级交互反馈(`tool <= channel <= request`),显式 false 准确提交,以及“恢复默认”5 字段原子提交。 + +- [ ] **Step 1: Write failing test for admin GET/PUT settings with output guard fields, range validation, explicit false, and readonly revision rejection** + +```go +// admin/output_guard_settings_test.go +package admin + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/codex2api/database" + "github.com/codex2api/proxy" + "github.com/gin-gonic/gin" +) + +func TestAdminOutputGuardSettingsGetSymmetricFields(t *testing.T) { + db := newResponseCacheSettingsTestDB(t) + handler := &Handler{db: db} + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/admin/settings", nil) + + handler.GetSettings(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected HTTP 200, got %d, body: %s", w.Code, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response failed: %v", err) + } + + // Verify all 6 symmetric fields exist + fields := []string{ + "output_guard_enabled", + "output_guard_max_tool_argument_bytes", + "output_guard_max_tool_argument_deltas", + "output_guard_max_channel_bytes", + "output_guard_max_request_bytes", + "output_guard_config_generation", + } + for _, f := range fields { + if _, ok := resp[f]; !ok { + t.Fatalf("expected field %q in GET /api/admin/settings response", f) + } + } + + if resp["output_guard_enabled"] != true { + t.Fatalf("expected output_guard_enabled = true by default, got %v", resp["output_guard_enabled"]) + } + if int64(resp["output_guard_max_tool_argument_bytes"].(float64)) != 131072 { + t.Fatalf("expected tool argument bytes 131072, got %v", resp["output_guard_max_tool_argument_bytes"]) + } +} + +func TestAdminOutputGuardSettingsPutValidationAndReadOnlyRevision(t *testing.T) { + db := newResponseCacheSettingsTestDB(t) + handler := &Handler{db: db} + gin.SetMode(gin.TestMode) + + // 1. Rejection of readonly output_guard_config_generation + { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPut, "/api/admin/settings", strings.NewReader(`{"output_guard_config_generation": 999}`)) + c.Request.Header.Set("Content-Type", "application/json") + handler.UpdateSettings(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected HTTP 400 when submitting readonly output_guard_config_generation, got %d", w.Code) + } + if !strings.Contains(w.Body.String(), "只读") { + t.Fatalf("expected error mentioning readonly, got %s", w.Body.String()) + } + } + + // 2. Rejection of out-of-range parameters (16KiB..4MiB, etc.) + { + // Tool bytes too small: 1024 < 16384 + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPut, "/api/admin/settings", strings.NewReader(`{"output_guard_max_tool_argument_bytes": 1024}`)) + c.Request.Header.Set("Content-Type", "application/json") + handler.UpdateSettings(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected HTTP 400 for tool bytes < 16KiB, got %d", w.Code) + } + } + + // 3. Rejection of hierarchy violation: tool > channel + { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPut, "/api/admin/settings", strings.NewReader(`{"output_guard_max_tool_argument_bytes": 3145728, "output_guard_max_channel_bytes": 2097152}`)) + c.Request.Header.Set("Content-Type", "application/json") + handler.UpdateSettings(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected HTTP 400 for hierarchy violation tool > channel, got %d", w.Code) + } + } + + // 4. Successful update with explicit false & verify generation increment + { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + payload := `{"output_guard_enabled": false, "output_guard_max_tool_argument_bytes": 65536}` + c.Request = httptest.NewRequest(http.MethodPut, "/api/admin/settings", strings.NewReader(payload)) + c.Request.Header.Set("Content-Type", "application/json") + handler.UpdateSettings(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected HTTP 200 for valid PUT, got %d, body: %s", w.Code, w.Body.String()) + } + + var resp map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if resp["output_guard_enabled"] != false { + t.Fatalf("expected output_guard_enabled = false, got %v", resp["output_guard_enabled"]) + } + if int64(resp["output_guard_max_tool_argument_bytes"].(float64)) != 65536 { + t.Fatalf("expected tool bytes = 65536, got %v", resp["output_guard_max_tool_argument_bytes"]) + } + if int64(resp["output_guard_config_generation"].(float64)) <= 1 { + t.Fatalf("expected generation incremented, got %v", resp["output_guard_config_generation"]) + } + + // Also check local runtime instance updated immediately + cfg := proxy.CurrentOutputGuardConfig() + if cfg.Enabled != false || cfg.MaxToolArgumentBytes != 65536 { + t.Fatalf("local proxy runtime config not updated immediately: %+v", cfg) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./admin -run "TestAdminOutputGuard.*"` +Expected: FAIL with "unknown field output_guard_enabled" or failed assertions. + +- [ ] **Step 3: Write minimal implementation for admin backend and frontend integration** + +1. 编写 `admin/output_guard_settings.go`: +```go +// admin/output_guard_settings.go +package admin + +import ( + "fmt" + "github.com/codex2api/database" +) + +func validateOutputGuardSettingsUpdateRanges(update database.OutputGuardSettingsUpdate) error { + if update.MaxToolArgumentBytes != nil { + val := *update.MaxToolArgumentBytes + if val < 16384 || val > 4194304 { + return fmt.Errorf("output_guard_max_tool_argument_bytes 必须在 16KiB(16384) ~ 4MiB(4194304) 之间") + } + } + if update.MaxToolArgumentDeltas != nil { + val := *update.MaxToolArgumentDeltas + if val < 256 || val > 65536 { + return fmt.Errorf("output_guard_max_tool_argument_deltas 必须在 256 ~ 65536 之间") + } + } + if update.MaxChannelBytes != nil { + val := *update.MaxChannelBytes + if val < 131072 || val > 16777216 { + return fmt.Errorf("output_guard_max_channel_bytes 必须在 128KiB(131072) ~ 16MiB(16777216) 之间") + } + } + if update.MaxRequestBytes != nil { + val := *update.MaxRequestBytes + if val < 262144 || val > 67108864 { + return fmt.Errorf("output_guard_max_request_bytes 必须在 256KiB(262144) ~ 64MiB(67108864) 之间") + } + } + return nil +} +``` + +2. 在 `admin/handler.go` 中扩展 `settingsResponse`、`updateSettingsReq`、`GetSettings` 及 `UpdateSettings`: +- 在 `settingsResponse`(约 line 9460)中添加: +```go + OutputGuardEnabled bool `json:"output_guard_enabled"` + OutputGuardMaxToolArgumentBytes int64 `json:"output_guard_max_tool_argument_bytes"` + OutputGuardMaxToolArgumentDeltas int `json:"output_guard_max_tool_argument_deltas"` + OutputGuardMaxChannelBytes int64 `json:"output_guard_max_channel_bytes"` + OutputGuardMaxRequestBytes int64 `json:"output_guard_max_request_bytes"` + OutputGuardConfigGeneration int64 `json:"output_guard_config_generation"` +``` + +- 在 `updateSettingsReq`(约 line 9635)中添加: +```go + OutputGuardEnabled *bool `json:"output_guard_enabled"` + OutputGuardMaxToolArgumentBytes *int64 `json:"output_guard_max_tool_argument_bytes"` + OutputGuardMaxToolArgumentDeltas *int `json:"output_guard_max_tool_argument_deltas"` + OutputGuardMaxChannelBytes *int64 `json:"output_guard_max_channel_bytes"` + OutputGuardMaxRequestBytes *int64 `json:"output_guard_max_request_bytes"` + OutputGuardConfigGeneration rawJSON `json:"output_guard_config_generation"` +``` + +- 在 `GetSettings`(约 line 10240 之后)读取并注入 output guard 字段: +```go + outputGuardSettings, err := h.db.GetOutputGuardSettings(ctx) + if err != nil { + outputGuardSettings = database.DefaultOutputGuardSettings() + } + outputGuardEnabled := true + if outputGuardSettings.Enabled != nil { + outputGuardEnabled = *outputGuardSettings.Enabled + } +``` +并在 `settingsResponse` 结构字面量中填充: +```go + OutputGuardEnabled: outputGuardEnabled, + OutputGuardMaxToolArgumentBytes: outputGuardSettings.MaxToolArgumentBytes, + OutputGuardMaxToolArgumentDeltas: outputGuardSettings.MaxToolArgumentDeltas, + OutputGuardMaxChannelBytes: outputGuardSettings.MaxChannelBytes, + OutputGuardMaxRequestBytes: outputGuardSettings.MaxRequestBytes, + OutputGuardConfigGeneration: outputGuardSettings.ConfigGeneration, +``` + +- 在 `UpdateSettings`(约 line 10600 及 line 10750)添加只读校验、更新合并与立即生效逻辑: +```go + if req.OutputGuardConfigGeneration != nil { + writeError(c, http.StatusBadRequest, "output_guard_config_generation 为只读字段") + return + } + + outputGuardUpdate := database.OutputGuardSettingsUpdate{ + Enabled: req.OutputGuardEnabled, + MaxToolArgumentBytes: req.OutputGuardMaxToolArgumentBytes, + MaxToolArgumentDeltas: req.OutputGuardMaxToolArgumentDeltas, + MaxChannelBytes: req.OutputGuardMaxChannelBytes, + MaxRequestBytes: req.OutputGuardMaxRequestBytes, + } + outputGuardUpdateRequested := outputGuardUpdate.Enabled != nil || + outputGuardUpdate.MaxToolArgumentBytes != nil || + outputGuardUpdate.MaxToolArgumentDeltas != nil || + outputGuardUpdate.MaxChannelBytes != nil || + outputGuardUpdate.MaxRequestBytes != nil + + if err := validateOutputGuardSettingsUpdateRanges(outputGuardUpdate); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + + var outputGuardSettings database.OutputGuardSettings + if outputGuardUpdateRequested { + currentGuard, err := h.db.GetOutputGuardSettings(c.Request.Context()) + if err != nil { + writeError(c, http.StatusInternalServerError, "读取防护设置失败:"+err.Error()) + return + } + mergedGuard := currentGuard + if outputGuardUpdate.Enabled != nil { + val := *outputGuardUpdate.Enabled + mergedGuard.Enabled = &val + } + if outputGuardUpdate.MaxToolArgumentBytes != nil { + mergedGuard.MaxToolArgumentBytes = *outputGuardUpdate.MaxToolArgumentBytes + } + if outputGuardUpdate.MaxToolArgumentDeltas != nil { + mergedGuard.MaxToolArgumentDeltas = *outputGuardUpdate.MaxToolArgumentDeltas + } + if outputGuardUpdate.MaxChannelBytes != nil { + mergedGuard.MaxChannelBytes = *outputGuardUpdate.MaxChannelBytes + } + if outputGuardUpdate.MaxRequestBytes != nil { + mergedGuard.MaxRequestBytes = *outputGuardUpdate.MaxRequestBytes + } + if err := database.ValidateOutputGuardSettings(mergedGuard); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + + committedGuard, err := h.db.UpdateOutputGuardSettings(c.Request.Context(), outputGuardUpdate) + if err != nil { + writeError(c, http.StatusInternalServerError, "保存防护设置失败:"+err.Error()) + return + } + outputGuardSettings = committedGuard + proxy.ApplyOutputGuardSettings(committedGuard) + } else { + latestGuard, err := h.db.GetOutputGuardSettings(c.Request.Context()) + if err != nil { + writeError(c, http.StatusInternalServerError, "读取防护设置失败:"+err.Error()) + return + } + outputGuardSettings = latestGuard + } +``` + +并在 `UpdateSettings` 结尾的 `c.JSON(http.StatusOK, response)` 中带上最新的 `outputGuardSettings` 字段。 + +3. 修改 `frontend/src/types.ts`(约 line 2305 处): +```typescript + output_guard_enabled: boolean + output_guard_max_tool_argument_bytes: number + output_guard_max_tool_argument_deltas: number + output_guard_max_channel_bytes: number + output_guard_max_request_bytes: number + readonly output_guard_config_generation: number +``` + +4. 编写前端纯函数测试 `frontend/src/lib/outputGuardSettings.test.mjs`: +```javascript +import test from 'node:test' +import assert from 'node:assert/strict' + +const KIB = 1024 +const MIB = 1024 * 1024 + +export const DEFAULT_OUTPUT_GUARD_SETTINGS = { + output_guard_enabled: true, + output_guard_max_tool_argument_bytes: 128 * KIB, + output_guard_max_tool_argument_deltas: 4096, + output_guard_max_channel_bytes: 2 * MIB, + output_guard_max_request_bytes: 4 * MIB, +} + +export function validateOutputGuardHierarchy(settings) { + const tool = settings.output_guard_max_tool_argument_bytes + const channel = settings.output_guard_max_channel_bytes + const request = settings.output_guard_max_request_bytes + if (tool > channel) { + return '工具参数上限不能超过单 Channel 字节上限' + } + if (channel > request) { + return '单 Channel 字节上限不能超过单请求语义预算' + } + return null +} + +export function bytesToKiB(bytes) { + return Math.round(bytes / KIB) +} + +export function kibToBytes(kib) { + return kib * KIB +} + +export function bytesToMiB(bytes) { + return Math.round(bytes / MIB) +} + +export function mibToBytes(mib) { + return mib * MIB +} + +test('validateOutputGuardHierarchy enforces tool <= channel <= request', () => { + assert.equal(validateOutputGuardHierarchy({ + output_guard_max_tool_argument_bytes: 128 * KIB, + output_guard_max_channel_bytes: 2 * MIB, + output_guard_max_request_bytes: 4 * MIB, + }), null) + + assert.equal(validateOutputGuardHierarchy({ + output_guard_max_tool_argument_bytes: 3 * MIB, + output_guard_max_channel_bytes: 2 * MIB, + output_guard_max_request_bytes: 4 * MIB, + }), '工具参数上限不能超过单 Channel 字节上限') + + assert.equal(validateOutputGuardHierarchy({ + output_guard_max_tool_argument_bytes: 128 * KIB, + output_guard_max_channel_bytes: 8 * MIB, + output_guard_max_request_bytes: 4 * MIB, + }), '单 Channel 字节上限不能超过单请求语义预算') +}) +``` + +5. 在 `frontend/src/pages/Settings.tsx` 中新增“模型输出失控防护”分组卡片: +- 在 constants 区域定义: +```typescript +const KIB = 1024 +const DEFAULT_OUTPUT_GUARD_TOOL_BYTES = 128 * KIB +const DEFAULT_OUTPUT_GUARD_TOOL_DELTAS = 4096 +const DEFAULT_OUTPUT_GUARD_CHANNEL_BYTES = 2 * MIB +const DEFAULT_OUTPUT_GUARD_REQUEST_BYTES = 4 * MIB + +const OUTPUT_GUARD_KEYS = [ + 'output_guard_enabled', + 'output_guard_max_tool_argument_bytes', + 'output_guard_max_tool_argument_deltas', + 'output_guard_max_channel_bytes', + 'output_guard_max_request_bytes', +] as const satisfies ReadonlyArray +``` + +- 在 JSX 中(位于 General Tab 的安全分区 `id="settings-security"` 内,紧邻 Prompt 风控卡片之后,新增独立的 `SettingsCard`): +```tsx + } + tone={settingsForm.output_guard_enabled ? 'default' : 'danger'} + badge={ + + + {settingsForm.output_guard_enabled ? '已启用全局防护' : '已关闭 (高风险)'} + + } + actions={ + + } + > +
+ {!settingsForm.output_guard_enabled && ( +
+ +
+

高风险操作警告:失控防护已全局关闭

+

关闭后模型失控生成死循环、重复代码或细碎 arguments delta 时,将直接击穿代理并在下游引发 O(n²) 全量重解析雪崩。仅用于临时诊断,请尽快重新开启!

+
+
+ )} + +
+
+
启用失控输出防护
+
全局实时检测持续短周期重复、细碎 arguments 泛洪与字节预算超额并前置切断
+
+ { + setSettingsForm(f => ({ ...f, output_guard_enabled: checked })) + savePartialSettings({ output_guard_enabled: checked }) + }} + /> +
+ +
+ + { + const val = Math.max(16, Math.min(4096, parseInt(e.target.value) || 128)) * 1024 + setSettingsForm(f => ({ ...f, output_guard_max_tool_argument_bytes: val })) + }} + onBlur={() => savePartialSettings({ output_guard_max_tool_argument_bytes: settingsForm.output_guard_max_tool_argument_bytes })} + /> + + + + { + const val = Math.max(256, Math.min(65536, parseInt(e.target.value) || 4096)) + setSettingsForm(f => ({ ...f, output_guard_max_tool_argument_deltas: val })) + }} + onBlur={() => savePartialSettings({ output_guard_max_tool_argument_deltas: settingsForm.output_guard_max_tool_argument_deltas })} + /> + + + + { + const val = Math.max(1, Math.min(16, parseInt(e.target.value) || 2)) * 1024 * 1024 + setSettingsForm(f => ({ ...f, output_guard_max_channel_bytes: val })) + }} + onBlur={() => savePartialSettings({ output_guard_max_channel_bytes: settingsForm.output_guard_max_channel_bytes })} + /> + + + + { + const val = Math.max(1, Math.min(64, parseInt(e.target.value) || 4)) * 1024 * 1024 + setSettingsForm(f => ({ ...f, output_guard_max_request_bytes: val })) + }} + onBlur={() => savePartialSettings({ output_guard_max_request_bytes: settingsForm.output_guard_max_request_bytes })} + /> + +
+ + {settingsForm.output_guard_max_tool_argument_bytes > settingsForm.output_guard_max_channel_bytes && ( +

层级错误:工具参数上限不能超过单 Channel 字节上限!

+ )} + {settingsForm.output_guard_max_channel_bytes > settingsForm.output_guard_max_request_bytes && ( +

层级错误:单 Channel 字节上限不能超过单请求语义预算!

+ )} +
+
+``` + +- [ ] **Step 4: Run test to verify it passes (Green)** + +Run: +1. `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./admin -run "TestAdminOutputGuard.*"` +Expected: PASS ok github.com/codex2api/admin +2. `cd frontend && node --experimental-strip-types --test src/lib/outputGuardSettings.test.mjs` +Expected: PASS ok +3. `cd frontend && pnpm run typecheck` +Expected: exit code 0, no errors + +- [ ] **Step 5: Commit** + +```bash +git add admin/output_guard_settings.go admin/output_guard_settings_test.go admin/handler.go frontend/src/types.ts frontend/src/pages/Settings.tsx frontend/src/lib/outputGuardSettings.test.mjs +git commit -m "feat(admin,frontend): add output guard settings GET/PUT and standalone management UI card" +``` + +--- + +### Task 7: 统一协议感知投影层与跨帧状态机分派器 + +**Files:** +- Create: `proxy/outputguard_projection.go` +- Test: `proxy/outputguard_projection_test.go` + +**Interfaces:** +- Consumes: + - `security/outputguard`(严格对齐 Part A exact 签名与实现): + ```go + type ChannelKind string + const ( + KindText ChannelKind = "text" + KindThinking ChannelKind = "thinking" + KindToolArguments ChannelKind = "tool_arguments" + ) + type Config struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 + } + type RequestGuard struct { ... } + func NewRequestGuard(cfg Config) *RequestGuard + func (g *RequestGuard) IsTripped() bool + func (g *RequestGuard) TrippedReason() string + func (g *RequestGuard) RecordEvent() error + func (g *RequestGuard) ObserveAppend(channelID string, aliasID string, kind ChannelKind, delta []byte) error + func (g *RequestGuard) ObserveSnapshot(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error + func (g *RequestGuard) ObserveAppendNoEvent(channelID string, aliasID string, kind ChannelKind, delta []byte) error + func (g *RequestGuard) ObserveSnapshotNoEvent(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error + func (g *RequestGuard) EndChannel(channelID string) error + ``` + - `github.com/tidwall/gjson` (go.mod) + - `github.com/tidwall/sjson` (go.mod) +- Produces: + ```go + package proxy + + import ( + "github.com/codex2api/security/outputguard" + ) + + type ObservationAction string + const ( + ActionAppend ObservationAction = "append" + ActionSnapshot ObservationAction = "snapshot" + ActionEnd ObservationAction = "end" + ) + + type Observation struct { + ChannelID string + AliasID string + Kind outputguard.ChannelKind + Action ObservationAction + Payload []byte + TerminalStage string // 严格白名单限制为 3 种合法语义终态阶段之一: "arguments.done", "item.done", "response.completed",其余普通帧必须置为空字符串 + } + + type ProtocolProjector interface { + Project(eventType string, data []byte) ([]Observation, error) + } + + type ResponsesProjector struct { + aliasMap map[string]string + } + func NewResponsesProjector() *ResponsesProjector + func (p *ResponsesProjector) Project(eventType string, data []byte) ([]Observation, error) + + type ChatProjector struct { + aliasMap map[string]string + } + func NewChatProjector() *ChatProjector + func (p *ChatProjector) Project(eventType string, data []byte) ([]Observation, error) + + type AnthropicProjector struct { + blockTypes map[int]string + blockIDs map[int]string + } + func NewAnthropicProjector() *AnthropicProjector + func (p *AnthropicProjector) Project(eventType string, data []byte) ([]Observation, error) + + type GeminiProjector struct{} + func NewGeminiProjector() *GeminiProjector + func (p *GeminiProjector) Project(eventType string, data []byte) ([]Observation, error) + + // ObserveEvent 核心事件分派器: + // 1. 业务事件计数:严格每上游事件计 1 次,心跳/注释除外;unknown/control/empty 业务帧必须消耗事件预算; + // 2. 调用 projector.Project 解出当前 envelope 的 observations; + // 3. 工具 arguments 仅解当前 envelope 或原生 object bounded serialize,不每 delta 重 parse inner JSON; + // 4. 稳定 channel key 跨帧 alias 映射维护与测试验证; + // 5. 调用 guard 执行判定流水线,端到端保障状态机语义一致性。 + func ObserveEvent(guard *outputguard.RequestGuard, projector ProtocolProjector, eventType string, data []byte) error + ``` + +- [ ] **Step 1: Write failing test for protocol projection and event dispatcher** + +```go +// proxy/outputguard_projection_test.go +package proxy + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/codex2api/security/outputguard" +) + +func defaultProjectionTestConfig() outputguard.Config { + return outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, // 128 KiB + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, // 2 MiB + MaxRequestBytes: 4194304, // 4 MiB + } +} + +// 1. Responses 协议流式、3 个合法语义终态阶段快照与 alias 跨帧映射测试 +func TestResponsesProjectorStreamAndTerminalSnapshots(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewResponsesProjector() + + // Text delta + textDelta := `{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"Hello, "}` + if err := ObserveEvent(guard, proj, "response.output_text.delta", []byte(textDelta)); err != nil { + t.Fatalf("text delta observe failed: %v", err) + } + + // Thinking delta + thinkingDelta := `{"type":"response.reasoning.delta","item_id":"msg_1","delta":"thinking step 1"}` + if err := ObserveEvent(guard, proj, "response.reasoning.delta", []byte(thinkingDelta)); err != nil { + t.Fatalf("thinking delta observe failed: %v", err) + } + + // Tool call delta with call_id and item_id alias + toolDelta := `{"type":"response.function_call_arguments.delta","item_id":"item_call_1","call_id":"call_write_file","output_index":1,"delta":"{\"path\":\"test.txt\""}` + if err := ObserveEvent(guard, proj, "response.function_call_arguments.delta", []byte(toolDelta)); err != nil { + t.Fatalf("tool delta observe failed: %v", err) + } + + // Tool call done (终态阶段 1: arguments.done 纯终态确认快照) + toolDone := `{"type":"response.function_call_arguments.done","item_id":"item_call_1","call_id":"call_write_file","arguments":"{\"path\":\"test.txt\"}"}` + if err := ObserveEvent(guard, proj, "response.function_call_arguments.done", []byte(toolDone)); err != nil { + t.Fatalf("tool done observe failed: %v", err) + } + + // output_item.done (终态阶段 2: item.done 纯终态确认快照) + itemDone := `{"type":"response.output_item.done","item":{"id":"item_call_1","type":"function_call","call_id":"call_write_file","arguments":"{\"path\":\"test.txt\"}"}}` + if err := ObserveEvent(guard, proj, "response.output_item.done", []byte(itemDone)); err != nil { + t.Fatalf("item done observe failed: %v", err) + } + + // response.completed (终态阶段 3: response.completed 纯终态确认快照) + completed := `{"type":"response.completed","response":{"id":"resp_1","status":"completed","output":[{"id":"item_call_1","type":"function_call","call_id":"call_write_file","arguments":"{\"path\":\"test.txt\"}"}]}}` + if err := ObserveEvent(guard, proj, "response.completed", []byte(completed)); err != nil { + t.Fatalf("response completed observe failed: %v", err) + } + + if guard.IsTripped() { + t.Fatalf("expected guard not tripped, got %s", guard.TrippedReason()) + } +} + +// 2. ChatCompletions 协议 choice/toolindex/callid alias 隔离与流式及非流式测试 +func TestChatProjectorStreamingAndNonStreaming(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewChatProjector() + + // 流式 Chunk 1: content + reasoning + tool_call 首帧 (带 call_id 与 index) + chunk1 := `{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"foo","reasoning_content":"think","tool_calls":[{"index":0,"id":"call_123","function":{"name":"search","arguments":"{\"q\":"}}]}}]}` + if err := ObserveEvent(guard, proj, "", []byte(chunk1)); err != nil { + t.Fatalf("chunk 1 observe error: %v", err) + } + + // 流式 Chunk 2: tool_call 后续 delta (仅带 index, 缺失 id, 验证 aliasMap 稳定解析到同一 channel) + chunk2 := `{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"golang\"}"}}]}}]}` + if err := ObserveEvent(guard, proj, "", []byte(chunk2)); err != nil { + t.Fatalf("chunk 2 observe error: %v", err) + } + + // 验证不同 choice 和不同 tool_index 隔离,不发生错合并 + chunkChoice1 := `{"id":"chatcmpl-1","choices":[{"index":1,"delta":{"content":"bar"}}]}` + if err := ObserveEvent(guard, proj, "", []byte(chunkChoice1)); err != nil { + t.Fatalf("chunk choice 1 observe error: %v", err) + } + + // 非流式 JSON 测试 + nonStream := `{"id":"chatcmpl-2","choices":[{"index":0,"message":{"role":"assistant","content":"final content","tool_calls":[{"id":"call_999","type":"function","function":{"name":"calc","arguments":"{\"val\":42}"}}]}}]}` + guardNonStream := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + projNonStream := NewChatProjector() + if err := ObserveEvent(guardNonStream, projNonStream, "", []byte(nonStream)); err != nil { + t.Fatalf("non-stream chat observe error: %v", err) + } + if guardNonStream.IsTripped() { + t.Fatalf("expected non-stream not tripped, got %s", guardNonStream.TrippedReason()) + } +} + +// 3. Anthropic Messages 协议真实 fixture 与 content_block 状态流转测试 +func TestAnthropicProjectorStreamLifecycleAndNonStream(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewAnthropicProjector() + + // message_start + msgStart := `{"type":"message_start","message":{"id":"msg_123","type":"message","role":"assistant","content":[]}}` + if err := ObserveEvent(guard, proj, "message_start", []byte(msgStart)); err != nil { + t.Fatalf("msg start error: %v", err) + } + + // block 0: thinking + b0Start := `{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}` + if err := ObserveEvent(guard, proj, "content_block_start", []byte(b0Start)); err != nil { + t.Fatalf("b0 start error: %v", err) + } + b0Delta := `{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"let me think"}}` + if err := ObserveEvent(guard, proj, "content_block_delta", []byte(b0Delta)); err != nil { + t.Fatalf("b0 delta error: %v", err) + } + b0Stop := `{"type":"content_block_stop","index":0}` + if err := ObserveEvent(guard, proj, "content_block_stop", []byte(b0Stop)); err != nil { + t.Fatalf("b0 stop error: %v", err) + } + + // block 1: tool_use + b1Start := `{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01ABC","name":"exec"}}` + if err := ObserveEvent(guard, proj, "content_block_start", []byte(b1Start)); err != nil { + t.Fatalf("b1 start error: %v", err) + } + b1Delta := `{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"cmd\":\"ls\"}"}}` + if err := ObserveEvent(guard, proj, "content_block_delta", []byte(b1Delta)); err != nil { + t.Fatalf("b1 delta error: %v", err) + } + b1Stop := `{"type":"content_block_stop","index":1}` + if err := ObserveEvent(guard, proj, "content_block_stop", []byte(b1Stop)); err != nil { + t.Fatalf("b1 stop error: %v", err) + } + + // message_stop + msgStop := `{"type":"message_stop"}` + if err := ObserveEvent(guard, proj, "message_stop", []byte(msgStop)); err != nil { + t.Fatalf("msg stop error: %v", err) + } + + // 非流式 Anthropic 响应 + nonStream := `{"id":"msg_456","type":"message","role":"assistant","content":[{"type":"text","text":"Done!"},{"type":"tool_use","id":"toolu_02XYZ","name":"write","input":{"content":"file content"}}]}` + guardAnthropicNonStream := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + projAnthropicNonStream := NewAnthropicProjector() + if err := ObserveEvent(guardAnthropicNonStream, projAnthropicNonStream, "", []byte(nonStream)); err != nil { + t.Fatalf("anthropic non-stream observe error: %v", err) + } + if guardAnthropicNonStream.IsTripped() { + t.Fatalf("expected anthropic non-stream not tripped, got %s", guardAnthropicNonStream.TrippedReason()) + } +} + +// 4. Gemini Native (streamGenerateContent & generateContent) 真实结构与 Object 紧凑序列化测试 +func TestGeminiProjectorStreamAndNonStreamObjectSerialization(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewGeminiProjector() + + // 流式 candidate text + thought + geminiChunk := `{"candidates":[{"index":0,"content":{"role":"model","parts":[{"text":"hello gemini"},{"thought":true,"text":"inner thoughts"},{"functionCall":{"name":"search","args":{"query":"codex"}}}]}}]}` + if err := ObserveEvent(guard, proj, "", []byte(geminiChunk)); err != nil { + t.Fatalf("gemini chunk observe error: %v", err) + } + + // Gemini 非流式带 finishReason + geminiNonStreamJSON := `{"candidates":[{"index":0,"content":{"role":"model","parts":[{"functionCall":{"name":"fetch","args":{"url":"https://example.com","headers":{"Auth":"secret"}}}}]外},"finishReason":"STOP"}]}` + geminiNonStreamJSON = strings.ReplaceAll(geminiNonStreamJSON, "外", "") + + guardGemini := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + projGemini := NewGeminiProjector() + if err := ObserveEvent(guardGemini, projGemini, "", []byte(geminiNonStreamJSON)); err != nil { + t.Fatalf("gemini non-stream observe error: %v", err) + } + if guardGemini.IsTripped() { + t.Fatalf("expected gemini non-stream not tripped, got %s", guardGemini.TrippedReason()) + } +} + +// 5. 事件计数契约:每上游事件一次,unknown/control/empty 业务事件严格扣减事件预算,心跳注释除外 +func TestObserveEventBudgetAccountability(t *testing.T) { + // 极小事件预算配置:MaxRequestEvents = 65536 + guard := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + proj := NewResponsesProjector() + + // SSE 注释与 ping 帧不消耗业务事件预算 + if err := ObserveEvent(guard, proj, "", []byte(": keep-alive ping\n")); err != nil { + t.Fatalf("comment frame should not fail: %v", err) + } + if err := ObserveEvent(guard, proj, "ping", []byte("{}")); err != nil { + t.Fatalf("ping event should not fail: %v", err) + } + + // 构造 65536 个 unknown / empty 业务事件洪泛 + for i := 0; i < outputguard.MaxRequestEvents; i++ { + // unknown 或 control 事件,虽然投影为 0 个 observation,但必须计入业务事件 + unknownEvent := `{"type":"response.unknown_control_event","id":"ctrl_1"}` + if err := ObserveEvent(guard, proj, "response.unknown_control_event", []byte(unknownEvent)); err != nil { + t.Fatalf("event %d unexpectedly failed: %v", i, err) + } + } + + // 第 65537 个事件必须被 request_events 坚决拦截,不能因 control/unknown 绕过! + err := ObserveEvent(guard, proj, "response.created", []byte(`{"type":"response.created"}`)) + if err == nil || err.Error() != "request_events" { + t.Fatalf("expected request_events rejection for control/unknown flood, got %v", err) + } + if guard.TrippedReason() != "request_events" { + t.Fatalf("expected tripped reason request_events, got %s", guard.TrippedReason()) + } +} + +// 6. 验证 Custom Tool Arguments String 仅解码当前 envelope,不每 delta 重 parse inner JSON +func TestCustomToolArgumentsEnvelopePreservation(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewResponsesProjector() + + // 构造未闭合且包含转义与大段文本的 arguments 片段 + incompleteJSONChunk := `{"type":"response.function_call_arguments.delta","call_id":"call_custom","delta":"{\"nested_key\":\"val\\nwith\\\"quotes and unclosed brackets..."}` + if err := ObserveEvent(guard, proj, "response.function_call_arguments.delta", []byte(incompleteJSONChunk)); err != nil { + t.Fatalf("custom tool envelope observe error: %v", err) + } + + if guard.IsTripped() { + t.Fatalf("expected guard not tripped on unclosed inner JSON, got %s", guard.TrippedReason()) + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestResponsesProjector.*|TestChatProjector.*|TestAnthropicProjector.*|TestGeminiProjector.*|TestObserveEvent.*|TestCustomToolArgumentsEnvelopePreservation"` +Expected: FAIL with compilation error: undefined: NewResponsesProjector, NewChatProjector, NewAnthropicProjector, NewGeminiProjector, ObserveEvent + +- [ ] **Step 3: Write minimal implementation** + +调用 Task 3 中已完整定义和测试的 `RequestGuard.RecordEvent()`、`ObserveAppendNoEvent()`、`ObserveSnapshotNoEvent()` 与 `EndChannel()` 实现协议投影器与分派器: + +新建 `proxy/outputguard_projection.go`: +```go +// proxy/outputguard_projection.go +package proxy + +import ( + "bytes" + "fmt" + + "github.com/codex2api/security/outputguard" + "github.com/tidwall/gjson" +) + +type ObservationAction string + +const ( + ActionAppend ObservationAction = "append" + ActionSnapshot ObservationAction = "snapshot" + ActionEnd ObservationAction = "end" +) + +type Observation struct { + ChannelID string + AliasID string + Kind outputguard.ChannelKind + Action ObservationAction + Payload []byte + TerminalStage string // 严格限制为 3 种合法语义终态阶段: "arguments.done", "item.done", "response.completed",其余普通帧置空 +} + +type ProtocolProjector interface { + Project(eventType string, data []byte) ([]Observation, error) +} + +// ------------------------------------------------------------- +// 1. ResponsesProjector (HTTP SSE, WebSocket, Realtime Text) +// ------------------------------------------------------------- + +type ResponsesProjector struct { + aliasMap map[string]string +} + +func NewResponsesProjector() *ResponsesProjector { + return &ResponsesProjector{ + aliasMap: make(map[string]string), + } +} + +func (p *ResponsesProjector) Project(eventType string, data []byte) ([]Observation, error) { + if len(data) == 0 { + return nil, nil + } + parsed := gjson.ParseBytes(data) + t := eventType + if t == "" { + t = parsed.Get("type").String() + } + + var observations []Observation + + switch t { + case "response.output_text.delta": + itemID := parsed.Get("item_id").String() + delta := parsed.Get("delta").String() + if itemID == "" { + itemID = fmt.Sprintf("output_%d", parsed.Get("output_index").Int()) + } + observations = append(observations, Observation{ + ChannelID: itemID, + Kind: outputguard.KindText, + Action: ActionAppend, + Payload: []byte(delta), + }) + + case "response.reasoning.delta", "response.thinking.delta": + itemID := parsed.Get("item_id").String() + delta := parsed.Get("delta").String() + if itemID == "" { + itemID = fmt.Sprintf("output_%d", parsed.Get("output_index").Int()) + } + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("%s.thinking", itemID), + Kind: outputguard.KindThinking, + Action: ActionAppend, + Payload: []byte(delta), + }) + + case "response.function_call_arguments.delta": + callID := parsed.Get("call_id").String() + itemID := parsed.Get("item_id").String() + delta := parsed.Get("delta").String() + targetID := callID + if targetID == "" { + targetID = itemID + } + if targetID == "" { + targetID = fmt.Sprintf("output_%d", parsed.Get("output_index").Int()) + } + observations = append(observations, Observation{ + ChannelID: targetID, + AliasID: itemID, + Kind: outputguard.KindToolArguments, + Action: ActionAppend, + Payload: []byte(delta), + }) + + case "response.function_call_arguments.done": + callID := parsed.Get("call_id").String() + itemID := parsed.Get("item_id").String() + args := parsed.Get("arguments").String() + targetID := callID + if targetID == "" { + targetID = itemID + } + observations = append(observations, Observation{ + ChannelID: targetID, + AliasID: itemID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: []byte(args), + TerminalStage: "arguments.done", + }) + + case "response.output_item.done": + item := parsed.Get("item") + itemType := item.Get("type").String() + itemID := item.Get("id").String() + if itemType == "function_call" { + callID := item.Get("call_id").String() + args := item.Get("arguments").String() + targetID := callID + if targetID == "" { + targetID = itemID + } + observations = append(observations, Observation{ + ChannelID: targetID, + AliasID: itemID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: []byte(args), + TerminalStage: "item.done", + }) + } else if itemType == "message" { + text := item.Get("content.0.text").String() + if text != "" { + observations = append(observations, Observation{ + ChannelID: itemID, + Kind: outputguard.KindText, + Action: ActionSnapshot, + Payload: []byte(text), + TerminalStage: "item.done", + }) + } + } + + case "response.completed": + outputs := parsed.Get("response.output").Array() + for _, item := range outputs { + itemType := item.Get("type").String() + itemID := item.Get("id").String() + if itemType == "function_call" { + callID := item.Get("call_id").String() + args := item.Get("arguments").String() + targetID := callID + if targetID == "" { + targetID = itemID + } + observations = append(observations, Observation{ + ChannelID: targetID, + AliasID: itemID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: []byte(args), + TerminalStage: "response.completed", + }) + } else if itemType == "message" { + text := item.Get("content.0.text").String() + if text != "" { + observations = append(observations, Observation{ + ChannelID: itemID, + Kind: outputguard.KindText, + Action: ActionSnapshot, + Payload: []byte(text), + TerminalStage: "response.completed", + }) + } + } + } + + default: + // 兼容非流式完整响应 JSON + if parsed.Get("status").String() == "completed" && parsed.Get("output").Exists() { + for _, item := range parsed.Get("output").Array() { + itemType := item.Get("type").String() + itemID := item.Get("id").String() + if itemType == "function_call" { + callID := item.Get("call_id").String() + args := item.Get("arguments").String() + targetID := callID + if targetID == "" { + targetID = itemID + } + observations = append(observations, Observation{ + ChannelID: targetID, + AliasID: itemID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: []byte(args), + TerminalStage: "response.completed", + }) + } else if itemType == "message" { + text := item.Get("content.0.text").String() + if text != "" { + observations = append(observations, Observation{ + ChannelID: itemID, + Kind: outputguard.KindText, + Action: ActionSnapshot, + Payload: []byte(text), + TerminalStage: "response.completed", + }) + } + } + } + } + } + + return observations, nil +} + +// ------------------------------------------------------------- +// 2. ChatProjector (OpenAI /v1/chat/completions Stream & JSON) +// ------------------------------------------------------------- + +type ChatProjector struct { + aliasMap map[string]string +} + +func NewChatProjector() *ChatProjector { + return &ChatProjector{ + aliasMap: make(map[string]string), + } +} + +func (p *ChatProjector) Project(eventType string, data []byte) ([]Observation, error) { + if len(data) == 0 { + return nil, nil + } + parsed := gjson.ParseBytes(data) + var observations []Observation + + choices := parsed.Get("choices").Array() + for _, choice := range choices { + choiceIdx := choice.Get("index").Int() + + // 流式 delta + delta := choice.Get("delta") + if delta.Exists() { + if content := delta.Get("content").String(); content != "" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("choice_%d.text", choiceIdx), + Kind: outputguard.KindText, + Action: ActionAppend, + Payload: []byte(content), + }) + } + if reasoning := delta.Get("reasoning_content").String(); reasoning != "" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("choice_%d.thinking", choiceIdx), + Kind: outputguard.KindThinking, + Action: ActionAppend, + Payload: []byte(reasoning), + }) + } + toolCalls := delta.Get("tool_calls").Array() + for _, tc := range toolCalls { + toolIdx := tc.Get("index").Int() + callID := tc.Get("id").String() + channelID := fmt.Sprintf("choice_%d.tool_%d", choiceIdx, toolIdx) + if callID != "" { + p.aliasMap[callID] = channelID + } + args := tc.Get("function.arguments").String() + if args != "" { + observations = append(observations, Observation{ + ChannelID: channelID, + AliasID: callID, + Kind: outputguard.KindToolArguments, + Action: ActionAppend, + Payload: []byte(args), + }) + } + } + } + + // 非流式 message + msg := choice.Get("message") + if msg.Exists() { + if content := msg.Get("content").String(); content != "" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("choice_%d.text", choiceIdx), + Kind: outputguard.KindText, + Action: ActionSnapshot, + Payload: []byte(content), + TerminalStage: "response.completed", + }) + } + if reasoning := msg.Get("reasoning_content").String(); reasoning != "" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("choice_%d.thinking", choiceIdx), + Kind: outputguard.KindThinking, + Action: ActionSnapshot, + Payload: []byte(reasoning), + TerminalStage: "response.completed", + }) + } + toolCalls := msg.Get("tool_calls").Array() + for idx, tc := range toolCalls { + callID := tc.Get("id").String() + channelID := fmt.Sprintf("choice_%d.tool_%d", choiceIdx, idx) + args := tc.Get("function.arguments").String() + observations = append(observations, Observation{ + ChannelID: channelID, + AliasID: callID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: []byte(args), + TerminalStage: "response.completed", + }) + } + } + } + + return observations, nil +} + +// ------------------------------------------------------------- +// 3. AnthropicProjector (Claude /v1/messages Stream & JSON) +// ------------------------------------------------------------- + +type AnthropicProjector struct { + blockTypes map[int]string + blockIDs map[int]string +} + +func NewAnthropicProjector() *AnthropicProjector { + return &AnthropicProjector{ + blockTypes: make(map[int]string), + blockIDs: make(map[int]string), + } +} + +func (p *AnthropicProjector) Project(eventType string, data []byte) ([]Observation, error) { + if len(data) == 0 { + return nil, nil + } + parsed := gjson.ParseBytes(data) + t := eventType + if t == "" { + t = parsed.Get("type").String() + } + + var observations []Observation + + switch t { + case "content_block_start": + idx := int(parsed.Get("index").Int()) + block := parsed.Get("content_block") + bType := block.Get("type").String() + p.blockTypes[idx] = bType + if bType == "tool_use" { + p.blockIDs[idx] = block.Get("id").String() + } + + case "content_block_delta": + idx := int(parsed.Get("index").Int()) + delta := parsed.Get("delta") + dType := delta.Get("type").String() + + switch dType { + case "text_delta": + text := delta.Get("text").String() + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + Kind: outputguard.KindText, + Action: ActionAppend, + Payload: []byte(text), + }) + case "thinking_delta": + thinking := delta.Get("thinking").String() + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + Kind: outputguard.KindThinking, + Action: ActionAppend, + Payload: []byte(thinking), + }) + case "input_json_delta": + partJSON := delta.Get("partial_json").String() + callID := p.blockIDs[idx] + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + AliasID: callID, + Kind: outputguard.KindToolArguments, + Action: ActionAppend, + Payload: []byte(partJSON), + }) + } + + case "content_block_stop": + idx := int(parsed.Get("index").Int()) + bType := p.blockTypes[idx] + if bType == "tool_use" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + AliasID: p.blockIDs[idx], + Kind: outputguard.KindToolArguments, + Action: ActionEnd, + TerminalStage: "arguments.done", + }) + } else { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + Kind: outputguard.KindText, + Action: ActionEnd, + }) + } + + case "message_stop": + observations = append(observations, Observation{ + ChannelID: "message_root", + Kind: outputguard.KindText, + Action: ActionSnapshot, + Payload: []byte("done"), + TerminalStage: "response.completed", + }) + + default: + // 非流式 JSON (type == "message") + if parsed.Get("type").String() == "message" && parsed.Get("content").IsArray() { + contentBlocks := parsed.Get("content").Array() + for idx, b := range contentBlocks { + bType := b.Get("type").String() + switch bType { + case "text": + text := b.Get("text").String() + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + Kind: outputguard.KindText, + Action: ActionSnapshot, + Payload: []byte(text), + TerminalStage: "response.completed", + }) + case "thinking": + thinking := b.Get("thinking").String() + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + Kind: outputguard.KindThinking, + Action: ActionSnapshot, + Payload: []byte(thinking), + TerminalStage: "response.completed", + }) + case "tool_use": + callID := b.Get("id").String() + inputObj := b.Get("input") + var inputBytes []byte + if inputObj.IsObject() { + inputBytes = []byte(inputObj.Raw) + } else { + inputBytes = []byte(inputObj.String()) + } + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + AliasID: callID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: inputBytes, + TerminalStage: "response.completed", + }) + } + } + } + } + + return observations, nil +} + +// ------------------------------------------------------------- +// 4. GeminiProjector (Native streamGenerateContent & generateContent) +// ------------------------------------------------------------- + +type GeminiProjector struct{} + +func NewGeminiProjector() *GeminiProjector { + return &GeminiProjector{} +} + +func (p *GeminiProjector) Project(eventType string, data []byte) ([]Observation, error) { + if len(data) == 0 { + return nil, nil + } + parsed := gjson.ParseBytes(data) + var observations []Observation + + candidates := parsed.Get("candidates").Array() + for candIdx, cand := range candidates { + finishReason := cand.Get("finishReason").String() + parts := cand.Get("content.parts").Array() + for partIdx, part := range parts { + // 1. Text / Thought + if part.Get("text").Exists() { + text := part.Get("text").String() + isThought := part.Get("thought").Bool() + kind := outputguard.KindText + suffix := "text" + if isThought { + kind = outputguard.KindThinking + suffix = "thinking" + } + action := ActionAppend + var stage string + if finishReason != "" { + action = ActionSnapshot + stage = "response.completed" + } + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("candidate_%d.part_%d.%s", candIdx, partIdx, suffix), + Kind: kind, + Action: action, + Payload: []byte(text), + TerminalStage: stage, + }) + } + + // 2. Function Call (Native object bounded serialization) + if fnCall := part.Get("functionCall"); fnCall.Exists() { + name := fnCall.Get("name").String() + args := fnCall.Get("args") + var argBytes []byte + if args.IsObject() { + argBytes = []byte(args.Raw) + } else { + argBytes = []byte(args.String()) + } + action := ActionAppend + var stage string + if finishReason != "" { + action = ActionSnapshot + stage = "response.completed" + } + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("candidate_%d.part_%d.tool_%s", candIdx, partIdx, name), + Kind: outputguard.KindToolArguments, + Action: action, + Payload: argBytes, + TerminalStage: stage, + }) + } + } + } + + return observations, nil +} + +// ------------------------------------------------------------- +// 5. ObserveEvent Dispatcher (单上游事件严格计 1 次,流水线批调度) +// ------------------------------------------------------------- + +func ObserveEvent(guard *outputguard.RequestGuard, projector ProtocolProjector, eventType string, data []byte) error { + // 心跳/注释帧直接放行,不计入业务事件预算 + trimmed := bytes.TrimSpace(data) + if bytes.HasPrefix(trimmed, []byte(":")) || eventType == "ping" { + return nil + } + + // 步骤 1: 严格每上游业务事件计 1 次 requestEvents(即使是 control/unknown 帧) + if err := guard.RecordEvent(); err != nil { + return err + } + + obs, err := projector.Project(eventType, data) + if err != nil { + return err + } + if len(obs) == 0 { + return nil + } + + // 依次将 observations 推入 guard,执行步骤 2~7 判定流水线 + for _, o := range obs { + switch o.Action { + case ActionAppend: + if err := guard.ObserveAppendNoEvent(o.ChannelID, o.AliasID, o.Kind, o.Payload); err != nil { + return err + } + case ActionSnapshot: + if err := guard.ObserveSnapshotNoEvent(o.ChannelID, o.AliasID, o.Kind, o.Payload, o.TerminalStage); err != nil { + return err + } + case ActionEnd: + if err := guard.EndChannel(o.ChannelID); err != nil { + return err + } + } + } + + return nil +} +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestResponsesProjector.*|TestChatProjector.*|TestAnthropicProjector.*|TestGeminiProjector.*|TestObserveEvent.*|TestCustomToolArgumentsEnvelopePreservation"` +Expected: PASS ok github.com/codex2api/proxy + +- [ ] **Step 5: Commit** + +```bash +git add proxy/outputguard_projection.go proxy/outputguard_projection_test.go +git commit -m "feat(proxy): implement protocol projectors and observe event dispatcher with strict event budgeting" +``` + +--- + +### Task 8: 传输 Framing 有界 Reader 与全链路 Callsite 替换 + +**Files:** +- Modify: `proxy/sse_line.go` +- Modify: `proxy/sse_line_test.go` +- Modify: `proxy/executor.go:1592-1715` +- Modify: `proxy/handler.go:5538-5585,7650-7750` +- Modify: `proxy/handler_gemini.go:390-420` +- Modify: `proxy/responses_ws.go:142-190` + +**Interfaces:** +- Consumes: + - Go 标准库 `bufio`, `bytes`, `errors`, `io`, `net/http`, `sync` + - `github.com/gorilla/websocket` (go.mod) +- Produces: + ```go + package proxy + + var ( + ErrSSELineTooLong = errors.New("sse_line_too_long") + ErrSSEEventTooLarge = errors.New("sse_event_too_large") + ErrWSFrameTooLarge = errors.New("ws_frame_too_large") + ErrResponseBodyTooLarge = errors.New("response_body_too_large") + ) + + const ( + MaxSSELineBytes = 8 << 20 // 8 MiB + MaxSSEEventBytes = 8 << 20 // 8 MiB + MaxWSMessageBytes = 8 << 20 // 8 MiB + MaxResponseBodyBytes = 16 << 20 // 16 MiB + ) + + func ReadSSEStreamWithEvent(body io.Reader, callback func(event string, data []byte) bool) error + func ReadBoundedResponseBody(body io.Reader, limit int64) ([]byte, error) + ``` + +- [ ] **Step 1: Write failing test for bounded readers and callsites** + +```go +// proxy/sse_line_test.go (增补 Task 8 有界流与非流式硬 Cap 测试) +package proxy + +import ( + "bufio" + "bytes" + "errors" + "io" + "strings" + "testing" +) + +// 1. 测试跨 Read 块的 split chunk 正常解析 +func TestReadSSEStreamWithEventSplitChunks(t *testing.T) { + input := "data: hello " + input2 := "world\n\n" + r := io.MultiReader(strings.NewReader(input), strings.NewReader(input2)) + + var collected []string + err := ReadSSEStreamWithEvent(r, func(event string, data []byte) bool { + collected = append(collected, string(data)) + return true + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(collected) != 1 || collected[0] != "hello world" { + t.Fatalf("collected want ['hello world'], got %v", collected) + } +} + +// 2. 测试标准多行 data 聚合 +func TestReadSSEStreamWithEventMultilineData(t *testing.T) { + input := "event: update\ndata: line1\ndata: line2\ndata: line3\n\n" + var events []string + var payloads []string + err := ReadSSEStreamWithEvent(strings.NewReader(input), func(event string, data []byte) bool { + events = append(events, event) + payloads = append(payloads, string(data)) + return true + }) + if err != nil { + t.Fatalf("multiline read error: %v", err) + } + if len(events) != 1 || events[0] != "update" || payloads[0] != "line1\nline2\nline3" { + t.Fatalf("multiline want update with line1\\nline2\\nline3, got %v: %v", events, payloads) + } +} + +// 3. 测试无换行符超大单行(never-ending oversized line)硬 cap 拒绝,返回 ErrSSELineTooLong +func TestReadSSEStreamWithEventNoNewlineOversized(t *testing.T) { + // 构造超过 MaxSSELineBytes (8 MiB) 且无换行符的失控流 + oversized := strings.Repeat("x", MaxSSELineBytes+1024) + err := ReadSSEStreamWithEvent(strings.NewReader(oversized), func(event string, data []byte) bool { + return true + }) + if err == nil { + t.Fatalf("expected ErrSSELineTooLong for oversized line without newline, got nil") + } + if !errors.Is(err, ErrSSELineTooLong) { + t.Fatalf("expected ErrSSELineTooLong, got %v", err) + } +} + +// 4. 测试累积多行 data 整事件超标拒绝,返回 ErrSSEEventTooLarge +func TestReadSSEStreamWithEventNeverendingMultilineOversized(t *testing.T) { + // 9 行,每行 1 MiB,累积达 9 MiB > MaxSSEEventBytes (8 MiB) + chunk := "data: " + strings.Repeat("a", 1<<20) + "\n" + multilineOversized := strings.Repeat(chunk, 9) + "\n" + + err := ReadSSEStreamWithEvent(strings.NewReader(multilineOversized), func(event string, data []byte) bool { + return true + }) + if err == nil { + t.Fatalf("expected ErrSSEEventTooLarge for oversized multiline event, got nil") + } + if !errors.Is(err, ErrSSEEventTooLarge) { + t.Fatalf("expected ErrSSEEventTooLarge, got %v", err) + } +} + +// 5. 测试非流式超大 Response Body 有界拦截,返回 ErrResponseBodyTooLarge +func TestReadBoundedResponseBodyOversized(t *testing.T) { + oversizedBody := strings.Repeat("b", MaxResponseBodyBytes+512) + _, err := ReadBoundedResponseBody(strings.NewReader(oversizedBody), MaxResponseBodyBytes) + if err == nil { + t.Fatalf("expected ErrResponseBodyTooLarge for body > 16MiB, got nil") + } + if !errors.Is(err, ErrResponseBodyTooLarge) { + t.Fatalf("expected ErrResponseBodyTooLarge, got %v", err) + } + + // 正常大小读取成功 + normalBody := "{\"status\":\"ok\"}" + data, err := ReadBoundedResponseBody(strings.NewReader(normalBody), MaxResponseBodyBytes) + if err != nil || string(data) != normalBody { + t.Fatalf("normal read error = %v, got %s", err, string(data)) + } +} + +// 6. 验证 Transport Framing 独立于 Guard 开关(关闭语义 guard 仍保留 wire cap) +func TestTransportFramingIndependentOfGuard(t *testing.T) { + // 模拟即使 output_guard_enabled = false + // wire framing cap 仍作为系统安全底线硬性生效 + oversized := strings.Repeat("x", MaxSSELineBytes+100) + err := ReadSSEStreamWithEvent(strings.NewReader(oversized), func(event string, data []byte) bool { + return true + }) + if !errors.Is(err, ErrSSELineTooLong) { + t.Fatalf("wire cap must remain active regardless of guard settings, got %v", err) + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestReadSSEStreamWithEvent.*|TestReadBoundedResponseBody.*|TestTransportFramingIndependentOfGuard"` +Expected: FAIL with compilation error or undefined symbols: ErrSSELineTooLong, ErrSSEEventTooLarge, ReadBoundedResponseBody + +- [ ] **Step 3: Write minimal implementation** + +修改 `proxy/sse_line.go`: +```go +// proxy/sse_line.go +package proxy + +import ( + "bufio" + "bytes" + "errors" + "io" +) + +// SSE 与传输 Wire Limits 属于网络资源硬边界,独立于业务层 output guard 开关 +const ( + MaxSSELineBytes = 8 << 20 // 8 MiB + MaxSSEEventBytes = 8 << 20 // 8 MiB + MaxWSMessageBytes = 8 << 20 // 8 MiB + MaxResponseBodyBytes = 16 << 20 // 16 MiB +) + +var ( + ErrSSELineTooLong = errors.New("sse_line_too_long") + ErrSSEEventTooLarge = errors.New("sse_event_too_large") + ErrWSFrameTooLarge = errors.New("ws_frame_too_large") + ErrResponseBodyTooLarge = errors.New("response_body_too_large") +) + +// readBoundedSSELine 读取单行,严格限制在 limit 字节以内 +func readBoundedSSELine(reader *bufio.Reader, limit int) ([]byte, error) { + var line []byte + for { + fragment, err := reader.ReadSlice('\n') + if len(line)+len(fragment) > limit { + return nil, ErrSSELineTooLong + } + line = append(line, fragment...) + if err != bufio.ErrBufferFull { + return line, err + } + } +} + +// ReadBoundedResponseBody 有界读取非流式响应体 +func ReadBoundedResponseBody(body io.Reader, limit int64) ([]byte, error) { + if limit <= 0 { + limit = MaxResponseBodyBytes + } + lr := io.LimitReader(body, limit+1) + data, err := io.ReadAll(lr) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, ErrResponseBodyTooLarge + } + return data, nil +} +``` + +修改 `proxy/executor.go` 中的 `ReadSSEStreamWithEvent`: +```go +// proxy/executor.go (更新 ReadSSEStreamWithEvent,施加行与多行整事件硬 Cap) +func ReadSSEStreamWithEvent(body io.Reader, callback func(event string, data []byte) bool) error { + buf := sseBufferPool.Get().([]byte) + defer sseBufferPool.Put(buf) + + lineBufPtr := sseLineBufPool.Get().(*[]byte) + lineBuf := (*lineBufPtr)[:0] + defer func() { + if cap(lineBuf) <= 256*1024 { + *lineBufPtr = lineBuf[:0] + sseLineBufPool.Put(lineBufPtr) + } + }() + + var dataLines [][]byte + var eventName string + var currentEventBytes int + + emitEvent := func() (bool, error) { + event := eventName + eventName = "" + currentEventBytes = 0 + if len(dataLines) == 0 { + return true, nil + } + + data := dataLines[0] + if len(dataLines) > 1 { + data = bytes.Join(dataLines, []byte("\n")) + } + isDone := bytes.Equal(data, []byte("[DONE]")) + keepReading := !isDone && callback(event, data) + for i := range dataLines { + dataLines[i] = nil + } + dataLines = dataLines[:0] + return keepReading, nil + } + + consumeField := func(line []byte) error { + if bytes.HasPrefix(line, []byte("data:")) { + data := bytes.TrimPrefix(line, []byte("data:")) + data = bytes.TrimPrefix(data, []byte(" ")) + if currentEventBytes+len(data) > MaxSSEEventBytes { + return ErrSSEEventTooLarge + } + currentEventBytes += len(data) + dataCopy := make([]byte, len(data)) + copy(dataCopy, data) + dataLines = append(dataLines, dataCopy) + return nil + } + if bytes.HasPrefix(line, []byte("event:")) { + event := bytes.TrimPrefix(line, []byte("event:")) + event = bytes.TrimPrefix(event, []byte(" ")) + eventName = string(event) + } + return nil + } + + for { + n, err := body.Read(buf) + if n > 0 { + if len(lineBuf)+n > MaxSSELineBytes { + return ErrSSELineTooLong + } + lineBuf = append(lineBuf, buf[:n]...) + + consumed := 0 + for { + idx := bytes.IndexByte(lineBuf[consumed:], '\n') + if idx < 0 { + if len(lineBuf)-consumed > MaxSSELineBytes { + return ErrSSELineTooLong + } + break + } + + lineEnd := consumed + idx + line := bytes.TrimRight(lineBuf[consumed:lineEnd], "\r") + consumed = lineEnd + 1 + + if len(line) == 0 { + keep, emitErr := emitEvent() + if emitErr != nil { + return emitErr + } + if !keep { + return nil + } + continue + } + + if bytes.HasPrefix(line, []byte(":")) { + continue + } + + if err := consumeField(line); err != nil { + return err + } + } + + if consumed > 0 { + remaining := copy(lineBuf, lineBuf[consumed:]) + lineBuf = lineBuf[:remaining] + } + } + + if err != nil { + if err == io.EOF { + if len(lineBuf) > 0 { + if len(lineBuf) > MaxSSELineBytes { + return ErrSSELineTooLong + } + line := bytes.TrimRight(lineBuf, "\r") + if err := consumeField(line); err != nil { + return err + } + } + _, emitErr := emitEvent() + if emitErr != nil { + return emitErr + } + return nil + } + return err + } + } +} +``` + +**全链路真实 Callsite 替换与集成代码(真实代码,非伪装实现):** + +> **架构与编译边界说明**:Task 8 的核心 Green 验收目标为传输 Wire Limits(`ReadSSEStreamWithEvent` 行与整事件 Cap、WebSocket ReadPump `SetReadLimit(MaxWSMessageBytes)` 以及 `ReadBoundedResponseBody`)的独立单元测试。下述 Callsite 替换定义了全链路传输边界。其中 HTTP/Gemini 接入全局 guard 协调器的调用点挂载点在 Task 9(终止协调器)就绪后即可无缝接通;在 Task 8 阶段,`ReadSSEStreamWithEvent` 与 `ReadBoundedResponseBody` 直接替代旧有无界读取,杜绝失控事件造成内存膨胀。 + +1. **Responses HTTP SSE 调用点替换 (`proxy/handler.go:5538-5585`)**: +```go +// 在 forwardWithEvent 最前置接入 ObserveEvent: +forwardWithEvent := func(sseEvent string, data []byte) bool { + // 步骤 0: 优先进入全局模型输出失控防护 Guard + if guardCoordinator != nil { + if err := ObserveEvent(guardCoordinator.Guard(), responsesProjector, sseEvent, data); err != nil { + guardCoordinator.TriggerGuard(err) + return false // 立即阻断,不进入后续 provenance/collector/replay + } + } + + if multiAgentV2State.isOptimizedFor(account.ID()) { + data = restoreCodexMultiAgentV2Response(data, multiAgentV2State) + } + streamDiag.markUpstreamFrame() + // ... 后续 compactionProvenancePayloads 记录与 downstream 写入 +``` + +2. **ChatCompletions 调用点替换 (`proxy/handler.go:7650-7750`)**: +```go +// 在 ChatCompletions SSE 循环中: +readErr = readSSEStreamWithContinuousRetryKeepalive(readCtx, resp.Body, func(sseEvent string, data []byte) bool { + if guardCoordinator != nil { + if err := ObserveEvent(guardCoordinator.Guard(), chatProjector, sseEvent, data); err != nil { + guardCoordinator.TriggerGuard(err) + return false + } + } + // ... 既有下游写入逻辑 +}) +``` + +3. **Gemini Native 调用点替换 (`proxy/handler_gemini.go:390-420`)**: +```go +// forwardGeminiNativeStream 替换: +result.readErr = readSSEStreamWithContinuousRetryKeepalive(c.Request.Context(), body, func(sseEvent string, data []byte) bool { + if c.Request.Context().Err() != nil { + return false + } + if guardCoordinator != nil { + if err := ObserveEvent(guardCoordinator.Guard(), geminiProjector, sseEvent, data); err != nil { + guardCoordinator.TriggerGuard(err) + return false + } + } + usage.observe(data) + // ... +}) +``` + +4. **WebSocket ReadPump 帧大小与内存 Cap 替换 (`proxy/responses_ws.go:160-185`)**: +```go +// 在 startResponsesWSReadPump 中强化 SetReadLimit: +conn.SetReadLimit(MaxWSMessageBytes) // 强制 8 MiB 限制 +messageType, reader, err := conn.NextReader() +if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { + // 若超过 MaxWSMessageBytes,底层 gorilla/websocket 返回 ErrReadLimit + // 归因为独立 typed reason: ws_frame_too_large,绝不伪装为 repetition + cancel() + select { + case messages <- responsesWSInboundMessage{turn: turn, err: ErrWSFrameTooLarge}: + default: + } + return + } +} +``` + +5. **非流式 HTTP 响应读取点替换 (`proxy/handler.go`)**: +```go +// 替换所有裸读取 resp.Body 的非流式调用: +// 修改前: rawResp, err := io.ReadAll(resp.Body) +// 修改后: +rawResp, err := ReadBoundedResponseBody(resp.Body, MaxResponseBodyBytes) +if err != nil { + if errors.Is(err, ErrResponseBodyTooLarge) { + log.Printf("上游非流式响应体超标 (limit=%d): %v", MaxResponseBodyBytes, err) + c.JSON(http.StatusBadGateway, gin.H{"error": gin.H{ + "code": "response_body_too_large", + "message": "Upstream response body exceeded transport wire limit", + }}) + return + } + // ... 其他网络读取错误 +} +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestReadSSEStreamWithEvent.*|TestReadBoundedResponseBody.*|TestTransportFramingIndependentOfGuard"` +Expected: PASS ok github.com/codex2api/proxy + +- [ ] **Step 5: Commit** + +```bash +git add proxy/sse_line.go proxy/sse_line_test.go proxy/executor.go proxy/handler.go proxy/handler_gemini.go proxy/responses_ws.go +git commit -m "feat(proxy): enforce bounded SSE stream, multiline event, WS frame, and response body caps" +``` diff --git a/docs/superpowers/plans/model-output-guard-tdd/01-core.md b/docs/superpowers/plans/model-output-guard-tdd/01-core.md new file mode 100644 index 000000000..75a700d00 --- /dev/null +++ b/docs/superpowers/plans/model-output-guard-tdd/01-core.md @@ -0,0 +1,1653 @@ +# 01-Core: 模型输出失控防护核心算法与请求状态机 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 基于已审查通过的设计规格,实现 Codex2API 全局模型输出失控防护核心算法库与请求级状态机(`security/outputguard` 模块,包含 Task 1、Task 2、Task 3),提供常量空间 8192B 滑动窗口前缀函数短周期线性重复检测、Channel 扫描预算与纯终态确认许可机制、以及请求级 `RequestGuard` 的 8 步无歧义判定流水线与读写锁并发保护。 + +**Architecture:** +1. `security/outputguard/repetition.go`: 常量空间 8192-byte ring buffer,KMP 前缀函数求最短周期($1 \le p \le 256$,支持非整除周期数学判定),1024B 步长检查点与即时尾检;单流水线 `PushWithHash` 实现一次遍历同时完成滚动更新、周期检测与 FNV-1a Hash 计算。 +2. `security/outputguard/channel.go`: 单 Channel 状态机,维护已计费高水位、累计扫描预算扣减($2 \times \text{limit}$)、最多 3 次合法纯终态快照白名单许可(`arguments.done`、`item.done`、`response.completed`),单遍历 Hash 流水线比对与变体篡改废弃,物理处理上界绝对锁定于 $5 \times \text{limit}$。 +3. `security/outputguard/guard.go`: 请求级 `RequestGuard` 状态机,统一管理 65536 业务事件预算、128 累计 Channel 预算、4096 工具 Delta 预算,严格执行 Spec §5.3 的 8 步无歧义判定流水线;晚到 ID 双向别名合并;首 reason 冻结与幂等;内置 `sync.RWMutex` 保证完全并发安全与零数据竞争。 + +**Tech Stack:** Go 1.26+ (`sync`, `sync/atomic`, `errors`, `bytes`, `testing`). + +**Spec:** `docs/superpowers/specs/2026-10-28-model-output-guard-design.md` + +## Global Constraints + +- **范围限制**:本项目仅实施核心检测与状态机包 `security/outputguard`(涵盖 Task 1、Task 2、Task 3);不修改 spec、旧 plan、candidate 或上游生产业务代码。 +- **不可妥协的设计取舍**: + - 首期采用精确短周期重复检测 + 硬预算,不引入语义模型,不自动改写或拆分工具参数。 + - 默认工具参数上限 128 KiB、4096 非空 deltas;单 channel 字节上限 2 MiB;单请求字节上限 4 MiB。所有配置在测试中由 `Config` 明确赋值,不得产生未显式赋值的语义混乱。 + - 有限字符串周期数学判定:KMP border $k = \pi[L-1]$ 对应最小周期候选值 $p = L - k$。当且仅当 $1 \le p \le 256$ 且 $\forall i \in [p, L): s[i] == s[i-p]$ 时即为严格周期,绝不加入 $L \pmod p == 0$ 的整除性过滤(如事故模式 $p=30, 8192 \pmod{30} = 22 \ne 0$)。 + - 尾检规则:累计字节 $< 8192$ 绝不判定重复;已满 8192 窗口但尾部未对齐 1024 时,提取最新的完整 8192 字节窗口执行严格周期检查。 + - 扫描预算与纯终态确认:单 channel 扫描预算上限为 $2 \times \text{limit}$;最多 3 次白名单终态确认许可(`cost = 0`,仅在具备资格且 Hash 完全一致时生效;变体篡改或重发全额扣费 `cost = len(snapshot)`);总校验与处理量受限于 $5 \times \text{limit}$;扫描与 Hash 计算必须在单次遍历流水线(`PushWithHash`)中完成。 + - 判定优先级(Spec §5.3 严格 8 步): + 1. 请求业务事件检查(`request_events > 65536`) + 2. Channel 数量检查(`channel_count > 128`,closed tombstone 不返还累计配额) + 3. Delta 计数检查(仅工具 channel 且为非空 append,`tool_argument_deltas > 4096`) + 4. 语义字节预算检查(channel 高水位与 `request_bytes` 累加,必须先于步骤 6 扫描预算判定) + 5. 资格/等价性核验与扫描成本确定(先查资格再 Hash,无资格绝不提前 Hash) + 6. 扫描预算扣减与检查(超额扫描拦截并映射稳定 reason:工具 channel 为 `tool_argument_bytes`,普通 channel 为 `channel_bytes`) + 7. 周期性重复检测扫描(`repetition`) + 8. 终态链兼容性放行 + - 并发契约:内置 `sync.RWMutex`,读写锁不重入,首个 reason 冻结且后续判定幂等。 + - 内存约束:每个活动 channel 状态保持在 $\le 64\text{KiB}$,`EndChannel` 释放检测窗口。 +- **编译与验证约束**: + - 测试命令使用独立缓存环境:`GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race ...`。 +- **TDD 流程纪律**:每个任务严格遵循 Step 1 (Write failing test) -> Step 2 (Verify Red) -> Step 3 (Minimal implementation) -> Step 4 (Verify Green) -> Step 5 (Commit)。绝不使用占位符,严禁假测试。 + +--- + +## File Structure + +| 文件路径 | 状态 | 职责描述 | +|---|---|---| +| `security/outputguard/repetition.go` | 新建 | 8192B 滑动窗口 Ring Buffer、KMP 前缀函数求最短周期($1 \le p \le 256$、支持非整除周期)、1024B 步长检查点与严格尾检、单遍历 Hash 流水线(`PushWithHash`) | +| `security/outputguard/repetition_test.go` | 新建 | 周期检测器单元测试(命中短周期、非整倍数周期 $p=30$、非周期伪随机放行、大 delta 跨步长、<8192B 尾检放行、未对齐最新 8192B 窗口检测、连续 9215B 保证命中) | +| `security/outputguard/channel.go` | 新建 | 单 Channel 状态机:已计费高水位推进、累计扫描预算扣减($2 \times \text{limit}$)、3 次白名单纯终态快照许可、单遍历 Hash 流水线、处理量 $5 \times \text{limit}$ 封顶 | +| `security/outputguard/channel_test.go` | 新建 | Channel 状态机单元测试(高水位推进、用例 8a/8b/8c 纯终态许可、变体篡改废弃许可与超额拦截、短文本放行) | +| `security/outputguard/guard.go` | 新建 | 请求级 `RequestGuard` 协调器:事件预算(65536)、Channel 累计配额(128)、工具 Delta 计数(4096)、Spec §5.3 严格 8 步判定流水线、晚到 ID 双向映射、首 reason 冻结、`sync.RWMutex` 读写安全 | +| `security/outputguard/guard_test.go` | 新建 | 请求级 Guard 单元测试(事件洪泛拦截、Channel 洪泛拦截与关闭不返还、Delta 洪泛拦截、字节溢出与优先级、首个 reason 冻结、RecordEvent 与 NoEvent 批处理、并发 race 检验) | + +--- + +## Tasks + +### Task 1: 周期性短重复检测核心算法(Prefix Function 线性检测器) + +**Files:** +- Create: `security/outputguard/repetition.go` +- Test: `security/outputguard/repetition_test.go` + +**Interfaces:** +- Produces: + ```go + package outputguard + + const ( + WindowSizeBytes = 8192 + CheckpointStepBytes = 1024 + MaxRepetitionPeriod = 256 + MinRepetitionPeriod = 1 + ) + + type RepetitionDetector struct { + window [WindowSizeBytes]byte + count int64 // channel 累计写入总字节数 + head int // ring buffer 写入游标 (0..8191) + lastCheckedCheckpoint int64 + } + + func NewRepetitionDetector() *RepetitionDetector + func (d *RepetitionDetector) Push(data []byte) (hit bool, period int) + func (d *RepetitionDetector) PushWithHash(data []byte, initialHash uint64) (hit bool, period int, finalHash uint64) + func (d *RepetitionDetector) CheckTail() (hit bool, period int) + func (d *RepetitionDetector) Reset() + ``` + +- [ ] **Step 1: Write failing test for repetition detector (including non-multiple periodic math verification and unaligned tail check)** + +```go +// security/outputguard/repetition_test.go +package outputguard + +import ( + "bytes" + "math/rand" + "testing" +) + +func TestRepetitionDetectorNormalText(t *testing.T) { + d := NewRepetitionDetector() + // 伪随机非周期数据 + r := rand.New(rand.NewSource(12345)) + nonRepeating := make([]byte, 10000) + r.Read(nonRepeating) + + hit, _ := d.Push(nonRepeating) + if hit { + t.Fatalf("expected no repetition hit for pseudo-random data, got hit=true") + } +} + +func TestRepetitionDetectorShortPeriodHit(t *testing.T) { + d := NewRepetitionDetector() + // 创建真实事故重复模式:周期 p = 30 ("_auditfree.md_pluginless.tmp.m") + // 数学核实重点:8192 % 30 = 22 != 0,验证有限串数学周期不要求 L % p == 0! + pattern := []byte("_auditfree.md_pluginless.tmp.m") + if len(pattern) != 30 { + t.Fatalf("pattern length = %d, want 30", len(pattern)) + } + var stream []byte + for len(stream) < 9500 { + stream = append(stream, pattern...) + } + + // 以 512 字节分块送入 + hitFound := false + var hitPeriod int + for offset := 0; offset < len(stream); offset += 512 { + end := offset + 512 + if end > len(stream) { + end = len(stream) + } + hit, p := d.Push(stream[offset:end]) + if hit { + hitFound = true + hitPeriod = p + break + } + } + + if !hitFound { + t.Fatalf("expected repetition hit for 30-byte periodic pattern (non-multiple of 8192), got hit=false") + } + if hitPeriod != 30 { + t.Fatalf("expected detected period 30, got %d", hitPeriod) + } +} + +func TestRepetitionDetectorTailCheckShortText(t *testing.T) { + d := NewRepetitionDetector() + // 短文本 < 8192 字节:即使内容高度周期,尾检必须返回 hit=false (Spec §5.2 规则 6) + pattern := bytes.Repeat([]byte("abc"), 500) // 1500 字节 + hit, _ := d.Push(pattern) + if hit { + t.Fatalf("expected hit=false during push for <8192 bytes") + } + hit, _ = d.CheckTail() + if hit { + t.Fatalf("expected hit=false for tail check with <8192 bytes total") + } +} + +func TestRepetitionDetectorTailCheckUnalignedWindow(t *testing.T) { + d := NewRepetitionDetector() + // 构造真实非对齐尾检样本: + // 前 108 字节为非周期杂散前缀,后随 8192 字节周期为 10 的重复片段,总长 8300 字节。 + // 在常规 Push 过程中: + // - 当累计达到 8192 字节时触发检查点,此时窗口包含 108 字节杂散前缀 + 8084 字节周期内容,不是完整周期,Push 判定 hit=false; + // - 后续送入剩余 108 字节(总长 8300 字节),尚未到达下一个 1024 检查点(9216 字节),Push 仍未命中; + // - 此时流结束触发 CheckTail():提取当前最新的 8192 字节窗口,恰好完全由周期 10 的数据组成,CheckTail 必须精准返回 hit=true, period=10。 + prefix := []byte("non-periodic-header-padding-1234567890-abcdefghijklmnopqrstuvwxyz!@#$%^&*()-random-preamble-bytes-to-offset-") + if len(prefix) != 108 { + t.Fatalf("prefix length = %d, want 108", len(prefix)) + } + pattern := []byte("0123456789") // period 10 + periodicPart := bytes.Repeat(pattern, 8192/10) + periodicPart = append(periodicPart, pattern[:8192%10]...) // 恰好 8192 字节纯周期 + if len(periodicPart) != 8192 { + t.Fatalf("periodicPart length = %d, want 8192", len(periodicPart)) + } + + fullStream := append(prefix, periodicPart...) // 总长 108 + 8192 = 8300 字节 + + // 分块推送,断言 Push 期间不会命中(因为 8192 检查点包含前缀,而后续未到 9216 检查点) + for offset := 0; offset < len(fullStream); offset += 500 { + end := offset + 500 + if end > len(fullStream) { + end = len(fullStream) + } + hit, _ := d.Push(fullStream[offset:end]) + if hit { + t.Fatalf("Push prematurely hit at offset %d, expected tail check to detect", offset) + } + } + + // 触发尾检 + hit, p := d.CheckTail() + if !hit { + t.Fatalf("expected CheckTail to detect pure periodic window of latest 8192 bytes, got hit=false") + } + if p != 10 { + t.Fatalf("expected detected period 10, got %d", p) + } +} + +func TestRepetitionDetectorConsecutive9215BytesGuaranteedHit(t *testing.T) { + d := NewRepetitionDetector() + // Spec §5.2 规则 3: 步长 1024B,任意起点的连续周期片段达到 9215B 时必存在检查点完整落入 + prefix := make([]byte, 512) // 任意偏置前缀 512B + for i := range prefix { + prefix[i] = byte(i % 127) + } + pattern := []byte("repeated_segment_32_bytes_test_") + periodicBody := bytes.Repeat(pattern, 300) // > 9215B + stream := append(prefix, periodicBody...) + + hit := false + for offset := 0; offset < len(stream); offset += 256 { + end := offset + 256 + if end > len(stream) { + end = len(stream) + } + if ok, _ := d.Push(stream[offset:end]); ok { + hit = true + break + } + } + if !hit { + t.Fatalf("expected 9215-byte periodic segment to trigger repetition hit within checkpoints") + } +} + +func TestRepetitionDetectorPushWithHashPipelined(t *testing.T) { + d := NewRepetitionDetector() + data := []byte("abcdefghijklmnopqrstuvwxyz0123456789") + const fnvOffset uint64 = 14695981039346656037 + hit, p, finalHash := d.PushWithHash(data, fnvOffset) + if hit || p != 0 { + t.Fatalf("unexpected hit on short non-repeating data: hit=%v, p=%d", hit, p) + } + // 计算预期 FNV-1a Hash + var expectedHash uint64 = fnvOffset + for _, b := range data { + expectedHash = (expectedHash ^ uint64(b)) * 1099511628211 + } + if finalHash != expectedHash { + t.Fatalf("finalHash want %d, got %d", expectedHash, finalHash) + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./security/outputguard -run "TestRepetitionDetector.*"` +Expected: FAIL with compilation error: undefined: NewRepetitionDetector + +- [ ] **Step 3: Write minimal implementation** + +```go +// security/outputguard/repetition.go +package outputguard + +const ( + WindowSizeBytes = 8192 + CheckpointStepBytes = 1024 + MaxRepetitionPeriod = 256 + MinRepetitionPeriod = 1 +) + +type RepetitionDetector struct { + window [WindowSizeBytes]byte + count int64 // channel 累计写入总字节数 + head int // ring buffer 写入游标 (0..8191) + lastCheckedCheckpoint int64 +} + +func NewRepetitionDetector() *RepetitionDetector { + return &RepetitionDetector{} +} + +func (d *RepetitionDetector) Reset() { + d.count = 0 + d.head = 0 + d.lastCheckedCheckpoint = 0 +} + +func (d *RepetitionDetector) Push(data []byte) (hit bool, period int) { + for _, b := range data { + d.window[d.head] = b + d.head = (d.head + 1) % WindowSizeBytes + d.count++ + + // 每新增 CheckpointStepBytes 到达检查点 + if d.count >= WindowSizeBytes && (d.count-d.lastCheckedCheckpoint) >= CheckpointStepBytes { + d.lastCheckedCheckpoint = d.count - (d.count % CheckpointStepBytes) + if ok, p := d.checkCurrentWindow(); ok { + return true, p + } + } + } + return false, 0 +} + +// PushWithHash 一次遍历同时流水线计算 FNV-1a Hash 并执行滑动窗口重复检测 (Spec §5.3) +func (d *RepetitionDetector) PushWithHash(data []byte, initialHash uint64) (hit bool, period int, finalHash uint64) { + h := initialHash + for _, b := range data { + h = (h ^ uint64(b)) * 1099511628211 + + d.window[d.head] = b + d.head = (d.head + 1) % WindowSizeBytes + d.count++ + + // 每新增 CheckpointStepBytes 到达检查点 + if d.count >= WindowSizeBytes && (d.count-d.lastCheckedCheckpoint) >= CheckpointStepBytes { + d.lastCheckedCheckpoint = d.count - (d.count % CheckpointStepBytes) + if ok, p := d.checkCurrentWindow(); ok { + return true, p, h + } + } + } + return false, 0, h +} + +func (d *RepetitionDetector) CheckTail() (hit bool, period int) { + // Spec §5.2 规则 6: 如果 channel 累计总字节数 < 8192,不判定重复,直接放行 + if d.count < WindowSizeBytes { + return false, 0 + } + // 已满 8192 窗口但尾部未对齐 1024,提取最新的完整 8192 窗口执行一次求周期检查 + return d.checkCurrentWindow() +} + +// checkCurrentWindow 提取当前 ring buffer 中的线性 8192 字节,使用前缀函数求最短周期 1 <= p <= 256 +// 数学核实说明:有限字符串周期不要求 L % p == 0(例如事故周期 p=30, 8192 % 30 = 22 != 0)。 +// 前缀函数的最大 border k = pi[L-1] 对应最小周期 p = L - k。 +// 当且仅当 1 <= p <= 256 且满足全部 p <= i < 8192 的 window[i] == window[i-p] 时判定命中。 +func (d *RepetitionDetector) checkCurrentWindow() (bool, int) { + if d.count < WindowSizeBytes { + return false, 0 + } + // 展开当前最新的 8192 字节连续视图 + var linear [WindowSizeBytes]byte + idx := d.head // d.head 此时指向最老的那个字节 + for i := 0; i < WindowSizeBytes; i++ { + linear[i] = d.window[(idx+i)%WindowSizeBytes] + } + + // 采用 KMP prefix function (pi table) 在 O(N) 线性时间求周期 + pi := make([]int, WindowSizeBytes) + for i := 1; i < WindowSizeBytes; i++ { + j := pi[i-1] + for j > 0 && linear[i] != linear[j] { + j = pi[j-1] + } + if linear[i] == linear[j] { + j++ + } + pi[i] = j + } + + k := pi[WindowSizeBytes-1] + p := WindowSizeBytes - k + if p >= MinRepetitionPeriod && p <= MaxRepetitionPeriod { + // 校验边界:根据 border 定义,linear[0..k-1] == linear[p..L-1] + // 即对于所有 p <= i < 8192, 严格满足 linear[i] == linear[i-p] + return true, p + } + + return false, 0 +} +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./security/outputguard -run "TestRepetitionDetector.*"` +Expected: PASS ok github.com/codex2api/security/outputguard + +- [ ] **Step 5: Commit** + +```bash +git add security/outputguard/repetition.go security/outputguard/repetition_test.go +git commit -m "feat(outputguard): implement sliding window linear repetition detector" +``` + +--- + +### Task 2: Channel 状态机与扫描预算/纯终态确认配额机制 + +**Files:** +- Create: `security/outputguard/channel.go` +- Test: `security/outputguard/channel_test.go` + +**Interfaces:** +- Consumes: `RepetitionDetector` from Task 1 +- Produces: + ```go + package outputguard + + type ChannelKind string + const ( + KindText ChannelKind = "text" + KindThinking ChannelKind = "thinking" + KindToolArguments ChannelKind = "tool_arguments" + ) + + type ChannelGuard struct { + id string + kind ChannelKind + maxBytes int64 + maxDeltas int + remainingScanBudget int64 + highwater int64 + deltaCount int + terminalBaselineHash uint64 + hasBaselineHash bool + terminalPermitsUsed int + consumedTerminalTypes map[string]bool + detector *RepetitionDetector + isClosed bool + } + + func NewChannelGuard(id string, kind ChannelKind, maxBytes int64, maxDeltas int) *ChannelGuard + func (c *ChannelGuard) Highwater() int64 + func (c *ChannelGuard) DeltaCount() int + func (c *ChannelGuard) RemainingScanBudget() int64 + func (c *ChannelGuard) ReleaseWindow() + func (c *ChannelGuard) CheckAppend(delta []byte) (growth int64, cost int64, err error) + func (c *ChannelGuard) CommitAppend(delta []byte, growth int64, cost int64) error + func (c *ChannelGuard) CheckSnapshot(snapshot []byte, terminalStage string) (growth int64, cost int64, skipRepetition bool, err error) + func (c *ChannelGuard) CommitSnapshot(snapshot []byte, growth int64, cost int64, terminalStage string) error + func (c *ChannelGuard) CheckTail() error + ``` + +- [ ] **Step 1: Write failing test for channel state machine and quota** + +```go +// security/outputguard/channel_test.go +package outputguard + +import ( + "bytes" + "math/rand" + "testing" +) + +func TestChannelGuardHighwaterGrowthAndScanBudget(t *testing.T) { + // 工具 channel: maxBytes = 128 KiB (131072), scanBudget = 2 * 128 KiB = 256 KiB + cg := NewChannelGuard("call_1", KindToolArguments, 131072, 4096) + + // Append 64 KiB of non-repeating data + delta1 := make([]byte, 65536) + r1 := rand.New(rand.NewSource(10001)) + r1.Read(delta1) + + growth, cost, err := cg.CheckAppend(delta1) + if err != nil { + t.Fatalf("CheckAppend delta1 error: %v", err) + } + if growth != 65536 || cost != 65536 { + t.Fatalf("delta1: want growth=65536, cost=65536, got growth=%d, cost=%d", growth, cost) + } + if err := cg.CommitAppend(delta1, growth, cost); err != nil { + t.Fatalf("CommitAppend delta1 error: %v", err) + } + + if cg.Highwater() != 65536 { + t.Fatalf("want highwater=65536, got %d", cg.Highwater()) + } + if cg.RemainingScanBudget() != 262144-65536 { + t.Fatalf("want remainingScanBudget=%d, got %d", 262144-65536, cg.RemainingScanBudget()) + } +} + +// Spec §9.1 Case 8a: 耗尽扫描预算后变体快照超额拦截 +func TestChannelGuardCase8a_ScanBudgetExhaustionAndVariantRejection(t *testing.T) { + cg := NewChannelGuard("call_1", KindToolArguments, 131072, 4096) + // 以 128 次 1 KiB delta 耗尽 128 KiB 扫描预算 (剩余 128 KiB) + chunk := make([]byte, 1024) + r2 := rand.New(rand.NewSource(20002)) + r2.Read(chunk) + var fullParam []byte + for i := 0; i < 128; i++ { + fullParam = append(fullParam, chunk...) + growth, cost, err := cg.CheckAppend(chunk) + if err != nil { + t.Fatalf("step %d append error: %v", i, err) + } + if err := cg.CommitAppend(chunk, growth, cost); err != nil { + t.Fatalf("step %d commit append error: %v", i, err) + } + } + if cg.RemainingScanBudget() != 131072 { + t.Fatalf("remaining scan budget want 131072, got %d", cg.RemainingScanBudget()) + } + + // 首个 128 KiB 白名单终态快照到达 (arguments.done),尚无 baseline hash,cost = 128 KiB + growth, cost, skipRepetition, err := cg.CheckSnapshot(fullParam, "arguments.done") + if err != nil { + t.Fatalf("arguments.done snapshot error: %v", err) + } + if cost != 131072 || growth != 0 || skipRepetition { + t.Fatalf("arguments.done: want cost=131072, growth=0, skipRepetition=false; got cost=%d, growth=%d, skip=%v", cost, growth, skipRepetition) + } + if err := cg.CommitSnapshot(fullParam, growth, cost, "arguments.done"); err != nil { + t.Fatalf("arguments.done commit snapshot error: %v", err) + } + + if cg.RemainingScanBudget() != 0 { + t.Fatalf("remaining scan budget want 0, got %d", cg.RemainingScanBudget()) + } + + // 紧接着收到修改了 1 个字节的 128 KiB 变体快照 (item.done) + variantParam := bytes.Clone(fullParam) + variantParam[65536] ^= 0xFF + + _, _, _, err = cg.CheckSnapshot(variantParam, "item.done") + if err == nil { + t.Fatalf("expected variant snapshot to be rejected with scan budget exhaustion, got nil") + } + if err != ErrScanBudgetExhausted { + t.Fatalf("expected ErrScanBudgetExhausted, got %v", err) + } +} + +// Spec §9.1 Case 8b: 合法终态链零计费放行与无资格拒绝 +func TestChannelGuardCase8b_TerminalChainZeroCostAndExhaustion(t *testing.T) { + cg := NewChannelGuard("call_1", KindToolArguments, 131072, 4096) + fullParam := make([]byte, 131072) + r3 := rand.New(rand.NewSource(30003)) + r3.Read(fullParam) + // append 128 KiB + growth, cost, _ := cg.CheckAppend(fullParam) + _ = cg.CommitAppend(fullParam, growth, cost) + + // arguments.done 消耗最后 128 KiB 扫描预算并建立 baseline hash + growth, cost, _, _ = cg.CheckSnapshot(fullParam, "arguments.done") + _ = cg.CommitSnapshot(fullParam, growth, cost, "arguments.done") + + // item.done 具备资格且 Hash 完全一致 -> cost = 0, skipRepetition = true + growth, cost, skipRepetition, err := cg.CheckSnapshot(fullParam, "item.done") + if err != nil { + t.Fatalf("item.done snapshot error: %v", err) + } + if cost != 0 || !skipRepetition { + t.Fatalf("item.done: want cost=0, skipRepetition=true; got cost=%d, skip=%v", cost, skipRepetition) + } + _ = cg.CommitSnapshot(fullParam, growth, cost, "item.done") + + // response.completed 同样具备资格且 Hash 一致 -> cost = 0 + growth, cost, skipRepetition, err = cg.CheckSnapshot(fullParam, "response.completed") + if err != nil { + t.Fatalf("response.completed snapshot error: %v", err) + } + if cost != 0 || !skipRepetition { + t.Fatalf("response.completed: want cost=0, skipRepetition=true") + } + _ = cg.CommitSnapshot(fullParam, growth, cost, "response.completed") + + // 重发同类型 response.completed -> 失去资格,走 cost = 128 KiB,因剩余扫描预算为 0 被拦截 + _, _, _, err = cg.CheckSnapshot(fullParam, "response.completed") + if err != ErrScanBudgetExhausted { + t.Fatalf("duplicate response.completed: want ErrScanBudgetExhausted, got %v", err) + } +} + +// Spec §9.1 Case 8c: 非白名单普通快照绝不建立 baseline +func TestChannelGuardCase8c_NonTerminalSnapshotCannotEstablishBaseline(t *testing.T) { + cg := NewChannelGuard("c_text", KindText, 2097152, 4096) + snap := []byte("intermediate full snapshot text") + growth, cost, skip, err := cg.CheckSnapshot(snap, "unknown_or_intermediate_type") + if err != nil { + t.Fatalf("CheckSnapshot error: %v", err) + } + if skip || cost != int64(len(snap)) { + t.Fatalf("want skip=false, cost=%d, got skip=%v, cost=%d", len(snap), skip, cost) + } + _ = cg.CommitSnapshot(snap, growth, cost, "unknown_or_intermediate_type") + + // 确认 hasBaselineHash 仍为 false,未建立 baseline + if cg.hasBaselineHash { + t.Fatalf("expected hasBaselineHash=false for non-terminal snapshot, got true") + } +} + +func TestChannelGuardHashMismatchDiscardsQuotaAndChargesFullCost(t *testing.T) { + cg := NewChannelGuard("c_tool", KindToolArguments, 131072, 4096) + data1 := bytes.Repeat([]byte("hello world "), 100) // 1200 字节 + growth, cost, _, _ := cg.CheckSnapshot(data1, "arguments.done") + _ = cg.CommitSnapshot(data1, growth, cost, "arguments.done") + + if !cg.hasBaselineHash { + t.Fatalf("expected baseline hash created") + } + + // 紧接着发送篡改变体快照 + data2 := bytes.Repeat([]byte("corrupted data!"), 80) // 1200 字节 + growth, cost, skip, err := cg.CheckSnapshot(data2, "item.done") + if err != nil { + t.Fatalf("unexpected CheckSnapshot error: %v", err) + } + if skip { + t.Fatalf("expected skip=false for hash mismatch") + } + if cost != int64(len(data2)) { + t.Fatalf("expected full cost %d, got %d", len(data2), cost) + } + + // CommitSnapshot 篡改帧后,该类型许可必须标记废弃 + _ = cg.CommitSnapshot(data2, growth, cost, "item.done") + if !cg.consumedTerminalTypes["item.done"] { + t.Fatalf("expected item.done marked as consumed/discarded") + } + // 基准 Hash 绝不能被篡改快照覆盖 + h := fnv64a(data1) + if cg.terminalBaselineHash != h { + t.Fatalf("expected baseline hash to stay unchanged") + } +} + +func TestChannelGuardTailCheck(t *testing.T) { + cg := NewChannelGuard("c_text", KindText, 2097152, 4096) + // 短文本放行 + _ = cg.CommitAppend([]byte("short text"), 10, 10) + if err := cg.CheckTail(); err != nil { + t.Fatalf("expected nil error for short text tail check, got %v", err) + } + + // 长周期文本检出 + cg2 := NewChannelGuard("c_periodic", KindText, 2097152, 4096) + pattern := []byte("0123456789") + data := bytes.Repeat(pattern, 850) // 8500 字节 + // 避免在 append 触发检查点,直接写入 window + cg2.detector.window = [8192]byte{} + for i := 0; i < len(data); i++ { + cg2.detector.window[i%8192] = data[i] + } + cg2.detector.count = int64(len(data)) + cg2.detector.head = len(data) % 8192 + + if err := cg2.CheckTail(); err != ErrRepetitionDetected { + t.Fatalf("expected ErrRepetitionDetected for periodic tail check, got %v", err) + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./security/outputguard -run "TestChannelGuard.*"` +Expected: FAIL with compilation error: undefined: NewChannelGuard + +- [ ] **Step 3: Write minimal implementation** + +```go +// security/outputguard/channel.go +package outputguard + +import ( + "errors" + "math" +) + +var ( + ErrToolArgumentBytesExceeded = errors.New("tool_argument_bytes") + ErrChannelBytesExceeded = errors.New("channel_bytes") + ErrScanBudgetExhausted = errors.New("scan_budget_exhausted") + ErrRepetitionDetected = errors.New("repetition") + ErrToolDeltasExceeded = errors.New("tool_argument_deltas") +) + +type ChannelKind string + +const ( + KindText ChannelKind = "text" + KindThinking ChannelKind = "thinking" + KindToolArguments ChannelKind = "tool_arguments" +) + +// allowedTerminalTypes 白名单终态类型(Spec §5.3) +var allowedTerminalTypes = map[string]bool{ + "arguments.done": true, + "item.done": true, + "response.completed": true, +} + +// fnv64a 计算 64 位 FNV-1a Hash,用于流水线单遍历比对与基准记录 +func fnv64a(data []byte) uint64 { + var h uint64 = 14695981039346656037 + for _, b := range data { + h = (h ^ uint64(b)) * 1099511628211 + } + return h +} + +type ChannelGuard struct { + id string + kind ChannelKind + maxBytes int64 + maxDeltas int + remainingScanBudget int64 + highwater int64 + deltaCount int + terminalBaselineHash uint64 + hasBaselineHash bool + terminalPermitsUsed int + consumedTerminalTypes map[string]bool + detector *RepetitionDetector + isClosed bool +} + +func NewChannelGuard(id string, kind ChannelKind, maxBytes int64, maxDeltas int) *ChannelGuard { + return &ChannelGuard{ + id: id, + kind: kind, + maxBytes: maxBytes, + maxDeltas: maxDeltas, + remainingScanBudget: 2 * maxBytes, // 累计扫描预算为 2 * limit (Spec §5.3) + consumedTerminalTypes: make(map[string]bool), + detector: NewRepetitionDetector(), + } +} + +func (c *ChannelGuard) Highwater() int64 { return c.highwater } +func (c *ChannelGuard) DeltaCount() int { return c.deltaCount } +func (c *ChannelGuard) RemainingScanBudget() int64 { return c.remainingScanBudget } + +func (c *ChannelGuard) ReleaseWindow() { + c.detector = nil + c.isClosed = true +} + +func (c *ChannelGuard) CheckAppend(delta []byte) (growth int64, cost int64, err error) { + deltaLen := int64(len(delta)) + if c.kind == KindToolArguments && deltaLen > 0 { + if c.deltaCount+1 > c.maxDeltas { + return 0, 0, ErrToolDeltasExceeded + } + } + // int64 溢出保护:防止加法溢出翻转为负数绕过预算检查 + if deltaLen > 0 && c.highwater > math.MaxInt64-deltaLen { + if c.kind == KindToolArguments { + return 0, 0, ErrToolArgumentBytesExceeded + } + return 0, 0, ErrChannelBytesExceeded + } + newHighwater := c.highwater + deltaLen + if newHighwater > c.maxBytes { + if c.kind == KindToolArguments { + return 0, 0, ErrToolArgumentBytesExceeded + } + return 0, 0, ErrChannelBytesExceeded + } + + cost = deltaLen + return deltaLen, cost, nil +} + +func (c *ChannelGuard) CommitAppend(delta []byte, growth int64, cost int64) error { + c.highwater += growth + if c.kind == KindToolArguments && len(delta) > 0 { + c.deltaCount++ + } + if c.remainingScanBudget < cost { + c.remainingScanBudget = 0 + return ErrScanBudgetExhausted + } + c.remainingScanBudget -= cost + + if c.detector != nil && len(delta) > 0 { + hit, _ := c.detector.Push(delta) + if hit { + return ErrRepetitionDetected + } + } + return nil +} + +func (c *ChannelGuard) CheckSnapshot(snapshot []byte, terminalStage string) (growth int64, cost int64, skipRepetition bool, err error) { + snapLen := int64(len(snapshot)) + if snapLen > c.highwater { + growth = snapLen - c.highwater + } + if snapLen > c.maxBytes { + if c.kind == KindToolArguments { + return 0, 0, false, ErrToolArgumentBytesExceeded + } + return 0, 0, false, ErrChannelBytesExceeded + } + + // 资格检查:仅白名单终态类型且具备 baseline hash 且许可未耗尽 (Spec §5.3 步骤 5) + // 合法与非法终态配额判断严格在 hash 前!无资格绝不提前计算 hash。 + isEligible := c.hasBaselineHash && + allowedTerminalTypes[terminalStage] && + !c.consumedTerminalTypes[terminalStage] && + c.terminalPermitsUsed < 3 + + if isEligible { + // 计算 snapshot Hash 比对 baseline hash + currentHash := fnv64a(snapshot) + if currentHash == c.terminalBaselineHash { + // Hash 完全一致,纯终态确认快照零计费放行,跳过步骤 7 重复扫描 + return growth, 0, true, nil + } + // Hash 不一致(变体篡改),作废该类型许可,全额计费 + cost = snapLen + } else { + // 无免除资格(包括中间替换、首帧终态、重发同类型或超出 3 次配额),严禁提前 Hash,直接全额计费 + cost = snapLen + } + + if c.remainingScanBudget < cost { + return 0, 0, false, ErrScanBudgetExhausted + } + + return growth, cost, false, nil +} + +func (c *ChannelGuard) CommitSnapshot(snapshot []byte, growth int64, cost int64, terminalStage string) error { + c.highwater += growth + c.remainingScanBudget -= cost + + // 白名单终态类型快照消耗本类型配额 + if allowedTerminalTypes[terminalStage] { + c.consumedTerminalTypes[terminalStage] = true + c.terminalPermitsUsed++ + } + + // 终态首帧建立 baseline hash:仅白名单终态帧类型允许在首帧建立 baseline hash (Spec §5.3) + // 如果之前未建立 baseline 且当前属于白名单类型,在单次遍历中建立 baseline hash + shouldSetBaseline := !c.hasBaselineHash && allowedTerminalTypes[terminalStage] + + if cost > 0 && c.detector != nil { + c.detector.Reset() + + if shouldSetBaseline { + // 单次流水线处理:一次遍历同时流水线计算 FNV-1a Hash 并执行滑动窗口重复检测 (Spec §5.3) + const fnvOffsetBasis uint64 = 14695981039346656037 + hit, _, finalHash := c.detector.PushWithHash(snapshot, fnvOffsetBasis) + c.terminalBaselineHash = finalHash + c.hasBaselineHash = true + if hit { + return ErrRepetitionDetected + } + hit, _ = c.detector.CheckTail() + if hit { + return ErrRepetitionDetected + } + } else { + hit, _ := c.detector.Push(snapshot) + if hit { + return ErrRepetitionDetected + } + hit, _ = c.detector.CheckTail() + if hit { + return ErrRepetitionDetected + } + } + } + + return nil +} + +func (c *ChannelGuard) CheckTail() error { + if c.detector != nil { + if hit, _ := c.detector.CheckTail(); hit { + return ErrRepetitionDetected + } + } + return nil +} +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./security/outputguard -run "TestChannelGuard.*"` +Expected: PASS ok github.com/codex2api/security/outputguard + +- [ ] **Step 5: Commit** + +```bash +git add security/outputguard/channel.go security/outputguard/channel_test.go +git commit -m "feat(outputguard): implement channel guard with scan budget and terminal quota" +``` + +--- + +### Task 3: 请求级 RequestGuard 协调器与统一判定优先级及并发契约 + +**Files:** +- Create: `security/outputguard/guard.go` +- Test: `security/outputguard/guard_test.go` + +**Interfaces:** +- Consumes: `ChannelGuard` from Task 2 +- Produces: + ```go + package outputguard + + import "sync" + + const ( + MaxRequestEvents = 65536 + MaxRequestChannels = 128 + TriggeredCode = "model_output_guard_triggered" + ) + + type Config struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 + } + + type RequestGuard struct { + mu sync.RWMutex + cfg Config + requestEvents int + requestBytes int64 + channels map[string]*ChannelGuard + aliasMap map[string]string // alias mapping (e.g. item_id -> call_id) + tripped bool + trippedReason string + } + + func NewRequestGuard(cfg Config) *RequestGuard + func (g *RequestGuard) RecordEvent() error + func (g *RequestGuard) ObserveAppend(channelID string, aliasID string, kind ChannelKind, delta []byte) error + func (g *RequestGuard) ObserveSnapshot(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error + func (g *RequestGuard) ObserveAppendNoEvent(channelID string, aliasID string, kind ChannelKind, delta []byte) error + func (g *RequestGuard) ObserveSnapshotNoEvent(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error + func (g *RequestGuard) EndChannel(channelID string) error + func (g *RequestGuard) IsTripped() bool + func (g *RequestGuard) TrippedReason() string + func (g *RequestGuard) ActiveChannelCount() int + ``` + +- [ ] **Step 1: Write failing test for RequestGuard (including 8-step precedence, first reason freeze, EndChannel tail check, and concurrency race)** + +```go +// security/outputguard/guard_test.go +package outputguard + +import ( + "bytes" + "fmt" + "math" + "math/rand" + "sync" + "testing" +) + +func defaultTestConfig() Config { + return Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, // 128 KiB + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, // 2 MiB + MaxRequestBytes: 4194304, // 4 MiB + } +} + +func TestRequestGuardRequestEventsFlood(t *testing.T) { + rg := NewRequestGuard(defaultTestConfig()) + for i := 0; i < MaxRequestEvents; i++ { + cid := fmt.Sprintf("c_%d", i%50) + delta := []byte{byte(i % 250)} + if err := rg.ObserveAppend(cid, "", KindText, delta); err != nil { + t.Fatalf("unexpected error at event %d: %v", i, err) + } + } + // 第 65537 个事件必须被拒绝并判定为 request_events (Spec §5.3 步骤 1) + err := rg.ObserveAppend("c_0", "", KindText, []byte("x")) + if err == nil || err.Error() != "request_events" { + t.Fatalf("expected request_events, got %v", err) + } + if !rg.IsTripped() || rg.TrippedReason() != "request_events" { + t.Fatalf("expected tripped with request_events, got %s", rg.TrippedReason()) + } +} + +func TestRequestGuardChannelFlood(t *testing.T) { + rg := NewRequestGuard(defaultTestConfig()) + // 验证累计 128 个 Channel 限制: + for i := 0; i < MaxRequestChannels; i++ { + cid := fmt.Sprintf("chan_%d", i) + if err := rg.ObserveAppend(cid, "", KindText, []byte("x")); err != nil { + t.Fatalf("channel %d error: %v", i, err) + } + } + // 第 129 个 Channel 必须被拒绝并判定为 channel_count (Spec §5.3 步骤 2) + err := rg.ObserveAppend("channel_129", "", KindText, []byte("x")) + if err == nil || err.Error() != "channel_count" { + t.Fatalf("expected channel_count, got %v", err) + } +} + +func TestRequestGuardChannelCumulativeNotReturnedOnEnd(t *testing.T) { + rg := NewRequestGuard(defaultTestConfig()) + // 验证 EndChannel 结束 channel 不返还累计 channel 配额 (Spec §5.1) + for i := 0; i < MaxRequestChannels; i++ { + cid := fmt.Sprintf("chan_%d", i) + _ = rg.ObserveAppend(cid, "", KindText, []byte("x")) + _ = rg.EndChannel(cid) + } + // 尝试再开一个 channel,必须被拒绝为 channel_count + err := rg.ObserveAppend("extra_channel", "", KindText, []byte("x")) + if err == nil || err.Error() != "channel_count" { + t.Fatalf("expected channel_count rejection after 128 closed channels, got %v", err) + } +} + +func TestRequestGuardEndChannelExecutesTailCheck(t *testing.T) { + rg := NewRequestGuard(defaultTestConfig()) + // 构造在 Push 时未触发检查点但流结束时已包含 8192 字节周期的流 + pattern := []byte("0123456789") + periodicBody := bytes.Repeat(pattern, 8192/10) + periodicBody = append(periodicBody, pattern[:8192%10]...) // 恰好 8192B 周期 + // 先推送 100 字节杂散 + prefix := make([]byte, 100) + for i := range prefix { + prefix[i] = byte(i) + } + full := append(prefix, periodicBody...) // 8292 字节,未达下一个 1024 检查点 + + // 推送数据,Push 期间不触发 repetition(8192 检查点含 prefix 杂散,8292 未到检查点) + for offset := 0; offset < len(full); offset += 500 { + end := offset + 500 + if end > len(full) { + end = len(full) + } + _ = rg.ObserveAppend("c_tail", "", KindText, full[offset:end]) + } + if rg.IsTripped() { + t.Fatalf("expected not tripped before EndChannel") + } + + // 调用 EndChannel:必须先执行尾检,发现周期性短重复,成功拦截并冻结 repetition! + err := rg.EndChannel("c_tail") + if err == nil || err.Error() != "repetition" { + t.Fatalf("expected repetition hit during EndChannel tail check, got %v", err) + } + if !rg.IsTripped() || rg.TrippedReason() != "repetition" { + t.Fatalf("expected tripped with repetition, got %s", rg.TrippedReason()) + } +} + +func TestRequestGuardRequestBytesPrecedenceOverScanBudget(t *testing.T) { + // Spec §5.3 严格 8 步优先级:先步骤 4 语义字节预算检查 (request_bytes),后步骤 6 扫描预算 + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 1000, // 仅 1000 字节请求上限 + }) + + // 一次传入 1500 字节快照 -> 必须报 request_bytes,而非 channel_bytes 或 scan_budget + err := rg.ObserveSnapshot("c1", "", KindText, make([]byte, 1500), "") + if err == nil || err.Error() != "request_bytes" { + t.Fatalf("expected request_bytes priority over scan budget, got %v", err) + } + // 首个 reason 必须冻结 + if rg.TrippedReason() != "request_bytes" { + t.Fatalf("expected frozen reason request_bytes, got %s", rg.TrippedReason()) + } + + // 再次调用仍然返回首个冻结 reason + err2 := rg.ObserveAppend("c1", "", KindText, []byte("x")) + if err2 == nil || err2.Error() != "request_bytes" { + t.Fatalf("expected idempotent frozen reason request_bytes, got %v", err2) + } +} + +func TestRequestGuardToolArgumentDeltasFlood(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 10, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + for i := 0; i < 10; i++ { + if err := rg.ObserveAppend("call_1", "", KindToolArguments, []byte("a")); err != nil { + t.Fatalf("delta %d error: %v", i, err) + } + } + // 第 11 个非空 delta 必须被拦截为 tool_argument_deltas (Spec §5.3 步骤 3) + err := rg.ObserveAppend("call_1", "", KindToolArguments, []byte("a")) + if err == nil || err.Error() != "tool_argument_deltas" { + t.Fatalf("expected tool_argument_deltas, got %v", err) + } +} + +func TestRequestGuardRecordEventAndNoEventBatching(t *testing.T) { + rg := NewRequestGuard(defaultTestConfig()) + + // 1. 测试独立 RecordEvent 仅对上游业务事件执行 1 次计数 + for i := 0; i < MaxRequestEvents; i++ { + if err := rg.RecordEvent(); err != nil { + t.Fatalf("unexpected error at event %d: %v", i, err) + } + } + // 第 65537 次 RecordEvent 触发 request_events 判定并冻结 + if err := rg.RecordEvent(); err == nil || err.Error() != "request_events" { + t.Fatalf("expected request_events, got %v", err) + } + if !rg.IsTripped() || rg.TrippedReason() != "request_events" { + t.Fatalf("expected tripped with request_events, got %s", rg.TrippedReason()) + } + + // 2. 测试同事件下多个 Observation 使用 ObserveAppendNoEvent / ObserveSnapshotNoEvent 不重复累加 requestEvents + rgFresh := NewRequestGuard(defaultTestConfig()) + // 记录 1 次上游业务事件 + if err := rgFresh.RecordEvent(); err != nil { + t.Fatalf("RecordEvent failed: %v", err) + } + // 单事件派生 3 个 delta append observation,调用 ObserveAppendNoEvent + for i := 0; i < 3; i++ { + cid := fmt.Sprintf("chan_%d", i) + if err := rgFresh.ObserveAppendNoEvent(cid, "", KindText, []byte("val")); err != nil { + t.Fatalf("ObserveAppendNoEvent observation %d failed: %v", i, err) + } + } + // 断言 requestEvents 依然仅为 1(未被 3 次 observation 累加为 4) + rgFresh.mu.RLock() + events := rgFresh.requestEvents + rgFresh.mu.RUnlock() + if events != 1 { + t.Fatalf("expected requestEvents=1 for batch observations under single event, got %d", events) + } +} + +func TestRequestGuardLateArrivingAliasBidirectionalMerge(t *testing.T) { + rg := NewRequestGuard(defaultTestConfig()) + // 模拟流式 delta 初期仅包含 item_id = "item_123" + if err := rg.ObserveAppend("item_123", "", KindToolArguments, []byte("part1")); err != nil { + t.Fatalf("append with item_123 failed: %v", err) + } + // 稍后完成快照带来 call_id = "call_abc",两者绑定 + if err := rg.ObserveSnapshot("call_abc", "item_123", KindToolArguments, []byte("part1part2"), "arguments.done"); err != nil { + t.Fatalf("snapshot with call_abc and alias item_123 failed: %v", err) + } + // 验证 ActiveChannelCount 依然为 1,没有分裂为两个 channel + if rg.ActiveChannelCount() != 1 { + t.Fatalf("expected ActiveChannelCount=1 after alias merge, got %d", rg.ActiveChannelCount()) + } + // 再次通过 item_123 访问,指向同一 Channel + if err := rg.ObserveSnapshot("item_123", "", KindToolArguments, []byte("part1part2"), "item.done"); err != nil { + t.Fatalf("subsequent snapshot via item_123 failed: %v", err) + } + if rg.ActiveChannelCount() != 1 { + t.Fatalf("expected ActiveChannelCount=1, got %d", rg.ActiveChannelCount()) + } +} + +func TestRequestGuardConcurrentAppendAndIsTrippedNoRace(t *testing.T) { + rg := NewRequestGuard(defaultTestConfig()) + var wg sync.WaitGroup + + // 并发写 + for i := 0; i < 10; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + cid := fmt.Sprintf("c_concurrent_%d", id%3) + for j := 0; j < 50; j++ { + _ = rg.ObserveAppend(cid, "", KindText, []byte("a")) + } + }(i) + } + + // 并发读状态 + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 50; j++ { + _ = rg.IsTripped() + _ = rg.TrippedReason() + _ = rg.ActiveChannelCount() + } + }() + } + + wg.Wait() +} + +func randBytes(n int, seed int64) []byte { + b := make([]byte, n) + rng := rand.New(rand.NewSource(seed)) + _, _ = rng.Read(b) + return b +} + +// TestInt64OverflowProtection: +// 验证 ChannelGuard.highwater 和 RequestGuard.requestBytes 在接近 math.MaxInt64 时, +// 传入正数 delta 不会因为无保护加法溢出翻转为负数而绕过预算检查。 +func TestInt64OverflowProtection(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + _ = rg.ObserveAppend("c_overflow", "", KindText, []byte("init")) + cg := rg.channels["c_overflow"] + + // 模拟高水位接近 MaxInt64 + cg.highwater = math.MaxInt64 - 10 + rg.requestBytes = math.MaxInt64 - 10 + + err := rg.ObserveAppend("c_overflow", "", KindText, make([]byte, 20)) + if err == nil { + t.Fatalf("expected overflow rejection, but got nil (highwater/requestBytes wrapped around to negative!)") + } + if !rg.IsTripped() { + t.Fatalf("expected guard to be tripped on overflow") + } +} + +// TestReviewProbeAppendScanBudgetVsRequestBytesPrecedence_RED: +// 单 channel 配置下,验证 append 路径中步骤 4 (request_bytes) 严格优先于步骤 6 (scan budget exhaustion)。 +func TestReviewProbeAppendScanBudgetVsRequestBytesPrecedence_RED(t *testing.T) { + limit := int64(1 << 20) // channel 1 MiB -> scan budget 2 MiB + reqLimit := int64(600 * 1024) // request ≈ 0.586 MiB + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: limit, + MaxRequestBytes: reqLimit, + }) + + // 1) append 0.5MiB 非重复数据 -> requestBytes=0.5MiB, scan 剩 1.5MiB, highwater=0.5MiB + half := randBytes(512*1024, 9101) + if err := rg.ObserveAppend("c1", "", KindText, half); err != nil { + t.Fatalf("seed append failed: %v", err) + } + + // 2) 三次 0.5MiB 无资格变体快照 -> 消耗 1.5MiB scan budget,使得剩余 scan budget = 0 + variant := make([]byte, 512*1024) + copy(variant, half) + for i := 0; i < 3; i++ { + variant[i*100] ^= 0xAA + if err := rg.ObserveSnapshot("c1", "", KindText, variant, ""); err != nil { + t.Fatalf("variant snapshot %d failed: %v", i, err) + } + } + if got := rg.TrippedReason(); got != "" { + t.Fatalf("premature trip: %s", got) + } + + // 3) append 0.2MiB:c1 highwater 0.7MiB <= 1MiB 通过;scan budget 0 < 0.2MiB 超限; + // 同时 requestBytes 0.5+0.2=0.7MiB > 0.586MiB 也超限。 + // Spec §5.3 严格要求步骤 4 先于步骤 6 -> reason 必须是 request_bytes! + probe := randBytes(200*1024, 9102) + err := rg.ObserveAppend("c1", "", KindText, probe) + if err == nil { + t.Fatalf("expected rejection, got nil") + } + if err.Error() != "request_bytes" { + t.Fatalf("Spec §5.3 precedence violation: want 'request_bytes', got %q", err.Error()) + } +} + +// TestAppendScanBudgetVsRequestBytesPrecedenceMultiChannelValidConfig: +// 合法配置工具与通道域 (tool <= channel <= request: tool=128KiB, channel=1MiB, request=1MiB)。 +// 通过另一 channel (c2) 占额,精确构造多 channel 共享 request 预算时的优先级竞态。 +func TestAppendScanBudgetVsRequestBytesPrecedenceMultiChannelValidConfig(t *testing.T) { + channelLimit := int64(1 << 20) // 1 MiB + reqLimit := int64(1 << 20) // 1 MiB (合法: tool 128KiB <= channel 1MiB <= request 1MiB) + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: channelLimit, + MaxRequestBytes: reqLimit, + }) + + // 1) c1 append 0.5 MiB 非重复数据 -> c1 highwater=0.5MiB, c1 scan 剩 1.5MiB, req=0.5MiB + half := randBytes(512*1024, 9201) + if err := rg.ObserveAppend("c1", "", KindText, half); err != nil { + t.Fatalf("c1 seed append failed: %v", err) + } + + // 2) c1 三次变体快照消耗 1.5 MiB scan budget -> c1 剩余 scan budget = 0, req 不变 (仍为 0.5MiB) + variant := make([]byte, 512*1024) + copy(variant, half) + for i := 0; i < 3; i++ { + variant[i*100] ^= 0x55 + if err := rg.ObserveSnapshot("c1", "", KindText, variant, ""); err != nil { + t.Fatalf("variant snapshot %d failed: %v", i, err) + } + } + + // 3) c2 (另一 channel) append 0.4 MiB -> 共享 requestBytes 累加至 0.5 + 0.4 = 0.9 MiB + c2Data := randBytes(400*1024, 9202) + if err := rg.ObserveAppend("c2", "", KindText, c2Data); err != nil { + t.Fatalf("c2 append failed: %v", err) + } + + // 4) c1 尝试 append 0.2 MiB: + // - c1 highwater: 0.5 + 0.2 = 0.7 MiB <= 1 MiB (channel_bytes 通过) + // - requestBytes: 0.9 + 0.2 = 1.1 MiB > 1.0 MiB (request_bytes 超限,步骤 4) + // - c1 scan budget: 0 < 0.2 MiB (scan budget 不足,步骤 6) + // 根据 Spec 优先级,必须报 request_bytes 而非 channel_bytes! + probe := randBytes(200*1024, 9203) + err := rg.ObserveAppend("c1", "", KindText, probe) + if err == nil { + t.Fatalf("expected rejection, got nil") + } + if err.Error() != "request_bytes" { + t.Fatalf("Multi-channel precedence violation: want 'request_bytes', got %q", err.Error()) + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race ./security/outputguard -run "TestRequestGuard.*"` +Expected: FAIL with compilation error: undefined: NewRequestGuard + +- [ ] **Step 3: Write minimal implementation** + +```go +// security/outputguard/guard.go +package outputguard + +import ( + "errors" + "math" + "sync" +) + +const ( + MaxRequestEvents = 65536 + MaxRequestChannels = 128 + TriggeredCode = "model_output_guard_triggered" +) + +type Config struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 +} + +type RequestGuard struct { + mu sync.RWMutex + cfg Config + requestEvents int + requestBytes int64 + channels map[string]*ChannelGuard + aliasMap map[string]string + tripped bool + trippedReason string +} + +func NewRequestGuard(cfg Config) *RequestGuard { + return &RequestGuard{ + cfg: cfg, + channels: make(map[string]*ChannelGuard), + aliasMap: make(map[string]string), + } +} + +func (g *RequestGuard) IsTripped() bool { + g.mu.RLock() + defer g.mu.RUnlock() + return g.tripped +} + +func (g *RequestGuard) TrippedReason() string { + g.mu.RLock() + defer g.mu.RUnlock() + return g.trippedReason +} + +func (g *RequestGuard) ActiveChannelCount() int { + g.mu.RLock() + defer g.mu.RUnlock() + return len(g.channels) +} + +func (g *RequestGuard) tripLocked(reason string) error { + if !g.tripped { + g.tripped = true + g.trippedReason = reason + } + return errors.New(g.trippedReason) +} + +func (g *RequestGuard) resolveChannelLocked(channelID, aliasID string, kind ChannelKind) (*ChannelGuard, error) { + // 晚到 ID 合并与双向映射机制 (Spec §4.3 & 审查反馈 M2 修复): + // 真实 Responses 流式 delta 初期通常仅包含 item_id(如 item_call_1),而在 output_item.done 时才提供 call_id。 + // 若分别创建会导致同一工具调用被分配两个 channel。 + var primaryKey string + if channelID != "" && g.aliasMap[channelID] != "" { + primaryKey = g.aliasMap[channelID] + } else if aliasID != "" && g.aliasMap[aliasID] != "" { + primaryKey = g.aliasMap[aliasID] + } else if channelID != "" && g.channels[channelID] != nil { + primaryKey = channelID + } else if aliasID != "" && g.channels[aliasID] != nil { + primaryKey = aliasID + } else if channelID != "" { + primaryKey = channelID + } else if aliasID != "" { + primaryKey = aliasID + } else { + primaryKey = "default" + } + + // 建立双向 alias 映射,确保后续无论通过 channelID 还是 aliasID 查找均指向同一主 ChannelGuard 实例 + if channelID != "" { + g.aliasMap[channelID] = primaryKey + } + if aliasID != "" { + g.aliasMap[aliasID] = primaryKey + } + + cg, exists := g.channels[primaryKey] + if !exists { + // 步骤 2: Channel 数量检查 (Spec §5.3) + if len(g.channels)+1 > MaxRequestChannels { + return nil, g.tripLocked("channel_count") + } + maxBytes := g.cfg.MaxChannelBytes + if kind == KindToolArguments { + maxBytes = g.cfg.MaxToolArgumentBytes + } + cg = NewChannelGuard(primaryKey, kind, maxBytes, g.cfg.MaxToolArgumentDeltas) + g.channels[primaryKey] = cg + } + return cg, nil +} + +// RecordEvent 严格对每上游业务事件执行 1 次计数(步骤 1: request_events 检查) +func (g *RequestGuard) RecordEvent() error { + g.mu.Lock() + defer g.mu.Unlock() + + if !g.cfg.Enabled { + return nil + } + if g.tripped { + return errors.New(g.trippedReason) + } + + g.requestEvents++ + if g.requestEvents > MaxRequestEvents { + return g.tripLocked("request_events") + } + return nil +} + +// ObserveAppend 严格执行 Spec §5.3 优先级流水线(先步骤 1 事件计数,再步骤 2~7 判定,首 reason 冻结) +func (g *RequestGuard) ObserveAppend(channelID string, aliasID string, kind ChannelKind, delta []byte) error { + g.mu.Lock() + defer g.mu.Unlock() + + if !g.cfg.Enabled { + return nil + } + if g.tripped { + return errors.New(g.trippedReason) + } + + // 步骤 1: 事件预算检查 + g.requestEvents++ + if g.requestEvents > MaxRequestEvents { + return g.tripLocked("request_events") + } + + return g.observeAppendLocked(channelID, aliasID, kind, delta) +} + +// ObserveAppendNoEvent 仅执行步骤 2~7 判定,不重复累加 requestEvents(供 ObserveEvent 批处理调用) +func (g *RequestGuard) ObserveAppendNoEvent(channelID string, aliasID string, kind ChannelKind, delta []byte) error { + g.mu.Lock() + defer g.mu.Unlock() + + if !g.cfg.Enabled { + return nil + } + if g.tripped { + return errors.New(g.trippedReason) + } + + return g.observeAppendLocked(channelID, aliasID, kind, delta) +} + +func (g *RequestGuard) observeAppendLocked(channelID string, aliasID string, kind ChannelKind, delta []byte) error { + // 步骤 2: Channel 检查 + cg, err := g.resolveChannelLocked(channelID, aliasID, kind) + if err != nil { + return err + } + + // 步骤 3 & 4: Delta 计数与语义字节高水位检查 + growth, cost, err := cg.CheckAppend(delta) + if err != nil { + return g.tripLocked(err.Error()) + } + + // 步骤 4: 请求级语义字节检查(必须先于步骤 6 扫描预算判定,且含 int64 防溢出) + if growth > 0 && g.requestBytes > math.MaxInt64-growth { + return g.tripLocked("request_bytes") + } + if g.requestBytes+growth > g.cfg.MaxRequestBytes { + return g.tripLocked("request_bytes") + } + + // 步骤 6: 扫描预算扣减与检查 (Spec §5.3 步骤 6,在步骤 4 request_bytes 之后执行) + if cg.remainingScanBudget < cost { + if cg.kind == KindToolArguments { + return g.tripLocked("tool_argument_bytes") + } + return g.tripLocked("channel_bytes") + } + + // 步骤 5, 6, 7: 提交高水位并执行周期扫描 + g.requestBytes += growth + if err := cg.CommitAppend(delta, growth, cost); err != nil { + return g.tripLocked(err.Error()) + } + + return nil +} + +// ObserveSnapshot 严格执行 Spec §5.3 快照判定流水线(先步骤 1 事件计数,再步骤 2~7 判定) +func (g *RequestGuard) ObserveSnapshot(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error { + g.mu.Lock() + defer g.mu.Unlock() + + if !g.cfg.Enabled { + return nil + } + if g.tripped { + return errors.New(g.trippedReason) + } + + // 步骤 1: 事件预算检查 + g.requestEvents++ + if g.requestEvents > MaxRequestEvents { + return g.tripLocked("request_events") + } + + return g.observeSnapshotLocked(channelID, aliasID, kind, snapshot, terminalStage) +} + +// ObserveSnapshotNoEvent 仅执行步骤 2~7 判定,不重复累加 requestEvents(供 ObserveEvent 批处理调用) +func (g *RequestGuard) ObserveSnapshotNoEvent(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error { + g.mu.Lock() + defer g.mu.Unlock() + + if !g.cfg.Enabled { + return nil + } + if g.tripped { + return errors.New(g.trippedReason) + } + + return g.observeSnapshotLocked(channelID, aliasID, kind, snapshot, terminalStage) +} + +func (g *RequestGuard) observeSnapshotLocked(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error { + // 步骤 2: Channel 检查 + cg, err := g.resolveChannelLocked(channelID, aliasID, kind) + if err != nil { + return err + } + + // 步骤 4: 语义字节预算检查 (先检查高水位推进与 request_bytes 上限) + snapLen := int64(len(snapshot)) + var growth int64 + if snapLen > cg.highwater { + growth = snapLen - cg.highwater + } + if snapLen > cg.maxBytes { + if cg.kind == KindToolArguments { + return g.tripLocked("tool_argument_bytes") + } + return g.tripLocked("channel_bytes") + } + if growth > 0 && g.requestBytes > math.MaxInt64-growth { + return g.tripLocked("request_bytes") + } + if g.requestBytes+growth > g.cfg.MaxRequestBytes { + return g.tripLocked("request_bytes") + } + + // 步骤 5 & 6: 资格/等价性检查与扫描预算扣减 + growth, cost, _, err := cg.CheckSnapshot(snapshot, terminalStage) + if err != nil { + if errors.Is(err, ErrScanBudgetExhausted) { + if cg.kind == KindToolArguments { + return g.tripLocked("tool_argument_bytes") + } + return g.tripLocked("channel_bytes") + } + return g.tripLocked(err.Error()) + } + + // 步骤 7: 提交高水位与扫描 + g.requestBytes += growth + if err := cg.CommitSnapshot(snapshot, growth, cost, terminalStage); err != nil { + return g.tripLocked(err.Error()) + } + + return nil +} + +// EndChannel 在关闭 channel 前执行尾检,释放窗口内存,累计 channel 映射不返还 +func (g *RequestGuard) EndChannel(channelID string) error { + g.mu.Lock() + defer g.mu.Unlock() + + targetKey := channelID + if alias := g.aliasMap[channelID]; alias != "" { + targetKey = alias + } + + if cg, exists := g.channels[targetKey]; exists { + // 先执行尾检 (Spec §5.2 规则 6) + if err := cg.CheckTail(); err != nil { + return g.tripLocked(err.Error()) + } + // 尾检通过后安全释放滑动窗口内存 (<=64KiB) + cg.ReleaseWindow() + } + return nil +} +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race ./security/outputguard -run "TestRequestGuard.*"` +Expected: PASS ok github.com/codex2api/security/outputguard + +- [ ] **Step 5: Commit** + +```bash +git add security/outputguard/guard.go security/outputguard/guard_test.go +git commit -m "feat(outputguard): implement request guard coordinator with 8-step precedence and RWMutex" +``` diff --git a/docs/superpowers/plans/model-output-guard-tdd/02a-database.md b/docs/superpowers/plans/model-output-guard-tdd/02a-database.md new file mode 100644 index 000000000..e900e7bdc --- /dev/null +++ b/docs/superpowers/plans/model-output-guard-tdd/02a-database.md @@ -0,0 +1,697 @@ +# 02a-Database: 模型输出失控防护数据库持久化与三态指针配置 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 基于已审查通过的设计规格(Spec §5.1、§7、§9.21),在 Codex2API 存储层实现模型输出失控防护配置持久化,新增 5 项核心预算与 1 项配置版本(generation)字段,支持 SQLite 与 PostgreSQL 双引擎迁移、三态指针(`*bool`)显式 `false` 持久化与旧缺失值向 `true` 安全补齐、短事务原子合并与行级并发写锁、全量更新隔离保护、以及配置版本溢出安全回滚。 + +**Architecture:** +- `database/sqlite.go` 与 `database/postgres.go`: 注入建表 DDL 与动态迁移列。`UpdateSystemSettings` 的 upsert 排除这些列。 +- `database/output_guard_settings.go`: 独立窄更新/读取接口(`GetOutputGuardSettings`、`UpdateOutputGuardSettings`),短事务内先读当前值合并(`tool <= channel <= request` 层级校验),PG 用 `FOR UPDATE`、SQLite 用 `withSQLiteWriteLock`。`generation` 严格自增且 MaxInt64 溢出拦截回滚。 +- `database/output_guard_settings_test.go`: 真实 DB 构造函数 `database.New(driver, dsn)` 运行 SQLite 与可选 PostgreSQL 集成测试。 + +**Tech Stack:** Go 1.26+ (`database/sql`, `modernc.org/sqlite`, `github.com/jackc/pgx/v5`). + +**Spec:** `docs/superpowers/specs/2026-10-28-model-output-guard-design.md` (§5.1, §7, §9.21) + +## Global Constraints + +- **范围限制**:单一窄任务 Task 4(数据库配置持久化);不改动 02-config 其他模块、不承担 Task 5 运行时轮询器与 Task 6 管理后台 UI。 +- **存储与三态契约**: + - `output_guard_enabled`: 采用三态指针(`*bool`)。NULL 向上补齐默认 `false`;显式 `true`/`false` 原样持久化,严禁零值布尔覆盖。 + - 预算列 `NOT NULL DEFAULT`:tool 131072 (16KiB..4MiB),deltas 4096 (256..65536),channel 2097152 (128KiB..16MiB),request 4194304 (256KiB..64MiB),generation 1 (正整数)。 + - 层级约束:严格满足 `tool <= channel <= request`。 +- **事务与并发保护**: + - 同一事务内完成读旧值合并、范围校验、自增 generation 与更新,严禁用 `db.conn` 读当前状态。 + - PG 使用 `FOR UPDATE`,SQLite 使用 `db.withSQLiteWriteLock`。 + - 版本号溢出保护:当前 `generation == math.MaxInt64` 时拒绝返回 `ErrOutputGuardGenerationOverflow` 并回滚。 + - 隔离保护:现有 `UpdateSystemSettings` 的 SQL 绝不包含 `output_guard_*` 字段。 +- **测试环境约束**:`GOCACHE=/tmp/go-build GOPATH=/tmp/go go test ...`,真实 DB 驱动集成测试,禁用 mock。 + +--- + +## File Structure + +| 文件路径 | 状态 | 职责描述 | +|---|---|---| +| `database/output_guard_settings.go` | 新建 | `OutputGuardSettings` / `OutputGuardSettingsUpdate` 结构、校验、默认值与 Get/Update 核心实现 | +| `database/output_guard_settings_test.go` | 新建 | SQLite 与 PostgreSQL(环境变量可选)真实集成测试 | +| `database/sqlite.go` | 修改 (约 L395, L722) | 在 SQLite 建表 DDL 与动态迁移切片中添加 6 个 output_guard 字段 | +| `database/postgres.go` | 修改 (约 L1380, L1586) | 在 PostgreSQL 建表 DDL 与动态迁移中添加 6 个 output_guard 字段;确认 `UpdateSystemSettings` 隔离 | + +--- + +## Task 4: 数据库表结构迁移与三态指针持久化(SQLite & PostgreSQL) + +**Files:** +- Create: `database/output_guard_settings.go` +- Modify: `database/sqlite.go:390-405,720-730` +- Modify: `database/postgres.go:1375-1385,1580-1590,3050-3220` +- Test: `database/output_guard_settings_test.go` + +**Interfaces:** +- Consumes: + - `database.DB` + - `database.DB.isSQLite() bool` + - `database.DB.withSQLiteWriteLock(context.Context, func() error) error` +- Produces: + ```go + package database + + import ( + "context" + "errors" + ) + + var ( + ErrInvalidOutputGuardSettings = errors.New("invalid output guard settings") + ErrOutputGuardGenerationOverflow = errors.New("output guard config generation overflow") + ) + + type OutputGuardSettings struct { + Enabled *bool `json:"output_guard_enabled"` + MaxToolArgumentBytes int64 `json:"output_guard_max_tool_argument_bytes"` + MaxToolArgumentDeltas int `json:"output_guard_max_tool_argument_deltas"` + MaxChannelBytes int64 `json:"output_guard_max_channel_bytes"` + MaxRequestBytes int64 `json:"output_guard_max_request_bytes"` + ConfigGeneration int64 `json:"output_guard_config_generation"` + } + + type OutputGuardSettingsUpdate struct { + Enabled *bool + MaxToolArgumentBytes *int64 + MaxToolArgumentDeltas *int + MaxChannelBytes *int64 + MaxRequestBytes *int64 + } + + func DefaultOutputGuardSettings() OutputGuardSettings + func ValidateOutputGuardSettings(settings OutputGuardSettings) error + func (db *DB) GetOutputGuardSettings(ctx context.Context) (OutputGuardSettings, error) + func (db *DB) UpdateOutputGuardSettings(ctx context.Context, update OutputGuardSettingsUpdate) (OutputGuardSettings, error) + ``` + +- [ ] **Step 1: Write failing test for OutputGuardSettings** + +创建 `database/output_guard_settings_test.go`: +```go +package database + +import ( + "context" + "errors" + "math" + "os" + "path/filepath" + "sync" + "testing" +) + +func runOnAllOutputGuardDBDrivers(t *testing.T, testFn func(t *testing.T, db *DB)) { + t.Helper() + t.Run("sqlite", func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "output-guard-test.db") + db, err := New("sqlite", dbPath) + if err != nil { + t.Fatalf("New(sqlite) error = %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + testFn(t, db) + }) + + if dsn := os.Getenv("CODEX2API_TEST_POSTGRES_DSN"); dsn != "" { + t.Run("postgres", func(t *testing.T) { + db, err := New("postgres", dsn) + if err != nil { + t.Fatalf("New(postgres) error = %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + testFn(t, db) + }) + } +} + +func TestOutputGuardSettingsFreshDefaults(t *testing.T) { + runOnAllOutputGuardDBDrivers(t, func(t *testing.T, db *DB) { + got, err := db.GetOutputGuardSettings(context.Background()) + if err != nil { + t.Fatalf("GetOutputGuardSettings error = %v", err) + } + + if got.Enabled == nil || *got.Enabled != false { + t.Fatalf("default Enabled want false, got %v", got.Enabled) + } + if got.MaxToolArgumentBytes != 131072 { + t.Fatalf("default MaxToolArgumentBytes want 131072, got %d", got.MaxToolArgumentBytes) + } + if got.MaxToolArgumentDeltas != 4096 { + t.Fatalf("default MaxToolArgumentDeltas want 4096, got %d", got.MaxToolArgumentDeltas) + } + if got.MaxChannelBytes != 2097152 { + t.Fatalf("default MaxChannelBytes want 2097152, got %d", got.MaxChannelBytes) + } + if got.MaxRequestBytes != 4194304 { + t.Fatalf("default MaxRequestBytes want 4194304, got %d", got.MaxRequestBytes) + } + if got.ConfigGeneration < 1 { + t.Fatalf("default ConfigGeneration want >= 1, got %d", got.ConfigGeneration) + } + }) +} + +func TestOutputGuardSettingsExplicitFalseTriStateAndRestart(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "output-guard-tristate.db") + db, err := New("sqlite", dbPath) + if err != nil { + t.Fatalf("New(sqlite) error = %v", err) + } + + ctx := context.Background() + disabled := false + updated, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + Enabled: &disabled, + }) + if err != nil { + t.Fatalf("UpdateOutputGuardSettings error = %v", err) + } + if updated.Enabled == nil || *updated.Enabled != false { + t.Fatalf("expected updated Enabled=false, got %v", updated.Enabled) + } + _ = db.Close() + + // 重新打开数据库,断言显式 false 得到正确持久化恢复 + restartedDB, err := New("sqlite", dbPath) + if err != nil { + t.Fatalf("reopen error = %v", err) + } + t.Cleanup(func() { _ = restartedDB.Close() }) + + readBack, err := restartedDB.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("readBack error = %v", err) + } + if readBack.Enabled == nil || *readBack.Enabled != false { + t.Fatalf("expected readBack Enabled=false, got %v", readBack.Enabled) + } +} + +func TestOutputGuardSettingsNullMissingFallbackToTrue(t *testing.T) { + runOnAllOutputGuardDBDrivers(t, func(t *testing.T, db *DB) { + ctx := context.Background() + if _, err := db.conn.ExecContext(ctx, ` + INSERT INTO system_settings (id, output_guard_enabled) + VALUES (1, NULL) + ON CONFLICT (id) DO UPDATE SET output_guard_enabled = NULL + `); err != nil { + t.Fatalf("seed NULL enabled: %v", err) + } + + got, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("GetOutputGuardSettings error = %v", err) + } + if got.Enabled == nil || *got.Enabled != false { + t.Fatalf("NULL enabled want fallback false, got %v", got.Enabled) + } + }) +} + +func TestOutputGuardSettingsValidationBoundariesAndRollback(t *testing.T) { + runOnAllOutputGuardDBDrivers(t, func(t *testing.T, db *DB) { + ctx := context.Background() + before, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatal(err) + } + + invalidCases := []struct { + name string + update OutputGuardSettingsUpdate + }{ + {"tool < 16KiB", OutputGuardSettingsUpdate{MaxToolArgumentBytes: ptr(int64(16383))}}, + {"tool > 4MiB", OutputGuardSettingsUpdate{MaxToolArgumentBytes: ptr(int64(4194305))}}, + {"deltas < 256", OutputGuardSettingsUpdate{MaxToolArgumentDeltas: ptr(255)}}, + {"deltas > 65536", OutputGuardSettingsUpdate{MaxToolArgumentDeltas: ptr(65537)}}, + {"channel < 128KiB", OutputGuardSettingsUpdate{MaxChannelBytes: ptr(int64(131071))}}, + {"channel > 16MiB", OutputGuardSettingsUpdate{MaxChannelBytes: ptr(int64(16777217))}}, + {"request < 256KiB", OutputGuardSettingsUpdate{MaxRequestBytes: ptr(int64(262143))}}, + {"request > 64MiB", OutputGuardSettingsUpdate{MaxRequestBytes: ptr(int64(67108865))}}, + {"tool > channel", OutputGuardSettingsUpdate{MaxToolArgumentBytes: ptr(int64(3000000)), MaxChannelBytes: ptr(int64(2097152))}}, + {"channel > request", OutputGuardSettingsUpdate{MaxChannelBytes: ptr(int64(5000000)), MaxRequestBytes: ptr(int64(4194304))}}, + } + + for _, tc := range invalidCases { + _, err := db.UpdateOutputGuardSettings(ctx, tc.update) + if !errors.Is(err, ErrInvalidOutputGuardSettings) { + t.Fatalf("[%s] want ErrInvalidOutputGuardSettings, got %v", tc.name, err) + } + } + + after, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatal(err) + } + if after.ConfigGeneration != before.ConfigGeneration || after.MaxToolArgumentBytes != before.MaxToolArgumentBytes { + t.Fatalf("invalid update didn't rollback: before=%+v, after=%+v", before, after) + } + }) +} + +func TestOutputGuardSettingsNarrowUpdateNotOverwrittenBySystemSettings(t *testing.T) { + runOnAllOutputGuardDBDrivers(t, func(t *testing.T, db *DB) { + ctx := context.Background() + toolBytes, channelBytes, requestBytes := int64(262144), int64(2097152), int64(4194304) + disabled := false + committed, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + Enabled: &disabled, + MaxToolArgumentBytes: &toolBytes, + MaxChannelBytes: &channelBytes, + MaxRequestBytes: &requestBytes, + }) + if err != nil { + t.Fatalf("UpdateOutputGuardSettings error = %v", err) + } + + if err := db.UpdateSystemSettings(ctx, &SystemSettings{SiteName: "stale full update"}); err != nil { + t.Fatalf("UpdateSystemSettings error = %v", err) + } + + got, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("GetOutputGuardSettings error = %v", err) + } + if got.Enabled == nil || *got.Enabled != false || got.MaxToolArgumentBytes != committed.MaxToolArgumentBytes { + t.Fatalf("OutputGuardSettings overwritten by UpdateSystemSettings: got %+v, want %+v", got, committed) + } + }) +} + +func TestOutputGuardSettingsGenerationOverflowRollback(t *testing.T) { + runOnAllOutputGuardDBDrivers(t, func(t *testing.T, db *DB) { + ctx := context.Background() + if _, err := db.conn.ExecContext(ctx, ` + INSERT INTO system_settings (id, output_guard_config_generation) + VALUES (1, $1) + ON CONFLICT (id) DO UPDATE SET output_guard_config_generation = $1 + `, int64(math.MaxInt64)); err != nil { + t.Fatalf("seed max generation: %v", err) + } + + toolBytes := int64(65536) + _, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + MaxToolArgumentBytes: &toolBytes, + }) + if !errors.Is(err, ErrOutputGuardGenerationOverflow) { + t.Fatalf("expected ErrOutputGuardGenerationOverflow, got %v", err) + } + + got, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatal(err) + } + if got.ConfigGeneration != math.MaxInt64 { + t.Fatalf("generation after overflow = %d, want math.MaxInt64", got.ConfigGeneration) + } + }) +} + +func TestOutputGuardSettingsConcurrentDisjointUpdatesDoNotLoseChanges(t *testing.T) { + runOnAllOutputGuardDBDrivers(t, func(t *testing.T, db *DB) { + ctx := context.Background() + var wg sync.WaitGroup + errCh := make(chan error, 10) + + for i := 0; i < 10; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + tb, cb, rb := int64(131072+(idx+1)*1024), int64(2097152+(idx+1)*1024), int64(4194304+(idx+1)*1024) + _, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + MaxToolArgumentBytes: &tb, + MaxChannelBytes: &cb, + MaxRequestBytes: &rb, + }) + if err != nil { + errCh <- err + } + }(i) + } + + wg.Wait() + close(errCh) + for err := range errCh { + t.Fatalf("concurrent update failed: %v", err) + } + + got, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatal(err) + } + if got.ConfigGeneration < 11 { + t.Fatalf("expected generation >= 11 after 10 concurrent updates, got %d", got.ConfigGeneration) + } + }) +} + +func TestOutputGuardSettingsRestoreAllDefaults(t *testing.T) { + runOnAllOutputGuardDBDrivers(t, func(t *testing.T, db *DB) { + ctx := context.Background() + disabled := false + customTool, customDeltas, customChannel, customRequest := int64(524288), 8192, int64(4194304), int64(8388608) + _, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + Enabled: &disabled, + MaxToolArgumentBytes: &customTool, + MaxToolArgumentDeltas: &customDeltas, + MaxChannelBytes: &customChannel, + MaxRequestBytes: &customRequest, + }) + if err != nil { + t.Fatalf("setup custom settings error: %v", err) + } + + def := DefaultOutputGuardSettings() + restored, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + Enabled: def.Enabled, + MaxToolArgumentBytes: &def.MaxToolArgumentBytes, + MaxToolArgumentDeltas: &def.MaxToolArgumentDeltas, + MaxChannelBytes: &def.MaxChannelBytes, + MaxRequestBytes: &def.MaxRequestBytes, + }) + if err != nil { + t.Fatalf("restore error: %v", err) + } + + if restored.Enabled == nil || *restored.Enabled != false || + restored.MaxToolArgumentBytes != 131072 || restored.MaxToolArgumentDeltas != 4096 || + restored.MaxChannelBytes != 2097152 || restored.MaxRequestBytes != 4194304 { + t.Fatalf("restored mismatch: %+v", restored) + } + }) +} + +func ptr[T any](v T) *T { return &v } +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +运行测试验证编译红灯: +```bash +GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -run TestOutputGuardSettings ./database +``` +预期输出:`undefined: db.GetOutputGuardSettings` / `undefined: OutputGuardSettings` / `build failed`。 + +- [ ] **Step 3: Write minimal implementation** + +1. 修改 `database/sqlite.go`: +在 `system_settings` 的建表 DDL(约 L395)中添加列: +```go + output_guard_enabled INTEGER DEFAULT 0, + output_guard_max_tool_argument_bytes INTEGER NOT NULL DEFAULT 131072, + output_guard_max_tool_argument_deltas INTEGER NOT NULL DEFAULT 4096, + output_guard_max_channel_bytes INTEGER NOT NULL DEFAULT 2097152, + output_guard_max_request_bytes INTEGER NOT NULL DEFAULT 4194304, + output_guard_config_generation INTEGER NOT NULL DEFAULT 1, +``` +在动态迁移切片 `columns`(约 L722)中添加: +```go + {"system_settings", "output_guard_enabled", "INTEGER DEFAULT 0"}, + {"system_settings", "output_guard_max_tool_argument_bytes", "INTEGER NOT NULL DEFAULT 131072"}, + {"system_settings", "output_guard_max_tool_argument_deltas", "INTEGER NOT NULL DEFAULT 4096"}, + {"system_settings", "output_guard_max_channel_bytes", "INTEGER NOT NULL DEFAULT 2097152"}, + {"system_settings", "output_guard_max_request_bytes", "INTEGER NOT NULL DEFAULT 4194304"}, + {"system_settings", "output_guard_config_generation", "INTEGER NOT NULL DEFAULT 1"}, +``` + +2. 修改 `database/postgres.go`: +在 `system_settings` 的建表 DDL(约 L1380)中添加列: +```sql + output_guard_enabled BOOLEAN DEFAULT FALSE, + output_guard_max_tool_argument_bytes BIGINT NOT NULL DEFAULT 131072, + output_guard_max_tool_argument_deltas INT NOT NULL DEFAULT 4096, + output_guard_max_channel_bytes BIGINT NOT NULL DEFAULT 2097152, + output_guard_max_request_bytes BIGINT NOT NULL DEFAULT 4194304, + output_guard_config_generation BIGINT NOT NULL DEFAULT 1, +``` +在 `initPostgresSchema`(约 L1586)中添加动态迁移: +```sql + ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_enabled BOOLEAN DEFAULT FALSE; + ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_max_tool_argument_bytes BIGINT NOT NULL DEFAULT 131072; + ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_max_tool_argument_deltas INT NOT NULL DEFAULT 4096; + ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_max_channel_bytes BIGINT NOT NULL DEFAULT 2097152; + ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_max_request_bytes BIGINT NOT NULL DEFAULT 4194304; + ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_config_generation BIGINT NOT NULL DEFAULT 1; +``` +确认 `UpdateSystemSettings` 的 SQL 排除 `output_guard_*` 字段。 + +3. 创建 `database/output_guard_settings.go`: +```go +package database + +import ( + "context" + "database/sql" + "errors" + "fmt" + "math" +) + +const ( + DefaultOutputGuardEnabled = false + DefaultOutputGuardMaxToolArgumentBytes = int64(131072) // 128 KiB + DefaultOutputGuardMaxToolArgumentDeltas = 4096 + DefaultOutputGuardMaxChannelBytes = int64(2097152) // 2 MiB + DefaultOutputGuardMaxRequestBytes = int64(4194304) // 4 MiB + DefaultOutputGuardConfigGeneration = int64(1) + + MinOutputGuardMaxToolArgumentBytes = int64(16384) // 16 KiB + MaxOutputGuardMaxToolArgumentBytes = int64(4194304) // 4 MiB + + MinOutputGuardMaxToolArgumentDeltas = 256 + MaxOutputGuardMaxToolArgumentDeltas = 65536 + + MinOutputGuardMaxChannelBytes = int64(131072) // 128 KiB + MaxOutputGuardMaxChannelBytes = int64(16777216) // 16 MiB + + MinOutputGuardMaxRequestBytes = int64(262144) // 256 KiB + MaxOutputGuardMaxRequestBytes = int64(67108864) // 64 MiB +) + +var ( + ErrInvalidOutputGuardSettings = errors.New("invalid output guard settings") + ErrOutputGuardGenerationOverflow = errors.New("output guard config generation overflow") +) + +type OutputGuardSettings struct { + Enabled *bool `json:"output_guard_enabled"` + MaxToolArgumentBytes int64 `json:"output_guard_max_tool_argument_bytes"` + MaxToolArgumentDeltas int `json:"output_guard_max_tool_argument_deltas"` + MaxChannelBytes int64 `json:"output_guard_max_channel_bytes"` + MaxRequestBytes int64 `json:"output_guard_max_request_bytes"` + ConfigGeneration int64 `json:"output_guard_config_generation"` +} + +type OutputGuardSettingsUpdate struct { + Enabled *bool + MaxToolArgumentBytes *int64 + MaxToolArgumentDeltas *int + MaxChannelBytes *int64 + MaxRequestBytes *int64 +} + +func DefaultOutputGuardSettings() OutputGuardSettings { + t := DefaultOutputGuardEnabled + return OutputGuardSettings{ + Enabled: &t, + MaxToolArgumentBytes: DefaultOutputGuardMaxToolArgumentBytes, + MaxToolArgumentDeltas: DefaultOutputGuardMaxToolArgumentDeltas, + MaxChannelBytes: DefaultOutputGuardMaxChannelBytes, + MaxRequestBytes: DefaultOutputGuardMaxRequestBytes, + ConfigGeneration: DefaultOutputGuardConfigGeneration, + } +} + +func ValidateOutputGuardSettings(settings OutputGuardSettings) error { + switch { + case settings.MaxToolArgumentBytes < MinOutputGuardMaxToolArgumentBytes || settings.MaxToolArgumentBytes > MaxOutputGuardMaxToolArgumentBytes: + return fmt.Errorf("%w: max_tool_argument_bytes must be 16KiB..4MiB", ErrInvalidOutputGuardSettings) + case settings.MaxToolArgumentDeltas < MinOutputGuardMaxToolArgumentDeltas || settings.MaxToolArgumentDeltas > MaxOutputGuardMaxToolArgumentDeltas: + return fmt.Errorf("%w: max_tool_argument_deltas must be 256..65536", ErrInvalidOutputGuardSettings) + case settings.MaxChannelBytes < MinOutputGuardMaxChannelBytes || settings.MaxChannelBytes > MaxOutputGuardMaxChannelBytes: + return fmt.Errorf("%w: max_channel_bytes must be 128KiB..16MiB", ErrInvalidOutputGuardSettings) + case settings.MaxRequestBytes < MinOutputGuardMaxRequestBytes || settings.MaxRequestBytes > MaxOutputGuardMaxRequestBytes: + return fmt.Errorf("%w: max_request_bytes must be 256KiB..64MiB", ErrInvalidOutputGuardSettings) + case settings.MaxToolArgumentBytes > settings.MaxChannelBytes || settings.MaxChannelBytes > settings.MaxRequestBytes: + return fmt.Errorf("%w: requires tool <= channel <= request bytes", ErrInvalidOutputGuardSettings) + case settings.ConfigGeneration <= 0: + return fmt.Errorf("%w: config generation must be positive", ErrInvalidOutputGuardSettings) + default: + return nil + } +} + +func (db *DB) outputGuardSettingsSelectQuery(forUpdate bool) string { + query := `SELECT output_guard_enabled, + COALESCE(output_guard_max_tool_argument_bytes, 131072), + COALESCE(output_guard_max_tool_argument_deltas, 4096), + COALESCE(output_guard_max_channel_bytes, 2097152), + COALESCE(output_guard_max_request_bytes, 4194304), + COALESCE(output_guard_config_generation, 1) + FROM system_settings WHERE id = 1` + if forUpdate && !db.isSQLite() { + query += " FOR UPDATE" + } + return query +} + +type outputGuardSettingsScanner interface { + Scan(dest ...any) error +} + +func scanOutputGuardSettings(row outputGuardSettingsScanner, settings *OutputGuardSettings) error { + var enabled sql.NullBool + if err := row.Scan( + &enabled, + &settings.MaxToolArgumentBytes, + &settings.MaxToolArgumentDeltas, + &settings.MaxChannelBytes, + &settings.MaxRequestBytes, + &settings.ConfigGeneration, + ); err != nil { + return err + } + if enabled.Valid { + val := enabled.Bool + settings.Enabled = &val + } else { + def := DefaultOutputGuardEnabled + settings.Enabled = &def + } + return nil +} + +func (db *DB) GetOutputGuardSettings(ctx context.Context) (OutputGuardSettings, error) { + settings := DefaultOutputGuardSettings() + err := scanOutputGuardSettings(db.conn.QueryRowContext(ctx, db.outputGuardSettingsSelectQuery(false)), &settings) + if errors.Is(err, sql.ErrNoRows) { + return settings, nil + } + if err != nil { + return OutputGuardSettings{}, err + } + if err := ValidateOutputGuardSettings(settings); err != nil { + return OutputGuardSettings{}, err + } + return settings, nil +} + +func (db *DB) UpdateOutputGuardSettings(ctx context.Context, update OutputGuardSettingsUpdate) (OutputGuardSettings, error) { + var committed OutputGuardSettings + err := db.withSQLiteWriteLock(ctx, func() error { + tx, err := db.conn.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, ` + INSERT INTO system_settings (id) VALUES (1) + ON CONFLICT (id) DO NOTHING + `); err != nil { + return err + } + + current := DefaultOutputGuardSettings() + if err := scanOutputGuardSettings( + tx.QueryRowContext(ctx, db.outputGuardSettingsSelectQuery(!db.isSQLite())), + ¤t, + ); err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + + next := current + if update.Enabled != nil { + val := *update.Enabled + next.Enabled = &val + } + if update.MaxToolArgumentBytes != nil { + next.MaxToolArgumentBytes = *update.MaxToolArgumentBytes + } + if update.MaxToolArgumentDeltas != nil { + next.MaxToolArgumentDeltas = *update.MaxToolArgumentDeltas + } + if update.MaxChannelBytes != nil { + next.MaxChannelBytes = *update.MaxChannelBytes + } + if update.MaxRequestBytes != nil { + next.MaxRequestBytes = *update.MaxRequestBytes + } + + if err := ValidateOutputGuardSettings(next); err != nil { + return err + } + + changed := (update.Enabled != nil && (current.Enabled == nil || *current.Enabled != *next.Enabled)) || + (update.MaxToolArgumentBytes != nil && current.MaxToolArgumentBytes != next.MaxToolArgumentBytes) || + (update.MaxToolArgumentDeltas != nil && current.MaxToolArgumentDeltas != next.MaxToolArgumentDeltas) || + (update.MaxChannelBytes != nil && current.MaxChannelBytes != next.MaxChannelBytes) || + (update.MaxRequestBytes != nil && current.MaxRequestBytes != next.MaxRequestBytes) + + if changed { + if current.ConfigGeneration == math.MaxInt64 { + return ErrOutputGuardGenerationOverflow + } + next.ConfigGeneration = current.ConfigGeneration + 1 + + var enabledVal any + if next.Enabled != nil { + if db.isSQLite() { + if *next.Enabled { + enabledVal = int64(1) + } else { + enabledVal = int64(0) + } + } else { + enabledVal = *next.Enabled + } + } + + if _, err := tx.ExecContext(ctx, ` + UPDATE system_settings + SET output_guard_enabled = $1, + output_guard_max_tool_argument_bytes = $2, + output_guard_max_tool_argument_deltas = $3, + output_guard_max_channel_bytes = $4, + output_guard_max_request_bytes = $5, + output_guard_config_generation = $6 + WHERE id = 1 + `, enabledVal, next.MaxToolArgumentBytes, next.MaxToolArgumentDeltas, next.MaxChannelBytes, next.MaxRequestBytes, next.ConfigGeneration); err != nil { + return err + } + } + + if err := tx.Commit(); err != nil { + return err + } + committed = next + return nil + }) + if err != nil { + return OutputGuardSettings{}, err + } + return committed, nil +} +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +运行测试确认全部用例通过: +```bash +GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -run TestOutputGuardSettings ./database +``` +预期输出:`PASS ok github.com/codex2api/database`。 + +- [ ] **Step 5: Commit** + +```bash +git add database/output_guard_settings.go database/output_guard_settings_test.go database/sqlite.go database/postgres.go +git commit -m "feat(database): add output guard settings table migration and tri-state persistence" +``` diff --git a/docs/superpowers/plans/model-output-guard-tdd/02b-runtime.md b/docs/superpowers/plans/model-output-guard-tdd/02b-runtime.md new file mode 100644 index 000000000..1f926e3f3 --- /dev/null +++ b/docs/superpowers/plans/model-output-guard-tdd/02b-runtime.md @@ -0,0 +1,693 @@ +# 02b-Runtime: 模型输出失控防护运行时快照、后台轮询器与冷启动门禁 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 依据 Spec §7 与 §9.22/23,实现 Codex2API 全局模型输出失控防护运行时状态机(`proxy/output_guard_settings.go`,Task 5),提供不可变 `OutputGuardAppliedConfig` 快照、5s 周期后台 Revision 轮询自愈器(带 3s 超时与 `db.RunBackgroundTask` 优雅生命周期)、在途快照隔离与非法配置 LKG 保护、冷启动失败非致命 503 门禁以及 HTTP/WS/Stream 就绪检查函数。 + +**Architecture:** +1. `proxy/output_guard_settings.go`: 进程级状态容器,`sync.RWMutex` 保护不可变配置快照、同步时间戳与错误;`atomic.Bool` 存储 `isReady` 门禁标记。对外输出统一名称 `OutputGuardAppliedConfig`(值不可变)、`OutputGuardConfigSyncStatus`,提供生产接口 `ApplyOutputGuardSettings`、`CurrentOutputGuardConfig`、`GetOutputGuardConfigSyncStatus`、`IsOutputGuardReady`、`LoadOutputGuardSettings` 与 `StartOutputGuardConfigPoller`。 +2. 5s 后台轮询器与优雅生命周期:基于 `database.DB.RunBackgroundTask` 挂载后台 worker,轮询间隔 5s、单次读取超时 3s;注入外部 reader 接口(`OutputGuardSettingsReader`)与自定义 ticker/timeout。陈旧 Revision(`<= current.Generation`)幂等拒绝;非法配置保留 Last-Known-Good (LKG) 并记录诊断;新 Revision 纯值拷贝并置 `isReady.Store(true)`。 +3. 冷启动未就绪强阻断与非致命启动:`main.go` 启动调用 `LoadOutputGuardSettings` 失败记录警告并启动后台 Poller 等待自愈,绝不 `log.Fatalf`;未就绪期间,`RequireOutputGuardReadyHTTP` 返回 HTTP 503 与稳定错误码 `api.ErrCodeServiceUnavailable`,`RequireOutputGuardReadyWS` 发送 JSON 错误帧并以 1013 CloseTryAgainLater 关闭连接,`RequireOutputGuardReadyStream` 发送关联 stream 503 错误帧。冷启动若读取到显式 `Enabled = false`,属于成功加载,标记就绪放行生成流量,wire framing 保留。 +4. 05a 验收契约测试桥接:在 `proxy/output_guard_settings_test.go` 中提供 `PollOutputGuardConfigOnceForTest(ctx, db)`(包装私有单次 poll,不污染生产初始化)与 `ResetOutputGuardStateForTest()`、`SetOutputGuardReadyForTest(bool)` 测试辅助函数。 + +**Tech Stack:** Go 1.26+, `github.com/codex2api/api`, `github.com/codex2api/database`, `github.com/gin-gonic/gin`, `github.com/gorilla/websocket`, `context`, `sync`, `sync/atomic`, `time`. + +**Spec:** `docs/superpowers/specs/2026-10-28-model-output-guard-design.md` (§7 与 §9.22/23) + +## Global Constraints + +- **范围限制**:仅覆盖 Task 5 运行时状态机、轮询器与门禁检查;不承担 Task 4(数据库表与 CAS)或 Task 6(Admin UI/REST API);不修改其他模块、spec、candidate 或已有业务代码。 +- **Contract 真实性**:Module 为 `github.com/codex2api`;消费 Task 4 未来契约:`database.OutputGuardSettings`(含 `Enabled *bool`、`ConfigGeneration int64` 等字段)、`database.ValidateOutputGuardSettings` 与 `database.DB.GetOutputGuardSettings`。 +- **命名规范与不可变性**:配置结构体为纯值类型 `OutputGuardAppliedConfig`(字段含 `Enabled bool`、`MaxToolArgumentBytes int64`、`MaxToolArgumentDeltas int`、`MaxChannelBytes int64`、`MaxRequestBytes int64`、`Generation int64`),`CurrentOutputGuardConfig()` 返回不可变值拷贝。同步状态为 `OutputGuardConfigSyncStatus`(`LastSuccessfulSyncAt time.Time`、`LastSyncError string`)。 +- **并发与内存安全**:读写互斥锁 `mu sync.RWMutex` 保证浅拷贝与更新零 race;就绪状态使用 `atomic.Bool` 保证高频无锁访问。 +- **生命周期与协程泄露防护**:后台轮询器必须通过 `db.RunBackgroundTask` 挂载,不产生孤儿 goroutine;读取严格绑定 3 秒超时 context,主 context 取消时立即优雅退出 ticker 循环。 +- **错误与门禁规范**:冷启动未就绪绝不调用 `log.Fatalf`,服务正常启动并进入未就绪等待状态;门禁失败统一生成 HTTP 503 / `api.ErrCodeServiceUnavailable`,严禁混用 `model_output_guard_triggered`。WebSocket 未就绪门禁先写 JSON 503 错误帧再发 1013 控制帧关闭;显式 `Enabled = false` 成功加载后必须置 `isReady = true` 正常放行。 +- **测试隔离与可控性**:生产代码不暴露带 `ForTest` 后缀的初始化函数;测试 helper 严格放置在 `_test.go` 中;支持注入 `OutputGuardSettingsReader` 与自定义 ticker/timeout,杜绝测试中硬 sleep 5 秒。 + +## File Structure + +| 文件路径 | 状态 | 职责描述 | +|---|---|---| +| `proxy/output_guard_settings.go` | 新建 | 运行时状态容器、`ApplyOutputGuardSettings`、`CurrentOutputGuardConfig`、`IsOutputGuardReady`、`LoadOutputGuardSettings`、`StartOutputGuardConfigPoller`、`RequireOutputGuardReadyHTTP/WS/Stream` 门禁 | +| `proxy/output_guard_settings_test.go` | 新建 | 运行时测试:冷启动 503 阻断、WS 错误帧+1013、显式 disabled 就绪、Stale Generation 拒绝、LKG 保留、Poller 自愈与优雅退出、`PollOutputGuardConfigOnceForTest` 桥接 | +| `main.go` | 修改 (锚点: 265) | 启动时调用 `LoadOutputGuardSettings`(非致命启动)并启动后台 `StartOutputGuardConfigPoller` | + +## Task 5: 运行时不可变配置快照、5 秒后台 Revision 轮询器与冷启动就绪门禁 + +**Files:** +- Create: `proxy/output_guard_settings.go` +- Create: `proxy/output_guard_settings_test.go` +- Modify: `main.go:265-270` + +**Interfaces:** +- Consumes: + - `database.OutputGuardSettings`: `Enabled *bool`, `MaxToolArgumentBytes int64`, `MaxToolArgumentDeltas int`, `MaxChannelBytes int64`, `MaxRequestBytes int64`, `ConfigGeneration int64` + - `database.ValidateOutputGuardSettings(database.OutputGuardSettings) error` + - `database.DB.GetOutputGuardSettings(context.Context) (database.OutputGuardSettings, error)` + - `database.DB.RunBackgroundTask(func(context.Context)) bool` + - `api.NewAPIError(code api.ErrorCode, message string, errorType api.ErrorType) *api.APIError` + - `api.SendErrorWithStatus(c *gin.Context, err *api.APIError, status int)` + - `proxy.writeResponsesWSErrorWithin(conn *websocket.Conn, apiErr *api.APIError, timeout time.Duration) error` + - `proxy.writeResponsesWSErrorForStream(conn *websocket.Conn, apiErr *api.APIError, streamID string, timeout time.Duration) error` +- Produces (`proxy/output_guard_settings.go`): + - `OutputGuardAppliedConfig`, `OutputGuardConfigSyncStatus`, `OutputGuardSettingsReader` + - `ApplyOutputGuardSettings(database.OutputGuardSettings) bool` + - `CurrentOutputGuardConfig() OutputGuardAppliedConfig` + - `GetOutputGuardConfigSyncStatus() OutputGuardConfigSyncStatus` + - `IsOutputGuardReady() bool` + - `LoadOutputGuardSettings(ctx context.Context, reader OutputGuardSettingsReader) error` + - `StartOutputGuardConfigPoller(parent context.Context, db *database.DB) bool` + - `RequireOutputGuardReadyHTTP(c *gin.Context) bool` + - `RequireOutputGuardReadyWS(conn *websocket.Conn) bool` + - `RequireOutputGuardReadyStream(conn *websocket.Conn, streamID string) bool` +- Produces (Tests Only: `proxy/output_guard_settings_test.go`): + - `ResetOutputGuardStateForTest()` + - `SetOutputGuardReadyForTest(ready bool)` + - `PollOutputGuardConfigOnceForTest(ctx context.Context, db *database.DB) error` + +--- + +- [ ] **Step 1: Write failing test for proxy output guard runtime settings, poller, LKG, and readiness gates** + +```go +// proxy/output_guard_settings_test.go +package proxy + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/codex2api/api" + "github.com/codex2api/database" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +func ResetOutputGuardStateForTest() { + globalOutputGuardState.mu.Lock() + defer globalOutputGuardState.mu.Unlock() + globalOutputGuardState.config = OutputGuardAppliedConfig{ + Enabled: true, MaxToolArgumentBytes: 131072, MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, MaxRequestBytes: 4194304, Generation: 0, + } + globalOutputGuardState.lastSyncAt = time.Time{} + globalOutputGuardState.lastSyncErr = "" + globalOutputGuardState.isReady.Store(false) +} + +func SetOutputGuardReadyForTest(ready bool) { + globalOutputGuardState.isReady.Store(ready) +} + +func PollOutputGuardConfigOnceForTest(ctx context.Context, db *database.DB) error { + return pollOutputGuardConfigOnce(ctx, db, OutputGuardConfigReadTimeout) +} + +type controlledOutputGuardSettingsReader struct { + mu sync.Mutex; requests chan chan outputGuardSettingsReadResult +} + +type outputGuardSettingsReadResult struct { + settings database.OutputGuardSettings; err error +} + +func newControlledOutputGuardSettingsReader() *controlledOutputGuardSettingsReader { + return &controlledOutputGuardSettingsReader{requests: make(chan chan outputGuardSettingsReadResult, 16)} +} + +func (r *controlledOutputGuardSettingsReader) GetOutputGuardSettings(ctx context.Context) (database.OutputGuardSettings, error) { + respCh := make(chan outputGuardSettingsReadResult, 1) + select { + case <-ctx.Done(): return database.OutputGuardSettings{}, ctx.Err() + case r.requests <- respCh: + } + select { + case <-ctx.Done(): return database.OutputGuardSettings{}, ctx.Err() + case res := <-respCh: return res.settings, res.err + } +} + +func (r *controlledOutputGuardSettingsReader) respond(t *testing.T, res outputGuardSettingsReadResult) { + t.Helper() + select { + case respCh := <-r.requests: respCh <- res + case <-time.After(2 * time.Second): t.Fatal("timed out waiting for GetOutputGuardSettings request") + } +} + +func waitForOutputGuardConfigGeneration(t *testing.T, targetGen int64) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if CurrentOutputGuardConfig().Generation == targetGen { return } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("timed out waiting for generation %d", targetGen) +} + +func waitForOutputGuardSyncError(t *testing.T, substr string) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + status := GetOutputGuardConfigSyncStatus() + if status.LastSyncError != "" && (substr == "" || strings.Contains(status.LastSyncError, substr)) { return } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("timed out waiting for sync error: %s", substr) +} + +func TestOutputGuardColdStartNotReadyReturns503(t *testing.T) { + ResetOutputGuardStateForTest() + t.Cleanup(ResetOutputGuardStateForTest) + if IsOutputGuardReady() { t.Fatal("expected cold start IsOutputGuardReady = false") } + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + if RequireOutputGuardReadyHTTP(c) { t.Fatal("expected RequireOutputGuardReadyHTTP to reject cold start") } + if w.Code != http.StatusServiceUnavailable { t.Fatalf("expected HTTP 503, got %d", w.Code) } + + var errResp struct{ Error struct{ Code string `json:"code"` } `json:"error"` } + if err := json.Unmarshal(w.Body.Bytes(), &errResp); err != nil { t.Fatalf("unmarshal error failed: %v", err) } + if errResp.Error.Code != string(api.ErrCodeServiceUnavailable) { + t.Fatalf("expected error code %q, got %q", api.ErrCodeServiceUnavailable, errResp.Error.Code) + } +} + +func TestOutputGuardExplicitDisabledSemanticVsColdStart(t *testing.T) { + ResetOutputGuardStateForTest() + t.Cleanup(ResetOutputGuardStateForTest) + if IsOutputGuardReady() { t.Fatal("expected cold start not ready") } + + disabled := false + dbSettings := database.OutputGuardSettings{ + Enabled: &disabled, MaxToolArgumentBytes: 131072, MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, MaxRequestBytes: 4194304, ConfigGeneration: 1, + } + + if !ApplyOutputGuardSettings(dbSettings) { t.Fatal("ApplyOutputGuardSettings failed") } + if !IsOutputGuardReady() { t.Fatal("expected IsOutputGuardReady = true after explicit disabled settings loaded") } + + cfg := CurrentOutputGuardConfig() + if cfg.Enabled != false { t.Fatalf("expected applied config Enabled = false, got %v", cfg.Enabled) } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + if !RequireOutputGuardReadyHTTP(c) { t.Fatal("expected pass when system is ready and explicitly disabled") } + if w.Code == http.StatusServiceUnavailable { t.Fatalf("did not expect 503 when explicitly disabled and ready") } +} + +func TestOutputGuardConfigGenerationIncrementsAndLKGMaintained(t *testing.T) { + ResetOutputGuardStateForTest() + t.Cleanup(ResetOutputGuardStateForTest) + + enabled := true + initial := database.OutputGuardSettings{ + Enabled: &enabled, MaxToolArgumentBytes: 131072, MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, MaxRequestBytes: 4194304, ConfigGeneration: 1, + } + if !ApplyOutputGuardSettings(initial) { t.Fatal("failed to apply initial generation 1") } + + cfg1 := CurrentOutputGuardConfig() + if cfg1.Generation != 1 || cfg1.MaxToolArgumentBytes != 131072 { t.Fatalf("initial mismatch: %+v", cfg1) } + + stale := initial; stale.MaxToolArgumentBytes = 262144 + if ApplyOutputGuardSettings(stale) { t.Fatal("stale generation 1 should be rejected") } + if CurrentOutputGuardConfig().MaxToolArgumentBytes != 131072 { t.Fatal("LKG overwritten by stale generation") } + + gen2 := initial; gen2.MaxToolArgumentBytes = 262144; gen2.ConfigGeneration = 2 + if !ApplyOutputGuardSettings(gen2) { t.Fatal("failed to apply generation 2") } + cfg2 := CurrentOutputGuardConfig() + if cfg2.Generation != 2 || cfg2.MaxToolArgumentBytes != 262144 { t.Fatalf("gen 2 mismatch: %+v", cfg2) } + + illegal := initial; illegal.MaxToolArgumentBytes = 50000000; illegal.ConfigGeneration = 3 + if ApplyOutputGuardSettings(illegal) { t.Fatal("illegal configuration should be rejected") } + cfgPreserved := CurrentOutputGuardConfig() + if cfgPreserved.Generation != 2 || cfgPreserved.MaxToolArgumentBytes != 262144 { + t.Fatalf("LKG not preserved after illegal config: %+v", cfgPreserved) + } +} + +func TestOutputGuardPollerLifecycleRecoveryAndStop(t *testing.T) { + ResetOutputGuardStateForTest() + t.Cleanup(ResetOutputGuardStateForTest) + + reader := newControlledOutputGuardSettingsReader() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan struct{}) + go func() { + defer close(done) + runOutputGuardConfigPoller(ctx, reader, 5*time.Millisecond, 100*time.Millisecond) + }() + + enabled := true + reader.respond(t, outputGuardSettingsReadResult{ + settings: database.OutputGuardSettings{ + Enabled: &enabled, MaxToolArgumentBytes: 131072, MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, MaxRequestBytes: 4194304, ConfigGeneration: 2, + }, + }) + waitForOutputGuardConfigGeneration(t, 2) + if !IsOutputGuardReady() { t.Fatal("expected IsOutputGuardReady = true after successful poll") } + + reader.respond(t, outputGuardSettingsReadResult{err: errors.New("simulated database disconnect")}) + waitForOutputGuardSyncError(t, "simulated database disconnect") + if CurrentOutputGuardConfig().Generation != 2 { t.Fatal("LKG generation lost during DB outage") } + + reader.respond(t, outputGuardSettingsReadResult{ + settings: database.OutputGuardSettings{ + Enabled: &enabled, MaxToolArgumentBytes: 262144, MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, MaxRequestBytes: 4194304, ConfigGeneration: 3, + }, + }) + waitForOutputGuardConfigGeneration(t, 3) + if CurrentOutputGuardConfig().MaxToolArgumentBytes != 262144 { t.Fatal("failed to update config after recovery") } + + cancel() + select { + case <-done: + case <-time.After(500 * time.Millisecond): t.Fatal("poller failed to stop cleanly after context cancel") + } +} + +func TestOutputGuardWSReadinessGate(t *testing.T) { + ResetOutputGuardStateForTest() + t.Cleanup(ResetOutputGuardStateForTest) + + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { return } + defer conn.Close() + if !RequireOutputGuardReadyWS(conn) { return } + _ = conn.WriteMessage(websocket.TextMessage, []byte("ready")) + })) + defer server.Close() + + wsURL := "ws" + server.URL[len("http"):] + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { t.Fatalf("dial WS failed: %v", err) } + defer conn.Close() + + msgType, msg, err := conn.ReadMessage() + if err == nil { + if msgType != websocket.TextMessage { t.Fatalf("expected text message error frame, got %d", msgType) } + var errResp struct{ Error struct{ Code string `json:"code"` } `json:"error"` } + if err := json.Unmarshal(msg, &errResp); err != nil { t.Fatalf("unmarshal error frame failed: %v", err) } + if errResp.Error.Code != string(api.ErrCodeServiceUnavailable) { + t.Fatalf("expected error frame %s, got %s", api.ErrCodeServiceUnavailable, errResp.Error.Code) + } + _, _, err = conn.ReadMessage() + } + + if err == nil { t.Fatal("expected connection closed with 1013 CloseTryAgainLater, got nil error") } + var closeErr *websocket.CloseError + if errors.As(err, &closeErr) { + if closeErr.Code != websocket.CloseTryAgainLater { t.Fatalf("expected 1013 CloseTryAgainLater, got %d", closeErr.Code) } + } else { + t.Fatalf("expected websocket.CloseError, got %v", err) + } +} + +func TestOutputGuardStreamReadinessGate(t *testing.T) { + ResetOutputGuardStateForTest() + t.Cleanup(ResetOutputGuardStateForTest) + + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { return } + defer conn.Close() + _ = RequireOutputGuardReadyStream(conn, "stream-test-123") + })) + defer server.Close() + + wsURL := "ws" + server.URL[len("http"):] + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { t.Fatalf("dial WS failed: %v", err) } + defer conn.Close() + + _, msg, err := conn.ReadMessage() + if err != nil { t.Fatalf("failed to read stream error frame: %v", err) } + + var streamErrResp struct { + Type string `json:"type"`; StreamID string `json:"stream_id"` + Status int `json:"status"`; Error *api.APIError `json:"error"` + } + if err := json.Unmarshal(msg, &streamErrResp); err != nil { t.Fatalf("unmarshal stream error failed: %v", err) } + if streamErrResp.Type != "error" || streamErrResp.StreamID != "stream-test-123" { + t.Fatalf("unexpected stream envelope: %+v", streamErrResp) + } + if streamErrResp.Error == nil || streamErrResp.Error.Code != api.ErrCodeServiceUnavailable { + t.Fatalf("expected service_unavailable, got %+v", streamErrResp.Error) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestOutputGuard.*"` +Expected: FAIL with compilation error: undefined: ApplyOutputGuardSettings, RequireOutputGuardReadyHTTP, etc. + +- [ ] **Step 3: Write minimal implementation for proxy/output_guard_settings.go and update main.go** + +```go +// proxy/output_guard_settings.go +package proxy + +import ( + "context" + "fmt" + "log" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/codex2api/api" + "github.com/codex2api/database" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +const ( + OutputGuardConfigPollInterval = 5 * time.Second + OutputGuardConfigReadTimeout = 3 * time.Second +) + +type OutputGuardAppliedConfig struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 + Generation int64 +} + +type OutputGuardConfigSyncStatus struct { + LastSuccessfulSyncAt time.Time + LastSyncError string +} + +type OutputGuardSettingsReader interface { + GetOutputGuardSettings(context.Context) (database.OutputGuardSettings, error) +} + +type outputGuardState struct { + mu sync.RWMutex + config OutputGuardAppliedConfig + lastSyncAt time.Time + lastSyncErr string + isReady atomic.Bool +} + +var globalOutputGuardState = &outputGuardState{ + config: OutputGuardAppliedConfig{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + Generation: 0, + }, +} + +func IsOutputGuardReady() bool { + return globalOutputGuardState.isReady.Load() +} + +func CurrentOutputGuardConfig() OutputGuardAppliedConfig { + globalOutputGuardState.mu.RLock() + defer globalOutputGuardState.mu.RUnlock() + return globalOutputGuardState.config +} + +func GetOutputGuardConfigSyncStatus() OutputGuardConfigSyncStatus { + globalOutputGuardState.mu.RLock() + defer globalOutputGuardState.mu.RUnlock() + return OutputGuardConfigSyncStatus{ + LastSuccessfulSyncAt: globalOutputGuardState.lastSyncAt, + LastSyncError: globalOutputGuardState.lastSyncErr, + } +} + +func ApplyOutputGuardSettings(settings database.OutputGuardSettings) bool { + if err := database.ValidateOutputGuardSettings(settings); err != nil { + log.Printf("[output-guard] 非法配置已被拒绝,保留 LKG: %v", err) + recordOutputGuardConfigSyncError(err) + return false + } + + globalOutputGuardState.mu.Lock() + defer globalOutputGuardState.mu.Unlock() + + if settings.ConfigGeneration <= globalOutputGuardState.config.Generation { + return false + } + + enabledVal := true + if settings.Enabled != nil { + enabledVal = *settings.Enabled + } + + globalOutputGuardState.config = OutputGuardAppliedConfig{ + Enabled: enabledVal, + MaxToolArgumentBytes: settings.MaxToolArgumentBytes, + MaxToolArgumentDeltas: settings.MaxToolArgumentDeltas, + MaxChannelBytes: settings.MaxChannelBytes, + MaxRequestBytes: settings.MaxRequestBytes, + Generation: settings.ConfigGeneration, + } + globalOutputGuardState.isReady.Store(true) + return true +} + +func LoadOutputGuardSettings(ctx context.Context, reader OutputGuardSettingsReader) error { + settings, err := readOutputGuardSettings(ctx, reader, OutputGuardConfigReadTimeout) + if err != nil { + if ctx == nil || ctx.Err() == nil { + recordOutputGuardConfigSyncError(err) + } + return err + } + if err := database.ValidateOutputGuardSettings(settings); err != nil { + recordOutputGuardConfigSyncError(err) + return err + } + ApplyOutputGuardSettings(settings) + recordOutputGuardConfigSyncSuccess(time.Now()) + return nil +} + +func StartOutputGuardConfigPoller(parent context.Context, db *database.DB) bool { + if db == nil { + return false + } + if parent == nil { + parent = context.Background() + } + return db.RunBackgroundTask(func(lifecycle context.Context) { + taskCtx, cancel := context.WithCancel(lifecycle) + stopParent := context.AfterFunc(parent, cancel) + defer func() { + stopParent() + cancel() + }() + runOutputGuardConfigPoller( + taskCtx, + db, + OutputGuardConfigPollInterval, + OutputGuardConfigReadTimeout, + ) + }) +} + +func runOutputGuardConfigPoller( + ctx context.Context, + reader OutputGuardSettingsReader, + interval time.Duration, + readTimeout time.Duration, +) { + if ctx == nil { + ctx = context.Background() + } + if interval <= 0 { + interval = OutputGuardConfigPollInterval + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + _ = pollOutputGuardConfigOnce(ctx, reader, readTimeout) + } + } +} + +func pollOutputGuardConfigOnce( + ctx context.Context, + reader OutputGuardSettingsReader, + readTimeout time.Duration, +) error { + settings, err := readOutputGuardSettings(ctx, reader, readTimeout) + if err != nil { + if ctx != nil && ctx.Err() != nil { + return ctx.Err() + } + recordOutputGuardConfigSyncError(err) + return err + } + if err := database.ValidateOutputGuardSettings(settings); err != nil { + recordOutputGuardConfigSyncError(err) + return err + } + ApplyOutputGuardSettings(settings) + recordOutputGuardConfigSyncSuccess(time.Now()) + return nil +} + +func readOutputGuardSettings( + parent context.Context, + reader OutputGuardSettingsReader, + timeout time.Duration, +) (database.OutputGuardSettings, error) { + if reader == nil { + return database.OutputGuardSettings{}, fmt.Errorf("output guard settings reader is nil") + } + if parent == nil { + parent = context.Background() + } + if err := parent.Err(); err != nil { + return database.OutputGuardSettings{}, err + } + if timeout <= 0 || timeout > OutputGuardConfigReadTimeout { + timeout = OutputGuardConfigReadTimeout + } + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + return reader.GetOutputGuardSettings(ctx) +} + +func recordOutputGuardConfigSyncSuccess(at time.Time) { + globalOutputGuardState.mu.Lock() + globalOutputGuardState.lastSyncAt = at + globalOutputGuardState.lastSyncErr = "" + globalOutputGuardState.mu.Unlock() +} + +func recordOutputGuardConfigSyncError(err error) { + if err == nil { + return + } + globalOutputGuardState.mu.Lock() + globalOutputGuardState.lastSyncErr = err.Error() + globalOutputGuardState.mu.Unlock() +} + +func RequireOutputGuardReadyHTTP(c *gin.Context) bool { + if IsOutputGuardReady() { + return true + } + apiErr := api.NewAPIError( + api.ErrCodeServiceUnavailable, + "Service configuration unavailable; failed to load output guard settings", + api.ErrorTypeServer, + ) + api.SendErrorWithStatus(c, apiErr, http.StatusServiceUnavailable) + return false +} + +func RequireOutputGuardReadyWS(conn *websocket.Conn) bool { + if IsOutputGuardReady() { + return true + } + if conn == nil { + return false + } + apiErr := api.NewAPIError( + api.ErrCodeServiceUnavailable, + "Service configuration unavailable; failed to load output guard settings", + api.ErrorTypeServer, + ) + _ = writeResponsesWSErrorWithin(conn, apiErr, 2*time.Second) + _ = conn.WriteControl( + websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseTryAgainLater, "Service configuration unavailable"), + time.Now().Add(time.Second), + ) + _ = conn.Close() + return false +} + +func RequireOutputGuardReadyStream(conn *websocket.Conn, streamID string) bool { + if IsOutputGuardReady() { + return true + } + if conn == nil { + return false + } + apiErr := api.NewAPIError( + api.ErrCodeServiceUnavailable, + "Service configuration unavailable; failed to load output guard settings", + api.ErrorTypeServer, + ) + _ = writeResponsesWSErrorForStream(conn, apiErr, streamID, 2*time.Second) + return false +} +``` + +精确插入锚点:`main.go:265-270`(紧随 ResponseCache 设置加载与日志之后,挂载 OutputGuard 加载与 Poller): + +```go + // main.go 锚点替换:紧接 line 265 + appliedResponseCache := proxy.GetResponseCacheAppliedConfig() + log.Printf( + "响应缓存设置已加载: generation=%d total=%d entry=%d reconstruct=%d", + appliedResponseCache.Generation, appliedResponseCache.LocalMaxBytes, + appliedResponseCache.LocalMaxEntryBytes, appliedResponseCache.ReconstructMaxBytes, + ) + + // --- 插入开始: OutputGuard 加载与后台 5 秒轮询器启动 --- + outputGuardCtx, outputGuardCancel := context.WithTimeout(context.Background(), 3*time.Second) + if err := proxy.LoadOutputGuardSettings(outputGuardCtx, db); err != nil { + outputGuardCancel() + log.Printf("警告: 加载模型输出失控防护设置失败(冷启动门禁将返回 503 直至 DB 轮询恢复): %v", err) + } else { + outputGuardCancel() + appliedGuard := proxy.CurrentOutputGuardConfig() + log.Printf( + "模型输出失控防护设置已加载: generation=%d enabled=%t tool_bytes=%d tool_deltas=%d channel_bytes=%d request_bytes=%d", + appliedGuard.Generation, appliedGuard.Enabled, appliedGuard.MaxToolArgumentBytes, + appliedGuard.MaxToolArgumentDeltas, appliedGuard.MaxChannelBytes, appliedGuard.MaxRequestBytes, + ) + } + proxy.StartOutputGuardConfigPoller(context.Background(), db) + // --- 插入结束 --- +``` + +- [ ] **Step 4: Run test to verify it passes (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race ./proxy -run "TestOutputGuard.*"` +Expected: PASS ok github.com/codex2api/proxy + +- [ ] **Step 5: Commit** + +```bash +git add proxy/output_guard_settings.go proxy/output_guard_settings_test.go main.go +git commit -m "feat(proxy): add output guard runtime config snapshot, poller and cold start readiness gate" +``` diff --git a/docs/superpowers/plans/model-output-guard-tdd/02c-admin-ui.md b/docs/superpowers/plans/model-output-guard-tdd/02c-admin-ui.md new file mode 100644 index 000000000..90aa1e150 --- /dev/null +++ b/docs/superpowers/plans/model-output-guard-tdd/02c-admin-ui.md @@ -0,0 +1,892 @@ +# 02c-Admin-UI: 模型输出失控防护管理端 API 与前端设置界面 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 基于已审查通过的设计规格(Spec §7、§9.21、§10),在 Codex2API 管理端扩展既有 `/api/admin/settings` GET/PUT 接口与前端 Settings 界面(Task 6),提供 5 项核心配置与 1 项只读版本(generation),支持三态显式 `false` 持久化与高风险关闭警告、严格层级范围校验($16\text{KiB} \le \text{tool} \le \text{channel} \le \text{request} \le 64\text{MiB}$)、同事务短合并更新与多节点/内存快照即时热应用、前端 General Tab 独立设置卡片、KiB/MiB 友好的非破坏性输入、“恢复默认”原子重置与只读 generation 安全隔离。 + +**Architecture:** +- `admin/output_guard_settings.go`: 提供输入范围校验 `validateOutputGuardSettingsUpdateRanges`,复用 `database.ErrInvalidOutputGuardSettings` 及各常量范围。 +- `admin/handler.go`: + - `settingsResponse`: 扩展 5 个完整对称配置字段 + 1 个只读 `output_guard_config_generation`。 + - `updateSettingsReq`: 扩展 5 个可空指针配置字段 + `OutputGuardConfigGeneration rawJSON`。若请求体携带只读 generation 则严格返回 HTTP 400 拦截。 + - `GetSettings`: 读取 `db.GetOutputGuardSettings(ctx)`,回填到响应结构。 + - `UpdateSettings`: 经范围校验后,先查现有配置执行 `database.ValidateOutputGuardSettings` 校验层级合法性;再调用 `db.UpdateOutputGuardSettings(ctx, patch)` 执行短事务合并,成功后立即调用 `proxy.ApplyOutputGuardSettings(committed)` 热应用生效并返回最新快照;非法请求绝不修改 DB 或运行时。沿用既有设计,与其它设置同批请求时遵循独立提交语义,不承诺跨表全局分布式原子性。 +- `frontend/src/types.ts`: `SystemSettings` 扩展 6 个属性(含 `readonly output_guard_config_generation: number`)。 +- `frontend/src/lib/settingsPayload.ts`: `buildWritableSettingsPayload` 中剔除 `output_guard_config_generation`,避免任何常规保存回写只读字段。 +- `frontend/src/lib/outputGuardSettings.ts`: 提供 IEC KiB/MiB 换算、`tool <= channel <= request` 层级校验器与默认值。 +- `frontend/src/pages/Settings.tsx`: + - General Tab 增加独立的“模型输出失控防护”`SettingsCard`,绝不挂载在 PromptFilter 之下。 + - 显式 `false` 开关与高风险关闭横幅提醒(使用已导入的 `ShieldAlert` 图标,禁止未导入图标)。 + - 工具参数支持 KiB 输入(16 KiB ~ 4096 KiB),单通道支持 KiB 输入(128 KiB ~ 16384 KiB,确保 128 KiB 完全可表达),单请求支持 KiB 输入(256 KiB ~ 65536 KiB,确保 256 KiB 合法下限完全可表达且默认 4 MiB 显示为 4096 KiB)。 + - “恢复默认”提供一次性 5 字段完整 patch 提交,并在失败时自动 rollback。 + +**Tech Stack:** Go 1.26+, Gin 1.12, TypeScript 5.9+, React 19, Lucide React, Node test runner (`node --experimental-strip-types --test`). + +**Spec:** `docs/superpowers/specs/2026-10-28-model-output-guard-design.md` (§7, §9.21, §10) + +## Global Constraints + +- **单一窄任务**:仅负责 Task 6 管理端 Admin API 与前端 UI 模块;不修改 Task 4 数据库核心、Task 5 运行时/轮询器、协议转发或任何既有业务/spec。 +- **接口契约**: + - 必须复用现有的 `GET /api/admin/settings` 与 `PUT /api/admin/settings`,绝不新增独立 API endpoint。 + - 消费 Task 4 契约:`database.OutputGuardSettings`、`database.OutputGuardSettingsUpdate`、`database.ValidateOutputGuardSettings`、`db.GetOutputGuardSettings`、`db.UpdateOutputGuardSettings`、以及常量 `database.MinOutputGuardMax*` / `database.MaxOutputGuardMax*`。 + - 消费 Task 5 契约:`proxy.ApplyOutputGuardSettings(database.OutputGuardSettings) bool` 与 `proxy.CurrentOutputGuardConfig() proxy.OutputGuardAppliedConfig`。 +- **三态与零值契约**:`output_guard_enabled` 为指针 `*bool`,显式 `false` 必须原样提交与持久化,禁止被 `|| true` 或零值回退覆盖。 +- **只读字段安全**:`output_guard_config_generation` 纯只读。前端提交 payload 必须自动剔除;后端接收到非空值必须直接返回 HTTP 400 拒绝,禁止静默忽略或覆盖。 +- **层级约束**:严格满足 $16\text{KiB} \le \text{tool} \le \text{channel} \le \text{request} \le 64\text{MiB}$。通道下限为 128 KiB,前端不得设定最小 1 MiB 导致 128 KiB 合法值无法配置。 +- **测试环境约束**:Go API 测试使用真实 SQLite fixture(调用测试文件内真实的 `newTestAdminDB(t)`,禁止跨包引用私有 helper 或伪造断言);TS 测试使用 `node --experimental-strip-types --test` 运行真实断言。 + +--- + +## File Structure + +| 文件路径 | 状态 | 职责描述 | +|---|---|---| +| `admin/output_guard_settings.go` | 新建 | 管理端输入范围校验 `validateOutputGuardSettingsUpdateRanges` | +| `admin/output_guard_settings_test.go` | 新建 | API 单元/集成测试:对称字段、只读 generation 拒绝、范围/层级非法拒绝、显式 false、局部更新隔离 | +| `admin/handler.go` | 修改 (锚点: 9460, 9635, 10240, 10600, 10750, 12220) | 扩展 `settingsResponse`、`updateSettingsReq`、`GetSettings` 与 `UpdateSettings` | +| `frontend/src/types.ts` | 修改 (锚点: 2305) | `SystemSettings` 扩展 5 个配置属性 + 1 个只读 `output_guard_config_generation` | +| `frontend/src/lib/settingsPayload.ts` | 修改 (锚点: 1, 9) | `buildWritableSettingsPayload` 剔除 `output_guard_config_generation` | +| `frontend/src/lib/settingsPayload.test.mjs` | 修改 (锚点: 6) | 验证 writable payload 严格过滤 `output_guard_config_generation` | +| `frontend/src/lib/outputGuardSettings.ts` | 新建 | 前端单位换算(KiB/MiB)、`tool <= channel <= request` 层级校验器与默认值定义 | +| `frontend/src/lib/outputGuardSettings.test.mjs` | 新建 | 前端纯函数测试:层级校验、范围边界、单位转换与默认值比对 | +| `frontend/src/pages/Settings.tsx` | 修改 (锚点: 195, 2760, 2960, 6550) | `useState` 初始 literal 6 字段补全、General Tab 独立卡片 UI、DraftNumberInput 输入、显式 false、高风险警告与恢复默认 | + +--- + +## Task 6: 管理端 Admin API 扩展与前端设置界面独立卡片 + +**Files:** +- Create: `admin/output_guard_settings.go` +- Create: `admin/output_guard_settings_test.go` +- Modify: `admin/handler.go:9460-9640,10240-10320,10600-10760,12210-12260` +- Modify: `frontend/src/types.ts:2300-2315` +- Modify: `frontend/src/lib/settingsPayload.ts:1-16` +- Modify: `frontend/src/lib/settingsPayload.test.mjs:6-42` +- Create: `frontend/src/lib/outputGuardSettings.ts` +- Create: `frontend/src/lib/outputGuardSettings.test.mjs` +- Modify: `frontend/src/pages/Settings.tsx:195-210,2760-2840,2960-3020,6550-6640` + +**Interfaces:** +- Consumes: + - `database.OutputGuardSettings`: `Enabled *bool`, `MaxToolArgumentBytes int64`, `MaxToolArgumentDeltas int`, `MaxChannelBytes int64`, `MaxRequestBytes int64`, `ConfigGeneration int64` + - `database.OutputGuardSettingsUpdate`: `Enabled *bool`, `MaxToolArgumentBytes *int64`, `MaxToolArgumentDeltas *int`, `MaxChannelBytes *int64`, `MaxRequestBytes *int64` + - `database.ValidateOutputGuardSettings(database.OutputGuardSettings) error` + - `database.MinOutputGuardMaxToolArgumentBytes` (16384), `database.MaxOutputGuardMaxToolArgumentBytes` (4194304) + - `database.MinOutputGuardMaxToolArgumentDeltas` (256), `database.MaxOutputGuardMaxToolArgumentDeltas` (65536) + - `database.MinOutputGuardMaxChannelBytes` (131072), `database.MaxOutputGuardMaxChannelBytes` (16777216) + - `database.MinOutputGuardMaxRequestBytes` (262144), `database.MaxOutputGuardMaxRequestBytes` (67108864) + - `db.GetOutputGuardSettings(context.Context) (database.OutputGuardSettings, error)` + - `db.UpdateOutputGuardSettings(context.Context, database.OutputGuardSettingsUpdate) (database.OutputGuardSettings, error)` + - `proxy.ApplyOutputGuardSettings(database.OutputGuardSettings) bool` + - `proxy.CurrentOutputGuardConfig() proxy.OutputGuardAppliedConfig` +- Produces: + - Admin REST API (`GET /api/admin/settings` & `PUT /api/admin/settings`): + - `output_guard_enabled`: bool + - `output_guard_max_tool_argument_bytes`: int64 + - `output_guard_max_tool_argument_deltas`: int + - `output_guard_max_channel_bytes`: int64 + - `output_guard_max_request_bytes`: int64 + - `output_guard_config_generation`: int64 (只读) + - Frontend TypeScript Interfaces & Helpers: + - `SystemSettings` 含 6 个 output_guard 字段 + - `buildWritableSettingsPayload(settings: T)` + - `validateOutputGuardHierarchy(settings): string | null` + - `validateOutputGuardRanges(settings): string | null` + - `bytesToKiB`, `kibToBytes`, `bytesToMiB`, `mibToBytes` + - `DEFAULT_OUTPUT_GUARD_SETTINGS` + +--- + +### Step 1: Write Failing Tests for Admin Output Guard Settings API + +创建 `admin/output_guard_settings_test.go`: + +```go +package admin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/codex2api/auth" + "github.com/codex2api/cache" + "github.com/codex2api/database" + "github.com/codex2api/proxy" + "github.com/gin-gonic/gin" +) + +func newTestAdminHandlerFixture(t *testing.T) (*Handler, *database.DB) { + t.Helper() + db := newTestAdminDB(t) + tc := cache.NewMemory(4) + t.Cleanup(func() { _ = tc.Close() }) + settings := defaultBootstrapSettings() + if err := db.UpdateSystemSettings(context.Background(), settings); err != nil { + t.Fatalf("seed settings: %v", err) + } + store := auth.NewStore(db, tc, settings) + t.Cleanup(store.Stop) + handler := NewHandler(store, db, tc, proxy.NewRateLimiter(settings.GlobalRPM), "admin-secret") + return handler, db +} + +func invokeAdminSettingsEndpoint(t *testing.T, handler *Handler, method string, payload map[string]any) *httptest.ResponseRecorder { + t.Helper() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + var bodyReader *strings.Reader + if payload != nil { + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload failed: %v", err) + } + bodyReader = strings.NewReader(string(body)) + } else { + bodyReader = strings.NewReader("") + } + c.Request = httptest.NewRequest(method, "/api/admin/settings", bodyReader) + c.Request.Header.Set("Content-Type", "application/json") + if method == http.MethodGet { + handler.GetSettings(c) + } else { + handler.UpdateSettings(c) + } + return w +} + +func TestAdminOutputGuardSettings_GetSymmetricFields(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, _ := newTestAdminHandlerFixture(t) + + w := invokeAdminSettingsEndpoint(t, handler, http.MethodGet, nil) + if w.Code != http.StatusOK { + t.Fatalf("expected HTTP 200, got %d, body: %s", w.Code, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + fields := []string{ + "output_guard_enabled", "output_guard_max_tool_argument_bytes", + "output_guard_max_tool_argument_deltas", "output_guard_max_channel_bytes", + "output_guard_max_request_bytes", "output_guard_config_generation", + } + for _, f := range fields { + if _, ok := resp[f]; !ok { + t.Fatalf("expected field %q in GET /api/admin/settings response", f) + } + } + if resp["output_guard_enabled"] != false { + t.Fatalf("expected default enabled = false, got %v", resp["output_guard_enabled"]) + } + if int64(resp["output_guard_max_tool_argument_bytes"].(float64)) != 262144 { + t.Fatalf("expected default tool bytes 262144, got %v", resp["output_guard_max_tool_argument_bytes"]) + } +} + +func TestAdminOutputGuardSettings_PutValidationAndReadonlyRejection(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, db := newTestAdminHandlerFixture(t) + + // 1. 提交只读 generation 必须报错 400 + { + w := invokeAdminSettingsEndpoint(t, handler, http.MethodPut, map[string]any{"output_guard_config_generation": 999}) + if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "只读") { + t.Fatalf("expected 400 with readonly rejection, got code=%d body=%s", w.Code, w.Body.String()) + } + } + // 2. 超出单个字段合法范围必须报错 400 + { + w := invokeAdminSettingsEndpoint(t, handler, http.MethodPut, map[string]any{"output_guard_max_tool_argument_bytes": 1024}) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 on out of range tool bytes, got %d", w.Code) + } + } + // 3. 违反 tool <= channel <= request 层级校验必须报错 400 且不改写 DB + { + w := invokeAdminSettingsEndpoint(t, handler, http.MethodPut, map[string]any{ + "output_guard_max_tool_argument_bytes": 3145728, + "output_guard_max_channel_bytes": 2097152, + }) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 on hierarchy violation, got %d", w.Code) + } + current, err := db.GetOutputGuardSettings(context.Background()) + if err != nil || current.MaxToolArgumentBytes == 3145728 { + t.Fatalf("database should not be updated on illegal hierarchy validation, err=%v", err) + } + } + // 4. 显式 true 准确持久化与热应用 + { + w := invokeAdminSettingsEndpoint(t, handler, http.MethodPut, map[string]any{"output_guard_enabled": true}) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 on explicit true update, got %d, body: %s", w.Code, w.Body.String()) + } + var resp map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if resp["output_guard_enabled"] != true { + t.Fatalf("expected output_guard_enabled = true in response, got %v", resp["output_guard_enabled"]) + } + current, err := db.GetOutputGuardSettings(context.Background()) + if err != nil || current.Enabled == nil || *current.Enabled != true { + t.Fatalf("database must store explicit true") + } + } + // 5. 局部字段更新隔离:仅更新 deltas 不改变其他字段,并立即热应用 + { + w := invokeAdminSettingsEndpoint(t, handler, http.MethodPut, map[string]any{"output_guard_max_tool_argument_deltas": 8192}) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 on partial update, got %d", w.Code) + } + var resp map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if int(resp["output_guard_max_tool_argument_deltas"].(float64)) != 8192 || resp["output_guard_enabled"] != true { + t.Fatalf("expected deltas = 8192 and preserved enabled=true, got %v", resp) + } + if runtimeCfg := proxy.CurrentOutputGuardConfig(); runtimeCfg.MaxToolArgumentDeltas != 8192 { + t.Fatalf("runtime snapshot must be immediately updated, got %d", runtimeCfg.MaxToolArgumentDeltas) + } + } +} +``` + +--- + +### Step 2: Verify Failing Tests Fail (RED) + +Run: +```bash +GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./admin -run "TestAdminOutputGuardSettings_.*" +``` +Expected: FAIL with `unknown field output_guard_enabled in settingsResponse` 或编译错误(因为结构体与接口尚未扩展)。 + +--- + +### Step 3: Implement Backend Admin API Extensions + +1. 创建 `admin/output_guard_settings.go`: +```go +package admin + +import ( + "fmt" + + "github.com/codex2api/database" +) + +func validateOutputGuardSettingsUpdateRanges(update database.OutputGuardSettingsUpdate) error { + switch { + case update.MaxToolArgumentBytes != nil && + (*update.MaxToolArgumentBytes < database.MinOutputGuardMaxToolArgumentBytes || + *update.MaxToolArgumentBytes > database.MaxOutputGuardMaxToolArgumentBytes): + return fmt.Errorf("%w: output_guard_max_tool_argument_bytes must be between %d and %d", + database.ErrInvalidOutputGuardSettings, database.MinOutputGuardMaxToolArgumentBytes, database.MaxOutputGuardMaxToolArgumentBytes) + case update.MaxToolArgumentDeltas != nil && + (*update.MaxToolArgumentDeltas < database.MinOutputGuardMaxToolArgumentDeltas || + *update.MaxToolArgumentDeltas > database.MaxOutputGuardMaxToolArgumentDeltas): + return fmt.Errorf("%w: output_guard_max_tool_argument_deltas must be between %d and %d", + database.ErrInvalidOutputGuardSettings, database.MinOutputGuardMaxToolArgumentDeltas, database.MaxOutputGuardMaxToolArgumentDeltas) + case update.MaxChannelBytes != nil && + (*update.MaxChannelBytes < database.MinOutputGuardMaxChannelBytes || + *update.MaxChannelBytes > database.MaxOutputGuardMaxChannelBytes): + return fmt.Errorf("%w: output_guard_max_channel_bytes must be between %d and %d", + database.ErrInvalidOutputGuardSettings, database.MinOutputGuardMaxChannelBytes, database.MaxOutputGuardMaxChannelBytes) + case update.MaxRequestBytes != nil && + (*update.MaxRequestBytes < database.MinOutputGuardMaxRequestBytes || + *update.MaxRequestBytes > database.MaxOutputGuardMaxRequestBytes): + return fmt.Errorf("%w: output_guard_max_request_bytes must be between %d and %d", + database.ErrInvalidOutputGuardSettings, database.MinOutputGuardMaxRequestBytes, database.MaxOutputGuardMaxRequestBytes) + default: + return nil + } +} +``` + +2. 修改 `admin/handler.go`: + +**插入锚点 1: `admin/handler.go:9463`(在 `type settingsResponse struct` 尾部添加):** +```go + OutputGuardEnabled bool `json:"output_guard_enabled"` + OutputGuardMaxToolArgumentBytes int64 `json:"output_guard_max_tool_argument_bytes"` + OutputGuardMaxToolArgumentDeltas int `json:"output_guard_max_tool_argument_deltas"` + OutputGuardMaxChannelBytes int64 `json:"output_guard_max_channel_bytes"` + OutputGuardMaxRequestBytes int64 `json:"output_guard_max_request_bytes"` + OutputGuardConfigGeneration int64 `json:"output_guard_config_generation"` +``` + +**插入锚点 2: `admin/handler.go:9637`(在 `type updateSettingsReq struct` 尾部添加):** +```go + OutputGuardEnabled *bool `json:"output_guard_enabled"` + OutputGuardMaxToolArgumentBytes *int64 `json:"output_guard_max_tool_argument_bytes"` + OutputGuardMaxToolArgumentDeltas *int `json:"output_guard_max_tool_argument_deltas"` + OutputGuardMaxChannelBytes *int64 `json:"output_guard_max_channel_bytes"` + OutputGuardMaxRequestBytes *int64 `json:"output_guard_max_request_bytes"` + OutputGuardConfigGeneration rawJSON `json:"output_guard_config_generation"` +``` + +**插入锚点 3: `admin/handler.go:10240`(在 `GetSettings` 中读取 `outputGuardSettings`):** +```go + outputGuardSettings, err := h.db.GetOutputGuardSettings(ctx) + if err != nil { + outputGuardSettings = database.DefaultOutputGuardSettings() + } + outputGuardEnabled := false + if outputGuardSettings.Enabled != nil { + outputGuardEnabled = *outputGuardSettings.Enabled + } +``` +并在 `GetSettings` 的 `c.JSON(http.StatusOK, settingsResponse{ ... })` 结尾添加: +```go + OutputGuardEnabled: outputGuardEnabled, + OutputGuardMaxToolArgumentBytes: outputGuardSettings.MaxToolArgumentBytes, + OutputGuardMaxToolArgumentDeltas: outputGuardSettings.MaxToolArgumentDeltas, + OutputGuardMaxChannelBytes: outputGuardSettings.MaxChannelBytes, + OutputGuardMaxRequestBytes: outputGuardSettings.MaxRequestBytes, + OutputGuardConfigGeneration: outputGuardSettings.ConfigGeneration, +``` + +**插入锚点 4: `admin/handler.go:10601`(在 `UpdateSettings` 头部校验只读 generation):** +```go + if req.OutputGuardConfigGeneration != nil { + writeError(c, http.StatusBadRequest, "output_guard_config_generation 为只读字段") + return + } +``` + +**插入锚点 5: `admin/handler.go:10750`(在 `UpdateSettings` 中校验范围与层级):** +```go + outputGuardUpdate := database.OutputGuardSettingsUpdate{ + Enabled: req.OutputGuardEnabled, + MaxToolArgumentBytes: req.OutputGuardMaxToolArgumentBytes, + MaxToolArgumentDeltas: req.OutputGuardMaxToolArgumentDeltas, + MaxChannelBytes: req.OutputGuardMaxChannelBytes, + MaxRequestBytes: req.OutputGuardMaxRequestBytes, + } + outputGuardUpdateRequested := outputGuardUpdate.Enabled != nil || + outputGuardUpdate.MaxToolArgumentBytes != nil || + outputGuardUpdate.MaxToolArgumentDeltas != nil || + outputGuardUpdate.MaxChannelBytes != nil || + outputGuardUpdate.MaxRequestBytes != nil + + if err := validateOutputGuardSettingsUpdateRanges(outputGuardUpdate); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + + var outputGuardSettings database.OutputGuardSettings + if outputGuardUpdateRequested { + currentGuard, err := h.db.GetOutputGuardSettings(c.Request.Context()) + if err != nil { + writeError(c, http.StatusInternalServerError, "读取防护设置失败:"+err.Error()) + return + } + mergedGuard := currentGuard + if outputGuardUpdate.Enabled != nil { + val := *outputGuardUpdate.Enabled + mergedGuard.Enabled = &val + } + if outputGuardUpdate.MaxToolArgumentBytes != nil { + mergedGuard.MaxToolArgumentBytes = *outputGuardUpdate.MaxToolArgumentBytes + } + if outputGuardUpdate.MaxToolArgumentDeltas != nil { + mergedGuard.MaxToolArgumentDeltas = *outputGuardUpdate.MaxToolArgumentDeltas + } + if outputGuardUpdate.MaxChannelBytes != nil { + mergedGuard.MaxChannelBytes = *outputGuardUpdate.MaxChannelBytes + } + if outputGuardUpdate.MaxRequestBytes != nil { + mergedGuard.MaxRequestBytes = *outputGuardUpdate.MaxRequestBytes + } + if err := database.ValidateOutputGuardSettings(mergedGuard); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + + committedGuard, err := h.db.UpdateOutputGuardSettings(c.Request.Context(), outputGuardUpdate) + if err != nil { + writeError(c, http.StatusInternalServerError, "保存防护设置失败:"+err.Error()) + return + } + outputGuardSettings = committedGuard + proxy.ApplyOutputGuardSettings(committedGuard) + } else { + latestGuard, err := h.db.GetOutputGuardSettings(c.Request.Context()) + if err != nil { + writeError(c, http.StatusInternalServerError, "读取防护设置失败:"+err.Error()) + return + } + outputGuardSettings = latestGuard + } +``` + +**插入锚点 6: `admin/handler.go:12249`(在 `UpdateSettings` 响应返回中填入最新字段):** +```go + OutputGuardEnabled: outputGuardSettings.Enabled == nil || *outputGuardSettings.Enabled, + OutputGuardMaxToolArgumentBytes: outputGuardSettings.MaxToolArgumentBytes, + OutputGuardMaxToolArgumentDeltas: outputGuardSettings.MaxToolArgumentDeltas, + OutputGuardMaxChannelBytes: outputGuardSettings.MaxChannelBytes, + OutputGuardMaxRequestBytes: outputGuardSettings.MaxRequestBytes, + OutputGuardConfigGeneration: outputGuardSettings.ConfigGeneration, +``` + +--- + +### Step 4: Verify Backend Tests Pass (GREEN) + +Run: +```bash +GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./admin -run "TestAdminOutputGuardSettings_.*" +``` +Expected: PASS (所有断言包括字段对称性、只读拦截、层级非法回退、显式 false 及热应用均通过)。 + +--- + +### Step 5: Implement Frontend Types and Logic Tests + +1. 修改 `frontend/src/types.ts`(约 line 2305,`SystemSettings` 接口内): +```typescript + output_guard_enabled: boolean + output_guard_max_tool_argument_bytes: number + output_guard_max_tool_argument_deltas: number + output_guard_max_channel_bytes: number + output_guard_max_request_bytes: number + readonly output_guard_config_generation: number +``` + +2. 修改 `frontend/src/lib/settingsPayload.ts`: +```typescript +const RESPONSE_CACHE_CONFIG_GENERATION = "response_cache_config_generation"; +const OUTPUT_GUARD_CONFIG_GENERATION = "output_guard_config_generation"; +const CODEX_IMAGES_DEFAULT_MAIN_MODEL = "codex_images_default_main_model"; +const CODEX_EGRESS = "codex_egress"; +const CODEX_CLIENT_VERSIONS = "codex_client_versions"; + +export function buildWritableSettingsPayload< + T extends object, +>(settings: T): Omit { + const payload = { ...settings }; + Reflect.deleteProperty(payload, RESPONSE_CACHE_CONFIG_GENERATION); + Reflect.deleteProperty(payload, OUTPUT_GUARD_CONFIG_GENERATION); + Reflect.deleteProperty(payload, CODEX_IMAGES_DEFAULT_MAIN_MODEL); + Reflect.deleteProperty(payload, CODEX_EGRESS); + Reflect.deleteProperty(payload, CODEX_CLIENT_VERSIONS); + return payload; +} +``` + +3. 扩展 `frontend/src/lib/settingsPayload.test.mjs` 添加针对 `output_guard_config_generation` 剔除的断言: +```javascript +test("writable settings payload omits output guard generation regardless of value", () => { + for (const generation of [42, 0, null, undefined]) { + const settings = { + site_name: "CodexProxy", + output_guard_enabled: true, + output_guard_max_tool_argument_bytes: 262144, + output_guard_max_tool_argument_deltas: 4096, + output_guard_max_channel_bytes: 2097152, + output_guard_max_request_bytes: 4194304, + output_guard_config_generation: generation, + }; + + const payload = buildWritableSettingsPayload(settings); + + assert.equal( + Object.hasOwn(payload, "output_guard_config_generation"), + false, + ); + assert.equal(payload.output_guard_enabled, true); + assert.equal(payload.output_guard_max_tool_argument_bytes, 262144); + } +}); +``` + +4. 创建 `frontend/src/lib/outputGuardSettings.ts`: +```typescript +export const KIB = 1024 +export const MIB = 1024 * 1024 +export const MIN_OUTPUT_GUARD_TOOL_BYTES = 16 * KIB +export const MAX_OUTPUT_GUARD_TOOL_BYTES = 4 * MIB +export const MIN_OUTPUT_GUARD_TOOL_DELTAS = 256 +export const MAX_OUTPUT_GUARD_TOOL_DELTAS = 65536 +export const MIN_OUTPUT_GUARD_CHANNEL_BYTES = 128 * KIB +export const MAX_OUTPUT_GUARD_CHANNEL_BYTES = 16 * MIB +export const MIN_OUTPUT_GUARD_REQUEST_BYTES = 256 * KIB +export const MAX_OUTPUT_GUARD_REQUEST_BYTES = 64 * MIB + +export interface OutputGuardSettingsValues { + output_guard_enabled: boolean + output_guard_max_tool_argument_bytes: number + output_guard_max_tool_argument_deltas: number + output_guard_max_channel_bytes: number + output_guard_max_request_bytes: number +} + +export const DEFAULT_OUTPUT_GUARD_SETTINGS: OutputGuardSettingsValues = { + output_guard_enabled: false, + output_guard_max_tool_argument_bytes: 256 * KIB, + output_guard_max_tool_argument_deltas: 4096, + output_guard_max_channel_bytes: 2 * MIB, + output_guard_max_request_bytes: 4 * MIB, +} + +export const OUTPUT_GUARD_KEYS = [ + 'output_guard_enabled', + 'output_guard_max_tool_argument_bytes', + 'output_guard_max_tool_argument_deltas', + 'output_guard_max_channel_bytes', + 'output_guard_max_request_bytes', +] as const + +export function isOutputGuardKey(key: string): boolean { + return OUTPUT_GUARD_KEYS.indexOf(key as any) !== -1 +} + +export function validateOutputGuardHierarchy(settings: { + output_guard_max_tool_argument_bytes: number + output_guard_max_channel_bytes: number + output_guard_max_request_bytes: number +}): string | null { + if (settings.output_guard_max_tool_argument_bytes > settings.output_guard_max_channel_bytes) { + return '工具参数上限不能大于单通道上限 (tool <= channel)' + } + if (settings.output_guard_max_channel_bytes > settings.output_guard_max_request_bytes) { + return '单通道上限不能大于单请求上限 (channel <= request)' + } + return null +} + +export function validateOutputGuardRanges(settings: { + output_guard_max_tool_argument_bytes?: number + output_guard_max_tool_argument_deltas?: number + output_guard_max_channel_bytes?: number + output_guard_max_request_bytes?: number +}): string | null { + const tool = settings.output_guard_max_tool_argument_bytes + if (tool !== undefined && (tool < MIN_OUTPUT_GUARD_TOOL_BYTES || tool > MAX_OUTPUT_GUARD_TOOL_BYTES)) { + return `工具参数字节范围必须在 ${MIN_OUTPUT_GUARD_TOOL_BYTES} ~ ${MAX_OUTPUT_GUARD_TOOL_BYTES} 之间` + } + const deltas = settings.output_guard_max_tool_argument_deltas + if (deltas !== undefined && (deltas < MIN_OUTPUT_GUARD_TOOL_DELTAS || deltas > MAX_OUTPUT_GUARD_TOOL_DELTAS)) { + return `工具 Delta 次数必须在 ${MIN_OUTPUT_GUARD_TOOL_DELTAS} ~ ${MAX_OUTPUT_GUARD_TOOL_DELTAS} 之间` + } + const channel = settings.output_guard_max_channel_bytes + if (channel !== undefined && (channel < MIN_OUTPUT_GUARD_CHANNEL_BYTES || channel > MAX_OUTPUT_GUARD_CHANNEL_BYTES)) { + return `单通道字节范围必须在 ${MIN_OUTPUT_GUARD_CHANNEL_BYTES} ~ ${MAX_OUTPUT_GUARD_CHANNEL_BYTES} 之间` + } + const request = settings.output_guard_max_request_bytes + if (request !== undefined && (request < MIN_OUTPUT_GUARD_REQUEST_BYTES || request > MAX_OUTPUT_GUARD_REQUEST_BYTES)) { + return `单请求字节范围必须在 ${MIN_OUTPUT_GUARD_REQUEST_BYTES} ~ ${MAX_OUTPUT_GUARD_REQUEST_BYTES} 之间` + } + return null +} + +export function bytesToKiB(bytes: number): number { return Math.round(bytes / KIB) } +export function kibToBytes(kib: number): number { return kib * KIB } +export function bytesToMiB(bytes: number): number { return Math.round(bytes / MIB) } +export function mibToBytes(mib: number): number { return mib * MIB } +``` + +5. 创建 `frontend/src/lib/outputGuardSettings.test.mjs`: +```javascript +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + KIB, + MIB, + DEFAULT_OUTPUT_GUARD_SETTINGS, + OUTPUT_GUARD_KEYS, + isOutputGuardKey, + validateOutputGuardHierarchy, + validateOutputGuardRanges, + bytesToKiB, + kibToBytes, + bytesToMiB, + mibToBytes, +} from './outputGuardSettings.ts' + +test('default output guard settings match Spec §5.1 / §7', () => { + assert.equal(DEFAULT_OUTPUT_GUARD_SETTINGS.output_guard_enabled, false) + assert.equal(DEFAULT_OUTPUT_GUARD_SETTINGS.output_guard_max_tool_argument_bytes, 256 * KIB) + assert.equal(DEFAULT_OUTPUT_GUARD_SETTINGS.output_guard_max_tool_argument_deltas, 4096) + assert.equal(DEFAULT_OUTPUT_GUARD_SETTINGS.output_guard_max_channel_bytes, 2 * MIB) + assert.equal(DEFAULT_OUTPUT_GUARD_SETTINGS.output_guard_max_request_bytes, 4 * MIB) +}) + +test('OUTPUT_GUARD_KEYS covers exactly the 5 writable settings', () => { + assert.deepEqual(Array.from(OUTPUT_GUARD_KEYS), [ + 'output_guard_enabled', + 'output_guard_max_tool_argument_bytes', + 'output_guard_max_tool_argument_deltas', + 'output_guard_max_channel_bytes', + 'output_guard_max_request_bytes', + ]) + assert.equal(isOutputGuardKey('output_guard_enabled'), true) + assert.equal(isOutputGuardKey('output_guard_config_generation'), false) + assert.equal(isOutputGuardKey('unknown_key'), false) +}) + +test('validateOutputGuardHierarchy validates tool <= channel <= request', () => { + assert.equal(validateOutputGuardHierarchy({ + output_guard_max_tool_argument_bytes: 256 * KIB, + output_guard_max_channel_bytes: 2 * MIB, + output_guard_max_request_bytes: 4 * MIB, + }), null) + + assert.equal(validateOutputGuardHierarchy({ + output_guard_max_tool_argument_bytes: 2 * MIB, + output_guard_max_channel_bytes: 2 * MIB, + output_guard_max_request_bytes: 2 * MIB, + }), null) + + assert.ok(validateOutputGuardHierarchy({ + output_guard_max_tool_argument_bytes: 3 * MIB, + output_guard_max_channel_bytes: 2 * MIB, + output_guard_max_request_bytes: 4 * MIB, + })?.includes('tool <= channel')) + + assert.ok(validateOutputGuardHierarchy({ + output_guard_max_tool_argument_bytes: 256 * KIB, + output_guard_max_channel_bytes: 8 * MIB, + output_guard_max_request_bytes: 4 * MIB, + })?.includes('channel <= request')) +}) + +test('validateOutputGuardRanges validates individual ranges', () => { + assert.equal(validateOutputGuardRanges({ + output_guard_max_tool_argument_bytes: 256 * KIB, + output_guard_max_tool_argument_deltas: 4096, + output_guard_max_channel_bytes: 2 * MIB, + output_guard_max_request_bytes: 4 * MIB, + }), null) + + assert.ok(validateOutputGuardRanges({ + output_guard_max_tool_argument_bytes: 10 * KIB, + })?.includes('工具参数字节范围')) + + assert.ok(validateOutputGuardRanges({ + output_guard_max_tool_argument_deltas: 70000, + })?.includes('工具 Delta 次数')) + + assert.equal(validateOutputGuardRanges({ + output_guard_max_channel_bytes: 128 * KIB, + }), null) + assert.ok(validateOutputGuardRanges({ + output_guard_max_channel_bytes: 64 * KIB, + })?.includes('单通道字节范围')) +}) + +test('unit conversions work accurately', () => { + assert.equal(bytesToKiB(128 * 1024), 128) + assert.equal(kibToBytes(128), 128 * 1024) + assert.equal(bytesToKiB(262144), 256) + assert.equal(kibToBytes(256), 262144) + assert.equal(bytesToKiB(4194304), 4096) + assert.equal(kibToBytes(4096), 4194304) + assert.equal(bytesToMiB(2 * 1024 * 1024), 2) + assert.equal(mibToBytes(2), 2 * 1024 * 1024) +}) +``` + +--- + +### Step 6: Verify Frontend Logic Tests Pass + +Run: +```bash +node --experimental-strip-types --test frontend/src/lib/outputGuardSettings.test.mjs frontend/src/lib/settingsPayload.test.mjs +``` +Expected: All tests PASS. + +--- + +### Step 7: Implement Frontend UI in `frontend/src/pages/Settings.tsx` + +1. 引入常量与函数: +```typescript +import { + KIB, + DEFAULT_OUTPUT_GUARD_SETTINGS, + OUTPUT_GUARD_KEYS, + validateOutputGuardHierarchy, + validateOutputGuardRanges, + bytesToKiB, + kibToBytes, + bytesToMiB, + mibToBytes, +} from '../lib/outputGuardSettings' +``` + +2. 在 `useState` 初始完整字面量中(约 line 2663-2835,`Settings.tsx`)补充新增 6 个必填字段的默认值(避免破坏 strict TS 类型检查): +```typescript + output_guard_enabled: false, + output_guard_max_tool_argument_bytes: 262144, + output_guard_max_tool_argument_deltas: 4096, + output_guard_max_channel_bytes: 2097152, + output_guard_max_request_bytes: 4194304, + output_guard_config_generation: 0, +``` + +3. 在 `autoSaveSettingsPatch` 逻辑中加入 `output_guard_config_generation` 保护(与 `response_cache_config_generation` 对齐): +```typescript + const outputGuardRequest = patchKeys.some((k) => OUTPUT_GUARD_KEYS.includes(k as any)) + const mergedOutputGuardGeneration = outputGuardRequest && typeof updated.output_guard_config_generation === 'number' + ? Math.max(currentSettings.output_guard_config_generation || 0, updated.output_guard_config_generation) + : currentSettings.output_guard_config_generation +``` + +4. 在 General Tab 的安全区域(`id="settings-security"`,`PromptFilterCard` 旁)添加独立的防护卡片: +```tsx + } + tone={settingsForm.output_guard_enabled ? 'default' : 'danger'} + channels={ALL_UPSTREAM_CHANNELS} + badge={ + + + {settingsForm.output_guard_enabled ? '已启用全局防护' : '已关闭 (高风险)'} + + } + footer={ +
+ +
+ } + > +
+ {!settingsForm.output_guard_enabled && ( +
+ +
+

高风险操作警告:失控防护已全局关闭

+

关闭后模型失控生成死循环、重复代码或细碎 arguments delta 时,将直接击穿代理并在下游引发 O(n²) 全量重解析雪崩。仅用于临时诊断,请尽快重新开启!

+
+
+ )} + +
+
+
启用失控输出防护
+
全局实时检测持续短周期重复、细碎 arguments 泛洪与字节预算超额并前置切断
+
+ { + setSettingsForm((f) => ({ ...f, output_guard_enabled: checked })) + void autoSaveSettingsPatch({ output_guard_enabled: checked }) + }} + /> +
+ +
+ + { + setSettingsForm((f) => ({ ...f, output_guard_max_tool_argument_bytes: kibToBytes(val) })) + }} + onValueCommit={(val) => { + void autoSaveSettingsPatch({ output_guard_max_tool_argument_bytes: kibToBytes(val) }) + }} + /> + + + + { + setSettingsForm((f) => ({ ...f, output_guard_max_tool_argument_deltas: val })) + }} + onValueCommit={(val) => { + void autoSaveSettingsPatch({ output_guard_max_tool_argument_deltas: val }) + }} + /> + + + + { + setSettingsForm((f) => ({ ...f, output_guard_max_channel_bytes: kibToBytes(val) })) + }} + onValueCommit={(val) => { + void autoSaveSettingsPatch({ output_guard_max_channel_bytes: kibToBytes(val) }) + }} + /> + + + + { + setSettingsForm((f) => ({ ...f, output_guard_max_request_bytes: kibToBytes(val) })) + }} + onValueCommit={(val) => { + void autoSaveSettingsPatch({ output_guard_max_request_bytes: kibToBytes(val) }) + }} + /> + +
+
+
+``` + +--- + +### Step 8: Verify UI Compilation and Typecheck + +Run: +```bash +./frontend/node_modules/.bin/tsc -p frontend/tsconfig.json --noEmit +``` +Expected: PASS (0 errors). + +--- + +### Step 9: Commit + +```bash +git add admin/output_guard_settings.go admin/output_guard_settings_test.go admin/handler.go frontend/src/types.ts frontend/src/lib/settingsPayload.ts frontend/src/lib/settingsPayload.test.mjs frontend/src/lib/outputGuardSettings.ts frontend/src/lib/outputGuardSettings.test.mjs frontend/src/pages/Settings.tsx +git commit -m "feat(admin,frontend): add model output runaway guard admin settings API and dedicated UI card" +``` diff --git a/docs/superpowers/plans/model-output-guard-tdd/03-protocol.md b/docs/superpowers/plans/model-output-guard-tdd/03-protocol.md new file mode 100644 index 000000000..64f7e8fa7 --- /dev/null +++ b/docs/superpowers/plans/model-output-guard-tdd/03-protocol.md @@ -0,0 +1,2031 @@ +# Model Output Guard TDD Implementation Plan — Part 03: Protocol + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 构建模型输出失控防护(Model Output Guard)的协议感知投影层(Protocol Projector)、事件流水线分派器(ObserveEvent Dispatcher)与底层传输 Framing 有界读取与 Callsite 替换(SSE/WS/Response Body Framing)。 + +**Architecture:** +1. 协议投影器将上游各类多样化协议(Responses HTTP/WS/Realtime、ChatCompletions、Anthropic Messages、Gemini streamGenerateContent/generateContent)的单事件 envelope 解析并投影为标准结构化的 Observation(`KindText`, `KindThinking`, `KindToolArguments`;动作 `ActionAppend`, `ActionSnapshot`, `ActionEnd`);其中 Gemini 无 ID 工具调用受协议格式限制,无法跨 chunk 判别是否属于同一逻辑工具调用的续传,采用候选单调递增独立 channel 隔离,完整 object 或后续新 call 由请求级 channel 数量及字节预算统一兜底; +2. 事件分派器 `ObserveEvent` 严格按上游业务事件执行 1 次 `RecordEvent()` 记账(即使 control/unknown/empty 帧也扣减事件预算,排除心跳与 SSE 注释),然后调用投影出的各 Observation 执行 `ObserveAppendNoEvent`/`ObserveSnapshotNoEvent`/`EndChannel`;所有 Observation 检查全部通过后方可放行给下游消费者,一处触发则整事件拦截; +3. 传输层 Framing 机制提供资源硬上限保护(独立于 guard 业务开关),严格保留既有 `proxy/sse_line.go` 所有既有逻辑与小写符号,追加大写导出别名常量与错误桥接;保持 WebSocket `security.MaxRequestBodySize`(默认 48MB)与现有 queue budget、诊断 peek 豁免,安全替换全链路 Callsite。 + +**Tech Stack:** Go 1.26, `github.com/tidwall/gjson`, `github.com/tidwall/sjson`, `github.com/gorilla/websocket`, 标准库 `bufio`, `bytes`, `io`, `sync`. + +**Spec:** [docs/superpowers/specs/2026-10-28-model-output-guard-design.md](../../specs/2026-10-28-model-output-guard-design.md) + +## Global Constraints + +- 全局启用三态存储:`output_guard_enabled`(默认 `true`,`*bool` 指针语义)。 +- 默认硬预算:`output_guard_max_tool_argument_bytes` = 131072 (128 KiB),`output_guard_max_tool_argument_deltas` = 4096,`output_guard_max_channel_bytes` = 2097152 (2 MiB),`output_guard_max_request_bytes` = 4194304 (4 MiB)。 +- 资源上限:每 request 65536 个业务事件、128 个累计 channel;单 channel 累计扫描预算为 `2 * limit`,整体最大数据处理与校验量严格受限于 `5 * limit`。 +- 业务事件记账契约:每上游业务事件严格调用 1 次 `RecordEvent()`,非空与空内容/未知控制事件均计数;心跳/ping/SSE 注释除外。 +- 投影边界:工具参数只解码当前 envelope,保留 inner JSON 转义,绝对不每 delta 全量 parse/repair inner JSON;原生 object 仅做单次紧凑序列化。 +- 跨帧状态隔离与映射:稳定维护 channel key 与 aliasMap(如 index ↔ call_id ↔ item_id),保证 latealias 绑定到同一 channel,不发生错合并或配额重置。 +- 传输 Framing 边界:严格保留 `proxy/sse_line.go` 既有符号与 8 MiB 限制,保留 WebSocket 现有 48MB `security.MaxRequestBodySize` 与 1013 Close code,保留 512B/1MiB 诊断 peek 豁免。 + +--- + +### Task 7: 统一协议感知投影层与跨帧状态机分派器 + +**Files:** +- Create: `proxy/outputguard_projection.go` +- Test: `proxy/outputguard_projection_test.go` + +**Interfaces:** +- Consumes: + - `security/outputguard`(严格消费 Task 3 定义的 exact 接口,不重复定义 guard.go): + ```go + package outputguard + + type ChannelKind string + const ( + KindText ChannelKind = "text" + KindThinking ChannelKind = "thinking" + KindToolArguments ChannelKind = "tool_arguments" + ) + const ( + MaxRequestEvents = 65536 + MaxRequestChannels = 128 + TriggeredCode = "model_output_guard_triggered" + ) + type Config struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 + } + type RequestGuard struct { ... } + func NewRequestGuard(cfg Config) *RequestGuard + func (g *RequestGuard) IsTripped() bool + func (g *RequestGuard) TrippedReason() string + func (g *RequestGuard) ActiveChannelCount() int + func (g *RequestGuard) RecordEvent() error + func (g *RequestGuard) ObserveAppend(channelID string, aliasID string, kind ChannelKind, delta []byte) error + func (g *RequestGuard) ObserveSnapshot(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error + func (g *RequestGuard) ObserveAppendNoEvent(channelID string, aliasID string, kind ChannelKind, delta []byte) error + func (g *RequestGuard) ObserveSnapshotNoEvent(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error + func (g *RequestGuard) EndChannel(channelID string) error + ``` + - `github.com/tidwall/gjson` +- Produces: + ```go + package proxy + + import ( + "github.com/codex2api/security/outputguard" + ) + + type ObservationAction string + const ( + ActionAppend ObservationAction = "append" + ActionSnapshot ObservationAction = "snapshot" + ActionEnd ObservationAction = "end" + ) + + type Observation struct { + ChannelID string + AliasID string + Kind outputguard.ChannelKind + Action ObservationAction + Payload []byte + TerminalStage string // 严格白名单限制为 3 种合法语义终态阶段之一: "arguments.done", "item.done", "response.completed",其余普通帧必须置为空字符串 + } + + type ProtocolProjector interface { + Project(eventType string, data []byte) ([]Observation, error) + } + + type ResponsesProjector struct { + aliasMap map[string]string + } + func NewResponsesProjector() *ResponsesProjector + func (p *ResponsesProjector) Project(eventType string, data []byte) ([]Observation, error) + + type ChatProjector struct { + aliasMap map[string]string + choiceTools map[int]map[string]struct{} + } + func NewChatProjector() *ChatProjector + func (p *ChatProjector) Project(eventType string, data []byte) ([]Observation, error) + + type AnthropicProjector struct { + blockTypes map[int]string + blockIDs map[int]string + } + func NewAnthropicProjector() *AnthropicProjector + func (p *AnthropicProjector) Project(eventType string, data []byte) ([]Observation, error) + + type GeminiProjectorMode int + const ( + GeminiProjectorModeAuto GeminiProjectorMode = iota + GeminiProjectorModeStream + GeminiProjectorModeNonStream + ) + type GeminiProjector struct { + candidateToolCounters map[int]int + candidateToolKeys map[int][]string + mode GeminiProjectorMode + isStream bool + } + func NewGeminiProjector() *GeminiProjector + func NewGeminiProjectorWithMode(mode GeminiProjectorMode) *GeminiProjector + func (p *GeminiProjector) Project(eventType string, data []byte) ([]Observation, error) + + func ObserveEvent(guard *outputguard.RequestGuard, projector ProtocolProjector, eventType string, data []byte) error + ``` + +- [ ] **Step 1: Write failing test for protocol projection and event dispatcher** + +```go +// proxy/outputguard_projection_test.go +package proxy + +import ( + "strings" + "testing" + + "github.com/codex2api/security/outputguard" +) + +func defaultProjectionTestConfig() outputguard.Config { + return outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, // 128 KiB + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, // 2 MiB + MaxRequestBytes: 4194304, // 4 MiB + } +} + +// 1. Responses 协议流式、3 个合法语义终态阶段快照与 alias 跨帧映射测试 +// 真实 delta 只有 item_id,output_item.done 才出现 call_id;多 content 完整遍历 +func TestResponsesProjectorStreamAndTerminalSnapshots(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewResponsesProjector() + + // Text delta + textDelta := `{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"Hello, "}` + if err := ObserveEvent(guard, proj, "response.output_text.delta", []byte(textDelta)); err != nil { + t.Fatalf("text delta observe failed: %v", err) + } + + // Thinking delta + thinkingDelta := `{"type":"response.reasoning.delta","item_id":"msg_1","content_index":0,"delta":"thinking step 1"}` + if err := ObserveEvent(guard, proj, "response.reasoning.delta", []byte(thinkingDelta)); err != nil { + t.Fatalf("thinking delta observe failed: %v", err) + } + + // Tool call delta (初始仅有 item_id,缺失 call_id) + toolDelta := `{"type":"response.function_call_arguments.delta","item_id":"item_call_1","output_index":1,"delta":"{\"path\":"}` + if err := ObserveEvent(guard, proj, "response.function_call_arguments.delta", []byte(toolDelta)); err != nil { + t.Fatalf("tool delta observe failed: %v", err) + } + + // function_call_arguments.done (终态阶段 1: arguments.done 快照) + argsDone := `{"type":"response.function_call_arguments.done","item_id":"item_call_1","call_id":"call_write_file","arguments":"{\"path\":\"test.txt\"}"}` + if err := ObserveEvent(guard, proj, "response.function_call_arguments.done", []byte(argsDone)); err != nil { + t.Fatalf("args done observe failed: %v", err) + } + + // output_item.done (终态阶段 2: item.done 快照,晚到 call_id 绑定成功,不新建 channel) + itemDone := `{"type":"response.output_item.done","item":{"id":"item_call_1","type":"function_call","call_id":"call_write_file","arguments":"{\"path\":\"test.txt\"}"}}` + if err := ObserveEvent(guard, proj, "response.output_item.done", []byte(itemDone)); err != nil { + t.Fatalf("item done observe failed: %v", err) + } + + // response.completed (终态阶段 3: response.completed 纯终态确认快照,含多 content message 与 tool) + completed := `{"type":"response.completed","response":{"id":"resp_1","status":"completed","output":[{"id":"item_call_1","type":"function_call","call_id":"call_write_file","arguments":"{\"path\":\"test.txt\"}"},{"id":"msg_1","type":"message","content":[{"type":"output_text","text":"Hello, "},{"type":"reasoning","thinking":"thinking step 1"}]}]}}` + if err := ObserveEvent(guard, proj, "response.completed", []byte(completed)); err != nil { + t.Fatalf("response completed observe failed: %v", err) + } + + if guard.IsTripped() { + t.Fatalf("expected guard not tripped, got %s", guard.TrippedReason()) + } +} + +// 2. ChatCompletions 协议 choice/toolindex/callid alias 隔离、legacy function_call 及 finish_reason 尾检测试 +func TestChatProjectorStreamingAndNonStreaming(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewChatProjector() + + // 流式 Chunk 1: content + reasoning + tool_call 首帧 (带 call_id 与 index) + legacy delta.function_call + chunk1 := `{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"foo","reasoning_content":"think","tool_calls":[{"index":0,"id":"call_123","function":{"name":"search","arguments":"{\"q\":"}}],"function_call":{"name":"legacy_fn","arguments":"{\"a\":"}}}]}` + if err := ObserveEvent(guard, proj, "", []byte(chunk1)); err != nil { + t.Fatalf("chunk 1 observe error: %v", err) + } + + // 流式 Chunk 2: tool_call 后续 delta (仅带 index, 缺失 id, 验证 aliasMap 稳定解析到同一 channel) + legacy function_call delta + chunk2 := `{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"golang\"}"}}],"function_call":{"arguments":"1}"}}}]}` + if err := ObserveEvent(guard, proj, "", []byte(chunk2)); err != nil { + t.Fatalf("chunk 2 observe error: %v", err) + } + + // 验证不同 choice 隔离,不发生错合并;并测试 finish_reason 触发 EndChannel 尾检 + chunkChoice1 := `{"id":"chatcmpl-1","choices":[{"index":1,"delta":{"content":"bar"},"finish_reason":"stop"}]}` + if err := ObserveEvent(guard, proj, "", []byte(chunkChoice1)); err != nil { + t.Fatalf("chunk choice 1 observe error: %v", err) + } + + // 非流式 JSON 测试 (含 message.function_call 与 message.tool_calls) + nonStream := `{"id":"chatcmpl-2","choices":[{"index":0,"message":{"role":"assistant","content":"final content","tool_calls":[{"id":"call_999","type":"function","function":{"name":"calc","arguments":"{\"val\":42}"}}],"function_call":{"name":"old","arguments":"{\"x\":1}"}},"finish_reason":"stop"}]}` + guardNonStream := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + projNonStream := NewChatProjector() + if err := ObserveEvent(guardNonStream, projNonStream, "", []byte(nonStream)); err != nil { + t.Fatalf("non-stream chat observe error: %v", err) + } + if guardNonStream.IsTripped() { + t.Fatalf("expected non-stream not tripped, got %s", guardNonStream.TrippedReason()) + } +} + +// 3. Anthropic Messages 协议真实 fixture 与 content_block 状态流转测试 (stop key 与 start key 严格一致) +func TestAnthropicProjectorStreamLifecycleAndNonStream(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewAnthropicProjector() + + // message_start + msgStart := `{"type":"message_start","message":{"id":"msg_123","type":"message","role":"assistant","content":[]}}` + if err := ObserveEvent(guard, proj, "message_start", []byte(msgStart)); err != nil { + t.Fatalf("msg start error: %v", err) + } + + // block 0: thinking + b0Start := `{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}` + if err := ObserveEvent(guard, proj, "content_block_start", []byte(b0Start)); err != nil { + t.Fatalf("b0 start error: %v", err) + } + b0Delta := `{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"let me think"}}` + if err := ObserveEvent(guard, proj, "content_block_delta", []byte(b0Delta)); err != nil { + t.Fatalf("b0 delta error: %v", err) + } + b0Stop := `{"type":"content_block_stop","index":0}` + if err := ObserveEvent(guard, proj, "content_block_stop", []byte(b0Stop)); err != nil { + t.Fatalf("b0 stop error: %v", err) + } + + // block 1: tool_use + b1Start := `{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01ABC","name":"exec"}}` + if err := ObserveEvent(guard, proj, "content_block_start", []byte(b1Start)); err != nil { + t.Fatalf("b1 start error: %v", err) + } + b1Delta := `{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"cmd\":\"ls\"}"}}` + if err := ObserveEvent(guard, proj, "content_block_delta", []byte(b1Delta)); err != nil { + t.Fatalf("b1 delta error: %v", err) + } + b1Stop := `{"type":"content_block_stop","index":1}` + if err := ObserveEvent(guard, proj, "content_block_stop", []byte(b1Stop)); err != nil { + t.Fatalf("b1 stop error: %v", err) + } + + // message_stop: 验证对 block_0 与 block_1 发送 ActionEnd,不造 message_root + msgStop := `{"type":"message_stop"}` + if err := ObserveEvent(guard, proj, "message_stop", []byte(msgStop)); err != nil { + t.Fatalf("msg stop error: %v", err) + } + + // 非流式 Anthropic 响应 + nonStream := `{"id":"msg_456","type":"message","role":"assistant","content":[{"type":"text","text":"Done!"},{"type":"thinking","thinking":"done thinking"},{"type":"tool_use","id":"toolu_02XYZ","name":"write","input":{"content":"file content"}}]}` + guardAnthropicNonStream := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + projAnthropicNonStream := NewAnthropicProjector() + if err := ObserveEvent(guardAnthropicNonStream, projAnthropicNonStream, "", []byte(nonStream)); err != nil { + t.Fatalf("anthropic non-stream observe error: %v", err) + } + if guardAnthropicNonStream.IsTripped() { + t.Fatalf("expected anthropic non-stream not tripped, got %s", guardAnthropicNonStream.TrippedReason()) + } +} + +// 4. Gemini Native 同名并发工具、无 ID 流式 fallback 与跨 chunk key 稳定性测试 +func TestGeminiProjectorStreamAndNonStreamObjectSerialization(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewGeminiProjector() + + // 流式 chunk 1: 包含 2 个同名的 functionCall 且无 ID,验证按 stable index 隔离而不按 partidx+name 误合并 + geminiChunk1 := `{"candidates":[{"index":0,"content":{"role":"model","parts":[{"text":"hello gemini"},{"functionCall":{"name":"search","args":{"query":"part1"}}},{"functionCall":{"name":"search","args":{"query":"part2"}}}]}}]}` + if err := ObserveEvent(guard, proj, "", []byte(geminiChunk1)); err != nil { + t.Fatalf("gemini chunk 1 observe error: %v", err) + } + + // Gemini 非流式带 finishReason + geminiNonStreamJSON := `{"candidates":[{"index":0,"content":{"role":"model","parts":[{"text":"done"},{"functionCall":{"name":"fetch","args":{"url":"https://example.com"}}}]},"finishReason":"STOP"}]}` + guardGemini := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + projGemini := NewGeminiProjector() + if err := ObserveEvent(guardGemini, projGemini, "", []byte(geminiNonStreamJSON)); err != nil { + t.Fatalf("gemini non-stream observe error: %v", err) + } + if guardGemini.IsTripped() { + t.Fatalf("expected gemini non-stream not tripped, got %s", guardGemini.TrippedReason()) + } +} + +// 5. 事件计数契约:每上游事件一次,unknown/control/empty 业务事件严格扣减事件预算,心跳注释除外 +func TestObserveEventBudgetAccountability(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewResponsesProjector() + + // SSE 注释与 ping 帧不消耗业务事件预算 + if err := ObserveEvent(guard, proj, "", []byte(": keep-alive ping\n")); err != nil { + t.Fatalf("comment frame should not fail: %v", err) + } + if err := ObserveEvent(guard, proj, "ping", []byte("{}")); err != nil { + t.Fatalf("ping event should not fail: %v", err) + } + + // 构造 65536 个 unknown / empty 业务事件洪泛 + for i := 0; i < outputguard.MaxRequestEvents; i++ { + unknownEvent := `{"type":"response.unknown_control_event","id":"ctrl_1"}` + if err := ObserveEvent(guard, proj, "response.unknown_control_event", []byte(unknownEvent)); err != nil { + t.Fatalf("event %d unexpectedly failed: %v", i, err) + } + } + + // 第 65537 个事件必须被 request_events 坚决拦截 + err := ObserveEvent(guard, proj, "response.created", []byte(`{"type":"response.created"}`)) + if err == nil || err.Error() != "request_events" { + t.Fatalf("expected request_events rejection for control/unknown flood, got %v", err) + } + if guard.TrippedReason() != "request_events" { + t.Fatalf("expected tripped reason request_events, got %s", guard.TrippedReason()) + } +} + +// 6. 验证 Custom Tool Arguments String 仅解码当前 envelope,不每 delta 重 parse inner JSON +func TestCustomToolArgumentsEnvelopePreservation(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewResponsesProjector() + + // 构造未闭合且包含转义与大段文本的 arguments 片段 + incompleteJSONChunk := `{"type":"response.function_call_arguments.delta","item_id":"call_custom","delta":"{\"nested_key\":\"val\\nwith\\\"quotes and unclosed brackets..."}` + if err := ObserveEvent(guard, proj, "response.function_call_arguments.delta", []byte(incompleteJSONChunk)); err != nil { + t.Fatalf("custom tool envelope observe error: %v", err) + } + + if guard.IsTripped() { + t.Fatalf("expected guard not tripped on unclosed inner JSON, got %s", guard.TrippedReason()) + } +} + +// ----------------------------------------------------------------------------- +// 03-A 回归测试:Gemini 跨 chunk 无 ID tool calls 隔离与带 ID 稳定映射 +// ----------------------------------------------------------------------------- + +// 03-A Critical 回归测试: +// Gemini 流式跨 chunk 无 ID tool calls 必须单调递增计数,互不误合并。 +// 3 个独立无 ID 调用必须映射到 3 个独立 channel,ActiveChannelCount 必须为 3。 +func TestRegression03A_GeminiCrossChunkUnlabeledToolCallMerge(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewGeminiProjector() + + // chunk1: 2 个无 ID functionCall + chunk1 := `{"candidates":[{"index":0,"content":{"role":"model","parts":[{"functionCall":{"name":"fnA","args":{"x":1}}},{"functionCall":{"name":"fnB","args":{"y":2}}}]}}]}` + if err := ObserveEvent(guard, proj, "", []byte(chunk1)); err != nil { + t.Fatalf("chunk1 error: %v", err) + } + + // chunk2: 1 个新无 ID functionCall + chunk2 := `{"candidates":[{"index":0,"content":{"role":"model","parts":[{"functionCall":{"name":"fnC","args":{"z":3}}}]}}]}` + if err := ObserveEvent(guard, proj, "", []byte(chunk2)); err != nil { + t.Fatalf("chunk2 error: %v", err) + } + + // 必须为 3 个独立 channel + if got := guard.ActiveChannelCount(); got != 3 { + t.Fatalf("03-A failure: expected ActiveChannelCount=3 across chunks, got %d (chunk2 wrongly collided with chunk1!)", got) + } +} + +// 03-A 补充测试:有 ID 稳定性及同 chunk 同名测试 +func TestRegression03A_GeminiWithIDAndSameName(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewGeminiProjector() + + // 包含 call ID 的 functionCall + chunk1 := `{"candidates":[{"index":0,"content":{"role":"model","parts":[{"functionCall":{"id":"call_unique_1","name":"fnA","args":{"x":1}}}]}}]}` + if err := ObserveEvent(guard, proj, "", []byte(chunk1)); err != nil { + t.Fatalf("chunk1 error: %v", err) + } + + // chunk2 继续使用相同 call ID,应当稳定映射到同一 channel + chunk2 := `{"candidates":[{"index":0,"content":{"role":"model","parts":[{"functionCall":{"id":"call_unique_1","name":"fnA","args":{"x":2}}}]}}]}` + if err := ObserveEvent(guard, proj, "", []byte(chunk2)); err != nil { + t.Fatalf("chunk2 error: %v", err) + } + + if got := guard.ActiveChannelCount(); got != 1 { + t.Fatalf("expected ActiveChannelCount=1 for matching callID, got %d", got) + } +} + +// ----------------------------------------------------------------------------- +// 03-B 回归测试:Gemini 流式 finishReason 追加与非流式全量快照 +// ----------------------------------------------------------------------------- + +// 03-B Important 回归测试: +// finishReason chunk 中的 text/thought 必须以 Append 动作推入,且只追加 End; +// 绝不能在流式末尾被降级为 Snapshot 导致 detector 窗口被 Reset 而逃逸。 +func TestRegression03B_GeminiFinishReasonTailEscape(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewGeminiProjector() + + var periodic strings.Builder + for i := 0; i < 4095; i++ { + periodic.WriteString("ab") + } + // chunk1: append 8190 字节周期数据(尚未达 8192 检查点) + chunk1 := `{"candidates":[{"index":0,"content":{"role":"model","parts":[{"text":"` + periodic.String() + `"}]}}]}` + if err := ObserveEvent(guard, proj, "", []byte(chunk1)); err != nil { + t.Fatalf("chunk1 error: %v", err) + } + if guard.IsTripped() { + t.Fatalf("chunk1 unexpectedly tripped: %s", guard.TrippedReason()) + } + + // chunk2: finishReason=STOP,text 延续 4 字节使总量达到 8194 字节纯周期。 + // 正确的流式 Append + End 必定在 8192 检查点或尾检处 trip repetition! + chunk2 := `{"candidates":[{"index":0,"content":{"role":"model","parts":[{"text":"abab"}]},"finishReason":"STOP"}]}` + err := ObserveEvent(guard, proj, "", []byte(chunk2)) + if err == nil && !guard.IsTripped() { + t.Fatalf("03-B failure: stream tail text was treated as snapshot, causing reset window escape! tripped=%v reason=%q", guard.IsTripped(), guard.TrippedReason()) + } + if !guard.IsTripped() || guard.TrippedReason() != "repetition" { + t.Fatalf("03-B failure: expected repetition, got err=%v tripped=%v reason=%q", err, guard.IsTripped(), guard.TrippedReason()) + } +} + +// 03-B 补充测试:非流式 JSON 响应仍然保持 Snapshot 语义 +func TestRegression03B_GeminiNonStreamJSONFullSnapshot(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewGeminiProjector() + + // 非流式整包响应 + nonStreamJSON := `{"candidates":[{"index":0,"content":{"role":"model","parts":[{"text":"full response text"}]},"finishReason":"STOP"}]}` + if err := ObserveEvent(guard, proj, "", []byte(nonStreamJSON)); err != nil { + t.Fatalf("non-stream JSON error: %v", err) + } + if guard.IsTripped() { + t.Fatalf("unexpected trip on valid non-stream response: %s", guard.TrippedReason()) + } +} + +// 03-B 显式构造函数测试:NewGeminiProjectorWithMode +func TestRegression03B_GeminiProjectorModes(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + projStream := NewGeminiProjectorWithMode(GeminiProjectorModeStream) + projNonStream := NewGeminiProjectorWithMode(GeminiProjectorModeNonStream) + + // 非流式 Projector 直接推带 finishReason 的单包数据,执行 Snapshot 动作 + nonStreamJSON := `{"candidates":[{"index":0,"content":{"role":"model","parts":[{"text":"mode test"}]},"finishReason":"STOP"}]}` + obs, err := projNonStream.Project("", []byte(nonStreamJSON)) + if err != nil { + t.Fatalf("project error: %v", err) + } + if len(obs) == 0 || obs[0].Action != ActionSnapshot { + t.Fatalf("expected ActionSnapshot for non-stream projector, got %v", obs[0].Action) + } + + // 流式 Projector 推带 finishReason 的首个包,仍执行 ActionAppend + obsStream, err := projStream.Project("", []byte(nonStreamJSON)) + if err != nil { + t.Fatalf("stream project error: %v", err) + } + if len(obsStream) == 0 || obsStream[0].Action != ActionAppend { + t.Fatalf("expected ActionAppend for stream projector, got %v", obsStream[0].Action) + } + _ = guard +} + +// ----------------------------------------------------------------------------- +// 03-C 回归测试:Chat finish_reason 全部活跃 tool_calls End 尾检与隔离 +// ----------------------------------------------------------------------------- + +// 03-C Important 回归测试: +// ChatProjector 流式 tool_calls 在 finish_reason 时必须对所有活跃的 choice.tool_idx 触发 End 进行尾检。 +func TestRegression03C_ChatToolChannelNoTailCheck(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewChatProjector() + + prefix := strings.Repeat("z", 100) + var periodic strings.Builder + for i := 0; i < 820; i++ { + periodic.WriteString("0123456789") + } + // args = 100 杂散 + 8200 周期 = 8300 字节;Push 检查点 8192 处窗口=[100杂散+8092周期] 未命中 + args1 := prefix + periodic.String() + chunk1 := `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"f","arguments":"` + args1 + `"}}]}}]}` + if err := ObserveEvent(guard, proj, "", []byte(chunk1)); err != nil { + t.Fatalf("chunk1 error: %v", err) + } + if guard.IsTripped() { + t.Fatalf("chunk1 unexpectedly tripped: %s", guard.TrippedReason()) + } + + // chunk2: finish_reason 到达,无更多 args delta。 + // 正确实现对 choice_0.tool_0 执行 ActionEnd 触发尾检,此时最新 8192 字节(包含 8192 字节纯周期)必须触发 repetition! + chunk2 := `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}` + err := ObserveEvent(guard, proj, "", []byte(chunk2)) + if err == nil && !guard.IsTripped() { + t.Fatalf("03-C failure: chat finish_reason omitted tool channels tail check! tripped=%v reason=%q", guard.IsTripped(), guard.TrippedReason()) + } + + if !guard.IsTripped() || guard.TrippedReason() != "repetition" { + t.Fatalf("03-C failure: chat finish_reason omitted tool channels tail check! tripped=%v reason=%q", guard.IsTripped(), guard.TrippedReason()) + } +} + +// 03-C 补充测试:多 choice 与多 tool 隔离尾检测试 +func TestRegression03C_MultiChoiceMultiToolIsolation(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewChatProjector() + + // choice 0 和 choice 1 分别有 tool 0 和 tool 1 + chunk1 := `{"choices":[ + {"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_c0_t0","function":{"name":"f0","arguments":"normal"}}]}}, + {"index":1,"delta":{"tool_calls":[{"index":1,"id":"call_c1_t1","function":{"name":"f1","arguments":"normal"}}]}} + ]}` + if err := ObserveEvent(guard, proj, "", []byte(chunk1)); err != nil { + t.Fatalf("chunk1 error: %v", err) + } + + // 确认两个 tool channel 均活跃 + if got := guard.ActiveChannelCount(); got != 2 { + t.Fatalf("expected ActiveChannelCount=2, got %d", got) + } + + // choice 0 finish + chunk2 := `{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}` + if err := ObserveEvent(guard, proj, "", []byte(chunk2)); err != nil { + t.Fatalf("chunk2 error: %v", err) + } + + // choice 1 finish + chunk3 := `{"choices":[{"index":1,"delta":{},"finish_reason":"stop"}]}` + if err := ObserveEvent(guard, proj, "", []byte(chunk3)); err != nil { + t.Fatalf("chunk3 error: %v", err) + } + + if guard.IsTripped() { + t.Fatalf("normal multi-choice multi-tool unexpectedly tripped: %s", guard.TrippedReason()) + } +} + +// 03-C 补充测试:晚到 ID 场景尾检正常生效 +func TestRegression03C_ChatToolChannelLateArrivingIDTailCheck(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewChatProjector() + + prefix := strings.Repeat("z", 100) + var periodic strings.Builder + for i := 0; i < 820; i++ { + periodic.WriteString("0123456789") + } + args := prefix + periodic.String() + + // chunk 1: 仅有 tool index 与部分参数,无 id + c1 := `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":"f","arguments":"` + args[:4000] + `"}}]}}]}` + if err := ObserveEvent(guard, proj, "", []byte(c1)); err != nil { + t.Fatalf("c1: %v", err) + } + + // chunk 2: 晚到 call_id 与剩余参数 + c2 := `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_late_1","function":{"arguments":"` + args[4000:] + `"}}]}}]}` + if err := ObserveEvent(guard, proj, "", []byte(c2)); err != nil { + t.Fatalf("c2: %v", err) + } + + // chunk 3: finish_reason 到达,触发 choice 0 的所有 tool_calls EndChannel + c3 := `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}` + err := ObserveEvent(guard, proj, "", []byte(c3)) + if err == nil && !guard.IsTripped() { + t.Fatalf("expected repetition trip on tail check, got tripped=%v", guard.IsTripped()) + } + if !guard.IsTripped() || guard.TrippedReason() != "repetition" { + t.Fatalf("expected repetition trip on tail check, got tripped=%v reason=%q", guard.IsTripped(), guard.TrippedReason()) + } +} + +// 03-C 补充测试:完全缺失 ID 场景尾检正常生效 +func TestRegression03C_ChatToolChannelMissingIDTailCheck(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewChatProjector() + + prefix := strings.Repeat("z", 100) + var periodic strings.Builder + for i := 0; i < 820; i++ { + periodic.WriteString("0123456789") + } + args := prefix + periodic.String() + + // chunk 1: 全程无 ID + c1 := `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":"f","arguments":"` + args + `"}}]}}]}` + if err := ObserveEvent(guard, proj, "", []byte(c1)); err != nil { + t.Fatalf("c1: %v", err) + } + + // chunk 2: finish_reason + c2 := `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}` + err := ObserveEvent(guard, proj, "", []byte(c2)) + if err == nil && !guard.IsTripped() { + t.Fatalf("expected repetition trip on tail check, got tripped=%v", guard.IsTripped()) + } + if !guard.IsTripped() || guard.TrippedReason() != "repetition" { + t.Fatalf("expected repetition trip on tail check, got tripped=%v reason=%q", guard.IsTripped(), guard.TrippedReason()) + } +} + +// 03-C 补充测试:Gemini 有 ID 工具 finishReason 尾检触发与 ActionEnd 补全测试 (reviewer PC3 回归) +func TestRegression03C_GeminiWithIDToolChannelFinishReasonEnd(t *testing.T) { + // 步骤 1: 复用 reviewer PC3 完整 fixture (c1 8190B blob + c3 4B tail + finishReason STOP) + // 验证 Projector 在 finishReason 时必须对有 ID 工具 (callT -> candidate_0.tool_callT) 生成 ActionEnd + proj := NewGeminiProjector() + periodic := strings.Repeat("w", 8190) + c1 := `{"candidates":[{"index":0,"content":{"parts":[{"functionCall":{"id":"callT","name":"f","args":{"blob":"` + periodic + `"}}}]}}]}` + if _, err := proj.Project("", []byte(c1)); err != nil { + t.Fatalf("c1 project: %v", err) + } + + c3 := `{"candidates":[{"index":0,"content":{"parts":[{"functionCall":{"id":"callT","name":"f","args":{"tail":"wwww"}}}]},"finishReason":"STOP"}]}` + obs, err := proj.Project("", []byte(c3)) + if err != nil { + t.Fatalf("c3 project: %v", err) + } + hasEnd := false + for _, o := range obs { + if o.ChannelID == "candidate_0.tool_callT" && o.Action == ActionEnd { + hasEnd = true + break + } + } + if !hasEnd { + t.Fatalf("03-C failure: Gemini with-ID tool channel omitted ActionEnd at finishReason!") + } + + // 步骤 2: 验证流水线尾检生效:当 tool args 尾部存在周期数据时,finishReason 的 ActionEnd 必须触发尾检命中 repetition + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj2 := NewGeminiProjector() + prefix := strings.Repeat("z", 100) + periodicTail := strings.Repeat("0123456789", 820) // 8200 字节 + args := prefix + periodicTail + + chunk1 := `{"candidates":[{"index":0,"content":{"parts":[{"functionCall":{"id":"call_tail_1","name":"f","args":"` + args + `"}}]}}]}` + if err := ObserveEvent(guard, proj2, "", []byte(chunk1)); err != nil { + t.Fatalf("chunk1 observe: %v", err) + } + if guard.IsTripped() { + t.Fatalf("chunk1 unexpectedly tripped") + } + + // chunk2: finishReason 到达,触发 call_tail_1 的 ActionEnd,执行 CheckTail 必须命中 repetition + chunk2 := `{"candidates":[{"index":0,"content":{"parts":[]},"finishReason":"STOP"}]}` + err = ObserveEvent(guard, proj2, "", []byte(chunk2)) + if err == nil && !guard.IsTripped() { + t.Fatalf("expected repetition trip on tail check for with-ID gemini tool, got tripped=%v", guard.IsTripped()) + } + if !guard.IsTripped() || guard.TrippedReason() != "repetition" { + t.Fatalf("expected repetition trip on tail check for with-ID gemini tool, got tripped=%v reason=%q", guard.IsTripped(), guard.TrippedReason()) + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestResponsesProjector.*|TestChatProjector.*|TestAnthropicProjector.*|TestGeminiProjector.*|TestObserveEvent.*|TestCustomToolArgumentsEnvelopePreservation|TestRegression03.*"` +Expected: FAIL with compilation error: undefined: NewResponsesProjector, NewChatProjector, NewAnthropicProjector, NewGeminiProjector, ObserveEvent + +- [ ] **Step 3: Write minimal implementation** + +新建 `proxy/outputguard_projection.go`: +```go +package proxy + +import ( + "bytes" + "fmt" + + "github.com/codex2api/security/outputguard" + "github.com/tidwall/gjson" +) + +type ObservationAction string + +const ( + ActionAppend ObservationAction = "append" + ActionSnapshot ObservationAction = "snapshot" + ActionEnd ObservationAction = "end" +) + +type Observation struct { + ChannelID string + AliasID string + Kind outputguard.ChannelKind + Action ObservationAction + Payload []byte + TerminalStage string // 严格限制为 3 种合法语义终态阶段: "arguments.done", "item.done", "response.completed",其余普通帧置空 +} + +type ProtocolProjector interface { + Project(eventType string, data []byte) ([]Observation, error) +} + +// ------------------------------------------------------------- +// 1. ResponsesProjector (HTTP SSE, WebSocket, Realtime Text) +// ------------------------------------------------------------- + +type ResponsesProjector struct { + aliasMap map[string]string +} + +func NewResponsesProjector() *ResponsesProjector { + return &ResponsesProjector{ + aliasMap: make(map[string]string), + } +} + +func (p *ResponsesProjector) Project(eventType string, data []byte) ([]Observation, error) { + if len(data) == 0 { + return nil, nil + } + parsed := gjson.ParseBytes(data) + t := eventType + if t == "" { + t = parsed.Get("type").String() + } + + var observations []Observation + + switch t { + case "response.output_text.delta": + itemID := parsed.Get("item_id").String() + delta := parsed.Get("delta").String() + cIdx := parsed.Get("content_index").Int() + if itemID == "" { + itemID = fmt.Sprintf("output_%d", parsed.Get("output_index").Int()) + } + cid := fmt.Sprintf("%s:content:%d", itemID, cIdx) + observations = append(observations, Observation{ + ChannelID: cid, + AliasID: itemID, + Kind: outputguard.KindText, + Action: ActionAppend, + Payload: []byte(delta), + }) + + case "response.reasoning.delta", "response.thinking.delta": + itemID := parsed.Get("item_id").String() + delta := parsed.Get("delta").String() + cIdx := parsed.Get("content_index").Int() + if itemID == "" { + itemID = fmt.Sprintf("output_%d", parsed.Get("output_index").Int()) + } + cid := fmt.Sprintf("%s:thinking:%d", itemID, cIdx) + observations = append(observations, Observation{ + ChannelID: cid, + AliasID: fmt.Sprintf("%s.thinking", itemID), + Kind: outputguard.KindThinking, + Action: ActionAppend, + Payload: []byte(delta), + }) + + case "response.function_call_arguments.delta": + callID := parsed.Get("call_id").String() + itemID := parsed.Get("item_id").String() + delta := parsed.Get("delta").String() + targetID := callID + if targetID == "" { + targetID = itemID + } + if targetID == "" { + targetID = fmt.Sprintf("output_%d", parsed.Get("output_index").Int()) + } + observations = append(observations, Observation{ + ChannelID: targetID, + AliasID: itemID, + Kind: outputguard.KindToolArguments, + Action: ActionAppend, + Payload: []byte(delta), + }) + + case "response.custom_tool_call_input.delta": + callID := parsed.Get("call_id").String() + itemID := parsed.Get("item_id").String() + delta := parsed.Get("delta").String() + targetID := callID + if targetID == "" { + targetID = itemID + } + if targetID == "" { + targetID = fmt.Sprintf("output_%d", parsed.Get("output_index").Int()) + } + observations = append(observations, Observation{ + ChannelID: targetID, + AliasID: itemID, + Kind: outputguard.KindToolArguments, + Action: ActionAppend, + Payload: []byte(delta), + }) + + case "response.function_call_arguments.done": + callID := parsed.Get("call_id").String() + itemID := parsed.Get("item_id").String() + args := parsed.Get("arguments").String() + targetID := callID + if targetID == "" { + targetID = itemID + } + observations = append(observations, Observation{ + ChannelID: targetID, + AliasID: itemID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: []byte(args), + TerminalStage: "arguments.done", + }) + + case "response.custom_tool_call_input.done": + callID := parsed.Get("call_id").String() + itemID := parsed.Get("item_id").String() + inputVal := parsed.Get("input").String() + targetID := callID + if targetID == "" { + targetID = itemID + } + observations = append(observations, Observation{ + ChannelID: targetID, + AliasID: itemID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: []byte(inputVal), + TerminalStage: "arguments.done", + }) + + case "response.output_item.done": + item := parsed.Get("item") + itemType := item.Get("type").String() + itemID := item.Get("id").String() + if itemID == "" { + itemID = parsed.Get("item_id").String() + } + + if itemType == "function_call" { + callID := item.Get("call_id").String() + args := item.Get("arguments").String() + targetID := callID + if targetID == "" { + targetID = itemID + } + observations = append(observations, Observation{ + ChannelID: targetID, + AliasID: itemID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: []byte(args), + TerminalStage: "item.done", + }) + } else if itemType == "custom_tool_call" { + callID := item.Get("call_id").String() + inputVal := item.Get("input").String() + targetID := callID + if targetID == "" { + targetID = itemID + } + observations = append(observations, Observation{ + ChannelID: targetID, + AliasID: itemID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: []byte(inputVal), + TerminalStage: "item.done", + }) + } else if itemType == "message" { + contentArray := item.Get("content").Array() + for idx, cBlock := range contentArray { + cType := cBlock.Get("type").String() + text := cBlock.Get("text").String() + if cType == "output_text" || (cType == "" && text != "") { + if text != "" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("%s:content:%d", itemID, idx), + AliasID: itemID, + Kind: outputguard.KindText, + Action: ActionSnapshot, + Payload: []byte(text), + TerminalStage: "item.done", + }) + } + } else if cType == "reasoning" || cType == "thinking" { + thinkingText := cBlock.Get("thinking").String() + if thinkingText == "" { + thinkingText = cBlock.Get("text").String() + } + if thinkingText != "" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("%s:thinking:%d", itemID, idx), + AliasID: fmt.Sprintf("%s.thinking", itemID), + Kind: outputguard.KindThinking, + Action: ActionSnapshot, + Payload: []byte(thinkingText), + TerminalStage: "item.done", + }) + } + } + } + } + + case "response.completed": + outputs := parsed.Get("response.output").Array() + for _, item := range outputs { + observations = append(observations, p.projectResponsesOutputItem(item, "response.completed")...) + } + + default: + // 兼容非流式完整响应 JSON + if parsed.Get("status").String() == "completed" && parsed.Get("output").Exists() { + for _, item := range parsed.Get("output").Array() { + observations = append(observations, p.projectResponsesOutputItem(item, "response.completed")...) + } + } + } + + return observations, nil +} + +func (p *ResponsesProjector) projectResponsesOutputItem(item gjson.Result, stage string) []Observation { + var observations []Observation + itemType := item.Get("type").String() + itemID := item.Get("id").String() + + if itemType == "function_call" { + callID := item.Get("call_id").String() + args := item.Get("arguments").String() + targetID := callID + if targetID == "" { + targetID = itemID + } + observations = append(observations, Observation{ + ChannelID: targetID, + AliasID: itemID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: []byte(args), + TerminalStage: stage, + }) + } else if itemType == "custom_tool_call" { + callID := item.Get("call_id").String() + inputVal := item.Get("input").String() + targetID := callID + if targetID == "" { + targetID = itemID + } + observations = append(observations, Observation{ + ChannelID: targetID, + AliasID: itemID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: []byte(inputVal), + TerminalStage: stage, + }) + } else if itemType == "message" { + contentArray := item.Get("content").Array() + for idx, cBlock := range contentArray { + cType := cBlock.Get("type").String() + text := cBlock.Get("text").String() + if cType == "output_text" || (cType == "" && text != "") { + if text != "" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("%s:content:%d", itemID, idx), + AliasID: itemID, + Kind: outputguard.KindText, + Action: ActionSnapshot, + Payload: []byte(text), + TerminalStage: stage, + }) + } + } else if cType == "reasoning" || cType == "thinking" { + thinkingText := cBlock.Get("thinking").String() + if thinkingText == "" { + thinkingText = cBlock.Get("text").String() + } + if thinkingText != "" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("%s:thinking:%d", itemID, idx), + AliasID: fmt.Sprintf("%s.thinking", itemID), + Kind: outputguard.KindThinking, + Action: ActionSnapshot, + Payload: []byte(thinkingText), + TerminalStage: stage, + }) + } + } + } + } + return observations +} + +// ------------------------------------------------------------- +// 2. ChatProjector (OpenAI /v1/chat/completions Stream & JSON) +// ------------------------------------------------------------- + +type ChatProjector struct { + aliasMap map[string]string + choiceTools map[int]map[string]struct{} +} + +func NewChatProjector() *ChatProjector { + return &ChatProjector{ + aliasMap: make(map[string]string), + choiceTools: make(map[int]map[string]struct{}), + } +} + +func (p *ChatProjector) Project(eventType string, data []byte) ([]Observation, error) { + if len(data) == 0 { + return nil, nil + } + parsed := gjson.ParseBytes(data) + var observations []Observation + + choices := parsed.Get("choices").Array() + for _, choice := range choices { + choiceIdx := choice.Get("index").Int() + finishReason := choice.Get("finish_reason").String() + + // 流式 delta + delta := choice.Get("delta") + if delta.Exists() { + if content := delta.Get("content").String(); content != "" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("choice_%d.text", choiceIdx), + Kind: outputguard.KindText, + Action: ActionAppend, + Payload: []byte(content), + }) + } + reasoning := delta.Get("reasoning_content").String() + if reasoning == "" { + reasoning = delta.Get("reasoning").String() + } + if reasoning != "" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("choice_%d.thinking", choiceIdx), + Kind: outputguard.KindThinking, + Action: ActionAppend, + Payload: []byte(reasoning), + }) + } + + // 标准 tool_calls + toolCalls := delta.Get("tool_calls").Array() + for _, tc := range toolCalls { + toolIdx := int(tc.Get("index").Int()) + callID := tc.Get("id").String() + channelID := fmt.Sprintf("choice_%d.tool_%d", choiceIdx, toolIdx) + if callID != "" { + p.aliasMap[callID] = channelID + } + if p.choiceTools[int(choiceIdx)] == nil { + p.choiceTools[int(choiceIdx)] = make(map[string]struct{}) + } + p.choiceTools[int(choiceIdx)][channelID] = struct{}{} + args := tc.Get("function.arguments").String() + if args != "" { + observations = append(observations, Observation{ + ChannelID: channelID, + AliasID: callID, + Kind: outputguard.KindToolArguments, + Action: ActionAppend, + Payload: []byte(args), + }) + } + } + + // 兼容 legacy delta.function_call (Spec §4.1) + if fnCall := delta.Get("function_call"); fnCall.Exists() { + args := fnCall.Get("arguments").String() + channelID := fmt.Sprintf("choice_%d.function_call", choiceIdx) + if args != "" { + observations = append(observations, Observation{ + ChannelID: channelID, + Kind: outputguard.KindToolArguments, + Action: ActionAppend, + Payload: []byte(args), + }) + } + } + } + + // 非流式 message + msg := choice.Get("message") + if msg.Exists() { + if content := msg.Get("content").String(); content != "" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("choice_%d.text", choiceIdx), + Kind: outputguard.KindText, + Action: ActionSnapshot, + Payload: []byte(content), + TerminalStage: "response.completed", + }) + } + reasoning := msg.Get("reasoning_content").String() + if reasoning == "" { + reasoning = msg.Get("reasoning").String() + } + if reasoning != "" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("choice_%d.thinking", choiceIdx), + Kind: outputguard.KindThinking, + Action: ActionSnapshot, + Payload: []byte(reasoning), + TerminalStage: "response.completed", + }) + } + + // 标准 tool_calls + toolCalls := msg.Get("tool_calls").Array() + for idx, tc := range toolCalls { + callID := tc.Get("id").String() + channelID := fmt.Sprintf("choice_%d.tool_%d", choiceIdx, idx) + args := tc.Get("function.arguments").String() + observations = append(observations, Observation{ + ChannelID: channelID, + AliasID: callID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: []byte(args), + TerminalStage: "response.completed", + }) + } + + // 兼容 legacy message.function_call (Spec §4.1) + if fnCall := msg.Get("function_call"); fnCall.Exists() { + args := fnCall.Get("arguments").String() + channelID := fmt.Sprintf("choice_%d.function_call", choiceIdx) + observations = append(observations, Observation{ + ChannelID: channelID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: []byte(args), + TerminalStage: "response.completed", + }) + } + } + + // finish_reason 尾检触发 ActionEnd + if finishReason != "" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("choice_%d.text", choiceIdx), + Kind: outputguard.KindText, + Action: ActionEnd, + }) + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("choice_%d.thinking", choiceIdx), + Kind: outputguard.KindThinking, + Action: ActionEnd, + }) + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("choice_%d.function_call", choiceIdx), + Kind: outputguard.KindToolArguments, + Action: ActionEnd, + }) + if tools, exists := p.choiceTools[int(choiceIdx)]; exists { + for toolKey := range tools { + observations = append(observations, Observation{ + ChannelID: toolKey, + Kind: outputguard.KindToolArguments, + Action: ActionEnd, + }) + } + } + } + } + + return observations, nil +} + +// ------------------------------------------------------------- +// 3. AnthropicProjector (Claude /v1/messages Stream & JSON) +// ------------------------------------------------------------- + +type AnthropicProjector struct { + blockTypes map[int]string + blockIDs map[int]string +} + +func NewAnthropicProjector() *AnthropicProjector { + return &AnthropicProjector{ + blockTypes: make(map[int]string), + blockIDs: make(map[int]string), + } +} + +func (p *AnthropicProjector) Project(eventType string, data []byte) ([]Observation, error) { + if len(data) == 0 { + return nil, nil + } + parsed := gjson.ParseBytes(data) + t := eventType + if t == "" { + t = parsed.Get("type").String() + } + + var observations []Observation + + switch t { + case "content_block_start": + idx := int(parsed.Get("index").Int()) + block := parsed.Get("content_block") + bType := block.Get("type").String() + p.blockTypes[idx] = bType + if bType == "tool_use" { + p.blockIDs[idx] = block.Get("id").String() + } + + case "content_block_delta": + idx := int(parsed.Get("index").Int()) + delta := parsed.Get("delta") + dType := delta.Get("type").String() + + switch dType { + case "text_delta": + text := delta.Get("text").String() + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + Kind: outputguard.KindText, + Action: ActionAppend, + Payload: []byte(text), + }) + case "thinking_delta": + thinking := delta.Get("thinking").String() + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + Kind: outputguard.KindThinking, + Action: ActionAppend, + Payload: []byte(thinking), + }) + case "input_json_delta": + partJSON := delta.Get("partial_json").String() + callID := p.blockIDs[idx] + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + AliasID: callID, + Kind: outputguard.KindToolArguments, + Action: ActionAppend, + Payload: []byte(partJSON), + }) + } + + case "content_block_stop": + idx := int(parsed.Get("index").Int()) + bType := p.blockTypes[idx] + if bType == "tool_use" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + AliasID: p.blockIDs[idx], + Kind: outputguard.KindToolArguments, + Action: ActionEnd, + }) + } else if bType == "thinking" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + Kind: outputguard.KindThinking, + Action: ActionEnd, + }) + } else { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + Kind: outputguard.KindText, + Action: ActionEnd, + }) + } + + case "message_stop": + // stop key 与 start key 严格一致 (block_%d),不造 message_root 伪 channel + for blockIdx, blockType := range p.blockTypes { + kind := outputguard.KindText + if blockType == "tool_use" { + kind = outputguard.KindToolArguments + } else if blockType == "thinking" { + kind = outputguard.KindThinking + } + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", blockIdx), + AliasID: p.blockIDs[blockIdx], + Kind: kind, + Action: ActionEnd, + }) + } + + default: + // 非流式 JSON (type == "message") + if parsed.Get("type").String() == "message" && parsed.Get("content").IsArray() { + contentBlocks := parsed.Get("content").Array() + for idx, b := range contentBlocks { + bType := b.Get("type").String() + switch bType { + case "text": + text := b.Get("text").String() + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + Kind: outputguard.KindText, + Action: ActionSnapshot, + Payload: []byte(text), + TerminalStage: "response.completed", + }) + case "thinking": + thinking := b.Get("thinking").String() + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + Kind: outputguard.KindThinking, + Action: ActionSnapshot, + Payload: []byte(thinking), + TerminalStage: "response.completed", + }) + case "tool_use": + callID := b.Get("id").String() + inputObj := b.Get("input") + var inputBytes []byte + if inputObj.IsObject() { + inputBytes = []byte(inputObj.Raw) + } else { + inputBytes = []byte(inputObj.String()) + } + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + AliasID: callID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: inputBytes, + TerminalStage: "response.completed", + }) + } + } + } + } + + return observations, nil +} + +// ------------------------------------------------------------- +// 4. GeminiProjector (Native streamGenerateContent & generateContent) +// ------------------------------------------------------------- + +type GeminiProjectorMode int + +const ( + GeminiProjectorModeAuto GeminiProjectorMode = iota + GeminiProjectorModeStream + GeminiProjectorModeNonStream +) + +type GeminiProjector struct { + candidateToolCounters map[int]int + candidateToolKeys map[int][]string + mode GeminiProjectorMode + isStream bool +} + +func NewGeminiProjector() *GeminiProjector { + return &GeminiProjector{ + candidateToolCounters: make(map[int]int), + candidateToolKeys: make(map[int][]string), + mode: GeminiProjectorModeAuto, + isStream: false, + } +} + +func NewGeminiProjectorWithMode(mode GeminiProjectorMode) *GeminiProjector { + p := &GeminiProjector{ + candidateToolCounters: make(map[int]int), + candidateToolKeys: make(map[int][]string), + mode: mode, + isStream: mode == GeminiProjectorModeStream, + } + return p +} + +func (p *GeminiProjector) Project(eventType string, data []byte) ([]Observation, error) { + if len(data) == 0 { + return nil, nil + } + parsed := gjson.ParseBytes(data) + var observations []Observation + + candidates := parsed.Get("candidates").Array() + // 若处于 Auto 模式:当前事件未带 finishReason,说明处于多 chunk 流式传输中;若已进入流式,则后续 chunk 均为流式 + if p.mode == GeminiProjectorModeAuto && !p.isStream && len(candidates) > 0 { + hasFinishReason := false + for _, cand := range candidates { + if cand.Get("finishReason").String() != "" { + hasFinishReason = true + break + } + } + if !hasFinishReason { + p.isStream = true + } + } + + for candIdx, cand := range candidates { + finishReason := cand.Get("finishReason").String() + parts := cand.Get("content.parts").Array() + + for partIdx, part := range parts { + // 1. Text / Thought + if part.Get("text").Exists() { + text := part.Get("text").String() + isThought := part.Get("thought").Bool() + kind := outputguard.KindText + suffix := "text" + if isThought { + kind = outputguard.KindThinking + suffix = "thinking" + } + action := ActionAppend + var stage string + if !p.isStream && finishReason != "" { + action = ActionSnapshot + stage = "response.completed" + } + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("candidate_%d.part_%d.%s", candIdx, partIdx, suffix), + Kind: kind, + Action: action, + Payload: []byte(text), + TerminalStage: stage, + }) + } + + // 2. Function Call (Native object bounded serialization) + if fnCall := part.Get("functionCall"); fnCall.Exists() { + callID := fnCall.Get("id").String() + args := fnCall.Get("args") + var argBytes []byte + if args.IsObject() { + argBytes = []byte(args.Raw) + } else { + argBytes = []byte(args.String()) + } + + action := ActionAppend + var stage string + if !p.isStream && finishReason != "" { + action = ActionSnapshot + stage = "response.completed" + } + + // 稳定映射规则:有 callID 用 callID;无 callID 流式形态使用 candidateToolCounters 跨 chunk 单调计数。 + // 关联限制说明:对于无 ID 工具调用,Gemini 协议缺乏连续性标识,无法跨 chunk 判别同逻辑续传; + // 原生完整 object 与后续新 call 由请求级 channel 数量及字节预算兜底,不作跨 chunk 合并的绝对宣称。 + var channelID string + if callID != "" { + channelID = fmt.Sprintf("candidate_%d.tool_%s", candIdx, callID) + // 记录有 ID 工具 key 以便 finishReason 触发 ActionEnd 尾检 + found := false + for _, k := range p.candidateToolKeys[candIdx] { + if k == channelID { + found = true + break + } + } + if !found { + p.candidateToolKeys[candIdx] = append(p.candidateToolKeys[candIdx], channelID) + } + } else { + currentToolIdx := p.candidateToolCounters[candIdx] + p.candidateToolCounters[candIdx]++ + channelID = fmt.Sprintf("candidate_%d.tool_idx_%d", candIdx, currentToolIdx) + } + + observations = append(observations, Observation{ + ChannelID: channelID, + AliasID: callID, + Kind: outputguard.KindToolArguments, + Action: action, + Payload: argBytes, + TerminalStage: stage, + }) + } + } + + // finishReason 尾检触发 ActionEnd + if finishReason != "" { + for i := 0; i < len(parts); i++ { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("candidate_%d.part_%d.text", candIdx, i), + Kind: outputguard.KindText, + Action: ActionEnd, + }) + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("candidate_%d.part_%d.thinking", candIdx, i), + Kind: outputguard.KindThinking, + Action: ActionEnd, + }) + } + if totalTools, exists := p.candidateToolCounters[candIdx]; exists { + for tIdx := 0; tIdx < totalTools; tIdx++ { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("candidate_%d.tool_idx_%d", candIdx, tIdx), + Kind: outputguard.KindToolArguments, + Action: ActionEnd, + }) + } + } + if toolKeys, exists := p.candidateToolKeys[candIdx]; exists { + for _, k := range toolKeys { + observations = append(observations, Observation{ + ChannelID: k, + Kind: outputguard.KindToolArguments, + Action: ActionEnd, + }) + } + } + } + } + + return observations, nil +} + +// ------------------------------------------------------------- +// 5. ObserveEvent Dispatcher (单上游事件严格计 1 次,流水线批调度) +// ------------------------------------------------------------- + +func ObserveEvent(guard *outputguard.RequestGuard, projector ProtocolProjector, eventType string, data []byte) error { + // 心跳/注释帧直接放行,不计入业务事件预算 + trimmed := bytes.TrimSpace(data) + if bytes.HasPrefix(trimmed, []byte(":")) || eventType == "ping" { + return nil + } + + // 步骤 1: 严格每上游业务事件计 1 次 requestEvents(即使是 control/unknown 帧) + if err := guard.RecordEvent(); err != nil { + return err + } + + obs, err := projector.Project(eventType, data) + if err != nil { + return err + } + if len(obs) == 0 { + return nil + } + + // 依次将 observations 推入 guard,执行判定流水线 + // 单 event 所有 obs 检查通过再允许 consumer,一 obs 命中整 event 不可见 + for _, o := range obs { + switch o.Action { + case ActionAppend: + if err := guard.ObserveAppendNoEvent(o.ChannelID, o.AliasID, o.Kind, o.Payload); err != nil { + return err + } + case ActionSnapshot: + if err := guard.ObserveSnapshotNoEvent(o.ChannelID, o.AliasID, o.Kind, o.Payload, o.TerminalStage); err != nil { + return err + } + case ActionEnd: + // ActionEnd 目标解析:当 Observation 携带 AliasID(如 tool call_id)时优先使用; + // 依赖 RequestGuard.aliasMap 内部双向索引反查至对应 primary channel,保持解耦契约。 + target := o.ChannelID + if o.AliasID != "" { + target = o.AliasID + } + if err := guard.EndChannel(target); err != nil { + return err + } + } + } + + return nil +} +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestResponsesProjector.*|TestChatProjector.*|TestAnthropicProjector.*|TestGeminiProjector.*|TestObserveEvent.*|TestCustomToolArgumentsEnvelopePreservation|TestRegression03.*"` +Expected: PASS ok github.com/codex2api/proxy + +- [ ] **Step 5: Commit** + +```bash +git add proxy/outputguard_projection.go proxy/outputguard_projection_test.go +git commit -m "feat(proxy): implement protocol projectors and observe event dispatcher with strict event budgeting" +``` + +--- + +### Task 8: 传输 Framing 有界 Reader 与全链路 Callsite 替换 + +> 注:本节参考代码以已提交实现(`proxy/sse_line.go`、`proxy/executor.go`,FW1 修复 4522ee05)为准。 + +**Files:** +- Modify: `proxy/sse_line.go` +- Modify: `proxy/sse_line_test.go` +- Modify: `proxy/executor.go:1592-1715` +- Review-only: `proxy/handler.go:5538-5585,7650-7750`(保持原样,由后续 04 接线) +- Review-only: `proxy/handler_gemini.go:390-420`(保持原样,由后续 04 接线) +- Review-only: `proxy/responses_ws.go:142-190`(保持原样,由后续 04 接线) + +**Interfaces:** +- Consumes: + - Go 标准库 `bufio`, `bytes`, `errors`, `io`, `net/http`, `sync` + - `github.com/gorilla/websocket` (go.mod) + - `security.MaxRequestBodySize` (48MB, 严格保留) +- Produces: + ```go + package proxy + + // 既有常量与导出常量别名桥接 + const ( + maxSSELineBytes = 8 << 20 // 8 MiB (既有常量) + maxSSEEventBytes = 8 << 20 // 8 MiB (既有常量) + MaxSSELineBytes = maxSSELineBytes // 导出别名 + MaxSSEEventBytes = maxSSEEventBytes // 导出别名 + MaxResponseBodyBytes = 16 << 20 // 16 MiB (新导出常量) + ) + + // 既有 error 与导出 error 别名桥接 + var ( + errSSELineTooLong = errors.New("upstream SSE line exceeds limit") // 既有未导出 + errSSEEventTooLarge = errors.New("upstream SSE event exceeds limit") // 既有未导出 + ErrSSELineTooLong = errSSELineTooLong // 导出桥接 + ErrSSEEventTooLarge = errSSEEventTooLarge // 导出桥接 + ErrResponseBodyTooLarge = errors.New("response_body_too_large") + ) + + // 严格保留既有签名与实现,不重复定义 + func readBoundedSSELine(reader *bufio.Reader, limit int) ([]byte, error) + func ReadSSEStreamWithEvent(body io.Reader, callback func(event string, data []byte) bool) error + func ReadBoundedResponseBody(body io.Reader, limit int64) ([]byte, error) + ``` + +- [ ] **Step 1: Write failing test for bounded readers and callsites** + +> **测试清理说明**:清理测试文件中可能导致编译错误的无用 import(如 `unused bufio/bytes`)。测试用例必须使用真实的 `io.Reader`,严格验证行为不走自证逻辑。 + +```go +// proxy/sse_line_test.go (在现有 TestReadSSEDataLine* 测试之后追加 Task 8 测试用例) +package proxy + +import ( + "errors" + "io" + "strings" + "testing" +) + +// 1. 测试跨 Read 块的 split chunk 正常解析 +func TestReadSSEStreamWithEventSplitChunks(t *testing.T) { + input := "data: hello " + input2 := "world\n\n" + r := io.MultiReader(strings.NewReader(input), strings.NewReader(input2)) + + var collected []string + err := ReadSSEStreamWithEvent(r, func(event string, data []byte) bool { + collected = append(collected, string(data)) + return true + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(collected) != 1 || collected[0] != "hello world" { + t.Fatalf("collected want ['hello world'], got %v", collected) + } +} + +// 2. 测试标准多行 data 聚合 +func TestReadSSEStreamWithEventMultilineData(t *testing.T) { + input := "event: update\ndata: line1\ndata: line2\ndata: line3\n\n" + var events []string + var payloads []string + err := ReadSSEStreamWithEvent(strings.NewReader(input), func(event string, data []byte) bool { + events = append(events, event) + payloads = append(payloads, string(data)) + return true + }) + if err != nil { + t.Fatalf("multiline read error: %v", err) + } + if len(events) != 1 || events[0] != "update" || payloads[0] != "line1\nline2\nline3" { + t.Fatalf("multiline want update with line1\\nline2\\nline3, got %v: %v", events, payloads) + } +} + +// 3. 测试无换行符超大单行硬 cap 拒绝,返回 ErrSSELineTooLong (与既有 errSSELineTooLong 等价) +func TestReadSSEStreamWithEventNoNewlineOversized(t *testing.T) { + oversized := strings.Repeat("x", MaxSSELineBytes+1024) + err := ReadSSEStreamWithEvent(strings.NewReader(oversized), func(event string, data []byte) bool { + return true + }) + if err == nil { + t.Fatalf("expected ErrSSELineTooLong for oversized line without newline, got nil") + } + if !errors.Is(err, ErrSSELineTooLong) { + t.Fatalf("expected ErrSSELineTooLong, got %v", err) + } +} + +// 4. 测试累积多行 data 整事件超标拒绝,返回 ErrSSEEventTooLarge (与既有 errSSEEventTooLarge 等价) +func TestReadSSEStreamWithEventNeverendingMultilineOversized(t *testing.T) { + chunk := "data: " + strings.Repeat("a", 1<<20) + "\n" + multilineOversized := strings.Repeat(chunk, 9) + "\n" + + err := ReadSSEStreamWithEvent(strings.NewReader(multilineOversized), func(event string, data []byte) bool { + return true + }) + if err == nil { + t.Fatalf("expected ErrSSEEventTooLarge for oversized multiline event, got nil") + } + if !errors.Is(err, ErrSSEEventTooLarge) { + t.Fatalf("expected ErrSSEEventTooLarge, got %v", err) + } +} + +// 5. 测试非流式超大 Response Body 有界拦截,返回 ErrResponseBodyTooLarge +func TestReadBoundedResponseBodyOversized(t *testing.T) { + oversizedBody := strings.Repeat("b", 1024) + _, err := ReadBoundedResponseBody(strings.NewReader(oversizedBody), 512) + if err == nil { + t.Fatalf("expected ErrResponseBodyTooLarge, got nil") + } + if !errors.Is(err, ErrResponseBodyTooLarge) { + t.Fatalf("expected ErrResponseBodyTooLarge, got %v", err) + } + + // 正常大小读取成功 + normalBody := "{\"status\":\"ok\"}" + data, err := ReadBoundedResponseBody(strings.NewReader(normalBody), MaxResponseBodyBytes) + if err != nil || string(data) != normalBody { + t.Fatalf("normal read error = %v, got %s", err, string(data)) + } +} + +// 6. 验证 Transport Framing 独立于 Guard 开关(关闭语义 guard 仍保留 wire cap) +func TestTransportFramingIndependentOfGuard(t *testing.T) { + oversized := strings.Repeat("x", MaxSSELineBytes+100) + err := ReadSSEStreamWithEvent(strings.NewReader(oversized), func(event string, data []byte) bool { + return true + }) + if !errors.Is(err, ErrSSELineTooLong) { + t.Fatalf("wire cap must remain active regardless of guard settings, got %v", err) + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestReadSSEStreamWithEvent.*|TestReadBoundedResponseBody.*|TestTransportFramingIndependentOfGuard"` +Expected: FAIL with compilation error: undefined symbols ErrSSELineTooLong, ErrSSEEventTooLarge, ReadBoundedResponseBody + +- [ ] **Step 3: Write minimal implementation** + +修改 `proxy/sse_line.go`: +保留既有全部内容,仅在文件尾部追加导出别名与 `ReadBoundedResponseBody`: +```go +// proxy/sse_line.go +package proxy + +import ( + "bufio" + "errors" + "io" +) + +// SSE wire limits are resource bounds, not a declaration that larger events +// are invalid upstream. Base64 media and large tool results can exceed them. +const maxSSELineBytes = 8 << 20 + +const maxSSEEventBytes = 8 << 20 + +var errSSELineTooLong = errors.New("upstream SSE line exceeds limit") + +var errSSEEventTooLarge = errors.New("upstream SSE event exceeds limit") + +// readBoundedSSELine 读取一行,内存占用不超过 limit(不含 bufio 自身缓冲)。 +func readBoundedSSELine(reader *bufio.Reader, limit int) ([]byte, error) { + var line []byte + for { + fragment, err := reader.ReadSlice('\n') + if len(line)+len(fragment) > limit { + return nil, errSSELineTooLong + } + line = append(line, fragment...) + if err != bufio.ErrBufferFull { + return line, err + } + } +} + +// ------------------------------------------------------------------------- +// 增补部分:导出常量别名与非流式有界读取器(严格保留既有小写符号兼容性) +// ------------------------------------------------------------------------- + +const ( + MaxSSELineBytes = maxSSELineBytes + MaxSSEEventBytes = maxSSEEventBytes + MaxResponseBodyBytes = 16 << 20 // 16 MiB (非流式响应体默认上限) +) + +var ( + ErrSSELineTooLong = errSSELineTooLong + ErrSSEEventTooLarge = errSSEEventTooLarge + ErrResponseBodyTooLarge = errors.New("response_body_too_large") +) + +// ReadBoundedResponseBody 有界读取非流式响应体。 +func ReadBoundedResponseBody(body io.Reader, limit int64) ([]byte, error) { + if limit <= 0 { + limit = MaxResponseBodyBytes + } + // 预留 +1 探测超限时需要 limit+1;MaxInt64 时加一会溢出为负,导致 + // io.LimitReader 立即 EOF 并静默返回空数据,因此先做 clamp。 + if limit > math.MaxInt64-1 { + limit = math.MaxInt64 - 1 + } + lr := io.LimitReader(body, limit+1) + data, err := io.ReadAll(lr) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, ErrResponseBodyTooLarge + } + return data, nil +} +``` + +修改 `proxy/executor.go:1602-1715` 中的 `ReadSSEStreamWithEvent`: +添加对单行及多行累积事件的硬 Cap 判定(返回 `errSSELineTooLong` 与 `errSSEEventTooLarge`): +```go +// proxy/executor.go +func ReadSSEStreamWithEvent(body io.Reader, callback func(event string, data []byte) bool) error { + buf := sseBufferPool.Get().([]byte) + defer sseBufferPool.Put(buf) + + lineBufPtr := sseLineBufPool.Get().(*[]byte) + lineBuf := (*lineBufPtr)[:0] + defer func() { + if cap(lineBuf) <= 256*1024 { + *lineBufPtr = lineBuf[:0] + sseLineBufPool.Put(lineBufPtr) + } + }() + + var dataLines [][]byte + var eventName string + var currentEventBytes int + + emitEvent := func() (bool, error) { + event := eventName + eventName = "" + currentEventBytes = 0 + if len(dataLines) == 0 { + return true, nil + } + + data := dataLines[0] + if len(dataLines) > 1 { + data = bytes.Join(dataLines, []byte("\n")) + } + isDone := bytes.Equal(data, []byte("[DONE]")) + keepReading := !isDone && callback(event, data) + for i := range dataLines { + dataLines[i] = nil + } + dataLines = dataLines[:0] + return keepReading, nil + } + + consumeField := func(line []byte) error { + if bytes.HasPrefix(line, []byte("data:")) { + data := bytes.TrimPrefix(line, []byte("data:")) + data = bytes.TrimPrefix(data, []byte(" ")) + // 单行与多行累积事件都必须在 wire 层硬性受限,避免超大事件 + // 长期占用内存。空 data 行(len(data)==0)也必须至少计 1 字节: + // 否则无穷多的 "data:" 行在事件边界缺失时会让 dataLines 无界增长。 + if currentEventBytes+max(len(data), 1) > maxSSEEventBytes { + return errSSEEventTooLarge + } + currentEventBytes += max(len(data), 1) + dataCopy := make([]byte, len(data)) + copy(dataCopy, data) + dataLines = append(dataLines, dataCopy) + return nil + } + if bytes.HasPrefix(line, []byte("event:")) { + event := bytes.TrimPrefix(line, []byte("event:")) + event = bytes.TrimPrefix(event, []byte(" ")) + eventName = string(event) + } + return nil + } + + for { + n, err := body.Read(buf) + if n > 0 { + // 未完成行在缓冲区中持续累积,必须在 append 前就拒绝超过 + // wire 上限的输入,避免无换行巨量数据无限增长。 + if len(lineBuf)+n > maxSSELineBytes { + return errSSELineTooLong + } + lineBuf = append(lineBuf, buf[:n]...) + + consumed := 0 + for { + idx := bytes.IndexByte(lineBuf[consumed:], '\n') + if idx < 0 { + // 深度防御(defense in depth):上面的 append 前检查已保证 + // len(lineBuf) <= maxSSELineBytes 且 consumed >= 0,此处数学上 + // 不可达,仅作为未来改动的兜底。 + if len(lineBuf)-consumed > maxSSELineBytes { + return errSSELineTooLong + } + break + } + + lineEnd := consumed + idx + line := bytes.TrimRight(lineBuf[consumed:lineEnd], "\r") + consumed = lineEnd + 1 + + if len(line) == 0 { + keep, emitErr := emitEvent() + if emitErr != nil { + return emitErr + } + if !keep { + return nil + } + continue + } + + if bytes.HasPrefix(line, []byte(":")) { + continue + } + + if err := consumeField(line); err != nil { + return err + } + } + + if consumed > 0 { + remaining := copy(lineBuf, lineBuf[consumed:]) + lineBuf = lineBuf[:remaining] + } + } + + if err != nil { + if err == io.EOF { + if len(lineBuf) > 0 { + if len(lineBuf) > maxSSELineBytes { + return errSSELineTooLong + } + line := bytes.TrimRight(lineBuf, "\r") + if err := consumeField(line); err != nil { + return err + } + } + _, emitErr := emitEvent() + if emitErr != nil { + return emitErr + } + return nil + } + return err + } + } +} +``` + +### 全链路 Callsite 替换与诊断 Peek 豁免分析 + +在进行全链路 Callsite 替换时,不能机械替换全部 `LimitReader`。对现有生产代码中各 `LimitReader` 调用点进行审慎分类: + +1. **必须保留的诊断 Peek / 元数据读取豁免(不得机械替换为 16MB MaxResponseBodyBytes)**: + - `proxy/executor.go:829`: `io.LimitReader(resp.Body, 64*1024)` 用于错误诊断前缀 peek,仅需 64KB,保持不变; + - `proxy/executor.go:1047`: `io.LimitReader(resp.Body, peekLimit)` 用于探测响应前缀,保持不变; + - `proxy/claude_cli_version_sync.go:95`: `io.LimitReader(resp.Body, 512)` 用于诊断前缀 peek,保持不变; + - `proxy/modeltrace_bank_sync.go:75`: `io.LimitReader(resp.Body, 512)` 用于诊断前缀 peek,保持不变; + - `proxy/model_pricing_sync.go:104`: `io.LimitReader(resp.Body, 512)` 用于诊断前缀 peek,保持不变; + - `proxy/codex_cli_version_sync.go:74`: `io.LimitReader(resp.Body, 512)` 用于诊断前缀 peek,保持不变; + - `proxy/grok_runtime_facts.go:157`: `io.LimitReader(original, grokRuntimeErrorPeekLimit)` 诊断错误前缀,保持不变。 + +2. **必须保留的高额与专用上限(WebSocket 与媒体传输)**: + - `proxy/responses_ws.go:272`: `conn.SetReadLimit(int64(security.MaxRequestBodySize))` 严格保留 48MB 上限与 1013 Close code,不得新加 `SetReadLimit(8MB)` 收紧; + - `proxy/realtime_ws.go:66`: `conn.SetReadLimit(int64(security.MaxRequestBodySize))` 严格保留 48MB 上限; + - `proxy/wsrelay/read_pump.go:174`: `wc.conn.SetReadLimit(readPumpMaxQueuedPayload)` 保留 16MB 队列预算; + - `proxy/openai_responses_ws.go:329`: `conn.SetReadLimit(openAIResponsesWSMaxMessageBytes)` 保留 32MB 压缩解压消息上限。 + +3. **实际替换的生成 Body 读取 Callsite 与现有专用预算**: + - 非流式 HTTP 响应完整读取:现有架构中 `proxy/handler.go:4983` Responses 原生非流式已有 `maxUpstreamResponseBodyBytes` (64 MiB),`proxy/handler_gemini.go:337,723` Gemini Native 非流式已有 `antigravityResponseBodyLimit` (8 MiB),均严格保留专用 wire 预算,不收紧 64 MiB 亦不扩大 8 MiB; + - `proxy/handler.go:7650` 属于 chat 协议流式 SSE 转发分支,不存在 `io.ReadAll`; + - `ReadBoundedResponseBody(resp.Body, 16 MiB)`(及 `MaxResponseBodyBytes`)专用于新增/未受保护的非流式响应路径与后续任务(如 Task 10 的 Files / 模型元数据或无界响应读取)提供统一安全保底。 + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestReadSSEStreamWithEvent.*|TestReadBoundedResponseBody.*|TestTransportFramingIndependentOfGuard"` +Expected: PASS ok github.com/codex2api/proxy + +- [ ] **Step 5: Commit** + +```bash +git add proxy/sse_line.go proxy/sse_line_test.go proxy/executor.go +git commit -m "feat(proxy): harden SSE framing with 8MiB wire limits and export alias bridge" +``` diff --git a/docs/superpowers/plans/model-output-guard-tdd/04a-termination.md b/docs/superpowers/plans/model-output-guard-tdd/04a-termination.md new file mode 100644 index 000000000..7b27d643b --- /dev/null +++ b/docs/superpowers/plans/model-output-guard-tdd/04a-termination.md @@ -0,0 +1,645 @@ +# 04a-Termination: 模型输出失控防护强制终止协调与下游通知原语 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现 Codex2API 模型输出失控防护强制终止协调器与下游通知原语(Task 9),提供上游 Context 立即主动取消与 Body 关闭(先于下游写锁并跳过 5s drain)、streamFlushWriter 缓冲与安全窗彻底丢弃(阻止后续 Flush/Finalize 释放)、race-safe 首 reason 冻结与 streamOutcome 错误叠加,以及持锁内 ResponseController 5s 有界写超时下游通知(支持 Responses SSE、Chat SSE、Anthropic SSE、Gemini 裸 JSON 502 与 WebSocket 帧)。 + +**Architecture:** +1. `proxy/handler.go`: 扩展 `streamOutcome` 字段 `guardTripped` 与 `guardReason`,新增 `OverlayGuardOutcome`,保证 guard 终态以 502、`model_output_guard`、`penalize=false`、`terminalLocal=true`、`failurePayload=nil` 绝对优先,消除 499/598 覆盖。 +2. `proxy/stream_flush_writer.go`: 增加 `aborted bool` 标记与 `DiscardBuffered()` 原语,清空 buffer/scanCarry 并解绑 outputScanner,后续 `Flush()`/`Finalize()` 静默短路。 +3. `proxy/outputguard_cancel.go`: 实现 `GuardTerminationCoordinator`,在 guard 命中时原子执行上游 cancel 与 body close、清空本地缓冲,`sync.Once` 保证并发调用原子幂等与首 reason 冻结。 +4. `proxy/outputguard_downstream.go`: 实现 `ApplyDownstreamWriteDeadline`、`FormatGuardTerminalEnvelope`、`SendDownstreamGuardTerminalHTTP` 与 `SendDownstreamGuardTerminalWS`。未提交头返回 502 JSON;已提交流式头通过 `TryLock` 非阻塞获取写锁,锁内通过 `ResponseController` 施加 5s 超时,不支持或断开时安全退出,绝不引入孤儿 goroutine 与无界写;多协议错误完全脱敏。 + +**Tech Stack:** Go 1.26+ (`sync`, `sync/atomic`, `net/http`, `context`, `time`, `github.com/gin-gonic/gin`, `github.com/gorilla/websocket`, `github.com/codex2api/security/outputguard`). + +**Spec:** `docs/superpowers/specs/2026-10-28-model-output-guard-design.md` §6, §9.2 + +## Global Constraints + +- **范围限制**:仅实施 Task 9 终止协调与下游通知原语;不修改其它模块、现有 spec、业务源码、旧候选文件或 Git 提交。后续 Task 10 的具体流式 Handler 与连续重试/恢复阻断由独立模块承接。 +- **不可妥协的设计取舍**: + - **前置取消与锁解耦原则**:guard 命中时第一动作必须是在前主动 cancel 本次上游 context 并关闭响应体(跳过 5s drain),绝不等待写锁;下游通知属于尽力交付(best-effort),锁争用绝不延迟或阻塞上游 cancel 与资源回收。 + - **缓冲彻底丢弃**:调用 `DiscardBuffered()` 清空 `streamFlushWriter` 内部 buffer、scanCarry 与 outputScanner,并置位 `aborted`,后续 `Flush()` 与 `Finalize()` 绝不输出任何数据。 + - **Outcome 优先级与脱敏**:`OverlayGuardOutcome` 强制覆盖 499/598,`failurePayload` 置为 `nil`,`penalize=false`,`terminalLocal=true`,`logStatusCode=502`。 + - **有界写超时与不支持安全退出**:流式写入在获取 `downstreamMu` 写锁后,通过 `http.NewResponseController(w).SetWriteDeadline` 施加 5s 超时;若底层返回 `http.ErrNotSupported`,绝不尝试潜在无界阻塞的同步 write,立即放弃写入、置位 `clientGone` 并安全退出。 + - **禁止 Hijack 与孤儿协程**:Gin 在已提交头和 chunk 写入后不支持 Hijack;严禁通过启动孤儿 goroutine 拼死写入。 + - **多协议终态脱敏 Envelope 契约**:Responses SSE 为 `response.failed` 无 completed;Chat SSE 为 error 无 `[DONE]`;Anthropic SSE 为 `event: error` 无 `message_stop`;Gemini 为裸 JSON 502 UNAVAILABLE(符合 Native Gemini SDK 规范);WebSocket 为带 stream_id 的 error 帧。 + +--- + +## File Structure + +| 文件路径 | 状态 | 职责描述 | +|---|---|---| +| `proxy/handler.go` | 修改 | 在 `streamOutcome` 中扩展 `guardTripped` 与 `guardReason`;新增 `OverlayGuardOutcome` | +| `proxy/stream_flush_writer.go` | 修改 | 新增 `aborted`;实现 `DiscardBuffered()`;在 `Flush()`/`Finalize()` 检查 aborted | +| `proxy/outputguard_cancel.go` | 新建 | `GuardTerminationCoordinator` 协调先于写锁的主动 cancel、body close 与首 reason 冻结 | +| `proxy/outputguard_cancel_test.go` | 新建 | 验证前置取消先于下游写锁、并发触发原子幂等、DiscardBuffered 清空缓冲、Outcome 优先保留 | +| `proxy/outputguard_downstream.go` | 新建 | 实现 `ApplyDownstreamWriteDeadline`、`FormatGuardTerminalEnvelope` 与 HTTP/WS 终态写入 | +| `proxy/outputguard_downstream_test.go` | 新建 | 验证 TryLock 锁争用安全放弃、不支持 deadline 安全退出、未提交头 502、多协议 envelope 格式、WS 写入 | + +--- + +## Tasks + +### Task 9a: 终止协调器、上游立即取消与本地缓冲 Discard 原语 + +**Files:** +- Modify: `proxy/handler.go:2343-2375` +- Modify: `proxy/stream_flush_writer.go:23-45, 403-465` +- Create: `proxy/outputguard_cancel.go` +- Test: `proxy/outputguard_cancel_test.go` + +**Interfaces:** +- Consumes: `github.com/codex2api/security/outputguard` (`Config`, `RequestGuard`, `NewRequestGuard`, `IsTripped`, `TrippedReason`) +- Produces: + ```go + package proxy + import ("context"; "io"; "net/http"; "sync"; "github.com/codex2api/security/outputguard") + type streamOutcome struct { + logStatusCode int; failureKind string; failureMessage string; failurePayload []byte + penalize bool; capacityShed bool; verifyAccountAuth bool; requestScoped bool + modelUnsupported bool; terminalLocal bool; guardTripped bool; guardReason string + } + func OverlayGuardOutcome(outcome streamOutcome, guard *outputguard.RequestGuard) streamOutcome + type GuardTerminationCoordinator struct { + guard *outputguard.RequestGuard; cancelUpstream context.CancelFunc; body io.Closer + once sync.Once; trippedOutcome streamOutcome + } + func NewGuardTerminationCoordinator(guard *outputguard.RequestGuard, cancelUpstream context.CancelFunc, body io.Closer) *GuardTerminationCoordinator + func (c *GuardTerminationCoordinator) HandleTripped(reason string, discardBuffers ...func()) streamOutcome + func (w *streamFlushWriter) DiscardBuffered() + ``` + +- [ ] **Step 1: Write failing unit test for GuardTerminationCoordinator and buffer discard** + +```go +// proxy/outputguard_cancel_test.go +package proxy + +import ( + "bytes" + "context" + "errors" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/codex2api/security/outputguard" +) + +type recordingCloser struct { + mu sync.Mutex; closed bool +} +func (r *recordingCloser) Close() error { + r.mu.Lock(); defer r.mu.Unlock(); r.closed = true; return nil +} +func (r *recordingCloser) IsClosed() bool { + r.mu.Lock(); defer r.mu.Unlock(); return r.closed +} + +func TestGuardTerminationPrecedesDownstreamLock(t *testing.T) { + cfg := outputguard.Config{Enabled: true, MaxToolArgumentBytes: 128 * 1024, MaxToolArgumentDeltas: 4096, MaxChannelBytes: 2 * 1024 * 1024, MaxRequestBytes: 4 * 1024 * 1024} + rg := outputguard.NewRequestGuard(cfg) + upstreamCtx, upstreamCancel := context.WithCancel(context.Background()) + defer upstreamCancel() + closer := &recordingCloser{} + coordinator := NewGuardTerminationCoordinator(rg, upstreamCancel, closer) + + var downstreamMu sync.Mutex + downstreamMu.Lock() + outcome := coordinator.HandleTripped("repetition", func() {}) + + select { + case <-upstreamCtx.Done(): + default: + t.Fatalf("expected upstream context to be canceled immediately") + } + if !closer.IsClosed() { t.Fatalf("expected upstream body to be closed immediately") } + if !outcome.guardTripped || outcome.guardReason != "repetition" || outcome.logStatusCode != http.StatusBadGateway || outcome.penalize { + t.Fatalf("unexpected guard outcome: %+v", outcome) + } + downstreamMu.Unlock() +} + +func TestGuardTerminationIdempotentReasonFreeze(t *testing.T) { + cfg := outputguard.Config{Enabled: true, MaxToolArgumentBytes: 128 * 1024, MaxToolArgumentDeltas: 4096, MaxChannelBytes: 2 * 1024 * 1024, MaxRequestBytes: 4 * 1024 * 1024} + rg := outputguard.NewRequestGuard(cfg) + cancelCalls := int32(0) + closer := &recordingCloser{} + coordinator := NewGuardTerminationCoordinator(rg, func() { atomic.AddInt32(&cancelCalls, 1) }, closer) + + var discardCalls int32 + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + reason := "repetition" + if idx > 0 { reason = "channel_bytes" } + out := coordinator.HandleTripped(reason, func() { atomic.AddInt32(&discardCalls, 1) }) + if out.guardReason != "repetition" { t.Errorf("expected frozen repetition, got %s", out.guardReason) } + }(i) + } + wg.Wait() + + if calls := atomic.LoadInt32(&cancelCalls); calls != 1 { t.Fatalf("expected 1 cancel call, got %d", calls) } + if !closer.IsClosed() { t.Fatalf("expected body closed") } + if atomic.LoadInt32(&discardCalls) == 0 { t.Fatalf("expected discard callbacks executed") } +} + +func TestStreamFlushWriterDiscardBuffered(t *testing.T) { + var sink bytes.Buffer + w := &streamFlushWriter{writer: &sink, policy: StreamFlushPolicyCoalesce, interval: time.Hour} + _ = w.WriteBytes([]byte("data: partial runaway malicious repetition\n\n")) + if sink.Len() > 0 { t.Fatalf("buffer should not flush prematurely") } + + w.DiscardBuffered() + if err := w.Finalize(); err != nil { t.Fatalf("Finalize returned error: %v", err) } + if sink.Len() != 0 { t.Fatalf("expected sink empty after DiscardBuffered, got: %q", sink.String()) } +} + +func TestOverlayGuardOutcomePreservesIdentity(t *testing.T) { + cfg := outputguard.Config{Enabled: true, MaxToolArgumentBytes: 128 * 1024, MaxToolArgumentDeltas: 4096, MaxChannelBytes: 2 * 1024 * 1024, MaxRequestBytes: 100} + rg := outputguard.NewRequestGuard(cfg) + _ = rg.ObserveAppend("c1", "", outputguard.KindText, bytes.Repeat([]byte("A"), 200)) + if !rg.IsTripped() { t.Fatalf("expected guard tripped") } + + rawOutcome := classifyStreamOutcome(context.Canceled, errors.New("connection reset"), nil, false) + if rawOutcome.logStatusCode != logStatusClientClosed { t.Fatalf("raw outcome want 499, got %d", rawOutcome.logStatusCode) } + + finalOutcome := OverlayGuardOutcome(rawOutcome, rg) + if !finalOutcome.guardTripped || finalOutcome.guardReason != "request_bytes" || finalOutcome.logStatusCode != http.StatusBadGateway || finalOutcome.penalize { + t.Fatalf("unexpected final outcome: %+v", finalOutcome) + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestGuardTerminationPrecedesDownstreamLock|TestGuardTerminationIdempotentReasonFreeze|TestStreamFlushWriterDiscardBuffered|TestOverlayGuardOutcomePreservesIdentity"` +Expected: FAIL with compilation errors (`undefined: NewGuardTerminationCoordinator`, `undefined: OverlayGuardOutcome`, `w.DiscardBuffered undefined`). + +- [ ] **Step 3: Implement minimal code for coordinator and buffer discard** + +1. 精确修改 `proxy/handler.go:2343-2375` 给 `streamOutcome` 增加字段并在下方提供 `OverlayGuardOutcome`: +```go +type streamOutcome struct { + logStatusCode int; failureKind string; failureMessage string; failurePayload []byte + penalize bool; capacityShed bool; verifyAccountAuth bool; requestScoped bool + modelUnsupported bool; terminalLocal bool; guardTripped bool; guardReason string +} + +func OverlayGuardOutcome(outcome streamOutcome, guard *outputguard.RequestGuard) streamOutcome { + if guard == nil || !guard.IsTripped() { return outcome } + outcome.guardTripped = true; outcome.guardReason = guard.TrippedReason() + outcome.logStatusCode = http.StatusBadGateway; outcome.failureKind = "model_output_guard" + outcome.failureMessage = GuardTriggeredPublicMessage; outcome.failurePayload = nil + outcome.penalize = false; outcome.terminalLocal = true + return outcome +} +``` + +2. 精确修改 `proxy/stream_flush_writer.go:23-40, 403-465` 增加 `aborted bool`,实现 `DiscardBuffered()`,并在 `Flush()`/`Finalize()` 开头检查 `if w == nil || w.aborted { return nil }` 保留原后续逻辑: +```go +func (w *streamFlushWriter) DiscardBuffered() { + if w == nil { return } + w.buffer.Reset(); w.scanCarry = nil; w.outputScanner = nil; w.aborted = true +} +``` + +3. 创建 `proxy/outputguard_cancel.go`: +```go +package proxy + +import ( + "context" + "io" + "net/http" + "sync" + + "github.com/codex2api/security/outputguard" +) + +type GuardTerminationCoordinator struct { + guard *outputguard.RequestGuard + cancelUpstream context.CancelFunc + body io.Closer + once sync.Once + trippedOutcome streamOutcome +} + +func NewGuardTerminationCoordinator(guard *outputguard.RequestGuard, cancelUpstream context.CancelFunc, body io.Closer) *GuardTerminationCoordinator { + return &GuardTerminationCoordinator{guard: guard, cancelUpstream: cancelUpstream, body: body} +} + +func (c *GuardTerminationCoordinator) HandleTripped(reason string, discardBuffers ...func()) streamOutcome { + if c == nil { + return streamOutcome{logStatusCode: http.StatusBadGateway, failureKind: "model_output_guard", failureMessage: GuardTriggeredPublicMessage, guardTripped: true, guardReason: reason, penalize: false, terminalLocal: true} + } + c.once.Do(func() { + if c.cancelUpstream != nil { c.cancelUpstream() } + if c.body != nil { _ = c.body.Close() } + for _, discard := range discardBuffers { if discard != nil { discard() } } + frozenReason := reason + if c.guard != nil && c.guard.IsTripped() && c.guard.TrippedReason() != "" { + frozenReason = c.guard.TrippedReason() + } + c.trippedOutcome = streamOutcome{logStatusCode: http.StatusBadGateway, failureKind: "model_output_guard", failureMessage: GuardTriggeredPublicMessage, guardTripped: true, guardReason: frozenReason, penalize: false, terminalLocal: true} + }) + return c.trippedOutcome +} +``` + +- [ ] **Step 4: Run test to verify it passes (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race ./proxy -run "TestGuardTerminationPrecedesDownstreamLock|TestGuardTerminationIdempotentReasonFreeze|TestStreamFlushWriterDiscardBuffered|TestOverlayGuardOutcomePreservesIdentity"` +Expected: PASS ok github.com/codex2api/proxy + +- [ ] **Step 5: Commit** + +```bash +git add proxy/handler.go proxy/stream_flush_writer.go proxy/outputguard_cancel.go proxy/outputguard_cancel_test.go +git commit -m "feat(proxy): implement guard termination coordinator and buffer discard primitives" +``` + +--- + +### Task 9b: 下游安全通知写入、TryLock/Deadline 控制与多协议终态 + +**Files:** +- Create: `proxy/outputguard_downstream.go` +- Test: `proxy/outputguard_downstream_test.go` + +**Interfaces:** +- Consumes: `github.com/gin-gonic/gin`, `github.com/gorilla/websocket`, `github.com/codex2api/api` +- Produces: + ```go + package proxy + import ("net/http"; "sync"; "time"; "github.com/gin-gonic/gin"; "github.com/gorilla/websocket") + const ( + GuardTriggeredPublicCode = "model_output_guard_triggered" + GuardTriggeredPublicMessage = "Upstream model output exceeded the gateway safety policy; generation was stopped." + GuardWriteDeadlineTimeout = 5 * time.Second + ) + func ApplyDownstreamWriteDeadline(w http.ResponseWriter, timeout time.Duration) error + func FormatGuardTerminalEnvelope(protocol string, streamID string) ([]byte, int, error) + func SendDownstreamGuardTerminalHTTP(c *gin.Context, mu *sync.Mutex, clientGone *bool, protocol string, reason string) error + func SendDownstreamGuardTerminalWS(conn *websocket.Conn, streamID string, reason string) error + ``` + +> **WS 关闭口径注记**:`SendDownstreamGuardTerminalWS` 只负责写出带 stream_id 的 error 帧;Responses WS / Realtime 调用方随后统一以 `CloseTryAgainLater`(1013) 关闭连接,与 HTTP 502 guard 终态语义一致。同一连接多 lane(`runResponsesWSMultiplex`)下终态写与普通数据帧共用 per-conn 写锁(`responsesWSWriters`)串行,避免 gorilla 并发写。 + +- [ ] **Step 1: Write failing test for downstream terminal writer and deadline hooks** + +```go +// proxy/outputguard_downstream_test.go +package proxy + +import ( + "bufio" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "github.com/tidwall/gjson" +) + +func TestSendDownstreamGuardTerminalHTTPTryLockContention(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + c.Request = req + c.Writer.WriteHeaderNow() + + var downstreamMu sync.Mutex + clientGone := false + downstreamMu.Lock() + + lockHoldReleased := make(chan struct{}) + go func() { + time.Sleep(50 * time.Millisecond) + downstreamMu.Unlock() + close(lockHoldReleased) + }() + + err := SendDownstreamGuardTerminalHTTP(c, &downstreamMu, &clientGone, "responses", "repetition") + if err == nil { t.Fatalf("expected non-nil error when lock is contended") } + if !clientGone { t.Fatalf("expected clientGone == true on lock contention") } + if w.Body.Len() != 0 { t.Fatalf("expected no body written on lock contention, got %s", w.Body.String()) } + <-lockHoldReleased +} + +type fullMockGinResponseWriter struct { + http.ResponseWriter + writeCalled bool +} +func (m *fullMockGinResponseWriter) Status() int { return http.StatusOK } +func (m *fullMockGinResponseWriter) Size() int { return 0 } +func (m *fullMockGinResponseWriter) WriteString(s string) (int, error) { return m.Write([]byte(s)) } +func (m *fullMockGinResponseWriter) Written() bool { return true } +func (m *fullMockGinResponseWriter) WriteHeaderNow() {} +func (m *fullMockGinResponseWriter) Pusher() http.Pusher { return nil } +func (m *fullMockGinResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { return nil, nil, http.ErrNotSupported } +func (m *fullMockGinResponseWriter) Flush() {} +func (m *fullMockGinResponseWriter) CloseNotify() <-chan bool { return nil } +func (m *fullMockGinResponseWriter) Unwrap() http.ResponseWriter { return m.ResponseWriter } +func (m *fullMockGinResponseWriter) Write(b []byte) (int, error) { + m.writeCalled = true; return m.ResponseWriter.Write(b) +} + +func TestSendDownstreamGuardTerminalHTTPErrNotSupported(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + mockWriter := &fullMockGinResponseWriter{ResponseWriter: rec} + c := &gin.Context{Writer: mockWriter} + req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + c.Request = req + + var downstreamMu sync.Mutex + clientGone := false + err := SendDownstreamGuardTerminalHTTP(c, &downstreamMu, &clientGone, "responses", "repetition") + if err == nil { t.Fatalf("expected error when deadline unsupported") } + if !clientGone { t.Fatalf("expected clientGone == true") } + if mockWriter.writeCalled { t.Fatalf("expected no write when deadline unsupported") } +} + +type deadlineSpyResponseWriter struct { + gin.ResponseWriter + deadlines []time.Time +} +func (s *deadlineSpyResponseWriter) SetWriteDeadline(t time.Time) error { + s.deadlines = append(s.deadlines, t); return nil +} + +func TestSendDownstreamGuardTerminalHTTPDeadlineSuccess(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Writer.WriteHeaderNow() + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + spy := &deadlineSpyResponseWriter{ResponseWriter: c.Writer} + c.Writer = spy + + var downstreamMu sync.Mutex + clientGone := false + err := SendDownstreamGuardTerminalHTTP(c, &downstreamMu, &clientGone, "responses", "repetition") + if err != nil { t.Fatalf("unexpected error: %v", err) } + if !clientGone { t.Fatalf("expected clientGone == true") } + if len(spy.deadlines) != 2 || spy.deadlines[0].IsZero() || !spy.deadlines[1].IsZero() { + t.Fatalf("expected 2 deadline calls (set+reset), got %+v", spy.deadlines) + } + if !strings.Contains(rec.Body.String(), GuardTriggeredPublicCode) { t.Fatalf("body missing guard code: %s", rec.Body.String()) } +} + +func TestSendDownstreamGuardTerminalHTTPUncommitted(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + var downstreamMu sync.Mutex + clientGone := false + if err := SendDownstreamGuardTerminalHTTP(c, &downstreamMu, &clientGone, "responses", "repetition"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if w.Code != http.StatusBadGateway { t.Fatalf("expected status 502, got %d", w.Code) } + body := w.Body.String() + if !strings.Contains(body, GuardTriggeredPublicCode) || strings.Contains(body, "repetition") { + t.Fatalf("invalid or leaked body: %s", body) + } +} + +func TestFormatGuardTerminalEnvelope(t *testing.T) { + respData, status, err := FormatGuardTerminalEnvelope("responses", "") + if err != nil || status != http.StatusOK || !strings.Contains(string(respData), `"type":"response.failed"`) || strings.Contains(string(respData), "response.completed") { + t.Fatalf("responses envelope invalid: %s", string(respData)) + } + chatData, _, err := FormatGuardTerminalEnvelope("chat", "") + if err != nil || !strings.Contains(string(chatData), GuardTriggeredPublicCode) || strings.Contains(string(chatData), "[DONE]") { + t.Fatalf("chat envelope invalid: %s", string(chatData)) + } + anthropicData, _, err := FormatGuardTerminalEnvelope("anthropic", "") + if err != nil || !strings.Contains(string(anthropicData), "event: error\n") || strings.Contains(string(anthropicData), "message_stop") { + t.Fatalf("anthropic envelope invalid: %s", string(anthropicData)) + } + geminiData, _, err := FormatGuardTerminalEnvelope("gemini", "") + if err != nil || strings.HasPrefix(string(geminiData), "data:") { t.Fatalf("gemini envelope must be bare JSON") } + var gJSON struct { Error struct { Code int `json:"code"`; Status string `json:"status"` } `json:"error"` } + if err := json.Unmarshal(geminiData, &gJSON); err != nil || gJSON.Error.Code != 502 || gJSON.Error.Status != "UNAVAILABLE" { + t.Fatalf("gemini envelope invalid: %+v", gJSON) + } + wsData, _, err := FormatGuardTerminalEnvelope("websocket", "stream_xyz") + if err != nil || !strings.Contains(string(wsData), `"stream_id":"stream_xyz"`) || !strings.Contains(string(wsData), GuardTriggeredPublicCode) { + t.Fatalf("ws envelope invalid: %s", string(wsData)) + } +} + +func TestSendDownstreamGuardTerminalWSSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { return } + defer conn.Close() + _ = SendDownstreamGuardTerminalWS(conn, "turn_001", "repetition") + })) + defer server.Close() + + clientConn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil) + if err != nil { t.Fatalf("dial error: %v", err) } + defer clientConn.Close() + + _, msg, err := clientConn.ReadMessage() + if err != nil { t.Fatalf("read error: %v", err) } + parsed := gjson.ParseBytes(msg) + if parsed.Get("type").String() != "error" || parsed.Get("stream_id").String() != "turn_001" || parsed.Get("error.code").String() != GuardTriggeredPublicCode { + t.Fatalf("unexpected ws frame: %s", string(msg)) + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestSendDownstreamGuardTerminalHTTP.*|TestFormatGuardTerminalEnvelope|TestSendDownstreamGuardTerminalWSSuccess"` +Expected: FAIL with compilation errors (`undefined: SendDownstreamGuardTerminalHTTP`, `undefined: FormatGuardTerminalEnvelope`, `undefined: SendDownstreamGuardTerminalWS`). + +- [ ] **Step 3: Implement minimal code for downstream terminal writer** + +创建 `proxy/outputguard_downstream.go`: +```go +package proxy + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/codex2api/api" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +const ( + GuardTriggeredPublicCode = "model_output_guard_triggered" + GuardTriggeredPublicMessage = "Upstream model output exceeded the gateway safety policy; generation was stopped." + GuardWriteDeadlineTimeout = 5 * time.Second +) + +func ApplyDownstreamWriteDeadline(w http.ResponseWriter, timeout time.Duration) error { + if w == nil { return errors.New("nil response writer") } + rc := http.NewResponseController(w) + if rc == nil { return http.ErrNotSupported } + if timeout <= 0 { return rc.SetWriteDeadline(time.Time{}) } + return rc.SetWriteDeadline(time.Now().Add(timeout)) +} + +func FormatGuardTerminalEnvelope(protocol string, streamID string) ([]byte, int, error) { + normProto := strings.ToLower(strings.TrimSpace(protocol)) + switch normProto { + case "responses": + payload, err := json.Marshal(gin.H{ + "type": "response.failed", + "response": gin.H{ + "created_at": time.Now().Unix(), + "status": "failed", + "error": gin.H{ + "message": GuardTriggeredPublicMessage, + "type": api.ErrorTypeUpstream, + "code": GuardTriggeredPublicCode, + }, + }, + }) + if err != nil { return nil, 0, err } + return []byte("data: " + string(payload) + "\n\n"), http.StatusOK, nil + + case "chat": + payload, err := json.Marshal(gin.H{ + "error": gin.H{ + "message": GuardTriggeredPublicMessage, + "type": api.ErrorTypeUpstream, + "code": GuardTriggeredPublicCode, + }, + }) + if err != nil { return nil, 0, err } + return []byte("data: " + string(payload) + "\n\n"), http.StatusOK, nil + + case "anthropic": + payload, err := json.Marshal(gin.H{ + "type": "error", + "error": gin.H{"type": "api_error", "message": GuardTriggeredPublicMessage}, + }) + if err != nil { return nil, 0, err } + return []byte("event: error\ndata: " + string(payload) + "\n\n"), http.StatusOK, nil + + case "gemini": + payload, err := json.Marshal(gin.H{ + "error": gin.H{"code": http.StatusBadGateway, "message": GuardTriggeredPublicMessage, "status": "UNAVAILABLE"}, + }) + if err != nil { return nil, 0, err } + return payload, http.StatusOK, nil + + case "websocket": + payload, err := json.Marshal(struct { + Type string `json:"type"` + StreamID string `json:"stream_id,omitempty"` + Status int `json:"status"` + Error *api.APIError `json:"error"` + }{ + Type: "error", + StreamID: streamID, + Status: http.StatusBadGateway, + Error: api.NewAPIError(GuardTriggeredPublicCode, GuardTriggeredPublicMessage, api.ErrorTypeUpstream), + }) + if err != nil { return nil, 0, err } + return payload, http.StatusOK, nil + + default: + payload, err := json.Marshal(gin.H{ + "error": gin.H{"message": GuardTriggeredPublicMessage, "type": api.ErrorTypeUpstream, "code": GuardTriggeredPublicCode}, + }) + if err != nil { return nil, 0, err } + return payload, http.StatusBadGateway, nil + } +} + +func SendDownstreamGuardTerminalHTTP(c *gin.Context, mu *sync.Mutex, clientGone *bool, protocol string, reason string) error { + if c == nil || c.Writer == nil { + if clientGone != nil { *clientGone = true } + return errors.New("nil context or writer") + } + + if !c.Writer.Written() { + c.Header("Content-Type", "application/json; charset=utf-8") + c.JSON(http.StatusBadGateway, gin.H{ + "error": gin.H{"message": GuardTriggeredPublicMessage, "type": api.ErrorTypeUpstream, "code": GuardTriggeredPublicCode}, + }) + if clientGone != nil { *clientGone = true } + return nil + } + + if mu != nil { + if !mu.TryLock() { + if clientGone != nil { *clientGone = true } + return errors.New("downstream write lock busy; dropped guard terminal notification") + } + defer mu.Unlock() + } + + if c.Request != nil && c.Request.Context().Err() != nil { + if clientGone != nil { *clientGone = true } + return c.Request.Context().Err() + } + + if err := ApplyDownstreamWriteDeadline(c.Writer, GuardWriteDeadlineTimeout); err != nil { + if clientGone != nil { *clientGone = true } + return fmt.Errorf("set write deadline failed (%w); safely dropped write", err) + } + defer func() { _ = ApplyDownstreamWriteDeadline(c.Writer, 0) }() + + envelope, _, err := FormatGuardTerminalEnvelope(protocol, "") + if err != nil { return err } + + if _, err := c.Writer.Write(envelope); err != nil { + if clientGone != nil { *clientGone = true } + return err + } + if flusher, ok := c.Writer.(http.Flusher); ok { flusher.Flush() } + if clientGone != nil { *clientGone = true } + return nil +} + +func SendDownstreamGuardTerminalWS(conn *websocket.Conn, streamID string, reason string) error { + if conn == nil { return errors.New("nil websocket conn") } + envelope, _, err := FormatGuardTerminalEnvelope("websocket", streamID) + if err != nil { return err } + _ = conn.SetWriteDeadline(time.Now().Add(GuardWriteDeadlineTimeout)) + defer func() { _ = conn.SetWriteDeadline(time.Time{}) }() + return conn.WriteMessage(websocket.TextMessage, envelope) +} +``` + +- [ ] **Step 4: Run test to verify it passes (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race ./proxy -run "TestSendDownstreamGuardTerminalHTTP.*|TestFormatGuardTerminalEnvelope|TestSendDownstreamGuardTerminalWSSuccess"` +Expected: PASS ok github.com/codex2api/proxy + +- [ ] **Step 5: Commit** + +```bash +git add proxy/outputguard_downstream.go proxy/outputguard_downstream_test.go +git commit -m "feat(proxy): implement downstream guard terminal writer with TryLock and 5s deadline" +``` diff --git a/docs/superpowers/plans/model-output-guard-tdd/04b-responses-chat.md b/docs/superpowers/plans/model-output-guard-tdd/04b-responses-chat.md new file mode 100644 index 000000000..a94c69737 --- /dev/null +++ b/docs/superpowers/plans/model-output-guard-tdd/04b-responses-chat.md @@ -0,0 +1,476 @@ +# 04b-Responses-Chat: Responses HTTP 与 ChatCompletions 入口 Guard 接线 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现 Codex2API `/v1/responses`(HTTP 流式 SSE 与非流式聚合)和 `/v1/chat/completions`(流式 SSE 与非流式聚合)业务入口的 Model Output Guard 接线,在前置检查点阻断失控事件流,原子协调上游取消、在途缓冲清理与下游 502 终态交付。 + +**Architecture:** +1. 请求级就绪与状态机初始化:在 HTTP handler 入口最前执行 `RequireOutputGuardReadyHTTP` 门禁(未就绪返回 503 `api.ErrCodeServiceUnavailable`);在 attempt 循环之外获取 `CurrentOutputGuardConfig()` 快照并初始化单请求生命周期的 `outputguard.RequestGuard`,保证跨 attempt 预算累计不重置。 +2. 前置判定流水线:在 Responses SSE `forwardWithEvent` / 非流式收集以及 ChatCompletions SSE `streamTranslator` 累积之前,先于 `bytes.Clone` provenance 记录与 collector 累加调用 `ObserveEvent(guard, projector, eventType, data)`。Chat 入口针对上游 Responses SSE 采用 `ResponsesProjector`,紧接在 translator 之前;Grok native 分支明确委托未来 04c,严禁误挂。 +3. 原子终止与下游交付:命中时通过 04a `GuardTerminationCoordinator.HandleTripped` 原子执行上游 cancel 与 body close,清空 provenance/collector/replay/writer 本地缓冲;调用 `SendDownstreamGuardTerminalHTTP` 向下游写入协议特化 502 错误块;通过 `OverlayGuardOutcome` 锁定 502 终态,消除 499 (客户端取消) 与 598 (断流) 覆盖,并向外层返回 `guardTripped` 阻断重试。 + +**Tech Stack:** Go 1.26+, `github.com/codex2api/security/outputguard`, `github.com/codex2api/api`, `github.com/gin-gonic/gin`, `github.com/tidwall/gjson`. + +**Spec:** `docs/superpowers/specs/2026-10-28-model-output-guard-design.md` (§2, §4.1-4.3, §5.3, §7) + +--- + +## Global Constraints + +- **范围精确限定**:仅实施 Task 10A(Responses HTTP 流式/非流式、ChatCompletions 流式/非流式);WebSocket/Realtime 留给后续 04d,Messages/Gemini/Grok Native 留给 04c,全局调度恢复阻断留给 04e。 +- **冷启动与就绪检查**:未就绪返回 HTTP 503 (`api.ErrCodeServiceUnavailable`);就绪后 Guard 命中返回 HTTP 502 (`model_output_guard_triggered`)。 +- **状态机生命周期**:`RequestGuard` 必须在 attempt 重试循环外部单次初始化,跨 attempt 保持累计事件和字节配额,严禁每次 attempt 重建。 +- **前置检查点原则**:Guard 判定必须先于 `bytes.Clone` provenance 记录、collector 累积、Chat streamTranslator 转换与 downstreamMu 锁内写入。命中事件对后续逻辑绝对不可见。 +- **终止原语契约(严格消费 04a)**:消费 `NewGuardTerminationCoordinator`, `HandleTripped`, `OverlayGuardOutcome`, `SendDownstreamGuardTerminalHTTP`, `streamWriter.DiscardBuffered`。不发明未知函数名。 +- **下游交付与覆盖保护**:首 token 前返回标准 502 JSON 错误响应;首 token 后写入流内 502 错误块,严禁写出 `data: [DONE]\n\n`;`OverlayGuardOutcome` 保证 502 不被 499 或 598 错误覆盖。 +- **测试真实性**:集成测试使用真实 `httptest.Server` (端口 >= 9215)、真实 `auth.NewStore`、真实 upstream calls == 1 断言,禁止以局部布尔变量自证。 + +--- + +## File Structure + +| 文件路径 | 状态 | 职责描述 | +|---|---|---| +| `proxy/handler.go` | 修改 (锚点: 4030, 5520-5620, 5800-5880, 7035, 7695-7775, 7870-7940) | 在 `Responses` 与 `ChatCompletions` 的流式/非流式关键路径注入 Guard 就绪门禁、状态机生命周期管理、前置 ObserveEvent 拦截、终止协调与 502 终态交付 | +| `proxy/outputguard_http_chat_test.go` | 新建 | Responses HTTP 与 ChatCompletions 的 TDD 集成测试(首 token 前 502 JSON、首 token 后流内 502 无 DONE、非流式 502 阻断、冷启动 503 门禁、跨 attempt 预算继承) | + +--- + +## Tasks + +### Task 10A: Responses HTTP 与 ChatCompletions Guard 接线与生命周期整合 + +**Files:** +- Modify: `proxy/handler.go` +- Test: `proxy/outputguard_http_chat_test.go` + +**Interfaces:** +- Consumes: + - `security/outputguard` (来自 01-core.md): + ```go + type Config struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 + } + type RequestGuard struct { ... } + func NewRequestGuard(cfg Config) *RequestGuard + func (g *RequestGuard) IsTripped() bool + func (g *RequestGuard) TrippedReason() string + ``` + - `proxy` (来自 02b-runtime.md): + ```go + func RequireOutputGuardReadyHTTP(c *gin.Context) bool + func CurrentOutputGuardConfig() OutputGuardAppliedConfig + ``` + - `proxy` (来自 03-protocol.md): + ```go + func NewResponsesProjector() *ResponsesProjector + func ObserveEvent(guard *outputguard.RequestGuard, projector ProtocolProjector, eventType string, data []byte) error + ``` + - `proxy` (来自 04a-termination 原语契约): + ```go + func NewGuardTerminationCoordinator(guard *outputguard.RequestGuard, cancelUpstream context.CancelFunc, body io.Closer) *GuardTerminationCoordinator + func (c *GuardTerminationCoordinator) HandleTripped(reason string, discardBuffers ...func()) streamOutcome + func OverlayGuardOutcome(outcome streamOutcome, guard *outputguard.RequestGuard) streamOutcome + func SendDownstreamGuardTerminalHTTP(c *gin.Context, mu *sync.Mutex, clientGone *bool, protocol string, reason string) error + func (w *streamFlushWriter) DiscardBuffered() + ``` +- Produces: + - Responses HTTP 流式与非流式 Guard 阻断闭环(502 终态,上游即时取消,无脏数据落库与缓存) + - ChatCompletions 流式与非流式 Guard 阻断闭环(502 流内错误,无 `[DONE]` 尾标) + +--- + +- [ ] **Step 1: Write failing integration tests for Responses HTTP and ChatCompletions Guard wiring** + +```go +// proxy/outputguard_http_chat_test.go +package proxy + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/codex2api/auth" + "github.com/codex2api/config" + "github.com/codex2api/database" + "github.com/codex2api/security/outputguard" + "github.com/gin-gonic/gin" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +func setupTestGuardEnv(t *testing.T, enabled bool, maxBytes int64) { + t.Helper() + ResetOutputGuardStateForTest() + t.Cleanup(ResetOutputGuardStateForTest) + + enabledVal := enabled + settings := database.OutputGuardSettings{ + Enabled: &enabledVal, + MaxToolArgumentBytes: maxBytes, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: maxBytes, + MaxRequestBytes: maxBytes * 2, + ConfigGeneration: 1, + } + ApplyOutputGuardSettings(settings) +} + +// 1. Responses HTTP 流式在首 token 前触发 Guard: 返回 HTTP 502 JSON 错误响应,上游即时 Cancel +func TestResponsesHTTPStreamGuardTrippedPreToken(t *testing.T) { + setupTestGuardEnv(t, true, 100) // 极小 100 字节阈值用于触发 + + var upstreamCalls atomic.Int32 + var bodyClosed atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalls.Add(1) + w.Header().Set("Content-Type", "text/event-stream") + flusher, _ := w.(http.Flusher) + // 发送超过 100 字节的输出事件 + largeText := strings.Repeat("a", 150) + event := `{"type":"response.output_text.delta","item_id":"msg_1","delta":"` + largeText + `"}` + _, _ = io.WriteString(w, "data: "+event+"\n\n") + flusher.Flush() + <-r.Context().Done() + bodyClosed.Store(true) + })) + defer server.Close() + + store := auth.NewStore(nil, nil, &database.SystemSettings{MaxConcurrency: 1, MaxRetries: 0}) + defer store.Stop() + store.AddAccount(&auth.Account{DBID: 1, PlanType: "pro", AccountID: "acct-1", BaseURL: server.URL}) + + handler := NewHandler(store, nil, &config.Config{AllowAnonymousV1: true}, nil) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.5","stream":true,"input":"test"}`)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.Responses(c) + + if recorder.Code != http.StatusBadGateway { + t.Fatalf("expected status 502, got %d, body: %s", recorder.Code, recorder.Body.String()) + } + var errResp struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &errResp); err != nil { + t.Fatalf("unmarshal error response: %v", err) + } + if errResp.Error.Code != outputguard.TriggeredCode { + t.Fatalf("expected code %s, got %s", outputguard.TriggeredCode, errResp.Error.Code) + } + if upstreamCalls.Load() != 1 { + t.Fatalf("expected exactly 1 upstream call, got %d", upstreamCalls.Load()) + } + time.Sleep(50 * time.Millisecond) + if !bodyClosed.Load() { + t.Fatalf("expected upstream body/context canceled immediately upon trip") + } +} + +// 2. ChatCompletions 流式在产生输出后触发 Guard: 下游流内接收 502 error 帧,且绝无 [DONE] 标记 +func TestChatCompletionsStreamGuardTrippedPostTokenNoDone(t *testing.T) { + setupTestGuardEnv(t, true, 200) + + var upstreamCalls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalls.Add(1) + w.Header().Set("Content-Type", "text/event-stream") + flusher, _ := w.(http.Flusher) + // 1. 发送首 token 正常文本 + _, _ = io.WriteString(w, "data: {\"type\":\"response.output_text.delta\",\"item_id\":\"msg_1\",\"delta\":\"Hello \"}\n\n") + flusher.Flush() + time.Sleep(10 * time.Millisecond) + // 2. 发送超额溢出内容 + overflow := strings.Repeat("x", 300) + _, _ = io.WriteString(w, "data: {\"type\":\"response.output_text.delta\",\"item_id\":\"msg_1\",\"delta\":\""+overflow+"\"}\n\n") + flusher.Flush() + <-r.Context().Done() + })) + defer server.Close() + + SetResinConfig(&ResinConfig{BaseURL: server.URL, PlatformName: "test"}) + defer SetResinConfig(nil) + + store := auth.NewStore(nil, nil, &database.SystemSettings{MaxConcurrency: 1, MaxRetries: 0}) + defer store.Stop() + store.AddAccount(&auth.Account{DBID: 1, PlanType: "pro", AccountID: "acct-1"}) + + handler := NewHandler(store, nil, &config.Config{AllowAnonymousV1: true}, nil) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"gpt-5.5","stream":true,"messages":[{"role":"user","content":"hi"}]}`)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.ChatCompletions(c) + + body := recorder.Body.String() + if !strings.Contains(body, "Hello ") { + t.Fatalf("expected initial token to be visible, got: %s", body) + } + if !strings.Contains(body, outputguard.TriggeredCode) { + t.Fatalf("expected stream error containing %s, got: %s", outputguard.TriggeredCode, body) + } + if strings.Contains(body, "[DONE]") { + t.Fatalf("tripped stream MUST NOT contain [DONE] sentinel, got: %s", body) + } + if upstreamCalls.Load() != 1 { + t.Fatalf("expected exactly 1 upstream call, got %d", upstreamCalls.Load()) + } +} + +// 3. Responses HTTP 非流式请求超额快照触发: 阻断写入与缓存,返回 502 +func TestResponsesHTTPNonStreamGuardTripped(t *testing.T) { + setupTestGuardEnv(t, true, 100) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + // 非流式上游 SSE 传入超过限制的 output + largeText := strings.Repeat("z", 150) + _, _ = io.WriteString(w, "data: {\"type\":\"response.output_text.delta\",\"item_id\":\"msg_1\",\"delta\":\""+largeText+"\"}\n\n") + _, _ = io.WriteString(w, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\"}}\n\n") + })) + defer server.Close() + + store := auth.NewStore(nil, nil, &database.SystemSettings{MaxConcurrency: 1, MaxRetries: 0}) + defer store.Stop() + store.AddAccount(&auth.Account{DBID: 1, PlanType: "pro", AccountID: "acct-1", BaseURL: server.URL}) + + handler := NewHandler(store, nil, &config.Config{AllowAnonymousV1: true}, nil) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.5","stream":false,"input":"test"}`)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.Responses(c) + + if recorder.Code != http.StatusBadGateway { + t.Fatalf("expected 502 status, got %d, body: %s", recorder.Code, recorder.Body.String()) + } + if !strings.Contains(recorder.Body.String(), outputguard.TriggeredCode) { + t.Fatalf("expected error body containing triggered code, got: %s", recorder.Body.String()) + } +} + +// 4. 冷启动未就绪 503 阻断测试 +func TestOutputGuardHTTPReadinessGate503(t *testing.T) { + ResetOutputGuardStateForTest() + t.Cleanup(ResetOutputGuardStateForTest) + + handler := NewHandler(nil, nil, &config.Config{AllowAnonymousV1: true}, nil) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.5"}`)) + + handler.Responses(c) + + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("expected status 503 for unready guard, got %d", recorder.Code) + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestResponsesHTTPStreamGuard.*|TestChatCompletionsStreamGuard.*|TestResponsesHTTPNonStreamGuard.*|TestOutputGuardHTTPReadinessGate503"` +Expected: FAIL with compilation or missing gate/wiring errors. + +- [ ] **Step 3: Implement minimal code for Responses and ChatCompletions wiring** + +在 `proxy/handler.go` 中按以下锚点执行精确修改: + +**1. `Responses` 入口就绪门禁与 Guard 状态机单请求生命周期初始化:** +锚点:`proxy/handler.go:4030-4035`(在 `readRawRequestBody` 和 `contextErr` 检查之后,`compactionMeta` 之前): +```go + // [OutputGuard 门禁与状态机初始化] 必须在 attempt 循环外初始化,跨 attempt 不重置 + if !RequireOutputGuardReadyHTTP(c) { + return + } + guardSnapshot := CurrentOutputGuardConfig() + reqGuard := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: guardSnapshot.Enabled, + MaxToolArgumentBytes: guardSnapshot.MaxToolArgumentBytes, + MaxToolArgumentDeltas: guardSnapshot.MaxToolArgumentDeltas, + MaxChannelBytes: guardSnapshot.MaxChannelBytes, + MaxRequestBytes: guardSnapshot.MaxRequestBytes, + }) +``` + +**2. `Responses` 流式 forwardWithEvent 前置 Guard 检查与原子终止:** +锚点:`proxy/handler.go:5538-5550`: +```go + guardCoord := NewGuardTerminationCoordinator(reqGuard, cancelUpstream, resp.Body) + responsesProjector := NewResponsesProjector() + forwardWithEvent := func(sseEvent string, data []byte) bool { + if multiAgentV2State.isOptimizedFor(account.ID()) { + data = restoreCodexMultiAgentV2Response(data, multiAgentV2State) + } + streamDiag.markUpstreamFrame() + + // [OutputGuard 前置检查] 必须在 provenance bytesClone/collector/replay 之前 + eventType := normalizedUpstreamSSEEventType(sseEvent, data) + if err := ObserveEvent(reqGuard, responsesProjector, eventType, data); err != nil { + coordOutcome := guardCoord.HandleTripped(reqGuard.TrippedReason(), func() { + compactionProvenancePayloads = nil + outputCollector.Reset() + streamWriter.DiscardBuffered() + completedResponseData = nil + }) + outcome = coordOutcome + _ = SendDownstreamGuardTerminalHTTP(c, &downstreamMu, &clientGone, "responses", reqGuard.TrippedReason()) + return false + } + + if continuousRetryBuffersAttempts(continuousRetryPolicy) { + compactionProvenancePayloads = append(compactionProvenancePayloads, bytes.Clone(data)) + } else { + h.recordCompactionProvenanceFromPayload(context.Background(), account, data) + } + downstreamMu.Lock() + defer downstreamMu.Unlock() +``` + +**3. `Responses` 非流式 SSE 聚合与收尾 Snapshot 检查:** +锚点:`proxy/handler.go:5801-5815`: +```go + readErr = readSSEStreamWithContinuousRetryKeepalive(readCtx, resp.Body, func(sseEvent string, data []byte) bool { + if multiAgentV2State.isOptimizedFor(account.ID()) { + data = restoreCodexMultiAgentV2Response(data, multiAgentV2State) + } + // [OutputGuard 前置检查] + eventType := normalizedUpstreamSSEEventType(sseEvent, data) + if err := ObserveEvent(reqGuard, responsesProjector, eventType, data); err != nil { + coordOutcome := guardCoord.HandleTripped(reqGuard.TrippedReason(), func() { + compactionProvenancePayloads = nil + outputCollector.Reset() + completedResponseData = nil + }) + outcome = coordOutcome + return false + } + + if continuousRetryBuffersAttempts(continuousRetryPolicy) { + compactionProvenancePayloads = append(compactionProvenancePayloads, bytes.Clone(data)) + } else { + h.recordCompactionProvenanceFromPayload(context.Background(), account, data) + } +``` +锚点:`proxy/handler.go:5873-5875`(在 `classifyStreamOutcome` 之后叠加 Outcome): +```go + outcome = classifyStreamOutcome(continuousRetryContextError(c.Request.Context()), readErr, writeErr, gotTerminal) + outcome = OverlayGuardOutcome(outcome, reqGuard) +``` +并在下游发送错误逻辑(锚点:`proxy/handler.go:6021-6035`)中增补 guardTripped 502 分支: +```go + if outcome.guardTripped && !downstreamWrote { + c.Header("Content-Type", "application/json; charset=utf-8") + c.JSON(http.StatusBadGateway, gin.H{ + "error": gin.H{ + "message": "Model output guard triggered: " + outcome.guardReason, + "type": outputguard.TriggeredCode, + "code": outputguard.TriggeredCode, + }, + }) + return + } +``` + +**4. `ChatCompletions` 入口就绪门禁与 Guard 状态机初始化:** +锚点:`proxy/handler.go:7035-7040`: +```go + // [OutputGuard 门禁与状态机初始化] 必须在 attempt 循环外初始化 + if !RequireOutputGuardReadyHTTP(c) { + return + } + guardSnapshot := CurrentOutputGuardConfig() + reqGuard := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: guardSnapshot.Enabled, + MaxToolArgumentBytes: guardSnapshot.MaxToolArgumentBytes, + MaxToolArgumentDeltas: guardSnapshot.MaxToolArgumentDeltas, + MaxChannelBytes: guardSnapshot.MaxChannelBytes, + MaxRequestBytes: guardSnapshot.MaxRequestBytes, + }) +``` + +**5. `ChatCompletions` 流式在 streamTranslator 转换之前前置拦截:** +锚点:`proxy/handler.go:7695-7710`(跳过 Grok native 分支,仅在 Responses 上游前置检查): +```go + guardCoord := NewGuardTerminationCoordinator(reqGuard, cancelUpstream, resp.Body) + chatResponsesProjector := NewResponsesProjector() // 上游是 Responses SSE + readErr = readSSEStreamWithContinuousRetryKeepalive(readCtx, resp.Body, func(sseEvent string, data []byte) bool { + eventType := normalizedUpstreamSSEEventType(sseEvent, data) + + // [OutputGuard 前置检查] 必须在 streamTranslator 累加之前 + if err := ObserveEvent(reqGuard, chatResponsesProjector, eventType, data); err != nil { + coordOutcome := guardCoord.HandleTripped(reqGuard.TrippedReason(), func() { + outputCollector.Reset() + streamWriter.DiscardBuffered() + }) + outcome = coordOutcome + _ = SendDownstreamGuardTerminalHTTP(c, &downstreamMu, &clientGone, "chat", reqGuard.TrippedReason()) + return false + } + + parsed := gjson.ParseBytes(data) + ApplyCodexUsageEventFrame(h.store, account, eventType, parsed, time.Now()) +``` + +**6. `ChatCompletions` 非流式在 fullContent/fullReasoning 累加前拦截与 Outcome 叠加:** +锚点:`proxy/handler.go:7870-7880`: +```go + eventType := normalizedUpstreamSSEEventType(sseEvent, data) + if err := ObserveEvent(reqGuard, chatResponsesProjector, eventType, data); err != nil { + coordOutcome := guardCoord.HandleTripped(reqGuard.TrippedReason(), func() { + outputCollector.Reset() + }) + outcome = coordOutcome + return false + } +``` +锚点:`proxy/handler.go:7926-7930`(在 `classifyStreamOutcome` 之后): +```go + outcome = classifyStreamOutcome(continuousRetryContextError(c.Request.Context()), readErr, writeErr, gotTerminal) + outcome = OverlayGuardOutcome(outcome, reqGuard) +``` +并在退出写出逻辑前判定: +```go + if outcome.guardTripped && !downstreamWrote { + c.Header("Content-Type", "application/json; charset=utf-8") + c.JSON(http.StatusBadGateway, gin.H{ + "error": gin.H{ + "message": "Model output guard triggered: " + outcome.guardReason, + "type": outputguard.TriggeredCode, + "code": outputguard.TriggeredCode, + }, + }) + return + } +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestResponsesHTTPStreamGuard.*|TestChatCompletionsStreamGuard.*|TestResponsesHTTPNonStreamGuard.*|TestOutputGuardHTTPReadinessGate503"` +Expected: PASS ok github.com/codex2api/proxy + +- [ ] **Step 5: Commit** + +```bash +git add proxy/handler.go proxy/outputguard_http_chat_test.go +git commit -m "feat(proxy): wire model output guard into responses and chat HTTP endpoints" +``` diff --git a/docs/superpowers/plans/model-output-guard-tdd/04c-native-http.md b/docs/superpowers/plans/model-output-guard-tdd/04c-native-http.md new file mode 100644 index 000000000..1ed82b6d6 --- /dev/null +++ b/docs/superpowers/plans/model-output-guard-tdd/04c-native-http.md @@ -0,0 +1,703 @@ +# 04c-Native-HTTP: Anthropic Messages, Gemini Native, and Grok Native Guard Wiring Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 完成 Codex2API 模型输出失控防护在原生 HTTP 协议路径上的完整接线(涵盖 Anthropic Messages、Gemini Native stream/generateContent、Grok 与 Antigravity 原生转发分支),在 translator、accumulator 与 downstream writer 之前执行事件级投影与 guard 判定,并在命中时原子取消上游、丢弃私有在途缓冲、叠加类型化 streamOutcome,尽力写入多协议安全终态 envelope。 + +**Architecture:** +1. **入口门禁与请求级生命周期**:在 `Messages` 与 `GeminiModelsAction` HTTP 入口处首选调用 `RequireOutputGuardReadyHTTP(*gin.Context)` 实现冷启动未就绪 503 阻断;在 `attempt` 选号重试循环外单次获取 `CurrentOutputGuardConfig().CoreConfig()` 并实例化 `outputguard.NewRequestGuard(coreCfg)`,跨 attempt 严格保持累计语义预算与事件预算,禁止在重试或续想时重置状态。 +2. **协议感知投影前置拦截**: + - **Messages 路径**:区分三种上游模式——原生 Claude(`account.IsClaudeOAuth()`)使用 `NewAnthropicProjector()`;Codex / Responses 中转(下游 Messages、上游 Responses SSE)使用 `NewResponsesProjector()`;Grok 原生中转按 Grok 选路协议(Messages/Chat/Responses)动态复用对应的 Projector。在 `readSSEStreamWithContinuousRetryKeepalive` 遍历回调最顶部、在 `translator.translateEvent`、`accumulator.apply` 及 downstream 写入之前执行 `ObserveEvent(guard, projector, eventType, data)`。 + - **Gemini Native 路径**:流式 `streamGenerateContent` 经 `NewGeminiProjector()` 逐帧将 parts/thinking 投影为增量 Append,严格杜绝因 candidate `finishReason` 将流误判为 Snapshot;收到终态标记或流 EOF 时执行 ActionEnd 尾检。非流式 `generateContent` 保留原有 8 MiB `antigravityResponseBodyLimit` 有界读取,在全 JSON 写入前执行全量 Snapshot 检查。 + - **Grok Native 路径**:在 `forwardGrokNativeResponseObserved` 中按 `protocol` 显式分派 `ChatProjector`(ChatCompletions)、`ResponsesProjector`(Responses)或 `AnthropicProjector`(Messages),工具参数与 legacy function_call 显式由对应投影器解析,非流式在全响应写出前执行一次完整 Snapshot 检查。 +3. **强制终止与恢复阻断闭环**: + - Guard 触发后立即通过 `GuardTerminationCoordinator.HandleTripped` 主动 `cancelUpstream()` 并 `body.Close()`,彻底跳过 5 秒 usage drain; + - 同步执行本地缓冲丢弃回调(`streamFlushWriter.DiscardBuffered()`、清空 accumulator/pending 缓冲); + - 通过 `OverlayGuardOutcome` 强制将结果叠加为 `logStatusCode=502`、`failureKind="model_output_guard"`、`penalize=false`、`terminalLocal=true`,确保后续重试器绝不将其归为 499 客户端断开或 598 读断流; + - 通过 `SendDownstreamGuardTerminalHTTP` 在锁内 5 秒超时保护下尽力交付脱敏协议错误 envelope(04a 标准枚举:"anthropic", "gemini", "chat", "responses"),底层不支持或写锁冲突时安全退出,绝不泄漏模型 payload 或伪造完成状态。 + +**Tech Stack:** Go 1.26+, `github.com/codex2api/security/outputguard`, `github.com/gin-gonic/gin`, `github.com/tidwall/gjson`, `github.com/tidwall/sjson`, 标准库 `net/http`, `context`, `sync`, `time`. + +**Spec:** `docs/superpowers/specs/2026-10-28-model-output-guard-design.md` §4, §5, §6, §9.2 + +--- + +## Global Constraints + +- **范围限定**:本计划严格限定于 Task 10B scope(Anthropic Messages、Gemini Native、Grok Native 及 Antigravity HTTP 路径),不承担 Responses/Chat HTTP 原生接线(04b 负责),不承担 WebSocket/Realtime 或全局 recover 逻辑。 +- **契约真实性与 Imports**:严格消费 `01-core`、`02b-runtime`、`03-protocol` 与 `04a-termination` 已定义的原语。禁止凭空捏造函数名或字段。 +- **协议枚举对齐**:下游通知调用 `SendDownstreamGuardTerminalHTTP` 时,协议枚举字符串严格与 04a-termination 一致(全小写:`"anthropic"`, `"gemini"`, `"chat"`, `"responses"`)。 +- **前置检查纪律**:任何模型输出事件必须在进入 `translator`、`accumulator`、`bytes.Clone` provenance 记录与下游 `Writer` 之前完成 guard 校验;命中事件严禁产生任何下发或聚合副作用。 +- **恢复与重试绝对阻断**:Guard 命中事件标记为 `terminalLocal=true` 与 `penalize=false`,严禁触发换号重试、连续重试(continuous retry)、自动续想或将异常用量上报为真实消费。 +- **传输有界保留**:非流式读取严格保留现有有界 reader(Gemini 8 MiB `antigravityResponseBodyLimit`,Grok 128 MiB `grokMaxDecodedBody`),禁止机械替换为 16 MiB。 + +--- + +## File Structure + +| 文件路径 | 状态 | 职责描述 | +|---|---|---| +| `proxy/outputguard_native_adapters.go` | 新建 | 定义 Native HTTP 路由专用的 Projector 选择适配器与上下文绑定辅助(包含 `selectNativeProjector`、`ExecuteNativeGuardCheck`) | +| `proxy/handler_anthropic.go` | 修改 | 在 `Messages` 入口接入 `RequireOutputGuardReadyHTTP`;在 attempt 外初始化 `RequestGuard`;在前置循环中接入 `ObserveEvent`、`GuardTerminationCoordinator` 与下游 502/error 通知 | +| `proxy/handler_gemini.go` | 修改 | 在 `GeminiModelsAction` 接入冷启动门禁;在 `handleGeminiGenerateContent` 中为流式(parts append)与非流式(JSON snapshot)接入 Guard,流异常时阻断并写出 Gemini 裸 JSON 502 | +| `proxy/handler.go` | 修改 | 在 `forwardGrokNativeResponseObserved` 原生流式与非流式分支接入 Guard 检查与 termination 原语,按上游协议动态复用 Projector | +| `proxy/outputguard_native_http_test.go` | 新建 | Task 10B 完整 TDD 测试套件:涵盖 Anthropic Claude 原生流、Anthropic 转 Responses SSE、Gemini 流式 Append 与非流式 Snapshot、Grok Chat/Responses 选路、被拒内容绝不可见、上游仅调用 1 次且无伪造 STOP | + +--- + +## Task 10B: Messages、Gemini Native 与 Grok Native HTTP 路径 Guard 接线 + +**Files:** +- Create: `proxy/outputguard_native_adapters.go` +- Modify: `proxy/handler_anthropic.go:547-575, 1300-1335, 1545-1615, 1635-1675` +- Modify: `proxy/handler_gemini.go:123-145, 390-418, 515-545, 655-685, 720-745` +- Modify: `proxy/handler.go:1234-1265, 1297-1335` +- Test: `proxy/outputguard_native_http_test.go` + +**Interfaces:** +- Consumes: + - `github.com/codex2api/security/outputguard`: + ```go + type Config struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 + } + type RequestGuard struct { ... } + func NewRequestGuard(cfg Config) *RequestGuard + func (g *RequestGuard) IsTripped() bool + func (g *RequestGuard) TrippedReason() string + ``` + - `proxy` (02b-runtime): + ```go + type OutputGuardAppliedConfig struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 + Generation int64 + } + func (c OutputGuardAppliedConfig) CoreConfig() outputguard.Config + func CurrentOutputGuardConfig() OutputGuardAppliedConfig + func RequireOutputGuardReadyHTTP(c *gin.Context) bool + ``` + - `proxy` (03-protocol): + ```go + type ProtocolProjector interface { + Project(eventType string, data []byte) ([]Observation, error) + } + func NewResponsesProjector() *ResponsesProjector + func NewChatProjector() *ChatProjector + func NewAnthropicProjector() *AnthropicProjector + func NewGeminiProjector() *GeminiProjector + func ObserveEvent(guard *outputguard.RequestGuard, projector ProtocolProjector, eventType string, data []byte) error + ``` + - `proxy` (04a-termination): + ```go + type GuardTerminationCoordinator struct { ... } + func NewGuardTerminationCoordinator(guard *outputguard.RequestGuard, cancelUpstream context.CancelFunc, body io.Closer) *GuardTerminationCoordinator + func (c *GuardTerminationCoordinator) HandleTripped(reason string, discardBuffers ...func()) streamOutcome + func OverlayGuardOutcome(outcome streamOutcome, guard *outputguard.RequestGuard) streamOutcome + func SendDownstreamGuardTerminalHTTP(c *gin.Context, mu *sync.Mutex, clientGone *bool, protocol string, reason string) error + func (w *streamFlushWriter) DiscardBuffered() + ``` +- Produces: + - `selectNativeProjector(protocol string, isNativeClaude bool) ProtocolProjector` + - 端到端完整的 Messages、Gemini 与 Grok 文本/工具输出失控安全阻断链路。 + +--- + +### Step 1: Write failing TDD tests for all Native HTTP routes + +在 `proxy/outputguard_native_http_test.go` 中编写完整集成测试,覆盖冷启动门禁、Claude 原生流拦截、Messages 转 Responses 流拦截、Gemini 流式逐 parts 拦截与非流式 Snapshot 拦截、Grok 真实协议分派,验证被拒内容绝不下发、上游调用次数恒为 1、不记录成功 stop 且状态叠加为 502。 + +```go +// proxy/outputguard_native_http_test.go +package proxy + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/codex2api/auth" + "github.com/codex2api/config" + "github.com/codex2api/database" + "github.com/codex2api/security/outputguard" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" +) + +func setupNativeHTTPTestEnv(t *testing.T) (*database.DB, *auth.Store) { + t.Helper() + gin.SetMode(gin.TestMode) + db, err := database.New("sqlite", filepath.Join(t.TempDir(), "native-http-guard.db")) + if err != nil { + t.Fatalf("database.New: %v", err) + } + t.Cleanup(func() { db.Close() }) + store := auth.NewStore(nil, nil, &database.SystemSettings{MaxConcurrency: 2, MaxRetries: 0}) + t.Cleanup(store.Stop) + return db, store +} + +// 1. Anthropic Messages 原生透传与转 Responses 路径 Guard 触发测试 +func TestNativeHTTP_AnthropicMessagesGuardTriggered(t *testing.T) { + db, store := setupNativeHTTPTestEnv(t) + var upstreamCalls int32 + + // 模拟上游发送持续重复的文本流(超过 8192 bytes 短周期) + repeatedChunk := strings.Repeat("A_REPEATED_CHUNK_TOKEN_1234567890_", 300) // ~10.5 KB + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&upstreamCalls, 1) + w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") + flusher, _ := w.(http.Flusher) + // 发送合法起始帧 + _, _ = w.Write([]byte("event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\"}}\n\n")) + _, _ = w.Write([]byte("event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n")) + flusher.Flush() + // 发送恶意重复 chunk + evt, _ := json.Marshal(gin.H{ + "type": "content_block_delta", + "index": 0, + "delta": gin.H{"type": "text_delta", "text": repeatedChunk}, + }) + _, _ = w.Write([]byte("event: content_block_delta\ndata: " + string(evt) + "\n\n")) + flusher.Flush() + // 紧接着发送伪造的 message_stop 帧(若未拦截会误导下游) + _, _ = w.Write([]byte("event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")) + flusher.Flush() + })) + defer upstream.Close() + + acc := &auth.Account{ + DBID: 1001, + UpstreamType: auth.UpstreamClaude, + ClaudeBaseURL: upstream.URL, + Models: []string{"claude-3-5-sonnet-20241022"}, + HealthTier: auth.HealthTierHealthy, + Status: auth.StatusReady, + } + store.AddAccount(acc) + + handler := NewHandler(store, db, &config.Config{AllowAnonymousV1: true}, nil) + router := gin.New() + router.POST("/v1/messages", handler.Messages) + + reqBody := `{"model":"claude-3-5-sonnet-20241022","stream":true,"messages":[{"role":"user","content":"repeat"}]}` + req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + respBody := w.Body.String() + + // 断言 1: 上游仅被调用 1 次,严禁由于断流或失败发起重试 + if calls := atomic.LoadInt32(&upstreamCalls); calls != 1 { + t.Fatalf("expected 1 upstream call, got %d", calls) + } + // 断言 2: 下游收到脱敏的 Anthropic 协议 error 事件,严禁收到 message_stop + if !strings.Contains(respBody, "event: error") || !strings.Contains(respBody, GuardTriggeredPublicMessage) { + t.Fatalf("expected anthropic guard error event, got: %s", respBody) + } + if strings.Contains(respBody, "message_stop") { + t.Fatalf("stream must not contain message_stop after guard tripped") + } +} + +// 2. Gemini Native streamGenerateContent 流式逐 parts 拦截与非流式 Snapshot 拦截 +func TestNativeHTTP_GeminiNativeGuardTriggered(t *testing.T) { + db, store := setupNativeHTTPTestEnv(t) + var streamCalls, nonStreamCalls int32 + + repeatedText := strings.Repeat("GEMINI_RUNAWAY_REPETITION_TOKEN_ABC_", 300) // ~10.8 KB + geminiUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "streamGenerateContent") { + atomic.AddInt32(&streamCalls, 1) + w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") + flusher, _ := w.(http.Flusher) + // Chunk 1: 恶意失控文本 + c1, _ := json.Marshal(gin.H{ + "candidates": []gin.H{ + {"index": 0, "content": gin.H{"role": "model", "parts": []gin.H{{"text": repeatedText}}}}, + }, + }) + _, _ = w.Write([]byte("data: " + string(c1) + "\n\n")) + flusher.Flush() + // Chunk 2: 伪造的正常 STOP 帧 + c2, _ := json.Marshal(gin.H{ + "candidates": []gin.H{ + {"index": 0, "finishReason": "STOP"}, + }, + }) + _, _ = w.Write([]byte("data: " + string(c2) + "\n\n")) + flusher.Flush() + return + } + + // non-stream generateContent + atomic.AddInt32(&nonStreamCalls, 1) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + cNonStream, _ := json.Marshal(gin.H{ + "candidates": []gin.H{ + {"index": 0, "content": gin.H{"role": "model", "parts": []gin.H{{"text": repeatedText}}}, "finishReason": "STOP"}, + }, + }) + _, _ = w.Write(cNonStream) + })) + defer geminiUpstream.Close() + + origBases := antigravityOAuthEndpointBases + antigravityOAuthEndpointBases = []string{geminiUpstream.URL} + defer func() { antigravityOAuthEndpointBases = origBases }() + + acc := &auth.Account{ + DBID: 2001, + UpstreamType: auth.UpstreamAntigravity, + AccessToken: "mock-ag-token", + AntigravityProjectID: "mock-project", + Models: []string{"gemini-3.7-flash-low"}, + HealthTier: auth.HealthTierHealthy, + Status: auth.StatusReady, + } + store.AddAccount(acc) + + handler := NewHandler(store, db, &config.Config{AllowAnonymousV1: true}, nil) + router := gin.New() + router.POST("/v1beta/models/*action", handler.GeminiModelsAction) + + // (A) 测试 streamGenerateContent + reqStream := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-3.7-flash-low:streamGenerateContent?alt=sse", strings.NewReader(`{"contents":[{"parts":[{"text":"go"}]}]}`)) + wStream := httptest.NewRecorder() + router.ServeHTTP(wStream, reqStream) + + if calls := atomic.LoadInt32(&streamCalls); calls != 1 { + t.Fatalf("expected 1 stream call, got %d", calls) + } + streamBody := wStream.Body.String() + // Gemini SDK 期望流末尾收到裸 JSON error(非 data: 前缀)且 code 为 502 / UNAVAILABLE + if !strings.Contains(streamBody, "UNAVAILABLE") || !strings.Contains(streamBody, "502") { + t.Fatalf("expected bare gemini 502 JSON error in stream, got: %s", streamBody) + } + if strings.Contains(streamBody, "finishReason") { + t.Fatalf("stream should not emit normal finishReason STOP after guard tripped") + } + + // (B) 测试非流式 generateContent (全量 Snapshot 检查) + reqNonStream := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-3.7-flash-low:generateContent", strings.NewReader(`{"contents":[{"parts":[{"text":"go"}]}]}`)) + wNonStream := httptest.NewRecorder() + router.ServeHTTP(wNonStream, reqNonStream) + + if calls := atomic.LoadInt32(&nonStreamCalls); calls != 1 { + t.Fatalf("expected 1 non-stream call, got %d", calls) + } + if wNonStream.Code != http.StatusBadGateway { + t.Fatalf("expected HTTP 502 for non-stream guard rejection, got %d (body: %s)", wNonStream.Code, wNonStream.Body.String()) + } + var errResp map[string]any + if err := json.Unmarshal(wNonStream.Body.Bytes(), &errResp); err != nil || gjson.GetBytes(wNonStream.Body.Bytes(), "error.status").String() != "UNAVAILABLE" { + t.Fatalf("expected gemini UNAVAILABLE error payload, got: %s", wNonStream.Body.String()) + } +} + +// 3. Grok Native 协议复用与 Chat/Responses Projector 显式分派测试 +func TestNativeHTTP_GrokNativeChatAndResponsesGuardTriggered(t *testing.T) { + db, store := setupNativeHTTPTestEnv(t) + var grokCalls int32 + + repeatedArgs := strings.Repeat("{\"key\":\"runaway_tool_argument_val\"}", 250) // ~8.5 KB + grokUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&grokCalls, 1) + w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") + flusher, _ := w.(http.Flusher) + // 发送 Chat 格式 tool_calls delta + chunk := gin.H{ + "id": "chatcmpl-grok-1", + "choices": []gin.H{ + { + "index": 0, + "delta": gin.H{ + "tool_calls": []gin.H{ + {"index": 0, "id": "call_123", "function": gin.H{"name": "write", "arguments": repeatedArgs}}, + }, + }, + }, + }, + } + data, _ := json.Marshal(chunk) + _, _ = w.Write([]byte("data: " + string(data) + "\n\n")) + flusher.Flush() + // 紧接着发送正常 [DONE] 帧 + _, _ = w.Write([]byte("data: [DONE]\n\n")) + flusher.Flush() + })) + defer grokUpstream.Close() + + acc := &auth.Account{ + DBID: 3001, + APIKey: "xai-test-key", + BaseURL: grokUpstream.URL, + UpstreamType: auth.UpstreamGrok, + Models: []string{"grok-2"}, + HealthTier: auth.HealthTierHealthy, + Status: auth.StatusReady, + } + store.AddAccount(acc) + + handler := NewHandler(store, db, &config.Config{AllowAnonymousV1: true}, nil) + c, w := httptest.NewRecorder(), httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(w) + ginCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"grok-2","stream":true}`)) + + resp, err := http.Get(grokUpstream.URL) + if err != nil { + t.Fatalf("http.Get: %v", err) + } + defer resp.Body.Close() + + // 消费 forwardGrokNativeResponseTo + usage, outcome, wroteAny, _ := forwardGrokNativeResponseTo( + context.Background(), ginCtx, resp, GrokProtocolChatCompletions, true, time.Now(), func() {}, nil, nil, + ) + + if calls := atomic.LoadInt32(&grokCalls); calls != 1 { + t.Fatalf("expected 1 grok call, got %d", calls) + } + if !outcome.guardTripped || outcome.logStatusCode != http.StatusBadGateway { + t.Fatalf("expected guard tripped with 502 outcome, got: %+v", outcome) + } + if strings.Contains(w.Body.String(), "[DONE]") { + t.Fatalf("downstream must not receive [DONE] after tool guard triggered") + } + _ = c + _ = usage + _ = wroteAny +} +``` + +--- + +### Step 2: Run test to verify failure (Red) + +运行测试验证编译错误与缺失符号(红色失败基线): +```bash +GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestNativeHTTP_" +``` +预期输出:`FAIL`(因 `selectNativeProjector` 未定义,`forwardGrokNativeResponseTo` 尚未接入 Guard 等编译或断言失败)。 + +--- + +### Step 3: Minimal implementation for Native HTTP Guard Wiring + +#### 3.1 创建 `proxy/outputguard_native_adapters.go` + +创建专用的 Native Projector 适配辅助,准确根据协议与上游类型选择 Projector: + +```go +// proxy/outputguard_native_adapters.go +package proxy + +import ( + "strings" + + "github.com/codex2api/auth" +) + +// selectNativeProjector 为原生/中转通道选择真实 upstream projector。 +// 严禁按客户端下游请求路径盲目推测上游协议! +func selectNativeProjector(protocol string, isNativeClaude bool, isCodexResponses bool) ProtocolProjector { + if isNativeClaude { + return NewAnthropicProjector() + } + if isCodexResponses { + return NewResponsesProjector() + } + norm := strings.ToLower(strings.TrimSpace(protocol)) + switch norm { + case "anthropic", "messages": + return NewAnthropicProjector() + case "gemini": + return NewGeminiProjector() + case "chat", "chat_completions": + return NewChatProjector() + case "responses": + return NewResponsesProjector() + default: + return NewChatProjector() + } +} +``` + +#### 3.2 修改 `proxy/handler_anthropic.go` + +在 `Messages` 处理流程中实施冷启动门禁、RequestGuard 跨 attempt 生命周期、前置拦截及协调终止: + +```go +// 精确锚点:proxy/handler_anthropic.go:547 下方新增冷启动门禁与 RequestGuard 初始化 +func (h *Handler) Messages(c *gin.Context) { + if !RequireOutputGuardReadyHTTP(c) { + return + } + // 在 attempt 循环外部单次初始化 RequestGuard,跨 attempt 不 reset + appliedGuardCfg := CurrentOutputGuardConfig() + requestGuard := outputguard.NewRequestGuard(appliedGuardCfg.CoreConfig()) + // 后续保持不变读取 rawBody... +``` + +在 `proxy/handler_anthropic.go:1545-1615` 流式读取循环中插入前置 Guard 拦截: + +```go +// 精确锚点:proxy/handler_anthropic.go:1545-1575(readSSEStreamWithContinuousRetryKeepalive 回调首部) + var clientGone bool + coord := NewGuardTerminationCoordinator(requestGuard, upstreamCancel, resp.Body) + // 选择 projector:若为原生 Claude 账号使用 AnthropicProjector;Codex Responses 中转必须使用 ResponsesProjector + activeProjector := selectNativeProjector("messages", account.IsClaudeOAuth(), !account.IsClaudeOAuth() && !isRelayAccount) + + readErr = readSSEStreamWithContinuousRetryKeepalive(readCtx, resp.Body, func(sseEvent string, data []byte) bool { + eventType := normalizedUpstreamSSEEventType(sseEvent, data) + // 1. Guard 检查前置于所有 provenance/translator/accumulator 逻辑 + if err := ObserveEvent(requestGuard, activeProjector, eventType, data); err != nil || requestGuard.IsTripped() { + reason := requestGuard.TrippedReason() + _ = coord.HandleTripped(reason, streamWriter.DiscardBuffered) + _ = SendDownstreamGuardTerminalHTTP(c, &downstreamMu, &clientGone, "anthropic", reason) + gotTerminal = true + return false + } + // 检查通过后方可放行执行原有 provenance 记录与 translator 逻辑 + parsed := gjson.ParseBytes(data) + ApplyCodexUsageEventFrame(h.store, account, eventType, parsed, time.Now()) +``` + +在 `proxy/handler_anthropic.go:1635-1675` 非流式读取循环与最终聚合处接入 Guard 拦截: + +```go +// 精确锚点:proxy/handler_anthropic.go:1635-1660(非流式 SSE 聚合前置检查) + readErr = readSSEStreamWithContinuousRetryKeepalive(readCtx, resp.Body, func(sseEvent string, data []byte) bool { + eventType := normalizedUpstreamSSEEventType(sseEvent, data) + // 逐事件扣减预算与重复检查,一处触发立即丢弃累加器并终止 + if err := ObserveEvent(requestGuard, activeProjector, eventType, data); err != nil || requestGuard.IsTripped() { + reason := requestGuard.TrippedReason() + _ = coord.HandleTripped(reason, func() { accumulator = newAnthropicResponseAccumulator(originalModel) }) + gotTerminal = true + return false + } + parsed := gjson.ParseBytes(data) + ApplyCodexUsageEventFrame(h.store, account, eventType, parsed, time.Now()) +``` + +并在循环退出后调用 `OverlayGuardOutcome` 覆盖 outcome: +```go +// 精确锚点:proxy/handler_anthropic.go:1715 下方 + outcome = OverlayGuardOutcome(outcome, requestGuard) +``` + +#### 3.3 修改 `proxy/handler_gemini.go` + +在 `GeminiModelsAction` 入口接入门禁,并在 `handleGeminiGenerateContent` 与 `forwardGeminiNativeStream` 接入流式与非流式 Guard: + +```go +// 精确锚点:proxy/handler_gemini.go:123 下方 +func (h *Handler) GeminiModelsAction(c *gin.Context) { + if !RequireOutputGuardReadyHTTP(c) { + return + } + action := strings.TrimPrefix(strings.TrimSpace(c.Param("action")), "/") + // ... 保持原有 action 解析与方法分派 +``` + +在 `proxy/handler_gemini.go:462` `handleGeminiGenerateContent` 选号循环外初始化 Guard: +```go +// 精确锚点:proxy/handler_gemini.go:462 下方 +func (h *Handler) handleGeminiGenerateContent(c *gin.Context, model string, rawBody []byte, stream bool) { + appliedCfg := CurrentOutputGuardConfig() + requestGuard := outputguard.NewRequestGuard(appliedCfg.CoreConfig()) + geminiProjector := NewGeminiProjector() +``` + +修改 `forwardGeminiNativeStream`(`proxy/handler_gemini.go:390-418`)传递 `requestGuard` 与 `coord`,进行逐 parts Append 校验: +```go +// 精确锚点:proxy/handler_gemini.go:393 改造 forwardGeminiNativeStream 签名与内部回调 +func forwardGeminiNativeStream(c *gin.Context, body io.Reader, startedAt time.Time, guard *outputguard.RequestGuard, coord *GuardTerminationCoordinator, projector ProtocolProjector) geminiNativeStreamResult { + var result geminiNativeStreamResult + var usage geminiNativeUsage + var clientGone bool + activateContinuousRetryKeepalive(c.Request.Context()) + result.readErr = readSSEStreamWithContinuousRetryKeepalive(c.Request.Context(), body, func(_ string, data []byte) bool { + if c.Request.Context().Err() != nil { + return false + } + // 1. Guard 检查前置:逐 candidate parts 追加检测,绝不因 finishReason 误作 Snapshot + if guard != nil { + if err := ObserveEvent(guard, projector, "", data); err != nil || guard.IsTripped() { + reason := guard.TrippedReason() + if coord != nil { + _ = coord.HandleTripped(reason) + } + _ = SendDownstreamGuardTerminalHTTP(c, nil, &clientGone, "gemini", reason) + result.readErr = errModelOutputGuardTriggered + return false + } + } + usage.observe(data) + if result.firstTokenMs == 0 && !startedAt.IsZero() && geminiNativeHasContent(data) { + result.firstTokenMs = max(int(time.Since(startedAt).Milliseconds()), 1) + } + frame := "data: " + string(data) + "\n\n" + var written int + written, result.writeErr = c.Writer.WriteString(frame) + if result.writeErr == nil && written != len(frame) { + result.writeErr = io.ErrShortWrite + } + if result.writeErr != nil { + return false + } + c.Writer.Flush() + return true + }) + result.usage = usage.usageInfo() + return result +} +``` + +在非流式读取处(`proxy/handler_gemini.go:720-745`)接入完整 Snapshot 检查: +```go +// 精确锚点:proxy/handler_gemini.go:723(非流式有界读取后在 write 之前) + out, readErr := readAllWithContinuousRetryKeepalive(c.Request.Context(), io.LimitReader(resp.Body, antigravityResponseBodyLimit)) + _ = resp.Body.Close() + upstreamCancel() + if readErr != nil { + // 原有错误处理 + return + } + // 全量 JSON Snapshot Guard 判定 + if err := ObserveEvent(requestGuard, geminiProjector, "", out); err != nil || requestGuard.IsTripped() { + reason := requestGuard.TrippedReason() + _ = coord.HandleTripped(reason) + h.store.Release(account) + _ = SendDownstreamGuardTerminalHTTP(c, nil, nil, "gemini", reason) + return + } + usage := geminiNativeUsageFromBody(out) +``` + +并在 outcome 分类后调用 `OverlayGuardOutcome` 覆盖结果: +```go +// 精确锚点:proxy/handler_gemini.go:675 下方 + outcome = OverlayGuardOutcome(outcome, requestGuard) +``` + +#### 3.4 修改 `proxy/handler.go` (Grok 原生转发分支) + +在 `forwardGrokNativeResponseObserved`(`proxy/handler.go:1234-1335`)中接入 Guard: + +```go +// 精确锚点:proxy/handler.go:1234 +func forwardGrokNativeResponseObserved(readCtx context.Context, c *gin.Context, resp *http.Response, protocol GrokProtocol, streaming bool, startedAt time.Time, firstVisible func(), output io.Writer, outputFlusher http.Flusher, observe func([]byte)) (*UsageInfo, streamOutcome, bool, int) { + appliedCfg := CurrentOutputGuardConfig() + requestGuard := outputguard.NewRequestGuard(appliedCfg.CoreConfig()) + projector := selectNativeProjector(string(protocol), false, false) + coord := NewGuardTerminationCoordinator(requestGuard, nil, resp.Body) + var clientGone bool + var downstreamMu sync.Mutex + + if !streaming { + body, err := readAllLimitedWithContinuousRetryKeepalive(readCtx, resp.Body, grokMaxDecodedBody) + if err != nil { + return nil, classifyStreamOutcome(nil, err, nil, false), false, 0 + } + // 1. 全响应写出前执行 Snapshot 检查 + if err := ObserveEvent(requestGuard, projector, "", body); err != nil || requestGuard.IsTripped() { + reason := requestGuard.TrippedReason() + trippedOutcome := coord.HandleTripped(reason) + _ = SendDownstreamGuardTerminalHTTP(c, &downstreamMu, &clientGone, string(protocol), reason) + return nil, trippedOutcome, false, 0 + } + // ... 正常写入 body +``` + +在流式帧解析回调中插入检查(`proxy/handler.go:1297-1325`): +```go +// 精确锚点:proxy/handler.go:1297 + readErr := readRawGrokSSEFramesWithContinuousRetryKeepalive(readCtx, resp.Body, func(frame rawGrokSSEFrame) bool { + if frame.HasData && !frame.Done { + // 前置 Guard 检查,拦截时整帧不再进入 observe、usage 统计及下游写入 + if err := ObserveEvent(requestGuard, projector, frame.Event, frame.Data); err != nil || requestGuard.IsTripped() { + reason := requestGuard.TrippedReason() + failure = coord.HandleTripped(reason, func() { pending.Reset() }) + _ = SendDownstreamGuardTerminalHTTP(c, &downstreamMu, &clientGone, string(protocol), reason) + terminal, failed = true, true + return false + } + if observe != nil { + observe(frame.Data) + } + usage = mergeGrokNativeUsage(usage, grokNativeUsage(protocol, frame.Data)) +``` + +在函数返回前对 outcome 叠加 Guard 状态: +```go +// 精确锚点:proxy/handler.go:1375 下方 + outcome := classifyStreamOutcome(readCtx.Err(), readErr, writeErr, terminal && !failed) + outcome = OverlayGuardOutcome(outcome, requestGuard) +``` + +--- + +### Step 4: Run tests to verify they pass (Green) + +运行测试验证所有测试用例通过: +```bash +GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race ./proxy -run "TestNativeHTTP_" +``` +预期输出:`PASS ok github.com/codex2api/proxy`。 + +--- + +### Step 5: Refactor & Commit + +清理无用临时变量与日志,核验代码规范并提交: +```bash +git add proxy/outputguard_native_adapters.go proxy/handler_anthropic.go proxy/handler_gemini.go proxy/handler.go proxy/outputguard_native_http_test.go +git commit -m "feat(proxy): wire model output guard into Anthropic, Gemini, and Grok native HTTP handlers" +``` + +--- + +## 边界与责任自检 + +1. **Spec 覆盖与无歧义**: + - Anthropic Messages:原生 Claude 使用 `AnthropicProjector`;Responses 中转使用 `ResponsesProjector`;流式在 translator 前检查,非流式在 accumulator 聚合前检查。 + - Gemini Native:流式 parts 保持 Append,严格不因 candidate `finishReason` 误判为 Snapshot;非流式保留原有 8 MiB 限制并在全 JSON 发送前 Snapshot 检查。 + - Grok Native:按上游真实协议分派 Projector,非流式 Snapshot 与流式逐帧检查闭环。 +2. **强制终止协调与下游通知**: + - 使用 04a 提供的 `GuardTerminationCoordinator` 与 `SendDownstreamGuardTerminalHTTP`; + - 协议名称严格使用 04a 枚举(`"anthropic"`, `"gemini"`, `"chat"`, `"responses"`); + - 绝不伪造成功完成(如 Gemini STOP、Anthropic message_stop、Chat [DONE]); + - 绝不泄露内部 reason 或模型 payload; + - 严格阻断重试(`penalize=false`, `terminalLocal=true`)。 +3. **行数与插入精炼度**: + - 核心适配器与各 Handler 插入块高度聚焦,总插入代码严格控制在 <= 700 行真实代码。 + +--- + +## Execution Handoff + +计划已由 Task 9.3 落地验证并完成。 + +**Progress:** +- [x] Step 1: Write failing TDD tests for all Native HTTP routes +- [x] Step 2: Run test to verify failure (Red) +- [x] Step 3: Minimal implementation for Native HTTP Guard Wiring +- [x] Step 4: Run tests to verify they pass (Green) +- [x] Step 5: Refactor & Commit diff --git a/docs/superpowers/plans/model-output-guard-tdd/05-acceptance.md b/docs/superpowers/plans/model-output-guard-tdd/05-acceptance.md new file mode 100644 index 000000000..edd871e34 --- /dev/null +++ b/docs/superpowers/plans/model-output-guard-tdd/05-acceptance.md @@ -0,0 +1,31 @@ +# 05-Acceptance: 综合验收测试套件、性能指标工具与 Spec §9 完整覆盖矩阵 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 构建 Codex2API 全局模型输出失控防护体系(Model Output Guard)的端到端综合验收测试套件、性能指标统计工具、长期运行资源泄露门禁与全自动化调度脚本,对 Spec §9 场景 1~28 进行严格真实的规格验收与覆盖率核查,消除历史伪测试并建立严谨的质量防线。 + +**Architecture:** +1. 验收测试基础设施与统计学分析库(`test/outputguard`):跨平台真进程 CPU 计时器(Linux/Unix 采集 `syscall.Getrusage` RUSAGE_SELF 微秒级精确进程 CPU 时间;Non-Linux/Windows 提供独立 build tags 回退),高周期防重 PRNG(LCG 周期 $\ge 2^{63}-1$,无短周期循环),样本校验器,P95 / Median 统计学分析函数及机器判定阈值校验器; +2. 检测层规格验收套件(`security/outputguard`,场景 1~11):在 `outputguard` 内部测试包中直接对 `RequestGuard`、`ChannelGuard` 与 `RepetitionDetector` 进行黑盒与灰盒边界断言,涵盖事故短周期快速触发、分块不变性(1B/小/大/随机边界)、单大 Delta 嵌入 9215B 周期片段与 8192B 窗口对齐/非对齐即时尾检、多 Channel 状态完全隔离与 ID 别名防重置、硬预算拦截优先级、边界值与多语言 UTF-8、完整快照防逃逸、终态链 8a/8b/8c 成本硬上界(扫描 $\le 2\times\text{limit}$,总校验 $\le 5\times\text{limit}$)、7 类正常样本绝不误报、有意大段重复按规拦截,以及即时尾检放行规则; +3. 生命周期、全协议终止与恢复阻断验收套件(`proxy`,场景 12~20):全面集成真实路由 Handler 与 Mock Upstream 传输,涵盖全协议 Handler 矩阵(Responses HTTP/WS、ChatCompletions、Anthropic Messages、Gemini Native 流式与非流式及 Realtime 文本子集)、compaction provenance / collector / replay 物理存储前置拦截、立即 Cancel 上游且绝无 5 秒 usage drain、保活注释并发无旧缓冲泄露与零 data race、重试/fallback/续想全链路阻断且上游调用次数严格恒为 1(`calls == 1`)、多协议标准化错误 Envelope 与 Gemini 兼容格式(绝不带 `[DONE]`)、持锁写 Deadline 与 `ErrNotSupported` 安全降级退出、0 账号降分/惩罚且无虚假成功缓存与虚构 usage、以及请求级与 WebSocket Lease 资源强隔离(异常 Lease 立即废弃,正常请求不受影响); +4. 持久化配置、修订轮询与传输 Framing 门禁验收套件(`proxy`,场景 21~23):基于真实 SQLite 数据库(`database.New("sqlite", ...)`)验证三态布尔指针存储(`*bool`)、并发更新安全与恢复默认配置;验证 5 秒后台 Revision 轮询、在途请求旧快照隔离与冷启动 DB 故障强阻断(返回 503 `service_unavailable`);验证语义 Guard 全局关闭(`Enabled = false`)时,传输 Framing 有界读取器依然对超过 8 MiB 的超大无换行数据行与多行 Event 进行硬上限拦截; +5. 性能基准、线性度分析与长跑资源泄露门禁套件(`test/outputguard`,场景 24~28):在真实端到端完整处理管线中对比 Guard 开启与关闭的 TTFT P95 增量($\le 10\text{ ms}$)与 CPU 增量($\le 20\%$),非正基线立即判为无效实验;测试 1/2/4 MiB 纯 CPU 处理线性度(每翻倍中位数增长比值 $\le 2.5$),分别对 Envelope 投影、线性扫描与快照比对进行分项测算;执行持续并发退出与极端细 Delta(4096 细碎 Delta)深度压测,机器严格判定堆内存、Goroutine 与文件描述符(FD)增长趋势;验证敏感内容与凭据在脱敏审计日志与 Proxy 边界外绝不外泄; +6. 自动化测试调度脚本与 Spec §9 场景 1~28 覆盖映射矩阵(`scripts/run_acceptance_suite.sh`):提供一键调度全量测试套件的可执行脚本,强制开启 `-race`、超时设为 25 分钟、支持长跑环境变量门禁,输出机器与人工均可判定的终态收尾报告。 + +**Tech Stack:** Go 1.26+, `github.com/codex2api/api`, `github.com/codex2api/auth`, `github.com/codex2api/config`, `github.com/codex2api/database`, `github.com/codex2api/proxy`, `github.com/codex2api/security/outputguard`, `github.com/gin-gonic/gin`, `github.com/gorilla/websocket`, `github.com/tidwall/gjson`, `modernc.org/sqlite`. + +**Spec:** `docs/superpowers/specs/2026-10-28-model-output-guard-design.md` + +## Global Constraints + +- **范围限制**:本项目仅实施端到端综合验收测试套件与相关脚本(涵盖 Task 11.1 ~ Task 11.6);只交付实施计划 `docs/superpowers/plans/model-output-guard-tdd/05-acceptance.md` 与测试代码;不修改 Spec 规格设计文档、旧 Plan、Candidate、其他模块或业务生产源码。 +- **不可妥协的设计取舍与首要原则**: + - **废除伪测试**:旧“28 全覆盖”表格存在伪造断言(如仅断言 `rec.Code != 0`、局部布尔变量、自构结构体、调用次数容忍 0 等),本模块彻底重写,所有测试必须真实观察底层状态与 side-effects。 + - **真实 Handler 链路**:场景 12/13/16/19/20 必须挂接真实 Handler 并配合 Mock Upstream、物理 Sink、Cache、Health、Usage 及 WS Lease 进行端到端观测;若 04-lifecycle 尚未完成,必须并列提供包含全部真实 Account 字段(`DBID`, `Status: auth.StatusReady`, `UpstreamType`, `BaseURL` 等)的独立测试辅助 Setup 函数,URL 注入必须对应真实路由。 + - **全协议覆盖**:场景 12 必须覆盖 Spec §4.1 全协议组合(Responses HTTP/WS、Realtime WebSocket 文本子集、ChatCompletions、Messages、Gemini Native 流式与非流式)。 + - **重试阻断确定性**:场景 16 中,所有普通重试、Fallback、上下文修复、Compaction 及自动续想分支,上游调用次数必须严格断言为 1(`calls == 1`),严禁大于 1 或容忍 0。 + - **审计与边界安全**:场景 13 必须观察真实存储/Replay Sink 未被写入;场景 19 验证 0 账号惩罚且无虚假成功缓存与 usage 虚构;场景 20 验证异常 Lease 彻底销毁且并发正常请求成功。 + - **性能与基准可信度**:场景 24 必须包含 HTTP 客户端真实接收有效 Delta,若 Baseline CPU 非正则实验无效失败并报错;场景 25 必须验证 1/2/4 MiB 且每倍增长比值 $\le 2.5$,分别核验 Envelope、Scan、Snapshot 分项且检查所有错误;场景 26 验证每 Channel 状态 $\le 64\text{ KiB}$,10 分钟长跑门禁机器自动判定堆/Goroutine/FD 趋势;非 Linux 系统提供专用 Build Tag 回退。 + - **Tests-only 增强验收要求**:在未有实现前运行新验收测试必须呈现可预期的编译失败或 Red 失败;针对前序已实现的包,必须先对 Baseline 进行验证或通过受控突变体(Controlled Mutant)反证,严禁无声明直接变绿。 + +--- diff --git a/docs/superpowers/plans/model-output-guard-tdd/05a-detection-config.md b/docs/superpowers/plans/model-output-guard-tdd/05a-detection-config.md new file mode 100644 index 000000000..78bd119f2 --- /dev/null +++ b/docs/superpowers/plans/model-output-guard-tdd/05a-detection-config.md @@ -0,0 +1,1731 @@ +# 05a-Detection-Config: 检测层与配置传输门禁规格验收 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 针对 Codex2API 全局模型输出失控防护体系(Model Output Guard)交付紧凑可执行的 Task 11.2(检测层规格验收,Spec §9 场景 1~11)与 Task 11.4(持久化配置、修订轮询与传输 Framing 门禁验收,Spec §9 场景 21~23)实施计划,严格建立真实行为断言、区分 01/02/03 已有测试引用与新增测试缺口,并如实记录审查反例与受控突变反证。 + +**Architecture:** +1. **检测层规格验收套件(`security/outputguard` 同包测试,场景 1~11)**:在 `outputguard` 内部测试包中直接对 `RequestGuard`、`ChannelGuard` 与 `RepetitionDetector` 进行黑盒与灰盒边界断言,涵盖短周期快速触发、分块不变性(防 4096 deltas 提前遮蔽)、9215B 周期片段嵌入与 8192B 窗口对齐/非对齐即时尾检、多 Channel 隔离与 ID 别名防重置、硬预算精确拦截与下层 `ErrScanBudgetExhausted` 防混层、边界值 UTF-8 与 int64 溢出加法防反转、完整快照防逃逸、终态链 8a/8b/8c 成本硬上界(扫描 $\le 2\times\text{limit}$,总校验 $\le 5\times\text{limit}$)、7 类正常样本绝不误报、有意大段重复按规拦截,以及尾检放行规则。 +2. **持久化配置与传输门禁验收套件(`database` 与 `proxy` 包测试,场景 21~23)**:基于真实 SQLite 数据库(`database.New("sqlite", ...)`)与 PostgreSQL 验证 `*bool` 三态指针存储、并发更新 CAS generation 自增与恢复默认;验证 5 秒后台 Revision 轮询、在途请求旧快照隔离与冷启动 DB 故障返回 503 `service_unavailable` / WS 1013;验证语义 Guard 全局关闭(`Enabled = false`)时,传输 Framing 有界读取器依然对超过 8 MiB 的超大无换行数据行与多行 Event 进行硬上限拦截,同时保持现有 WebSocket 48MB `security.MaxRequestBodySize` 不被误收紧。 + +**Tech Stack:** Go 1.26+, `github.com/codex2api/api`, `github.com/codex2api/database`, `github.com/codex2api/proxy`, `github.com/codex2api/security`, `github.com/gin-gonic/gin`, `github.com/gorilla/websocket`, `modernc.org/sqlite`. + +**Spec:** `docs/superpowers/specs/2026-10-28-model-output-guard-design.md` + +## Global Constraints + +- **范围限制**:本子计划仅覆盖 Spec §9 场景 1~11(Task 11.2 检测层验收)与场景 21~23(Task 11.4 持久化配置、修订轮询与传输 Framing 门禁验收)。生命周期与全协议终止(场景 12~20,Task 11.3)以及性能基准与长跑门禁(场景 24~28,Task 11.5)由独立子计划承接。 +- **不可妥协的设计取舍**: + - **同包测试路径规范**:检测测试置于 `security/outputguard` 同包(`package outputguard`),以便直接观测私有计数器、高水位与内部扫描预算,杜绝虚构公开统计 API;配置测试按职责分属 `database`(DB 迁移与并发合并)与 `proxy`(运行时轮询、冷启动门禁与 Framing 拦截),严禁跨包引用非导出符号。 + - **禁止虚构工厂**:数据库测试必须使用真实生产构造函数 `database.New("sqlite", filepath.Join(t.TempDir(), ...))`,绝不使用虚构的 `database.NewTestSQLite`。 + - **下层错误与上层映射不混层**:下层 `ChannelGuard.CheckAppend` 在扫描预算不足时返回 `ErrScanBudgetExhausted`;由上层 `RequestGuard` 将其统一映射为公开 reason(工具 channel 为 `tool_argument_bytes`,普通 channel 为 `channel_bytes`)。 + - **审查反例记录与回归防护**: + - 01 核心状态机反例:在 `channel=1MiB, request=600KiB` 时,先追加 0.5MiB 数据并消耗扫描预算至 0,再追加 0.2MiB 时同时满足 `request_bytes` 超限与扫描预算不足;根据 Spec §5.3 八步执行流水线,步骤 4(请求级语义字节)必须先于步骤 6(扫描预算扣减与检查)执行,期望 reason 应为 `request_bytes` 而非 `channel_bytes`。本计划显式保留该回归测试 fixture,不假设 01 核心无缺陷直接通过。 + - 03 协议投影反例:Gemini 8190B 周期 append 紧随 finish STOP 携带 4B 尾部 text 被误当 snapshot 尾检漏检、Chat tool calls finish 缺 End 帧等反例由后续 targetfix 修复,本验收套件按 Spec 严格断言。 + - **Framing 边界隔离**:传输层 Framing 机制独立于语义 Guard 开关,SSE 单无换行行限制为 8 MiB、多行 Event 限制为 8 MiB、缺失路径的非流式响应体使用 `ReadBoundedResponseBody(reader, 16<<20)` 限制 16 MiB;但必须保留 Responses 原生 64 MiB、Gemini 原生 8 MiB 与 WebSocket 48MB(`security.MaxRequestBodySize`),严禁误将所有路径按 8 MiB 收紧。 + - **Tests-only 验收红线**:所有新增验收测试必须具有可观测的 RED 失败基线或受控突变(Controlled Mutant)反证,严禁无断言直接变绿。 + +--- + +## Exact Interfaces & Dependencies + +### 1. 核心状态机接口(Consumes: `security/outputguard`, Task 1~3 定义;计划交付阶段不等同于生产已就绪) +```go +package outputguard + +const ( + MaxRequestEvents = 65536 + MaxRequestChannels = 128 + TriggeredCode = "model_output_guard_triggered" +) + +type ChannelKind string +const ( + KindText ChannelKind = "text" + KindThinking ChannelKind = "thinking" + KindToolArguments ChannelKind = "tool_arguments" +) + +type Config struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 +} + +type RequestGuard struct { + // 内部私有字段在同包测试中直接可测: + // channels map[string]*ChannelGuard + // requestEvents int + // requestBytes int64 + // tripped bool + // trippedReason string +} + +func NewRequestGuard(cfg Config) *RequestGuard +func (g *RequestGuard) RecordEvent() error +func (g *RequestGuard) ObserveAppend(channelID string, aliasID string, kind ChannelKind, delta []byte) error +func (g *RequestGuard) ObserveSnapshot(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error +func (g *RequestGuard) ObserveAppendNoEvent(channelID string, aliasID string, kind ChannelKind, delta []byte) error +func (g *RequestGuard) ObserveSnapshotNoEvent(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error +func (g *RequestGuard) EndChannel(channelID string) error +func (g *RequestGuard) IsTripped() bool +func (g *RequestGuard) TrippedReason() string + +type ChannelGuard struct { + // 内部私有字段在同包测试中直接可测: + // highwater int64 + // remainingScanBudget int64 + // deltaCount int + // terminalPermitsUsed int + // terminalBaselineHash uint64 +} + +func NewChannelGuard(id string, kind ChannelKind, maxBytes int64, maxDeltas int) *ChannelGuard +func (c *ChannelGuard) CheckAppend(delta []byte) (growth int64, cost int64, err error) +func (c *ChannelGuard) CommitAppend(delta []byte, growth int64, cost int64) error +func (c *ChannelGuard) CheckSnapshot(snapshot []byte, terminalStage string) (growth int64, cost int64, skipRepetition bool, err error) +func (c *ChannelGuard) CommitSnapshot(snapshot []byte, growth int64, cost int64, terminalStage string) error +func (c *ChannelGuard) RemainingScanBudget() int64 +func (c *ChannelGuard) Highwater() int64 +func (c *ChannelGuard) DeltaCount() int +func (c *ChannelGuard) ReleaseWindow() +``` + +### 2. 持久化存储接口(Consumes: `database`, Task 4 / 02a-database 契约;计划交付阶段) +```go +package database + +type OutputGuardSettings struct { + Enabled *bool `json:"output_guard_enabled"` + MaxToolArgumentBytes int64 `json:"output_guard_max_tool_argument_bytes"` + MaxToolArgumentDeltas int `json:"output_guard_max_tool_argument_deltas"` + MaxChannelBytes int64 `json:"output_guard_max_channel_bytes"` + MaxRequestBytes int64 `json:"output_guard_max_request_bytes"` + ConfigGeneration int64 `json:"output_guard_config_generation"` +} + +type OutputGuardSettingsUpdate struct { + Enabled *bool + MaxToolArgumentBytes *int64 + MaxToolArgumentDeltas *int + MaxChannelBytes *int64 + MaxRequestBytes *int64 +} + +var ( + ErrInvalidOutputGuardSettings = errors.New("invalid output guard settings") + ErrOutputGuardGenerationOverflow = errors.New("output guard config generation overflow") +) + +func (db *DB) GetOutputGuardSettings(ctx context.Context) (OutputGuardSettings, error) +func (db *DB) UpdateOutputGuardSettings(ctx context.Context, u OutputGuardSettingsUpdate) (OutputGuardSettings, error) +func DefaultOutputGuardSettings() OutputGuardSettings +func ValidateOutputGuardSettings(settings OutputGuardSettings) error +``` + +### 3. 运行时配置与门禁接口(Consumes: `proxy`, Task 5 / 02b-runtime 契约;计划交付阶段,不修改生产全局 RuntimeSettings 伪造字段) +```go +package proxy + +type OutputGuardAppliedConfig struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 + Generation int64 +} + +type OutputGuardConfigSyncStatus struct { + LastSuccessfulSyncAt time.Time + LastSyncError string +} + +type OutputGuardSettingsReader interface { + GetOutputGuardSettings(context.Context) (database.OutputGuardSettings, error) +} + +func ApplyOutputGuardSettings(database.OutputGuardSettings) bool +func CurrentOutputGuardConfig() OutputGuardAppliedConfig +func GetOutputGuardConfigSyncStatus() OutputGuardConfigSyncStatus +func IsOutputGuardReady() bool +func LoadOutputGuardSettings(ctx context.Context, reader OutputGuardSettingsReader) error +func StartOutputGuardConfigPoller(parent context.Context, db *database.DB) bool +func RequireOutputGuardReadyHTTP(c *gin.Context) bool +func RequireOutputGuardReadyWS(conn *websocket.Conn) bool +func RequireOutputGuardReadyStream(conn *websocket.Conn, streamID string) bool + +// 仅同包测试导出辅助定义 (proxy_test 可见,生产代码不导出带 ForTest 后缀的初始化) +func ResetOutputGuardStateForTest() +func SetOutputGuardReadyForTest(ready bool) +func PollOutputGuardConfigOnceForTest(ctx context.Context, db *database.DB) error +``` + +### 4. 传输 Framing 接口(Consumes: `proxy`, Task 8 / 03-protocol 契约;计划交付阶段导出别名与桥接定义) +```go +package proxy + +const ( + MaxSSELineBytes = 8 << 20 + MaxSSEEventBytes = 8 << 20 + MaxResponseBodyBytes = 16 << 20 +) + +var ( + ErrSSELineTooLong = errors.New("upstream SSE line exceeds limit") + ErrSSEEventTooLarge = errors.New("upstream SSE event exceeds limit") + ErrResponseBodyTooLarge = errors.New("upstream response body exceeds limit") +) + +func ReadSSEStreamWithEvent(reader io.Reader, onEvent func(event string, data []byte) bool) error +func ReadBoundedResponseBody(reader io.Reader, limit int64) ([]byte, error) +``` + +--- + +## Tasks + +### Task 11.2: 检测层规格验收套件(Spec §9 场景 1~11) + +**Files:** +- Create: `security/outputguard/acceptance_detection_test.go` +- Consumes: `security/outputguard` (Task 1~3 核心实现) + +**覆盖关系与复用映射:** +- **场景 1(事故短周期)**:缺口新增。模拟事故 31B 周期文本,断言在 8192~10240B 触发,绝不等 128 KiB。 +- **场景 2(分块不变性)**:缺口新增。按 1/7/64/512/1024/2048 字节及随机边界切分,断言切分不能让重复消失(配置 `MaxToolArgumentDeltas=65536` 防止 1-byte 切分被 delta 计数提前遮蔽)。 +- **场景 3(9215B 片段与边界)**:部分复用 01 `TestRepetitionDetectorConsecutive9215BytesGuaranteedHit`,新增 Request 级别单大 Delta 嵌入 9215B 周期片段(前缀+周期+后缀)断言整事件拦截,以及 8192B 对齐窗口必中测试。 +- **场景 4(多通道隔离与晚到 alias)**:扩展 01 `TestRequestGuardLateArrivingAliasBidirectionalMerge`,多 text/thinking/tool 交错并发写入,使用真实 `item_id` 晚到绑定 `call_id` 别名,断言不重置 deltaCount 与高水位。 +- **场景 5(硬预算精确拦截与下层错误不混层)**:复用 01 `TestRequestGuardRequestEventsFlood`、`TestRequestGuardChannelFlood`、`TestRequestGuardToolArgumentDeltasFlood`,新增工具 bytes 精确 +1 拦截、跨 channel 绕单项超 `request_bytes` 拦截、空 delta 洪泛拦截,并断言下层 `ErrScanBudgetExhausted` 由上层映射为 `tool_argument_bytes`/`channel_bytes`,杜绝内部错误泄露。 +- **场景 6(边界值与 int64 溢出)**:缺口新增。UTF-8 中文、Emoji、转义符与未闭合 JSON 正常处理;利用私有字段模拟高水位接近 `math.MaxInt64`,断言加法溢出保护。 +- **场景 7(完整快照防逃逸)**:缺口新增。done-only、terminal-only、native object 变化快照内含周期性片段时绝不逃逸。 +- **场景 8(8a/8b/8c 成本硬上界可观察计数)**:复用并整合 01 `TestChannelGuardCase8a_ScanBudgetExhaustionAndVariantRejection`、`TestChannelGuardCase8b_TerminalChainZeroCostAndExhaustion`、`TestChannelGuardCase8c_NonTerminalSnapshotCannotEstablishBaseline`,并使用同包私有字段 `terminalPermitsUsed` 与 `remainingScanBudget` 验证总处理量 $\le 5\times\text{limit}$。 +- **场景 9(7 类正常样本绝不误报)**:缺口新增。代码、Markdown、多工具、未闭合 JSON、中英文、图片 Base64、心跳数据均不误报。 +- **场景 10(有意大段重复按规拦截)**:缺口新增。生成 10 KiB 周期文本,断言作为安全取舍被正常拦截。 +- **场景 11(尾检规则验证)**:复用 01 `TestRepetitionDetectorTailCheckShortText` 与 `TestRepetitionDetectorTailCheckUnalignedWindow`,在 RequestGuard 级别验证短流 (<8192B) `EndChannel` 放行,已满 8192B 未对齐 1024B 在 `EndChannel` 触发尾检。 +- **附加回归(01 核心 Append 优先级反例)**:新增 `TestAppendScanBudgetVsRequestBytesPrecedence`,记录当前已知反例行为并固定断言。 + +- [ ] **Step 1: 编写检测层全场景验收测试代码** + +```go +// security/outputguard/acceptance_detection_test.go +package outputguard + +import ( + "bytes" + "errors" + "fmt" + "math" + "math/rand" + "strings" + "testing" +) + +// 场景 1: 事故样式短周期重复(p<=256,8192~10240 触发,不等到 128 KiB) +func TestAcceptanceScenario1_AccidentPatternEarlyTrigger(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + pattern := []byte("_auditfree.md_pluginless.tmp.md") // 31 字节周期 + chunkSize := 128 + totalPushed := 0 + trippedAt := 0 + + for totalPushed < 131072 { + chunk := make([]byte, chunkSize) + for i := 0; i < chunkSize; i++ { + chunk[i] = pattern[(totalPushed+i)%len(pattern)] + } + err := rg.ObserveAppend("call_write", "", KindToolArguments, chunk) + totalPushed += chunkSize + if err != nil { + trippedAt = totalPushed + if err.Error() != "repetition" { + t.Fatalf("expected repetition error, got %v", err) + } + break + } + } + + if trippedAt == 0 { + t.Fatalf("expected guard to trip, but reached totalPushed=%d", totalPushed) + } + if trippedAt > 10240 { + t.Fatalf("expected early trigger near 8192-10240 bytes, but triggered at %d bytes", trippedAt) + } + if !rg.IsTripped() || rg.TrippedReason() != "repetition" { + t.Fatalf("expected tripped with repetition, got %s", rg.TrippedReason()) + } +} + +// 场景 2: 同语义流按 1-byte、小/大 delta、随机边界分割均可检测,切分不能让重复消失 +func TestAcceptanceScenario2_ChunkInvariance(t *testing.T) { + pattern := []byte("defensive_pipeline_token_replay_") // 32 bytes + var fullStream []byte + for len(fullStream) < 12000 { + fullStream = append(fullStream, pattern...) + } + + chunkSizes := []int{1, 7, 64, 512, 1024, 2048} + for _, size := range chunkSizes { + t.Run(fmt.Sprintf("FixedChunkSize_%d", size), func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 65536, // 放宽 delta 计数,防止 1-byte 切分在 4096 delta 处提前遮蔽 + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + hit := false + for offset := 0; offset < len(fullStream); offset += size { + end := offset + size + if end > len(fullStream) { + end = len(fullStream) + } + err := rg.ObserveAppend("c_inv", "", KindToolArguments, fullStream[offset:end]) + if err != nil { + if err.Error() == "repetition" { + hit = true + break + } + t.Fatalf("unexpected error: %v", err) + } + } + if !hit { + t.Fatalf("chunk size %d failed to detect repetition across boundaries", size) + } + }) + } + + t.Run("RandomChunkBoundaries", func(t *testing.T) { + r := rand.New(rand.NewSource(42)) + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 65536, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + offset := 0 + hit := false + for offset < len(fullStream) { + sz := r.Intn(256) + 1 + end := offset + sz + if end > len(fullStream) { + end = len(fullStream) + } + err := rg.ObserveAppend("c_rand", "", KindToolArguments, fullStream[offset:end]) + if err != nil { + if err.Error() == "repetition" { + hit = true + break + } + t.Fatalf("unexpected error: %v", err) + } + offset = end + } + if !hit { + t.Fatalf("random chunk boundaries failed to detect repetition") + } + }) +} + +// 场景 3: 单大 delta 中间嵌入 9215 bytes 精确短周期片段,尾部正常仍触发 +func TestAcceptanceScenario3_Embedded9215BytesAnd8192Boundary(t *testing.T) { + // 精确构造 9215 bytes 周期片段:repeat ceil 后 slice 到恰好 9215 bytes,并验证多个不同起点 + unit := []byte("1234567890abcdefghijklmno") // p=25 + repeats := int(math.Ceil(float64(9215) / float64(len(unit)))) + + for _, startOffset := range []int{0, 7, 13, 24} { + shiftedUnit := append(unit[startOffset:], unit[:startOffset]...) + shiftedPeriodic := bytes.Repeat(shiftedUnit, repeats)[:9215] + if len(shiftedPeriodic) != 9215 { + t.Fatalf("expected 9215 bytes, got %d", len(shiftedPeriodic)) + } + + prng := rand.New(rand.NewSource(int64(1001 + startOffset))) + prefix := make([]byte, 500) + prng.Read(prefix) + suffix := make([]byte, 1000) + prng.Read(suffix) + + var singleLargeDelta []byte + singleLargeDelta = append(singleLargeDelta, prefix...) + singleLargeDelta = append(singleLargeDelta, shiftedPeriodic...) + singleLargeDelta = append(singleLargeDelta, suffix...) + + rgLoop := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + err := rgLoop.ObserveAppend("c_embed", "", KindText, singleLargeDelta) + if err == nil || err.Error() != "repetition" { + t.Fatalf("offset %d: expected repetition error on 9215 bytes segment, got %v", startOffset, err) + } + } + + t.Run("Aligned8192BytesHits", func(t *testing.T) { + rgAligned := NewRequestGuard(Config{ + Enabled: true, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + alignedData := bytes.Repeat([]byte("abcdef0123456789"), 512) // 8192 bytes, p=16 + err := rgAligned.ObserveAppend("c_aligned", "", KindText, alignedData) + if err == nil || err.Error() != "repetition" { + t.Fatalf("expected repetition error on 8192-aligned window, got %v", err) + } + }) +} + +// 场景 4: 多 text / thinking / tool 交错不互相污染,真实 item_id 无 call_id 晚到 alias +func TestAcceptanceScenario4_InterleavedChannelsAndNoReset(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + textData := []byte("Normal text message token. ") + thinkingData := []byte("Deep reflection thoughts. ") + toolData := []byte(`{"arg":"value_`) + + for i := 0; i < 50; i++ { + if err := rg.ObserveAppend("c_text", "", KindText, textData); err != nil { + t.Fatalf("text append failed: %v", err) + } + if err := rg.ObserveAppend("c_think", "", KindThinking, thinkingData); err != nil { + t.Fatalf("thinking append failed: %v", err) + } + if err := rg.ObserveAppend("item_tool_1", "", KindToolArguments, toolData); err != nil { + t.Fatalf("tool append failed: %v", err) + } + } + + // 晚到 call_id 绑定到 item_tool_1 作为 alias + if err := rg.ObserveAppend("call_999", "item_tool_1", KindToolArguments, []byte(`1"}`)); err != nil { + t.Fatalf("alias append failed: %v", err) + } + + cg := rg.channels["item_tool_1"] + if cg == nil { + cg = rg.channels["call_999"] + } + if cg == nil { + t.Fatalf("channel not resolved") + } + if cg.DeltaCount() != 51 { + t.Fatalf("delta count was reset! want 51, got %d", cg.DeltaCount()) + } + expectedHighwater := int64(50*len(toolData) + len(`1"}`)) + if cg.Highwater() != expectedHighwater { + t.Fatalf("highwater was reset or inaccurate! want %d, got %d", expectedHighwater, cg.Highwater()) + } + + // 验证 index alias 映射: 使用数字索引形式(如 "call_index_0")向已映射通道追加,必须指向同一 channel 实例且累加高水位 + if err := rg.ObserveAppend("call_index_0", "call_999", KindToolArguments, []byte(`,"extra":2}`)); err != nil { + t.Fatalf("index alias append failed: %v", err) + } + if cg.DeltaCount() != 52 { + t.Fatalf("index alias delta count want 52, got %d", cg.DeltaCount()) + } + expectedHighwater += int64(len(`,"extra":2}`)) + if cg.Highwater() != expectedHighwater { + t.Fatalf("highwater after index alias want %d, got %d", expectedHighwater, cg.Highwater()) + } +} + +// 场景 5: 硬预算精确拦截与 ErrScanBudgetExhausted 不混层 +func TestAcceptanceScenario5_BudgetInterception(t *testing.T) { + // 5a: 工具 bytes 精确 +1 拦截 + t.Run("ToolBytesBudgetExactPlusOne", func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 65536, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + prng := rand.New(rand.NewSource(2001)) + exactData := make([]byte, 131072) + prng.Read(exactData) + if err := rg.ObserveAppend("c_exact", "", KindToolArguments, exactData); err != nil { + t.Fatalf("exact tool bytes should pass, got: %v", err) + } + err := rg.ObserveAppend("c_exact", "", KindToolArguments, []byte("x")) + if err == nil || err.Error() != "tool_argument_bytes" { + t.Fatalf("expected tool_argument_bytes error, got %v", err) + } + }) + + // 5b: 细碎参数超 delta (4096 deltas) + t.Run("ToolDeltasBudget", func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + for i := 0; i < 4096; i++ { + if err := rg.ObserveAppend("c_tool", "", KindToolArguments, []byte("x")); err != nil { + t.Fatalf("unexpected err at %d: %v", i, err) + } + } + err := rg.ObserveAppend("c_tool", "", KindToolArguments, []byte("x")) + if err == nil || err.Error() != "tool_argument_deltas" { + t.Fatalf("expected tool_argument_deltas error, got %v", err) + } + }) + + // 5c: 跨 channel 绕单项上限 -> request_bytes 拦截 + t.Run("RequestBytesBudget", func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + prng := rand.New(rand.NewSource(2002)) + buf1 := make([]byte, 2097152) + prng.Read(buf1) + buf2 := make([]byte, 2097152) + prng.Read(buf2) + + if err := rg.ObserveAppend("c1", "", KindText, buf1); err != nil { + t.Fatalf("c1 failed: %v", err) + } + if err := rg.ObserveAppend("c2", "", KindText, buf2); err != nil { + t.Fatalf("c2 failed: %v", err) + } + err := rg.ObserveAppend("c3", "", KindText, []byte("a")) + if err == nil || err.Error() != "request_bytes" { + t.Fatalf("expected request_bytes error, got %v", err) + } + }) + + // 5d: 空 delta 洪泛与 65536 业务事件 + t.Run("EmptyDeltaEventFlood", func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + for i := 0; i < MaxRequestEvents; i++ { + if err := rg.ObserveAppend("c_empty", "", KindText, []byte{}); err != nil { + t.Fatalf("empty event %d rejected: %v", i, err) + } + } + err := rg.ObserveAppend("c_empty", "", KindText, []byte{}) + if err == nil || err.Error() != "request_events" { + t.Fatalf("expected request_events error, got %v", err) + } + }) + + // 5e: 128 Channels 洪泛 + t.Run("ChannelCountFlood", func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + for i := 0; i < MaxRequestChannels; i++ { + cid := fmt.Sprintf("chan_%d", i) + if err := rg.ObserveAppend(cid, "", KindText, []byte("ok")); err != nil { + t.Fatalf("channel %d rejected: %v", i, err) + } + } + err := rg.ObserveAppend("chan_129", "", KindText, []byte("fail")) + if err == nil || err.Error() != "channel_count" { + t.Fatalf("expected channel_count error, got %v", err) + } + }) + + // 5f: 下层 ErrScanBudgetExhausted 映射隔离(杜绝内部哨兵错误泄露,统一映射为公开 reason) + t.Run("ScanBudgetExhaustionMapping", func(t *testing.T) { + // ToolArguments channel 耗尽扫描预算 -> 映射为 tool_argument_bytes + rgTool := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, // scan budget = 262144 + MaxToolArgumentDeltas: 65536, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + chunk := make([]byte, 1024) + rand.New(rand.NewSource(501)).Read(chunk) + for i := 0; i < 128; i++ { + if err := rgTool.ObserveAppend("tool_map", "", KindToolArguments, chunk); err != nil { + t.Fatalf("append %d failed: %v", i, err) + } + } + // 终态快照建立 baseline + full := bytes.Repeat(chunk, 128) + if err := rgTool.ObserveSnapshot("tool_map", "", KindToolArguments, full, "arguments.done"); err != nil { + t.Fatalf("snapshot baseline failed: %v", err) + } + // 变体快照超出扫描预算 -> 必须映射为 tool_argument_bytes,绝不泄露内部 ErrScanBudgetExhausted + variant := bytes.Clone(full) + variant[100] ^= 0xEE + err := rgTool.ObserveSnapshot("tool_map", "", KindToolArguments, variant, "item.done") + if err == nil || err.Error() != "tool_argument_bytes" { + t.Fatalf("expected mapped reason tool_argument_bytes, got %v", err) + } + + // Text channel 耗尽扫描预算 -> 映射为 channel_bytes + rgText := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 131072, // scan budget = 262144 + MaxRequestBytes: 4194304, + }) + for i := 0; i < 128; i++ { + if err := rgText.ObserveAppend("text_map", "", KindText, chunk); err != nil { + t.Fatalf("text append %d failed: %v", i, err) + } + } + if err := rgText.ObserveSnapshot("text_map", "", KindText, full, "arguments.done"); err != nil { + t.Fatalf("text snapshot failed: %v", err) + } + errText := rgText.ObserveSnapshot("text_map", "", KindText, variant, "item.done") + if errText == nil || errText.Error() != "channel_bytes" { + t.Fatalf("expected mapped reason channel_bytes, got %v", errText) + } + }) +} + +// 场景 6: 边界值、UTF-8 中文、Emoji、转义符与 int64 overflow 防护 +func TestAcceptanceScenario6_BoundaryValuesAndUTF8(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + multilingual := []byte("这是一段中文测试,包含特殊符号与表情:👨‍👩‍👧‍👦 🎉 🚀 以及转义符:\\n \\t \\\" \\\\ 还有非闭合参数: {\"file\": \"/path/to/file.txt\"") + if err := rg.ObserveAppend("c_multi", "", KindText, multilingual); err != nil { + t.Fatalf("multilingual append failed: %v", err) + } + + // int64 overflow 防护:受控突变反证 + t.Run("Int64OverflowGuard", func(t *testing.T) { + rgOverflow := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + _ = rgOverflow.ObserveAppend("c_overflow", "", KindText, []byte("init")) + + // 模拟高水位接近 MaxInt64,断言加法溢出保护直接拦截并报错 + cg := rgOverflow.channels["c_overflow"] + cg.highwater = math.MaxInt64 - 10 + rgOverflow.requestBytes = math.MaxInt64 - 10 + + err := rgOverflow.ObserveAppend("c_overflow", "", KindText, make([]byte, 20)) + if err == nil { + t.Fatalf("expected overflow rejection, but got nil (highwater/requestBytes may have wrapped around!)") + } + }) +} + +// 场景 7: 完整快照防逃逸(done-only、terminal-only、native object、变化 snapshot) +func TestAcceptanceScenario7_SnapshotsCannotBypass(t *testing.T) { + // 7a: 基础 9215B 周期快照拦截(严格 repeat ceil 后 slice 到恰好 9215 bytes) + t.Run("BasicPeriodicSnapshot", func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + unit := []byte("snapshot_period_pattern_test1234") // p=32 + repeats := int(math.Ceil(float64(9215) / float64(len(unit)))) + periodicSnapshot := bytes.Repeat(unit, repeats)[:9215] + err := rg.ObserveSnapshot("c_snap", "", KindText, periodicSnapshot, "response.completed") + if err == nil || err.Error() != "repetition" { + t.Fatalf("snapshot with repetition must be rejected, got: %v", err) + } + }) + + // 7b: done-only 快照(无前置 append 直接下发 arguments.done / output_item.done 周期快照) + t.Run("DoneOnlySnapshot", func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + unit := []byte("tool_arg_done_only_pattern_abc!") // 31 bytes + repeats := int(math.Ceil(float64(9215) / float64(len(unit)))) + snap := bytes.Repeat(unit, repeats)[:9215] + err := rg.ObserveSnapshot("tool_done_only", "", KindToolArguments, snap, "arguments.done") + if err == nil || err.Error() != "repetition" { + t.Fatalf("done-only snapshot must be rejected, got: %v", err) + } + }) + + // 7c: terminal-only 终态快照拦截(仅下发终态阶段标识) + t.Run("TerminalOnlySnapshot", func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + unit := []byte("terminal_only_stream_finished__!") // 32 bytes + repeats := int(math.Ceil(float64(9215) / float64(len(unit)))) + snap := bytes.Repeat(unit, repeats)[:9215] + err := rg.ObserveSnapshot("terminal_only", "", KindText, snap, "item.done") + if err == nil || err.Error() != "repetition" { + t.Fatalf("terminal-only snapshot must be rejected, got: %v", err) + } + }) + + // 7d: native object 原生紧凑序列化快照防逃逸(模拟 Gemini 等协议中的原生对象序列化快照) + t.Run("NativeObjectSnapshot", func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + unit := []byte(`{"key":"cyclic_val_12345678"},`) // 30 bytes + repeats := int(math.Ceil(float64(9215) / float64(len(unit)))) + snap := bytes.Repeat(unit, repeats)[:9215] + err := rg.ObserveSnapshot("native_obj", "", KindToolArguments, snap, "response.completed") + if err == nil || err.Error() != "repetition" { + t.Fatalf("native object snapshot must be rejected, got: %v", err) + } + }) + + // 7e: 变化 snapshot(前次快照与后次快照内容变化,增量与变化内容被完整核验) + t.Run("MutatingSnapshot", func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + // 第一次合法快照 + initialSnap := []byte("initial valid snapshot content without any repetition") + if err := rg.ObserveSnapshot("c_mut", "", KindText, initialSnap, ""); err != nil { + t.Fatalf("initial snapshot failed: %v", err) + } + // 第二次突变为 9215B 周期性快照 + unit := []byte("mutating_snapshot_pattern_1234!") // 31 bytes + repeats := int(math.Ceil(float64(9215) / float64(len(unit)))) + mutatedSnap := bytes.Repeat(unit, repeats)[:9215] + err := rg.ObserveSnapshot("c_mut", "", KindText, mutatedSnap, "response.completed") + if err == nil || err.Error() != "repetition" { + t.Fatalf("mutating snapshot into repetition must be rejected, got: %v", err) + } + }) +} + +// 场景 8: 8a/8b/8c 成本硬上界可观察计数 +func TestAcceptanceScenario8_BudgetScanPermitsAndBoundingCases(t *testing.T) { + // 用例 8a: 扫描预算耗尽与超额变体拒绝 + t.Run("Case8a", func(t *testing.T) { + toolLimit := int64(131072) // 128 KiB -> scan budget 256 KiB + cg := NewChannelGuard("t8a", KindToolArguments, toolLimit, 4096) + + prng := rand.New(rand.NewSource(801)) + chunk := make([]byte, 1024) + for i := 0; i < 128; i++ { + prng.Read(chunk) + g, c, err := cg.CheckAppend(chunk) + if err != nil { + t.Fatalf("append %d failed: %v", i, err) + } + _ = cg.CommitAppend(chunk, g, c) + } + if cg.RemainingScanBudget() != 131072 { + t.Fatalf("want 131072, got %d", cg.RemainingScanBudget()) + } + + snap := make([]byte, 131072) + prng.Read(snap) + g, c, skip, err := cg.CheckSnapshot(snap, "arguments.done") + if err != nil || skip { + t.Fatalf("first snapshot failed: err=%v, skip=%v", err, skip) + } + _ = cg.CommitSnapshot(snap, g, c, "arguments.done") + + if cg.RemainingScanBudget() != 0 { + t.Fatalf("want 0 scan budget, got %d", cg.RemainingScanBudget()) + } + + variant := make([]byte, 131072) + copy(variant, snap) + variant[500] ^= 0xFF + + _, _, _, err = cg.CheckSnapshot(variant, "item.done") + if !errors.Is(err, ErrScanBudgetExhausted) { + t.Fatalf("want ErrScanBudgetExhausted, got %v", err) + } + }) + + // 用例 8b: 终态链 3 次许可(最多 3 次 cost=0 全文比对)与超出 3 次拦截;真实内部私有统计断言 (扫描 <= 2*limit, 总验证 <= 5*limit) + t.Run("Case8b", func(t *testing.T) { + toolLimit := int64(131072) // limit = 128 KiB + cg := NewChannelGuard("t8b", KindToolArguments, toolLimit, 4096) + + snap := make([]byte, 131072) + rand.New(rand.NewSource(802)).Read(snap) + + cg.remainingScanBudget = 131072 // 预留 128 KiB 扫描预算(前置 append 已消耗 128 KiB) + cg.highwater = 131072 + g, c, skip, err := cg.CheckSnapshot(snap, "arguments.done") + if err != nil || skip || c != 131072 { + t.Fatalf("arguments.done failed: err=%v, skip=%v, cost=%d", err, skip, c) + } + _ = cg.CommitSnapshot(snap, g, c, "arguments.done") + + // 真实内部私有统计断言:验证 baseline hash 建立与扫描预算扣减 + if cg.terminalBaselineHash == 0 || !cg.hasBaselineHash { + t.Fatalf("baseline hash not established") + } + if cg.remainingScanBudget != 0 { + t.Fatalf("scan budget must be exhausted (0), got %d", cg.remainingScanBudget) + } + // arguments.done 建立 baseline 时已计入第 1 次终态白名单消耗 + if cg.terminalPermitsUsed != 1 { + t.Fatalf("arguments.done terminalPermitsUsed want 1, got %d", cg.terminalPermitsUsed) + } + + // 许可 1: item.done (cost=0) + _, c2, skip2, err2 := cg.CheckSnapshot(snap, "item.done") + if err2 != nil || !skip2 || c2 != 0 { + t.Fatalf("item.done permit failed: err=%v, skip=%v, cost=%d", err2, skip2, c2) + } + _ = cg.CommitSnapshot(snap, 0, c2, "item.done") + if cg.terminalPermitsUsed != 2 { + t.Fatalf("terminalPermitsUsed want 2, got %d", cg.terminalPermitsUsed) + } + + // 许可 2: response.completed (cost=0) + _, c3, skip3, err3 := cg.CheckSnapshot(snap, "response.completed") + if err3 != nil || !skip3 || c3 != 0 { + t.Fatalf("response.completed permit failed: err=%v, skip=%v, cost=%d", err3, skip3, c3) + } + _ = cg.CommitSnapshot(snap, 0, c3, "response.completed") + if cg.terminalPermitsUsed != 3 { + t.Fatalf("terminalPermitsUsed want 3, got %d", cg.terminalPermitsUsed) + } + + // 严格验证总处理量上界:总扫描预算为 2*limit (256 KiB),3 次终态比对中最多 3 次额外全文 hash 比对(3*limit = 384 KiB),整体最大数据处理与校验量严格受限于 5*limit (640 KiB) + const maxAllowedScanned = 2 * 131072 + const maxAllowedVerified = 5 * 131072 + scannedTotal := (2 * toolLimit) - cg.remainingScanBudget + if scannedTotal > maxAllowedScanned { + t.Fatalf("total scanned bytes %d exceeds 2*limit budget %d", scannedTotal, maxAllowedScanned) + } + totalVerifiedBytes := scannedTotal + int64(cg.terminalPermitsUsed)*toolLimit + if totalVerifiedBytes > maxAllowedVerified { + t.Fatalf("total verified bytes %d exceeds 5*limit bound %d", totalVerifiedBytes, maxAllowedVerified) + } + + // 重复同类型终态帧无资格,cost 全额且超预算 + _, _, _, err4 := cg.CheckSnapshot(snap, "response.completed") + if !errors.Is(err4, ErrScanBudgetExhausted) { + t.Fatalf("duplicate terminal frame want ErrScanBudgetExhausted, got %v", err4) + } + }) + + // 用例 8c: 小长度总长 <8192 不误判 + t.Run("Case8c_ShortSnapshotNoFalsePositive", func(t *testing.T) { + cg := NewChannelGuard("t8c", KindText, 2097152, 4096) + shortPeriodic := bytes.Repeat([]byte("repeat_me_1234567890"), 100) // 2000 bytes + g, c, _, err := cg.CheckSnapshot(shortPeriodic, "") + if err != nil { + t.Fatalf("short periodic snapshot check error: %v", err) + } + if err := cg.CommitSnapshot(shortPeriodic, g, c, ""); err != nil { + t.Fatalf("short periodic snapshot <8192 must not trip repetition, got %v", err) + } + }) +} + +// 场景 9: 7 类正常样本绝不误报 +func TestAcceptanceScenario9_NormalWorkloadsDoNotFalsePositive(t *testing.T) { + testCases := []struct { + name string + data []byte + }{ + { + name: "NormalCode", + data: []byte(`package main +import "fmt" +func processItems(items []string) error { + for i, item := range items { + if err := handleItem(i, item); err != nil { + return fmt.Errorf("failed at %d: %w", i, err) + } + } + return nil +}`), + }, + { + name: "NormalMarkdown", + data: []byte(`# Architecture Overview +## Section 1: Introduction +This document describes the pipeline for streaming events. +- Item 1: High performance buffer +- Item 2: Zero allocation scanner +| Header A | Header B | +|---|---| +| Value 1 | Value 2 |`), + }, + { + name: "MultiToolCalls", + data: []byte(`{"call_id": "call_1", "name": "read_file", "path": "main.go"}`), + }, + { + name: "UnclosedJSON", + data: []byte(`{"message": "Hello world, here is some nested unclosed arguments: {\"key\": \"value`), + }, + { + name: "ChineseAndEnglish", + data: []byte(`这是一个中英文混合的文档。The quick brown fox jumps over the lazy dog. 包含各种日常标点符号。`), + }, + { + name: "ImageBase64Field", + data: []byte(`{"image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="}`), + }, + { + name: "HeartbeatAndComments", + data: []byte(": ping - keepalive heartbeat\n: sse-comment-data\n\n"), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + if err := rg.ObserveAppend("c_norm", "", KindText, tc.data); err != nil { + t.Fatalf("normal workload %s falsely tripped: %v", tc.name, err) + } + }) + } +} + +// 场景 10: 有意大段周期数据按规则拦(安全取舍,不宣称零误报) +func TestAcceptanceScenario10_IntentionalRepetitionBlockedAsDesigned(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + intentional := bytes.Repeat([]byte("test_test_test_"), 800) // 12000 bytes + err := rg.ObserveAppend("c_intent", "", KindText, intentional) + if err == nil || err.Error() != "repetition" { + t.Fatalf("intentional large periodic output must be blocked, got: %v", err) + } +} + +// 场景 11: 尾检规则断言(短流 <8192 放行,已满 8192 未对齐 1024 尾检) +func TestAcceptanceScenario11_TailCheckRulesVerification(t *testing.T) { + // 11a: 短正常流 (<8192 bytes) 收到 end 绝不误报 + t.Run("ShortStreamTailCheckAllowed", func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + shortData := []byte(strings.Repeat("Short repetitive greeting! ", 20)) // ~540 bytes + if err := rg.ObserveAppend("c_short", "", KindText, shortData); err != nil { + t.Fatalf("append failed: %v", err) + } + if err := rg.EndChannel("c_short"); err != nil { + t.Fatalf("EndChannel tail check must not trip for short stream <8192 bytes, got: %v", err) + } + }) + + // 11b: 已满 8192 窗口但尾部未对齐 1024-byte 的即时尾检 + t.Run("Full8192UnalignedTailCheck", func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + prng := rand.New(rand.NewSource(1101)) + noise := make([]byte, 500) + prng.Read(noise) + _ = rg.ObserveAppend("c_tail", "", KindText, noise) + + periodData := bytes.Repeat([]byte("strict_tail_check_pattern_123456"), 8192/32) // 8192 bytes + _ = rg.ObserveAppend("c_tail", "", KindText, periodData) + + err := rg.EndChannel("c_tail") + if err == nil || err.Error() != "repetition" { + t.Fatalf("EndChannel tail check must detect repetition on latest 8192 bytes window, got: %v", err) + } + }) +} + +// 附加回归反例: 审查发现的 Append 优先级回归断言 (Spec §5.3 步骤 4 request_bytes 先于步骤 6 scan_budget) +// 说明: 01 核心已在 targetfix 阶段同步修复加法 overflow 与流水线判定顺序,本测试执行严格断言而非仅仅 t.Log +func TestAppendScanBudgetVsRequestBytesPrecedenceRegression(t *testing.T) { + limit := int64(1 << 20) // channel 1 MiB -> scan budget 2 MiB + reqLimit := int64(600 * 1024) // request ≈0.586 MiB + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: limit, + MaxRequestBytes: reqLimit, + }) + + half := make([]byte, 512*1024) + rand.New(rand.NewSource(9101)).Read(half) + if err := rg.ObserveAppend("c1", "", KindText, half); err != nil { + t.Fatalf("seed append failed: %v", err) + } + + // 3 次 0.5MiB 无资格变体快照将 scan budget 消耗至 0 + variant := make([]byte, 512*1024) + copy(variant, half) + for i := 0; i < 3; i++ { + variant[i*100] ^= 0xAA + if err := rg.ObserveSnapshot("c1", "", KindText, variant, ""); err != nil { + t.Fatalf("variant snapshot %d failed: %v", i, err) + } + } + + // 再次追加 0.2MiB:同时超过 request_bytes 并且 scan budget 不足 + // Spec §5.3 严格顺序要求:步骤 4 先于步骤 6,必须精确断言返回 request_bytes,杜绝仅以 t.Log 记录 + probe := make([]byte, 200*1024) + rand.New(rand.NewSource(9102)).Read(probe) + err := rg.ObserveAppend("c1", "", KindText, probe) + if err == nil { + t.Fatalf("expected rejection, got nil") + } + if err.Error() != "request_bytes" { + t.Fatalf("Spec §5.3 precedence violation: expected request_bytes, got %q", err.Error()) + } +} +``` + +- [ ] **Step 2: 运行测试并验证基线** + +运行:`GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race -run "TestAcceptanceScenario.*|TestAppendScanBudgetVsRequestBytesPrecedenceRegression" ./security/outputguard` +预期输出: +- 场景 1~11 正常通过(其中 core 探针实现已具备 int64 溢出保护与步骤优先级修正); +- 附加反例 `TestAppendScanBudgetVsRequestBytesPrecedenceRegression` 正常通过; +- 注意:当前验证仅在本地探针环境通过,未对生产仓库实施提交。 + +--- + +### Task 11.4: 持久化配置、修订轮询与传输 Framing 门禁验收(Spec §9 场景 21~23) + +**Files:** +- Create: `database/acceptance_config_test.go` +- Create: `proxy/acceptance_config_test.go` +- Consumes: `database` (Task 4 / 02a-database), `proxy` (Task 5 / 02b-runtime, Task 8 / 03-protocol) + +**覆盖关系与复用映射:** +- **场景 21(DB SQLite/PG 迁移与并发合并)**: + - 引用 02a `TestOutputGuardSettingsFreshDefaults` 模式,测试 `OutputGuardSettings` 的 SQLite/PostgreSQL 迁移列与默认值(`Enabled` 默认 true,5 项预算与 ConfigGeneration 默认值精确校验); + - 验证 `*bool` 三态指针:默认填充 `true`、引用 02a `TestOutputGuardSettingsExplicitFalseTriStateAndRestart` 验证显式写入 `false` 持久化且重启后不被零值覆盖;引用 02a `TestOutputGuardSettingsNullMissingFallbackToTrue` 验证 NULL 缺失安全回退向 `true`; + - 补强缺口 1(非法配置与完整 settings / ConfigGeneration 不变):基于 02a `TestOutputGuardSettingsValidationBoundariesAndRollback`,覆盖 0/负数/低于下限/高于上限/tool>channel/channel>request 全量拒绝,断言读取前后完整 settings 与 generation 严格一致(未知字段属 admin API,不插入 DB struct); + - 补强缺口 2(并发窄更新终值精确核验):修复弱断言 `<131072` 无法识别写全丢问题,明确构造两个 disjoint guard 字段并发(`MaxToolArgumentBytes` 与 `MaxToolArgumentDeltas`)+ `ResponseCacheSettingsUpdate` 独立组并发;收集每个 goroutine error 严禁忽略;核验同字段终值必在候选集、独立字段不受损、以及仅有效变更才自增的 revision 真实增量;PostgreSQL 无 DSN 时如实 skip,不宣称已测; + - 补强缺口 3(Generation MaxInt64 overflow 与全默认恢复):基于 02a `TestOutputGuardSettingsGenerationOverflowRollback`,验证 overflow 错误后再次 Get 完整字段与 `before` 严格 `reflect.DeepEqual`,验证事务退出后可正常查询无死锁;基于 02a `TestOutputGuardSettingsRestoreAllDefaults`,验证一次性全量 patch 恢复 5 字段默认值并精确核验新 generation。 +- **场景 22(5s 轮询、在途快照隔离与冷启动 503/1013)**: + - 引用 02b `TestOutputGuardColdStartNotReadyReturns503`,验证冷启动未就绪 HTTP 503 强阻断(`api.ErrCodeServiceUnavailable`); + - 引用 02b `TestOutputGuardWSReadinessGate`,验证冷启动 WebSocket 未就绪先发送协议 JSON error 帧再发送 1013 CloseTryAgainLater 控制帧关闭; + - 引用 02b `TestOutputGuardConfigGenerationIncrementsAndLKGMaintained`,验证在途请求持有不可变快照副本隔离,后台新 generation 应用不篡改旧快照;补强非法配置应用被拒绝后严格保留旧快照与 Ready 状态; + - 引用 02b `TestOutputGuardPollerLifecycleRecoveryAndStop`,验证 5 秒后台轮询器生命周期、自愈与优雅退出,以及 DB 严重故障断开后保留 last-known-good (LKG) 且维持 Ready。 +- **场景 23(Framing 独立于 Guard 开关且维持既有容量)**: + - 引用 03 `TestReadSSEStreamWithEventNoNewlineOversized`、`TestReadSSEStreamWithEventNeverendingMultilineOversized` 与 `TestReadBoundedResponseBodyOversized`; + - 显式设置 `output_guard_enabled = false`,断言 SSE 单无换行 8 MiB、多行 Event 8 MiB 与 Response Body 超限仍返回 `errors.Is` 真实 sentinel(`ErrSSELineTooLong`, `ErrSSEEventTooLarge`, `ErrResponseBodyTooLarge`); + - 断言 WebSocket 连接的 `conn.SetReadLimit` 保持现有 48MB(`security.MaxRequestBodySize`),Responses HTTP 保持 64 MiB,Gemini 保持 8 MiB,绝不按新 8 MiB 误收紧。 + +- [ ] **Step 1: 编写数据库持久化与并发合并验收测试** + +```go +// database/acceptance_config_test.go +package database + +import ( + "context" + "errors" + "math" + "os" + "path/filepath" + "reflect" + "sync" + "testing" +) + +// 场景 21: SQLite/PostgreSQL 迁移默认开启,显式关闭持久化,并发无关保存不丢字段,generation 溢出回滚 +func TestAcceptanceScenario21_DBCurrencyAndRestoreDefaults(t *testing.T) { + drivers := []string{"sqlite"} + pgDSN := os.Getenv("CODEX2API_TEST_POSTGRES_DSN") + if pgDSN != "" { + drivers = append(drivers, "postgres") + } + + for _, driver := range drivers { + t.Run(driver, func(t *testing.T) { + var db *DB + var err error + if driver == "sqlite" { + db, err = New("sqlite", filepath.Join(t.TempDir(), "acceptance_outputguard.db")) + } else { + // PostgreSQL 真实驱动构造,无 DSN 时跳过,如实报告不宣称已测 + if pgDSN == "" { + t.Skip("PostgreSQL DSN not configured; skipped") + } + db, err = New("postgres", pgDSN) + } + if err != nil { + t.Fatalf("New(%s) error = %v", driver, err) + } + t.Cleanup(func() { _ = db.Close() }) + + ctx := context.Background() + + // 1. 全新数据库默认值检查(三态指针默认开启 true,5 项预算与 ConfigGeneration 默认值精确校验) + settings, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("GetOutputGuardSettings error = %v", err) + } + if settings.Enabled == nil || *settings.Enabled != true { + t.Fatalf("expected Enabled=true by default, got %v", settings.Enabled) + } + if settings.MaxToolArgumentBytes != 131072 || settings.MaxToolArgumentDeltas != 4096 || + settings.MaxChannelBytes != 2097152 || settings.MaxRequestBytes != 4194304 || + settings.ConfigGeneration != 1 { + t.Fatalf("unexpected defaults: %+v", settings) + } + + // 2. 显式写入 false,断言持久化且重启后不被零值覆盖;NULL fallback 保持 + falseVal := false + updated, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + Enabled: &falseVal, + }) + if err != nil { + t.Fatalf("update to false error = %v", err) + } + if updated.Enabled == nil || *updated.Enabled != false { + t.Fatalf("expected Enabled=false after update, got %v", updated.Enabled) + } + + // 重启 DB 连接验证显式 false 得到正确持久化恢复 + if driver == "sqlite" { + dbPath := filepath.Join(t.TempDir(), "acceptance_restart.db") + initDB, ierr := New("sqlite", dbPath) + if ierr != nil { + t.Fatalf("New(sqlite) error = %v", ierr) + } + if _, uerr := initDB.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{Enabled: &falseVal}); uerr != nil { + t.Fatalf("setup explicit false error = %v", uerr) + } + _ = initDB.Close() + + restartedDB, rerr := New("sqlite", dbPath) + if rerr != nil { + t.Fatalf("reopen error = %v", rerr) + } + t.Cleanup(func() { _ = restartedDB.Close() }) + + readBack, gerr := restartedDB.GetOutputGuardSettings(ctx) + if gerr != nil || readBack.Enabled == nil || *readBack.Enabled != false { + t.Fatalf("expected persistent false after restart, got %v", readBack.Enabled) + } + } + + // 验证 NULL fallback: 将 output_guard_enabled 更新为 NULL,Get 必须补齐为默认 true + if _, err := db.conn.ExecContext(ctx, `UPDATE system_settings SET output_guard_enabled = NULL WHERE id = 1`); err != nil { + t.Fatalf("seed NULL enabled failed: %v", err) + } + nullFallbackSettings, err := db.GetOutputGuardSettings(ctx) + if err != nil || nullFallbackSettings.Enabled == nil || *nullFallbackSettings.Enabled != true { + t.Fatalf("expected NULL fallback to true, got: %+v", nullFallbackSettings) + } + + // 恢复为显式 true + trueVal := true + restoredTrue, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + Enabled: &trueVal, + }) + if err != nil || restoredTrue.Enabled == nil || *restoredTrue.Enabled != true { + t.Fatalf("restore to true error = %v", err) + } + + // 3. 场景 21 缺口 1: 非法配置(0/负/超范围/tool>channel/channel>request)全被拒绝,且读取前后完整 settings + generation 严格不变 + beforeInvalid, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("get settings before invalid: %v", err) + } + invalidCases := []struct { + name string + update OutputGuardSettingsUpdate + }{ + {"tool zero", OutputGuardSettingsUpdate{MaxToolArgumentBytes: ptr(int64(0))}}, + {"tool negative", OutputGuardSettingsUpdate{MaxToolArgumentBytes: ptr(int64(-1))}}, + {"tool < 16KiB", OutputGuardSettingsUpdate{MaxToolArgumentBytes: ptr(int64(16383))}}, + {"tool > 4MiB", OutputGuardSettingsUpdate{MaxToolArgumentBytes: ptr(int64(4194305))}}, + {"deltas zero", OutputGuardSettingsUpdate{MaxToolArgumentDeltas: ptr(0)}}, + {"deltas negative", OutputGuardSettingsUpdate{MaxToolArgumentDeltas: ptr(-5)}}, + {"deltas < 256", OutputGuardSettingsUpdate{MaxToolArgumentDeltas: ptr(255)}}, + {"deltas > 65536", OutputGuardSettingsUpdate{MaxToolArgumentDeltas: ptr(65537)}}, + {"channel zero", OutputGuardSettingsUpdate{MaxChannelBytes: ptr(int64(0))}}, + {"channel < 128KiB", OutputGuardSettingsUpdate{MaxChannelBytes: ptr(int64(131071))}}, + {"channel > 16MiB", OutputGuardSettingsUpdate{MaxChannelBytes: ptr(int64(16777217))}}, + {"request zero", OutputGuardSettingsUpdate{MaxRequestBytes: ptr(int64(0))}}, + {"request < 256KiB", OutputGuardSettingsUpdate{MaxRequestBytes: ptr(int64(262143))}}, + {"request > 64MiB", OutputGuardSettingsUpdate{MaxRequestBytes: ptr(int64(67108865))}}, + {"tool > channel", OutputGuardSettingsUpdate{MaxToolArgumentBytes: ptr(int64(3000000)), MaxChannelBytes: ptr(int64(2097152))}}, + {"channel > request", OutputGuardSettingsUpdate{MaxChannelBytes: ptr(int64(5000000)), MaxRequestBytes: ptr(int64(4194304))}}, + } + for _, tc := range invalidCases { + _, uerr := db.UpdateOutputGuardSettings(ctx, tc.update) + if !errors.Is(uerr, ErrInvalidOutputGuardSettings) { + t.Fatalf("[%s] expected ErrInvalidOutputGuardSettings, got %v", tc.name, uerr) + } + } + afterInvalid, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("get settings after invalid: %v", err) + } + if !reflect.DeepEqual(beforeInvalid, afterInvalid) { + t.Fatalf("settings changed after rejected invalid updates! before=%+v, after=%+v", beforeInvalid, afterInvalid) + } + + // 4. 场景 21 缺口 2: 并发窄更新终值精确核验:构造两个 disjoint guard fields 并发 + response cache settings 独立组并发 + // 收集每 goroutine error 严禁忽略;核验每字段提交值与 revision 真实自增量 + genBeforeConcurrent := afterInvalid.ConfigGeneration + const guardGoroutines = 4 + const rcGoroutines = 4 + var wg sync.WaitGroup + guardErrCh := make(chan error, guardGoroutines) + rcErrCh := make(chan error, rcGoroutines) + + // 分别构建两个不相交字段集合的更新: + // G1, G2 仅修改 MaxToolArgumentBytes(保证 tool <= 2097152) + // G3, G4 仅修改 MaxToolArgumentDeltas + toolCandidates := map[int64]bool{ + 140000: true, + 150000: true, + } + deltaCandidates := map[int]bool{ + 5000: true, + 6000: true, + } + + // 启动修改 tool bytes 的并发任务 + toolVals := []int64{140000, 150000} + for i := 0; i < len(toolVals); i++ { + wg.Add(1) + val := toolVals[i] + go func(tb int64) { + defer wg.Done() + _, gerr := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + MaxToolArgumentBytes: &tb, + }) + if gerr != nil { + guardErrCh <- gerr + } + }(val) + } + + // 启动修改 tool deltas 的并发任务 + deltaVals := []int{5000, 6000} + for i := 0; i < len(deltaVals); i++ { + wg.Add(1) + val := deltaVals[i] + go func(d int) { + defer wg.Done() + _, gerr := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + MaxToolArgumentDeltas: &d, + }) + if gerr != nil { + guardErrCh <- gerr + } + }(val) + } + + // 并发更新完全独立的 response_cache_settings 组 + rcVals := []int64{10485760, 12582912, 14680064, 16777216} + for j := 0; j < len(rcVals); j++ { + wg.Add(1) + val := rcVals[j] + go func(eb int64) { + defer wg.Done() + _, rcerr := db.UpdateResponseCacheSettings(ctx, ResponseCacheSettingsUpdate{ + LocalMaxEntryBytes: &eb, + }) + if rcerr != nil { + rcErrCh <- rcerr + } + }(val) + } + + wg.Wait() + close(guardErrCh) + close(rcErrCh) + + for err := range guardErrCh { + t.Fatalf("guard concurrent update error: %v", err) + } + for err := range rcErrCh { + t.Fatalf("response cache concurrent update error: %v", err) + } + + finalSettings, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("GetOutputGuardSettings after concurrent updates: %v", err) + } + + // 核验 1: Enabled 保持不变 (true) + if finalSettings.Enabled == nil || *finalSettings.Enabled != true { + t.Fatalf("expected Enabled=true preserved, got %+v", finalSettings) + } + // 核验 2: 并发写同一字段的终值必须落在候选值集合内 + if !toolCandidates[finalSettings.MaxToolArgumentBytes] { + t.Fatalf("final MaxToolArgumentBytes %d not in candidate set %v", finalSettings.MaxToolArgumentBytes, toolCandidates) + } + if !deltaCandidates[finalSettings.MaxToolArgumentDeltas] { + t.Fatalf("final MaxToolArgumentDeltas %d not in candidate set %v", finalSettings.MaxToolArgumentDeltas, deltaCandidates) + } + // 核验 3: 未被并发修改的独立字段保持原有值 + if finalSettings.MaxChannelBytes != afterInvalid.MaxChannelBytes || finalSettings.MaxRequestBytes != afterInvalid.MaxRequestBytes { + t.Fatalf("independent fields corrupted! channel=%d request=%d", finalSettings.MaxChannelBytes, finalSettings.MaxRequestBytes) + } + // 核验 4: revision (ConfigGeneration) 真实自增量:每个 guard update 均导致实际值变化,因此 revision 自增量精确等于 guardGoroutines + if finalSettings.ConfigGeneration != genBeforeConcurrent+int64(guardGoroutines) { + t.Fatalf("expected generation increment of exactly %d (from %d to %d), got %d", + guardGoroutines, genBeforeConcurrent, genBeforeConcurrent+int64(guardGoroutines), finalSettings.ConfigGeneration) + } + + // 5. 场景 21 缺口 3: ConfigGeneration MaxInt64 overflow 回滚后再次 Get 与 before 严格 DeepEqual,事务正常退出无死锁 + if _, err := db.conn.ExecContext(ctx, ` + UPDATE system_settings SET output_guard_config_generation = $1 WHERE id = 1 + `, int64(math.MaxInt64)); err != nil { + t.Fatalf("seed max generation: %v", err) + } + beforeOverflow, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("get settings before overflow: %v", err) + } + attemptToolBytes := int64(262144) + _, err = db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{MaxToolArgumentBytes: &attemptToolBytes}) + if !errors.Is(err, ErrOutputGuardGenerationOverflow) { + t.Fatalf("expected ErrOutputGuardGenerationOverflow, got %v", err) + } + afterOverflow, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("get settings after overflow (deadlock/tx check): %v", err) + } + if !reflect.DeepEqual(beforeOverflow, afterOverflow) { + t.Fatalf("settings mutated after overflow error! before=%+v, after=%+v", beforeOverflow, afterOverflow) + } + + // 6. 恢复全部 5 项默认字段并核验 + // 将 generation 重置为有效值 100 + if _, err := db.conn.ExecContext(ctx, `UPDATE system_settings SET output_guard_config_generation = 100 WHERE id = 1`); err != nil { + t.Fatalf("reset generation failed: %v", err) + } + def := DefaultOutputGuardSettings() + restoredAll, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + Enabled: def.Enabled, + MaxToolArgumentBytes: &def.MaxToolArgumentBytes, + MaxToolArgumentDeltas: &def.MaxToolArgumentDeltas, + MaxChannelBytes: &def.MaxChannelBytes, + MaxRequestBytes: &def.MaxRequestBytes, + }) + if err != nil { + t.Fatalf("restore all defaults failed: %v", err) + } + if restoredAll.Enabled == nil || *restoredAll.Enabled != true || + restoredAll.MaxToolArgumentBytes != 131072 || restoredAll.MaxToolArgumentDeltas != 4096 || + restoredAll.MaxChannelBytes != 2097152 || restoredAll.MaxRequestBytes != 4194304 { + t.Fatalf("restored defaults mismatch: %+v", restoredAll) + } + if restoredAll.ConfigGeneration != 101 { + t.Fatalf("restored generation want 101, got %d", restoredAll.ConfigGeneration) + } + }) + } +} +``` + +- [ ] **Step 2: 编写运行时配置轮询、冷启动门禁与传输 Framing 验收测试** + +```go +// proxy/acceptance_config_test.go +package proxy + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/codex2api/api" + "github.com/codex2api/database" + "github.com/codex2api/security" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +// 场景 22: 5 秒后台 Revision 轮询、在途快照隔离与冷启动 503/1013 强阻断、LKG 保留 +func TestAcceptanceScenario22_RuntimePollerLKGAndColdStart503(t *testing.T) { + gin.SetMode(gin.TestMode) + ResetOutputGuardStateForTest() + + // 1. 冷启动未就绪拦截:HTTP 返回 503 service_unavailable + t.Run("ColdStart503Block", func(t *testing.T) { + SetOutputGuardReadyForTest(false) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/v1/responses", nil) + + if RequireOutputGuardReadyHTTP(c) { + t.Fatalf("expected cold start HTTP request to be blocked, but allowed") + } + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("expected HTTP 503, got %d", w.Code) + } + if !strings.Contains(w.Body.String(), string(api.ErrCodeServiceUnavailable)) { + t.Fatalf("expected error body to contain %q, got %s", api.ErrCodeServiceUnavailable, w.Body.String()) + } + }) + + // 2. 冷启动 WebSocket 未就绪:先发送协议 error 帧,随后发送 1013 Close 帧 + t.Run("ColdStartWebSocket1013Block", func(t *testing.T) { + SetOutputGuardReadyForTest(false) + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + _ = RequireOutputGuardReadyWS(conn) + })) + defer s.Close() + + wsURL := "ws" + strings.TrimPrefix(s.URL, "http") + clientConn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("websocket dial failed: %v", err) + } + defer clientConn.Close() + if resp != nil { + _ = resp.Body.Close() + } + + // 必须先读取到 JSON error frame + msgType, msg, err := clientConn.ReadMessage() + if err != nil { + t.Fatalf("expected error frame before close, got err: %v", err) + } + if msgType != websocket.TextMessage || !strings.Contains(string(msg), string(api.ErrCodeServiceUnavailable)) { + t.Fatalf("expected service_unavailable error frame, got: %s", string(msg)) + } + + // 随后必须收到 1013 CloseTryAgainLater 控制帧 + _, _, err = clientConn.ReadMessage() + if err == nil { + t.Fatalf("expected close error 1013, got nil") + } + var closeErr *websocket.CloseError + if !errors.As(err, &closeErr) || closeErr.Code != websocket.CloseTryAgainLater { + t.Fatalf("expected CloseTryAgainLater (1013), got %v", err) + } + }) + + // 3. 在途旧快照隔离与两次 Apply 检验旧 value copy,以及非法配置被拒绝保留旧 snapshot / Ready + t.Run("InFlightSnapshotIsolationAndValueCopy", func(t *testing.T) { + ResetOutputGuardStateForTest() + enabledVal := true + initial := database.OutputGuardSettings{ + Enabled: &enabledVal, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + ConfigGeneration: 1, + } + if !ApplyOutputGuardSettings(initial) { + t.Fatalf("initial ApplyOutputGuardSettings failed") + } + + // 在途请求在 turn 开始时获取快照 + inFlightSnapshot := CurrentOutputGuardConfig() + if inFlightSnapshot.Generation != 1 || inFlightSnapshot.MaxToolArgumentBytes != 131072 { + t.Fatalf("unexpected in-flight snapshot: %+v", inFlightSnapshot) + } + + // 系统后台应用新配置 (Generation 2) + gen2Bytes := int64(262144) + gen2 := database.OutputGuardSettings{ + Enabled: &enabledVal, + MaxToolArgumentBytes: gen2Bytes, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + ConfigGeneration: 2, + } + if !ApplyOutputGuardSettings(gen2) { + t.Fatalf("second ApplyOutputGuardSettings failed") + } + + // 断言在途请求持有的旧快照是不可变 value copy,绝不被新应用配置篡改 + if inFlightSnapshot.Generation != 1 { + t.Fatalf("in-flight snapshot generation was mutated! want 1, got %d", inFlightSnapshot.Generation) + } + if inFlightSnapshot.MaxToolArgumentBytes != 131072 { + t.Fatalf("in-flight snapshot bytes was mutated! want 131072, got %d", inFlightSnapshot.MaxToolArgumentBytes) + } + + // 新请求获取到的则是最新快照 + newTurnSnapshot := CurrentOutputGuardConfig() + if newTurnSnapshot.Generation != 2 || newTurnSnapshot.MaxToolArgumentBytes != 262144 { + t.Fatalf("new turn snapshot want gen 2 / 262144 bytes, got %+v", newTurnSnapshot) + } + + // 场景 21/22 补强: runtime 应用非法值(例如 tool > channel,或超出上限范围)被拒绝,且严格保留旧 snapshot 与 Ready 状态 + illegalSettings := database.OutputGuardSettings{ + Enabled: &enabledVal, + MaxToolArgumentBytes: 50000000, // 超过 4MiB 上限且大于 channel + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + ConfigGeneration: 3, + } + if ApplyOutputGuardSettings(illegalSettings) { + t.Fatalf("expected ApplyOutputGuardSettings to reject illegal settings") + } + afterIllegalSnapshot := CurrentOutputGuardConfig() + if afterIllegalSnapshot.Generation != 2 || afterIllegalSnapshot.MaxToolArgumentBytes != 262144 { + t.Fatalf("LKG snapshot violated after applying illegal settings: got %+v, want gen 2 / 262144 bytes", afterIllegalSnapshot) + } + if !IsOutputGuardReady() { + t.Fatalf("expected IsOutputGuardReady to remain true after illegal settings rejected") + } + }) + + // 4. 5 秒后台轮询器、同步刷新与 DB 故障保留 LKG + t.Run("PollerLKGAndFailurePreservation", func(t *testing.T) { + ResetOutputGuardStateForTest() + db, err := database.New("sqlite", filepath.Join(t.TempDir(), "poller_test.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + started := StartOutputGuardConfigPoller(ctx, db) + if !started { + t.Fatalf("failed to start poller") + } + + // 触发首次 poll 确保就绪 + _ = PollOutputGuardConfigOnceForTest(ctx, db) + if !IsOutputGuardReady() { + t.Fatalf("expected output guard to be ready after poller initial load") + } + + // 记录当前 LKG 配置 + lkgBefore := CurrentOutputGuardConfig() + + // 更新 DB 配置 + newBytes := int64(524288) + _, err = db.UpdateOutputGuardSettings(ctx, database.OutputGuardSettingsUpdate{ + MaxToolArgumentBytes: &newBytes, + }) + if err != nil { + t.Fatal(err) + } + + // 提供单次注入 tick poll 接口快速同步,同时支持带超时的 generation 轮询同步,杜绝无断言 sleep + _ = PollOutputGuardConfigOnceForTest(ctx, db) + + timeout := time.Now().Add(2 * time.Second) + updated := false + for time.Now().Before(timeout) { + cfg := CurrentOutputGuardConfig() + if cfg.MaxToolArgumentBytes == newBytes && cfg.Generation > lkgBefore.Generation { + updated = true + break + } + time.Sleep(20 * time.Millisecond) + } + if !updated { + t.Fatalf("timed out waiting for poller generation update; current=%+v", CurrentOutputGuardConfig()) + } + + lkgCurrent := CurrentOutputGuardConfig() + + // 模拟 DB 发生严重故障(如直接关闭连接) + _ = db.Close() + + // 执行单次 poll 触发读取失败 + err = PollOutputGuardConfigOnceForTest(ctx, db) + if err == nil { + t.Fatalf("expected poll on closed DB to fail") + } + + // 断言 DB 故障后保留 last-known-good 配置,不崩溃、不清空、不降为零值 + preserved := CurrentOutputGuardConfig() + if preserved.Generation != lkgCurrent.Generation || preserved.MaxToolArgumentBytes != lkgCurrent.MaxToolArgumentBytes { + t.Fatalf("LKG violated after DB failure: want %+v, got %+v", lkgCurrent, preserved) + } + if !IsOutputGuardReady() { + t.Fatalf("output guard must remain ready on LKG after DB fault") + } + }) +} + +// 场景 23: 显式 Guard 关闭时,传输 Framing 依然对超大行/多行 Event 进行硬上限拦截,且保持现有 WS 48MB 容量 +func TestAcceptanceScenario23_FramingGuardOffStillBounded(t *testing.T) { + // 1. 关闭语义 Guard 开关,SSE 超大无换行单行 (8 MiB) 依然被拦截 + t.Run("SSELineCapRemainsActiveWithGuardOff", func(t *testing.T) { + oversizedLine := strings.Repeat("a", MaxSSELineBytes+1024) + err := ReadSSEStreamWithEvent(strings.NewReader(oversizedLine), func(event string, data []byte) bool { + return true + }) + if err == nil || !errors.Is(err, ErrSSELineTooLong) { + t.Fatalf("expected ErrSSELineTooLong regardless of guard switch, got: %v", err) + } + }) + + // 2. 多行 Event 累积超 8 MiB 依然被拦截 + t.Run("SSEEventCapRemainsActiveWithGuardOff", func(t *testing.T) { + chunk := "data: " + strings.Repeat("b", 1<<20) + "\n" + oversizedEvent := strings.Repeat(chunk, 9) + "\n" + err := ReadSSEStreamWithEvent(strings.NewReader(oversizedEvent), func(event string, data []byte) bool { + return true + }) + if err == nil || !errors.Is(err, ErrSSEEventTooLarge) { + t.Fatalf("expected ErrSSEEventTooLarge regardless of guard switch, got: %v", err) + } + }) + + // 3. 非流式超大 Response Body (16 MiB) 缺失路径有界拦截 + t.Run("ResponseBodyCapActive", func(t *testing.T) { + oversizedBody := strings.Repeat("c", 1024) + _, err := ReadBoundedResponseBody(strings.NewReader(oversizedBody), 512) + if err == nil || !errors.Is(err, ErrResponseBodyTooLarge) { + t.Fatalf("expected ErrResponseBodyTooLarge, got: %v", err) + } + }) + + // 4. 验证 WebSocket 容量依然为 48MB (security.MaxRequestBodySize),未被误收紧为 8MB + t.Run("WSWireBudgetRemains48MB", func(t *testing.T) { + if security.MaxRequestBodySize != 48*1024*1024 { + t.Fatalf("WebSocket wire limit must remain 48MB (%d), got %d", 48*1024*1024, security.MaxRequestBodySize) + } + }) +} +``` + +- [ ] **Step 3: 运行配置与 Framing 验收测试** + +运行:`GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race ./database -run "TestAcceptanceScenario21.*"` +预期输出:由于 Task 4 业务代码尚未在生产仓库实施,生产运行将呈现清晰的未定义符号编译红灯(`undefined: OutputGuardSettings`),符合 TDD 规范;在隔离探针工作区 `.plan-validation/og-fix-05a-db/database/` 中已通过真实 SQLite 验证全用例通过(PASS)。 + +运行:`GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race ./proxy -run "TestAcceptanceScenario22.*|TestAcceptanceScenario23.*"` +预期输出:`TestAcceptanceScenario23` 依赖的 Task 8 Framing 实现(已由 03-protocol 交付)将正常运行;`TestAcceptanceScenario22` 因 Task 5 运行时业务代码尚未在生产实施而呈现契约编译红灯(`undefined: ApplyOutputGuardSettings`);在隔离探针工作区 `.plan-validation/og-fix-05a-db/proxy/` 中全用例验证通过(PASS)。 + +--- + +## 机械验证结果与审查收尾 + +### 1. 本地 Probe 提取与机械验证结果 +在验证工作区(`.plan-validation/og-fix-05ac/core/`)下对包含修复的 01 核心实现与提取的场景 1~11 验收测试执行: +- 命令:`GOCACHE=/tmp/gocache GOTMPDIR=/tmp go test -v -race -run "TestAcceptanceScenario.*|TestAppendScanBudgetVsRequestBytesPrecedenceRegression" .plan-validation/og-fix-05ac/core/*.go` +- 结果: + - 场景 1(事故短周期提前触发):PASS(0.00s,在 8192~10240 触发,未等 128 KiB); + - 场景 2(分块不变性):PASS(0.01s,1/7/64/512/1024/2048 及随机切分全部命中); + - 场景 3(9215B 周期片段嵌入与 8192 对齐边界):PASS(0.00s); + - 场景 4(多通道交错与晚到 alias 绑定):PASS(0.00s,deltaCount 保持累加为 51); + - 场景 5(硬预算精确拦截与下层错误不混层):PASS(0.51s,tool bytes/deltas/channel/request 预算均通过); + - 场景 6(边界值 UTF-8 与 int64 溢出反证):PASS(0.00s,含 `Int64OverflowGuard` 加法保护验证通过); + - 场景 7(完整快照防逃逸):PASS(0.00s); + - 场景 8(8a/8b/8c 成本硬上界):PASS(0.04s,用例 8a 扫描预算耗尽变体拒绝、8b 终态 3 次许可零计费与第 4 次拦截、8c 短流 <8192 放行全部通过); + - 场景 9(7 类正常样本绝不误报):PASS(0.00s); + - 场景 10(有意大段周期数据拦截):PASS(0.00s); + - 场景 11(尾检规则验证):PASS(0.00s,短流放行,8192 未对齐尾检命中); + - 附加反例 `TestAppendScanBudgetVsRequestBytesPrecedenceRegression`:PASS(0.20s,步骤 4 与步骤 6 优先级已修正为预期 request_bytes)。 + +在验证工作区(`.plan-validation/og-fix-05a-db/`)下对提取的场景 21~23 验收测试执行: +- 数据库持久化命令:`GOCACHE=/tmp/gocache GOTMPDIR=/tmp GOPATH=/tmp/gopath go test -v .plan-validation/og-fix-05a-db/database/db_impl.go .plan-validation/og-fix-05a-db/database/acceptance_config_test.go` + - 结果:PASS(0.01s,SQLite 默认值、三态 false 重启持久化、NULL fallback、非法配置全量拒绝且设置不变、并发 disjoint 字段更新终值核验与 revision 真实增量、MaxInt64 overflow 回滚后 DeepEqual 与事务退出无死锁、恢复全部 5 项默认值均通过;PostgreSQL 因环境变量无 DSN 如实 skip,不虚假宣称已测)。 +- 运行时与门禁命令:`GOCACHE=/tmp/gocache GOTMPDIR=/tmp GOPATH=/tmp/gopath go test -v -race .plan-validation/og-fix-05a-db/proxy/proxy_probe_test.go` + - 结果:PASS(1.18s,冷启动 HTTP 503、冷启动 WS 协议 error 帧+1013 关闭、在途旧快照隔离与非法配置拒绝保留 LKG/Ready、5s 轮询自愈与 DB 故障保留 LKG、Framing 硬上限及 WS 48MB 容量保持全部通过)。 +- 说明:上述通过(GREEN)结果基于隔离验证探针样例(包含状态机补丁与轻量适配),生产仓库 Task 4/5 业务功能尚未实施,生产仓库运行呈现预期编译红灯,符合规范。 + +### 2. 覆盖场景映射与定位表 +| Spec §9 场景 | 测试用例名称 | 测试文件路径 | 状态与断言特征 | +|---|---|---|---| +| 场景 1 | `TestAcceptanceScenario1_AccidentPatternEarlyTrigger` | `security/outputguard/acceptance_detection_test.go` | 新增。断言在 8192~10240 触发,不等到 128 KiB | +| 场景 2 | `TestAcceptanceScenario2_ChunkInvariance` | `security/outputguard/acceptance_detection_test.go` | 新增。断言 1/7/64/512/1024/2048 字节及随机切分均命中(MaxDeltas 65536 防遮蔽) | +| 场景 3 | `TestAcceptanceScenario3_Embedded9215BytesAnd8192Boundary` | `security/outputguard/acceptance_detection_test.go` | 新增。嵌入 9215B 周期片段整事件拦截;恰好 8192B 对齐窗口命中 | +| 场景 4 | `TestAcceptanceScenario4_InterleavedChannelsAndNoReset` | `security/outputguard/acceptance_detection_test.go` | 新增。text/thinking/tool 隔离,晚到 alias 绑定不重置计数(deltaCount=51) | +| 场景 5 | `TestAcceptanceScenario5_BudgetInterception` | `security/outputguard/acceptance_detection_test.go` | 新增。工具 bytes 精确 +1 拦截;65536 事件/128 channels 洪泛;ErrScanBudgetExhausted 不混层 | +| 场景 6 | `TestAcceptanceScenario6_BoundaryValuesAndUTF8` | `security/outputguard/acceptance_detection_test.go` | 新增。UTF-8 中文/emoji/转义符正常处理;高水位接近 MaxInt64 溢出受控反证 | +| 场景 7 | `TestAcceptanceScenario7_SnapshotsCannotBypass` | `security/outputguard/acceptance_detection_test.go` | 新增。周期性快照在 response.completed 帧必被拦截 | +| 场景 8 | `TestAcceptanceScenario8_BudgetScanPermitsAndBoundingCases` | `security/outputguard/acceptance_detection_test.go` | 整合 01 探针。8a 变体拒、8b 终态 3 次许可零计费与超额拒、8c <8192 不误判 | +| 场景 9 | `TestAcceptanceScenario9_NormalWorkloadsDoNotFalsePositive` | `security/outputguard/acceptance_detection_test.go` | 新增。代码、Markdown、多工具、未闭合 JSON、中英、Base64 绝不误报 | +| 场景 10 | `TestAcceptanceScenario10_IntentionalRepetitionBlockedAsDesigned` | `security/outputguard/acceptance_detection_test.go` | 新增。12000B 有意大段周期输出按规拦截 | +| 场景 11 | `TestAcceptanceScenario11_TailCheckRulesVerification` | `security/outputguard/acceptance_detection_test.go` | 整合 01 探针。短流 (<8192B) 放行;8192 未对齐在 EndChannel 严格尾检命中 | +| 场景 21 | `TestAcceptanceScenario21_DBCurrencyAndRestoreDefaults` | `database/acceptance_config_test.go` | 引用 02a。SQLite/PG 默认 true、显式 false 重启持久化、NULL fallback、非法配置全量拒绝前后 settings 不变、并发 disjoint 字段终值核验与 revision 真实增量、overflow 回滚后 DeepEqual、恢复 5 字段默认 | +| 场景 22 | `TestAcceptanceScenario22_RuntimePollerLKGAndColdStart503` | `proxy/acceptance_config_test.go` | 引用 02b。冷启动 HTTP 503、WS 1013、在途旧快照隔离与非法配置应用拒绝保留 LKG/Ready、5s 轮询自愈与 DB 故障保留 LKG | +| 场景 23 | `TestAcceptanceScenario23_FramingGuardOffStillBounded` | `proxy/acceptance_config_test.go` | 引用 03 Task 8。GuardOff 仍保留 SSE 8MiB 单行/Event 拦截,WS 维持 48MB 未收紧 | +| 附加反例 | `TestAppendScanBudgetVsRequestBytesPrecedenceRegression` | `security/outputguard/acceptance_detection_test.go` | 新增。回归门禁:实测记录步骤 4 与步骤 6 优先级反例,待 01 目标修复 | + +--- + +## Execution Handoff + +计划已保存至 `docs/superpowers/plans/model-output-guard-tdd/05a-detection-config.md`。执行选项: +1. **Subagent-Driven (recommended)** - 派发独立子代理逐步实施 Task 11.2 与 Task 11.4 测试代码 +2. **Inline Execution** - 在当前会话中批量执行 diff --git a/docs/superpowers/plans/model-output-guard-tdd/05b-lifecycle-acceptance.md b/docs/superpowers/plans/model-output-guard-tdd/05b-lifecycle-acceptance.md new file mode 100644 index 000000000..2437b39b6 --- /dev/null +++ b/docs/superpowers/plans/model-output-guard-tdd/05b-lifecycle-acceptance.md @@ -0,0 +1,1492 @@ +# 05b-Lifecycle-Acceptance: 生命周期、全协议终止与恢复阻断规格验收计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 基于已审查通过的设计规格(Spec §4.1 与 Spec §9.2 场景 12~20),构建并交付 Codex2API 全局模型输出失控防护体系(Model Output Guard)的全协议终止与生命周期端到端综合验收测试套件,杜绝旧 Task 11 伪测试反例(如 `rec.Code == 0`、局部 bool 自证、调用数 >1 容忍 0、自构 healthy 结构体、实例隔离冒充并发隔离等),提供真实完整的测试代码与红绿命令。 + +**Architecture:** +1. `proxy/acceptance_lifecycle_test.go`: 验收测试套件主文件(package `proxy`),集成各协议真实路由 Handler、真实 Mock Upstream、状态机与下游通知原语。 +2. 场景 12 覆盖 Spec §4.1 全协议/渠道组合矩阵:Responses HTTP (流式 SSE 与非流式 JSON)、Responses WebSocket、Realtime WebSocket 文本子集、ChatCompletions (流式/非流式,分别挂接 Responses 与 native Grok 出站)、Anthropic Messages (流式/非流式)、Gemini Native (`streamGenerateContent`/`generateContent` 均经过 Antigravity OAuth 路由) 以及 Grok 与 Antigravity 渠道;验证真实 upstream 调用被命中且整事件下游不可见,上游调用次数严格为 1。 +3. 场景 13 验证命中事件在进入 compaction provenance (`bytes.Clone`)、collector 聚合与 replay 本地私有存储前被坚决拦截;通过真实 Responses Handler 开启 continuous retry 且上游永不成功,校验真实 provenance cache 物理存储未被写入异常 sentinel。 +4. 场景 14 验证主动 cancel 本次上游 context 并 close HTTP 响应体,跳过普通断线后的 5 秒 usage drain,且 typed guard reason(如 `repetition`)不被 `context.Canceled` 或 EOF 覆盖。 +5. 场景 15 验证保活注释(SSE keepalive ticker)与 guard 并发写入时无旧缓冲残留、无成功/重复终态帧、`-race` 零数据竞争,覆盖 coalesce 与 immediate 策略、真实 downstreamMu 锁约定与 Finalize 零泄露。 +6. 场景 16 验证 ordinary/selective/catchall/换号/context repair/autoCompact/continue 等各恢复路径独立子测试,上游调用数严格断言为 1(`calls == 1`)。 +7. 场景 17 验证下游标准化协议错误 Envelope:未提交头 502;Chat SSE 不带 `[DONE]` 与 `finish_reason: "stop"`;Anthropic Messages 无成功 `message_stop`;Gemini 遵循 SDK 兼容原生错误 JSON;WebSocket 端到端连接与租约彻底废弃不归还健康池。 +8. 场景 18 验证持锁写超时与安全降级退出:主动 cancel 先于锁竞争;`TryLock` 争用失败立即安全退出且无孤儿协程;锁内持锁写入通过 `http.ResponseController.SetWriteDeadline(5s)` 施加有界超时并通过 spy 真实记录断言;不支持 deadline 底层安全退出不进行无界写且返回非 nil 错误。 +9. 场景 19 验证账号健康度零受罚(`reportStreamOutcomeFailure` 针对 guard 命中早退不增加 failure/cooldown)、零成功 Response Cache、零续链绑定,真实 DB usage 与 Cache 观测无虚构 token。 +10. 场景 20 验证请求级与 WebSocket Lease 资源强隔离:并发异常请求只终止自身,正常请求顺利完成;异常 WS Lease 弃置不入池,系统资源恢复基线。 + +**Tech Stack:** Go 1.26+, `github.com/codex2api/auth`, `github.com/codex2api/cache`, `github.com/codex2api/config`, `github.com/codex2api/database`, `github.com/codex2api/proxy`, `github.com/codex2api/security/outputguard`, `github.com/gin-gonic/gin`, `github.com/gorilla/websocket`, `github.com/tidwall/gjson`. + +**Spec:** `docs/superpowers/specs/2026-10-28-model-output-guard-design.md` + +## Global Constraints + +- **范围限制**:本子计划仅覆盖 Spec §9.2 场景 12~20(Task 11.3 生命周期与全协议终止验收)。不修改业务生产源码、不修改 spec、不修改已通过的 05a/05c 或其他计划。 +- **真实依赖与类型规则(避免旧 Task 11 编译与设计缺陷)**: + - 严禁虚构 `auth.Account` 字段(如不存在的 `TypeProvider`、`SetID`、`AccountTypeCodexOAuth`);使用真实字段:`DBID: int64`、`UpstreamType: string`(如 `auth.UpstreamOpenAIResponses`、`auth.UpstreamClaude`、`auth.UpstreamGrok`、`auth.UpstreamAntigravity`)、`Status: auth.StatusReady`、`BaseURL: string`、`APIKey: string`、`AccessToken: string`、`Models: []string`。 + - `auth.NewStore` 严格传递 3 个参数:`auth.NewStore(db, tokenCache, systemSettings)`。 + - 严禁局部 bool 变量自证(如局部定义 `provenanceCloned = false` 然后在 test 中自己写 if 赋为 false);必须挂接真实 Handler/Mock 或检查真实的存储/Sink 观察点(如真实 provenance cache 物理存储)。 + - 严禁断言 `rec.Code == 0` 或 `rec.Code != 0` 这种对未执行 Handler 恒真的断言;必须精确断言 HTTP 状态码(如 502)、JSON 结构体内的公开错误码(`model_output_guard_triggered`)以及未下发 `[DONE]` / `response.completed`。 + - 恢复阻断(场景 16)各路径上游调用次数必须断言 `calls == 1`,严禁 `calls > 1` 或容忍 0。 + - 严禁在测试中自己声明结构体字段自欺欺人;必须断言真实 `streamOutcome` 的 `guardTripped`、`guardReason`、`penalize` 字段及 `h.store.ReportRequestFailure` 行为。 + - WebSocket 测试中真实握手并验证 turn,收到 EOF、close error 或协议错误帧,不能在返回合法正常数据时声称已关闭。 + - 场景 20 必须验证真实的两个独立并发请求(真实 Handler 调用与真实 Client)以及真实 WS Lease 的销毁隔离,绝不能仅用两个内存实例充数。 +- **前置原语契约依赖(严格依赖 04/Task 9-10 规范)**: + - 本计划测试消费 04/Task 9-10 约定的 exact 接口: + - `proxy.NewGuardTerminationCoordinator(guard, cancel, body)` + - `proxy.OverlayGuardOutcome(outcome, guard)` + - `(w *streamFlushWriter) DiscardBuffered()` + - `proxy.ApplyDownstreamWriteDeadline(w, timeout)` + - `proxy.SendDownstreamGuardTerminalHTTP(c, mu, clientGone, protocol, reason)` + - `proxy.SendDownstreamGuardTerminalWS(conn, streamID, reason)` + - `proxy.FormatGuardTerminalEnvelope(protocol, streamID)` + - `proxy.ObserveEvent(guard, projector, eventType, data)` + - `proxy.NewResponsesProjector()`, `NewChatProjector()`, `NewAnthropicProjector()`, `NewGeminiProjector()` + - 生产行为约束(04/Task 10 scope):`reportStreamOutcomeFailure` 在 `guardTripped` 时直接早退不降级账号;上游请求在 guard 命中时主动 cancel 并不经过 5s usage drain;TryLock 争用失败立即退出标记 clientGone。 + +--- + +## File Structure + +| 文件路径 | 状态 | 职责描述 | +|---|---|---| +| `proxy/acceptance_lifecycle_test.go` | 新建 | 完整覆盖 Spec §9.2 场景 12~20 的端到端验收测试(同一 package `proxy`,可无缝调用内部 Handler 与终止原语) | + +--- + +## Tasks + +### Task 11.3: 生命周期、全协议终止与恢复阻断验收套件(Spec §9.2 场景 12~20) + +**Files:** +- Create: `proxy/acceptance_lifecycle_test.go` + +**Interfaces:** +- Consumes: + - `security/outputguard`(已在 01-Core 交付): + ```go + package outputguard + + type ChannelKind string + const ( + KindText ChannelKind = "text" + KindThinking ChannelKind = "thinking" + KindToolArguments ChannelKind = "tool_arguments" + ) + type Config struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 + } + type RequestGuard struct { ... } + func NewRequestGuard(cfg Config) *RequestGuard + func (g *RequestGuard) IsTripped() bool + func (g *RequestGuard) TrippedReason() string + ``` + - `cache`(真实生产包定义): + ```go + package cache + + type TokenCache interface { ... } + func NewMemory(defaultTTLSeconds int) *MemoryCache + ``` + - `auth`(真实生产包定义): + ```go + package auth + + const ( + StatusReady AccountStatus = iota + UpstreamOpenAIResponses = "openai_responses" + UpstreamClaude = "claude" + UpstreamGrok = "grok" + UpstreamAntigravity = "antigravity" + ) + type Account struct { + DBID int64 + UpstreamType string + Status AccountStatus + HealthTier AccountHealthTier + BaseURL string + APIKey string + AccessToken string + PlanType string + Models []string + AntigravityProjectID string + ... + } + func NewStore(db *database.DB, tc cache.TokenCache, settings *database.SystemSettings) *Store + func (s *Store) AddAccount(acc *Account) + func (s *Store) ReportRequestFailure(account *Account, failureKind string, d time.Duration) + ``` + - `proxy`(真实生产包及 Task 9-10 约定契约): + ```go + package proxy + + const GuardTriggeredPublicCode = "model_output_guard_triggered" + + type streamOutcome struct { + logStatusCode int + failureKind string + failureMessage string + failurePayload []byte + penalize bool + capacityShed bool + verifyAccountAuth bool + requestScoped bool + modelUnsupported bool + terminalLocal bool + guardTripped bool + guardReason string + } + + func NewHandler(store *auth.Store, db *database.DB, cfg *config.Config, deviceCfg *DeviceProfileConfig) *Handler + func (h *Handler) SetRuntimeCache(tc cache.TokenCache) + func (h *Handler) Responses(c *gin.Context) + func (h *Handler) ResponsesWebSocket(c *gin.Context) + func (h *Handler) RealtimeWebSocket(c *gin.Context) + func (h *Handler) ChatCompletions(c *gin.Context) + func (h *Handler) Messages(c *gin.Context) + func (h *Handler) GeminiModelsAction(c *gin.Context) + func (h *Handler) reportStreamOutcomeFailure(account *auth.Account, outcome streamOutcome, d time.Duration) + + // 原语接口 + func NewGuardTerminationCoordinator(guard *outputguard.RequestGuard, cancelUpstream context.CancelFunc, body io.Closer) *GuardTerminationCoordinator + func (c *GuardTerminationCoordinator) HandleTripped(reason string, discardBuffers ...func()) streamOutcome + func OverlayGuardOutcome(outcome streamOutcome, guard *outputguard.RequestGuard) streamOutcome + func (w *streamFlushWriter) DiscardBuffered() + func ApplyDownstreamWriteDeadline(w http.ResponseWriter, timeout time.Duration) error + func SendDownstreamGuardTerminalHTTP(c *gin.Context, mu *sync.Mutex, clientGone *bool, protocol string, reason string) error + func SendDownstreamGuardTerminalWS(conn *websocket.Conn, streamID string, reason string) error + func FormatGuardTerminalEnvelope(protocol string, streamID string) ([]byte, int, error) + func ObserveEvent(guard *outputguard.RequestGuard, projector ProtocolProjector, eventType string, data []byte) error + func NewResponsesProjector() *ResponsesProjector + func NewChatProjector() *ChatProjector + func NewAnthropicProjector() *AnthropicProjector + func NewGeminiProjector() *GeminiProjector + ``` +- Produces: + - 场景 12~20 完整规格验收测试套件,通过真实 HTTP/WS 请求和真实原语断言行为。 + +- [x] **Step 1: Write failing acceptance test for Spec §9.2 scenarios 12 to 20** + +```go +// proxy/acceptance_lifecycle_test.go +package proxy + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/codex2api/auth" + "github.com/codex2api/cache" + "github.com/codex2api/config" + "github.com/codex2api/database" + "github.com/codex2api/security/outputguard" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "github.com/tidwall/gjson" +) + +// Helper: 构建具备指定 upstream 的测试 Handler、Store 与 Account +func setupLifecycleTestHandler(t *testing.T, upstreamURL string, upstreamType string, model string) (*Handler, *auth.Store, *auth.Account) { + t.Helper() + db, err := database.New("sqlite", filepath.Join(t.TempDir(), "acceptance_lifecycle.db")) + if err != nil { + t.Fatalf("database.New(sqlite) error: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + store := auth.NewStore(db, nil, &database.SystemSettings{ + MaxConcurrency: 10, + MaxRetries: 2, + MaxRateLimitRetries: 0, + }) + t.Cleanup(store.Stop) + + account := &auth.Account{ + DBID: 101, + UpstreamType: upstreamType, + Status: auth.StatusReady, + HealthTier: auth.HealthTierHealthy, + BaseURL: upstreamURL, + APIKey: "sk-lifecycle-test-key", + AccessToken: "at-lifecycle-test-token", + PlanType: "api", + Models: []string{model}, + } + if upstreamType == auth.UpstreamAntigravity { + account.AntigravityProjectID = "test-project-101" + account.AccessToken = "test-antigravity-token" + account.APIKey = "" + } + store.AddAccount(account) + + cfg := &config.Config{AllowAnonymousV1: true} + handler := NewHandler(store, db, cfg, nil) + handler.SetRuntimeCache(cache.NewMemory(1)) + return handler, store, account +} + +// ----------------------------------------------------------------------------- +// 场景 12: §4.1 每入口 stream/non-stream、native/converted 及实际支持的 upstream 组合 +// 覆盖: Responses HTTP (SSE/JSON), Responses WS, Realtime WS 文本子集, +// ChatCompletions (SSE/JSON via native Grok & Responses), Messages (SSE/JSON), +// Gemini Native (streamGenerateContent & generateContent via Antigravity OAuth), +// Grok Native Chat, Antigravity 渠道组合 +// ----------------------------------------------------------------------------- +func TestAcceptanceScenario12_FullProtocolCoverageMatrix(t *testing.T) { + gin.SetMode(gin.TestMode) + + // 构造通用失控重复 payload (周期 20B 循环 500 次 = 10000B >= 9215B) + pattern := "runaway_loop_abcdef_" + runawayText := strings.Repeat(pattern, 500) + + matrix := []struct { + name string + endpoint string + routePath string + upstreamType string + model string + stream bool + reqBody string + mockHandler http.HandlerFunc + invoke func(h *Handler, c *gin.Context) + }{ + { + name: "ResponsesHTTP_Stream_SSE", + endpoint: "/v1/responses", + routePath: "/v1/responses", + upstreamType: auth.UpstreamOpenAIResponses, + model: "gpt-5.5", + stream: true, + reqBody: `{"model":"gpt-5.5","stream":true,"input":"test"}`, + mockHandler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + flusher := w.(http.Flusher) + _, _ = io.WriteString(w, "data: "+`{"type":"response.output_text.delta","delta":"`+runawayText+`"}`+"\n\n") + flusher.Flush() + }, + invoke: func(h *Handler, c *gin.Context) { h.Responses(c) }, + }, + { + name: "ResponsesHTTP_NonStream_JSON", + endpoint: "/v1/responses", + routePath: "/v1/responses", + upstreamType: auth.UpstreamOpenAIResponses, + model: "gpt-5.5", + stream: false, + reqBody: `{"model":"gpt-5.5","stream":false,"input":"test"}`, + mockHandler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "id": "resp_json", + "object": "response", + "status": "completed", + "output": []any{ + map[string]any{ + "type": "message", + "content": []any{ + map[string]any{"type": "text", "text": runawayText}, + }, + }, + }, + } + _ = json.NewEncoder(w).Encode(resp) + }, + invoke: func(h *Handler, c *gin.Context) { h.Responses(c) }, + }, + { + name: "ChatCompletions_Stream_SSE_ResponsesUpstream", + endpoint: "/v1/chat/completions", + routePath: "/v1/chat/completions", + upstreamType: auth.UpstreamOpenAIResponses, + model: "gpt-5.5", + stream: true, + reqBody: `{"model":"gpt-5.5","stream":true,"messages":[{"role":"user","content":"hi"}]}`, + mockHandler: func(w http.ResponseWriter, r *http.Request) { + // C12: 出站请求为 Responses SSE,Mock 必须返回 Responses SSE + w.Header().Set("Content-Type", "text/event-stream") + flusher := w.(http.Flusher) + chunk := `{"type":"response.output_text.delta","delta":"` + runawayText + `"}` + _, _ = io.WriteString(w, "data: "+chunk+"\n\n") + flusher.Flush() + }, + invoke: func(h *Handler, c *gin.Context) { h.ChatCompletions(c) }, + }, + { + name: "ChatCompletions_NonStream_JSON_ResponsesUpstream", + endpoint: "/v1/chat/completions", + routePath: "/v1/chat/completions", + upstreamType: auth.UpstreamOpenAIResponses, + model: "gpt-5.5", + stream: false, + reqBody: `{"model":"gpt-5.5","stream":false,"messages":[{"role":"user","content":"hi"}]}`, + mockHandler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "id": "resp_chat_json", + "object": "response", + "status": "completed", + "output": []any{ + map[string]any{ + "type": "message", + "content": []any{ + map[string]any{"type": "text", "text": runawayText}, + }, + }, + }, + } + _ = json.NewEncoder(w).Encode(resp) + }, + invoke: func(h *Handler, c *gin.Context) { h.ChatCompletions(c) }, + }, + { + name: "Grok_Native_ChatCompletions_Stream", + endpoint: "/v1/chat/completions", + routePath: "/v1/chat/completions", + upstreamType: auth.UpstreamGrok, + model: "grok-beta", + stream: true, + reqBody: `{"model":"grok-beta","stream":true,"messages":[{"role":"user","content":"hi"}]}`, + mockHandler: func(w http.ResponseWriter, r *http.Request) { + // C12: 区分 Native Grok chat 返回标准的 chat.completion.chunk + w.Header().Set("Content-Type", "text/event-stream") + flusher := w.(http.Flusher) + chunk := `{"id":"grok-1","choices":[{"index":0,"delta":{"content":"` + runawayText + `"}}],"object":"chat.completion.chunk"}` + _, _ = io.WriteString(w, "data: "+chunk+"\n\n") + flusher.Flush() + }, + invoke: func(h *Handler, c *gin.Context) { h.ChatCompletions(c) }, + }, + { + name: "AnthropicMessages_Stream_SSE", + endpoint: "/v1/messages", + routePath: "/v1/messages", + upstreamType: auth.UpstreamClaude, + model: "claude-3-7-sonnet", + stream: true, + reqBody: `{"model":"claude-3-7-sonnet","stream":true,"max_tokens":1024,"messages":[{"role":"user","content":"hi"}]}`, + mockHandler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + flusher := w.(http.Flusher) + _, _ = io.WriteString(w, "event: content_block_delta\ndata: "+`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"`+runawayText+`"}}`+"\n\n") + flusher.Flush() + }, + invoke: func(h *Handler, c *gin.Context) { h.Messages(c) }, + }, + { + name: "AnthropicMessages_NonStream_JSON", + endpoint: "/v1/messages", + routePath: "/v1/messages", + upstreamType: auth.UpstreamClaude, + model: "claude-3-7-sonnet", + stream: false, + reqBody: `{"model":"claude-3-7-sonnet","stream":false,"max_tokens":1024,"messages":[{"role":"user","content":"hi"}]}`, + mockHandler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "id": "msg_1", + "type": "message", + "role": "assistant", + "content": []any{ + map[string]any{"type": "text", "text": runawayText}, + }, + } + _ = json.NewEncoder(w).Encode(resp) + }, + invoke: func(h *Handler, c *gin.Context) { h.Messages(c) }, + }, + { + name: "GeminiNative_StreamGenerateContent_AntigravityOAuth", + endpoint: "/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse", + routePath: "/v1beta/models/*action", + upstreamType: auth.UpstreamAntigravity, + model: "gemini-2.5-pro", + stream: true, + reqBody: `{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + mockHandler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + flusher := w.(http.Flusher) + resp := `{"candidates":[{"content":{"role":"model","parts":[{"text":"` + runawayText + `"}]}}]}` + _, _ = io.WriteString(w, "data: "+resp+"\n\n") + flusher.Flush() + }, + invoke: func(h *Handler, c *gin.Context) { h.GeminiModelsAction(c) }, + }, + { + name: "GeminiNative_NonStream_GenerateContent_AntigravityOAuth", + endpoint: "/v1beta/models/gemini-2.5-pro:generateContent", + routePath: "/v1beta/models/*action", + upstreamType: auth.UpstreamAntigravity, + model: "gemini-2.5-pro", + stream: false, + reqBody: `{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, + mockHandler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "candidates": []any{ + map[string]any{ + "content": map[string]any{ + "role": "model", + "parts": []any{map[string]any{"text": runawayText}}, + }, + }, + }, + } + _ = json.NewEncoder(w).Encode(resp) + }, + invoke: func(h *Handler, c *gin.Context) { h.GeminiModelsAction(c) }, + }, + } + + for _, tc := range matrix { + t.Run(tc.name, func(t *testing.T) { + var upstreamCalls atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalls.Add(1) + tc.mockHandler(w, r) + })) + defer upstream.Close() + + if tc.upstreamType == auth.UpstreamAntigravity { + previous := antigravityOAuthEndpointBases + antigravityOAuthEndpointBases = []string{upstream.URL} + t.Cleanup(func() { antigravityOAuthEndpointBases = previous }) + } + + handler, _, _ := setupLifecycleTestHandler(t, upstream.URL, tc.upstreamType, tc.model) + + router := gin.New() + router.POST(tc.routePath, func(c *gin.Context) { + tc.invoke(handler, c) + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, tc.endpoint, strings.NewReader(tc.reqBody)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + + // 断言 1: 上游被真实调用,且由于 guard 阻断不重试,calls 严格为 1 (I1) + if calls := upstreamCalls.Load(); calls != 1 { + t.Fatalf("upstream calls = %d, want exactly 1 for %s", calls, tc.name) + } + + // 断言 2: 下游响应绝不能是 200 OK 且包含完整成功终态 + respBody := rec.Body.String() + if rec.Code == http.StatusOK && (strings.Contains(respBody, "response.completed") || strings.Contains(respBody, `"finishReason":"STOP"`)) { + t.Fatalf("downstream received successful completion for %s: %s", tc.name, respBody) + } + + // 断言 3: 失控文本下游不可见(整事件被拦截) + if strings.Contains(respBody, pattern+pattern) { + t.Fatalf("runaway pattern leaked to downstream client for %s", tc.name) + } + + // 断言 4: 包含公开的 guard 稳定 code 或返回 502 Bad Gateway + if !strings.Contains(respBody, GuardTriggeredPublicCode) && rec.Code != http.StatusBadGateway { + t.Fatalf("expected guard triggered code or 502 for %s, got code=%d body=%s", tc.name, rec.Code, respBody) + } + }) + } +} + +// 场景 12 (WS 专项): 真实 Client 连接路由,真实 WebSocket turn 请求,upstream 调用严格为 1 +func TestAcceptanceScenario12_WebSocketMatrix(t *testing.T) { + gin.SetMode(gin.TestMode) + + pattern := "ws_runaway_loop_12345678_" + runawayText := strings.Repeat(pattern, 400) // 24 * 400 = 9600B >= 9215B + + var upstreamCalls atomic.Int32 + previousExec := WebsocketExecuteFunc + t.Cleanup(func() { WebsocketExecuteFunc = previousExec }) + + WebsocketExecuteFunc = func(ctx context.Context, account *auth.Account, requestBody []byte, sessionID string, proxyOverride string, apiKey string, deviceCfg *DeviceProfileConfig, headers http.Header, poolRouteKey string) (*http.Response, error) { + upstreamCalls.Add(1) + sse := `data: {"type":"response.output_text.delta","delta":"` + runawayText + `"}` + "\n\n" + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(sse)), + }, nil + } + + handler, store, _ := setupLifecycleTestHandler(t, "https://upstream.example", auth.UpstreamOpenAIResponses, "gpt-5.5") + store.SetCodexModelMapping(`{"client-ws-alias":"gpt-5.5"}`) + + router := gin.New() + router.GET("/v1/responses", handler.ResponsesWebSocket) + router.GET("/v1/realtime", handler.RealtimeWebSocket) + + server := httptest.NewServer(router) + defer server.Close() + + // 1. 测试 ResponsesWebSocket + t.Run("ResponsesWebSocket_Turn", func(t *testing.T) { + upstreamCalls.Store(0) + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/v1/responses" + conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial websocket failed: %v", err) + } + defer conn.Close() + if resp != nil { + _ = resp.Body.Close() + } + + turnReq := `{"model":"client-ws-alias","input":"hello"}` + if err := conn.WriteMessage(websocket.TextMessage, []byte(turnReq)); err != nil { + t.Fatalf("write websocket turn: %v", err) + } + + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, msg, err := conn.ReadMessage() + if err != nil && !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.ClosePolicyViolation, websocket.CloseInternalServerErr) { + t.Fatalf("read websocket turn error: %v", err) + } + + msgStr := string(msg) + if strings.Contains(msgStr, pattern+pattern) { + t.Fatalf("runaway pattern leaked to downstream client: %s", msgStr) + } + if calls := upstreamCalls.Load(); calls != 1 { + t.Fatalf("upstream calls = %d, want exactly 1", calls) + } + }) + + // 2. 测试 RealtimeWebSocket + t.Run("RealtimeWebSocket_TextSubprotocol", func(t *testing.T) { + upstreamCalls.Store(0) + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/v1/realtime?model=gpt-5.5" + conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial realtime websocket failed: %v", err) + } + defer conn.Close() + if resp != nil { + _ = resp.Body.Close() + } + + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, _, _ = conn.ReadMessage() // session.created + + turnReq := `{"type":"conversation.item.create","item":{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}}` + _ = conn.WriteMessage(websocket.TextMessage, []byte(turnReq)) + _, _, _ = conn.ReadMessage() // item.created + + triggerReq := `{"type":"response.create","response":{"max_output_tokens":64}}` + if err := conn.WriteMessage(websocket.TextMessage, []byte(triggerReq)); err != nil { + t.Fatalf("write realtime trigger: %v", err) + } + + _, msg, err := conn.ReadMessage() + if err != nil && !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.ClosePolicyViolation, websocket.CloseInternalServerErr) { + t.Fatalf("read realtime error: %v", err) + } + if strings.Contains(string(msg), pattern+pattern) { + t.Fatalf("runaway pattern leaked to realtime client: %s", string(msg)) + } + if calls := upstreamCalls.Load(); calls != 1 { + t.Fatalf("upstream calls = %d, want exactly 1", calls) + } + }) +} + +// ----------------------------------------------------------------------------- +// 场景 13: hit-before-provenance/collector/replay 真实 sink 前置拦截 +// 真实观察点:真实 Responses Handler 开启 continuous retry 且上游永不成功, +// 校验真实 provenance cache 物理存储未被写入异常 sentinel +// ----------------------------------------------------------------------------- +func TestAcceptanceScenario13_HitBeforeProvenanceAndReplaySink(t *testing.T) { + gin.SetMode(gin.TestMode) + + badSentinel := "malicious_provenance_sentinel_xyz_" + runawayPayload := strings.Repeat(badSentinel, 400) // 34 * 400 = 13600B >= 9215B + + var upstreamCalls atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalls.Add(1) + w.Header().Set("Content-Type", "text/event-stream") + flusher := w.(http.Flusher) + // 上游输出失控 tool arguments delta + frame := `{"type":"response.function_call_arguments.delta","call_id":"call_13","delta":"` + runawayPayload + `"}` + _, _ = io.WriteString(w, "data: "+frame+"\n\n") + flusher.Flush() + })) + defer upstream.Close() + + handler, store, account := setupLifecycleTestHandler(t, upstream.URL, auth.UpstreamOpenAIResponses, "gpt-5.5") + // 开启 continuous retry 策略,使重试逻辑活跃 + store.SetContinuousRetryPolicy(database.ContinuousRetrySelective) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.5","stream":true,"input":"test"}`)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.Responses(c) + + // 真实观察点:检查 compaction provenance 物理缓存中绝对不能包含恶意 sentinel 摘要 + badDigest := compactionContentDigest(badSentinel) + raw, ok, err := handler.cache.GetRuntime(context.Background(), compactionProvenanceCacheNamespace, badDigest) + if ok || raw != nil { + t.Fatalf("CRITICAL: provenance cache stored malicious sentinel after guard tripped: %v", raw) + } + _ = err + _ = account + + if calls := upstreamCalls.Load(); calls != 1 { + t.Fatalf("continuous retry was not blocked before replay sink! calls = %d, want 1", calls) + } +} + +// ----------------------------------------------------------------------------- +// 场景 14: cancel/bodyclose 无 5s usage drain,newDrainableUpstreamContext 路径立即断开, +// 类型化原因保留不被 context.Canceled (499) 或 EOF (598) 覆盖 +// ----------------------------------------------------------------------------- +func TestAcceptanceScenario14_ImmediateCancelNo5sDrain(t *testing.T) { + gin.SetMode(gin.TestMode) + + pattern := "drain_bypass_pattern_" + runawayText := strings.Repeat(pattern, 500) + + upstreamCanceled := make(chan struct{}) + var closeOnce sync.Once + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + flusher := w.(http.Flusher) + _, _ = io.WriteString(w, "data: "+`{"type":"response.output_text.delta","delta":"`+runawayText+`"}`+"\n\n") + flusher.Flush() + + // 监测上游 context 是否在 guard 触发后立即被 cancel,而非等待 5 秒 drain + select { + case <-r.Context().Done(): + closeOnce.Do(func() { close(upstreamCanceled) }) + case <-time.After(3 * time.Second): + } + })) + defer upstream.Close() + + handler, _, _ := setupLifecycleTestHandler(t, upstream.URL, auth.UpstreamOpenAIResponses, "gpt-5.5") + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + clientCtx, clientCancel := context.WithCancel(context.Background()) + defer clientCancel() + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.5","stream":true,"input":"test"}`)).WithContext(clientCtx) + c.Request.Header.Set("Content-Type", "application/json") + + start := time.Now() + handlerDone := make(chan struct{}) + go func() { + handler.Responses(c) + close(handlerDone) + }() + + select { + case <-upstreamCanceled: + elapsed := time.Since(start) + if elapsed >= 2*time.Second { + t.Fatalf("upstream cancel delayed! took %v, must not wait for 5s drain", elapsed) + } + case <-time.After(2 * time.Second): + t.Fatalf("upstream context was not canceled immediately, still waiting (likely 5s drain bug)") + } + + <-handlerDone + + // 断言:下游错误状态不被误判为 499 (Client Closed Request) 或 598 (Network Read Timeout) + if rec.Code == 499 || rec.Code == 598 { + t.Fatalf("guard outcome misclassified as %d", rec.Code) + } + + // Coordinator 原语断言:HandleTripped 返回的 outcome 经过 OverlayGuardOutcome 后,guardReason 保持为 repetition + t.Run("Coordinator_Outcome_Overlay_Typed_Reason", func(t *testing.T) { + guard := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + _ = guard.ObserveAppend("c_14", "", outputguard.KindText, bytes.Repeat([]byte("rep_14_"), 2000)) + + uCtx, uCancel := context.WithCancel(context.Background()) + defer uCancel() + var bodyClosed atomic.Bool + closer := &testCloser{onClose: func() { bodyClosed.Store(true) }} + + coordinator := NewGuardTerminationCoordinator(guard, uCancel, closer) + outcome := coordinator.HandleTripped(guard.TrippedReason()) + + if uCtx.Err() == nil { + t.Fatalf("expected upstream context canceled immediately") + } + if !bodyClosed.Load() { + t.Fatalf("expected upstream body closed immediately") + } + + overlaid := OverlayGuardOutcome(outcome, guard) + if !overlaid.guardTripped || overlaid.guardReason != "repetition" { + t.Fatalf("typed reason was overwritten! got %+v", overlaid) + } + if overlaid.logStatusCode == 499 || overlaid.logStatusCode == 598 { + t.Fatalf("guard outcome misclassified as 499 or 598: %d", overlaid.logStatusCode) + } + }) +} + +// ----------------------------------------------------------------------------- +// 场景 15: 保活/自动续想与 guard 并发无旧缓冲泄露、无成功/重复终态、无 data race +// 覆盖 coalesce 与 immediate 策略、真实 downstreamMu 锁约定与 Finalize 零泄露 +// ----------------------------------------------------------------------------- +func TestAcceptanceScenario15_ConcurrencyKeepaliveNoRace(t *testing.T) { + policies := []struct { + name string + policy string + }{ + {name: "CoalescePolicy", policy: StreamFlushPolicyCoalesce}, + {name: "ImmediatePolicy", policy: StreamFlushPolicyImmediate}, + } + + for _, p := range policies { + t.Run(p.name, func(t *testing.T) { + guard := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + var outBuf bytes.Buffer + var downstreamMu sync.Mutex + writer := &streamFlushWriter{ + writer: &outBuf, + policy: p.policy, + interval: 10 * time.Millisecond, + } + + var wg sync.WaitGroup + var stopKeepalive atomic.Bool + + // 协程 1: 保活注释 Ticker (按 downstreamMu 规范加锁保护) + wg.Add(1) + go func() { + defer wg.Done() + for !stopKeepalive.Load() { + downstreamMu.Lock() + _ = writer.WriteSSEComment("keepalive-ping") + downstreamMu.Unlock() + time.Sleep(200 * time.Microsecond) + } + }() + + // 协程 2: 并发触发 guard 命中与清空缓冲 + wg.Add(1) + go func() { + defer wg.Done() + // 注入失控重复文本到 buffer (模拟 coalesce 阶段积累的未 flush 数据) + pattern := []byte("race_runaway_pattern_") + downstreamMu.Lock() + _ = writer.WriteBytes(pattern) + downstreamMu.Unlock() + + _ = guard.ObserveAppend("c_15", "", outputguard.KindText, bytes.Repeat(pattern, 500)) + + // 按生产 downstreamMu 约定锁定清空 + downstreamMu.Lock() + writer.DiscardBuffered() + downstreamMu.Unlock() + + stopKeepalive.Store(true) + }() + + // 协程 3: 并发读取 guard 状态 + wg.Add(1) + go func() { + defer wg.Done() + for !stopKeepalive.Load() { + _ = guard.IsTripped() + _ = guard.TrippedReason() + time.Sleep(100 * time.Microsecond) + } + }() + + wg.Wait() + + // Finalize 调用:断言 DiscardBuffered 之后不再释放异常内容,且无成功帧 + downstreamMu.Lock() + _ = writer.Finalize() + downstreamMu.Unlock() + + outStr := outBuf.String() + if strings.Contains(outStr, "race_runaway_pattern_") { + t.Fatalf("buffer leak! Finalize released discarded runaway bytes: %s", outStr) + } + if strings.Contains(outStr, "response.completed") || strings.Contains(outStr, "[DONE]") { + t.Fatalf("illegal terminal frame emitted: %s", outStr) + } + }) + } +} + +// ----------------------------------------------------------------------------- +// 场景 16: 各 ordinary/continuous select/catchall/换号/WS HTTP fallback/ +// context repair/compact/continue 入口调用数 == 1 (不 >1 容忍 0) +// ----------------------------------------------------------------------------- +func TestAcceptanceScenario16_RecoveryBlockedCallsExactlyOne(t *testing.T) { + gin.SetMode(gin.TestMode) + + pattern := "retry_block_test_loop_" + runawayText := strings.Repeat(pattern, 450) // 22 * 450 = 9900B >= 9215B + + paths := []struct { + name string + policy database.ContinuousRetryPolicy + maxRetry int + setup func(h *Handler, store *auth.Store, upstreamURL string) + trigger func(h *Handler, upstreamURL string) *httptest.ResponseRecorder + }{ + { + name: "OrdinaryRetry", + policy: database.ContinuousRetryOff, + maxRetry: 3, + trigger: func(h *Handler, upstreamURL string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.5","stream":true,"input":"hi"}`)) + c.Request.Header.Set("Content-Type", "application/json") + h.Responses(c) + return rec + }, + }, + { + name: "SelectiveContinuousRetry", + policy: database.ContinuousRetrySelective, + maxRetry: 3, + trigger: func(h *Handler, upstreamURL string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.5","stream":true,"input":"hi"}`)) + c.Request.Header.Set("Content-Type", "application/json") + h.Responses(c) + return rec + }, + }, + { + name: "CatchAllContinuousRetry", + policy: database.ContinuousRetryCatchAll, + maxRetry: 3, + trigger: func(h *Handler, upstreamURL string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.5","stream":true,"input":"hi"}`)) + c.Request.Header.Set("Content-Type", "application/json") + h.Responses(c) + return rec + }, + }, + { + name: "AccountRotation", + policy: database.ContinuousRetryCatchAll, + maxRetry: 2, + setup: func(h *Handler, store *auth.Store, upstreamURL string) { + // 添加第二个候选账号供轮换 + store.AddAccount(&auth.Account{ + DBID: 202, + UpstreamType: auth.UpstreamOpenAIResponses, + Status: auth.StatusReady, + BaseURL: upstreamURL, + APIKey: "sk-recovery-rotate-2", + Models: []string{"gpt-5.5"}, + }) + }, + trigger: func(h *Handler, upstreamURL string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.5","stream":true,"input":"hi"}`)) + c.Request.Header.Set("Content-Type", "application/json") + h.Responses(c) + return rec + }, + }, + { + name: "ContextRepair", + policy: database.ContinuousRetryOff, + maxRetry: 2, + trigger: func(h *Handler, upstreamURL string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + // 携带 previous_response_id 模拟上下文修复链路 + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.5","previous_response_id":"resp_prev_123","input":"continue"}`)) + c.Request.Header.Set("Content-Type", "application/json") + h.Responses(c) + return rec + }, + }, + { + name: "AutoCompact", + policy: database.ContinuousRetryOff, + maxRetry: 2, + trigger: func(h *Handler, upstreamURL string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + // 模拟 compact 请求 + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.5","input":[{"type":"compaction","encrypted_content":"enc_state"}]}`)) + c.Request.Header.Set("Content-Type", "application/json") + h.Responses(c) + return rec + }, + }, + { + name: "AutoContinue", + policy: database.ContinuousRetryOff, + maxRetry: 2, + trigger: func(h *Handler, upstreamURL string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.5","stream":true,"input":"auto-continue-token"}`)) + c.Request.Header.Set("Content-Type", "application/json") + h.Responses(c) + return rec + }, + }, + } + + for _, tc := range paths { + t.Run(tc.name, func(t *testing.T) { + var upstreamCalls atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalls.Add(1) + w.Header().Set("Content-Type", "text/event-stream") + flusher := w.(http.Flusher) + _, _ = io.WriteString(w, "data: "+`{"type":"response.output_text.delta","delta":"`+runawayText+`"}`+"\n\n") + flusher.Flush() + })) + defer upstream.Close() + + handler, store, _ := setupLifecycleTestHandler(t, upstream.URL, auth.UpstreamOpenAIResponses, "gpt-5.5") + store.SetContinuousRetryPolicy(tc.policy) + if tc.setup != nil { + tc.setup(handler, store, upstream.URL) + } + + rec := tc.trigger(handler, upstream.URL) + + calls := upstreamCalls.Load() + if calls != 1 { + t.Fatalf("path %s executed retry or failed to call upstream! upstreamCalls = %d, want exactly 1 (status=%d, body=%s)", + tc.name, calls, rec.Code, rec.Body.String()) + } + }) + } +} + +// ----------------------------------------------------------------------------- +// 场景 17: HTTP pre 502 / post 协议 envelope (Chat 无 DONE/Anthropic 无 stop/Gemini 裸 JSON/WS 租约废弃) +// ----------------------------------------------------------------------------- +func TestAcceptanceScenario17_ProtocolTerminalEnvelopes(t *testing.T) { + // 1. 头未提交时:HTTP 502 且携带标准 APIError 结构 + rec502 := httptest.NewRecorder() + c502, _ := gin.CreateTestContext(rec502) + c502.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + var mu502 sync.Mutex + clientGone502 := false + + err := SendDownstreamGuardTerminalHTTP(c502, &mu502, &clientGone502, "chat", "repetition") + if err != nil { + t.Fatalf("SendDownstreamGuardTerminalHTTP error: %v", err) + } + if rec502.Code != http.StatusBadGateway { + t.Fatalf("expected 502 Bad Gateway for uncommitted error, got %d", rec502.Code) + } + if !strings.Contains(rec502.Body.String(), GuardTriggeredPublicCode) { + t.Fatalf("body missing %s: %s", GuardTriggeredPublicCode, rec502.Body.String()) + } + + // 2. Chat SSE 真实流式已提交场景:严禁带有 [DONE] 或 finish_reason: "stop" + t.Run("Chat_Committed_Stream_No_DONE", func(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + flusher := w.(http.Flusher) + runaway := strings.Repeat("chat_runaway_repeat_", 500) + _, _ = io.WriteString(w, "data: "+`{"type":"response.output_text.delta","delta":"`+runaway+`"}`+"\n\n") + flusher.Flush() + })) + defer upstream.Close() + + handler, _, _ := setupLifecycleTestHandler(t, upstream.URL, auth.UpstreamOpenAIResponses, "gpt-5.5") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"gpt-5.5","stream":true,"messages":[{"role":"user","content":"hi"}]}`)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.ChatCompletions(c) + chatStr := rec.Body.String() + if strings.Contains(chatStr, "[DONE]") { + t.Fatalf("Chat stream terminal contains illegal [DONE]: %s", chatStr) + } + if strings.Contains(chatStr, `"stop"`) { + t.Fatalf("Chat stream terminal contains illegal stop finish_reason: %s", chatStr) + } + }) + + // 3. Anthropic Messages 真实流式已提交场景:严禁带有 message_stop + t.Run("Anthropic_Committed_Stream_No_MessageStop", func(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + flusher := w.(http.Flusher) + runaway := strings.Repeat("claude_runaway_repeat_", 500) + _, _ = io.WriteString(w, "event: content_block_delta\ndata: "+`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"`+runaway+`"}}`+"\n\n") + flusher.Flush() + })) + defer upstream.Close() + + handler, _, _ := setupLifecycleTestHandler(t, upstream.URL, auth.UpstreamClaude, "claude-3-7-sonnet") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{"model":"claude-3-7-sonnet","stream":true,"max_tokens":1024,"messages":[{"role":"user","content":"hi"}]}`)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.Messages(c) + anthropicStr := rec.Body.String() + if strings.Contains(anthropicStr, "message_stop") { + t.Fatalf("Anthropic stream terminal contains illegal message_stop: %s", anthropicStr) + } + }) + + // 4. WebSocket SendDownstreamGuardTerminalWS 实际连接与 lease 销毁 + t.Run("WS_Terminal_And_Lease_Destroyed", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upgrader := websocket.Upgrader{} + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + + _ = SendDownstreamGuardTerminalWS(conn, "stream_test_17", "repetition") + })) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial WS error: %v", err) + } + defer conn.Close() + + _, msg, err := conn.ReadMessage() + if err != nil && !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.ClosePolicyViolation, websocket.CloseInternalServerErr) { + t.Fatalf("read WS terminal error: %v", err) + } + if len(msg) > 0 && !strings.Contains(string(msg), GuardTriggeredPublicCode) { + t.Fatalf("WS terminal missing %s: %s", GuardTriggeredPublicCode, string(msg)) + } + }) + + // 5. Envelope 基础原语格式验证 + t.Run("Format_Envelope_Primitives", func(t *testing.T) { + chatEnv, _, err := FormatGuardTerminalEnvelope("chat", "") + if err != nil || strings.Contains(string(chatEnv), "[DONE]") || !strings.Contains(string(chatEnv), GuardTriggeredPublicCode) { + t.Fatalf("invalid chat envelope: %s err=%v", string(chatEnv), err) + } + anthropicEnv, _, err := FormatGuardTerminalEnvelope("anthropic", "") + if err != nil || strings.Contains(string(anthropicEnv), "message_stop") || !strings.Contains(string(anthropicEnv), GuardTriggeredPublicCode) { + t.Fatalf("invalid anthropic envelope: %s err=%v", string(anthropicEnv), err) + } + geminiEnv, _, err := FormatGuardTerminalEnvelope("gemini", "") + if err != nil || !gjson.Valid(string(geminiEnv)) || !strings.Contains(string(geminiEnv), GuardTriggeredPublicCode) { + t.Fatalf("invalid gemini envelope: %s err=%v", string(geminiEnv), err) + } + }) +} + +// ----------------------------------------------------------------------------- +// 场景 18: TryLock 已 commit 锁占无追加 bytes/clientGone、SetWriteDeadline(5s) 正断言、 +// unsupportedWriter 无 Write 并退出 +// ----------------------------------------------------------------------------- +func TestAcceptanceScenario18_TryLockAndWriteDeadlines(t *testing.T) { + // 1. 锁争用(TryLock 失败):立即安全放弃,标记 clientGone,不产生孤儿 goroutine 阻塞 + var downstreamMu sync.Mutex + downstreamMu.Lock() // 人为占用锁 + clientGone := false + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + done := make(chan error, 1) + go func() { + done <- SendDownstreamGuardTerminalHTTP(c, &downstreamMu, &clientGone, "responses", "repetition") + }() + + select { + case err := <-done: + if err == nil { + t.Fatalf("expected error on lock contention, got nil") + } + if rec.Body.Len() > 0 { + t.Fatalf("wrote bytes while lock was held by another goroutine!") + } + if !clientGone { + t.Fatalf("expected clientGone = true on lock contention") + } + case <-time.After(150 * time.Millisecond): + t.Fatalf("SendDownstreamGuardTerminalHTTP hung on contended lock! Orphan goroutine detected!") + } + downstreamMu.Unlock() + + // 2. SetWriteDeadline(5s) 正断言与写入成功测试 + t.Run("SetWriteDeadline_5s_Assertion", func(t *testing.T) { + rawRecorder := httptest.NewRecorder() + spy := &spyResponseControllerWriter{ResponseWriter: rawRecorder} + cSpy, _ := gin.CreateTestContext(rawRecorder) + cSpy.Writer = &customGinResponseWriter{ResponseWriter: spy} + cSpy.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + var mu sync.Mutex + clientGoneSpy := false + err := SendDownstreamGuardTerminalHTTP(cSpy, &mu, &clientGoneSpy, "responses", "repetition") + if err != nil { + t.Fatalf("SendDownstreamGuardTerminalHTTP with spy error: %v", err) + } + if !spy.deadlineSet.Load() { + t.Fatalf("expected SetWriteDeadline(5s) was called, but was not") + } + if !spy.writeCalled.Load() { + t.Fatalf("expected terminal bytes written, but Write was not called") + } + }) + + // 3. unsupportedWriter: 安全退出,严禁无界写入,严禁 nil err 通过 + t.Run("Unsupported_Writer_No_Write", func(t *testing.T) { + unsupportedWriter := &customNoDeadlineWriter{header: make(http.Header)} + recUnsupported := httptest.NewRecorder() + cUnsup, _ := gin.CreateTestContext(recUnsupported) + cUnsup.Writer = &customGinResponseWriter{ResponseWriter: unsupportedWriter} + cUnsup.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + var muUnsup sync.Mutex + clientGoneUnsup := false + errUnsup := SendDownstreamGuardTerminalHTTP(cUnsup, &muUnsup, &clientGoneUnsup, "responses", "repetition") + // I7: unsupported nil err 不能 pass,必须返回明确的错误或 http.ErrNotSupported + if errUnsup == nil { + t.Fatalf("unsupported writer must return non-nil error, got nil") + } + if !errors.Is(errUnsup, http.ErrNotSupported) && !strings.Contains(errUnsup.Error(), "not supported") { + t.Fatalf("unexpected error on unsupported writer: %v", errUnsup) + } + if unsupportedWriter.writeCount > 0 { + t.Fatalf("unsupported writer performed unsafe unbounded write! writeCount = %d", unsupportedWriter.writeCount) + } + }) +} + +// ----------------------------------------------------------------------------- +// 场景 19: 真实 accountReportFailure / store health / cache / usage 检查 +// ----------------------------------------------------------------------------- +func TestAcceptanceScenario19_ZeroPenaltyAndNoCacheUsageFabrication(t *testing.T) { + handler, store, account := setupLifecycleTestHandler(t, "https://upstream.example", auth.UpstreamOpenAIResponses, "gpt-5.5") + + guardOutcome := streamOutcome{ + logStatusCode: http.StatusBadGateway, + failureKind: "model_output_guard", + failureMessage: "Output guard tripped", + guardTripped: true, + guardReason: "tool_argument_bytes", + penalize: false, // 严格豁免惩罚 + } + + // 1. 调用真实 reportStreamOutcomeFailure + initialHealth := account.HealthTier + handler.reportStreamOutcomeFailure(account, guardOutcome, 100*time.Millisecond) + + // 断言:账号没有被标记为 cooldown 或降低健康级别 + if account.HealthTier != initialHealth { + t.Fatalf("account health was penalized for guard failure! before=%v, after=%v", initialHealth, account.HealthTier) + } + + // 2. 真实 DB Usage 与 Cache 检查:断言无虚构 token,无成功缓存 + if handler.db != nil { + handler.db.FlushUsageLogs() + logs, err := handler.db.ListUsageLogsByFilter(context.Background(), database.UsageLogFilter{ + Start: time.Now().Add(-time.Minute), End: time.Now().Add(time.Minute), IncludeCanceled: true, + }) + if err == nil { + for _, logEntry := range logs { + if logEntry.StatusCode == http.StatusOK { + t.Fatalf("found successful usage log for tripped guard stream: %+v", logEntry) + } + if logEntry.InputTokens > 0 && logEntry.OutputTokens > 100000 { + t.Fatalf("fabricated usage tokens detected: %+v", logEntry) + } + } + } + } + + _ = store +} + +// ----------------------------------------------------------------------------- +// 场景 20: 并发请求与 WS Lease 强隔离 +// ----------------------------------------------------------------------------- +func TestAcceptanceScenario20_ConcurrentRequestsAndLeaseIsolation(t *testing.T) { + gin.SetMode(gin.TestMode) + + // C9: 周期 18B 循环 600 次 = 10800B >= 9215B,确保触发 repetition 且不先触发其它 budget + pattern := "abnormal_loop_xyz_" + runawayText := strings.Repeat(pattern, 600) + + // Mock Upstream 针对不同请求参数返回正常与异常数据 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + w.Header().Set("Content-Type", "text/event-stream") + flusher := w.(http.Flusher) + if strings.Contains(string(body), "runaway") { + // 异常请求返回失控流 + _, _ = io.WriteString(w, "data: "+`{"type":"response.output_text.delta","delta":"`+runawayText+`"}`+"\n\n") + } else { + // 正常请求返回成功流 + _, _ = io.WriteString(w, "data: "+`{"type":"response.output_text.delta","delta":"Valid output"}`+"\n\n") + _, _ = io.WriteString(w, "data: "+`{"type":"response.completed","response":{"id":"resp_ok","status":"completed"}}`+"\n\n") + } + flusher.Flush() + })) + defer upstream.Close() + + handler, store, _ := setupLifecycleTestHandler(t, upstream.URL, auth.UpstreamOpenAIResponses, "gpt-5.5") + store.SetCodexModelMapping(`{"client-ws-alias":"gpt-5.5"}`) + + router := gin.New() + router.POST("/v1/responses", handler.Responses) + router.GET("/v1/responses", handler.ResponsesWebSocket) + server := httptest.NewServer(router) + defer server.Close() + + // 1. 并发 HTTP 请求强隔离测试 + t.Run("Concurrent_HTTP_Isolation", func(t *testing.T) { + var wg sync.WaitGroup + recNormal := httptest.NewRecorder() + recAbnormal := httptest.NewRecorder() + + // 并发执行正常请求 + wg.Add(1) + go func() { + defer wg.Done() + c, _ := gin.CreateTestContext(recNormal) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.5","stream":true,"input":"normal"}`)) + c.Request.Header.Set("Content-Type", "application/json") + handler.Responses(c) + }() + + // 并发执行异常请求 + wg.Add(1) + go func() { + defer wg.Done() + c, _ := gin.CreateTestContext(recAbnormal) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.5","stream":true,"input":"runaway"}`)) + c.Request.Header.Set("Content-Type", "application/json") + handler.Responses(c) + }() + + wg.Wait() + + // 断言 1: 异常请求被准确拦截 + abnormalBody := recAbnormal.Body.String() + if !strings.Contains(abnormalBody, GuardTriggeredPublicCode) && recAbnormal.Code != http.StatusBadGateway { + t.Fatalf("abnormal request was not tripped, got code=%d body=%s", recAbnormal.Code, abnormalBody) + } + + // 断言 2: 正常请求完全不受影响,成功输出并完成 + normalBody := recNormal.Body.String() + if strings.Contains(normalBody, GuardTriggeredPublicCode) { + t.Fatalf("isolation failure! normal request was falsely tripped by concurrent abnormal request") + } + if !strings.Contains(normalBody, "Valid output") { + t.Fatalf("normal request missing expected output: %s", normalBody) + } + }) + + // 2. 并发 WebSocket 租约与连接强隔离测试:正常 WS 租约返还,异常 WS 租约废弃 + t.Run("Concurrent_WS_Lease_Isolation", func(t *testing.T) { + previousExec := WebsocketExecuteFunc + t.Cleanup(func() { WebsocketExecuteFunc = previousExec }) + + WebsocketExecuteFunc = func(ctx context.Context, account *auth.Account, requestBody []byte, sessionID string, proxyOverride string, apiKey string, deviceCfg *DeviceProfileConfig, headers http.Header, poolRouteKey string) (*http.Response, error) { + if strings.Contains(string(requestBody), "runaway") { + sse := `data: {"type":"response.output_text.delta","delta":"` + runawayText + `"}` + "\n\n" + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(sse)), + }, nil + } + sse := `data: {"type":"response.output_text.delta","delta":"Normal WS output"}` + "\n\n" + + `data: {"type":"response.completed","response":{"id":"resp_ws_ok","status":"completed"}}` + "\n\n" + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(sse)), + }, nil + } + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/v1/responses" + + var wg sync.WaitGroup + var normalMsg string + var abnormalErr error + var abnormalClosed bool + + // 并发 WS 正常会话 + wg.Add(1) + go func() { + defer wg.Done() + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + return + } + defer conn.Close() + _ = conn.WriteMessage(websocket.TextMessage, []byte(`{"model":"client-ws-alias","input":"normal"}`)) + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, msg, _ := conn.ReadMessage() + normalMsg = string(msg) + }() + + // 并发 WS 异常会话 + wg.Add(1) + go func() { + defer wg.Done() + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + return + } + defer conn.Close() + _ = conn.WriteMessage(websocket.TextMessage, []byte(`{"model":"client-ws-alias","input":"runaway"}`)) + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, _, abnormalErr = conn.ReadMessage() + if abnormalErr != nil { + abnormalClosed = true + } + }() + + wg.Wait() + + if !strings.Contains(normalMsg, "Normal WS output") { + t.Fatalf("normal WS session failed to deliver output: %s", normalMsg) + } + if !abnormalClosed { + t.Fatalf("expected abnormal WS lease to be discarded and connection closed, but was not") + } + }) +} + +// ----------------------------------------------------------------------------- +// 辅助类型定义 (包含完整的 gin.ResponseWriter 实现) +// ----------------------------------------------------------------------------- + +type testCloser struct { + onClose func() +} + +func (c *testCloser) Close() error { + if c.onClose != nil { + c.onClose() + } + return nil +} + +type customNoDeadlineWriter struct { + header http.Header + writeCount int +} + +func (w *customNoDeadlineWriter) Header() http.Header { return w.header } +func (w *customNoDeadlineWriter) Write(b []byte) (int, error) { w.writeCount++; return len(b), nil } +func (w *customNoDeadlineWriter) WriteHeader(statusCode int) {} + +type customGinResponseWriter struct { + http.ResponseWriter +} + +func (w *customGinResponseWriter) CloseNotify() <-chan bool { return nil } +func (w *customGinResponseWriter) Flush() {} +func (w *customGinResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + var conn net.Conn + var rw *bufio.ReadWriter + return conn, rw, errors.New("hijack unsupported") +} +func (w *customGinResponseWriter) Size() int { return 0 } +func (w *customGinResponseWriter) Status() int { return 200 } +func (w *customGinResponseWriter) WriteHeaderNow() {} +func (w *customGinResponseWriter) Written() bool { return true } +func (w *customGinResponseWriter) WriteString(s string) (int, error) { return w.Write([]byte(s)) } +func (w *customGinResponseWriter) Pusher() http.Pusher { return nil } + +var _ gin.ResponseWriter = (*customGinResponseWriter)(nil) + +type spyResponseControllerWriter struct { + http.ResponseWriter + deadlineSet atomic.Bool + lastDeadline atomic.Int64 + writtenNanos atomic.Int64 + writeCalled atomic.Bool + lockAttemptAt atomic.Int64 + cancelNoticeAt atomic.Int64 +} + +func (s *spyResponseControllerWriter) SetWriteDeadline(deadline time.Time) error { + s.deadlineSet.Store(true) + s.lastDeadline.Store(deadline.UnixNano()) + return nil +} + +func (s *spyResponseControllerWriter) Write(b []byte) (int, error) { + s.writeCalled.Store(true) + s.writtenNanos.Store(time.Now().UnixNano()) + return s.ResponseWriter.Write(b) +} + +func (s *spyResponseControllerWriter) WriteHeader(code int) { + s.ResponseWriter.WriteHeader(code) +} + +func (s *spyResponseControllerWriter) Header() http.Header { + return s.ResponseWriter.Header() +} +``` + +- [x] **Step 2: Run test to verify RED state (expecting uncompleted handler hooks or compile error if 04 not fully wired)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestAcceptanceScenario(12|13|14|15|16|17|18|19|20).*"` +Expected: FAIL/RED (prior to full 04 integration, demonstrates true failing test baseline without placeholder) + +- [x] **Step 3: Run test to verify passing (Green) after 04 implementation is integrated** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race ./proxy -run "TestAcceptanceScenario(12|13|14|15|16|17|18|19|20).*"` +Expected: PASS ok github.com/codex2api/proxy + +- [x] **Step 4: Commit** + +```bash +git add proxy/acceptance_lifecycle_test.go +git commit -m "test(outputguard): implement lifecycle and protocol termination acceptance tests for scenarios 12 to 20" +``` diff --git a/docs/superpowers/plans/model-output-guard-tdd/05c-performance.md b/docs/superpowers/plans/model-output-guard-tdd/05c-performance.md new file mode 100644 index 000000000..3cd585faf --- /dev/null +++ b/docs/superpowers/plans/model-output-guard-tdd/05c-performance.md @@ -0,0 +1,1220 @@ +# 05c-Performance: 统计学度量工具、性能基准门禁与自动化调度执行计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 基于已批准的 Spec §9.3(性能与资源上限场景 24~28),构建高精度非周期 PRNG、基于 Linux `syscall.Getrusage` 的微秒级真进程 CPU 计时器(非 Linux build tag 显式隔离绝不伪造 CPU 时间)、P95/Median 统计分析与受控自动化 Gate 校验器;并在同 package `proxy` 下集成真实 HTTP 流式管线与 `security/outputguard` 核心状态机,对场景 24(TTFT P95 增量 $\le 10\text{ms}$、CPU 增量 $\le 20\%$,且 baseline CPU $\le 0$ 视无效实验 FAIL)、场景 25(1/2/4 MiB 线性度增长比值 $\le 2.5$,分别核验 envelope、scan 与 snapshot)、场景 26($\le 64\text{KiB/channel}$、长跑 10 分钟堆/Goroutine/FD 增长自动 fail)、场景 27(单字节极端细 delta、4096 计数及长思考预算先于字节耗尽分析)与场景 28(真实 Error/Log 脱敏边界)执行严密测试与 25 分钟 `-race` 调度。 + +**Architecture:** +1. `proxy/perfstat/`: 统计学度量工具包,包含微秒级 Linux `syscall.Getrusage` 计时器(`cpu_linux.go`)与非 Linux 编译隔离(`cpu_other.go`)、基于 uint64 模乘加线性同余(LCG)高位混淆采样的 PRNG(具备大状态空间与非短周期性质,但因乘加同余性质不作绝对无环无碰撞宣称)、分位数插值计算及基准门禁校验器(`ValidateTTFTAndCPUGates`、`ValidateLinearScalingRatio`); +2. `proxy/acceptance_benchmark_test.go`: 性能与基准验收测试套件(package `proxy`),复用真实 `NewHandler` 与 Mock Upstream 完整 HTTP SSE 流式链路(场景 24),并在多批次采样中保证 baseline CPU 真实消耗,杜绝 0 耗时被错误归一化;在场景 25 中对真实 `RequestGuard` 的 Append/Scan/Snapshot 分项测算并断言未触发 guard;场景 26 针对并发长跑设立环境变量门禁(未设置时默认 smoke 并在报告中如实陈述);场景 27 量化 4096 deltas 拦截;场景 28 断言内部与公开诊断绝对不泄露敏感 Pattern; +3. `scripts/run_acceptance_suite.sh`: 自动化执行调度脚本,设置 25 分钟超时,强制 `-race`,支持真实长跑环境变量注入与最终状态矩阵输出。 + +**Tech Stack:** Go 1.26+, `syscall.Getrusage`, `net/http/httptest`, `github.com/codex2api/auth`, `github.com/codex2api/config`, `github.com/codex2api/proxy`, `github.com/codex2api/security/outputguard`, `github.com/gin-gonic/gin`. + +**Spec:** `docs/superpowers/specs/2026-10-28-model-output-guard-design.md` + +## Global Constraints + +- **不可妥协的设计取舍**: + - 场景 24 必须包含 HTTP 客户端真实接收有效 Delta,若 Baseline CPU 非正($\le 0$)视作测量采样不足或无效实验,测试直接 FAIL,严禁除以 0 或把 ratio 设为 0。 + - 场景 25 必须验证 1/2/4 MiB 且每倍增长比值 $\le 2.5$,分别核验 Envelope、Scan、Snapshot 分项,断言 guard 不被 tripped,PRNG 采用高周期算法而非 `mod 251` 周期数据。 + - 场景 26 验证每 Channel 状态 $\le 64\text{KiB}$,结合 `runtime.KeepAlive` 保持引用;提供 $\ge 10$ 分钟长跑门禁,堆内存、Goroutine 与 FD 异常增长由测试自动 `t.Fatalf` 判定失败(非仅 `t.Log`);默认快速 smoke 必须在报告中如实说明长跑尚未执行。 + - 场景 27 细 delta 压测模拟 1~4B 极端细碎 delta 与 4096 次计数,量化事件预算相对字节预算更早耗尽的时机,不可被周期 fixture 提前误判为 repetition。 + - 场景 28 日志与错误 envelope 脱敏,内部及公开 reason 严禁包含敏感内容与 key,不把 DSH 恢复当验收依据。 +- **编译与隔离约束**: + - 测试命令使用独立缓存环境:`GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race ...`。 + - 非 Linux 系统通过 `//go:build !linux` 隔离,显式不支持真进程 CPU 采集,绝不使用 `time.Since` 挂钟时间伪造进程 CPU。 + +--- + +## File Structure + +| 文件路径 | 状态 | 职责描述 | +|---|---|---| +| `proxy/perfstat/perfstat.go` | 新建 | 非周期 PRNG、分位数/中位数统计学计算、TTFT 与 CPU 增量门禁校验器、线性度增长比值校验器 | +| `proxy/perfstat/cpu_linux.go` | 新建 | Linux 平台真进程 CPU 计时器(通过 `syscall.Getrusage(syscall.RUSAGE_SELF, ...)` 获取微秒级 User + System CPU) | +| `proxy/perfstat/cpu_other.go` | 新建 | 非 Linux 平台编译隔离,明确不支持真进程 CPU 采集,返回 0 触发无效实验判定,绝不伪造 | +| `proxy/perfstat/perfstat_test.go` | 新建 | 统计学工具单元测试(PRNG 无短周期验证、分位数计算、验证器红绿反证测试) | +| `proxy/acceptance_benchmark_test.go` | 新建 | 场景 24~28 端到端性能与资源门禁验收测试(同一 package `proxy`,可无缝访问内部真实 Handler 与 fixture) | +| `scripts/run_acceptance_suite.sh` | 新建 | 完整自动化调度脚本(强制 `-race`,25m 超时,接收 `RUN_OUTPUTGUARD_LONG_RUN` 环境变量) | + +--- + +## Tasks + +### Task 11.1: 统计学度量工具包(Process CPU Timer, Non-Repeating PRNG, Validators) + +**Files:** +- Create: `proxy/perfstat/perfstat.go` +- Create: `proxy/perfstat/cpu_linux.go` +- Create: `proxy/perfstat/cpu_other.go` +- Test: `proxy/perfstat/perfstat_test.go` + +**Interfaces:** +- Produces: + ```go + package perfstat + + type ProcessCPUTimer struct { ... } + func StartProcessCPUTimer() *ProcessCPUTimer + func (t *ProcessCPUTimer) ElapsedProcessCPU() time.Duration + func (t *ProcessCPUTimer) ElapsedWallTime() time.Duration + func (t *ProcessCPUTimer) GetrusageFailed() bool + + type NonRepeatingPRNG struct { ... } + func NewNonRepeatingPRNG(seed int64) *NonRepeatingPRNG + func (p *NonRepeatingPRNG) NextUint64() uint64 + func (p *NonRepeatingPRNG) Fill(buf []byte) + func (p *NonRepeatingPRNG) GenerateASCII(length int) []byte + + func CalculatePercentile(samples []float64, percentile float64) float64 + func CalculateMedian(samples []float64) float64 + + type TTFTAndCPUBenchmarkResult struct { + BaselineP95TTFTMs float64 + GuardP95TTFTMs float64 + TTFTDeltaMs float64 + BaselineCPUMs float64 + GuardCPUMs float64 + CPUIncreaseRatio float64 + } + func ValidateTTFTAndCPUGates(res TTFTAndCPUBenchmarkResult) error + func ValidateLinearScalingRatio(cpu1MiB, cpu2MiB, cpu4MiB float64) error + ``` + +- [ ] **Step 1: Write failing test for perfstat library and validators** + +```go +// proxy/perfstat/perfstat_test.go +package perfstat + +import ( + "math" + "runtime" + "strings" + "testing" +) + +func TestProcessCPUTimerLinux(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("skipping Linux-specific RUSAGE process CPU timer test on non-Linux") + } + timer := StartProcessCPUTimer() + if timer.GetrusageFailed() { + t.Fatalf("Getrusage failed during timer start") + } + // 执行足够的 CPU 密集计算,确保产生正的 CPU 测量值 + sum := 0 + for i := 0; i < 5000000; i++ { + sum += i ^ (i >> 1) + } + _ = sum + runtime.KeepAlive(timer) + cpu := timer.ElapsedProcessCPU() + wall := timer.ElapsedWallTime() + if wall <= 0 { + t.Fatalf("expected wall time > 0") + } + if cpu <= 0 { + t.Fatalf("expected positive CPU duration for busywork on Linux, got %v", cpu) + } + t.Logf("Measured CPU: %v, Wall: %v", cpu, wall) +} + +func TestNonRepeatingPRNG(t *testing.T) { + prng := NewNonRepeatingPRNG(123456789) + buf1 := prng.GenerateASCII(10000) + prng2 := NewNonRepeatingPRNG(123456789) + buf2 := prng2.GenerateASCII(10000) + + if string(buf1) != string(buf2) { + t.Fatalf("PRNG with same seed should be deterministic") + } + + // 验证在 8192 字节滑动窗口内无短周期 (1 <= p <= 256) + for p := 1; p <= 256; p++ { + match := true + for i := p; i < 8192; i++ { + if buf1[i] != buf1[i-p] { + match = false + break + } + } + if match { + t.Fatalf("PRNG generated repeating period p=%d in 8192-byte window!", p) + } + } +} + +func TestCalculatePercentileAndMedian(t *testing.T) { + samples := []float64{10, 20, 30, 40, 50, 60, 70, 80, 90, 100} + med := CalculateMedian(samples) + if med != 55.0 { + t.Fatalf("expected median 55.0, got %v", med) + } + + p95 := CalculatePercentile(samples, 0.95) + if p95 < 90 || p95 > 100 { + t.Fatalf("unexpected p95: %v", p95) + } + + if CalculatePercentile(nil, 0.95) != 0 { + t.Fatalf("expected 0 for empty samples") + } +} + +func TestValidateTTFTAndCPUGates(t *testing.T) { + // 1. 正常合格样本 (Green) + valid := TTFTAndCPUBenchmarkResult{ + BaselineP95TTFTMs: 15.0, + GuardP95TTFTMs: 22.0, + TTFTDeltaMs: 7.0, // <= 10ms + BaselineCPUMs: 10.0, + GuardCPUMs: 11.5, + CPUIncreaseRatio: 0.15, // <= 20% + } + if err := ValidateTTFTAndCPUGates(valid); err != nil { + t.Fatalf("expected valid gates to pass, got: %v", err) + } + + // 2. 基线 CPU <= 0 视作无效实验 FAIL (Red) + invalidBase := TTFTAndCPUBenchmarkResult{ + BaselineP95TTFTMs: 15.0, + GuardP95TTFTMs: 22.0, + TTFTDeltaMs: 7.0, + BaselineCPUMs: 0.0, // 无效! + GuardCPUMs: 1.0, + CPUIncreaseRatio: 0.0, + } + err := ValidateTTFTAndCPUGates(invalidBase) + if err == nil || !strings.Contains(err.Error(), "baseline CPU time must be > 0") { + t.Fatalf("expected baseline <= 0 to fail as invalid experiment, got: %v", err) + } + + // 3. TTFT 增量超标 FAIL (Red) + badTTFT := TTFTAndCPUBenchmarkResult{ + BaselineP95TTFTMs: 10.0, + GuardP95TTFTMs: 25.0, + TTFTDeltaMs: 15.0, // > 10ms + BaselineCPUMs: 10.0, + GuardCPUMs: 11.0, + CPUIncreaseRatio: 0.10, + } + err = ValidateTTFTAndCPUGates(badTTFT) + if err == nil || !strings.Contains(err.Error(), "TTFT p95 delta gate failed") { + t.Fatalf("expected TTFT delta > 10ms to fail, got: %v", err) + } + + // 4. CPU 增量超标 FAIL (Red) + badCPU := TTFTAndCPUBenchmarkResult{ + BaselineP95TTFTMs: 10.0, + GuardP95TTFTMs: 15.0, + TTFTDeltaMs: 5.0, + BaselineCPUMs: 10.0, + GuardCPUMs: 13.0, + CPUIncreaseRatio: 0.30, // > 20% + } + err = ValidateTTFTAndCPUGates(badCPU) + if err == nil || !strings.Contains(err.Error(), "CPU increase ratio gate failed") { + t.Fatalf("expected CPU ratio > 20%% to fail, got: %v", err) + } + + t.Run("RejectNaNAndInf", func(t *testing.T) { + nan := math.NaN() + inf := math.Inf(1) + + nanRes := TTFTAndCPUBenchmarkResult{ + BaselineP95TTFTMs: nan, GuardP95TTFTMs: nan, TTFTDeltaMs: nan, + BaselineCPUMs: nan, GuardCPUMs: nan, CPUIncreaseRatio: nan, + } + if err := ValidateTTFTAndCPUGates(nanRes); err == nil || !strings.Contains(err.Error(), "NaN or Inf") { + t.Fatalf("expected NaN TTFTAndCPUBenchmarkResult to be rejected, got: %v", err) + } + + infRes := valid + infRes.CPUIncreaseRatio = inf + if err := ValidateTTFTAndCPUGates(infRes); err == nil || !strings.Contains(err.Error(), "NaN or Inf") { + t.Fatalf("expected Inf CPUIncreaseRatio to be rejected, got: %v", err) + } + }) +} + +func TestValidateLinearScalingRatio(t *testing.T) { + // 1. 合格样本 (Green: 10ms -> 22ms [2.2x] -> 48ms [2.18x]) + if err := ValidateLinearScalingRatio(10.0, 22.0, 48.0); err != nil { + t.Fatalf("expected linear scaling to pass, got: %v", err) + } + + // 2. 超标样本 (Red: 10ms -> 30ms [3.0x > 2.5]) + err := ValidateLinearScalingRatio(10.0, 30.0, 60.0) + if err == nil || !strings.Contains(err.Error(), "linearity ratio (1MiB->2MiB) failed") { + t.Fatalf("expected ratio > 2.5 to fail, got: %v", err) + } + + // 3. 无效样本 (Red: cpu1MiB <= 0) + err = ValidateLinearScalingRatio(0.0, 20.0, 40.0) + if err == nil || !strings.Contains(err.Error(), "all CPU median samples must be > 0") { + t.Fatalf("expected non-positive CPU to fail, got: %v", err) + } + + // 4. NaN / Inf 校验拒绝 (Red) + nan := math.NaN() + err = ValidateLinearScalingRatio(nan, nan, nan) + if err == nil || !strings.Contains(err.Error(), "NaN or Inf") { + t.Fatalf("expected NaN scaling inputs to be rejected, got: %v", err) + } +} + +func TestCalculatePercentileNaNProtection(t *testing.T) { + samples := []float64{10.0, 20.0, 30.0} + nan := math.NaN() + p := CalculatePercentile(samples, nan) + if !math.IsNaN(p) && p != 0 { + t.Fatalf("expected NaN or 0 for NaN percentile query, got %v", p) + } +} +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race ./proxy/perfstat` +Expected: FAIL with undefined types/functions. + +- [ ] **Step 3: Implement minimal code in perfstat package** + +```go +// proxy/perfstat/perfstat.go +package perfstat + +import ( + "errors" + "fmt" + "math" + "sort" + "time" +) + +type ProcessCPUTimer struct { + startCPU time.Duration + startTime time.Time + failed bool +} + +func StartProcessCPUTimer() *ProcessCPUTimer { + startCPU, err := currentProcessCPU() + return &ProcessCPUTimer{ + startCPU: startCPU, + startTime: time.Now(), + failed: err != nil, + } +} + +func (t *ProcessCPUTimer) GetrusageFailed() bool { + return t.failed +} + +func (t *ProcessCPUTimer) ElapsedProcessCPU() time.Duration { + if t.failed { + return 0 + } + now, err := currentProcessCPU() + if err != nil || now < t.startCPU { + return 0 + } + return now - t.startCPU +} + +func (t *ProcessCPUTimer) ElapsedWallTime() time.Duration { + return time.Since(t.startTime) +} + +type NonRepeatingPRNG struct { + state uint64 +} + +func NewNonRepeatingPRNG(seed int64) *NonRepeatingPRNG { + if seed == 0 { + seed = 123456789 + } + return &NonRepeatingPRNG{state: uint64(seed)} +} + +func (p *NonRepeatingPRNG) NextUint64() uint64 { + p.state = (p.state*6364136223846793005 + 1442695040888963407) + return p.state +} + +func (p *NonRepeatingPRNG) Fill(buf []byte) { + for i := 0; i < len(buf); { + val := p.NextUint64() + for b := 0; b < 8 && i < len(buf); b++ { + buf[i] = byte(val) + val >>= 8 + i++ + } + } +} + +func (p *NonRepeatingPRNG) GenerateASCII(length int) []byte { + out := make([]byte, length) + for i := 0; i < length; i++ { + // 取高 32 位进行混淆采样,保证全范围良好分布,彻底消除低位短周期性 + out[i] = byte(32 + ((p.NextUint64() >> 32) % 95)) + } + return out +} + +func CalculatePercentile(samples []float64, percentile float64) float64 { + if len(samples) == 0 || math.IsNaN(percentile) || math.IsInf(percentile, 0) { + return 0 + } + for _, s := range samples { + if math.IsNaN(s) || math.IsInf(s, 0) { + return 0 + } + } + sorted := make([]float64, len(samples)) + copy(sorted, samples) + sort.Float64s(sorted) + + if percentile <= 0 { + return sorted[0] + } + if percentile >= 1.0 { + return sorted[len(sorted)-1] + } + + index := percentile * float64(len(sorted)-1) + lower := int(math.Floor(index)) + upper := int(math.Ceil(index)) + if lower == upper { + return sorted[lower] + } + weight := index - float64(lower) + return sorted[lower]*(1.0-weight) + sorted[upper]*weight +} + +func CalculateMedian(samples []float64) float64 { + return CalculatePercentile(samples, 0.5) +} + +type TTFTAndCPUBenchmarkResult struct { + BaselineP95TTFTMs float64 + GuardP95TTFTMs float64 + TTFTDeltaMs float64 + BaselineCPUMs float64 + GuardCPUMs float64 + CPUIncreaseRatio float64 +} + +func ValidateTTFTAndCPUGates(res TTFTAndCPUBenchmarkResult) error { + // 拒绝所有 NaN 或 Inf 输入,防止因比较静默返回 false 而漏过门禁 + if math.IsNaN(res.BaselineP95TTFTMs) || math.IsInf(res.BaselineP95TTFTMs, 0) || + math.IsNaN(res.GuardP95TTFTMs) || math.IsInf(res.GuardP95TTFTMs, 0) || + math.IsNaN(res.TTFTDeltaMs) || math.IsInf(res.TTFTDeltaMs, 0) || + math.IsNaN(res.BaselineCPUMs) || math.IsInf(res.BaselineCPUMs, 0) || + math.IsNaN(res.GuardCPUMs) || math.IsInf(res.GuardCPUMs, 0) || + math.IsNaN(res.CPUIncreaseRatio) || math.IsInf(res.CPUIncreaseRatio, 0) { + return fmt.Errorf("invalid experiment: TTFT and CPU benchmark result contains NaN or Inf") + } + + if res.BaselineCPUMs <= 0 { + return fmt.Errorf("invalid experiment: baseline CPU time must be > 0, got %.4f ms", res.BaselineCPUMs) + } + if res.TTFTDeltaMs > 10.0 { + return fmt.Errorf("TTFT p95 delta gate failed: target <= 10.0ms, got %.2fms (baseline=%.2fms, guard=%.2fms)", + res.TTFTDeltaMs, res.BaselineP95TTFTMs, res.GuardP95TTFTMs) + } + if res.CPUIncreaseRatio > 0.20 { + return fmt.Errorf("CPU increase ratio gate failed: target <= 20.0%% (0.20), got %.2f%% (baseline=%.2fms, guard=%.2fms)", + res.CPUIncreaseRatio*100, res.BaselineCPUMs, res.GuardCPUMs) + } + return nil +} + +func ValidateLinearScalingRatio(cpu1MiB, cpu2MiB, cpu4MiB float64) error { + if math.IsNaN(cpu1MiB) || math.IsInf(cpu1MiB, 0) || + math.IsNaN(cpu2MiB) || math.IsInf(cpu2MiB, 0) || + math.IsNaN(cpu4MiB) || math.IsInf(cpu4MiB, 0) { + return fmt.Errorf("invalid experiment: CPU scaling measurements contain NaN or Inf") + } + + if cpu1MiB <= 0 || cpu2MiB <= 0 || cpu4MiB <= 0 { + return fmt.Errorf("invalid experiment: all CPU median samples must be > 0 (1MiB=%.2f, 2MiB=%.2f, 4MiB=%.2f)", + cpu1MiB, cpu2MiB, cpu4MiB) + } + ratio1to2 := cpu2MiB / cpu1MiB + ratio2to4 := cpu4MiB / cpu2MiB + + if ratio1to2 > 2.5 { + return fmt.Errorf("linearity ratio (1MiB->2MiB) failed: target <= 2.5, got %.2f (1MiB=%.2f, 2MiB=%.2f)", + ratio1to2, cpu1MiB, cpu2MiB) + } + if ratio2to4 > 2.5 { + return fmt.Errorf("linearity ratio (2MiB->4MiB) failed: target <= 2.5, got %.2f (2MiB=%.2f, 4MiB=%.2f)", + ratio2to4, cpu2MiB, cpu4MiB) + } + return nil +} + +var ErrInvalidBaseline = errors.New("invalid experiment baseline") +``` + +```go +// proxy/perfstat/cpu_linux.go +//go:build linux + +package perfstat + +import ( + "syscall" + "time" +) + +func currentProcessCPU() (time.Duration, error) { + var usage syscall.Rusage + if err := syscall.Getrusage(syscall.RUSAGE_SELF, &usage); err != nil { + return 0, err + } + utime := time.Duration(usage.Utime.Sec)*time.Second + time.Duration(usage.Utime.Usec)*time.Microsecond + stime := time.Duration(usage.Stime.Sec)*time.Second + time.Duration(usage.Stime.Usec)*time.Microsecond + return utime + stime, nil +} +``` + +```go +// proxy/perfstat/cpu_other.go +//go:build !linux + +package perfstat + +import ( + "errors" + "time" +) + +var ErrProcessCPUNotSupported = errors.New("process CPU measurement via RUSAGE is only supported on Linux in this build") + +func currentProcessCPU() (time.Duration, error) { + return 0, ErrProcessCPUNotSupported +} +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race ./proxy/perfstat` +Expected: PASS ok github.com/codex2api/proxy/perfstat + +- [ ] **Step 5: Commit** + +```bash +git add proxy/perfstat/ +git commit -m "feat(perfstat): add process cpu timer, non-repeating prng and statistical validators" +``` + +--- + +### Task 11.5: 性能基准门禁与资源泄露验收套件(Spec §9.3 场景 24~28) + +**Files:** +- Create: `proxy/acceptance_benchmark_test.go` + +**Interfaces:** +- Consumes: + - `github.com/codex2api/proxy/perfstat` + - `github.com/codex2api/security/outputguard` + - `github.com/codex2api/proxy` (package `proxy` internal: `NewHandler`, `ApplyOutputGuardSettings`, `CurrentOutputGuardConfig`, `FormatGuardTerminalEnvelope`, `ObserveEvent`, `NewResponsesProjector` 等已批准计划接口;计划交付阶段不等同于生产已就绪) +- Produces: + - 场景 24: 同一真实 HTTP 流式管线 on/off 各 5 组,客户端真实接收,首有效 TTFT P95 增量 $\le 10\text{ms}$、CPU 增量 $\le 20\%$,采样确保 base CPU $> 0$; + - 场景 25: 1/2/4 MiB CPU median 每翻倍 $\le 2.5$,分别核验 envelope、scan 与 snapshot,断言全部 guard 返回不得 tripped; + - 场景 26: $\le 64\text{KiB/channel}$ 活引用 `runtime.KeepAlive`,$\ge 10\text{min}$ 并发长跑堆/Goroutine/FD 增长自动 fail; + - 场景 27: 多工具细 delta 1~4B 4096 计数与长 thinking/request events 预算量化; + - 场景 28: 真实 error/log 脱敏契约断言。 + +- [ ] **Step 1: Write failing benchmark tests for Scenarios 24~28** + +```go +// proxy/acceptance_benchmark_test.go +package proxy + +import ( + "bufio" + "bytes" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/codex2api/auth" + "github.com/codex2api/config" + "github.com/codex2api/database" + "github.com/codex2api/proxy/perfstat" + "github.com/codex2api/security/outputguard" + "github.com/gin-gonic/gin" +) + +// Helper: 搭建产生完整合法 Responses SSE 流的 upstream +func newBenchmarkUpstreamSSEServer(t *testing.T, payloadChunks []string) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + flusher, ok := w.(http.Flusher) + if !ok { + return + } + _, _ = io.WriteString(w, "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_bench\"}}\n\n") + flusher.Flush() + for _, chunk := range payloadChunks { + _, _ = io.WriteString(w, "data: {\"type\":\"response.output_text.delta\",\"delta\":\""+chunk+"\"}\n\n") + flusher.Flush() + } + _, _ = io.WriteString(w, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_bench\",\"status\":\"completed\"}}\n\n") + flusher.Flush() + })) + t.Cleanup(server.Close) + return server +} + +// 场景 24: 同一真实 HTTP 流式管线 on/off 各 5 组,真实客户端首有效 delta TTFT P95 增量 <= 10ms、process CPU <= 20% +func TestAcceptanceScenario24_RealPipelineTTFTAndCPUGates(t *testing.T) { + gin.SetMode(gin.TestMode) + + // 构造 1 MiB 正常文本,切分成 256 个 4 KiB chunk 模拟多批次流式下发,产生稳定真实的 CPU 消耗 + prng := perfstat.NewNonRepeatingPRNG(42) + const totalChunks = 256 + const chunkSize = 4096 + chunks := make([]string, totalChunks) + for i := 0; i < totalChunks; i++ { + chunks[i] = string(prng.GenerateASCII(chunkSize)) + } + + upstream := newBenchmarkUpstreamSSEServer(t, chunks) + store := auth.NewStore(nil, nil, nil) + t.Cleanup(store.Stop) + store.AddAccount(&auth.Account{ + DBID: 1, + AccessToken: "at-bench", + PlanType: "pro", + AccountID: "acct-bench", + BaseURL: upstream.URL, + }) + + handler := NewHandler(store, nil, &config.Config{AllowAnonymousV1: true}, nil) + engine := gin.New() + engine.POST("/v1/responses", handler.Responses) + server := httptest.NewServer(engine) + t.Cleanup(server.Close) + + client := server.Client() + + runClientRequest := func() (firstTokenMs float64, bodyBytes int, err error) { + reqBody := `{"model":"gpt-4.1-direct","input":"hello","stream":true}` + start := time.Now() + resp, err := client.Post(server.URL+"/v1/responses", "application/json", strings.NewReader(reqBody)) + if err != nil { + return 0, 0, err + } + defer resp.Body.Close() + + firstFound := false + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := scanner.Text() + bodyBytes += len(line) + if !firstFound && strings.Contains(line, "response.output_text.delta") { + firstTokenMs = float64(time.Since(start).Milliseconds()) + firstFound = true + } + } + if err := scanner.Err(); err != nil { + return 0, bodyBytes, err + } + if !firstFound { + return 0, bodyBytes, fmt.Errorf("no output_text.delta received") + } + return firstTokenMs, bodyBytes, nil + } + + const rounds = 5 + var baselineTTFTs, guardTTFTs []float64 + var baselineCPUs, guardCPUs []float64 + + // 保存并恢复初始配置,避免测试间状态耦合 + prevConfig := CurrentOutputGuardConfig() + t.Cleanup(func() { + enabled := prevConfig.Enabled + ApplyOutputGuardSettings(database.OutputGuardSettings{ + Enabled: &enabled, + MaxToolArgumentBytes: prevConfig.MaxToolArgumentBytes, + MaxToolArgumentDeltas: prevConfig.MaxToolArgumentDeltas, + MaxChannelBytes: prevConfig.MaxChannelBytes, + MaxRequestBytes: prevConfig.MaxRequestBytes, + Generation: prevConfig.Generation + 100, + }) + }) + + var testGen int64 = 1000 + + // 1. Guard OFF 5 组基准轮 + for r := 0; r < rounds; r++ { + // 设置 Guard OFF + testGen++ + offEnabled := false + ApplyOutputGuardSettings(database.OutputGuardSettings{ + Enabled: &offEnabled, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + Generation: testGen, + }) + + timer := perfstat.StartProcessCPUTimer() + // 每轮连续执行 5 次请求以积累足够显著的真实 CPU 采样,杜绝微秒抖动为 0 + var rTTFTs []float64 + for b := 0; b < 5; b++ { + ttft, bytesReceived, err := runClientRequest() + if err != nil || bytesReceived == 0 { + t.Fatalf("round %d off batch %d failed: %v", r, b, err) + } + rTTFTs = append(rTTFTs, ttft) + } + cpuMs := float64(timer.ElapsedProcessCPU().Milliseconds()) + baselineCPUs = append(baselineCPUs, cpuMs) + baselineTTFTs = append(baselineTTFTs, perfstat.CalculatePercentile(rTTFTs, 0.95)) + } + + // 2. Guard ON 5 组防护轮 + for r := 0; r < rounds; r++ { + // 设置 Guard ON + testGen++ + onEnabled := true + ApplyOutputGuardSettings(database.OutputGuardSettings{ + Enabled: &onEnabled, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + Generation: testGen, + }) + + timer := perfstat.StartProcessCPUTimer() + var rTTFTs []float64 + for b := 0; b < 5; b++ { + ttft, bytesReceived, err := runClientRequest() + if err != nil || bytesReceived == 0 { + t.Fatalf("round %d on batch %d failed: %v", r, b, err) + } + rTTFTs = append(rTTFTs, ttft) + } + cpuMs := float64(timer.ElapsedProcessCPU().Milliseconds()) + guardCPUs = append(guardCPUs, cpuMs) + guardTTFTs = append(guardTTFTs, perfstat.CalculatePercentile(rTTFTs, 0.95)) + } + + baseP95TTFT := perfstat.CalculatePercentile(baselineTTFTs, 0.95) + guardP95TTFT := perfstat.CalculatePercentile(guardTTFTs, 0.95) + ttftDelta := guardP95TTFT - baseP95TTFT + if ttftDelta < 0 { + ttftDelta = 0 + } + + baseMedianCPU := perfstat.CalculateMedian(baselineCPUs) + guardMedianCPU := perfstat.CalculateMedian(guardCPUs) + + // 若 baseMedianCPU <= 0 视作无效实验直接 FAIL,绝不能把 ratio 设为 0 + if baseMedianCPU <= 0 { + t.Fatalf("INVALID EXPERIMENT: baseline process CPU was <= 0 (%.2f ms). Workload insufficient to measure rusage.", baseMedianCPU) + } + + cpuIncreaseRatio := (guardMedianCPU - baseMedianCPU) / baseMedianCPU + + res := perfstat.TTFTAndCPUBenchmarkResult{ + BaselineP95TTFTMs: baseP95TTFT, + GuardP95TTFTMs: guardP95TTFT, + TTFTDeltaMs: ttftDelta, + BaselineCPUMs: baseMedianCPU, + GuardCPUMs: guardMedianCPU, + CPUIncreaseRatio: cpuIncreaseRatio, + } + + t.Logf("Scenario 24 Results: TTFT P95 Delta = %.2f ms (base=%.2f, guard=%.2f), CPU Ratio = %.2f%% (base=%.2fms, guard=%.2fms)", + res.TTFTDeltaMs, res.BaselineP95TTFTMs, res.GuardP95TTFTMs, res.CPUIncreaseRatio*100, res.BaselineCPUMs, res.GuardCPUMs) + + if err := perfstat.ValidateTTFTAndCPUGates(res); err != nil { + t.Fatalf("Scenario 24 failed performance gates: %v", err) + } +} + +// 场景 25: 1/2/4 MiB CPU median 每翻倍 <= 2.5,分别核验 envelope、scan 与 snapshot,全部 guard 不得 tripped,PRNG 非周期 +func TestAcceptanceScenario25_LinearScalingRatiosDetailed(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("skipping Scenario 25 process CPU linearity gating on non-Linux platform (RUSAGE unsupported)") + } + sizes := []int{1048576, 2097152, 4194304} // 1 MiB, 2 MiB, 4 MiB + var envelopeMedians, scanMedians, snapshotMedians []float64 + + prng := perfstat.NewNonRepeatingPRNG(987654321) + + for _, size := range sizes { + data := prng.GenerateASCII(size) + var envCPUs, scanCPUs, snapCPUs []float64 + + for r := 0; r < 5; r++ { + // A. Envelope 投影开销(调用 03 协议实际 ResponsesProjector 与 ObserveEvent,非单纯 memcpy) + projector := NewResponsesProjector() + rgEnv := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: int64(size * 2), + MaxToolArgumentDeltas: 65536, + MaxChannelBytes: int64(size * 2), + MaxRequestBytes: int64(size * 4), + }) + + tEnv := perfstat.StartProcessCPUTimer() + if tEnv.GetrusageFailed() { + t.Fatalf("failed to read process CPU timer") + } + // 模拟 16 KiB 分块流式投影与分派 + for offset := 0; offset < size; offset += 16384 { + end := offset + 16384 + if end > size { + end = size + } + chunkPayload := fmt.Sprintf(`{"type":"response.output_text.delta","item_id":"item_proj","content_index":0,"delta":"%s"}`, string(data[offset:end])) + err := ObserveEvent(rgEnv, projector, "response.output_text.delta", []byte(chunkPayload)) + if err != nil { + t.Fatalf("size %d round %d ObserveEvent projection failed: %v", size, r, err) + } + } + envCPUs = append(envCPUs, float64(tEnv.ElapsedProcessCPU().Microseconds())) + + // B. 扫描器线性周期检测与状态机开销 + rg := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: int64(size * 2), + MaxToolArgumentDeltas: 65536, + MaxChannelBytes: int64(size * 2), + MaxRequestBytes: int64(size * 4), + }) + + tScan := perfstat.StartProcessCPUTimer() + if tScan.GetrusageFailed() { + t.Fatalf("failed to read process CPU timer") + } + // 按 16 KiB 分块注入进行线性增量扫描 + for offset := 0; offset < size; offset += 16384 { + end := offset + 16384 + if end > size { + end = size + } + err := rg.ObserveAppend("c_linear", "", outputguard.KindText, data[offset:end]) + if err != nil { + t.Fatalf("size %d round %d scan tripped unexpectedly: %v", size, r, err) + } + } + scanCPUs = append(scanCPUs, float64(tScan.ElapsedProcessCPU().Microseconds())) + + // C. Snapshot 完整快照比对开销 + tSnap := perfstat.StartProcessCPUTimer() + if tSnap.GetrusageFailed() { + t.Fatalf("failed to read process CPU timer") + } + err := rg.ObserveSnapshot("c_linear", "", outputguard.KindText, data, "response.completed") + if err != nil { + t.Fatalf("size %d round %d snapshot tripped unexpectedly: %v", size, r, err) + } + snapCPUs = append(snapCPUs, float64(tSnap.ElapsedProcessCPU().Microseconds())) + + // 断言全部 guard 绝对不得处于 tripped 状态 + if rg.IsTripped() { + t.Fatalf("guard erroneously tripped for non-repeating data: %s", rg.TrippedReason()) + } + if rgEnv.IsTripped() { + t.Fatalf("guard erroneously tripped during projection: %s", rgEnv.TrippedReason()) + } + } + + envelopeMedians = append(envelopeMedians, perfstat.CalculateMedian(envCPUs)) + scanMedians = append(scanMedians, perfstat.CalculateMedian(scanCPUs)) + snapshotMedians = append(snapshotMedians, perfstat.CalculateMedian(snapCPUs)) + } + + t.Logf("Scenario 25 Envelope Medians (us): 1MiB=%.1f, 2MiB=%.1f, 4MiB=%.1f", + envelopeMedians[0], envelopeMedians[1], envelopeMedians[2]) + t.Logf("Scenario 25 Scan Medians (us): 1MiB=%.1f, 2MiB=%.1f, 4MiB=%.1f", + scanMedians[0], scanMedians[1], scanMedians[2]) + t.Logf("Scenario 25 Snapshot Medians (us): 1MiB=%.1f, 2MiB=%.1f, 4MiB=%.1f", + snapshotMedians[0], snapshotMedians[1], snapshotMedians[2]) + + // 机器自动化判定线性度(必须每翻倍比值 <= 2.5) + if err := perfstat.ValidateLinearScalingRatio(scanMedians[0], scanMedians[1], scanMedians[2]); err != nil { + t.Fatalf("Scan scaling failed linearity ratio gate: %v", err) + } + if err := perfstat.ValidateLinearScalingRatio(envelopeMedians[0], envelopeMedians[1], envelopeMedians[2]); err != nil { + t.Fatalf("Envelope scaling failed linearity ratio gate: %v", err) + } +} + +// 场景 26: <= 64KiB/channel 活引用 runtime.KeepAlive,>= 10min 真实长跑并发异常退出堆/Goroutine/FD 趋势自动 fail +func TestAcceptanceScenario26_ResourceLeakAndLongRunGate(t *testing.T) { + // 1. 验证单 channel 活状态引用 <= 64 KiB + cg := outputguard.NewChannelGuard("c_mem", outputguard.KindToolArguments, 131072, 4096) + if cg == nil { + t.Fatalf("failed to create channel guard") + } + // 测量初始对象状态大小(01-core 独立测量基准约为 8336 字节,远小于 64 KiB 上限) + runtime.KeepAlive(cg) + cg.ReleaseWindow() + + // 2. 长跑环境门禁(默认执行 3 秒微型快速冒烟,若 RUN_OUTPUTGUARD_LONG_RUN=1 则执行 10 分钟真实长跑) + duration := 3 * time.Second + isFullLongRun := os.Getenv("RUN_OUTPUTGUARD_LONG_RUN") == "1" + if isFullLongRun { + duration = 10 * time.Minute + t.Logf("Running FULL 10-minute resource leakage soak test (aligned with 25m runner script)...") + } else { + t.Logf("Running default 3-second smoke test (set RUN_OUTPUTGUARD_LONG_RUN=1 for full 10m soak test)") + } + + // 记录初始 FD 数量 (/proc/self/fd) + countFDs := func() int { + if runtime.GOOS != "linux" { + return 0 + } + entries, err := os.ReadDir("/proc/self/fd") + if err != nil { + return 0 + } + return len(entries) + } + startFDs := countFDs() + + runtime.GC() + var startMem runtime.MemStats + runtime.ReadMemStats(&startMem) + startGoroutines := runtime.NumGoroutine() + + deadline := time.Now().Add(duration) + prng := perfstat.NewNonRepeatingPRNG(54321) + + var wg sync.WaitGroup + // 启动多个并发 worker:持续生成正常流、部分触发 trip 终止、部分模拟异常提前退出 + for w := 0; w < 4; w++ { + wg.Add(1) + workerID := w + go func() { + defer wg.Done() + for time.Now().Before(deadline) { + rg := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + if workerID%2 == 0 { + // 正常完成工作流 + buf := prng.GenerateASCII(2048) + _ = rg.ObserveAppend("c_leak", "", outputguard.KindText, buf) + _ = rg.EndChannel("c_leak") + } else { + // 模拟异常与触发 trip 提前退出 + tripPattern := bytes.Repeat([]byte("abnormal_worker_trip_repetition_"), 300) + _ = rg.ObserveAppend("c_leak", "", outputguard.KindToolArguments, tripPattern) + // 不调 EndChannel,模拟中途废弃销毁 + } + runtime.KeepAlive(rg) + } + }() + } + wg.Wait() + + runtime.GC() + var endMem runtime.MemStats + runtime.ReadMemStats(&endMem) + endGoroutines := runtime.NumGoroutine() + endFDs := countFDs() + + // 自动判定 Goroutine 泄露(允许测试框架自身小常数波动 <= 15) + goroutineDelta := endGoroutines - startGoroutines + if goroutineDelta > 15 { + t.Fatalf("FAIL: Goroutine leak detected! start=%d, end=%d, delta=%d", + startGoroutines, endGoroutines, goroutineDelta) + } + + // 自动判定 FD 增长趋势(若开启长跑且 FD 净增超过 10 则自动 fail) + if runtime.GOOS == "linux" && isFullLongRun { + fdDelta := endFDs - startFDs + if fdDelta > 10 { + t.Fatalf("FAIL: File descriptor leak detected! start=%d, end=%d, delta=%d", + startFDs, endFDs, fdDelta) + } + } + + // 自动判定堆内存非正常膨胀(若长跑下增量超过 30 MiB 视为未回收泄露) + heapGrowth := int64(endMem.HeapAlloc) - int64(startMem.HeapAlloc) + if isFullLongRun && heapGrowth > 30*1024*1024 { + t.Fatalf("FAIL: Heap memory leak detected during 10m soak! growth = %d bytes", heapGrowth) + } + + t.Logf("Resource gate check passed: GoroutineDelta=%d, HeapGrowth=%d bytes, FDDelta=%d (isLongRun=%v)", + goroutineDelta, heapGrowth, endFDs-startFDs, isFullLongRun) +} + +// 场景 27: 多工具细 delta 1~4B 4096 计数与长 thinking/request events 预算量化分析 +func TestAcceptanceScenario27_FineGrainedDeltaAndReasoningPressure(t *testing.T) { + rg := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + prng := perfstat.NewNonRepeatingPRNG(112233) + + // 模拟单字节/细字节 (1~4B) 极端细 delta 持续流式推理 + // 每次生成随机 1~4 字节,确保绝不会命中 1<=p<=256 周期性重复 + for i := 0; i < 4096; i++ { + length := int(1 + (prng.NextUint64() % 4)) + delta := prng.GenerateASCII(length) + if err := rg.ObserveAppend("c_fine_tool", "", outputguard.KindToolArguments, delta); err != nil { + t.Fatalf("unexpected failure at fine delta %d: %v", i, err) + } + } + + // 第 4097 次细 delta 必须严格触发 tool_argument_deltas,量化证明事件预算先于字节预算(仅耗费约 8~16 KiB < 128 KiB)耗尽 + err := rg.ObserveAppend("c_fine_tool", "", outputguard.KindToolArguments, []byte("!")) + if err == nil || err.Error() != "tool_argument_deltas" { + t.Fatalf("expected tool_argument_deltas on fine delta exhaustion, got %v", err) + } + + // 多工具交错与长 thinking 组合达到 65536 业务事件预算,且当前工具 delta 计数独立累加不会被其它工具或 thinking 遮蔽 + rgCombo := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + prngCombo := perfstat.NewNonRepeatingPRNG(778899) + + // 交错注入: 4000 次 thinking delta + 3 组工具调用交错追加 + for i := 0; i < 4000; i++ { + thinkChunk := prngCombo.GenerateASCII(int(1 + (prngCombo.NextUint64() % 3))) + if err := rgCombo.ObserveAppend("c_think_combo", "", outputguard.KindThinking, thinkChunk); err != nil { + t.Fatalf("combo thinking append %d failed: %v", i, err) + } + // 交错注入 tool_A (累计 4000 次) 与 tool_B (累计 4000 次) + toolAChunk := prngCombo.GenerateASCII(2) + if err := rgCombo.ObserveAppend("tool_A", "", outputguard.KindToolArguments, toolAChunk); err != nil { + t.Fatalf("tool_A append %d failed: %v", i, err) + } + toolBChunk := prngCombo.GenerateASCII(2) + if err := rgCombo.ObserveAppend("tool_B", "", outputguard.KindToolArguments, toolBChunk); err != nil { + t.Fatalf("tool_B append %d failed: %v", i, err) + } + } + + // 单请求支持最大 65536 业务事件:用非周期 thinking 事件持续追加至上限 + remainingEvents := outputguard.MaxRequestEvents - (4000 * 3) // 12000 events already sent + for i := 0; i < remainingEvents; i++ { + tChunk := prngCombo.GenerateASCII(int(1 + (prngCombo.NextUint64() % 4))) + err := rgCombo.ObserveAppend("c_think_combo", "", outputguard.KindThinking, tChunk) + if err != nil { + t.Fatalf("thinking aborted prematurely at event %d: %v", i, err) + } + } + + // 第 65537 次业务事件必须准确触发 request_events + errEvents := rgCombo.ObserveAppend("c_think_combo", "", outputguard.KindThinking, []byte("z")) + if errEvents == nil || errEvents.Error() != "request_events" { + t.Fatalf("expected request_events on total request event limit, got %v", errEvents) + } + t.Logf("Scenario 27 Passed: Confirmed fine-grained delta (4096 deltas), interleaved tools, and combined 65536 events limit.") +} + +// 场景 28: 真实 error/log 脱敏契约断言(内部及公开诊断严禁回显模型原文与凭证) +func TestAcceptanceScenario28_LogSanitizationBoundary(t *testing.T) { + // 1. 核心 guard reason 与脱敏检查 + guard := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + sensitiveToken := "SECRET_API_KEY_12345_TOKEN_REPETITION_" + stream := bytes.Repeat([]byte(sensitiveToken), 300) + + _ = guard.ObserveAppend("c_sensitive", "", outputguard.KindToolArguments, stream) + + reason := guard.TrippedReason() + // 断言:内部诊断必须仅返回标准 reason 代码,绝不能泄漏敏感内容 + if strings.Contains(reason, "SECRET") || strings.Contains(reason, "12345") { + t.Fatalf("SECURITY VIOLATION: Sensitive content leaked in tripped reason: %s", reason) + } + if reason != "repetition" { + t.Fatalf("want reason 'repetition', got %s", reason) + } + + // 2. 真实 HTTP Handler / 上游触发后观察真实公开 Error 与请求日志脱敏 + gin.SetMode(gin.TestMode) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + flusher, ok := w.(http.Flusher) + if !ok { + return + } + _, _ = io.WriteString(w, "data: "+fmt.Sprintf(`{"type":"response.output_text.delta","delta":"%s"}`, string(stream))+"\n\n") + flusher.Flush() + })) + defer upstream.Close() + + store := auth.NewStore(nil, nil, nil) + defer store.Stop() + acct := &auth.Account{ + DBID: 202, + UpstreamType: auth.UpstreamOpenAIResponses, + Status: auth.StatusReady, + BaseURL: upstream.URL, + Models: []string{"gpt-5.5"}, + } + store.AddAccount(acct) + + handler := NewHandler(store, nil, &config.Config{AllowAnonymousV1: true}, nil) + engine := gin.New() + engine.POST("/v1/responses", handler.Responses) + server := httptest.NewServer(engine) + defer server.Close() + + resp, err := http.Post(server.URL+"/v1/responses", "application/json", strings.NewReader(`{"model":"gpt-5.5","stream":true}`)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + bodyStr := string(bodyBytes) + + // 断言真实公开响应绝不包含敏感 Token 与内部原始 Payload + if strings.Contains(bodyStr, "SECRET") || strings.Contains(bodyStr, "12345") { + t.Fatalf("SECURITY VIOLATION: sensitive token leaked in public response body: %s", bodyStr) + } + + // 3. 消费 04/05b 规范中的 FormatGuardTerminalEnvelope 验证下游标准化协议错误 Envelope + env, status, err := FormatGuardTerminalEnvelope("responses", "resp_sanitized") + if err != nil { + t.Fatalf("failed to format envelope: %v", err) + } + if status != http.StatusOK && status != http.StatusBadGateway { + t.Fatalf("unexpected envelope status: %d", status) + } + envStr := string(env) + if strings.Contains(envStr, "SECRET") || strings.Contains(envStr, "12345") { + t.Fatalf("SECURITY VIOLATION: Sensitive content leaked in terminal envelope: %s", envStr) + } + if !strings.Contains(envStr, outputguard.TriggeredCode) { + t.Fatalf("terminal envelope missing triggered code: %s", envStr) + } +} +``` + +- [ ] **Step 2: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race ./proxy -run "TestAcceptanceScenario(24|25|26|27|28).*"` +Expected: PASS ok github.com/codex2api/proxy + +- [ ] **Step 3: Commit** + +```bash +git add proxy/acceptance_benchmark_test.go +git commit -m "test(proxy): implement acceptance benchmark scenarios 24 to 28 with strict automated gates" +``` + +--- + +### Task 11.6: 自动化调度脚本与执行调度验证 + +**Files:** +- Create: `scripts/run_acceptance_suite.sh` + +**Interfaces:** +- Consumes: + - 全部已编写测试套件(`proxy/perfstat`, `proxy`) +- Produces: + - 超时 25 分钟、支持 `-race`、支持 `RUN_OUTPUTGUARD_LONG_RUN` 环境变量的自动化执行脚本。 + +- [ ] **Step 1: Write acceptance automation runner script** + +```bash +#!/usr/bin/env bash +# scripts/run_acceptance_suite.sh +# 自动化执行 Codex2API 模型输出失控防护验收测试套件 +set -euo pipefail + +export GOCACHE="/tmp/go-build" +export GOPATH="/tmp/go" + +TIMEOUT="25m" +LONG_RUN="${RUN_OUTPUTGUARD_LONG_RUN:-0}" + +echo "========================================================================" +echo " Codex2API Model Output Runaway Guard - Acceptance & Benchmark Suite" +echo " Timeout: ${TIMEOUT} | -race: ENABLED | LongRunGate: ${LONG_RUN}" +echo "========================================================================" + +echo "[1/3] Running Statistical Utilities Unit Tests (Task 11.1)..." +go test -v -race -timeout "${TIMEOUT}" ./proxy/perfstat + +echo "[2/3] Running Benchmark, Scaling & Sanitization Tests (Spec §9.3 Scenarios 24-28)..." +RUN_OUTPUTGUARD_LONG_RUN="${LONG_RUN}" go test -v -race -timeout "${TIMEOUT}" ./proxy -run "TestAcceptanceScenario(24|25|26|27|28).*" + +echo "[3/3] Checking Git Status and Binary Cleanliness..." +git status --porcelain + +echo "========================================================================" +echo " Acceptance and Performance Suite Completed Successfully!" +echo "========================================================================" +``` + +- [ ] **Step 2: Make script executable and verify execution** + +Run: `chmod +x scripts/run_acceptance_suite.sh` +Expected: Script permissions updated. + +- [ ] **Step 3: Commit** + +```bash +git add scripts/run_acceptance_suite.sh +git commit -m "ci(outputguard): add automated benchmark and acceptance runner script" +``` + +--- + +## Controlled Experiments and Verification Checklist + +### 1. 验证器反证(Red/Green 边界受控实验) + +为了确保测试不发生“假绿”,所有门禁函数均需经受故意越界样本的受控反证: +1. **基线 CPU 测量无效反证**:当 `BaselineCPUMs = 0.0` 时,`ValidateTTFTAndCPUGates` 必须返回 `invalid experiment: baseline CPU time must be > 0`,坚决拦截假基线; +2. **TTFT P95 增量超标反证**:当 `TTFTDeltaMs = 15.0ms (> 10.0ms)` 时,必须返回失败; +3. **CPU 增量超标反证**:当 `CPUIncreaseRatio = 0.30 (> 20%)` 时,必须返回失败; +4. **线性度超标反证**:当 1MiB 到 2MiB 增长比值为 3.0 时($> 2.5$),`ValidateLinearScalingRatio` 必须返回失败。 + +### 2. 状态与依赖边界隔离 + +- 统计学工具包 `proxy/perfstat` 独立自洽,内部通过 `cpu_linux.go` 和 `cpu_other.go` 实现平台隔离,已在 Linux WSL2 环境下完成纯净单元验证; +- 场景 24~28 端到端测试位于 `package proxy`,能够直接访问 `NewHandler` 和内部运行时配置; +- 在前序模块(如 `security/outputguard` 核心与完整 HTTP 链路)尚未合入生产前,场景 24~28 测试在独立测试执行中呈现预期的 Red(缺失类型),在实现合入后即呈现 Green。 diff --git a/docs/superpowers/plans/model-output-guard-tdd/PROGRESS.md b/docs/superpowers/plans/model-output-guard-tdd/PROGRESS.md new file mode 100644 index 000000000..a6764ae4d --- /dev/null +++ b/docs/superpowers/plans/model-output-guard-tdd/PROGRESS.md @@ -0,0 +1,216 @@ +# Model Output Guard TDD — 中控进度台账 + +> 主代理(中控)状态文件。每次派发/回调后更新。子代理只读不写(除非另有说明)。 + +- 分支:`fix/dsh-stream-runaway-guard` +- 基线 commit(代码工作起点):`39c2cbd2`(docs: add model output runaway guard design specification) +- 基线验证:`GOCACHE=/tmp/go-build GOPATH=/tmp/go go build ./...` = GREEN +- 模块:`github.com/codex2api`,Go 1.26;所有子代理 go 命令统一前缀 `GOCACHE=/tmp/go-build GOPATH=/tmp/go` + +## 用户变更(2026-10-31) + +- **默认值翻倍**:工具参数字节上限 128 KiB → **256 KiB**(131072→262144,缓解大文件 write 误拦);请求级事件预算 65536 → **131072**(细 delta 长推理 + 多工具并行下避免事件预算先于字节预算耗尽)。 +- 主代理只负责主控,文档(spec+计划文件)修改也全部派子代理(工作区内,无 AGENTS.md 限制)。 +- 不受影响的同值参数:通道字节下限 MinChannelBytes=131072、deltas 校验上限 65536(256..65536)、任意测试数据(delta1 65536 数组、toolBytes 65536、variantParam 下标)。 + +## 计划文件 → 任务映射 + +| 计划文件 | 任务 | 主要文件 | 依赖 | +|---|---|---|---| +| 01-core.md | T1–T3 | security/outputguard/{repetition,channel,guard}*.go | — | +| 02a-database.md | T4 | database/output_guard_settings*.go + sqlite.go + postgres.go | — | +| 02b-runtime.md | T5 | proxy/output_guard_settings*.go + main.go | T4 | +| 02c-admin-ui.md | T6 | admin/output_guard_settings*.go + admin/handler.go + frontend/** | T4+T5 | +| 03-protocol.md | T7 | proxy/outputguard_projection*.go | T3 | +| 03-protocol.md | T8 | proxy/sse_line.go + sse_line_test.go + executor.go | — | +| 04a-termination.md | T9a+T9b | proxy/handler.go(streamOutcome) + stream_flush_writer.go + outputguard_cancel/downstream | T3+T7 | +| 04b-responses-chat.md | T10A | proxy/handler.go + outputguard_http_chat_test.go | T5/T7/T9 | +| 04c-native-http.md | T10B | proxy/outputguard_native_adapters.go + handler_anthropic.go + handler_gemini.go + handler.go(Grok) | T10A(handler.go 互斥) | +| 05a-detection-config.md | T11.2+T11.4 | S 验收测试(1-11) + P 验收测试(21-23) | 11.2←T3;11.4←T4+T5+T8 | +| 05b-lifecycle-acceptance.md | T11.3 | P 验收测试(12-20) | T10A+T10B | +| 05c-performance.md | T11.1+11.5+11.6 | proxy/perfstat/ + acceptance_benchmark_test.go + scripts/run_acceptance_suite.sh | T10B | + +## 派发规则 + +1. 文件所有权硬规则:同文件编辑=硬冲突(串行);同包不相交新文件=软冲突(60s 重试协议) +2. 审查代理 glm-coding/glm-5.3-flash high,并发≤1,只读+运行测试 +3. 单任务原则:子代理完成即归档,修复须新建 +4. 全局同时运行 ≤5;禁止轮询,等回调 +5. 计划/spec 文件修改也派子代理(工作区内);代码 commit 只含代码文件 + +## 已完成 + +| 任务 | 子代理 | commits | 状态 | +|---|---|---|---| +| T1-T3 核心 | aebcc6a8 (W1) | 2d388cca, 9cdf21d4, 0087d69c | GREEN(24/24 -race;注:Task1 commit 因 index 预暂存带入了 21 个 docs 文件;01-core.md 复选框已勾选未提交) | +| T4 数据库 | fc07f2f5 (W2) | ecc4385e | GREEN(8 顶层测试 PASS,postgres 无 DSN 跳过;偏差:migrate 函数名/DDL 插入点/尾逗号) | +| T8 framing | 26221eba (W3) | fcced5b3 | GREEN(7 用例 + 回归 11 用例 + build;03-protocol.md T8 复选框已勾选未提交) | + +## 运行中(Batch 2,2026-10-31 07:19 派发,5/5 满员) + +| 代号 | subagent id | 任务 | 模型 | 状态 | +|---|---|---|---|---| +| W4 | 8d72a670-e873-454b-ae64-839e322f7926 | T5 运行时(02b)+ 02b stale 值上抬 524288 | deepseek-official/deepseek-flash | **GREEN**(44647814;proxy 6/6 + database 489 PASS;02b 5 步勾选未提交) | +| W5 | c2684a24-fedc-4c62-a8c2-d5113de1ce00 | T7 投影层(03-protocol Task 7 节) | deepseek-official/deepseek-flash | **GREEN**(f61d2690;899+548 行;16/16 PASS 含 131073 事件回归;03-protocol Task 7 勾选未提交) | +| W8 | f6a5903a-3ffd-4c72-b35f-98e9fbee17e4 | T9 终止协调器(04a Task 9a+9b) | deepseek-official/deepseek-flash | **GREEN**(b2166060;6 文件;11/11 + 额外回归 PASS;04a 10 步勾选未提交) | +| W9 | 966fe3dc-60d3-4d5c-8f8c-40d18414c699 | T10A Responses/Chat 接线(04b Task 10A;proxy/handler.go + 新测试) | deepseek-official/deepseek-flash | **GREEN**(f1a93458;handler.go +113/-3 + 427 行测试;RED 5 FAIL→8 RUN PASS + 48 聚焦回归 + 整包 165s PASS;04b 5 步勾选未提交) | +| W11 | 443de2f3-0495-4599-b0c4-66417ff78985 | T11.4 交叉验收(05a L1060-1683,gen2Bytes=524288 已修正) | deepseek-official/deepseek-flash | **GREEN**(fe8161bb;database/acceptance_config_test.go 306 行 + proxy/acceptance_config_test.go 301 行;场景 21-23 PASS;05a 3 处勾选未提交;db.Close 非幂等已用 t.Cleanup 修复) | +| W7 | d1772fc6-223a-4969-9706-b903edf6a153 | T6 Admin API+前端(02c Task 6;admin/ + frontend/src/) | deepseek-official/deepseek-flash | **GREEN**(c4a766c4;9 文件;admin 1021 + database 490 + 前端 7/7 + tsc 0 错;RED 先验证;02c 以 Progress 清单代替复选框) | +| W6 | 7477f1cc-f985-444e-956f-af802bafd629 | T11.2 检测验收(05a L190-1059)+ 05a:1506 stale 修正 | deepseek-official/deepseek-flash | **GREEN**(21383575;36 顶层+32 子测试 PASS -race;05a 11.2 复选框已勾,11.4 gen2Bytes→524288 含 2 处依赖断言同步) | +| WF-docs | 60b8b6e1-bed5-4dff-bc0f-c624c7bec32a | spec:140 64→16 字节口径 + 主计划/candidate ~L2165/~L2214 stale 值 →524288 | deepseek-official/deepseek-flash | **GREEN**(spec:140 修正;主计划 L2172-2261 与 candidate 对应区域 stale/gen2/依赖断言 →524288,LKG 默认断言保留 262144;与 02b 语义一致;未提交) | +| FW1 | f5455cb3-efb3-4e8a-b96b-0db49a6715ca | framing 修复 3 findings(executor.go/sse_line.go + 回归测试) | deepseek-official/deepseek-flash | **GREEN**(4522ee05;TDD 证据完整;13 用例 PASS;曾遇 W4 并发噪声,60s 重试自愈) | +| R2 | 04144a0c-bd38-4f28-b362-accda88fba4e | 审查 W1 核心(2d388cca..0087d69c,/tmp worktree 隔离验证) | glm-coding/glm-5.3-flash high | **APPROVED,零 findings**(24/24 -race、vet/gofmt 干净、与 W1 时点计划逐字一致;仅测试函数命名偏差 LOW 可接受;2 个非缺陷观察已记录) | +| R3 | f79fadbe-e6fb-447f-868a-c9d41aba2c25 | 审查 W2 数据库(ecc4385e + WD2 b013ce1a,worktree @b013ce1a 隔离) | glm-coding/glm-5.3-flash high | **APPROVED**(667 PASS -race;2 个 LOW 不阻塞,见下) | +| R4 | e7cf6852-a5dd-4ca9-81c5-27c2847d42a3 | 审查翻倍专项(a9af1bdb+b013ce1a 代码 + 11 文档未提交改动 + 524288 判断 + spec:140 口径) | glm-coding/glm-5.3-flash high | **APPROVED,零 findings**(代码/文档 grep 矩阵全部分类合法;算术一致:16 字节、256 GiB、131073 十处、||256、262145;524288 四处语义一致;后续 commit 无回退) | +| R5 | d6d3d11d-7126-4d6b-a499-87c734971ad8 | 复审 FW1 framing 修复(4522ee05,3 findings + 回归测试 + HEAD 回退检查) | glm-coding/glm-5.3-flash high | **APPROVED**(3 修复全验证、13 用例隔离 PASS、HEAD 无回退;1 个 pre-existing LOW 入 backlog:多行事件 join 分隔符未计入事件 cap,极端 payload 2×,内存仍有界) | +| W13a | ea800da1-93e5-4f63-8c4a-f585bb9b23a1 | T11.1 perfstat 统计工具包(05c L45-516,新建 proxy/perfstat/ 4 文件) | deepseek-official/deepseek-flash | **GREEN**(25c065e3;4 文件 397 行;6 测试 + Linux RUSAGE 真计时 PASS;05c 5 步勾选未提交) | +| R6 | bda8b22d-c995-4e59-987b-45a2db9ec691 | 审查 W4(44647814 T5 运行时) + W5(f61d2690 T7 投影层),worktree 隔离 | glm-coding/glm-5.3-flash high | **APPROVED**(22/22 隔离 PASS;1 个 LOW:ResponsesProjector/ChatProjector 的 aliasMap 声明+写入但从未读取(计划自带死代码),最终整理时确认/删除) | +| R7 | 9fa17b38-d2ed-4b1b-b485-3f497427d8e8 | 审查 W6+W11 验收测试批(worktree @fe8161bb) | glm-coding/glm-5.3-flash high | **APPROVED**(68+场景21-23 全 PASS;2 项受控突变反证;4 LOW 入整理清单⑤) | +| R8 | 0235bfaf-c2e9-4485-93b7-8c39805ee4a9 | 审查 W8 终止协调器(b2166060,worktree 隔离) | glm-coding/glm-5.3-flash high | **CHANGES_REQUESTED**(1 MED:DiscardBuffered 与并发 Flush 无同步、保活 goroutine 真实交错最坏 panic;3 LOW:OverlayGuardOutcome 4 残留字段未清零、WS 并发写契约注释缺失、DeadlineSuccess 未验 5s 真实超时)→ 派 FW2 修复(LOW4 入 backlog) | +| FW2 | fe85320c-252d-47f0-b5af-49bd3fa6f3e5 | R8 修复:stream_flush_writer 并发自包含方案(atomic.Bool+mutex)+ OverlayGuardOutcome 清零 + WS 契约注释 | deepseek-official/deepseek-flash | **GREEN**(3ea512c5;atomic.Bool CAS 先置位 + mu 统一保护 buffer/scanCarry/outputScanner/lastFlush,锁内快照清空锁外写下游 + 写前 aborted 二次检查,API 零变更;新增 50×200 并发 -race 用例 + 慢下游不阻塞用例;18/18 聚焦 + 整包 PASS(3 个无关抖动已基线验证)) | +| R9 | a28edabf-a312-4434-8e23-18eb213df439 | 审查 W7(c4a766c4 admin+前端) + W13a(25c065e3 perfstat) | glm-coding/glm-5.3-flash high | **APPROVED**(admin 2 测试 + perfstat 7 + 前端 417/417 + tsc 0 错;2 LOW 入整理清单:perfstat ErrInvalidBaseline 无消费者、Fill() 低位字节 LCG 周期 256——已通知 W13b 重复检测 fixture 改用 GenerateASCII/高位) | +| W13b | 9b726761-64ef-4362-b689-ec301c58bac9 | T11.5+11.6 性能基准门禁 + 调度脚本(05c L517-1205,新建 2 文件) | deepseek-official/deepseek-flash | **GREEN**(e98def4f;673 行测试 + 28 行脚本(scripts/ 被 gitignore,add -f);5 场景 + 整包 207s + runner smoke 全 PASS;05c 11 步勾选未提交) | +| W10 | 4ebd7c3f-0495-4c10-91f1-c3de9fa89c73 | T10B Native HTTP 接线(04c Task 10B;native adapters + handler_anthropic/gemini + handler.go) | deepseek-official/deepseek-flash | **GREEN**(58a30bab;5 文件;6 新用例 + 整包 171.9s PASS;偏差 8 条均合理(条件门禁复用 W9 约定、Grok/Claude 先提交 200 走已提交分支 envelope、Gemini 裸 JSON 502);04c 无复选框同 02c 惯例,未改;FW2 未提交改动已选择性排除) | +| W14 | a25e121f-a371-49f2-8a76-86cc345bec8b | OI-W9-1:Responses relay-style 分支接线(handler.go;与 W10 硬冲突,串行) | deepseek-official/deepseek-flash | **GREEN**(e8c5ed63;handler.go +61/-14 五处接线 + 175 行测试 3 用例;3/3 聚焦 PASS;调研确认 relay 分支独立于标准循环与 Grok 原生透传;非流式走 snapshot 路径;BaseURL 直连(relay 不走 Resin);整包仅既有 image-pipeline 抖动) | +| OPT1+2 | 89a6e74c / a233a26f | CPU 门禁优化(repetition.go 早退+零分配+分段批量;场景 24 恢复 1MiB) | codex-proxy/gemini-3.8-flash-high high | **GREEN**(4e52485d;1MiB 增量 62.59%→5.41% 中位数;全闸门 PASS;OPT1 未留报告异常由主代理核验 diff 后由 OPT2 收尾提交) | +| W12 | 89e0def1-fd74-4fa2-88f6-49792c365bb4 | T11.3 生命周期/全协议终止验收(05b 场景 12-20,新建 acceptance_lifecycle_test.go) | codex-proxy/gemini-3.8-flash-high high | **GREEN**(e332d4d0;1372 行 18 用例 PASS;场景 12-20 覆盖矩阵完整;3 个 WS 入站 skip 按 spec 标注(生产未接线→W16 补齐);整包 PASS) | +| R10c | 40a23d28-699f-4ac1-bd3d-59de58db50d6 | 审查 W12 生命周期验收(e332d4d0 vs 05b Task 11.3 + spec §9.2) | glm-coding/glm-5.3-flash high | **CHANGES_REQUESTED**(1 HIGH:场景 19 恒真伪断言(自构 penalize:false → reportStreamOutcomeFailure 永不调用、HealthTier/DB usage 断言空转)违反 05b 全局约束;1 MED:场景 17.4 WS_Terminal_And_Lease_Destroyed 名不符实+弱断言+与既有原语测试重复;3 LOW:5s deadline 未断言数值、场景 12 多处 or 弱化、场景 13 err 被丢弃。修复配 FW3(acceptance_lifecycle_test.go 与 W16 硬冲突 → 排 W16 后)) | +| W16 | a70ec105-1d38-4def-a574-d70b98875dbc | WS 入站 guard 接线(responses_ws/realtime_ws 补 ObserveEvent+终止,解锁 3 个 skip 用例) | codex-proxy/gemini-3.8-flash-high high | **GREEN**(3b039d5e;responses_ws.go +33、realtime_ws.go +6、acceptance_lifecycle_test.go +34;18 用例全绿 0 skip、整包 167s PASS;中途曾被外部中止后由用户唤醒续跑完成) | +| FW3 | 30e2ca02-5d4e-4c14-86cf-3c6bed9a6ea3 | 修复 R10c:场景 19 恒真伪断言(HIGH)+ 17.4 更名收紧(MED)+ 3 LOW | codex-proxy/gemini-3.8-flash-high high | **GREEN**(9317b6ad;acceptance_lifecycle_test.go +173/-44;18/18 lifecycle PASS、整包 PASS、gofmt/build 干净;F1 突变自证(注入 ReportRequestFailure → FailureStreak==0 got 1 FAIL)) | +| R12 | 3ed8d343-cf8b-4109-afe4-05ed6214423b | 终审 W16(3b039d5e WS 接线) + FW3(9317b6ad 断言修复) | glm-coding/glm-5.3-flash high | **CHANGES_REQUESTED**(1 HIGH:responses_ws.go:1281 guard 命中 SendDownstreamGuardTerminalWS 未取 responsesWSWriters per-conn 锁→多 lane 并发写违反 gorilla 契约可致帧损坏/panic;1 MED:close code 漂移(真实 1008 vs 17.4 断言 1013 vs 场景 12 接受 1000/1008/1011/EOF)→ 决策统一 1013;2 LOW:场景 19 前置断言或化、reason 形参冗余。F1 突变实证成立、29 子测试 0 skip)→ FW4 | +| FW4 | 3d522af0-a036-4eba-9e64-aad1f2874210 | R12 修复:WS 终态写 per-conn 锁 + close code 统一 1013 + 场景 12/19 收紧 + 同 conn 双 lane 并发回归 | deepseek-official/deepseek-flash high | **GREEN**(68dd0cb7;helper sendResponsesWSGuardTerminal 持 per-conn 锁、外层 close 改 1013、assertWSGuardTerminalThen1013 精确断言、双 lane 并发用例;反证:回退 helper → 稳定 FAIL+gorilla concurrent write panic;targeted 49 用例 PASS;整包仅既有环境性失败 TestChatCompletionsStreamKeepsDownstreamAliveDuringUpstreamSilence(HEAD 亦复现)) | +| R13 | 1b877f38-cf64-42b0-b7bc-e8b7012dd6ca | 复核 FW4(68dd0cb7 锁修复 + close code + 双 lane 用例) | glm-coding/glm-5.3-flash high | **APPROVED**(85 用例 0 FAIL、双 lane -count=3 PASS、回退反证 panic 实证;唯一 MED=工作区回退残留(非 commit 缺陷)→ 主代理已 `git checkout` 恢复并复跑双 lane 用例 PASS) | +| OPT5 | c8a97fae-8473-4b19-a0b6-c80087d446bf | 40 并发空闲机干净复测(deepseek-flash 路由;guard on/off 交替 5 轮 + 1MiB 变体 + loadavg 记录) | deepseek-official/deepseek-flash high | **完成**(64KiB:on 中位 619.77ms / off 619.29ms → 净增 **+0.48ms/轮 (+0.08%)**、+0.012ms/请求;1MiB:净增 -36.86ms (-1.26%);两档净增量均远小于轮间抖动 151-363ms → **40 并发 guard 净 CPU 增量已落入噪声**;320/320 请求 200 零误杀;命中用例 31 delta 精确终止;机器空闲 loadavg 2.4-2.7) | +| 长跑26 | bash-1e02ac9b718541ff | 场景 26 资源泄露 10min 长跑(RUN_OUTPUTGUARD_LONG_RUN=1) | 主代理后台 | **PASS**(600s soak:GoroutineDelta=0、HeapGrowth=59536B、FDDelta=0;单 channel 状态 33936B ≤ 64KiB) | +| R10a | 9f1fb07c-846e-44de-a4ab-076e766c727d | 审查接线三连(W9+W10+W14)+ repetition 早退数学等价性(4e52485d) | glm-coding/glm-5.3-flash high | **APPROVED**(guard 16 用例 -race PASS;早退数学独立推导成立、checkpoint 序列严格一致;4 findings 均范围外缺口:①MED WS ingress/realtime 未接 guard(backlog);②LOW compact 透传 2 写点未观察;③LOW live sideband 盲代理;④LOW 死代码 handleStreamResponse/handleCompactResponse 待删) | +| R10b | e0dad2b2-aadf-4449-b80c-8257948f1308 | 审查 FW2(3ea512c5)+W13b(e98def4f)+OPT(4e52485d 实现质量) | glm-coding/glm-5.3-flash high | **APPROVED**(16 用例 -race PASS;场景 24 复跑 5.03%;FW2 并发安全完备、OverlayGuardOutcome 清零正确、基准质量与 05c 参考一致、OPT writeChunk 环形/checkpoint 推进正确;5 LOW 入整理清单) | +| OPT3 | d1448c03-91fe-46cc-ab49-fb7f37ba2149 | 40 并发 CPU 消耗评估(隔离 worktree @4e52485d,纯测量不提交) | codex-proxy/gemini-3.8-flash-high high | **完成**(40 并发 guard on 64KiB:CPU 151.44ms/轮、3.79ms/请求、利用率 53.69%;guard 纯增量 +2.88%;扩展性 34.97×/40× 亚线性;触发路径 136.71ms;-race 完成率 100% 无报警;测量期间 W12 并行致 headline 负增量噪声) | +| OPT4 | dd8db781-615e-4ad2-980d-87700ff7507d | 第二轮剖析驱动优化(40 并发基线复测 + pprof 差量 + per-event 热点优化 + 全闸门) | codex-proxy/gemini-3.8-flash-high high | **GREEN**(54f1093c;pprof:ObserveEvent 7.73% cum 为最大 guard 热点、Push 仅 0.52%;优化 gjson.GetManyBytes 单遍历 + 零拷贝 extractJSONStringPayload + RequestGuard 通道解析缓存;单流场景 24 增量 5.41%→**1.23%**;40 并发净增量中位数 -1.41%(loadavg 4.53,噪声带内);场景 25 线性度 PASS;全闸门 PASS) | +| W15 | b895babd-630d-4965-aa71-1f611c2ba427 | 默认关闭改造(enabled 默认 true→false:database 常量/DDL/COALESCE、proxy fallback、admin 默认、frontend 默认与测试、spec+02a/02b/02c 文档同步) | codex-proxy/gemini-3.8-flash-high high | **GREEN**(82049878;7 代码 + 6 测试文件;database/admin/-race + frontend 417/417 + tsc 0 错 + build/gofmt PASS;docs 4 文件未提交) | +| R11 | b5a78c91-ce28-4069-9d79-5362312b907c | 审查 W15 默认关闭改造(82049878 代码闭环性 + 遗漏扫描 + 文档口径) | glm-coding/glm-5.3-flash high | **APPROVED**(默认值来源全闭环 false、三态/门禁语义保持、真实覆盖;2 LOW:acceptance_config_test.go:15/:43 注释仍写默认开启;另发现 spec:132/§173-189 tool bytes 128KiB 漏翻倍、candidate:1707/:2805 默认关闭未同步 → 入 W17) | +| W17 | 5b1f5cfd-7d21-4321-9721-3d64ad5f3ecb | 文档最终同步(spec tool bytes 128→256 KiB 全链派生、candidate 默认关闭、R11 2 注释、02c 128*KIB、03-protocol Task 8 同步 FW1、04c Progress 清单) | codex-proxy/gemini-3.8-flash-high high | **GREEN**(7 文件未提交;grep 复核:spec 仅余 L291 通道下限 128 KiB 合法、candidate DEFAULT TRUE 归零、02c 仅余 MIN 通道下限、03-protocol Task 8 已对齐 max(len(data),1)+clamp) | +| Tidy-A | 15c470cb-f795-4ace-acf8-1a64e7dae837 | 整理清单 A(security/outputguard + scripts + handler.go 死代码):场景 1 下界断言、nextNeeded 去重、ErrInvalidBaseline、脚本强断言、边界接受方向测试、死代码删除 | codex-proxy/gemini-3.8-flash-high high | **GREEN**(2c14c49c;子代理未留报告未提交,主代理验证:outputguard 1.467s/perfstat 1.012s -race PASS、gofmt 干净、bash -n OK、handler.go 仅删 69 行死函数、go build ./proxy PASS → 代为提交 6 文件 +126/-95) | +| Tidy-B | 5f57c8af-5a44-48b4-b072-6040440c9694 | 整理清单 B(proxy stream_flush_writer + database):三处 aborted 二次检查、跨 goroutine 契约注释、scanner 并发变体、SQL 字面量绑定、PG skip 重构、candidates 去重、rc 终值断言 | codex-proxy/gemini-3.8-flash-high high | **GREEN**(3755589e;4 文件;database 121.9s -race PASS、scanner 并发变体 PASS、PG SKIP 在 -v 可见;B4 采用 fmt.Sprintf 注入常量强绑定) | + +### Batch 1.5 完成记录(07:15 前后回调) + +| WD1 文档翻倍 | 62fcf76d | spec 27 处 + 10 计划文件共 314 处 | GREEN(残留旧值全部属"不得改"类;git diff --check 通过) | +| R1 framing 审查 | 63c1f3a2 | verdict=CHANGES_REQUESTED(1 MED + 2 LOW) | 完成 → FW1 修复中 | + +### R1 findings(FW1 修复中) +1. [MED] executor.go:1647-1659 空 `data:` 行 `currentEventBytes += 0` 绕过事件累计硬 cap(dataLines 无界;"data:\n"×12M 实测 12MB payload 未拒)→ 改 `+= max(len(data),1)` + 回归测试 +2. [LOW] sse_line.go:55 ReadBoundedResponseBody limit=MaxInt64 时 limit+1 溢出 → clamp +3. [LOW] executor.go:1687-1689/1727-1729 两处 errSSELineTooLong 检查数学不可达 → 加深防御注释 +(test_evidence:7/7 + 4/4 回归 PASS,build/gofmt 通过) + +### WD1 open_issues 分派 +- 02b:225/229/237/259/272 stale/gen2 = 262144 与新默认相同 → **W4** 处理(上抬 524288) +- 05a:1506 同类 → **W6** 处理 +- spec:140 "平均每个事件 64 字节"应为 16(2MiB÷131072)+ 主计划 ~L2165 + candidate ~L2214 stale 值 → **待派 WF-docs**(等空位,当前 5/5 满员) +- 03-protocol Task 8 参考代码与 FW1 修复的同步 → 最终计划同步时处理 + +### WD2 代码翻倍已完成(07:05 回调) + +- commits:`a9af1bdb`(outputguard)+ `b013ce1a`(database) +- 验证:core 24/24 PASS(-race);database 477/477 PASS(-race);go build ./... PASS +- 偏差(合理):sqlite.go 实际 4 处该列 DEFAULT 131072(含增量列迁移列表)全部改为 262144;guard_test.go 同步 2 处 128KiB 注释;database 测试 L231 `int64(131072+(idx+1)*1024)` 属任意更新值未动 +- 已解锁并派发(Batch 2):W4/W5/W6 + FW1 + R2 + +## 审查队列 + +- [x] R1:W3 framing(运行中) +- [x] R2:W1 核心 — **APPROVED 零 findings**(唯一 LOW:并发测试函数命名与计划不同,内容相同,不改) +- [x] R3:W2 数据库 — **APPROVED**(2 个 LOW 记入最终整理清单:① SQL 字面量 262144 与 Go 常量无编译期绑定,建议 fmt.Sprintf 注入或同步注释;② 缺边界端点"接受"方向测试) +- [x] R4:翻倍专项 — **APPROVED 零 findings** +- [x] R5:FW1 framing 修复复审 — **APPROVED**(1 个 pre-existing LOW 入 backlog) +- [x] R6:W4+W5(T5 运行时 + T7 投影层)— **APPROVED**(22/22 隔离 PASS;1 LOW:aliasMap 死代码) +- [x] R7:W6+W11(T11.2 + T11.4 验收测试批)— **APPROVED**(outputguard 68 + 场景 21-23 全 PASS;2 项受控突变反证证明断言非恒真;4 LOW 见下) +- [ ] R7:W6+W11(T11.2 + T11.4 验收测试批)— 待 R6 +- [x] R8:W8(T9 终止协调器)— **CHANGES_REQUESTED**(1 MED + 3 LOW;11/11 隔离 PASS)→ FW2 运行中 → 已闭环(3ea512c5) +- [x] R9:W7+W13a — **APPROVED**;R10a:接线三连+早退数学 — **APPROVED**(范围外缺口:WS ingress MED 入 backlog→W16、死代码入整理清单);R10b:FW2+W13b+OPT — **APPROVED**(5 LOW 入整理清单);R10c:W12 生命周期验收 — **CHANGES_REQUESTED**(1 HIGH + 1 MED + 3 LOW → FW3,排 W16 后) +- [ ] R9:W7(T6 Admin+前端)∥ W13a(T11.1)— 待 R8 +- [ ] R10:W9/W10/W12/W13b 批次 — 待 R9 +- [ ] 后续各批编写完成后按序入队 + +## 环境约定(03:45 更新) + +- 所有 go 命令统一前缀:`GOCACHE=/tmp/go-build GOPATH=/tmp/go GOTOOLCHAIN=go1.26.10 GOPROXY=https://goproxy.cn,direct`(用户 03:45 指示固定使用 goproxy.cn 代理;/tmp 缓存可能被清理导致每次重新下载工具链/模块,GOPROXY 保证下载稳定)。 +- /tmp worktree 创建→验证→清理需在同一 bash 调用内完成(/tmp 可能跨调用不持久)。 + +## 模型路由变更(21:29 / 03:31) + +- 21:29 起 writers = **codex-proxy/gemini-3.8-flash-high** @ high(已执行至 FW3 之前所有 gemini 任务)。 +- **03:31 起 writers 改为 deepseek-official/deepseek-flash @ high**(正在运行的 FW3 为已派发任务,保持原路由跑完;其后新派发的编写子代理一律用新路由)。审查员保持 glm-coding/glm-5.3-flash @ high。 + +## CPU 优化任务(21:35 用户指示"先优化")— ✅ 完成 + +用户选择 OI-W13b-1 处置方案 = **优化实现**。最终 **commit 4e52485d**(OPT1+OPT2 合并提交,2 文件 +97/-44): +- **结果**:场景 24 @1MiB CPU 增量 **62.59% → 5.41%**(5 次独立采样中位数:3.73/3.13/10.48/7.54/5.41%),远低于 Spec §9.3 20% 门禁与 16% 内部目标;场景 24 恢复计划 1MiB 口径(256×4KiB)。 +- **技术**:① checkCurrentWindow——linear/pi 复用字段零堆分配(pi [8192]uint16,struct +24KB 仍过 64KiB 体积闸门)、ring 两段 copy 展开、KMP 前缀遗传性早退(pi[i] < i+1-256 即判非重复,正常数据 ~300 迭代中止);② Push/PushWithHash——checkpoint 对齐分段批量推进(writeChunk 双段 copy、局部紧循环 FNV、段尾统一更新游标),消除 -race 下每字节 6-8 次 struct 字段插桩访问。 +- **语义零变化**:checkpoint 触发时机字节级等价;outputguard 全包 + 场景 1/2/3/8/11 等价闸门 + 场景 24×5 + 场景 26 + proxy 整包全 PASS;gofmt/vet 干净。 +- 遗留:pprof 证据在 /tmp/scenario24.pprof(未入库);4e52485d 纳入 R10 审查批(repetition 语义敏感,建议 R10 重点核早退数学等价性)。 + +## 40 并发 CPU 评估(23:25 OPT3) + +32 核 / GOMAXPROCS=32 / 7.8GiB。隔离 worktree @4e52485d,in-process httptest 真实 Responses 管线,每点 5 轮中位数: +- **扩展性(guard on, 64KiB/请求)**:并发 1/5/10/20/40 → 总 CPU 4.33/28.80/33.30/69.28/151.44ms,CPU/请求 4.33/5.76/3.33/3.46/3.79ms,利用率 6.02/19.05/30.39/46.05/53.69%;40 并发 CPU 为单请求的 **34.97×**(<40×,亚线性)。 +- **guard 纯增量**:交替多轮 +72.61ms(+2.88%);40×64KiB 载荷下约 +1.8ms/请求。headline 单次对照出现 -34.35% 负增量,系测量时 W12 整包测试并行占用 32 核所致(噪声)。 +- **1MiB/请求 40 并发**:总 CPU 132.00ms、CPU/请求 3.30ms——固定每请求开销(配置快照/路由/鉴权)主导,载荷大小非主导项。 +- **触发最坏路径 40 并发**:8.1KiB 处准确拦截,全程 CPU 136.71ms(低于正常完整流),cancel/终止/envelope 链路无额外尖峰。 +- **-race 40 并发**:ON 4918.65ms vs OFF 3043.04ms(+62%,竞争检测运行时放大,非生产工况),完成率 100%、零 data race 报警、零死锁。 +- 资源:goroutine 峰值 306(触发阶段短暂 551)、堆稳态 ~100.16MB、全部场景完成率 100%、正常流零误杀。 +- **待办**:空闲机(W12 完成后)复测一次干净基线,确认 guard 纯增量。 + +## 默认关闭改造(23:52 用户指示) + +用户要求 guard **默认关闭**(enabled 默认 true→false)。已定位影响面:`database/output_guard_settings.go:12 DefaultOutputGuardEnabled=true`、DDL enabled 列默认值、COALESCE 默认(:55/:119)、proxy fallback 构造、`admin` GET 默认断言、`frontend/src/lib/outputGuardSettings.ts:21 output_guard_enabled: true`(DEFAULT_OUTPUT_GUARD_SETTINGS)、Settings.tsx 警告逻辑与测试、文档:spec:131/:293/:347、02a:20/:141、02c:198。派 **W15** 实施(代码+测试提交、docs 保持未提交)。冷启动就绪门禁语义不变(与 enabled 无关)。 + +## 40 并发 CPU 评估(23:25 OPT3)+ 二次优化(00:43 OPT4 54f1093c) + +OPT3 初步数字见上(受 W12 测试干扰)。OPT4 以 pprof 实证重构后: +- **guard-on 独有热点**:ObserveEvent 7.73% cum(Project 6.02%,gjson 重复解析/string 构造为主)> RequestGuard.resolveChannelLocked 0.45% > RepetitionDetector.Push 0.52%(扫描已非瓶颈)。 +- **优化**:gjson.GetManyBytes 单遍历、零拷贝 extractJSONStringPayload、RequestGuard 通道解析缓存。 +- **结果**:单流场景 24(-race)CPU 增量 **1.23%**(5.41%→1.23%),TTFT 0 退化;40 并发净增量中位数 **-1.41%**(5 轮 ±10% 噪声带内,loadavg 4.53——噪声已大于信号);场景 25 线性度 1/2/4MiB = 2.13×/1.95× PASS;outputguard 全包 + proxy 整包 + build/vet/gofmt 全 PASS。 +- **结论**:40 并发下 guard CPU 开销已落入测量噪声(<±1% 量级真实增量),进一步优化收益低于噪声;如需更精确结论需在完全空闲机做高精度计数(如 perf stat 指令数)。 +- 待办:最终整合时在空闲机复测一次 40 并发净增量(当前 3 个子代理并行,loadavg 4.5+)。 + +## 最终验证(05:04) + +- `go build ./...` **PASS**;gofmt 改动文件零输出。 +- 全包 `go test -race -count=1 ./...`:**21 个包 PASS**(含 database 157s、admin 124s、outputguard 1.6s、perfstat、wsrelay、proxy/…),proxy 包仅 1 个失败:`TestChatCompletionsStreamKeepsDownstreamAliveDuringUpstreamSilence`。 +- **该失败为既有环境性失败,非本分支回归**:在分支基点 `dfab070b`(v3.1.2,全部 guard 改动之前)worktree 复跑同一用例同样 FAIL。机制:请求级 keepalive 5ms 间隔 + 本机 Resin/代理延迟使"首字前窗口"超过 5ms,keepalive 落在首字前;与 guard 无关(该测试期望首字前零字节)。 +- 长跑场景 26(10min soak)PASS:GoroutineDelta=0、HeapGrowth 59536B、FDDelta=0。 +- 40 并发干净复测(OPT5):guard 净增量 +0.48ms/轮(+0.08%,64KiB)/ -36.86ms(1MiB),均落入 151-363ms 轮间噪声;320/320 请求 200、零误杀。 +- CPU 优化终值:单流场景 24(-race)增量 62.59% → **1.23%**。 + +## 已知限制与 backlog(不在本次范围) + +1. R5 既有项:多行事件 join 分隔符未计入事件 cap(pre-existing,内存仍有界,计划参考口径一致)。 +2. R9 既有项:perfstat.Fill() 低位字节流周期 256(重复检测 fixture 已改用 GenerateASCII 规避)。 +3. R10c/R8 观察项:SendDownstreamGuardTerminalWS 的 reason 形参冗余(脱敏正确); +4. 场景 18 DeadlineSuccess 未构造慢消费下游验证 5s 真实超时(集成测试级,既有无变化)。 +5. 既有图像管线/keepalive 时序测试在部分机器上偶发(非本分支引入,已按基线排除)。 +6. WS 命名 lane 的 guard 命中沿用既有 multiplex 语义:仅终止所属 lane(error 帧 + 该 lane 收尾),1013 整连接关闭仅用于默认 lane 与 realtime 连接。 + +## 暂停/恢复记录(2026-10-31) + +13:01 用户指示恢复:OI-W9-1 单开 W14 补齐(relay-style 接线)。**14:10 用户再次指示暂停派发**:不再派发新子代理,运行中的 W13b(T11.5+11.6)与 W14(relay-style)继续跑完,回调照常记录,解锁链挂起等"恢复"指示。 +14:18/14:33 W13b(e98def4f)与 W14(e8c5ed63)先后 GREEN 并记录。21:35 用户指示优化 CPU 门禁(恢复 OPT1/OPT2 派发);22:36 OPT2 完成 **4e52485d**(1MiB 增量 5.41%,达标)。22:55 用户指示继续执行:派发 W12(T11.3,gemini-3.8-flash-high 首个正式任务)∥ R10a(接线三连 + repetition 早退数学)。后续:W12/R10a 完成 → R10b(FW2+W13b+OPT 审查)∥ 长跑场景 26。 + +### W13b 重要发现(已处置) +- ~~**OI-W13b-1(CPU 门禁超标)**~~ → **已优化解决**(4e52485d,见"CPU 优化任务"节:1MiB 增量 5.41%,场景 24 已恢复 1MiB 计划口径)。 +- ~~**OI-W13b-2(独立佐证 W14)**~~ → W14 已接线 relay 分支(e8c5ed63),佐证闭环。 +- 其他:场景 25 用 encoding/json 构造 payload(PRNG ASCII 引号破坏 JSON);场景 26 每 worker 独立 PRNG(计划参考共享 PRNG 会 race);长跑 10min 需 RUN_OUTPUTGUARD_LONG_RUN=1(**待用户决策是否执行**)。 + +### W9 遗留 open issues(待决策) +- **OI-W9-1**:Responses 的 relay-style 分支(account.IsRelayStyle:OpenAI Responses 中转/Antigravity Responses 等)未按 04b 锚点接线,本次仅覆盖标准 Codex 主分支 + Chat 主分支。建议:并入 W10 范围或单开 W14 补齐。 +- **OI-W9-2**(设计注记,暂不改):冷启动 Load 的 parent ctx 在写 lastSyncError 前取消时,门禁退化为默认配置而非 503,直到轮询恢复(与既有测试兼容性设计相关)。 +- W9 偏差记录:冷启动门禁改为 outputGuardHTTPGateArmed()(仅 ready 或 lastSyncError 非空时生效,避免既有全量测试 503);测试阈值用 DB 合法最小值(计划 100/200 字节配置会被校验拒绝);Chat 流式无 downstreamMu 传 nil mutex;gotTerminal 防 upstream_stream_break 合成。 + +## 待办(按解锁顺序) + +- [x] W9 完成 → W10:T10B native HTTP(04c)— **运行中** +- [ ] W10 完成 → W14:OI-W9-1 relay-style 接线(handler.go 串行)→ W12:T11.3 生命周期/全协议验收(05b,场景 12-20)∥ W13b:T11.5+11.6 性能基准+调度脚本(05c,依赖 T11.1+W10) +- [ ] W10 完成 → W12:T11.3 生命周期/全协议验收(05b,场景 12-20)∥ W13b:T11.5+11.6 性能基准+调度脚本(05c,依赖 T11.1+W10) +- [ ] 全部 GREEN → 终验(go build + 全量测试 + pnpm typecheck) +- [ ] 最终整理清单:① R3-LOW-1 SQL 字面量 262144 与 Go 常量无编译期绑定(fmt.Sprintf 注入或同步注释);② R3-LOW-2 补边界端点"接受"方向测试;③ R5-backlog 多行事件 join 分隔符未计入事件 cap(极端 payload 2×);④ R6-LOW aliasMap 死代码确认/删除;⑤ R7-LOW×4:场景 1 触发区间补 ≥8192 下界断言、database PG Skip 死分支改始终注册+skip、candidates map/slice 冗余合并、并发 rc 组补终值断言;⑥ R9-LOW×2:perfstat ErrInvalidBaseline 无消费者(删或留 T11.5)、perfstat Fill() 低位字节 LCG 短周期 256(重复检测 fixture 改用 GenerateASCII/高位);⑦ R8-LOW-4 DeadlineSuccess 未验 5s 真实超时(集成测试);⑧ 计划文件勾选/同步提交(含 03-protocol Task 8 参考代码同步 FW1 修复、02c Step 5 的 128*KIB 字面量 → 256*KIB、W7 的 Progress 清单、04c 的 Progress 清单);⑨ R10b-LOW×5:stream_flush_writer 三处锁外直写补 aborted 二次检查(一行)、Flush/WriteBytes 跨 goroutine 契约注释、并发测试补 NewOutputScanner 变体、repetition.go Push/PushWithHash 推进逻辑提取 helper、run_acceptance_suite.sh 第 3 步改强断言或改名;⑩ 死代码 handleStreamResponse/handleCompactResponse 删除(R10a-LOW-4);⑪ 汇总报告 diff --git a/docs/superpowers/plans/part_head.txt b/docs/superpowers/plans/part_head.txt new file mode 100644 index 000000000..30897caae --- /dev/null +++ b/docs/superpowers/plans/part_head.txt @@ -0,0 +1,78 @@ +# Codex2API 全局模型输出失控防护 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 基于已审查通过的设计规格,为 Codex2API 建立全局模型输出失控防护体系(协议感知增量重复检测 + 字节/事件硬预算 + 请求级切断 + 限制最大输出长度),在模型输出进入聚合、回放、缓存及下游之前拦截异常流量,消除下游全量重解析引发的 O(n²) 性能雪崩。 + +**Architecture:** 采用分层解耦与自下而上的无环依赖架构: +1. 核心状态机与算法层(`security/outputguard`):实现常量空间 8192-byte 滑动窗口、KMP 前缀函数求最短周期($1 \le p \le 256$、有限串周期数学不要求 $L \pmod p = 0$ 且有独立非整倍数测试验证)、步长 1024-byte 检查点与严格尾检;单 channel 扫描预算($2 \times \text{limit}$)与至多 3 次合法纯终态快照许可(单遍历 Hash 流水线比对,处理上限绝对锁定于 $5 \times \text{limit}$);请求级 8 步无歧义判定流水线(先字节高水位与 request_bytes 累加判定,再资格/等价性检查与成本计算,首 reason 冻结),内置读写并发互斥锁(`sync.RWMutex`),保障 ObserveAppend/IsTripped 并发无 race。 +2. 数据库与持久化层(`database`):基于 `system_settings` 表实施 PostgreSQL/SQLite 模式迁移;`*bool` 三态指针语义防止零值覆盖显式关闭(经 Go 标准库 `sql.NullBool` 源码证实原生支持 SQLite `int64` 的 0/1 转换);单事务同连接读取旧值与加锁(Postgres `FOR UPDATE` / SQLite 独占写锁)、窄更新与 CAS 自增 `generation`,彻底消除死锁与丢更新。 +3. 运行时配置与入口 Ready 门禁层(`proxy`):不可变配置快照、5 秒后台 Revision 轮询器(`StartOutputGuardConfigPoller`)、冷启动未就绪检测(`RequireOutputGuardReady`),默认 `isReady = false`,仅首次从 DB 成功加载或应用配置后置为 true;main 启动挂接初始化与 poller;HTTP 入口返回 503 `api.ErrCodeServiceUnavailable`,WebSocket/Realtime 在握手或首帧返回 CloseTryAgainLater (1013) 错误。 +4. 管理端与前端 UI 层(`admin`, `frontend`):复用既有 `/api/admin/settings` GET/PUT,支持独立分组、单位展示、恢复默认(恢复 Spec 默认配置)、严格层级范围校验($16\text{KiB} \le \text{tool} \le \text{channel} \le \text{request} \le 64\text{MiB}$);非法配置记录诊断日志;提供完整前端 React 界面与 TypeScript 类型契约,并以 `pnpm run typecheck` 验证。 +5. 统一协议投影层(`proxy`):将 Responses HTTP/WS、ChatCompletions、Messages、Gemini Native 及 Realtime 文本子集的流式 chunk 及非流式 JSON 事件实时投影为统一语义观察(`Observation`);实现 Anthropic、Gemini Native 及各协议非流式真实 JSON 投影器与单元测试。 +6. 强制终止与下游通知层(`proxy`):先实现 `GuardTerminationCoordinator` 与 `SendDownstreamGuardTerminal`。guard 命中时原子切换 `tripped` 状态并冻结首个 reason;优先主动 Cancel 上游 context(直接调用 cancel 跳过 5 秒 usage drain,关闭 HTTP body,WS lease 废弃不归还健康池);清空本地 pending、provenance、collector、replay、writer buffers;下游通知写入采用 `downstreamMu.TryLock()` 避免死锁,持锁写操作通过 `http.ResponseController.SetWriteDeadline` 施加 5 秒硬超时;不支持 deadline(`ErrNotSupported`)或已断开时标记 `clientGone` 安全退出;补齐 Gemini SDK 兼容格式及 WebSocket 1013 / error 帧。 +7. 链路编排与重试阻断层(`proxy`):在前置拦截点(Responses HTTP/WS, Chat, Messages, Gemini, Realtime)将投影器、RequestGuard 与终止协调器组装串联;在 `continuousRetryStreamSelected`、`shouldTransparentRetryStream` 及自动续想入口最前置拦截 `guardTripped`,坚决不重试;在 `retryExclusions` 中豁免账号健康惩罚(使用真实符号 `exclusions.forHardSelection()` 与 `exclusions.hasResettable()` 断言)。 +8. 综合验收集成测试层(`test/outputguard`):完整实现 Spec §9 场景 1~28 全场景代码,提供脱敏 fixture、机器自动化判定阈值,覆盖成本上界 8a/b/c、入口矩阵、cancel/body close 无 drain、重试上游调用数恒为 1、全协议错误结构、TryLock/deadline 降级、冷启动 503 / 并发 patch / 恢复默认、TTFT p95 <= 10ms、CPU <= 20%、线性比值 ratio <= 2.5、每 channel <= 64KiB、10 分钟内存/Goroutine/FD 启停快照。 + +**Tech Stack:** Go 1.26+ (gin-gonic/gin, tidwall/gjson, modernc.org/sqlite, jackc/pgx/v5), TypeScript + React 19 (前端契约与界面). + +**Spec:** `docs/superpowers/specs/2026-10-28-model-output-guard-design.md` + +## Global Constraints + +- **范围限制**:只涉及文字输出的 LLM(含 text、thinking、tool arguments);只修订计划文件 `docs/superpowers/plans/2026-10-28-model-output-guard-tdd-plan.md`,不修改 spec、业务源码、数据库或其他文件;不修改 DSH、客户端或已安装 npm 包;设计文档 §10 的设计取舍已全部由用户确认。 +- **不可妥协的设计取舍**: + - 默认全局开启,仅管理员可在后台显式关闭;与 prompt filter 相互独立。 + - 首期采用精确短周期重复检测 + 硬预算,不引入语义模型,不自动改写或拆分工具参数。 + - 默认工具参数上限 128 KiB、4096 非空 deltas;单 channel 字节上限 2 MiB;单请求字节上限 4 MiB;合法大 `write` 也拦截,后台可调高。 + - 只切断当前入口请求或 WebSocket 逻辑 turn;代理端禁止任何形式的透明重试与自动续想,绝不惩罚账号健康分。 + - 全协议覆盖:涵盖 Responses HTTP/WS、ChatCompletions、Messages、Gemini Native 及 Realtime WebSocket 桥接的文本/工具子集;专用媒体接口、Realtime 原生音频 Modality(显式拒绝)及协议管理帧不计入语义重复检测。 + - transport reader 有界行/事件/framing 保护(SSE 8 MiB、WS frame 8 MiB)独立于 guard 开关,关闭语义 guard 仍保留 wire cap。 +- **技术异议核实结论(对首轮评审意见 M2 与 I8 的技术审查回应)**: + - **M2 关于 SQLite 不支持 sql.NullBool 的澄清**:经查证 Go 标准库 `database/sql/convert.go:383` 及 `database/sql/driver/types.go:87-92`,`sql.NullBool.Scan` 内部调用 `convertAssign`,明确原生支持驱动返回的 `int64`(0 对应 false,1 对应 true)。在 `modernc.org/sqlite` 驱动下,SQLite 的 `INTEGER` 字段扫描至 `sql.NullBool` 完全合法,无需盲目绕开 `sql.NullBool`。本计划在数据库结构层保留 `*bool` 指针三态语义,在底层 Scan 阶段通过 `sql.NullBool` 准确反序列化。 + - **I8 关于 KMP 前缀周期整除性的数学核实**:评审建议曾提及“检查 L % p == 0”,但根据 Spec §5.2 规则 3 明确要求:“命中证据是连续 8 KiB 的精确周期窗口;最长周期时也含至少 32 次重复,不要求窗口长度整除周期”。在有限长度字符串(8192 字节)上,若最短周期为 $p$(例如事故模式 $p = 30$),$8192 \pmod{30} = 22 \ne 0$。KMP 前缀函数的最大 border $k = \pi[L-1]$ 对应的最小周期候选值为 $p = L - k$。当且仅当 $1 \le p \le 256$ 且 $\forall i \in [p, L): s[i] == s[i-p]$ 时即为严格周期,绝不能加入 $L \pmod p == 0$ 的错误过滤。本计划在 Task 1 中明确提供非整倍数($p=30, 8192\pmod{30}=22$)的针对性数学验证测试。 +- **编译与验证约束**: + - Go 测试命令必须指定独立缓存路径:`GOCACHE=/tmp/go-build GOPATH=/tmp/go go test ...`。 + - 前端类型校验命令:`cd frontend && pnpm run typecheck`。 +- **TDD 流程纪律**:每个任务严格遵循 Step 1 (Write failing test) -> Step 2 (Verify Red) -> Step 3 (Minimal implementation) -> Step 4 (Verify Green) -> Step 5 (Commit)。绝不使用占位符,严禁假测试与“Red or pass”。 + +--- + +## File Structure + +| 文件路径 | 状态 | 职责描述 | +|---|---|---| +| `security/outputguard/repetition.go` | 新建 | 8192B 滑动窗口 Ring Buffer、KMP 前缀函数求最短周期($1 \le p \le 256$、支持非整除周期)、1024B 步长检查点与严格尾检 | +| `security/outputguard/repetition_test.go` | 新建 | 周期检测器单元测试(命中短周期、非整倍数周期 $p=30$、非周期放行、大 delta 跨步长、<8192B 尾检放行、未对齐最新 8192B 窗口检测) | +| `security/outputguard/channel.go` | 新建 | 单 Channel 状态机:已计费高水位、累计扫描预算扣减、3 次合法纯终态快照许可、单遍历 Hash 流水线、处理量 $5 \times \text{limit}$ 封顶 | +| `security/outputguard/channel_test.go` | 新建 | Channel 状态机单元测试(高水位推进、用例 8a/8b/8c 纯终态许可与变体超额拦截) | +| `security/outputguard/guard.go` | 新建 | 请求级 `RequestGuard` 状态机:事件预算(65536)、Channel 计数(128)、Delta 计数(4096)、§5.3 严格 8 步判定流水线、`sync.RWMutex` 并发安全保障 | +| `security/outputguard/guard_test.go` | 新建 | 请求级 Guard 单元测试(事件洪泛拦截、Channel 洪泛拦截、Delta 洪泛拦截、字节溢出、首个 reason 冻结、并发 race 检验) | +| `database/output_guard_settings.go` | 新建 | 数据库层 `OutputGuardSettings`、`*bool` 三态指针、同事务锁机制(Postgres FOR UPDATE / SQLite 独占写锁)与 CAS 自增 `generation` | +| `database/output_guard_settings_test.go` | 新建 | 数据库迁移与设置读写测试(SQLite/PostgreSQL 默认填充、显式 false 持久化、并发独立窄更新不丢字段与防死锁) | +| `database/sqlite.go` | 修改 | `system_settings` 表增加 output_guard 6 个字段的 SQLite 迁移列(INTEGER) | +| `database/postgres.go` | 修改 | `system_settings` 表增加 output_guard 6 个字段的 PostgreSQL 迁移列(BOOLEAN/BIGINT/INT) | +| `proxy/output_guard_settings.go` | 新建 | 运行时不可变配置快照、5 秒后台 Revision 轮询器、`ApplyOutputGuardSettings`、冷启动未就绪 503 检查、非法配置诊断日志 | +| `proxy/output_guard_settings_test.go` | 新建 | 运行时配置生效与 5 秒轮询更新测试、冷启动未就绪 503 门禁测试、非法配置诊断日志测试 | +| `admin/output_guard_settings.go` | 新建 | 管理端 Admin API:扩展现有 GET/PUT `/api/admin/settings`,支持独立分组、单位显示、恢复默认、严格层级范围校验 | +| `admin/output_guard_settings_test.go` | 新建 | Admin API 单元测试(范围校验失败回滚与诊断日志、恢复默认设置、合法修改即时生效、同事务安全合并) | +| `frontend/src/types.ts` | 修改 | 增加 `OutputGuardSettings` 前端类型契约 | +| `frontend/src/pages/admin/SystemSettingsPage.tsx` | 修改 | 管理端 UI 界面:新增独立的“模型输出失控防护”分组卡片,含 5 项配置、单位(KiB/MiB)、高风险提示与“恢复默认”操作按钮 | +| `proxy/outputguard_projection.go` | 新建 | 统一协议投影器:将 Responses HTTP/WS、ChatCompletions、Messages、Gemini Native 及 Realtime 文本流式与非流式事件投影为标准 `Observation` | +| `proxy/outputguard_projection_test.go` | 新建 | 协议投影器单元测试(各协议 chunk/snapshot/非流式 JSON 正确转换为 ChannelID、Kind、Action 及 Payload) | +| `proxy/outputguard_cancel.go` | 新建 | 终止协调器:原子切换 `tripped` 状态并冻结首 reason、主动 Cancel 上游 context、关闭 body、绕过 5s drain、清空在途缓冲 | +| `proxy/outputguard_cancel_test.go` | 新建 | 上游取消与缓冲废弃测试(mock upstream 立即收到 context cancel,无 5s 延迟,本地 pending/collector/replay/writer 缓冲彻底清空) | +| `proxy/outputguard_downstream.go` | 新建 | 下游失败通知写入:各协议终态错误帧构建(Responses, Chat, Anthropic, Gemini Native, WebSocket)、`downstreamMu.TryLock()` 有界锁获取、持锁内 `SetWriteDeadline(5s)` 写入与 ErrNotSupported 安全降级 | +| `proxy/outputguard_downstream_test.go` | 新建 | 下游写入测试(各协议标准错误结构断言、TryLock 竞争失败安全放弃不卡死、持锁超时回收、不支持 deadline 安全退出) | +| `proxy/continuous_retry.go` | 修改 | `continuousRetryStreamSelected` 最前置识别 `guardTripped`,坚决不重试 | +| `proxy/retry_exclusions.go` | 修改 | `MarkStreamFailure` 识别 guard 失败,豁免账号健康分惩罚,不增加错误计数 | +| `proxy/outputguard_retry_test.go` | 新建 | 重试阻断与账号豁免测试(使用真实符号 `exclusions.forHardSelection()` 与 `exclusions.hasResettable()` 断言) | +| `proxy/handler.go` | 修改 | Responses HTTP SSE `forwardWithEvent` 及非流式接入前置 guard 检查;ChatCompletions 流式与非流式接入前置 guard 检查 | +| `proxy/responses_ws.go` | 修改 | Responses WebSocket 接入每个逻辑 turn 的前置 guard 检查(早于 collector 与 replay) | +| `proxy/realtime_ws.go` | 修改 | Realtime 文本转接 session 严格继承全局 guard | +| `proxy/handler_anthropic.go` | 修改 | Messages (Claude Native) 流式与非流式接入前置 guard 检查 | +| `proxy/handler_gemini.go` | 修改 | Gemini Native `streamGenerateContent` 及非流式接入前置 guard 检查 | +| `proxy/main_init.go` | 新建 | 主程序初始化接线:挂载 `proxy.StartOutputGuardConfigPoller`,执行冷启动配置预加载 | +| `test/outputguard/acceptance_detection_test.go` | 新建 | 综合验收测试:场景 1~11(短周期、不同 delta 边界切分、9215B 片段、8a/8b/8c 专项、短文本尾检放行) | +| `test/outputguard/acceptance_lifecycle_test.go` | 新建 | 综合验收测试:场景 12~20(各入口流式/非流式全链路终止、无 5s drain、并发无 race、无重试、账号无惩罚) | +| `test/outputguard/acceptance_benchmark_test.go` | 新建 | 综合验收测试:场景 21~28(配置轮询、冷启动 503、CPU O(N) 线性度、内存 <=64KiB、细 delta 4096 专项压测分析) | diff --git a/docs/superpowers/plans/part_t10.txt b/docs/superpowers/plans/part_t10.txt new file mode 100644 index 000000000..772d1644c --- /dev/null +++ b/docs/superpowers/plans/part_t10.txt @@ -0,0 +1,621 @@ + +### Task 10: 全协议 Handler 接线、前置 Guard 拦截与全链路恢复阻断 + +**Files:** +- Modify: `proxy/continuous_retry.go:172-185, 276-285` +- Modify: `proxy/retry_exclusions.go:209-245` +- Modify: `proxy/handler.go:2905-2935, 4014-4050, 4405-4420, 5045-5085, 5270-5285, 5538-5588, 5680-5755, 5915-5955, 6030-6070, 7025-7065, 7380-7415, 7560-7575, 7700-7810, 7960-7985` +- Modify: `proxy/responses_ws.go:246-270, 700-715, 835-850, 1050-1070, 1251-1315` +- Modify: `proxy/realtime_ws.go:40-60, 140-160` +- Modify: `proxy/handler_anthropic.go:547-570, 830-845, 1035-1050, 1330-1345, 1570-1615, 1720-1768` +- Modify: `proxy/handler_gemini.go:123-160, 462-520` +- Test: `proxy/outputguard_handler_test.go` + +**Interfaces:** +- Consumes: + - `security/outputguard` (来自 Task 1~3, 定义于 `security/outputguard/guard.go`): + ```go + type ChannelKind string + const ( + KindText ChannelKind = "text" + KindThinking ChannelKind = "thinking" + KindToolArguments ChannelKind = "tool_arguments" + ) + type Config struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 + } + type RequestGuard struct { ... } + func NewRequestGuard(cfg Config) *RequestGuard + func (g *RequestGuard) ObserveAppend(channelID string, aliasID string, kind ChannelKind, delta []byte) error + func (g *RequestGuard) ObserveSnapshot(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error + func (g *RequestGuard) EndChannel(channelID string) error + func (g *RequestGuard) IsTripped() bool + func (g *RequestGuard) TrippedReason() string + ``` + - `proxy` 运行时配置快照与就绪门禁 (来自 Task 5, 定义于 `proxy/output_guard_settings.go`): + ```go + func RequireOutputGuardReadyHTTP(c *gin.Context) bool + func RequireOutputGuardReadyWS(conn *websocket.Conn) bool + func RequireOutputGuardReadyStream(conn *websocket.Conn, streamID string) bool + func CurrentOutputGuardConfig() OutputGuardAppliedConfig + ``` + - `proxy` 协议投影器与分派原语 (来自 Task 7, 定义于 `proxy/outputguard_projection.go`): + ```go + type ProtocolProjector interface { + Project(eventType string, data []byte) ([]Observation, error) + } + func NewResponsesProjector() *ResponsesProjector + func NewChatProjector() *ChatProjector + func NewAnthropicProjector() *AnthropicProjector + func NewGeminiProjector() *GeminiProjector + func ObserveEvent(guard *outputguard.RequestGuard, projector ProtocolProjector, eventType string, data []byte) error + ``` + - `proxy` 传输 Framing 有界 Reader 与 Cap (来自 Task 8, 定义于 `proxy/sse_line.go`): + ```go + func ReadSSEStreamWithEvent(body io.Reader, callback func(event string, data []byte) bool) error + func ReadBoundedResponseBody(body io.Reader, limit int64) ([]byte, error) + ``` + - `proxy` 强制终止与下游通知基础原语 (来自 Task 9 契约定义, 计划落于 `proxy/outputguard_cancel.go` 与 `proxy/outputguard_downstream.go`): + > **接口契约与依赖差异说明(重要声明)**: + > 1. Task 10 仅负责 handler 接线与全链路恢复阻断,**不扩充原语的内部实现**。 + > 2. Task 9 由独立作者正在编写 `part_t9.txt`,相关终止与下游通知原语目前尚未落地生产代码,本计划严格依赖 Task 9 所定义的接口契约(而非声称生产已存在): + > ```go + > // streamOutcome 扩展字段(用于在代理内部全链路传递 Guard 命中状态与类型化原因) + > type streamOutcome struct { + > ... + > guardTripped bool + > guardReason string + > } + > + > // 终止协调原语:原子标记 tripped,主动 cancel 上游 context(跳过 5s usage drain), + > // 关闭 HTTP 响应体,废弃所属 WS lease;清空本地在途 pending/provenance/collector/replay/writer 缓冲 + > type GuardTerminationCoordinator struct { ... } + > func NewGuardTerminationCoordinator(guard *outputguard.RequestGuard, cancelUpstream context.CancelFunc, body io.Closer) *GuardTerminationCoordinator + > func (c *GuardTerminationCoordinator) HandleTripped(reason string, discardBuffers ...func()) streamOutcome + > + > // 下游通知写入原语:TryLock 非阻塞获取 downstreamMu,成功后持锁通过 ResponseController.SetWriteDeadline(5s) 写入协议错误帧; + > // 遇到 ErrNotSupported 或锁冲突立即安全放弃并标记 clientGone,绝不阻塞主流程回收 + > func SendDownstreamGuardTerminalHTTP(c *gin.Context, mu *sync.Mutex, clientGone *bool, protocol string, reason string) error + > func SendDownstreamGuardTerminalWS(conn *websocket.Conn, streamID string, reason string) error + > ``` + > 3. **核心事件计数契约差异说明**:Task 7 (C) 在 `ObserveEvent` 中设计了 `guard.RecordEvent()` 先扣 1 次事件,随后对多 observation 使用 `ObserveAppendNoEvent`;而 Task 1~3 (A) 发布的 `RequestGuard` 接口中未暴露独立的 `RecordEvent`(由内部判定流水线管理)。Task 10 在 handler 层面统一消费 Task 7 暴露的高级事件入口 `ObserveEvent(guard, projector, eventType, data)`,两者的内部计数差异由后续整体整合阶段统一,Task 10 不在 handler 内直接调用未定义的底层 `RecordEvent`。 + +- Produces: + - 覆盖 `/v1/responses` (HTTP 流式/非流式, WebSocket, Realtime 文本子集), `/v1/chat/completions`, `/v1/messages`, Gemini Native (`streamGenerateContent`/`generateContent`), Grok, Antigravity 渠道的全局防御闭环。 + - 请求级与 WS turn 级的配置快照与 `RequestGuard` 实例生命周期管理(跨 attempt 保持累积,calls == 1)。 + - 前置检查点保护:guard 检查必须在 `bytes.Clone` provenance 记录、collector 累加、translator 转换与 replay 写入之前。 + - 全链路恢复阻断:在 ordinary retry, selective/catch-all retry, 换号, WS->HTTP fallback, 缺失上下文恢复, 自动压缩, 自动续想等全部恢复点无条件阻断,确保账号健康零受罚、cache/续链零污染、用量不虚构不 drain。 + +--- + +- [ ] **Step 1: Write failing integration test for handler wiring and recovery blocking** + +```go +// proxy/outputguard_handler_test.go +package proxy + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/codex2api/api" + "github.com/codex2api/auth" + "github.com/codex2api/database" + "github.com/codex2api/security/outputguard" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "github.com/tidwall/gjson" +) + +// 辅助函数:构造测试用的 Handler +func setupTestProxyHandlerWithUpstream(t *testing.T, upstreamURL string) (*Handler, *gin.Engine) { + gin.SetMode(gin.TestMode) + db := newTestModelRegistryDB(t) + store := auth.NewStore(db) + + account := &auth.Account{ + Type: auth.AccountTypeCodexOAuth, + Status: auth.AccountStatusActive, + Provider: "openai", + } + account.SetID(101) + store.AddAccount(account) + + h := &Handler{ + db: db, + store: store, + } + + router := gin.New() + router.POST("/v1/responses", h.Responses) + router.POST("/v1/chat/completions", h.ChatCompletions) + router.POST("/v1/messages", h.Messages) + router.POST("/v1beta/models/:action", h.GeminiModelsAction) + router.GET("/v1/responses", h.ResponsesWebSocket) + router.GET("/v1/realtime", h.RealtimeWebSocket) + + return h, router +} + +// 1. 验证 Responses HTTP 流式上游失控输出被前置拦截,不进入 provenance/collector,立即 cancel 上游且阻断重试 +func TestResponsesHTTPStreamGuardTrippedAndRecoveryBlocked(t *testing.T) { + gin.SetMode(gin.TestMode) + SetOutputGuardReadyForTest(true) + defer ResetOutputGuardStateForTest() + + // 模拟上游返回持续短周期重复的流式 arguments + upstreamCalls := int32(0) + upstreamCanceled := make(chan struct{}) + var cancelOnce sync.Once + + upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&upstreamCalls, 1) + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + + // 监听 client cancel + notify := r.Context().Done() + go func() { + <-notify + cancelOnce.Do(func() { + close(upstreamCanceled) + }) + }() + + // 发送合法开始事件 + _, _ = fmt.Fprintf(w, "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n") + flusher.Flush() + + // 持续发送短周期重复 delta (p=10, 8192B 以上) + pattern := "0123456789" + for i := 0; i < 900; i++ { + chunk := fmt.Sprintf("event: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"call_id\":\"call_1\",\"delta\":%q}\n\n", pattern) + _, err := fmt.Fprint(w, chunk) + if err != nil { + return // 客户端已主动断开 + } + flusher.Flush() + time.Sleep(1 * time.Millisecond) + } + })) + defer upstreamServer.Close() + + // 配置 Guard 阈值 (使用 Spec 默认值) + ApplyOutputGuardSettings(database.OutputGuardSettings{ + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + _, router := setupTestProxyHandlerWithUpstream(t, upstreamServer.URL) + + reqBody := `{"model":"gpt-5.4","stream":true,"input":[{"role":"user","content":"run"}]}` + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + // 断言 1:上游只被调用了 1 次(禁止任何透明重试、换号重试或 catch-all 循环) + if calls := atomic.LoadInt32(&upstreamCalls); calls != 1 { + t.Fatalf("expected exactly 1 upstream call, got %d", calls) + } + + // 断言 2:上游 context 被主动 cancel(跳过了 5s usage drain) + select { + case <-upstreamCanceled: + // 正常收到上游取消 + case <-time.After(2 * time.Second): + t.Fatalf("expected upstream context to be canceled immediately, timed out") + } + + respBody := w.Body.String() + // 断言 3:下游收到标准的 response.failed 终态,且错误码为 model_output_guard_triggered + if !strings.Contains(respBody, "model_output_guard_triggered") { + t.Fatalf("expected response.failed with model_output_guard_triggered, got: %s", respBody) + } + // 断言 4:下游严禁收到完成帧 response.completed + if strings.Contains(respBody, "response.completed") { + t.Fatalf("downstream received response.completed after guard tripped!") + } +} + +// 2. 验证 continuousRetryStreamSelected 与 shouldTransparentRetryStream 坚决阻断 guardTripped +func TestRecoveryPointsBlockGuardTripped(t *testing.T) { + guardOutcome := streamOutcome{ + logStatusCode: http.StatusOK, + failureKind: "model_output_guard", + failureMessage: "Upstream model output exceeded safety policy", + guardTripped: true, + guardReason: "repetition", + penalize: false, + } + + catchAllPolicy := database.ContinuousRetryPolicy{ + Enabled: true, + MaxRetries: 5, + Categories: []string{"all"}, + } + + // 1) continuousRetryStreamSelected 必须返回 false + if continuousRetryStreamSelected(guardOutcome, nil, "response.failed", catchAllPolicy) { + t.Fatalf("continuousRetryStreamSelected must return false for guardTripped") + } + + // 2) continuousRetryStreamFailureSelected 必须返回 false + if continuousRetryStreamFailureSelected(guardOutcome, nil, "response.failed", catchAllPolicy) { + t.Fatalf("continuousRetryStreamFailureSelected must return false for guardTripped") + } + + // 3) shouldTransparentRetryStreamWithBudgets 必须返回 false + general := 0 + rate := 0 + if shouldTransparentRetryStreamWithBudgets(guardOutcome, &general, &rate, 3, 3, false, nil, nil, catchAllPolicy) { + t.Fatalf("shouldTransparentRetryStreamWithBudgets must return false for guardTripped") + } +} + +// 3. 验证 retryExclusions 对 guardTripped 豁免健康度惩罚 (使用真实方法 forHardSelection / hasResettable) +func TestRetryExclusionsZeroPenaltyForGuardTripped(t *testing.T) { + exclusions := newRetryAccountExclusions() + accountID := int64(1001) + + guardOutcome := streamOutcome{ + logStatusCode: http.StatusBadGateway, + failureKind: "model_output_guard", + guardTripped: true, + guardReason: "tool_argument_bytes", + penalize: false, + } + + exclusions.MarkStreamFailure(accountID, guardOutcome, 3, 3) + + // 断言:账号没有被放入 hard 或 transient 排除列表 + if exclusions.hasResettable() { + t.Fatalf("exclusions.hasResettable() should be false for guard failure") + } + hard := exclusions.forHardSelection() + if hard != nil && hard[accountID] { + t.Fatalf("account %d was penalized in hard exclusions for guard failure", accountID) + } +} + +// 4. 验证 WebSocket 入口 Ready 检查未就绪返回 1013 Close 帧,已就绪则正常 turn 检测 +func TestResponsesWebSocketReadinessAndGuardTripped(t *testing.T) { + SetOutputGuardReadyForTest(false) + defer ResetOutputGuardStateForTest() + + // 冷启动未就绪时建立 WebSocket + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upgrader := websocket.Upgrader{} + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + // 消费 Task 5 门禁 + if !RequireOutputGuardReadyWS(conn) { + return + } + })) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err == nil { + defer conn.Close() + _, _, readErr := conn.ReadMessage() + if readErr == nil { + t.Fatalf("expected websocket close error, got nil") + } + } else if resp != nil && resp.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("expected 503 or close 1013 on unready WS, got: %d", resp.StatusCode) + } +} + +// 5. 验证锁内 SetWriteDeadline 与 TryLock 终态写入非阻塞 +func TestDownstreamWriteWithDeadlineAndTryLock(t *testing.T) { + var mu sync.Mutex + clientGone := false + + // 模拟已持锁的耗时写协程 + mu.Lock() + lockReleased := make(chan struct{}) + go func() { + time.Sleep(100 * time.Millisecond) + mu.Unlock() + close(lockReleased) + }() + + // 终态写入尝试 TryLock:若锁被占用应立即非阻塞放弃,绝不挂起 + err := SendDownstreamGuardTerminalHTTP(nil, &mu, &clientGone, "responses", "repetition") + if err == nil { + t.Fatalf("expected error or early return when downstream lock is busy") + } + + <-lockReleased +} + +// 6. 验证 Chat Completions 流式失控输出拦截:不追加 [DONE],返回合法 error 帧 +func TestChatCompletionsStreamGuardTrippedNoDone(t *testing.T) { + gin.SetMode(gin.TestMode) + SetOutputGuardReadyForTest(true) + defer ResetOutputGuardStateForTest() + + upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + + // 持续发送超大文本 delta (触发 channel_bytes) + bigChunk := strings.Repeat("A", 100000) + for i := 0; i < 25; i++ { // > 2.5 MiB + payload := fmt.Sprintf("data: {\"choices\":[{\"delta\":{\"content\":%q}}]}\n\n", bigChunk) + _, _ = fmt.Fprint(w, payload) + flusher.Flush() + } + })) + defer upstreamServer.Close() + + _, router := setupTestProxyHandlerWithUpstream(t, upstreamServer.URL) + + reqBody := `{"model":"gpt-4","stream":true,"messages":[{"role":"user","content":"generate"}]}` + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + respBody := w.Body.String() + // 下游严禁出现 [DONE] 标记 + if strings.Contains(respBody, "[DONE]") { + t.Fatalf("Chat Completions stream contained [DONE] after guard tripped: %s", respBody) + } + // 下游必须出现 model_output_guard_triggered 错误码 + if !strings.Contains(respBody, "model_output_guard_triggered") { + t.Fatalf("expected model_output_guard_triggered in chat stream, got: %s", respBody) + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestResponsesHTTPStreamGuardTrippedAndRecoveryBlocked|TestRecoveryPointsBlockGuardTripped|TestRetryExclusionsZeroPenaltyForGuardTripped|TestChatCompletionsStreamGuardTrippedNoDone"` +Expected: FAIL with compilation/runtime errors: `streamOutcome.guardTripped undefined` or `upstreamCalls > 1`. + +- [ ] **Step 3: Implement minimal code for Handler wiring and recovery blocking** + +#### 3.1 恢复与重试机制最前置阻断 (`proxy/continuous_retry.go`, `proxy/retry_exclusions.go`, `proxy/handler.go`) + +在 `proxy/continuous_retry.go` 的 `continuousRetryStreamSelected` 和 `continuousRetryStreamFailureSelected` 最开头插入 guardTripped 快速短路: + +```go +// proxy/continuous_retry.go:172 +func continuousRetryStreamSelected(outcome streamOutcome, payload []byte, eventType string, policies ...database.ContinuousRetryPolicy) bool { + // Guard 命中是请求级硬终态,绝不允许任何形式的 continuous retry 重新拉起! + if outcome.guardTripped { + return false + } + policy := continuousRetryPolicyForCall(policies) + if !policy.Enabled || outcome.terminalLocal { + return false + } +... +``` + +```go +// proxy/continuous_retry.go:276 +func continuousRetryStreamFailureSelected(outcome streamOutcome, payload []byte, eventType string, policies ...database.ContinuousRetryPolicy) bool { + if outcome.guardTripped || outcome.terminalLocal || strings.EqualFold(strings.TrimSpace(outcome.failureKind), "continuous_retry_timeout") { + return false + } +... +``` + +在 `proxy/retry_exclusions.go` 的 `MarkStreamFailureForEvent` 中增加对 `guardTripped` 的免罚判断: + +```go +// proxy/retry_exclusions.go:209 +func (r *retryAccountExclusions) MarkStreamFailureForEvent(accountID int64, outcome streamOutcome, eventType string, generalLimit, rateLimit int, policies ...database.ContinuousRetryPolicy) { + // 模型输出失控属于模型生成异常,不是账号故障的充分证据,严禁对账号施加健康度惩罚! + if outcome.guardTripped || strings.EqualFold(strings.TrimSpace(outcome.failureKind), "model_output_guard") { + return + } + failureKind := strings.ToLower(strings.TrimSpace(outcome.failureKind)) +... +``` + +在 `proxy/handler.go` 的 `shouldTransparentRetryStreamEventWithBudgets` 最开头增加 guardTripped 拦截: + +```go +// proxy/handler.go:2909 +func shouldTransparentRetryStreamEventWithBudgets(outcome streamOutcome, eventType string, generalRetries, rateLimitRetries *int, maxGeneralRetries, maxRateLimitRetries int, wroteAnyBody bool, ctxErr, writeErr error, policies ...database.ContinuousRetryPolicy) bool { + if outcome.guardTripped { + return false + } + if outcome.terminalLocal || wroteAnyBody || ctxErr != nil || writeErr != nil || outcome.logStatusCode == logStatusClientClosed { + return false + } +... +``` + +#### 3.2 `/v1/responses` HTTP 流式与非流式接线 (`proxy/handler.go`) + +在 `Responses` 处理函数中: +1. 入口检查门禁:`if !RequireOutputGuardReadyHTTP(c) { return }` +2. 初始化逻辑请求级 Guard 快照(跨 attempt 共享,单次请求 `reqGuard` 仅创建 1 次,不随 attempt 重新创建): + ```go + appliedGuardCfg := CurrentOutputGuardConfig() + reqGuard := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: appliedGuardCfg.Enabled, + MaxToolArgumentBytes: appliedGuardCfg.MaxToolArgumentBytes, + MaxToolArgumentDeltas: appliedGuardCfg.MaxToolArgumentDeltas, + MaxChannelBytes: appliedGuardCfg.MaxChannelBytes, + MaxRequestBytes: appliedGuardCfg.MaxRequestBytes, + }) + responsesProjector := NewResponsesProjector() + ``` +3. 在流式透传 `forwardWithEvent` 最开头(**必须早于 `bytes.Clone` provenance 记录与 `outputCollector.Add`**)插入 Guard 检查: + ```go + // proxy/handler.go:5538 + forwardWithEvent := func(sseEvent string, data []byte) bool { + if multiAgentV2State.isOptimizedFor(account.ID()) { + data = restoreCodexMultiAgentV2Response(data, multiAgentV2State) + } + streamDiag.markUpstreamFrame() + + // Output Guard 前置检查:必须在 bytes.Clone 记录 provenance、collector 累积及下游写入之前执行! + if err := ObserveEvent(reqGuard, responsesProjector, sseEvent, data); err != nil { + // Guard 命中!立即执行强制终止契约: + // 1. 取消上游 context(直接调用 cancel 跳过 5s usage drain) + if lastUpstreamCancel != nil { + lastUpstreamCancel() + } + // 2. 丢弃未提交的在途缓冲(provenance, collector, replay, pending buffers) + compactionProvenancePayloads = nil + outputCollector.clearOverflow() + pendingFirstTokenEvents.Reset() + if streamWriter != nil { + streamWriter.DiscardBuffered() + } + // 3. 标记 outcome 为 guardTripped,保留 typed reason + outcome = streamOutcome{ + logStatusCode: http.StatusBadGateway, + failureKind: "model_output_guard", + failureMessage: "Upstream model output exceeded safety policy; generation was stopped.", + guardTripped: true, + guardReason: reqGuard.TrippedReason(), + penalize: false, + } + // 4. 下游尽力交付写入错误帧(TryLock + SetWriteDeadline 5s,防挂死) + _ = SendDownstreamGuardTerminalHTTP(c, &downstreamMu, &clientGone, "responses", reqGuard.TrippedReason()) + return false + } + + if continuousRetryBuffersAttempts(continuousRetryPolicy) { + compactionProvenancePayloads = append(compactionProvenancePayloads, bytes.Clone(data)) + } else { + h.recordCompactionProvenanceFromPayload(context.Background(), account, data) + } + downstreamMu.Lock() + defer downstreamMu.Unlock() + ... + ``` +4. 在锁内所有常规写操作(包括数据帧写和注释保活写)之前,统一调用 `rc.SetWriteDeadline(time.Now().Add(5 * time.Second))`: + ```go + if rc := http.NewResponseController(c.Writer); rc != nil { + if err := rc.SetWriteDeadline(time.Now().Add(5 * time.Second)); err != nil { + if errors.Is(err, http.ErrNotSupported) { + // 不支持 Deadline 控制的底层,严禁无界阻塞同步写,安全退出 + clientGone = true + return false + } + } + } + ``` +5. 在恢复选择点(WebSocket 1009 降级 HTTP、首包前透明重试、自动续想、自动压缩等)前置阻断: + ```go + // proxy/handler.go:5915 + if outcome.guardTripped { + _ = streamAttempt.Close() + resp.Body.Close() + h.store.Release(account) + // 严格不重试、不降级、不写成功缓存 + return + } + ``` +6. 非流式响应处理:在读取完整响应体 `ReadBoundedResponseBody` 后,通过 `responsesProjector` 进行一次性完整快照观察;若触发 guard,清空本地缓冲并返回 502 错误,不返回半截 JSON。 + +#### 3.3 `/v1/responses` WebSocket 与 `/v1/realtime` 接线 (`proxy/responses_ws.go`, `proxy/realtime_ws.go`) + +1. 在 `ResponsesWebSocket` 握手阶段与 `RealtimeWebSocket` 建立阶段消费门禁: + ```go + if !RequireOutputGuardReadyWS(conn) { + return + } + ``` +2. 对每个 WebSocket 逻辑 turn 创建独立的 `RequestGuard` 实例(与逻辑 turn 生命周期绑定): + ```go + // proxy/responses_ws.go + turnGuard := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: appliedGuardCfg.Enabled, + MaxToolArgumentBytes: appliedGuardCfg.MaxToolArgumentBytes, + MaxToolArgumentDeltas: appliedGuardCfg.MaxToolArgumentDeltas, + MaxChannelBytes: appliedGuardCfg.MaxChannelBytes, + MaxRequestBytes: appliedGuardCfg.MaxRequestBytes, + }) + turnProjector := NewResponsesProjector() + ``` +3. 在 `readSSEStreamWithContinuousRetryKeepalive` 回调中最前置插入 `ObserveEvent`: + - 命中时立即废弃上游 WS lease(不归还健康池),关闭上游 body; + - 丢弃 collector、replay、pendingFirstTokenMessages; + - 标记 `turnOutcome.guardTripped = true`; + - 经由 `SendDownstreamGuardTerminalWS` 仅向当前 stream 发送失败帧,不影响无故障的复用流; + - 阻断 `wsHTTPFallback` 记录与重试。 +4. 在 `realtime_ws.go` 中,`normalizeRealtimeTextClientEvent` 显式拒绝音频 Modality;转发到 Responses WS 的文本 turn 自动继承上述 `turnGuard` 约束。 + +#### 3.4 `/v1/chat/completions` 接线 (`proxy/handler.go`) + +1. 入口检查:`if !RequireOutputGuardReadyHTTP(c) { return }` +2. 初始化 `chatProjector := NewChatProjector()` 与单请求级 `reqGuard`。 +3. 在流式转发回调中,于 `TranslateStreamChunkResult` 之前执行 `ObserveEvent`: + - 命中时主动 cancel 上游 context,清空 `pendingFirstTokenChunks`; + - 经由 `SendDownstreamGuardTerminalHTTP(..., "chat", ...)` 写入 OpenAI 格式错误包; + - 坚决**不追加 `data: [DONE]\n\n`**; + - 阻断后续 attempt,豁免账号惩罚。 + +#### 3.5 `/v1/messages` (Anthropic) 接线 (`proxy/handler_anthropic.go`) + +1. 入口检查:`if !RequireOutputGuardReadyHTTP(c) { return }` +2. 初始化 `anthropicProjector := NewAnthropicProjector()` 与请求级 `reqGuard`。 +3. 在流式循环(line 1575 之前)执行 `ObserveEvent`: + - 必须早于 `translator.translateEvent(data)`; + - 命中时 cancel 上游 context,清空 `pendingFirstTokenEvents`; + - 发送 Anthropic 标准 `event: error` 结构(带 `model_output_guard_triggered` code); + - 坚决**不伪造 `message_stop` 或 tool input 完成**; + - 阻断重试并安全退出。 + +#### 3.6 Gemini Native 接线 (`proxy/handler_gemini.go`) + +1. 在 `GeminiModelsAction` 与 `handleGeminiGenerateContent` 最前置检查门禁: + `if !RequireOutputGuardReadyHTTP(c) { return }` +2. 初始化 `geminiProjector := NewGeminiProjector()` 与请求级 `reqGuard`。 +3. 在流式读取块回调中最前置投影检查: + - 命中时主动 cancel 上游; + - 采用 Gemini SDK 兼容的错误输出结构(复用已有的 `sendUpstreamError` 或原生 error payload,不伪造不支持的 `event: error` 导致客户端崩溃); + - 阻断重试循环。 + +#### 3.7 Grok 与 Antigravity 原生渠道透传校验 + +在 Grok native passthrough 以及 Antigravity 专属通道中: +- 即使协议为 native 转发,数据流在到达下游 ResponseWriter 或 attempt writer 之前,一律经由 `ObserveEvent` 进行语义投影与预算/周期检查; +- 命中时坚决终止本次 dispatch,不让原生流绕过防护。 + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race ./proxy -run "TestResponsesHTTPStreamGuardTrippedAndRecoveryBlocked|TestRecoveryPointsBlockGuardTripped|TestRetryExclusionsZeroPenaltyForGuardTripped|TestResponsesWebSocketReadinessAndGuardTripped|TestDownstreamWriteWithDeadlineAndTryLock|TestChatCompletionsStreamGuardTrippedNoDone"` +Expected: PASS ok github.com/codex2api/proxy + +- [ ] **Step 5: Commit** + +```bash +git add proxy/continuous_retry.go proxy/retry_exclusions.go proxy/handler.go proxy/responses_ws.go proxy/realtime_ws.go proxy/handler_anthropic.go proxy/handler_gemini.go proxy/outputguard_handler_test.go +git commit -m "feat(proxy): wire model output guard across all protocols with complete recovery blocking" +``` diff --git a/docs/superpowers/plans/part_t11_acceptance.txt b/docs/superpowers/plans/part_t11_acceptance.txt new file mode 100644 index 000000000..2d127e587 --- /dev/null +++ b/docs/superpowers/plans/part_t11_acceptance.txt @@ -0,0 +1,2000 @@ +### Task 11: 综合验收测试套件、性能指标工具与 Spec §9 完整覆盖矩阵 + +**Files:** +- Create: `test/outputguard/helpers.go`(脱敏合成 Fixture、真进程 CPU 计时器、高周期 PRNG、P95/中位数统计学分析函数及机器可判验证器) +- Create: `test/outputguard/helpers_test.go`(Helper 基础设施自验证测试:验证真实 RUSAGE 进程 CPU 测量、PRNG 无短周期、P95 算法及红灯故意失败捕获机制) +- Create: `security/outputguard/acceptance_detection_test.go`(检测正确性验收:场景 1~11,含 chunk-invariance、9215B 片段、8a/8b/8c 成本上界、尾检放行) +- Create: `proxy/acceptance_lifecycle_test.go`(全协议终止与生命周期验收:场景 12~20,含全协议 Handler 矩阵、无 5s drain、并发无 race、重试阻断 calls==1、多协议终态格式、TryLock/Deadline 降级、无账号惩罚与租约隔离) +- Create: `proxy/acceptance_config_test.go`(持久化与门禁验收:场景 21~23,含 SQLite/PostgreSQL 并发更新与恢复默认、冷启动 503 与 5s 轮询、Framing GuardOff 依然有界) +- Create: `test/outputguard/acceptance_benchmark_test.go`(性能与基准验收:场景 24~28,含 TTFT p95 <=10ms/CPU <=20%、1/2/4MiB ratio <=2.5、内存/FD/Goroutine 10分钟长跑门禁、细 delta 4096 deltas 专项压测、脱敏日志检查) +- Create: `scripts/run_acceptance_suite.sh`(全套验收与性能门禁自动化执行脚本) + +**Interfaces:** +- Consumes: + - `security/outputguard`(已在主 Plan Task 1~3 整合交付): + - `NewRepetitionDetector() *RepetitionDetector` + - `(d *RepetitionDetector) Push(data []byte) (bool, int)` + - `(d *RepetitionDetector) CheckTail() (bool, int)` + - `(d *RepetitionDetector) Reset()` + - `NewChannelGuard(id string, kind ChannelKind, maxBytes int64, maxDeltas int) *ChannelGuard` + - `(c *ChannelGuard) CheckAppend(delta []byte) (int64, int64, error)` + - `(c *ChannelGuard) CommitAppend(delta []byte, growth int64, cost int64) error` + - `(c *ChannelGuard) CheckSnapshot(snapshot []byte, terminalStage string) (int64, int64, bool, error)` + - `(c *ChannelGuard) CommitSnapshot(snapshot []byte, growth int64, cost int64, terminalStage string) error` + - `(c *ChannelGuard) RemainingScanBudget() int64` + - `(c *ChannelGuard) Highwater() int64` + - `(c *ChannelGuard) DeltaCount() int` + - `(c *ChannelGuard) ReleaseWindow()` + - `NewRequestGuard(cfg Config) *RequestGuard` + - `(g *RequestGuard) RecordEvent() error` + - `(g *RequestGuard) ObserveAppend(channelID string, aliasID string, kind ChannelKind, delta []byte) error` + - `(g *RequestGuard) ObserveSnapshot(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error` + - `(g *RequestGuard) ObserveAppendNoEvent(channelID string, aliasID string, kind ChannelKind, delta []byte) error` + - `(g *RequestGuard) ObserveSnapshotNoEvent(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error` + - `(g *RequestGuard) EndChannel(channelID string) error` + - `(g *RequestGuard) IsTripped() bool` + - `(g *RequestGuard) TrippedReason() string` + - `database`(已在主 Plan Task 4 整合交付): + - `DB.GetOutputGuardSettings(context.Context) (OutputGuardSettings, error)` + - `DB.UpdateOutputGuardSettings(context.Context, OutputGuardSettingsUpdate) (OutputGuardSettings, error)` + - `DefaultOutputGuardSettings() OutputGuardSettings` + - `ValidateOutputGuardSettings(OutputGuardSettings) error` + - `proxy`(已在主 Plan Task 5, 7, 8 及独立 Task 9, 10 交付): + - `ApplyOutputGuardSettings(database.OutputGuardSettings) bool` + - `CurrentOutputGuardConfig() OutputGuardAppliedConfig` + - `IsOutputGuardReady() bool` + - `SetOutputGuardReadyForTest(bool)` + - `ResetOutputGuardStateForTest()` + - `LoadOutputGuardSettings(context.Context, OutputGuardSettingsReader) error` + - `StartOutputGuardConfigPoller(context.Context, *database.DB) bool` + - `RequireOutputGuardReadyHTTP(*gin.Context) bool` + - `RequireOutputGuardReadyWS(*websocket.Conn) bool` + - `RequireOutputGuardReadyStream(*websocket.Conn, string) bool` + - `NewResponsesProjector() *ResponsesProjector` + - `NewChatProjector() *ChatProjector` + - `NewAnthropicProjector() *AnthropicProjector` + - `NewGeminiProjector() *GeminiProjector` + - `ObserveEvent(guard *outputguard.RequestGuard, projector ProtocolProjector, eventType string, data []byte) error` + - `ReadSSEStreamWithEvent(io.Reader, func(event string, data []byte) bool) error` + - `ReadBoundedResponseBody(io.Reader, int64) ([]byte, error)` + - `NewGuardTerminationCoordinator(guard *outputguard.RequestGuard, cancel context.CancelFunc, body io.Closer) *GuardTerminationCoordinator` + - `(c *GuardTerminationCoordinator) HandleTripped(reason string, discardBuffers ...func()) streamOutcome` + - `ApplyDownstreamWriteDeadline(w http.ResponseWriter, timeout time.Duration) error` + - `SendDownstreamGuardTerminalHTTP(c *gin.Context, mu *sync.Mutex, clientGone *bool, protocol string, reason string) error` + - `SendDownstreamGuardTerminalWS(conn *websocket.Conn, streamID string, reason string) error` + - `FormatGuardTerminalEnvelope(protocol string, streamID string) ([]byte, int, error)` + - `OverlayGuardOutcome(outcome streamOutcome, guard *outputguard.RequestGuard) streamOutcome` + - `(w *streamFlushWriter) DiscardBuffered()` +- Produces: + - 机器可判定的 Spec §9 场景 1~28 全量自动化验收套件与覆盖矩阵; + - 严格消除伪基准与伪函数的统计学验证库; + - 具备 15 分钟长跑泄漏门禁与环境门控标志的压测脚本。 + +--- + +### Task 11.1: 验收测试基础设施与统计学分析库 (Helpers, Process CPU Timer, PRNG, Validators) + +**Files:** +- Create: `test/outputguard/helpers.go` +- Create: `test/outputguard/helpers_test.go` + +**Interfaces:** +- Produces: + ```go + package testoutputguard + + import ( + "math/rand" + "syscall" + "time" + ) + + type ProcessCPUTimer struct { + startUsage syscall.Rusage + startTime time.Time + } + + func StartProcessCPUTimer() *ProcessCPUTimer + func (t *ProcessCPUTimer) ElapsedProcessCPU() time.Duration + func (t *ProcessCPUTimer) ElapsedWallTime() time.Duration + + type NonRepeatingPRNG struct { + r *rand.Rand + } + func NewNonRepeatingPRNG(seed int64) *NonRepeatingPRNG + func (p *NonRepeatingPRNG) Fill(buf []byte) + func (p *NonRepeatingPRNG) GenerateASCII(length int) []byte + + func CalculatePercentile(samples []float64, percentile float64) float64 + func CalculateMedian(samples []float64) float64 + + type PerformanceBenchmarkResult struct { + BaselineP95TTFTMs float64 + GuardP95TTFTMs float64 + TTFTDeltaMs float64 + BaselineCPUTimeMs float64 + GuardCPUTimeMs float64 + CPUIncreaseRatio float64 + } + + func ValidateP95AndCPUIncrease(res PerformanceBenchmarkResult) error + func ValidateLinearScalingRatio(cpu1, cpu2, cpu4 float64) error + ``` + +- [ ] **Step 1: Write failing test for benchmark helpers and statistical validators** + +```go +// test/outputguard/helpers_test.go +package testoutputguard + +import ( + "strings" + "testing" + "time" +) + +func TestProcessCPUTimerMeasuresActualWork(t *testing.T) { + timer := StartProcessCPUTimer() + // 执行一段纯 CPU 计算密集型循环,确保产生可测量的 Process CPU 纳秒 + sum := int64(0) + for i := int64(0); i < 10000000; i++ { + sum += (i * 3) ^ (i >> 1) + } + _ = sum + + cpuElapsed := timer.ElapsedProcessCPU() + wallElapsed := timer.ElapsedWallTime() + + if cpuElapsed <= 0 { + t.Fatalf("expected positive process CPU elapsed, got %v", cpuElapsed) + } + if wallElapsed <= 0 { + t.Fatalf("expected positive wall elapsed, got %v", wallElapsed) + } + // 在单核密集计算下,CPU 耗时应小于等于 Wall 耗时(外加误差窗) + if cpuElapsed > wallElapsed+50*time.Millisecond { + t.Fatalf("process CPU elapsed (%v) exceeded wall elapsed (%v)", cpuElapsed, wallElapsed) + } +} + +func TestNonRepeatingPRNGHasNoShortCycle(t *testing.T) { + prng := NewNonRepeatingPRNG(987654321) + data := prng.GenerateASCII(32768) // 32 KiB + + // 验证绝不使用短周期 mod 251 算法,连续 16 字节在 32 KiB 内无任何简单重复片段 + seen16 := make(map[string]bool) + duplicates := 0 + for i := 0; i <= len(data)-16; i += 16 { + block := string(data[i : i+16]) + if seen16[block] { + duplicates++ + } + seen16[block] = true + } + if duplicates > 0 { + t.Fatalf("PRNG generated %d duplicate 16-byte blocks, expected pseudo-random text without short cycle", duplicates) + } +} + +func TestCalculatePercentileAndMedian(t *testing.T) { + samples := []float64{10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0} + p95 := CalculatePercentile(samples, 0.95) + if p95 < 94.0 || p95 > 96.0 { + t.Fatalf("want p95 around 95.0, got %f", p95) + } + + median := CalculateMedian(samples) + if median != 55.0 { + t.Fatalf("want median 55.0, got %f", median) + } +} + +// 验证 TDD Red 门禁:对故意不达标的输入,验证器必须返回明确的门禁失败,绝不放行 +func TestPerformanceValidatorRejectsUnqualifiedResults(t *testing.T) { + // 1. TTFT 增量超标(目标 <= 10ms,实际 15ms) + unqualifiedTTFT := PerformanceBenchmarkResult{ + BaselineP95TTFTMs: 50.0, + GuardP95TTFTMs: 65.0, + TTFTDeltaMs: 15.0, + BaselineCPUTimeMs: 10.0, + GuardCPUTimeMs: 11.0, + CPUIncreaseRatio: 0.10, + } + if err := ValidateP95AndCPUIncrease(unqualifiedTTFT); err == nil || !strings.Contains(err.Error(), "TTFT delta") { + t.Fatalf("expected error on TTFT delta > 10ms, got %v", err) + } + + // 2. CPU 增量超标(目标 <= 20%,实际 25%) + unqualifiedCPU := PerformanceBenchmarkResult{ + BaselineP95TTFTMs: 50.0, + GuardP95TTFTMs: 55.0, + TTFTDeltaMs: 5.0, + BaselineCPUTimeMs: 10.0, + GuardCPUTimeMs: 12.5, + CPUIncreaseRatio: 0.25, + } + if err := ValidateP95AndCPUIncrease(unqualifiedCPU); err == nil || !strings.Contains(err.Error(), "CPU increase ratio") { + t.Fatalf("expected error on CPU increase > 20%%, got %v", err) + } + + // 3. 非线性比值超标(目标 <= 2.5,实际 3.0) + if err := ValidateLinearScalingRatio(10.0, 22.0, 66.0); err == nil || !strings.Contains(err.Error(), "scaling ratio") { + t.Fatalf("expected error on scaling ratio > 2.5, got %v", err) + } + + // 4. 合格结果顺利通过 + qualified := PerformanceBenchmarkResult{ + BaselineP95TTFTMs: 50.0, + GuardP95TTFTMs: 56.0, + TTFTDeltaMs: 6.0, + BaselineCPUTimeMs: 10.0, + GuardCPUTimeMs: 11.2, + CPUIncreaseRatio: 0.12, + } + if err := ValidateP95AndCPUIncrease(qualified); err != nil { + t.Fatalf("expected qualified result to pass, got: %v", err) + } + if err := ValidateLinearScalingRatio(10.0, 20.0, 42.0); err != nil { + t.Fatalf("expected qualified scaling to pass, got: %v", err) + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./test/outputguard -run "TestProcessCPUTimer.*"` +Expected: FAIL with compilation error: package testoutputguard is not in GOROOT or GOPATH / undefined: StartProcessCPUTimer + +- [ ] **Step 3: Write minimal implementation for test helpers** + +```go +// test/outputguard/helpers.go +package testoutputguard + +import ( + "fmt" + "math" + "math/rand" + "sort" + "syscall" + "time" +) + +type ProcessCPUTimer struct { + startUsage syscall.Rusage + startTime time.Time +} + +func StartProcessCPUTimer() *ProcessCPUTimer { + var usage syscall.Rusage + _ = syscall.Getrusage(syscall.RUSAGE_SELF, &usage) + return &ProcessCPUTimer{ + startUsage: usage, + startTime: time.Now(), + } +} + +func (t *ProcessCPUTimer) ElapsedProcessCPU() time.Duration { + var current syscall.Rusage + _ = syscall.Getrusage(syscall.RUSAGE_SELF, ¤t) + + userSec := current.Utime.Sec - t.startUsage.Utime.Sec + userUsec := current.Utime.Usec - t.startUsage.Utime.Usec + sysSec := current.Stime.Sec - t.startUsage.Stime.Sec + sysUsec := current.Stime.Usec - t.startUsage.Stime.Usec + + totalMicros := (userSec+sysSec)*1000000 + (userUsec + sysUsec) + if totalMicros < 0 { + totalMicros = 0 + } + return time.Duration(totalMicros) * time.Microsecond +} + +func (t *ProcessCPUTimer) ElapsedWallTime() time.Duration { + return time.Since(t.startTime) +} + +type NonRepeatingPRNG struct { + r *rand.Rand +} + +func NewNonRepeatingPRNG(seed int64) *NonRepeatingPRNG { + return &NonRepeatingPRNG{r: rand.New(rand.NewSource(seed))} +} + +func (p *NonRepeatingPRNG) Fill(buf []byte) { + _, _ = p.r.Read(buf) +} + +// GenerateASCII 生成指定长度的非周期可打印 ASCII 文本,基于高混淆字典,避免短周期 +func (p *NonRepeatingPRNG) GenerateASCII(length int) []byte { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_/.,~+!@#$%^&*() " + buf := make([]byte, length) + for i := 0; i < length; i++ { + buf[i] = charset[p.r.Intn(len(charset))] + } + return buf +} + +func CalculatePercentile(samples []float64, percentile float64) float64 { + if len(samples) == 0 { + return 0 + } + sorted := make([]float64, len(samples)) + copy(sorted, samples) + sort.Float64s(sorted) + + index := percentile * float64(len(sorted)-1) + lower := int(math.Floor(index)) + upper := int(math.Ceil(index)) + weight := index - float64(lower) + + if lower == upper { + return sorted[lower] + } + return sorted[lower]*(1.0-weight) + sorted[upper]*weight +} + +func CalculateMedian(samples []float64) float64 { + return CalculatePercentile(samples, 0.50) +} + +type PerformanceBenchmarkResult struct { + BaselineP95TTFTMs float64 + GuardP95TTFTMs float64 + TTFTDeltaMs float64 + BaselineCPUTimeMs float64 + GuardCPUTimeMs float64 + CPUIncreaseRatio float64 +} + +func ValidateP95AndCPUIncrease(res PerformanceBenchmarkResult) error { + const maxTTFTDeltaMs = 10.0 + const maxCPUIncreaseRatio = 0.20 + + if res.TTFTDeltaMs > maxTTFTDeltaMs { + return fmt.Errorf("TTFT delta %.2f ms exceeds threshold %.2f ms (baseline=%.2f, guard=%.2f)", + res.TTFTDeltaMs, maxTTFTDeltaMs, res.BaselineP95TTFTMs, res.GuardP95TTFTMs) + } + if res.CPUIncreaseRatio > maxCPUIncreaseRatio { + return fmt.Errorf("CPU increase ratio %.2f%% exceeds threshold %.2f%% (baseline=%.2fms, guard=%.2fms)", + res.CPUIncreaseRatio*100, maxCPUIncreaseRatio*100, res.BaselineCPUTimeMs, res.GuardCPUTimeMs) + } + return nil +} + +func ValidateLinearScalingRatio(cpu1, cpu2, cpu4 float64) error { + const maxRatio = 2.5 + if cpu1 <= 0 || cpu2 <= 0 || cpu4 <= 0 { + return fmt.Errorf("invalid non-positive CPU timings: cpu1=%.2f, cpu2=%.2f, cpu4=%.2f", cpu1, cpu2, cpu4) + } + ratio12 := cpu2 / cpu1 + ratio24 := cpu4 / cpu2 + + if ratio12 > maxRatio { + return fmt.Errorf("scaling ratio 2MiB/1MiB %.2f exceeds threshold %.2f (cpu1=%.2f, cpu2=%.2f)", + ratio12, maxRatio, cpu1, cpu2) + } + if ratio24 > maxRatio { + return fmt.Errorf("scaling ratio 4MiB/2MiB %.2f exceeds threshold %.2f (cpu2=%.2f, cpu4=%.2f)", + ratio24, maxRatio, cpu2, cpu4) + } + return nil +} +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./test/outputguard -run "Test.*"` +Expected: PASS ok github.com/codex2api/test/outputguard + +- [ ] **Step 5: Commit** + +```bash +git add test/outputguard/helpers.go test/outputguard/helpers_test.go +git commit -m "test(outputguard): implement benchmark helpers, process CPU timer and validators" +``` + +--- + +### Task 11.2: 检测层规格验收套件(Spec §9.1 场景 1~11) + +**Files:** +- Create: `security/outputguard/acceptance_detection_test.go` + +**Interfaces:** +- Consumes: + - `security/outputguard.NewRepetitionDetector()` + - `security/outputguard.NewChannelGuard()` + - `security/outputguard.NewRequestGuard()` + - `security/outputguard.KindText`, `KindToolArguments`, `KindThinking` +- Produces: + - 场景 1~11 完整端到端黑盒覆盖与可执行断言。 + +- [ ] **Step 1: Write failing test for Spec §9.1 detection scenarios 1-11** + +```go +// security/outputguard/acceptance_detection_test.go +package outputguard + +import ( + "bytes" + "fmt" + "math/rand" + "strings" + "testing" +) + +// 场景 1: 有效 outer SSE JSON、未闭合 inner arguments、持续事故样式短周期重复:在重复阈值附近触发,不等到 128 KiB +func TestAcceptanceScenario1_AccidentPatternEarlyTrigger(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, // 128 KiB + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + // 事故短周期模式 p = 30 ("_auditfree.md_pluginless.tmp.m") + pattern := []byte("_auditfree.md_pluginless.tmp.m") + chunk := bytes.Repeat(pattern, 10) // 300 字节 + totalPushed := 0 + trippedAt := 0 + + // 逐步输入,未闭合 inner arguments + for i := 0; i < 50; i++ { + err := rg.ObserveAppend("call_write", "alias_write", KindToolArguments, chunk) + totalPushed += len(chunk) + if err != nil { + trippedAt = totalPushed + if err.Error() != "repetition" { + t.Fatalf("expected repetition error, got %v", err) + } + break + } + } + + if trippedAt == 0 { + t.Fatalf("expected guard to trip, but reached totalPushed=%d", totalPushed) + } + // 断言:在 8192 ~ 10240 字节(第一个对齐检查点)附近立即触发,绝不等 128 KiB (131072) + if trippedAt > 10240 { + t.Fatalf("expected early trigger near 8192-10240 bytes, but triggered at %d bytes", trippedAt) + } + if !rg.IsTripped() || rg.TrippedReason() != "repetition" { + t.Fatalf("expected tripped with repetition, got %s", rg.TrippedReason()) + } +} + +// 场景 2: 同语义流按 1-byte、小/大 delta、随机边界分割均可检测,切分不能让重复消失 +func TestAcceptanceScenario2_ChunkInvariance(t *testing.T) { + pattern := []byte("defensive_pipeline_token_replay_") // 32 bytes + var fullStream []byte + for len(fullStream) < 12000 { + fullStream = append(fullStream, pattern...) + } + + chunkSizes := []int{1, 7, 64, 512, 1024, 2048} + for _, size := range chunkSizes { + t.Run(fmt.Sprintf("FixedChunkSize_%d", size), func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 65536, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + hit := false + for offset := 0; offset < len(fullStream); offset += size { + end := offset + size + if end > len(fullStream) { + end = len(fullStream) + } + err := rg.ObserveAppend("c_inv", "", KindText, fullStream[offset:end]) + if err != nil { + if err.Error() == "repetition" { + hit = true + break + } + t.Fatalf("unexpected error: %v", err) + } + } + if !hit { + t.Fatalf("chunk size %d failed to detect repetition across boundaries", size) + } + }) + } + + // 随机边界切分测试 + t.Run("RandomChunkBoundaries", func(t *testing.T) { + r := rand.New(rand.NewSource(42)) + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 65536, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + offset := 0 + hit := false + for offset < len(fullStream) { + sz := r.Intn(256) + 1 + end := offset + sz + if end > len(fullStream) { + end = len(fullStream) + } + err := rg.ObserveAppend("c_rand", "", KindText, fullStream[offset:end]) + if err != nil { + if err.Error() == "repetition" { + hit = true + break + } + t.Fatalf("unexpected error: %v", err) + } + offset = end + } + if !hit { + t.Fatalf("random chunk boundaries failed to detect repetition") + } + }) +} + +// 场景 3: 单大 delta 中间有任意起点、长度至少 9215 bytes 的精确短周期片段,尾部正常仍触发,整事件下游不可见 +// 另测恰好 8192 bytes 的检查点对齐与非对齐边界 +func TestAcceptanceScenario3_Embedded9215BytesAnd8192Boundary(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + // 构造:前置 500 字节正常非重复,中间连续 9215 字节短周期 (p=25),尾部 1000 字节正常非重复 + prng := rand.New(rand.NewSource(1001)) + prefix := make([]byte, 500) + prng.Read(prefix) + + periodic := bytes.Repeat([]byte("1234567890abcdefghijklmno"), 9215/25) // 9215 bytes + suffix := make([]byte, 1000) + prng.Read(suffix) + + var singleLargeDelta []byte + singleLargeDelta = append(singleLargeDelta, prefix...) + singleLargeDelta = append(singleLargeDelta, periodic...) + singleLargeDelta = append(singleLargeDelta, suffix...) + + // 单大 delta 注入 + err := rg.ObserveAppend("c_embed", "", KindText, singleLargeDelta) + if err == nil || err.Error() != "repetition" { + t.Fatalf("expected repetition triggered on embedded 9215 bytes pattern, got %v", err) + } + + // 另测恰好 8192 bytes 检查点对齐(对齐步长 1024 整数倍):必命中 + t.Run("Aligned8192Checkpoint", func(t *testing.T) { + detector := NewRepetitionDetector() + pattern := bytes.Repeat([]byte("0123456789"), 8192/10) // 8190 + 2 + pattern = append(pattern, []byte("01")...) // exactly 8192 + hit, p := detector.Push(pattern) + if !hit || p != 10 { + t.Fatalf("expected aligned 8192 to hit with period 10, got hit=%v, p=%d", hit, p) + } + }) +} + +// 场景 4: 多 tool/text/thinking 交错不互相污染,ID/index 补发不重置计数 +func TestAcceptanceScenario4_InterleavedChannelsAndNoReset(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + textData := []byte("Normal text message token. ") + thinkingData := []byte("Deep reflection thoughts. ") + toolData := []byte(`{"arg":"value_`) + + for i := 0; i < 50; i++ { + if err := rg.ObserveAppend("c_text", "", KindText, textData); err != nil { + t.Fatalf("text append failed: %v", err) + } + if err := rg.ObserveAppend("c_think", "", KindThinking, thinkingData); err != nil { + t.Fatalf("thinking append failed: %v", err) + } + if err := rg.ObserveAppend("c_tool", "alias_tool_0", KindToolArguments, toolData); err != nil { + t.Fatalf("tool append failed: %v", err) + } + } + + // 补发已存在的 ID/alias,断言不重置计数(高水位和 deltaCount 保持累加) + cg := rg.channels["c_tool"] + if cg == nil { + t.Fatalf("channel c_tool not found") + } + initialDeltas := cg.DeltaCount() + if initialDeltas != 50 { + t.Fatalf("want deltaCount=50, got %d", initialDeltas) + } + + // 再次通过 alias 补发 + if err := rg.ObserveAppend("c_tool", "alias_tool_0", KindToolArguments, []byte(`"}`)); err != nil { + t.Fatalf("subsequent append failed: %v", err) + } + if cg.DeltaCount() != 51 { + t.Fatalf("delta count was reset! want 51, got %d", cg.DeltaCount()) + } +} + +// 场景 5: 非重复参数超 bytes、细碎参数超 delta、跨 channel 绕单项上限、空 delta 洪泛、新 channel 洪泛 +func TestAcceptanceScenario5_BudgetInterception(t *testing.T) { + // 5a: 非重复参数超 bytes (128 KiB) + t.Run("ToolBytesBudget", func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 65536, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + prng := rand.New(rand.NewSource(2001)) + data := make([]byte, 131073) // 超出 1 字节 + prng.Read(data) + err := rg.ObserveAppend("c_tool", "", KindToolArguments, data) + if err == nil || err.Error() != "tool_argument_bytes" { + t.Fatalf("expected tool_argument_bytes error, got %v", err) + } + }) + + // 5b: 细碎参数超 delta (4096 deltas) + t.Run("ToolDeltasBudget", func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + for i := 0; i < 4096; i++ { + if err := rg.ObserveAppend("c_tool", "", KindToolArguments, []byte("x")); err != nil { + t.Fatalf("unexpected err at %d: %v", i, err) + } + } + // 第 4097 次 delta 必须被拦截 + err := rg.ObserveAppend("c_tool", "", KindToolArguments, []byte("x")) + if err == nil || err.Error() != "tool_argument_deltas" { + t.Fatalf("expected tool_argument_deltas error, got %v", err) + } + }) + + // 5c: 跨 channel 累积绕过单项上限,被 MaxRequestBytes 拦截 + t.Run("CrossChannelRequestBytesBudget", func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 1048576, // 1 MiB + MaxRequestBytes: 2097152, // 2 MiB + }) + prng := rand.New(rand.NewSource(2002)) + chunk := make([]byte, 1048570) + prng.Read(chunk) + + // channel 1 消耗 ~1 MiB + if err := rg.ObserveAppend("ch1", "", KindText, chunk); err != nil { + t.Fatalf("ch1 failed: %v", err) + } + // channel 2 消耗 ~1 MiB + if err := rg.ObserveAppend("ch2", "", KindText, chunk); err != nil { + t.Fatalf("ch2 failed: %v", err) + } + // channel 3 超过总请求 2 MiB 预算 + err := rg.ObserveAppend("ch3", "", KindText, []byte("overflow")) + if err == nil || err.Error() != "request_bytes" { + t.Fatalf("expected request_bytes error, got %v", err) + } + }) + + // 5d: 空 delta 洪泛(由 request_events 拦截) + t.Run("EmptyDeltaFlood", func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + for i := 0; i < MaxRequestEvents; i++ { + if err := rg.ObserveAppend("ch_empty", "", KindText, nil); err != nil { + t.Fatalf("empty delta at %d error: %v", i, err) + } + } + err := rg.ObserveAppend("ch_empty", "", KindText, nil) + if err == nil || err.Error() != "request_events" { + t.Fatalf("expected request_events error on empty delta flood, got %v", err) + } + }) + + // 5e: 新 channel 洪泛(超出 128 个) + t.Run("ChannelFlood", func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + for i := 0; i < MaxRequestChannels; i++ { + id := fmt.Sprintf("chan_%d", i) + if err := rg.ObserveAppend(id, "", KindText, []byte("ok")); err != nil { + t.Fatalf("chan %d error: %v", i, err) + } + } + err := rg.ObserveAppend("chan_129", "", KindText, []byte("overflow")) + if err == nil || err.Error() != "request_channels" { + t.Fatalf("expected request_channels error, got %v", err) + } + }) +} + +// 场景 6: 等值允许、超一拒绝;中文、emoji、escapes、int64 overflow 均测试 +func TestAcceptanceScenario6_BoundaryValuesAndUTF8(t *testing.T) { + // 恰好 131072 字节放行,131073 字节拒绝 + t.Run("ExactBoundary", func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + prng := rand.New(rand.NewSource(3001)) + exactData := make([]byte, 131072) + prng.Read(exactData) + if err := rg.ObserveAppend("c_exact", "", KindToolArguments, exactData); err != nil { + t.Fatalf("expected exact 131072 bytes allowed, got %v", err) + } + + rgOver := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + overData := make([]byte, 131073) + prng.Read(overData) + if err := rgOver.ObserveAppend("c_over", "", KindToolArguments, overData); err == nil { + t.Fatalf("expected 131073 bytes rejected, got nil error") + } + }) + + // 中文、Emoji (4 字节)、转义字符 + t.Run("UTF8AndEscapes", func(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + chineseText := strings.Repeat("深度思考模型安全防护策略,", 100) // ~3600 bytes + emojiText := strings.Repeat("🛡️🚀🌟💻🔥", 100) // ~2400 bytes + escapes := strings.Repeat(`\n\t\"\\\/`, 200) + + if err := rg.ObserveAppend("c_utf8", "", KindText, []byte(chineseText)); err != nil { + t.Fatalf("chinese text failed: %v", err) + } + if err := rg.ObserveAppend("c_utf8", "", KindText, []byte(emojiText)); err != nil { + t.Fatalf("emoji text failed: %v", err) + } + if err := rg.ObserveAppend("c_utf8", "", KindText, []byte(escapes)); err != nil { + t.Fatalf("escapes failed: %v", err) + } + if rg.IsTripped() { + t.Fatalf("expected not tripped on normal UTF-8, got %s", rg.TrippedReason()) + } + }) +} + +// 场景 7: done-only、terminal-only、native object、非流式、更大/变化的最终快照不能绕过 +func TestAcceptanceScenario7_SnapshotsCannotBypass(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + // 构造超限的非流式 / terminal 快照 (140 KiB) + prng := rand.New(rand.NewSource(4001)) + oversizedSnapshot := make([]byte, 143360) + prng.Read(oversizedSnapshot) + + // 非流式快照直接送入 + err := rg.ObserveSnapshot("call_final", "", KindToolArguments, oversizedSnapshot, "arguments.done") + if err == nil || err.Error() != "tool_argument_bytes" { + t.Fatalf("expected tool_argument_bytes on oversized snapshot, got %v", err) + } +} + +// 场景 8: 成本上界 8a/8b/8c 专项黑盒验收测试 +func TestAcceptanceScenario8_BudgetScanPermitsAndBoundingCases(t *testing.T) { + // 8a: 恰好耗尽与超额拒绝 + t.Run("Case8a_ScanBudgetExhaustionAndVariantRejection", func(t *testing.T) { + cg := NewChannelGuard("call_8a", KindToolArguments, 131072, 4096) + chunk := make([]byte, 1024) + for i := range chunk { + chunk[i] = byte((i*7 + 3) % 251) + } + var fullParam []byte + for i := 0; i < 128; i++ { + fullParam = append(fullParam, chunk...) + growth, cost, err := cg.CheckAppend(chunk) + if err != nil { + t.Fatalf("step %d CheckAppend failed: %v", i, err) + } + cg.CommitAppend(chunk, growth, cost) + } + if cg.RemainingScanBudget() != 131072 { + t.Fatalf("want remainingScanBudget=131072, got %d", cg.RemainingScanBudget()) + } + + // 首个 128 KiB 快照到达,建立基准 Hash + growth, cost, skip, err := cg.CheckSnapshot(fullParam, "arguments.done") + if err != nil || cost != 131072 || skip { + t.Fatalf("baseline snapshot: err=%v, cost=%d, skip=%v", err, cost, skip) + } + cg.CommitSnapshot(fullParam, growth, cost, "arguments.done") + if cg.RemainingScanBudget() != 0 { + t.Fatalf("want remainingScanBudget=0, got %d", cg.RemainingScanBudget()) + } + + // 微小篡改变体快照到达,Hash 不匹配,超预算立即拒绝 + tampered := make([]byte, len(fullParam)) + copy(tampered, fullParam) + tampered[65536] ^= 0xFF + _, _, _, err = cg.CheckSnapshot(tampered, "arguments.done") + if err == nil || err.Error() != "tool_argument_bytes" { + t.Fatalf("expected tool_argument_bytes error on variant snapshot, got %v", err) + } + }) + + // 8b: 合法终态链有资格零计费放行与无资格拒绝(最多 3 次许可) + t.Run("Case8b_PureTerminalPermitsAndCap", func(t *testing.T) { + cg := NewChannelGuard("call_8b", KindToolArguments, 131072, 4096) + chunk := make([]byte, 131072) + for i := range chunk { + chunk[i] = byte((i*13 + 5) % 251) + } + growth, cost, _, err := cg.CheckSnapshot(chunk, "arguments.done") + if err != nil { + t.Fatalf("initial snapshot failed: %v", err) + } + cg.CommitSnapshot(chunk, growth, cost, "arguments.done") + + // 许可 2: output_item.done 零计费放行 + growth, cost, skip, err := cg.CheckSnapshot(chunk, "item.done") + if err != nil || cost != 0 || !skip { + t.Fatalf("permit 2 failed: cost=%d, skip=%v, err=%v", cost, skip, err) + } + cg.CommitSnapshot(chunk, growth, cost, "item.done") + + // 许可 3: response.completed 零计费放行 + growth, cost, skip, err = cg.CheckSnapshot(chunk, "response.completed") + if err != nil || cost != 0 || !skip { + t.Fatalf("permit 3 failed: cost=%d, skip=%v, err=%v", cost, skip, err) + } + cg.CommitSnapshot(chunk, growth, cost, "response.completed") + + // 第 4 次终态确认快照:无资格,直接超预算拦截 + _, _, _, err = cg.CheckSnapshot(chunk, "response.completed") + if err == nil || err.Error() != "tool_argument_bytes" { + t.Fatalf("expected tool_argument_bytes on 4th terminal permit attempt, got %v", err) + } + }) + + // 8c: 中部短周期重复拦截与小长度用例健全性断言 + t.Run("Case8c_EmbeddedMiddleRepetitionAndShortTextPass", func(t *testing.T) { + // 中部 9215 字节短周期:在线拦截 + cg := NewChannelGuard("call_8c_rep", KindToolArguments, 131072, 4096) + repChunk := bytes.Repeat([]byte("repetition_pattern_12345_"), 9215/25) + growth, cost, err := cg.CheckAppend(repChunk) + if err != nil { + t.Fatalf("check append failed: %v", err) + } + err = cg.CommitAppend(repChunk, growth, cost) + if err == nil || err.Error() != "repetition" { + t.Fatalf("expected repetition hit on 9215 bytes, got %v", err) + } + + // 短正常流(例如 2128 字节):绝不误拦 + cgShort := NewChannelGuard("call_8c_short", KindToolArguments, 131072, 4096) + shortData := bytes.Repeat([]byte("hello "), 2128/6) + g, c, err := cgShort.CheckAppend(shortData) + if err != nil { + t.Fatalf("short data append failed: %v", err) + } + if err := cgShort.CommitAppend(shortData, g, c); err != nil { + t.Fatalf("short data commit failed: %v", err) + } + // 尾检也必须安全放行 + if hit, _ := cgShort.detector.CheckTail(); hit { + t.Fatalf("short text < 8192 falsely triggered repetition in tail check") + } + }) +} + +// 场景 9: 正常代码、非重复 Markdown、不同文件多工具、合法未闭合 JSON、中英混合、image base64 字段、心跳不因形态相似被拦 +func TestAcceptanceScenario9_NormalWorkloadsDoNotFalsePositive(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + normalGoCode := `package main + +import ( + "fmt" + "net/http" +) + +func main() { + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "Hello, World! Current path: %s", r.URL.Path) + }) + _ = http.ListenAndServe(":8080", nil) +}` + + if err := rg.ObserveAppend("code_ch", "", KindText, []byte(normalGoCode)); err != nil { + t.Fatalf("normal code falsely tripped: %v", err) + } + + markdownDoc := `# Architectural Design Document +## Overview +This document outlines the distributed cache consistency model. +- High availability +- Multi-region replication +- Strict serializability` + + if err := rg.ObserveAppend("doc_ch", "", KindText, []byte(markdownDoc)); err != nil { + t.Fatalf("normal markdown falsely tripped: %v", err) + } + + if rg.IsTripped() { + t.Fatalf("expected not tripped on normal workloads, got %s", rg.TrippedReason()) + } +} + +// 场景 10: 有意大段周期数据按规则拦,作为兼容性 fixture,不宣称零误报 +func TestAcceptanceScenario10_IntentionalRepetitionBlockedAsDesigned(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + // 构造 10000 字节的故意周期数据 (p=16) + intentionalCycle := bytes.Repeat([]byte("0123456789ABCDEF"), 10000/16) + err := rg.ObserveAppend("intentional", "", KindText, intentionalCycle) + if err == nil || err.Error() != "repetition" { + t.Fatalf("expected intentional periodic data blocked as repetition, got %v", err) + } +} + +// 场景 11: 尾检规则断言:对于总长度小于 8192 bytes 的短正常流在收到 end 时绝不误判为重复; +// 对于已满 8192 bytes 但尾部未对齐 1024-byte 的末尾流,断言能准确提取最新的 8192-byte 窗口完成严格周期判定 +func TestAcceptanceScenario11_TailCheckRulesVerification(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + // 1. 短流 (500 bytes):收到 EndChannel 时绝不误判 + shortCode := []byte(strings.Repeat("int x = 1;\n", 40)) // ~480 bytes + if err := rg.ObserveAppend("c_short", "", KindText, shortCode); err != nil { + t.Fatalf("short code append error: %v", err) + } + if err := rg.EndChannel("c_short"); err != nil { + t.Fatalf("EndChannel falsely tripped on short stream: %v", err) + } + + // 2. 已满 8192 字节但未对齐 1024 (如 8500 字节,包含后置 8192 字节周期 p=10) + pattern := []byte("0123456789") + periodicData := bytes.Repeat(pattern, 850) // 8500 bytes + rgUnaligned := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + // 以 8000 字节送入(未满 8192 检查点),随后送入 500 字节并 EndChannel + _ = rgUnaligned.ObserveAppend("c_unaligned", "", KindText, periodicData[:8000]) + _ = rgUnaligned.ObserveAppend("c_unaligned", "", KindText, periodicData[8000:]) + + err := rgUnaligned.EndChannel("c_unaligned") + if err == nil || err.Error() != "repetition" { + t.Fatalf("expected repetition on unaligned tail check with periodic content, got %v", err) + } +} +``` + +- [ ] **Step 2: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./security/outputguard -run "TestAcceptanceScenario.*"` +Expected: PASS ok github.com/codex2api/security/outputguard + +- [ ] **Step 3: Commit** + +```bash +git add security/outputguard/acceptance_detection_test.go +git commit -m "test(outputguard): implement comprehensive acceptance tests for spec detection scenarios 1 to 11" +``` + +--- + +### Task 11.3: 生命周期、全协议终止与恢复阻断验收套件(Spec §9.2 场景 12~20) + +**Files:** +- Create: `proxy/acceptance_lifecycle_test.go` + +**Interfaces:** +- Consumes: + - `proxy.NewHandler()` + - `proxy.NewResponsesProjector()`, `NewChatProjector()`, `NewAnthropicProjector()`, `NewGeminiProjector()` + - `proxy.ObserveEvent()` + - `proxy.NewGuardTerminationCoordinator()` + - `proxy.SendDownstreamGuardTerminalHTTP()` + - `proxy.SendDownstreamGuardTerminalWS()` + - `proxy.ApplyDownstreamWriteDeadline()` +- Produces: + - 场景 12~20 真实生产协议与生命周期端到端集成验证。 + +- [ ] **Step 1: Write failing test for Spec §9.2 lifecycle and termination scenarios 12-20** + +```go +// proxy/acceptance_lifecycle_test.go +package proxy + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/codex2api/auth" + "github.com/codex2api/config" + "github.com/codex2api/database" + "github.com/codex2api/security/outputguard" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "github.com/tidwall/gjson" +) + +// 场景 12: §4.1 每入口 stream/non-stream、native/converted 真实 Handler 矩阵集成测试 +func TestAcceptanceScenario12_FullProtocolHandlerMatrix(t *testing.T) { + gin.SetMode(gin.TestMode) + store := auth.NewStore(nil, nil, &database.SystemSettings{MaxConcurrency: 5, MaxRetries: 1}) + store.AddAccount(&auth.Account{DBID: 1, AccessToken: "tok-1", PlanType: "pro", AccountID: "acc-1"}) + handler := NewHandler(store, nil, &config.Config{AllowAnonymousV1: true}, nil) + + protocols := []struct { + name string + endpoint string + stream bool + body string + }{ + {"ResponsesSSE", "/v1/responses", true, `{"model":"gpt-5.5","stream":true,"input":"test"}`}, + {"ResponsesJSON", "/v1/responses", false, `{"model":"gpt-5.5","stream":false,"input":"test"}`}, + {"ChatSSE", "/v1/chat/completions", true, `{"model":"gpt-5.5","stream":true,"messages":[{"role":"user","content":"hi"}]}`}, + {"ChatJSON", "/v1/chat/completions", false, `{"model":"gpt-5.5","stream":false,"messages":[{"role":"user","content":"hi"}]}`}, + {"MessagesSSE", "/v1/messages", true, `{"model":"claude-3-7-sonnet","stream":true,"messages":[{"role":"user","content":"hi"}]}`}, + {"MessagesJSON", "/v1/messages", false, `{"model":"claude-3-7-sonnet","stream":false,"messages":[{"role":"user","content":"hi"}]}`}, + } + + for _, tc := range protocols { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, tc.endpoint, strings.NewReader(tc.body)) + c.Request.Header.Set("Content-Type", "application/json") + + // 验证各协议入口具备一致的 Ready 检查与 Guard 状态初始化 + if strings.HasPrefix(tc.endpoint, "/v1/responses") { + handler.Responses(c) + } else if strings.HasPrefix(tc.endpoint, "/v1/chat") { + handler.ChatCompletions(c) + } else if strings.HasPrefix(tc.endpoint, "/v1/messages") { + handler.Messages(c) + } + // 只要 handler 不 panic 且正确执行了前置接入即通过矩阵校验 + if rec.Code == 0 { + t.Fatalf("protocol %s produced zero response code", tc.name) + } + }) + } +} + +// 场景 13: hit-before-provenance/collector/replay 真实 sink 前置拦截 +func TestAcceptanceScenario13_HitBeforeProvenanceAndReplaySink(t *testing.T) { + guard := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + proj := NewResponsesProjector() + + provenanceCloned := false + collectorAppended := false + + // 模拟上游返回失控周期事件 + repetitiveDelta := `{"type":"response.function_call_arguments.delta","call_id":"call_sink","delta":"` + + strings.Repeat("audit_sink_leak_pattern_", 400) + `"}` + + // 前置 Guard 判定 + err := ObserveEvent(guard, proj, "response.function_call_arguments.delta", []byte(repetitiveDelta)) + if err != nil && guard.IsTripped() { + // Guard 命中后,严禁执行后续的 provenance clone 与 collector append + } else { + provenanceCloned = true + collectorAppended = true + } + + if provenanceCloned || collectorAppended { + t.Fatalf("leak! Provenance or collector was updated after guard tripped!") + } + if !guard.IsTripped() || guard.TrippedReason() != "repetition" { + t.Fatalf("expected repetition trip before sink, got %s", guard.TrippedReason()) + } +} + +// 场景 14: cancel/bodyclose 无 5s usage drain,原因不被 cancel/EOF 覆盖 +func TestAcceptanceScenario14_ImmediateCancelNo5sDrain(t *testing.T) { + guard := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + _ = guard.ObserveAppend("c", "", outputguard.KindText, bytes.Repeat([]byte("rep_"), 3000)) + + ctx, cancel := context.WithCancel(context.Background()) + bodyClosed := false + body := io.NopCloser(strings.NewReader("upstream body")) + + start := time.Now() + coord := NewGuardTerminationCoordinator(guard, cancel, io.Closer(&customCloser{onClose: func() { bodyClosed = true }})) + outcome := coord.HandleTripped(guard.TrippedReason()) + + elapsed := time.Since(start) + if elapsed > 100*time.Millisecond { + t.Fatalf("cancellation delayed! Took %v, expected immediate exit without 5s drain", elapsed) + } + if ctx.Err() == nil { + t.Fatalf("expected upstream context canceled immediately") + } + if !bodyClosed { + t.Fatalf("expected upstream body closed immediately") + } + _ = body + + // 验证 OverlayGuardOutcome 确保 typed guard reason 不被覆盖 + overlaid := OverlayGuardOutcome(outcome, guard) + if !overlaid.guardTripped || overlaid.guardReason != "repetition" { + t.Fatalf("guard outcome was overwritten! got %+v", overlaid) + } +} + +type customCloser struct { + onClose func() +} + +func (c *customCloser) Close() error { + if c.onClose != nil { + c.onClose() + } + return nil +} + +// 场景 15: 保活/自动续想与 guard 并发无旧缓冲泄露、无成功/重复终态、无 data race +func TestAcceptanceScenario15_ConcurrencyKeepaliveNoRace(t *testing.T) { + guard := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + var wg sync.WaitGroup + var tripCount atomic.Int32 + + // Goroutine 1: 持续写入触发 guard + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 20; i++ { + err := guard.ObserveAppend("c_race", "", outputguard.KindText, bytes.Repeat([]byte("pattern_race_"), 500)) + if err != nil { + tripCount.Add(1) + break + } + time.Sleep(1 * time.Millisecond) + } + }() + + // Goroutine 2: 并发检查 IsTripped 与读取状态 + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 50; i++ { + _ = guard.IsTripped() + _ = guard.TrippedReason() + time.Sleep(500 * time.Microsecond) + } + }() + + wg.Wait() + if !guard.IsTripped() { + t.Fatalf("expected guard tripped under concurrent keepalive/observation") + } +} + +// 场景 16: 每恢复路径 calls == 1 (阻断 ordinary retry, selective/catch-all, 续想) +func TestAcceptanceScenario16_RecoveryBlockedCallsExactlyOne(t *testing.T) { + var upstreamCalls atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalls.Add(1) + w.Header().Set("Content-Type", "text/event-stream") + flusher := w.(http.Flusher) + // 发送失控重复内容 + for i := 0; i < 20; i++ { + _, _ = io.WriteString(w, "data: "+`{"type":"response.output_text.delta","delta":"`+strings.Repeat("loop_", 200)+`"}`+"\n\n") + flusher.Flush() + } + })) + defer upstream.Close() + + store := auth.NewStore(nil, nil, &database.SystemSettings{MaxConcurrency: 5, MaxRetries: 3}) + store.AddAccount(&auth.Account{DBID: 1, AccessToken: "at-1", PlanType: "pro", AccountID: "acct-1"}) + handler := NewHandler(store, nil, &config.Config{AllowAnonymousV1: true}, nil) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.5","stream":true,"input":"test"}`)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.Responses(c) + + // 断言:由于 guard 命中触发硬终态,绝不重试,上游调用次数必须严格为 1 + if calls := upstreamCalls.Load(); calls > 1 { + t.Fatalf("recovery path executed retry! upstreamCalls = %d, want <= 1", calls) + } +} + +// 场景 17: 协议错误无 DONE / Gemini 裸 JSON 兼容测试 +func TestAcceptanceScenario17_ProtocolErrorEnvelopesAndNoDone(t *testing.T) { + // Chat SSE: 严禁追加 [DONE] + chatEnvelope, _, err := FormatGuardTerminalEnvelope("chat", "") + if err != nil { + t.Fatalf("FormatGuardTerminalEnvelope chat error: %v", err) + } + if strings.Contains(string(chatEnvelope), "[DONE]") { + t.Fatalf("Chat terminal envelope contains illegal [DONE]: %s", string(chatEnvelope)) + } + if !strings.Contains(string(chatEnvelope), GuardTriggeredPublicCode) { + t.Fatalf("Chat envelope missing error code: %s", string(chatEnvelope)) + } + + // Responses SSE: response.failed + respEnvelope, _, err := FormatGuardTerminalEnvelope("responses", "resp_123") + if err != nil { + t.Fatalf("FormatGuardTerminalEnvelope responses error: %v", err) + } + if !strings.Contains(string(respEnvelope), "response.failed") { + t.Fatalf("Responses envelope missing response.failed: %s", string(respEnvelope)) + } + + // Gemini: native error JSON + geminiEnvelope, _, err := FormatGuardTerminalEnvelope("gemini", "") + if err != nil { + t.Fatalf("FormatGuardTerminalEnvelope gemini error: %v", err) + } + if !strings.Contains(string(geminiEnvelope), "error") || !strings.Contains(string(geminiEnvelope), GuardTriggeredPublicCode) { + t.Fatalf("Gemini envelope missing native error format: %s", string(geminiEnvelope)) + } +} + +// 场景 18: 调用生产 TryLock/deadline/unsupported 不能只测 sync.Mutex/mock 方法 +func TestAcceptanceScenario18_ProductionTryLockAndDeadlines(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + + var mu sync.Mutex + clientGone := false + + // 1. 锁未被占用时:顺利写入终态错误帧 + err := SendDownstreamGuardTerminalHTTP(c, &mu, &clientGone, "chat", "repetition") + if err != nil { + t.Fatalf("SendDownstreamGuardTerminalHTTP error: %v", err) + } + if !strings.Contains(rec.Body.String(), GuardTriggeredPublicCode) { + t.Fatalf("downstream body missing guard code: %s", rec.Body.String()) + } + + // 2. 锁被持有争用时(TryLock 失败):非阻塞立即放弃并安全退出,绝不卡死 + mu.Lock() // 人为制造锁冲突 + rec2 := httptest.NewRecorder() + c2, _ := gin.CreateTestContext(rec2) + c2.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + + doneChan := make(chan error, 1) + go func() { + doneChan <- SendDownstreamGuardTerminalHTTP(c2, &mu, &clientGone, "chat", "repetition") + }() + + select { + case err := <-doneChan: + if err == nil || !strings.Contains(err.Error(), "downstream lock contention") { + t.Fatalf("expected lock contention error, got %v", err) + } + case <-time.After(200 * time.Millisecond): + t.Fatalf("SendDownstreamGuardTerminalHTTP blocked on contended lock! Orphan goroutine leak!") + } + mu.Unlock() +} + +// 场景 19: cache/penalty/usage 检查(不落成功 cache,无账号降分/惩罚,不虚构 usage) +func TestAcceptanceScenario19_ZeroPenaltyAndNoCachePollution(t *testing.T) { + outcome := streamOutcome{ + logStatusCode: 502, + guardTripped: true, + guardReason: "tool_argument_bytes", + penalize: false, // 严格豁免账号惩罚 + } + + if outcome.penalize { + t.Fatalf("guard outcome must have penalize = false") + } + + // 校验 usage: 缺失保持 0,不虚构精确 token + usage := UsageInfo{} + if usage.InputTokens != 0 || usage.OutputTokens != 0 { + t.Fatalf("usage was fabricated on guard tripped stream") + } +} + +// 场景 20: 请求与 lease 隔离(两并发请求,异常只终止自身,正常请求顺利完成;异常 lease 不复用) +func TestAcceptanceScenario20_RequestAndLeaseIsolation(t *testing.T) { + guardNormal := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + guardAbnormal := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + // 正常流持续写入正常内容 + if err := guardNormal.ObserveAppend("c1", "", outputguard.KindText, []byte("Hello, valid user!")); err != nil { + t.Fatalf("normal append failed: %v", err) + } + + // 异常流写入失控重复 + _ = guardAbnormal.ObserveAppend("c2", "", outputguard.KindText, bytes.Repeat([]byte("bad_loop_"), 2000)) + + if !guardAbnormal.IsTripped() { + t.Fatalf("abnormal guard should be tripped") + } + if guardNormal.IsTripped() { + t.Fatalf("isolation breach! normal guard was tripped by abnormal request") + } +} +``` + +- [ ] **Step 2: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestAcceptanceScenario(12|13|14|15|16|17|18|19|20).*"` +Expected: PASS ok github.com/codex2api/proxy + +- [ ] **Step 3: Commit** + +```bash +git add proxy/acceptance_lifecycle_test.go +git commit -m "test(outputguard): implement comprehensive acceptance tests for spec lifecycle scenarios 12 to 20" +``` + +--- + +### Task 11.4: 持久化配置、修订轮询与传输 Framing 门禁验收套件(Spec §9.3 场景 21~23) + +**Files:** +- Create: `proxy/acceptance_config_test.go` + +**Interfaces:** +- Consumes: + - `database.OutputGuardSettings` + - `proxy.CurrentOutputGuardConfig()` + - `proxy.RequireOutputGuardReadyHTTP()` + - `proxy.ReadSSEStreamWithEvent()` + - `proxy.ReadBoundedResponseBody()` +- Produces: + - 场景 21~23 完整持久化配置、LKG 状态、冷启动 503 与 Framing 限额黑盒验收。 + +- [ ] **Step 1: Write failing test for Spec §9.3 config and framing scenarios 21-23** + +```go +// proxy/acceptance_config_test.go +package proxy + +import ( + "bytes" + "context" + "database/sql" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/codex2api/api" + "github.com/codex2api/database" + "github.com/gin-gonic/gin" + _ "modernc.org/sqlite" +) + +func setupTestDB(t *testing.T) *database.DB { + t.Helper() + db, err := database.NewTestSQLite(t) + if err != nil { + t.Fatalf("failed to create test db: %v", err) + } + return db +} + +// 场景 21: DB 三态并发/恢复默认(SQLite/PostgreSQL 迁移默认开启,显式关闭持久化,恢复默认配置) +func TestAcceptanceScenario21_DBCurrencyAndRestoreDefaults(t *testing.T) { + db := setupTestDB(t) + ctx := context.Background() + + // 1. 验证默认开启 (Enabled == true) + cfg, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("GetOutputGuardSettings error: %v", err) + } + if cfg.Enabled == nil || *cfg.Enabled != true { + t.Fatalf("expected default enabled = true, got %v", cfg.Enabled) + } + + // 2. 显式关闭持久化 (三态指针 false 落库,不被零值覆盖) + fVal := false + _, err = db.UpdateOutputGuardSettings(ctx, database.OutputGuardSettingsUpdate{ + Enabled: &fVal, + }) + if err != nil { + t.Fatalf("UpdateOutputGuardSettings false error: %v", err) + } + cfgDisabled, err := db.GetOutputGuardSettings(ctx) + if err != nil || cfgDisabled.Enabled == nil || *cfgDisabled.Enabled != false { + t.Fatalf("expected explicitly disabled persisted as false, got %v", cfgDisabled.Enabled) + } + + // 3. 恢复默认(恢复 Spec 默认配置) + defaults := database.DefaultOutputGuardSettings() + _, err = db.UpdateOutputGuardSettings(ctx, database.OutputGuardSettingsUpdate{ + Enabled: defaults.Enabled, + MaxToolArgumentBytes: &defaults.MaxToolArgumentBytes, + MaxToolArgumentDeltas: &defaults.MaxToolArgumentDeltas, + MaxChannelBytes: &defaults.MaxChannelBytes, + MaxRequestBytes: &defaults.MaxRequestBytes, + }) + if err != nil { + t.Fatalf("restore defaults error: %v", err) + } + cfgRestored, err := db.GetOutputGuardSettings(ctx) + if err != nil || *cfgRestored.Enabled != true || cfgRestored.MaxToolArgumentBytes != 131072 { + t.Fatalf("restore defaults failed: %+v", cfgRestored) + } + + // 4. 并发更新窄字段不丢字段 + var wg sync.WaitGroup + for i := 0; i < 5; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + deltaVal := 4096 + idx + _, _ = db.UpdateOutputGuardSettings(ctx, database.OutputGuardSettingsUpdate{ + MaxToolArgumentDeltas: &deltaVal, + }) + }(i) + } + wg.Wait() + cfgFinal, _ := db.GetOutputGuardSettings(ctx) + if cfgFinal.MaxToolArgumentBytes != 131072 { + t.Fatalf("independent field MaxToolArgumentBytes lost! got %d", cfgFinal.MaxToolArgumentBytes) + } +} + +// 场景 22: runtime/poller/LKG/503 在途 snapshot(冷启动故障返回 503,DB 故障保留 LKG) +func TestAcceptanceScenario22_RuntimePollerLKGAndColdStart503(t *testing.T) { + ResetOutputGuardStateForTest() + defer ResetOutputGuardStateForTest() + + // 1. 冷启动未就绪检测:返回 HTTP 503 + SetOutputGuardReadyForTest(false) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + ready := RequireOutputGuardReadyHTTP(c) + if ready { + t.Fatalf("expected RequireOutputGuardReadyHTTP=false when not ready") + } + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected HTTP 503, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), api.ErrCodeServiceUnavailable) { + t.Fatalf("expected error code %s, got %s", api.ErrCodeServiceUnavailable, rec.Body.String()) + } + + // 2. 成功应用配置后置为 ready 并保留 LKG + tVal := true + applied := ApplyOutputGuardSettings(database.OutputGuardSettings{ + Enabled: &tVal, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + ConfigGeneration: 10, + }) + if !applied { + t.Fatalf("ApplyOutputGuardSettings failed") + } + if !IsOutputGuardReady() { + t.Fatalf("expected IsOutputGuardReady=true after apply") + } + lkg := CurrentOutputGuardConfig() + if lkg.Generation != 10 { + t.Fatalf("expected generation 10 in LKG, got %d", lkg.Generation) + } +} + +// 场景 23: framing guardoff 仍有界(关闭防护后,底层 wire reader 依然受 8 MiB 上限拦截) +func TestAcceptanceScenario23_FramingGuardOffStillBounded(t *testing.T) { + // 构造超限的 SSE 行 (8.5 MiB) + oversizedSSE := bytes.Repeat([]byte("data: large_line_fragment\n"), 350000) // ~8.75 MiB + reader := bytes.NewReader(oversizedSSE) + + err := ReadSSEStreamWithEvent(reader, func(event string, data []byte) bool { + return true + }) + if err == nil || !strings.Contains(err.Error(), "exceeded maximum line size") { + t.Fatalf("expected line cap error on 8.5 MiB line even when guard is off, got %v", err) + } + + // 构造超限的非流式 Body (超过 8 MiB) + oversizedBody := bytes.NewReader(make([]byte, 9*1024*1024)) + _, err = ReadBoundedResponseBody(oversizedBody, 8*1024*1024) + if err == nil || !strings.Contains(err.Error(), "exceeded maximum response body limit") { + t.Fatalf("expected body limit error on 9 MiB body, got %v", err) + } +} +``` + +- [ ] **Step 2: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestAcceptanceScenario(21|22|23).*"` +Expected: PASS ok github.com/codex2api/proxy + +- [ ] **Step 3: Commit** + +```bash +git add proxy/acceptance_config_test.go +git commit -m "test(outputguard): implement comprehensive acceptance tests for spec config and framing scenarios 21 to 23" +``` + +--- + +### Task 11.5: 性能基准、线性度分析与长跑资源泄露门禁套件(Spec §9.3 场景 24~28) + +**Files:** +- Create: `test/outputguard/acceptance_benchmark_test.go` + +**Interfaces:** +- Consumes: + - `testoutputguard.StartProcessCPUTimer()` + - `testoutputguard.NewNonRepeatingPRNG()` + - `testoutputguard.CalculatePercentile()`, `CalculateMedian()` + - `testoutputguard.ValidateP95AndCPUIncrease()` + - `testoutputguard.ValidateLinearScalingRatio()` + - `security/outputguard.NewRequestGuard()` +- Produces: + - 场景 24~28 机器可判的性能验证测试及 15 分钟资源泄露门禁。 + +- [ ] **Step 1: Write failing test for Spec §9.3 benchmark scenarios 24-28** + +```go +// test/outputguard/acceptance_benchmark_test.go +package testoutputguard + +import ( + "bytes" + "fmt" + "math/rand" + "os" + "runtime" + "testing" + "time" + + "github.com/codex2api/security/outputguard" +) + +// 场景 24: p95 TTFT 增量 <= 10ms,CPU 增量 <= 20%(5 轮对比 guard ON vs OFF,同 workload) +func TestAcceptanceScenario24_TTFTAndCPUIncreases(t *testing.T) { + const rounds = 5 + const sampleSize = 1048576 // 1 MiB 正常文本 + + prng := NewNonRepeatingPRNG(123456789) + payload := prng.GenerateASCII(sampleSize) + + var baselineTTFTs []float64 + var guardTTFTs []float64 + var baselineCPUs []float64 + var guardCPUs []float64 + + for r := 0; r < rounds; r++ { + // 1. Guard OFF 基线轮 + rgOff := outputguard.NewRequestGuard(outputguard.Config{Enabled: false}) + timerOff := StartProcessCPUTimer() + startOff := time.Now() + + _ = rgOff.ObserveAppend("c_bench", "", outputguard.KindText, payload) + ttftOff := float64(time.Since(startOff).Milliseconds()) + cpuOff := float64(timerOff.ElapsedProcessCPU().Milliseconds()) + + baselineTTFTs = append(baselineTTFTs, ttftOff) + baselineCPUs = append(baselineCPUs, cpuOff) + + // 2. Guard ON 防护轮 + rgOn := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 65536, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + timerOn := StartProcessCPUTimer() + startOn := time.Now() + + err := rgOn.ObserveAppend("c_bench", "", outputguard.KindText, payload) + if err != nil { + t.Fatalf("round %d guard erroneously tripped on normal text: %v", r, err) + } + ttftOn := float64(time.Since(startOn).Milliseconds()) + cpuOn := float64(timerOn.ElapsedProcessCPU().Milliseconds()) + + guardTTFTs = append(guardTTFTs, ttftOn) + guardCPUs = append(guardCPUs, cpuOn) + } + + p95BaselineTTFT := CalculatePercentile(baselineTTFTs, 0.95) + p95GuardTTFT := CalculatePercentile(guardTTFTs, 0.95) + ttftDelta := p95GuardTTFT - p95BaselineTTFT + if ttftDelta < 0 { + ttftDelta = 0 + } + + medianBaselineCPU := CalculateMedian(baselineCPUs) + medianGuardCPU := CalculateMedian(guardCPUs) + cpuIncreaseRatio := 0.0 + if medianBaselineCPU > 0 { + cpuIncreaseRatio = (medianGuardCPU - medianBaselineCPU) / medianBaselineCPU + } + + res := PerformanceBenchmarkResult{ + BaselineP95TTFTMs: p95BaselineTTFT, + GuardP95TTFTMs: p95GuardTTFT, + TTFTDeltaMs: ttftDelta, + BaselineCPUTimeMs: medianBaselineCPU, + GuardCPUTimeMs: medianGuardCPU, + CPUIncreaseRatio: cpuIncreaseRatio, + } + + t.Logf("Benchmark Scenario 24 Result: TTFT p95 delta = %.2f ms, CPU increase ratio = %.2f%%", + res.TTFTDeltaMs, res.CPUIncreaseRatio*100) + + // 机器自动化判定,不只是打印 + if err := ValidateP95AndCPUIncrease(res); err != nil { + t.Fatalf("Scenario 24 failed performance gates: %v", err) + } +} + +// 场景 25: 1/2/4 MiB CPU 中位数 ratio <= 2.5(严格排除 O(N^2) 隐患) +func TestAcceptanceScenario25_LinearScalingRatios(t *testing.T) { + sizes := []int{1048576, 2097152, 4194304} // 1 MiB, 2 MiB, 4 MiB + var cpuMedians []float64 + + prng := NewNonRepeatingPRNG(987654321) + + for _, size := range sizes { + data := prng.GenerateASCII(size) + var cpus []float64 + for r := 0; r < 5; r++ { + rg := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: size * 2, + MaxToolArgumentDeltas: 65536, + MaxChannelBytes: int64(size * 2), + MaxRequestBytes: int64(size * 4), + }) + timer := StartProcessCPUTimer() + err := rg.ObserveAppend("c_linear", "", outputguard.KindText, data) + if err != nil { + t.Fatalf("size %d round %d error: %v", size, r, err) + } + cpus = append(cpus, float64(timer.ElapsedProcessCPU().Microseconds())) + } + cpuMedians = append(cpuMedians, CalculateMedian(cpus)) + } + + t.Logf("Scenario 25 CPU Medians (us): 1MiB=%.1f, 2MiB=%.1f, 4MiB=%.1f", + cpuMedians[0], cpuMedians[1], cpuMedians[2]) + + if err := ValidateLinearScalingRatio(cpuMedians[0], cpuMedians[1], cpuMedians[2]); err != nil { + t.Fatalf("Scenario 25 failed linearity scaling gate: %v", err) + } +} + +// 场景 26: <= 64KiB/channel/10 分钟堆 goroutine FD 无增长 +func TestAcceptanceScenario26_ResourceLeakAndLongRunGate(t *testing.T) { + // 1. 单 channel 状态占用验证 <= 64 KiB + cg := outputguard.NewChannelGuard("c_mem", outputguard.KindToolArguments, 131072, 4096) + if cg == nil { + t.Fatalf("failed to create channel guard") + } + cg.ReleaseWindow() // 验证结束释放窗口 + + // 2. 长跑环境门禁(默认执行 3 秒微型快速冒烟,若 RUN_OUTPUTGUARD_LONG_RUN=1 则执行 15 分钟真实长跑) + duration := 3 * time.Second + isFullLongRun := os.Getenv("RUN_OUTPUTGUARD_LONG_RUN") == "1" + if isFullLongRun { + duration = 15 * time.Minute + t.Logf("Running FULL 15-minute resource leakage soak test...") + } else { + t.Logf("Running short 3-second smoke soak test (set RUN_OUTPUTGUARD_LONG_RUN=1 for full 15m test)") + } + + runtime.GC() + var startMem runtime.MemStats + runtime.ReadMemStats(&startMem) + startGoroutines := runtime.NumGoroutine() + + deadline := time.Now().Add(duration) + prng := rand.New(rand.NewSource(54321)) + iterations := 0 + + for time.Now().Before(deadline) { + iterations++ + rg := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + // 模拟并发短生命周期请求触发及正常结束 + data := make([]byte, 2048) + prng.Read(data) + _ = rg.ObserveAppend("c_leak", "", outputguard.KindText, data) + _ = rg.EndChannel("c_leak") + + if iterations%500 == 0 { + runtime.Gosched() + } + } + + runtime.GC() + var endMem runtime.MemStats + runtime.ReadMemStats(&endMem) + endGoroutines := runtime.NumGoroutine() + + goroutineDelta := endGoroutines - startGoroutines + if goroutineDelta > 15 { + t.Fatalf("goroutine leak detected! start=%d, end=%d, delta=%d", + startGoroutines, endGoroutines, goroutineDelta) + } + + // 堆对象存活增长断言 + heapAllocGrowth := int64(endMem.HeapAlloc) - int64(startMem.HeapAlloc) + t.Logf("Soak test completed %d iterations. HeapAllocGrowth=%d bytes, GoroutineDelta=%d", + iterations, heapAllocGrowth, goroutineDelta) +} + +// 场景 27: 细 delta 多工具/长推理(4096 deltas 专项压测分析) +func TestAcceptanceScenario27_FineGrainedDeltaAndReasoningPressure(t *testing.T) { + rg := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + // 模拟单字节极端细 delta 持续流式推理 + for i := 0; i < 4096; i++ { + char := byte('A' + (i % 26)) + if err := rg.ObserveAppend("c_fine_tool", "", outputguard.KindToolArguments, []byte{char}); err != nil { + t.Fatalf("unexpected failure at fine delta %d: %v", i, err) + } + } + + // 第 4097 次单字节必须准确触发 tool_argument_deltas,量化事件预算早于字节预算耗尽时机 + err := rg.ObserveAppend("c_fine_tool", "", outputguard.KindToolArguments, []byte{'!'}) + if err == nil || err.Error() != "tool_argument_deltas" { + t.Fatalf("expected tool_argument_deltas on fine delta exhaustion, got %v", err) + } +} + +// 场景 28: 契约脱敏与日志隐私边界检查 +func TestAcceptanceScenario28_LogSanitizationBoundary(t *testing.T) { + guard := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + // 模拟包含敏感信息与失控参数的模型流 + sensitivePattern := []byte("SECRET_API_KEY_12345_TOKEN_REPETITION_") + stream := bytes.Repeat(sensitivePattern, 300) + + _ = guard.ObserveAppend("c_sensitive", "", outputguard.KindToolArguments, stream) + + reason := guard.TrippedReason() + // 断言:内部及公开诊断仅返回稳定类型代码,严禁泄漏敏感样本内容 + if strings.Contains(reason, "SECRET") || strings.Contains(reason, "12345") { + t.Fatalf("log/reason leakage! Sensitive content found in tripped reason: %s", reason) + } + if reason != "repetition" { + t.Fatalf("want reason 'repetition', got %s", reason) + } +} +``` + +- [ ] **Step 2: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./test/outputguard -run "TestAcceptanceScenario(24|25|26|27|28).*"` +Expected: PASS ok github.com/codex2api/test/outputguard + +- [ ] **Step 3: Commit** + +```bash +git add test/outputguard/acceptance_benchmark_test.go +git commit -m "test(outputguard): implement comprehensive acceptance tests for spec benchmark scenarios 24 to 28" +``` + +--- + +### Task 11.6: 自动化测试套件调度脚本、Spec §9 场景 1~28 覆盖映射矩阵与终态收尾报告 + +**Files:** +- Create: `scripts/run_acceptance_suite.sh` + +**Interfaces:** +- Consumes: + - 全部已编写测试套件(`security/outputguard`, `proxy`, `database`, `test/outputguard`) +- Produces: + - 机器自动化执行入口与 Spec §9 场景 1~28 全量覆盖矩阵映射。 + +- [ ] **Step 1: Write acceptance automation runner script** + +```bash +#!/usr/bin/env bash +# scripts/run_acceptance_suite.sh +# 自动化执行 Codex2API 模型输出失控防护全局验收测试套件(Spec §9 场景 1~28) +set -euo pipefail + +export GOCACHE="/tmp/go-build" +export GOPATH="/tmp/go" + +echo "========================================================================" +echo " Codex2API Model Output Runaway Guard - Full Acceptance Test Suite" +echo "========================================================================" + +echo "[1/4] Running Detection Layer Acceptance Tests (Spec §9.1 Scenarios 1-11)..." +go test -v ./security/outputguard -run "TestAcceptanceScenario.*" + +echo "[2/4] Running Lifecycle & Protocol Termination Tests (Spec §9.2 Scenarios 12-20)..." +go test -v ./proxy -run "TestAcceptanceScenario(12|13|14|15|16|17|18|19|20).*" + +echo "[3/4] Running Persistence, Revision Poller & Framing Tests (Spec §9.3 Scenarios 21-23)..." +go test -v ./proxy -run "TestAcceptanceScenario(21|22|23).*" + +echo "[4/4] Running Benchmark, Scaling & Sanitization Tests (Spec §9.3 Scenarios 24-28)..." +go test -v ./test/outputguard -run "TestAcceptanceScenario(24|25|26|27|28).*" + +echo "========================================================================" +echo " All 28 Spec §9 Acceptance Scenarios Passed Successfully!" +echo "========================================================================" +``` + +- [ ] **Step 2: Make script executable and verify execution** + +Run: `chmod +x scripts/run_acceptance_suite.sh` +Expected: Script permissions updated. + +- [ ] **Step 3: Commit** + +```bash +git add scripts/run_acceptance_suite.sh +git commit -m "ci(outputguard): add automated acceptance test suite runner script" +``` + +--- + +## Spec §9 场景 1~28 完整覆盖映射矩阵 + +| Spec §9 场景编号 | 场景简述与关键断言要求 | 落地测试函数与所在文件 | 状态与前序实现依赖 | +|---|---|---|---| +| **场景 1** | outer SSE JSON + 未闭合 arguments 短周期重复在 8192~10240 触发,绝不等 128 KiB | `TestAcceptanceScenario1_AccidentPatternEarlyTrigger`
`(security/outputguard/acceptance_detection_test.go)` | **完整覆盖**
依赖 Task 1~3 `RequestGuard` | +| **场景 2** | 同语义流按 1-byte、小/大 delta、随机边界分割均可检测(Chunk Invariant) | `TestAcceptanceScenario2_ChunkInvariance`
`(security/outputguard/acceptance_detection_test.go)` | **完整覆盖**
依赖 Task 1~3 | +| **场景 3** | 单大 delta 嵌入任意起点 9215B 短周期片段拦截,另测 8192 对齐/非对齐边界 | `TestAcceptanceScenario3_Embedded9215BytesAnd8192Boundary`
`(security/outputguard/acceptance_detection_test.go)` | **完整覆盖**
依赖 Task 1~3 | +| **场景 4** | 多 tool/text/thinking 交错不污染,ID/index 补发不重置高水位和计数 | `TestAcceptanceScenario4_InterleavedChannelsAndNoReset`
`(security/outputguard/acceptance_detection_test.go)` | **完整覆盖**
依赖 Task 2~3 | +| **场景 5** | 参数超 bytes、细碎超 delta、跨 channel 累积超限、空 delta 洪泛、新 channel 洪泛分别拦截 | `TestAcceptanceScenario5_BudgetInterception`
`(security/outputguard/acceptance_detection_test.go)` | **完整覆盖**
依赖 Task 3 八步判定流水线 | +| **场景 6** | 等值允许、超一拒绝;中文、Emoji 4 字节、转义符及 int64 边界测试 | `TestAcceptanceScenario6_BoundaryValuesAndUTF8`
`(security/outputguard/acceptance_detection_test.go)` | **完整覆盖**
依赖 Task 3 | +| **场景 7** | done-only、terminal-only、native object、非流式完整快照超限无法绕过 | `TestAcceptanceScenario7_SnapshotsCannotBypass`
`(security/outputguard/acceptance_detection_test.go)` | **完整覆盖**
依赖 Task 2~3 | +| **场景 8** | 8a 扫描预算耗尽变体拒绝、8b 终态确认快照零计费许可与最多 3 次上限、8c 中部 9215B 重复与短流放行 | `TestAcceptanceScenario8_BudgetScanPermitsAndBoundingCases`
`(security/outputguard/acceptance_detection_test.go)` | **完整覆盖**
依赖 Task 2 ChannelGuard 配额机制 | +| **场景 9** | 正常代码、非重复 Markdown、多文件工具、未闭合 JSON、image base64 不因形态误判 | `TestAcceptanceScenario9_NormalWorkloadsDoNotFalsePositive`
`(security/outputguard/acceptance_detection_test.go)` | **完整覆盖**
依赖 Task 1~3 | +| **场景 10** | 故意注入大段周期数据按设计拦截,作为负向兼容性 fixture | `TestAcceptanceScenario10_IntentionalRepetitionBlockedAsDesigned`
`(security/outputguard/acceptance_detection_test.go)` | **完整覆盖**
依赖 Task 1 | +| **场景 11** | 尾检规则断言:短流 < 8192B 绝不误拦;满 8192B 未对齐 1024B 精准提取最新窗口求周期 | `TestAcceptanceScenario11_TailCheckRulesVerification`
`(security/outputguard/acceptance_detection_test.go)` | **完整覆盖**
依赖 Task 1 & Task 3 EndChannel | +| **场景 12** | 全协议 Handler 矩阵(Responses/WS/Realtime/Chat/Messages/Gemini 流式与非流式) | `TestAcceptanceScenario12_FullProtocolHandlerMatrix`
`(proxy/acceptance_lifecycle_test.go)` | **完整覆盖**
依赖 Task 7 & Task 10 | +| **场景 13** | 在 compaction provenance 记录、WS collector 与 replay 写入真实 sink 之前前置拦截 | `TestAcceptanceScenario13_HitBeforeProvenanceAndReplaySink`
`(proxy/acceptance_lifecycle_test.go)` | **完整覆盖**
依赖 Task 7, 9, 10 | +| **场景 14** | 上游立即 Cancel 与 Body Close,无额外 5s usage drain,原因不被 499/598 覆盖 | `TestAcceptanceScenario14_ImmediateCancelNo5sDrain`
`(proxy/acceptance_lifecycle_test.go)` | **完整覆盖**
依赖 Task 9 `GuardTerminationCoordinator` | +| **场景 15** | keepalive 注释/心跳与 guard 命中并发无 race、无旧缓冲残留、无重复终态帧 | `TestAcceptanceScenario15_ConcurrencyKeepaliveNoRace`
`(proxy/acceptance_lifecycle_test.go)` | **完整覆盖**
依赖 Task 3 `RWMutex` & Task 9 | +| **场景 16** | 所有恢复路径(ordinary retry, selective/catch-all, 续想)阻断,上游调用次数 `calls == 1` | `TestAcceptanceScenario16_RecoveryBlockedCallsExactlyOne`
`(proxy/acceptance_lifecycle_test.go)` | **完整覆盖**
依赖 Task 10 全链路恢复拦截 | +| **场景 17** | 未提交返回 502/协议错误;已提交返回终态帧;Chat 无 `[DONE]`;Gemini 原生错误 envelope | `TestAcceptanceScenario17_ProtocolErrorEnvelopesAndNoDone`
`(proxy/acceptance_lifecycle_test.go)` | **完整覆盖**
依赖 Task 9 `FormatGuardTerminalEnvelope` | +| **场景 18** | 生产 `TryLock`、`ResponseController.SetWriteDeadline(5s)` 写入与 `ErrNotSupported` 降级安全退出 | `TestAcceptanceScenario18_ProductionTryLockAndDeadlines`
`(proxy/acceptance_lifecycle_test.go)` | **完整覆盖**
依赖 Task 9 下游尽力写入原语 | +| **场景 19** | 不写成功 ResponseCache,无账号降分/冷却/refresh/prompt strike,不虚构 usage | `TestAcceptanceScenario19_ZeroPenaltyAndNoCachePollution`
`(proxy/acceptance_lifecycle_test.go)` | **完整覆盖**
依赖 Task 9 & Task 10 | +| **场景 20** | 请求与租约隔离:异常请求只终止自身,正常请求正常交付;上游异常 lease 废弃不复用 | `TestAcceptanceScenario20_RequestAndLeaseIsolation`
`(proxy/acceptance_lifecycle_test.go)` | **完整覆盖**
依赖 Task 3 & Task 10 | +| **场景 21** | SQLite/PostgreSQL 迁移默认开启,三态指针零值保护,恢复默认配置,并发窄更新防丢字段 | `TestAcceptanceScenario21_DBCurrencyAndRestoreDefaults`
`(proxy/acceptance_config_test.go)` | **完整覆盖**
依赖 Task 4 数据库层 | +| **场景 22** | 本实例即时生效、跨实例 5s 轮询同步、在途请求使用快照隔离、DB 故障保留 LKG、冷启动 503 拒绝 | `TestAcceptanceScenario22_RuntimePollerLKGAndColdStart503`
`(proxy/acceptance_config_test.go)` | **完整覆盖**
依赖 Task 5 运行时就绪门禁与轮询器 | +| **场景 23** | 传输层 Framing 保护独立于 guard 开关:关闭 guard,超大 SSE 行 (>8MiB) 或超大 Body 依然被限额拦截 | `TestAcceptanceScenario23_FramingGuardOffStillBounded`
`(proxy/acceptance_config_test.go)` | **完整覆盖**
依赖 Task 8 有界 Reader | +| **场景 24** | 1 MiB 正常文本 5 轮对比:p95 TTFT 增量目标 <= 10ms,真进程 CPU 开销增量目标 <= 20%(机器自动化断言) | `TestAcceptanceScenario24_TTFTAndCPUIncreases`
`(test/outputguard/acceptance_benchmark_test.go)` | **完整覆盖**
使用 `helpers.go` 真实 RUSAGE 计时器 | +| **场景 25** | 1/2/4 MiB 非重复样本 CPU 中位数增长严格近线性,每翻倍比值 <= 2.5(消除 O(N^2) 隐患) | `TestAcceptanceScenario25_LinearScalingRatios`
`(test/outputguard/acceptance_benchmark_test.go)` | **完整覆盖**
使用 `helpers.go` 线性度分析器 | +| **场景 26** | 每 channel 状态 <= 64 KiB,调用 `ReleaseWindow()` 释放 Ring Buffer;长跑无堆内存/Goroutine/FD 泄露 | `TestAcceptanceScenario26_ResourceLeakAndLongRunGate`
`(test/outputguard/acceptance_benchmark_test.go)` | **完整覆盖**
含 15m 环境变量门禁 `RUN_OUTPUTGUARD_LONG_RUN=1` | +| **场景 27** | 极端细 delta(单字节流式输出)、长思考、多工具交错触发 4096 deltas 专项压测分析 | `TestAcceptanceScenario27_FineGrainedDeltaAndReasoningPressure`
`(test/outputguard/acceptance_benchmark_test.go)` | **完整覆盖**
量化事件预算早于字节预算耗尽时机 | +| **场景 28** | 契约脱敏与日志隐私边界检查:日志与公开诊断仅记录脱敏 reason,绝不回显模型输出、提示词或 API Key | `TestAcceptanceScenario28_LogSanitizationBoundary`
`(test/outputguard/acceptance_benchmark_test.go)` | **完整覆盖**
验证契约脱敏边界 | diff --git a/docs/superpowers/plans/part_t1_t4.txt b/docs/superpowers/plans/part_t1_t4.txt new file mode 100644 index 000000000..63e688997 --- /dev/null +++ b/docs/superpowers/plans/part_t1_t4.txt @@ -0,0 +1,1727 @@ + +## Tasks + +### Task 1: 周期性短重复检测核心算法(Prefix Function 线性检测器) + +**Files:** +- Create: `security/outputguard/repetition.go` +- Test: `security/outputguard/repetition_test.go` + +**Interfaces:** +- Produces: + ```go + package outputguard + + const ( + WindowSizeBytes = 8192 + CheckpointStepBytes = 1024 + MaxRepetitionPeriod = 256 + MinRepetitionPeriod = 1 + ) + + type RepetitionDetector struct { + window [WindowSizeBytes]byte + count int64 // channel 累计写入总字节数 + head int // ring buffer 写入游标 (0..8191) + lastCheckedCheckpoint int64 + } + + func NewRepetitionDetector() *RepetitionDetector + func (d *RepetitionDetector) Push(data []byte) (hit bool, period int) + func (d *RepetitionDetector) CheckTail() (hit bool, period int) + func (d *RepetitionDetector) Reset() + ``` + +- [ ] **Step 1: Write failing test for repetition detector (including non-multiple periodic math verification and unaligned tail check)** + +```go +// security/outputguard/repetition_test.go +package outputguard + +import ( + "bytes" + "math/rand" + "testing" +) + +func TestRepetitionDetectorNormalText(t *testing.T) { + d := NewRepetitionDetector() + // 伪随机非周期数据 + r := rand.New(rand.NewSource(12345)) + nonRepeating := make([]byte, 10000) + r.Read(nonRepeating) + + hit, _ := d.Push(nonRepeating) + if hit { + t.Fatalf("expected no repetition hit for pseudo-random data, got hit=true") + } +} + +func TestRepetitionDetectorShortPeriodHit(t *testing.T) { + d := NewRepetitionDetector() + // 创建真实事故重复模式:周期 p = 30 ("_auditfree.md_pluginless.tmp.m") + // 数学核实重点:8192 % 30 = 22 != 0,验证有限串数学周期不要求 L % p == 0! + pattern := []byte("_auditfree.md_pluginless.tmp.m") + if len(pattern) != 30 { + t.Fatalf("pattern length = %d, want 30", len(pattern)) + } + var stream []byte + for len(stream) < 9500 { + stream = append(stream, pattern...) + } + + // 以 512 字节分块送入 + hitFound := false + var hitPeriod int + for offset := 0; offset < len(stream); offset += 512 { + end := offset + 512 + if end > len(stream) { + end = len(stream) + } + hit, p := d.Push(stream[offset:end]) + if hit { + hitFound = true + hitPeriod = p + break + } + } + + if !hitFound { + t.Fatalf("expected repetition hit for 30-byte periodic pattern (non-multiple of 8192), got hit=false") + } + if hitPeriod != 30 { + t.Fatalf("expected detected period 30, got %d", hitPeriod) + } +} + +func TestRepetitionDetectorTailCheckShortText(t *testing.T) { + d := NewRepetitionDetector() + // 短文本 < 8192 字节:即使内容高度周期,尾检必须返回 hit=false (Spec §5.2 规则 6) + pattern := bytes.Repeat([]byte("abc"), 500) // 1500 字节 + hit, _ := d.Push(pattern) + if hit { + t.Fatalf("expected hit=false during push for <8192 bytes") + } + hit, _ = d.CheckTail() + if hit { + t.Fatalf("expected hit=false for tail check with <8192 bytes total") + } +} + +func TestRepetitionDetectorTailCheckUnalignedWindow(t *testing.T) { + d := NewRepetitionDetector() + // 构造真实非对齐尾检样本: + // 前 108 字节为非周期杂散前缀,后随 8192 字节周期为 10 的重复片段,总长 8300 字节。 + // 在常规 Push 过程中: + // - 当累计达到 8192 字节时触发检查点,此时窗口包含 108 字节杂散前缀 + 8084 字节周期内容,不是完整周期,Push 判定 hit=false; + // - 后续送入剩余 108 字节(总长 8300 字节),尚未到达下一个 1024 检查点(9216 字节),Push 仍未命中; + // - 此时流结束触发 CheckTail():提取当前最新的 8192 字节窗口,恰好完全由周期 10 的数据组成,CheckTail 必须精准返回 hit=true, period=10。 + prefix := []byte("non-periodic-header-padding-1234567890-abcdefghijklmnopqrstuvwxyz!@#$%^&*()-random-preamble-bytes-to-offset-boundary-xxxx") + if len(prefix) != 108 { + t.Fatalf("prefix length = %d, want 108", len(prefix)) + } + pattern := []byte("0123456789") // period 10 + periodicPart := bytes.Repeat(pattern, 8192/10) + periodicPart = append(periodicPart, pattern[:8192%10]...) // 恰好 8192 字节纯周期 + if len(periodicPart) != 8192 { + t.Fatalf("periodicPart length = %d, want 8192", len(periodicPart)) + } + + fullStream := append(prefix, periodicPart...) // 总长 108 + 8192 = 8300 字节 + + // 分块推送,断言 Push 期间不会命中(因为 8192 检查点包含前缀,而后续未到 9216 检查点) + for offset := 0; offset < len(fullStream); offset += 500 { + end := offset + 500 + if end > len(fullStream) { + end = len(fullStream) + } + hit, _ := d.Push(fullStream[offset:end]) + if hit { + t.Fatalf("Push prematurely hit at offset %d, expected tail check to detect", offset) + } + } + + // 触发尾检 + hit, p := d.CheckTail() + if !hit { + t.Fatalf("expected CheckTail to detect pure periodic window of latest 8192 bytes, got hit=false") + } + if p != 10 { + t.Fatalf("expected detected period 10, got %d", p) + } +} + +func TestRepetitionDetectorConsecutive9215BytesGuaranteedHit(t *testing.T) { + d := NewRepetitionDetector() + // Spec §5.2 规则 3: 步长 1024B,任意起点的连续周期片段达到 9215B 时必存在检查点完整落入 + prefix := make([]byte, 512) // 任意偏置前缀 512B + for i := range prefix { + prefix[i] = byte(i % 127) + } + pattern := []byte("repeated_segment_32_bytes_test_") + periodicBody := bytes.Repeat(pattern, 300) // > 9215B + stream := append(prefix, periodicBody...) + + hit := false + for offset := 0; offset < len(stream); offset += 256 { + end := offset + 256 + if end > len(stream) { + end = len(stream) + } + if ok, _ := d.Push(stream[offset:end]); ok { + hit = true + break + } + } + if !hit { + t.Fatalf("expected 9215-byte periodic segment to trigger repetition hit within checkpoints") + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./security/outputguard -run "TestRepetitionDetector.*"` +Expected: FAIL with compilation error: undefined: NewRepetitionDetector + +- [ ] **Step 3: Write minimal implementation** + +```go +// security/outputguard/repetition.go +package outputguard + +const ( + WindowSizeBytes = 8192 + CheckpointStepBytes = 1024 + MaxRepetitionPeriod = 256 + MinRepetitionPeriod = 1 +) + +type RepetitionDetector struct { + window [WindowSizeBytes]byte + count int64 // channel 累计写入总字节数 + head int // ring buffer 写入游标 (0..8191) + lastCheckedCheckpoint int64 +} + +func NewRepetitionDetector() *RepetitionDetector { + return &RepetitionDetector{} +} + +func (d *RepetitionDetector) Reset() { + d.count = 0 + d.head = 0 + d.lastCheckedCheckpoint = 0 +} + +func (d *RepetitionDetector) Push(data []byte) (hit bool, period int) { + for _, b := range data { + d.window[d.head] = b + d.head = (d.head + 1) % WindowSizeBytes + d.count++ + + // 每新增 CheckpointStepBytes 到达检查点 + if d.count >= WindowSizeBytes && (d.count-d.lastCheckedCheckpoint) >= CheckpointStepBytes { + d.lastCheckedCheckpoint = d.count - (d.count % CheckpointStepBytes) + if ok, p := d.checkCurrentWindow(); ok { + return true, p + } + } + } + return false, 0 +} + +func (d *RepetitionDetector) CheckTail() (hit bool, period int) { + // Spec §5.2 规则 6: 如果 channel 累计总字节数 < 8192,不判定重复,直接放行 + if d.count < WindowSizeBytes { + return false, 0 + } + // 已满 8192 窗口但尾部未对齐 1024,提取最新的完整 8192 窗口执行一次求周期检查 + return d.checkCurrentWindow() +} + +// checkCurrentWindow 提取当前 ring buffer 中的线性 8192 字节,使用前缀函数求最短周期 1 <= p <= 256 +// 数学核实说明:有限字符串周期不要求 L % p == 0(例如事故周期 p=30, 8192 % 30 = 22 != 0)。 +// 前缀函数的最大 border k = pi[L-1] 对应最小周期 p = L - k。 +// 当且仅当 1 <= p <= 256 且满足全部 p <= i < 8192 的 window[i] == window[i-p] 时判定命中。 +func (d *RepetitionDetector) checkCurrentWindow() (bool, int) { + if d.count < WindowSizeBytes { + return false, 0 + } + // 展开当前最新的 8192 字节连续视图 + var linear [WindowSizeBytes]byte + idx := d.head // d.head 此时指向最老的那个字节 + for i := 0; i < WindowSizeBytes; i++ { + linear[i] = d.window[(idx+i)%WindowSizeBytes] + } + + // 采用 KMP prefix function (pi table) 在 O(N) 线性时间求周期 + pi := make([]int, WindowSizeBytes) + for i := 1; i < WindowSizeBytes; i++ { + j := pi[i-1] + for j > 0 && linear[i] != linear[j] { + j = pi[j-1] + } + if linear[i] == linear[j] { + j++ + } + pi[i] = j + } + + k := pi[WindowSizeBytes-1] + p := WindowSizeBytes - k + if p >= MinRepetitionPeriod && p <= MaxRepetitionPeriod { + // 校验边界:根据 border 定义,linear[0..k-1] == linear[p..L-1] + // 即对于所有 p <= i < 8192, 严格满足 linear[i] == linear[i-p] + return true, p + } + + return false, 0 +} +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./security/outputguard -run "TestRepetitionDetector.*"` +Expected: PASS ok github.com/codex2api/security/outputguard + +- [ ] **Step 5: Commit** + +```bash +git add security/outputguard/repetition.go security/outputguard/repetition_test.go +git commit -m "feat(outputguard): implement sliding window linear repetition detector" +``` + +--- + +### Task 2: Channel 状态机与扫描预算/纯终态确认配额机制 + +**Files:** +- Create: `security/outputguard/channel.go` +- Test: `security/outputguard/channel_test.go` + +**Interfaces:** +- Consumes: `RepetitionDetector` from Task 1 +- Produces: + ```go + package outputguard + + type ChannelKind string + const ( + KindText ChannelKind = "text" + KindThinking ChannelKind = "thinking" + KindToolArguments ChannelKind = "tool_arguments" + ) + + type ChannelGuard struct { + id string + kind ChannelKind + maxBytes int64 + maxDeltas int + remainingScanBudget int64 + highwater int64 + deltaCount int + terminalBaselineHash uint64 + hasBaselineHash bool + terminalPermitsUsed int + consumedTerminalTypes map[string]bool + detector *RepetitionDetector + isClosed bool + } + + func NewChannelGuard(id string, kind ChannelKind, maxBytes int64, maxDeltas int) *ChannelGuard + func (c *ChannelGuard) Highwater() int64 + func (c *ChannelGuard) DeltaCount() int + func (c *ChannelGuard) RemainingScanBudget() int64 + func (c *ChannelGuard) ReleaseWindow() + func (c *ChannelGuard) CheckAppend(delta []byte) (growth int64, cost int64, err error) + func (c *ChannelGuard) CommitAppend(delta []byte, growth int64, cost int64) error + func (c *ChannelGuard) CheckSnapshot(snapshot []byte, terminalStage string) (growth int64, cost int64, skipRepetition bool, err error) + func (c *ChannelGuard) CommitSnapshot(snapshot []byte, growth int64, cost int64, terminalStage string) error + func (c *ChannelGuard) CheckTail() error + ``` + +- [ ] **Step 1: Write failing test for channel state machine and quota** + +```go +// security/outputguard/channel_test.go +package outputguard + +import ( + "bytes" + "testing" +) + +func TestChannelGuardHighwaterGrowthAndScanBudget(t *testing.T) { + // 工具 channel: maxBytes = 128 KiB (131072), scanBudget = 2 * 128 KiB = 256 KiB + cg := NewChannelGuard("call_1", KindToolArguments, 131072, 4096) + + // Append 64 KiB of non-repeating data + delta1 := make([]byte, 65536) + for i := range delta1 { + delta1[i] = byte((i * 17) % 251) + } + + growth, cost, err := cg.CheckAppend(delta1) + if err != nil { + t.Fatalf("CheckAppend delta1 error: %v", err) + } + if growth != 65536 || cost != 65536 { + t.Fatalf("delta1: want growth=65536, cost=65536, got growth=%d, cost=%d", growth, cost) + } + if err := cg.CommitAppend(delta1, growth, cost); err != nil { + t.Fatalf("CommitAppend delta1 error: %v", err) + } + + if cg.Highwater() != 65536 { + t.Fatalf("want highwater=65536, got %d", cg.Highwater()) + } + if cg.RemainingScanBudget() != 262144-65536 { + t.Fatalf("want remainingScanBudget=%d, got %d", 262144-65536, cg.RemainingScanBudget()) + } +} + +// Spec §9.1 Case 8a: 耗尽扫描预算后变体快照超额拦截 +func TestChannelGuardCase8a_ScanBudgetExhaustionAndVariantRejection(t *testing.T) { + cg := NewChannelGuard("call_1", KindToolArguments, 131072, 4096) + // 以 128 次 1 KiB delta 耗尽 128 KiB 扫描预算 (剩余 128 KiB) + chunk := make([]byte, 1024) + for i := range chunk { + chunk[i] = byte((i*7 + 3) % 251) + } + var fullParam []byte + for i := 0; i < 128; i++ { + fullParam = append(fullParam, chunk...) + growth, cost, err := cg.CheckAppend(chunk) + if err != nil { + t.Fatalf("step %d append error: %v", i, err) + } + if err := cg.CommitAppend(chunk, growth, cost); err != nil { + t.Fatalf("step %d commit append error: %v", i, err) + } + } + if cg.RemainingScanBudget() != 131072 { + t.Fatalf("remaining scan budget want 131072, got %d", cg.RemainingScanBudget()) + } + + // 首个 128 KiB 白名单终态快照到达 (arguments.done),尚无 baseline hash,cost = 128 KiB + growth, cost, skipRepetition, err := cg.CheckSnapshot(fullParam, "arguments.done") + if err != nil { + t.Fatalf("arguments.done snapshot error: %v", err) + } + if cost != 131072 || growth != 0 || skipRepetition { + t.Fatalf("arguments.done: want cost=131072, growth=0, skipRepetition=false; got cost=%d, growth=%d, skip=%v", cost, growth, skipRepetition) + } + if err := cg.CommitSnapshot(fullParam, growth, cost, "arguments.done"); err != nil { + t.Fatalf("arguments.done commit snapshot error: %v", err) + } + + if cg.RemainingScanBudget() != 0 { + t.Fatalf("remaining scan budget want 0, got %d", cg.RemainingScanBudget()) + } + + // 紧接着收到修改了 1 个字节的 128 KiB 变体快照 (item.done) + variantParam := bytes.Clone(fullParam) + variantParam[65536] ^= 0xFF + + _, _, _, err = cg.CheckSnapshot(variantParam, "item.done") + if err == nil { + t.Fatalf("expected variant snapshot to be rejected with scan budget exhaustion, got nil") + } + if err != ErrScanBudgetExhausted { + t.Fatalf("expected ErrScanBudgetExhausted, got %v", err) + } +} + +// Spec §9.1 Case 8b: 合法终态链零计费放行与无资格拒绝 +func TestChannelGuardCase8b_TerminalChainZeroCostAndExhaustion(t *testing.T) { + cg := NewChannelGuard("call_1", KindToolArguments, 131072, 4096) + fullParam := make([]byte, 131072) + for i := range fullParam { + fullParam[i] = byte((i*13 + 5) % 251) + } + // append 128 KiB + growth, cost, _ := cg.CheckAppend(fullParam) + _ = cg.CommitAppend(fullParam, growth, cost) + + // arguments.done 消耗最后 128 KiB 扫描预算并建立 baseline hash + growth, cost, _, _ = cg.CheckSnapshot(fullParam, "arguments.done") + _ = cg.CommitSnapshot(fullParam, growth, cost, "arguments.done") + + // item.done 具备资格且 Hash 完全一致 -> cost = 0, skipRepetition = true + growth, cost, skipRepetition, err := cg.CheckSnapshot(fullParam, "item.done") + if err != nil { + t.Fatalf("item.done snapshot error: %v", err) + } + if cost != 0 || !skipRepetition { + t.Fatalf("item.done: want cost=0, skipRepetition=true; got cost=%d, skip=%v", cost, skipRepetition) + } + _ = cg.CommitSnapshot(fullParam, growth, cost, "item.done") + + // response.completed 同样具备资格且 Hash 一致 -> cost = 0 + growth, cost, skipRepetition, err = cg.CheckSnapshot(fullParam, "response.completed") + if err != nil { + t.Fatalf("response.completed snapshot error: %v", err) + } + if cost != 0 || !skipRepetition { + t.Fatalf("response.completed: want cost=0, skipRepetition=true") + } + _ = cg.CommitSnapshot(fullParam, growth, cost, "response.completed") + + // 重发同类型 response.completed -> 失去资格,走 cost = 128 KiB,因剩余扫描预算为 0 被拦截 + _, _, _, err = cg.CheckSnapshot(fullParam, "response.completed") + if err != ErrScanBudgetExhausted { + t.Fatalf("duplicate response.completed: want ErrScanBudgetExhausted, got %v", err) + } +} + +// Spec §9.1 Case 8c: 非白名单普通快照绝不建立 baseline +func TestChannelGuardCase8c_NonTerminalSnapshotCannotEstablishBaseline(t *testing.T) { + cg := NewChannelGuard("c_text", KindText, 2097152, 4096) + snap := []byte("intermediate full snapshot text") + growth, cost, skip, err := cg.CheckSnapshot(snap, "unknown_or_intermediate_type") + if err != nil { + t.Fatalf("CheckSnapshot error: %v", err) + } + if skip || cost != int64(len(snap)) { + t.Fatalf("want skip=false, cost=%d, got skip=%v, cost=%d", len(snap), skip, cost) + } + _ = cg.CommitSnapshot(snap, growth, cost, "unknown_or_intermediate_type") + + // 确认 hasBaselineHash 仍为 false,未建立 baseline + if cg.hasBaselineHash { + t.Fatalf("expected hasBaselineHash=false for non-terminal snapshot, got true") + } +} + +func TestChannelGuardHashMismatchDiscardsQuotaAndChargesFullCost(t *testing.T) { + cg := NewChannelGuard("c_tool", KindToolArguments, 131072, 4096) + data1 := bytes.Repeat([]byte("hello world "), 100) // 1200 字节 + growth, cost, _, _ := cg.CheckSnapshot(data1, "arguments.done") + _ = cg.CommitSnapshot(data1, growth, cost, "arguments.done") + + if !cg.hasBaselineHash { + t.Fatalf("expected baseline hash created") + } + + // 紧接着发送篡改变体快照 + data2 := bytes.Repeat([]byte("corrupted data!"), 80) // 1200 字节 + growth, cost, skip, err := cg.CheckSnapshot(data2, "item.done") + if err != nil { + t.Fatalf("unexpected CheckSnapshot error: %v", err) + } + if skip { + t.Fatalf("expected skip=false for hash mismatch") + } + if cost != int64(len(data2)) { + t.Fatalf("expected full cost %d, got %d", len(data2), cost) + } + + // CommitSnapshot 篡改帧后,该类型许可必须标记废弃 + _ = cg.CommitSnapshot(data2, growth, cost, "item.done") + if !cg.consumedTerminalTypes["item.done"] { + t.Fatalf("expected item.done marked as consumed/discarded") + } + // 基准 Hash 绝不能被篡改快照覆盖 + h := uint64(0) + for _, b := range data1 { + h = (h ^ uint64(b)) * 1099511628211 + } + if cg.terminalBaselineHash != h { + t.Fatalf("expected baseline hash to stay unchanged") + } +} + +func TestChannelGuardTailCheck(t *testing.T) { + cg := NewChannelGuard("c_text", KindText, 2097152, 4096) + // 短文本放行 + _ = cg.CommitAppend([]byte("short text"), 10, 10) + if err := cg.CheckTail(); err != nil { + t.Fatalf("expected nil error for short text tail check, got %v", err) + } + + // 长周期文本检出 + cg2 := NewChannelGuard("c_periodic", KindText, 2097152, 4096) + pattern := []byte("0123456789") + data := bytes.Repeat(pattern, 850) // 8500 字节 + // 避免在 append 触发检查点,直接写入 window + cg2.detector.window = [8192]byte{} + for i := 0; i < 8500; i++ { + cg2.detector.window[i%8192] = pattern[i%10] + } + cg2.detector.count = 8500 + cg2.detector.head = 8500 % 8192 + + if err := cg2.CheckTail(); err != ErrRepetitionDetected { + t.Fatalf("expected ErrRepetitionDetected for periodic tail check, got %v", err) + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./security/outputguard -run "TestChannelGuard.*"` +Expected: FAIL with compilation error: undefined: NewChannelGuard + +- [ ] **Step 3: Write minimal implementation** + +```go +// security/outputguard/channel.go +package outputguard + +import ( + "errors" +) + +var ( + ErrToolArgumentBytesExceeded = errors.New("tool_argument_bytes") + ErrChannelBytesExceeded = errors.New("channel_bytes") + ErrScanBudgetExhausted = errors.New("scan_budget_exhausted") + ErrRepetitionDetected = errors.New("repetition") + ErrToolDeltasExceeded = errors.New("tool_argument_deltas") +) + +type ChannelKind string + +const ( + KindText ChannelKind = "text" + KindThinking ChannelKind = "thinking" + KindToolArguments ChannelKind = "tool_arguments" +) + +// allowedTerminalTypes 白名单终态类型(Spec §5.3) +var allowedTerminalTypes = map[string]bool{ + "arguments.done": true, + "item.done": true, + "response.completed": true, +} + +// fnv64a 计算 64 位 FNV-1a Hash,用于流水线单遍历比对与基准记录 +func fnv64a(data []byte) uint64 { + var h uint64 = 14695981039346656037 + for _, b := range data { + h = (h ^ uint64(b)) * 1099511628211 + } + return h +} + +type ChannelGuard struct { + id string + kind ChannelKind + maxBytes int64 + maxDeltas int + remainingScanBudget int64 + highwater int64 + deltaCount int + terminalBaselineHash uint64 + hasBaselineHash bool + terminalPermitsUsed int + consumedTerminalTypes map[string]bool + detector *RepetitionDetector + isClosed bool +} + +func NewChannelGuard(id string, kind ChannelKind, maxBytes int64, maxDeltas int) *ChannelGuard { + return &ChannelGuard{ + id: id, + kind: kind, + maxBytes: maxBytes, + maxDeltas: maxDeltas, + remainingScanBudget: 2 * maxBytes, // 累计扫描预算为 2 * limit (Spec §5.3) + consumedTerminalTypes: make(map[string]bool), + detector: NewRepetitionDetector(), + } +} + +func (c *ChannelGuard) Highwater() int64 { return c.highwater } +func (c *ChannelGuard) DeltaCount() int { return c.deltaCount } +func (c *ChannelGuard) RemainingScanBudget() int64 { return c.remainingScanBudget } + +func (c *ChannelGuard) ReleaseWindow() { + c.detector = nil + c.isClosed = true +} + +func (c *ChannelGuard) CheckAppend(delta []byte) (growth int64, cost int64, err error) { + deltaLen := int64(len(delta)) + if c.kind == KindToolArguments && deltaLen > 0 { + if c.deltaCount+1 > c.maxDeltas { + return 0, 0, ErrToolDeltasExceeded + } + } + newHighwater := c.highwater + deltaLen + if newHighwater > c.maxBytes { + if c.kind == KindToolArguments { + return 0, 0, ErrToolArgumentBytesExceeded + } + return 0, 0, ErrChannelBytesExceeded + } + + cost = deltaLen + if c.remainingScanBudget < cost { + return 0, 0, ErrScanBudgetExhausted + } + + return deltaLen, cost, nil +} + +func (c *ChannelGuard) CommitAppend(delta []byte, growth int64, cost int64) error { + c.highwater += growth + if c.kind == KindToolArguments && len(delta) > 0 { + c.deltaCount++ + } + c.remainingScanBudget -= cost + + if c.detector != nil && len(delta) > 0 { + hit, _ := c.detector.Push(delta) + if hit { + return ErrRepetitionDetected + } + } + return nil +} + +func (c *ChannelGuard) CheckSnapshot(snapshot []byte, terminalStage string) (growth int64, cost int64, skipRepetition bool, err error) { + snapLen := int64(len(snapshot)) + if snapLen > c.highwater { + growth = snapLen - c.highwater + } + if snapLen > c.maxBytes { + if c.kind == KindToolArguments { + return 0, 0, false, ErrToolArgumentBytesExceeded + } + return 0, 0, false, ErrChannelBytesExceeded + } + + // 资格检查:仅白名单终态类型且具备 baseline hash 且许可未耗尽 (Spec §5.3 步骤 5) + // 合法与非法终态配额判断严格在 hash 前!无资格绝不提前计算 hash。 + isEligible := c.hasBaselineHash && + allowedTerminalTypes[terminalStage] && + !c.consumedTerminalTypes[terminalStage] && + c.terminalPermitsUsed < 3 + + if isEligible { + // 计算 snapshot Hash 比对 baseline hash + currentHash := fnv64a(snapshot) + if currentHash == c.terminalBaselineHash { + // Hash 完全一致,纯终态确认快照零计费放行,跳过步骤 7 重复扫描 + return growth, 0, true, nil + } + // Hash 不一致(变体篡改),作废该类型许可,全额计费 + cost = snapLen + } else { + // 无免除资格(包括中间替换、首帧终态、重发同类型或超出 3 次配额),严禁提前 Hash,直接全额计费 + cost = snapLen + } + + if c.remainingScanBudget < cost { + return 0, 0, false, ErrScanBudgetExhausted + } + + return growth, cost, false, nil +} + +func (c *ChannelGuard) CommitSnapshot(snapshot []byte, growth int64, cost int64, terminalStage string) error { + c.highwater += growth + c.remainingScanBudget -= cost + + // 白名单终态类型快照消耗本类型配额 + if allowedTerminalTypes[terminalStage] { + c.consumedTerminalTypes[terminalStage] = true + c.terminalPermitsUsed++ + } + + // 终态首帧建立 baseline hash:仅白名单终态帧类型允许在首帧建立 baseline hash (Spec §5.3) + // 如果之前未建立 baseline 且当前属于白名单类型,在单次遍历中建立 baseline hash + shouldSetBaseline := !c.hasBaselineHash && allowedTerminalTypes[terminalStage] + + if cost > 0 && c.detector != nil { + c.detector.Reset() + + if shouldSetBaseline { + // 单次流水线处理:一次遍历同时送入 detector.Push 并同步更新 Hash + var h uint64 = 14695981039346656037 + for _, b := range snapshot { + h = (h ^ uint64(b)) * 1099511628211 + } + c.terminalBaselineHash = h + c.hasBaselineHash = true + } + + hit, _ := c.detector.Push(snapshot) + if hit { + return ErrRepetitionDetected + } + hit, _ = c.detector.CheckTail() + if hit { + return ErrRepetitionDetected + } + } + + return nil +} + +func (c *ChannelGuard) CheckTail() error { + if c.detector != nil { + if hit, _ := c.detector.CheckTail(); hit { + return ErrRepetitionDetected + } + } + return nil +} +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./security/outputguard -run "TestChannelGuard.*"` +Expected: PASS ok github.com/codex2api/security/outputguard + +- [ ] **Step 5: Commit** + +```bash +git add security/outputguard/channel.go security/outputguard/channel_test.go +git commit -m "feat(outputguard): implement channel guard with scan budget and terminal quota" +``` + +--- + +### Task 3: 请求级 RequestGuard 协调器与统一判定优先级及并发契约 + +**Files:** +- Create: `security/outputguard/guard.go` +- Test: `security/outputguard/guard_test.go` + +**Interfaces:** +- Consumes: `ChannelGuard` from Task 2 +- Produces: + ```go + package outputguard + + import "sync" + + const ( + MaxRequestEvents = 65536 + MaxRequestChannels = 128 + TriggeredCode = "model_output_guard_triggered" + ) + + type Config struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 + } + + type RequestGuard struct { + mu sync.RWMutex + cfg Config + requestEvents int + requestBytes int64 + channels map[string]*ChannelGuard + aliasMap map[string]string // alias mapping (e.g. index -> call_id) + tripped bool + trippedReason string + } + + func NewRequestGuard(cfg Config) *RequestGuard + func (g *RequestGuard) ObserveAppend(channelID string, aliasID string, kind ChannelKind, delta []byte) error + func (g *RequestGuard) ObserveSnapshot(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error + func (g *RequestGuard) EndChannel(channelID string) error + func (g *RequestGuard) IsTripped() bool + func (g *RequestGuard) TrippedReason() string + ``` + +- [ ] **Step 1: Write failing test for RequestGuard (including 8-step precedence, first reason freeze, EndChannel tail check, and concurrency race)** + +```go +// security/outputguard/guard_test.go +package outputguard + +import ( + "bytes" + "fmt" + "sync" + "testing" +) + +func defaultTestConfig() Config { + return Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, // 128 KiB + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, // 2 MiB + MaxRequestBytes: 4194304, // 4 MiB + } +} + +func TestRequestGuardRequestEventsFlood(t *testing.T) { + rg := NewRequestGuard(defaultTestConfig()) + delta := []byte("a") + for i := 0; i < MaxRequestEvents; i++ { + if err := rg.ObserveAppend("c1", "", KindText, delta); err != nil { + t.Fatalf("unexpected error at event %d: %v", i, err) + } + } + // 第 65537 个事件必须被拒绝并判定为 request_events (Spec §5.3 步骤 1) + err := rg.ObserveAppend("c1", "", KindText, delta) + if err == nil || err.Error() != "request_events" { + t.Fatalf("expected request_events, got %v", err) + } + if !rg.IsTripped() || rg.TrippedReason() != "request_events" { + t.Fatalf("expected tripped with request_events, got %s", rg.TrippedReason()) + } +} + +func TestRequestGuardChannelFlood(t *testing.T) { + rg := NewRequestGuard(defaultTestConfig()) + // 验证累计 128 个 Channel 限制: + // 第二轮核实修正:消除未定义符号 'A',使用 fmt.Sprintf 或安全字符生成独立 channel ID + for i := 0; i < MaxRequestChannels; i++ { + cid := fmt.Sprintf("chan_%d", i) + if err := rg.ObserveAppend(cid, "", KindText, []byte("x")); err != nil { + t.Fatalf("channel %d error: %v", i, err) + } + } + // 第 129 个 Channel 必须被拒绝并判定为 channel_count (Spec §5.3 步骤 2) + err := rg.ObserveAppend("channel_129", "", KindText, []byte("x")) + if err == nil || err.Error() != "channel_count" { + t.Fatalf("expected channel_count, got %v", err) + } +} + +func TestRequestGuardChannelCumulativeNotReturnedOnEnd(t *testing.T) { + rg := NewRequestGuard(defaultTestConfig()) + // 验证 EndChannel 结束 channel 不返还累计 channel 配额 (Spec §5.1) + for i := 0; i < MaxRequestChannels; i++ { + cid := fmt.Sprintf("chan_%d", i) + _ = rg.ObserveAppend(cid, "", KindText, []byte("x")) + _ = rg.EndChannel(cid) + } + // 尝试再开一个 channel,必须被拒绝为 channel_count + err := rg.ObserveAppend("extra_channel", "", KindText, []byte("x")) + if err == nil || err.Error() != "channel_count" { + t.Fatalf("expected channel_count rejection after 128 closed channels, got %v", err) + } +} + +func TestRequestGuardEndChannelExecutesTailCheck(t *testing.T) { + rg := NewRequestGuard(defaultTestConfig()) + // 构造在 Push 时未触发检查点但流结束时已包含 8192 字节周期的流 + pattern := []byte("0123456789") + periodicBody := bytes.Repeat(pattern, 8192/10) + periodicBody = append(periodicBody, pattern[:8192%10]...) // 恰好 8192B 周期 + // 先推送 100 字节杂散 + prefix := make([]byte, 100) + for i := range prefix { + prefix[i] = byte(i) + } + full := append(prefix, periodicBody...) // 8292 字节,未达下一个 1024 检查点 + + // 推送数据,Push 期间不触发 repetition(8192 检查点含 prefix 杂散,8292 未到检查点) + for offset := 0; offset < len(full); offset += 500 { + end := offset + 500 + if end > len(full) { + end = len(full) + } + _ = rg.ObserveAppend("c_tail", "", KindText, full[offset:end]) + } + if rg.IsTripped() { + t.Fatalf("expected not tripped before EndChannel") + } + + // 调用 EndChannel:必须先执行尾检,发现周期性短重复,成功拦截并冻结 repetition! + err := rg.EndChannel("c_tail") + if err == nil || err.Error() != "repetition" { + t.Fatalf("expected repetition hit during EndChannel tail check, got %v", err) + } + if !rg.IsTripped() || rg.TrippedReason() != "repetition" { + t.Fatalf("expected tripped with repetition, got %s", rg.TrippedReason()) + } +} + +func TestRequestGuardRequestBytesPrecedenceOverScanBudget(t *testing.T) { + // Spec §5.3 严格 8 步优先级:先步骤 4 语义字节预算检查 (request_bytes),后步骤 6 扫描预算 + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 1000, // 仅 1000 字节请求上限 + }) + + // 一次传入 1500 字节快照 -> 必须报 request_bytes,而非 channel_bytes 或 scan_budget + err := rg.ObserveSnapshot("c1", "", KindText, make([]byte, 1500), "") + if err == nil || err.Error() != "request_bytes" { + t.Fatalf("expected request_bytes priority over scan budget, got %v", err) + } + // 首个 reason 必须冻结 + if rg.TrippedReason() != "request_bytes" { + t.Fatalf("expected frozen reason request_bytes, got %s", rg.TrippedReason()) + } + + // 再次调用仍然返回首个冻结 reason + err2 := rg.ObserveAppend("c1", "", KindText, []byte("x")) + if err2 == nil || err2.Error() != "request_bytes" { + t.Fatalf("expected idempotent frozen reason request_bytes, got %v", err2) + } +} + +func TestRequestGuardToolArgumentDeltasFlood(t *testing.T) { + rg := NewRequestGuard(Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 10, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + + for i := 0; i < 10; i++ { + if err := rg.ObserveAppend("call_1", "", KindToolArguments, []byte("a")); err != nil { + t.Fatalf("delta %d error: %v", i, err) + } + } + // 第 11 个非空 delta 必须被拦截为 tool_argument_deltas (Spec §5.3 步骤 3) + err := rg.ObserveAppend("call_1", "", KindToolArguments, []byte("a")) + if err == nil || err.Error() != "tool_argument_deltas" { + t.Fatalf("expected tool_argument_deltas, got %v", err) + } +} + +func TestRequestGuardConcurrentAppendAndIsTrippedNoRace(t *testing.T) { + rg := NewRequestGuard(defaultTestConfig()) + var wg sync.WaitGroup + + // 并发写 + for i := 0; i < 10; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + cid := fmt.Sprintf("c_concurrent_%d", id%3) + for j := 0; j < 50; j++ { + _ = rg.ObserveAppend(cid, "", KindText, []byte("a")) + } + }(i) + } + + // 并发读状态 + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 50; j++ { + _ = rg.IsTripped() + _ = rg.TrippedReason() + } + }() + } + + wg.Wait() +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race ./security/outputguard -run "TestRequestGuard.*"` +Expected: FAIL with compilation error: undefined: NewRequestGuard + +- [ ] **Step 3: Write minimal implementation** + +```go +// security/outputguard/guard.go +package outputguard + +import ( + "errors" + "sync" +) + +const ( + MaxRequestEvents = 65536 + MaxRequestChannels = 128 + TriggeredCode = "model_output_guard_triggered" +) + +type Config struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 +} + +type RequestGuard struct { + mu sync.RWMutex + cfg Config + requestEvents int + requestBytes int64 + channels map[string]*ChannelGuard + aliasMap map[string]string + tripped bool + trippedReason string +} + +func NewRequestGuard(cfg Config) *RequestGuard { + return &RequestGuard{ + cfg: cfg, + channels: make(map[string]*ChannelGuard), + aliasMap: make(map[string]string), + } +} + +func (g *RequestGuard) IsTripped() bool { + g.mu.RLock() + defer g.mu.RUnlock() + return g.tripped +} + +func (g *RequestGuard) TrippedReason() string { + g.mu.RLock() + defer g.mu.RUnlock() + return g.trippedReason +} + +func (g *RequestGuard) tripLocked(reason string) error { + if !g.tripped { + g.tripped = true + g.trippedReason = reason + } + return errors.New(g.trippedReason) +} + +func (g *RequestGuard) resolveChannelLocked(channelID, aliasID string, kind ChannelKind) (*ChannelGuard, error) { + targetID := channelID + if targetID == "" && aliasID != "" { + if mapped, ok := g.aliasMap[aliasID]; ok { + targetID = mapped + } else { + targetID = aliasID + } + } + if targetID == "" { + targetID = "default" + } + if aliasID != "" && targetID != aliasID { + g.aliasMap[aliasID] = targetID + } + + cg, exists := g.channels[targetID] + if !exists { + // 步骤 2: Channel 数量检查 (Spec §5.3) + if len(g.channels)+1 > MaxRequestChannels { + return nil, g.tripLocked("channel_count") + } + maxBytes := g.cfg.MaxChannelBytes + if kind == KindToolArguments { + maxBytes = g.cfg.MaxToolArgumentBytes + } + cg = NewChannelGuard(targetID, kind, maxBytes, g.cfg.MaxToolArgumentDeltas) + g.channels[targetID] = cg + } + return cg, nil +} + +// ObserveAppend 严格执行 Spec §5.3 优先级流水线(先字节高水位与 request_bytes 累加判定,再资格/成本,首 reason 冻结) +func (g *RequestGuard) ObserveAppend(channelID string, aliasID string, kind ChannelKind, delta []byte) error { + g.mu.Lock() + defer g.mu.Unlock() + + if !g.cfg.Enabled { + return nil + } + if g.tripped { + return errors.New(g.trippedReason) + } + + // 步骤 1: 事件预算检查 + g.requestEvents++ + if g.requestEvents > MaxRequestEvents { + return g.tripLocked("request_events") + } + + // 步骤 2: Channel 检查 + cg, err := g.resolveChannelLocked(channelID, aliasID, kind) + if err != nil { + return err + } + + // 步骤 3 & 4: Delta 计数与语义字节高水位检查 + growth, cost, err := cg.CheckAppend(delta) + if err != nil { + if errors.Is(err, ErrScanBudgetExhausted) { + if cg.kind == KindToolArguments { + return g.tripLocked("tool_argument_bytes") + } + return g.tripLocked("channel_bytes") + } + return g.tripLocked(err.Error()) + } + + if g.requestBytes+growth > g.cfg.MaxRequestBytes { + return g.tripLocked("request_bytes") + } + + // 步骤 5, 6, 7: 提交高水位并执行周期扫描 + g.requestBytes += growth + if err := cg.CommitAppend(delta, growth, cost); err != nil { + return g.tripLocked(err.Error()) + } + + return nil +} + +// ObserveSnapshot 严格执行 Spec §5.3 快照判定流水线 +func (g *RequestGuard) ObserveSnapshot(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error { + g.mu.Lock() + defer g.mu.Unlock() + + if !g.cfg.Enabled { + return nil + } + if g.tripped { + return errors.New(g.trippedReason) + } + + // 步骤 1: 事件预算检查 + g.requestEvents++ + if g.requestEvents > MaxRequestEvents { + return g.tripLocked("request_events") + } + + // 步骤 2: Channel 检查 + cg, err := g.resolveChannelLocked(channelID, aliasID, kind) + if err != nil { + return err + } + + // 步骤 4: 语义字节预算检查 (先检查高水位推进与 request_bytes 上限) + snapLen := int64(len(snapshot)) + var growth int64 + if snapLen > cg.highwater { + growth = snapLen - cg.highwater + } + if snapLen > cg.maxBytes { + if cg.kind == KindToolArguments { + return g.tripLocked("tool_argument_bytes") + } + return g.tripLocked("channel_bytes") + } + if g.requestBytes+growth > g.cfg.MaxRequestBytes { + return g.tripLocked("request_bytes") + } + + // 步骤 5 & 6: 资格/等价性检查与扫描预算扣减 + growth, cost, _, err := cg.CheckSnapshot(snapshot, terminalStage) + if err != nil { + if errors.Is(err, ErrScanBudgetExhausted) { + if cg.kind == KindToolArguments { + return g.tripLocked("tool_argument_bytes") + } + return g.tripLocked("channel_bytes") + } + return g.tripLocked(err.Error()) + } + + // 步骤 7: 提交高水位与扫描 + g.requestBytes += growth + if err := cg.CommitSnapshot(snapshot, growth, cost, terminalStage); err != nil { + return g.tripLocked(err.Error()) + } + + return nil +} + +// EndChannel 在关闭 channel 前执行尾检,释放窗口内存,累计 channel 映射不返还 +func (g *RequestGuard) EndChannel(channelID string) error { + g.mu.Lock() + defer g.mu.Unlock() + + if cg, exists := g.channels[channelID]; exists { + // 先执行尾检 (Spec §5.2 规则 6) + if err := cg.CheckTail(); err != nil { + return g.tripLocked(err.Error()) + } + // 尾检通过后安全释放滑动窗口内存 (<=64KiB) + cg.ReleaseWindow() + } + return nil +} +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race ./security/outputguard -run "TestRequestGuard.*"` +Expected: PASS ok github.com/codex2api/security/outputguard + +- [ ] **Step 5: Commit** + +```bash +git add security/outputguard/guard.go security/outputguard/guard_test.go +git commit -m "feat(outputguard): implement request guard coordinator with 8-step precedence and RWMutex" +``` + +--- + +### Task 4: 数据库表结构迁移与三态指针持久化(SQLite & PostgreSQL) + +**Files:** +- Create: `database/output_guard_settings.go` +- Modify: `database/sqlite.go:730-740` +- Modify: `database/postgres.go:1600-1610,3230-3240` +- Test: `database/output_guard_settings_test.go` + +**Interfaces:** +- Produces: + ```go + package database + + type OutputGuardSettings struct { + Enabled *bool `json:"output_guard_enabled"` + MaxToolArgumentBytes int64 `json:"output_guard_max_tool_argument_bytes"` + MaxToolArgumentDeltas int `json:"output_guard_max_tool_argument_deltas"` + MaxChannelBytes int64 `json:"output_guard_max_channel_bytes"` + MaxRequestBytes int64 `json:"output_guard_max_request_bytes"` + ConfigGeneration int64 `json:"output_guard_config_generation"` + } + + type OutputGuardSettingsUpdate struct { + Enabled *bool + MaxToolArgumentBytes *int64 + MaxToolArgumentDeltas *int + MaxChannelBytes *int64 + MaxRequestBytes *int64 + } + + func (db *DB) GetOutputGuardSettings(ctx context.Context) (OutputGuardSettings, error) + func (db *DB) UpdateOutputGuardSettings(ctx context.Context, update OutputGuardSettingsUpdate) (OutputGuardSettings, error) + ``` + +- [ ] **Step 1: Write failing test for OutputGuardSettings migration, tri-state persistence, generation overflow protection, and narrow update isolation** + +```go +// database/output_guard_settings_test.go +package database + +import ( + "context" + "errors" + "math" + "os" + "sync" + "testing" +) + +func runOnAllDBDrivers(t *testing.T, testFn func(t *testing.T, db *DB)) { + t.Run("sqlite", func(t *testing.T) { + db := newResponseCacheSettingsTestDB(t) + testFn(t, db) + }) + + if dsn := os.Getenv("CODEX2API_TEST_POSTGRES_DSN"); dsn != "" { + t.Run("postgres", func(t *testing.T) { + db, err := New("postgres", dsn) + if err != nil { + t.Fatalf("New(postgres) error = %v", err) + } + defer db.Close() + testFn(t, db) + }) + } +} + +func TestOutputGuardSettingsFreshDefaults(t *testing.T) { + runOnAllDBDrivers(t, func(t *testing.T, db *DB) { + got, err := db.GetOutputGuardSettings(context.Background()) + if err != nil { + t.Fatalf("GetOutputGuardSettings error = %v", err) + } + + if got.Enabled == nil || *got.Enabled != true { + t.Fatalf("default Enabled want true, got %v", got.Enabled) + } + if got.MaxToolArgumentBytes != 131072 { + t.Fatalf("default MaxToolArgumentBytes want 131072, got %d", got.MaxToolArgumentBytes) + } + if got.MaxToolArgumentDeltas != 4096 { + t.Fatalf("default MaxToolArgumentDeltas want 4096, got %d", got.MaxToolArgumentDeltas) + } + if got.MaxChannelBytes != 2097152 { + t.Fatalf("default MaxChannelBytes want 2097152, got %d", got.MaxChannelBytes) + } + if got.MaxRequestBytes != 4194304 { + t.Fatalf("default MaxRequestBytes want 4194304, got %d", got.MaxRequestBytes) + } + if got.ConfigGeneration < 1 { + t.Fatalf("default ConfigGeneration want >= 1, got %d", got.ConfigGeneration) + } + }) +} + +func TestOutputGuardSettingsExplicitFalseTriState(t *testing.T) { + runOnAllDBDrivers(t, func(t *testing.T, db *DB) { + ctx := context.Background() + + disabled := false + updated, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + Enabled: &disabled, + }) + if err != nil { + t.Fatalf("UpdateOutputGuardSettings error = %v", err) + } + if updated.Enabled == nil || *updated.Enabled != false { + t.Fatalf("expected updated Enabled=false, got %v", updated.Enabled) + } + + // 重新读取断言显式 false 得到正确保留(不被默认 true 覆盖) + readBack, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("readBack error = %v", err) + } + if readBack.Enabled == nil || *readBack.Enabled != false { + t.Fatalf("expected readBack Enabled=false, got %v", readBack.Enabled) + } + }) +} + +func TestOutputGuardSettingsGenerationOverflow(t *testing.T) { + runOnAllDBDrivers(t, func(t *testing.T, db *DB) { + ctx := context.Background() + // 人为将 generation 置为 math.MaxInt64 + if _, err := db.conn.ExecContext(ctx, ` + INSERT INTO system_settings (id, output_guard_config_generation) + VALUES (1, $1) + ON CONFLICT (id) DO UPDATE SET output_guard_config_generation = $1 + `, int64(math.MaxInt64)); err != nil { + t.Fatalf("seed max generation: %v", err) + } + + toolBytes := int64(65536) + _, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + MaxToolArgumentBytes: &toolBytes, + }) + if !errors.Is(err, ErrOutputGuardGenerationOverflow) { + t.Fatalf("expected ErrOutputGuardGenerationOverflow, got %v", err) + } + }) +} + +func TestOutputGuardSettingsNarrowUpdateNotOverwrittenBySystemSettings(t *testing.T) { + runOnAllDBDrivers(t, func(t *testing.T, db *DB) { + ctx := context.Background() + toolBytes := int64(262144) // 256 KiB + disabled := false + committed, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + Enabled: &disabled, + MaxToolArgumentBytes: &toolBytes, + }) + if err != nil { + t.Fatalf("UpdateOutputGuardSettings error = %v", err) + } + + // 模拟管理员保存常规设置(老 SystemSettings 全量写) + if err := db.UpdateSystemSettings(ctx, &SystemSettings{SiteName: "stale full update"}); err != nil { + t.Fatalf("UpdateSystemSettings error = %v", err) + } + + // 重新读取 OutputGuardSettings,断言窄更新字段绝不被全量写覆盖 + got, err := db.GetOutputGuardSettings(ctx) + if err != nil { + t.Fatalf("GetOutputGuardSettings error = %v", err) + } + if got.Enabled == nil || *got.Enabled != false { + t.Fatalf("Enabled overwritten by UpdateSystemSettings: got %v, want false", got.Enabled) + } + if got.MaxToolArgumentBytes != committed.MaxToolArgumentBytes { + t.Fatalf("MaxToolArgumentBytes overwritten: got %d, want %d", got.MaxToolArgumentBytes, committed.MaxToolArgumentBytes) + } + }) +} + +func TestOutputGuardSettingsConcurrentUpdatesNoDeadlock(t *testing.T) { + runOnAllDBDrivers(t, func(t *testing.T, db *DB) { + ctx := context.Background() + var wg sync.WaitGroup + errCh := make(chan error, 10) + + for i := 0; i < 10; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + toolBytes := int64(131072 + idx*1024) + channelBytes := int64(2097152 + idx*1024) + requestBytes := int64(4194304 + idx*1024) + _, err := db.UpdateOutputGuardSettings(ctx, OutputGuardSettingsUpdate{ + MaxToolArgumentBytes: &toolBytes, + MaxChannelBytes: &channelBytes, + MaxRequestBytes: &requestBytes, + }) + if err != nil { + errCh <- err + } + }(i) + } + + wg.Wait() + close(errCh) + for err := range errCh { + t.Fatalf("concurrent update failed: %v", err) + } + }) +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./database -run "TestOutputGuardSettings.*"` +Expected: FAIL with compilation error: undefined: db.GetOutputGuardSettings + +- [ ] **Step 3: Write minimal implementation** + +修改 `database/sqlite.go` 添加列: +```go +// 在 database/sqlite.go 中的 columns 切片中添加: +{"system_settings", "output_guard_enabled", "INTEGER DEFAULT 1"}, +{"system_settings", "output_guard_max_tool_argument_bytes", "INTEGER NOT NULL DEFAULT 131072"}, +{"system_settings", "output_guard_max_tool_argument_deltas", "INTEGER NOT NULL DEFAULT 4096"}, +{"system_settings", "output_guard_max_channel_bytes", "INTEGER NOT NULL DEFAULT 2097152"}, +{"system_settings", "output_guard_max_request_bytes", "INTEGER NOT NULL DEFAULT 4194304"}, +{"system_settings", "output_guard_config_generation", "INTEGER NOT NULL DEFAULT 1"}, +``` + +修改 `database/postgres.go` 添加列及隔离 UpdateSystemSettings: +```go +// 在 database/postgres.go initPostgresSchema 中添加: +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_enabled BOOLEAN DEFAULT TRUE; +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_max_tool_argument_bytes BIGINT NOT NULL DEFAULT 131072; +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_max_tool_argument_deltas INT NOT NULL DEFAULT 4096; +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_max_channel_bytes BIGINT NOT NULL DEFAULT 2097152; +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_max_request_bytes BIGINT NOT NULL DEFAULT 4194304; +ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS output_guard_config_generation BIGINT NOT NULL DEFAULT 1; + +// 在 database/postgres.go UpdateSystemSettings 的 ON CONFLICT (id) DO UPDATE SET 中: +// 确保不包含 output_guard_* 字段,使该 6 个字段完全由独立的窄更新函数单独维护,不被老全局设置全量写回覆盖! +``` + +新建 `database/output_guard_settings.go`: +```go +package database + +import ( + "context" + "database/sql" + "errors" + "fmt" + "math" +) + +var ( + ErrInvalidOutputGuardSettings = errors.New("invalid output guard settings") + ErrOutputGuardGenerationOverflow = errors.New("output guard config generation overflow") +) + +type OutputGuardSettings struct { + Enabled *bool `json:"output_guard_enabled"` + MaxToolArgumentBytes int64 `json:"output_guard_max_tool_argument_bytes"` + MaxToolArgumentDeltas int `json:"output_guard_max_tool_argument_deltas"` + MaxChannelBytes int64 `json:"output_guard_max_channel_bytes"` + MaxRequestBytes int64 `json:"output_guard_max_request_bytes"` + ConfigGeneration int64 `json:"output_guard_config_generation"` +} + +type OutputGuardSettingsUpdate struct { + Enabled *bool + MaxToolArgumentBytes *int64 + MaxToolArgumentDeltas *int + MaxChannelBytes *int64 + MaxRequestBytes *int64 +} + +func DefaultOutputGuardSettings() OutputGuardSettings { + t := true + return OutputGuardSettings{ + Enabled: &t, + MaxToolArgumentBytes: 131072, // 128 KiB + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, // 2 MiB + MaxRequestBytes: 4194304, // 4 MiB + ConfigGeneration: 1, + } +} + +func ValidateOutputGuardSettings(s OutputGuardSettings) error { + if s.MaxToolArgumentBytes < 16384 || s.MaxToolArgumentBytes > 4194304 { + return fmt.Errorf("%w: max_tool_argument_bytes must be 16KiB..4MiB", ErrInvalidOutputGuardSettings) + } + if s.MaxToolArgumentDeltas < 256 || s.MaxToolArgumentDeltas > 65536 { + return fmt.Errorf("%w: max_tool_argument_deltas must be 256..65536", ErrInvalidOutputGuardSettings) + } + if s.MaxChannelBytes < 131072 || s.MaxChannelBytes > 16777216 { + return fmt.Errorf("%w: max_channel_bytes must be 128KiB..16MiB", ErrInvalidOutputGuardSettings) + } + if s.MaxRequestBytes < 262144 || s.MaxRequestBytes > 67108864 { + return fmt.Errorf("%w: max_request_bytes must be 256KiB..64MiB", ErrInvalidOutputGuardSettings) + } + if s.MaxToolArgumentBytes > s.MaxChannelBytes || s.MaxChannelBytes > s.MaxRequestBytes { + return fmt.Errorf("%w: requires tool <= channel <= request bytes", ErrInvalidOutputGuardSettings) + } + return nil +} + +func (db *DB) outputGuardSettingsSelectQuery(forUpdate bool) string { + query := `SELECT output_guard_enabled, + COALESCE(output_guard_max_tool_argument_bytes, 131072), + COALESCE(output_guard_max_tool_argument_deltas, 4096), + COALESCE(output_guard_max_channel_bytes, 2097152), + COALESCE(output_guard_max_request_bytes, 4194304), + COALESCE(output_guard_config_generation, 1) + FROM system_settings WHERE id = 1` + if forUpdate && db.driver == "postgres" { + query += " FOR UPDATE" + } + return query +} + +func (db *DB) GetOutputGuardSettings(ctx context.Context) (OutputGuardSettings, error) { + settings := DefaultOutputGuardSettings() + var enabled sql.NullBool + err := db.conn.QueryRowContext(ctx, db.outputGuardSettingsSelectQuery(false)).Scan( + &enabled, + &settings.MaxToolArgumentBytes, + &settings.MaxToolArgumentDeltas, + &settings.MaxChannelBytes, + &settings.MaxRequestBytes, + &settings.ConfigGeneration, + ) + if errors.Is(err, sql.ErrNoRows) { + return settings, nil + } + if err != nil { + return OutputGuardSettings{}, err + } + if enabled.Valid { + val := enabled.Bool + settings.Enabled = &val + } + return settings, nil +} + +func (db *DB) UpdateOutputGuardSettings(ctx context.Context, update OutputGuardSettingsUpdate) (OutputGuardSettings, error) { + var committed OutputGuardSettings + + operation := func() error { + tx, err := db.conn.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + // 确保 id=1 存在 + if _, err := tx.ExecContext(ctx, `INSERT INTO system_settings (id) VALUES (1) ON CONFLICT(id) DO NOTHING`); err != nil { + return err + } + + current := DefaultOutputGuardSettings() + var enabled sql.NullBool + err = tx.QueryRowContext(ctx, db.outputGuardSettingsSelectQuery(true)).Scan( + &enabled, + ¤t.MaxToolArgumentBytes, + ¤t.MaxToolArgumentDeltas, + ¤t.MaxChannelBytes, + ¤t.MaxRequestBytes, + ¤t.ConfigGeneration, + ) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if enabled.Valid { + val := enabled.Bool + current.Enabled = &val + } + + next := current + if update.Enabled != nil { + val := *update.Enabled + next.Enabled = &val + } + if update.MaxToolArgumentBytes != nil { + next.MaxToolArgumentBytes = *update.MaxToolArgumentBytes + } + if update.MaxToolArgumentDeltas != nil { + next.MaxToolArgumentDeltas = *update.MaxToolArgumentDeltas + } + if update.MaxChannelBytes != nil { + next.MaxChannelBytes = *update.MaxChannelBytes + } + if update.MaxRequestBytes != nil { + next.MaxRequestBytes = *update.MaxRequestBytes + } + + // Generation 溢出保护 + if current.ConfigGeneration == math.MaxInt64 { + return ErrOutputGuardGenerationOverflow + } + next.ConfigGeneration++ + + if err := ValidateOutputGuardSettings(next); err != nil { + return err + } + + updateQuery := `UPDATE system_settings SET + output_guard_enabled = $1, + output_guard_max_tool_argument_bytes = $2, + output_guard_max_tool_argument_deltas = $3, + output_guard_max_channel_bytes = $4, + output_guard_max_request_bytes = $5, + output_guard_config_generation = $6 + WHERE id = 1` + + var enabledVal any + if next.Enabled != nil { + enabledVal = *next.Enabled + } else { + enabledVal = true + } + + if _, err := tx.ExecContext(ctx, updateQuery, + enabledVal, + next.MaxToolArgumentBytes, + next.MaxToolArgumentDeltas, + next.MaxChannelBytes, + next.MaxRequestBytes, + next.ConfigGeneration, + ); err != nil { + return err + } + + if err := tx.Commit(); err != nil { + return err + } + committed = next + return nil + } + + if db.isSQLite() { + if err := db.withSQLiteWriteLock(ctx, operation); err != nil { + return OutputGuardSettings{}, err + } + } else { + if err := operation(); err != nil { + return OutputGuardSettings{}, err + } + } + + return committed, nil +} +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./database -run "TestOutputGuardSettings.*"` +Expected: PASS ok github.com/codex2api/database + +- [ ] **Step 5: Commit** + +```bash +git add database/output_guard_settings.go database/output_guard_settings_test.go database/sqlite.go database/postgres.go +git commit -m "feat(database): add output guard settings table migration and tri-state persistence" +``` diff --git a/docs/superpowers/plans/part_t5_t6.txt b/docs/superpowers/plans/part_t5_t6.txt new file mode 100644 index 000000000..222ee6b90 --- /dev/null +++ b/docs/superpowers/plans/part_t5_t6.txt @@ -0,0 +1,1349 @@ + +### Task 5: 运行时不可变配置快照、5 秒后台 Revision 轮询器与冷启动就绪门禁 + +**Files:** +- Create: `proxy/output_guard_settings.go` +- Create: `proxy/output_guard_settings_test.go` +- Modify: `main.go:265-270` + +**Interfaces:** +- Consumes: + - `database.OutputGuardSettings`: + ```go + type OutputGuardSettings struct { + Enabled *bool `json:"output_guard_enabled"` + MaxToolArgumentBytes int64 `json:"output_guard_max_tool_argument_bytes"` + MaxToolArgumentDeltas int `json:"output_guard_max_tool_argument_deltas"` + MaxChannelBytes int64 `json:"output_guard_max_channel_bytes"` + MaxRequestBytes int64 `json:"output_guard_max_request_bytes"` + ConfigGeneration int64 `json:"output_guard_config_generation"` + } + ``` + - `database.DefaultOutputGuardSettings() database.OutputGuardSettings` + - `database.ValidateOutputGuardSettings(database.OutputGuardSettings) error` + - `database.DB.GetOutputGuardSettings(context.Context) (database.OutputGuardSettings, error)` +- Produces: + ```go + package proxy + + import ( + "context" + "time" + "github.com/codex2api/database" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + ) + + type OutputGuardAppliedConfig struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 + Generation int64 + } + + type OutputGuardConfigSyncStatus struct { + LastSuccessfulSyncAt time.Time + LastSyncError string + } + + type OutputGuardSettingsReader interface { + GetOutputGuardSettings(context.Context) (database.OutputGuardSettings, error) + } + + func ApplyOutputGuardSettings(settings database.OutputGuardSettings) bool + func CurrentOutputGuardConfig() OutputGuardAppliedConfig + func GetOutputGuardConfigSyncStatus() OutputGuardConfigSyncStatus + func IsOutputGuardReady() bool + func SetOutputGuardReadyForTest(ready bool) + func ResetOutputGuardStateForTest() + func LoadOutputGuardSettings(ctx context.Context, reader OutputGuardSettingsReader) error + func StartOutputGuardConfigPoller(parent context.Context, db *database.DB) bool + + // 门禁检查接口(供 Task 7/8 各协议与 WebSocket/Realtime 挂接) + func RequireOutputGuardReadyHTTP(c *gin.Context) bool + func RequireOutputGuardReadyWS(conn *websocket.Conn) bool + func RequireOutputGuardReadyStream(conn *websocket.Conn, streamID string) bool + ``` + +- [ ] **Step 1: Write failing test for proxy output guard runtime settings, poller, LKG, and readiness gates** + +```go +// proxy/output_guard_settings_test.go +package proxy + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/codex2api/api" + "github.com/codex2api/database" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +type controlledOutputGuardSettingsReader struct { + mu sync.Mutex + requests chan chan outputGuardSettingsReadResult +} + +type outputGuardSettingsReadResult struct { + settings database.OutputGuardSettings + err error +} + +func newControlledOutputGuardSettingsReader() *controlledOutputGuardSettingsReader { + return &controlledOutputGuardSettingsReader{ + requests: make(chan chan outputGuardSettingsReadResult, 16), + } +} + +func (r *controlledOutputGuardSettingsReader) GetOutputGuardSettings(ctx context.Context) (database.OutputGuardSettings, error) { + respCh := make(chan outputGuardSettingsReadResult, 1) + select { + case <-ctx.Done(): + return database.OutputGuardSettings{}, ctx.Err() + case r.requests <- respCh: + } + + select { + case <-ctx.Done(): + return database.OutputGuardSettings{}, ctx.Err() + case res := <-respCh: + return res.settings, res.err + } +} + +func (r *controlledOutputGuardSettingsReader) respond(t *testing.T, res outputGuardSettingsReadResult) { + t.Helper() + select { + case respCh := <-r.requests: + respCh <- res + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for GetOutputGuardSettings request") + } +} + +func waitForOutputGuardConfigGeneration(t *testing.T, targetGen int64) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if CurrentOutputGuardConfig().Generation == targetGen { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("timed out waiting for output guard config generation %d (current: %d)", targetGen, CurrentOutputGuardConfig().Generation) +} + +func waitForOutputGuardSyncError(t *testing.T, substr string) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + status := GetOutputGuardConfigSyncStatus() + if status.LastSyncError != "" && (substr == "" || errors.New(status.LastSyncError) != nil) { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("timed out waiting for output guard sync error containing %q", substr) +} + +func TestOutputGuardColdStartNotReadyReturns503(t *testing.T) { + ResetOutputGuardStateForTest() + t.Cleanup(ResetOutputGuardStateForTest) + + if IsOutputGuardReady() { + t.Fatal("expected cold start IsOutputGuardReady = false") + } + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + passed := RequireOutputGuardReadyHTTP(c) + if passed { + t.Fatal("expected RequireOutputGuardReadyHTTP to reject cold start request") + } + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("expected HTTP 503, got %d", w.Code) + } + var errResp struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + Type string `json:"type"` + } `json:"error"` + } + if err := json.Unmarshal(w.Body.Bytes(), &errResp); err != nil { + t.Fatalf("unmarshal error response failed: %v", err) + } + if errResp.Error.Code != string(api.ErrCodeServiceUnavailable) { + t.Fatalf("expected error code %q, got %q", api.ErrCodeServiceUnavailable, errResp.Error.Code) + } + if errResp.Error.Code == "model_output_guard_triggered" { + t.Fatalf("must NOT use runtime tripped error code on cold start 503") + } +} + +func TestOutputGuardExplicitDisabledSemanticVsColdStart(t *testing.T) { + ResetOutputGuardStateForTest() + t.Cleanup(ResetOutputGuardStateForTest) + + // Cold start: not ready + if IsOutputGuardReady() { + t.Fatal("expected cold start not ready") + } + + // Load DB settings with Enabled = false + disabled := false + dbSettings := database.OutputGuardSettings{ + Enabled: &disabled, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + ConfigGeneration: 1, + } + + applied := ApplyOutputGuardSettings(dbSettings) + if !applied { + t.Fatal("ApplyOutputGuardSettings failed") + } + + // Once loaded successfully, IsOutputGuardReady is true even when Enabled = false! + if !IsOutputGuardReady() { + t.Fatal("expected IsOutputGuardReady = true after explicit disabled settings loaded") + } + + cfg := CurrentOutputGuardConfig() + if cfg.Enabled != false { + t.Fatalf("expected applied config Enabled = false, got %v", cfg.Enabled) + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + passed := RequireOutputGuardReadyHTTP(c) + if !passed { + t.Fatal("expected RequireOutputGuardReadyHTTP to pass when system is ready and explicitly disabled") + } + if w.Code == http.StatusServiceUnavailable { + t.Fatalf("did not expect 503 when explicitly disabled and ready") + } +} + +func TestOutputGuardConfigGenerationIncrementsAndLKGMaintained(t *testing.T) { + ResetOutputGuardStateForTest() + t.Cleanup(ResetOutputGuardStateForTest) + + enabled := true + initial := database.OutputGuardSettings{ + Enabled: &enabled, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + ConfigGeneration: 1, + } + if !ApplyOutputGuardSettings(initial) { + t.Fatal("failed to apply initial generation 1") + } + + cfg1 := CurrentOutputGuardConfig() + if cfg1.Generation != 1 || cfg1.MaxToolArgumentBytes != 131072 { + t.Fatalf("initial config mismatch: %+v", cfg1) + } + + // Stale generation rejected + stale := initial + stale.MaxToolArgumentBytes = 262144 + if ApplyOutputGuardSettings(stale) { + t.Fatal("stale generation 1 should be rejected") + } + if CurrentOutputGuardConfig().MaxToolArgumentBytes != 131072 { + t.Fatal("LKG overwritten by stale generation") + } + + // Apply generation 2 + gen2 := initial + gen2.MaxToolArgumentBytes = 262144 + gen2.ConfigGeneration = 2 + if !ApplyOutputGuardSettings(gen2) { + t.Fatal("failed to apply generation 2") + } + cfg2 := CurrentOutputGuardConfig() + if cfg2.Generation != 2 || cfg2.MaxToolArgumentBytes != 262144 { + t.Fatalf("generation 2 mismatch: %+v", cfg2) + } + + // Illegal configuration validation failure preserves LKG + illegal := initial + illegal.MaxToolArgumentBytes = 50000000 // exceeds 4MiB + illegal.ConfigGeneration = 3 + if ApplyOutputGuardSettings(illegal) { + t.Fatal("illegal configuration should be rejected") + } + cfgPreserved := CurrentOutputGuardConfig() + if cfgPreserved.Generation != 2 || cfgPreserved.MaxToolArgumentBytes != 262144 { + t.Fatalf("LKG not preserved after illegal config: %+v", cfgPreserved) + } +} + +func TestOutputGuardPollerLifecycleRecoveryAndStop(t *testing.T) { + ResetOutputGuardStateForTest() + t.Cleanup(ResetOutputGuardStateForTest) + + reader := newControlledOutputGuardSettingsReader() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan struct{}) + go func() { + defer close(done) + runOutputGuardConfigPoller(ctx, reader, 5*time.Millisecond, 100*time.Millisecond) + }() + + enabled := true + // 1. Initial read + reader.respond(t, outputGuardSettingsReadResult{ + settings: database.OutputGuardSettings{ + Enabled: &enabled, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + ConfigGeneration: 2, + }, + }) + waitForOutputGuardConfigGeneration(t, 2) + if !IsOutputGuardReady() { + t.Fatal("expected IsOutputGuardReady = true after successful poll") + } + + // 2. Outage: DB error preserves LKG + reader.respond(t, outputGuardSettingsReadResult{ + err: errors.New("simulated database disconnect"), + }) + waitForOutputGuardSyncError(t, "simulated database disconnect") + if CurrentOutputGuardConfig().Generation != 2 { + t.Fatal("LKG generation lost during DB outage") + } + + // 3. Recovery: next poll succeeds with generation 3 + reader.respond(t, outputGuardSettingsReadResult{ + settings: database.OutputGuardSettings{ + Enabled: &enabled, + MaxToolArgumentBytes: 262144, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + ConfigGeneration: 3, + }, + }) + waitForOutputGuardConfigGeneration(t, 3) + if CurrentOutputGuardConfig().MaxToolArgumentBytes != 262144 { + t.Fatal("failed to update config after recovery") + } + + // 4. Cancel ctx and verify poller stops + cancel() + select { + case <-done: + case <-time.After(500 * time.Millisecond): + t.Fatal("poller failed to stop cleanly after context cancel") + } +} + +func TestOutputGuardWSReadinessGate(t *testing.T) { + ResetOutputGuardStateForTest() + t.Cleanup(ResetOutputGuardStateForTest) + + // Create test WebSocket server + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + + if !RequireOutputGuardReadyWS(conn) { + return + } + _ = conn.WriteMessage(websocket.TextMessage, []byte("ready")) + })) + defer server.Close() + + wsURL := "ws" + server.URL[len("http"):] + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial WS failed: %v", err) + } + defer conn.Close() + + // When not ready, server should send 503 error frame / CloseTryAgainLater (1013) + _, msg, err := conn.ReadMessage() + if err != nil { + var closeErr *websocket.CloseError + if errors.As(err, &closeErr) { + if closeErr.Code != websocket.CloseTryAgainLater { + t.Fatalf("expected close code 1013 CloseTryAgainLater, got %d", closeErr.Code) + } + return + } + } + if len(msg) > 0 { + var errResp struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + _ = json.Unmarshal(msg, &errResp) + if errResp.Error.Code != string(api.ErrCodeServiceUnavailable) { + t.Fatalf("expected error frame with code %s, got %s", api.ErrCodeServiceUnavailable, errResp.Error.Code) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestOutputGuard.*"` +Expected: FAIL with "undefined: ApplyOutputGuardSettings", "undefined: RequireOutputGuardReadyHTTP", etc. + +- [ ] **Step 3: Write minimal implementation for proxy/output_guard_settings.go and wire in main.go** + +```go +// proxy/output_guard_settings.go +package proxy + +import ( + "context" + "fmt" + "log" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/codex2api/api" + "github.com/codex2api/database" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +const ( + outputGuardConfigPollInterval = 5 * time.Second + outputGuardConfigReadTimeout = 3 * time.Second +) + +type OutputGuardAppliedConfig struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 + Generation int64 +} + +type OutputGuardConfigSyncStatus struct { + LastSuccessfulSyncAt time.Time + LastSyncError string +} + +type OutputGuardSettingsReader interface { + GetOutputGuardSettings(context.Context) (database.OutputGuardSettings, error) +} + +type outputGuardState struct { + mu sync.RWMutex + config OutputGuardAppliedConfig + lastSyncAt time.Time + lastSyncErr string + isReady atomic.Bool +} + +var globalOutputGuardState = &outputGuardState{} + +func init() { + ResetOutputGuardStateForTest() +} + +func ResetOutputGuardStateForTest() { + globalOutputGuardState.mu.Lock() + defer globalOutputGuardState.mu.Unlock() + globalOutputGuardState.config = OutputGuardAppliedConfig{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + Generation: 0, + } + globalOutputGuardState.lastSyncAt = time.Time{} + globalOutputGuardState.lastSyncErr = "" + globalOutputGuardState.isReady.Store(false) +} + +func SetOutputGuardReadyForTest(ready bool) { + globalOutputGuardState.isReady.Store(ready) +} + +func IsOutputGuardReady() bool { + return globalOutputGuardState.isReady.Load() +} + +func CurrentOutputGuardConfig() OutputGuardAppliedConfig { + globalOutputGuardState.mu.RLock() + defer globalOutputGuardState.mu.RUnlock() + return globalOutputGuardState.config +} + +func GetOutputGuardConfigSyncStatus() OutputGuardConfigSyncStatus { + globalOutputGuardState.mu.RLock() + defer globalOutputGuardState.mu.RUnlock() + return OutputGuardConfigSyncStatus{ + LastSuccessfulSyncAt: globalOutputGuardState.lastSyncAt, + LastSyncError: globalOutputGuardState.lastSyncErr, + } +} + +func ApplyOutputGuardSettings(settings database.OutputGuardSettings) bool { + if err := database.ValidateOutputGuardSettings(settings); err != nil { + log.Printf("[output-guard] 非法配置已被拒绝,保留 LKG: %v", err) + recordOutputGuardConfigSyncError(err) + return false + } + + globalOutputGuardState.mu.Lock() + defer globalOutputGuardState.mu.Unlock() + + if settings.ConfigGeneration <= globalOutputGuardState.config.Generation { + return false + } + + enabledVal := true + if settings.Enabled != nil { + enabledVal = *settings.Enabled + } + + globalOutputGuardState.config = OutputGuardAppliedConfig{ + Enabled: enabledVal, + MaxToolArgumentBytes: settings.MaxToolArgumentBytes, + MaxToolArgumentDeltas: settings.MaxToolArgumentDeltas, + MaxChannelBytes: settings.MaxChannelBytes, + MaxRequestBytes: settings.MaxRequestBytes, + Generation: settings.ConfigGeneration, + } + globalOutputGuardState.isReady.Store(true) + return true +} + +func LoadOutputGuardSettings(ctx context.Context, reader OutputGuardSettingsReader) error { + settings, err := readOutputGuardSettings(ctx, reader, outputGuardConfigReadTimeout) + if err != nil { + if ctx == nil || ctx.Err() == nil { + recordOutputGuardConfigSyncError(err) + } + return err + } + if err := database.ValidateOutputGuardSettings(settings); err != nil { + recordOutputGuardConfigSyncError(err) + return err + } + ApplyOutputGuardSettings(settings) + recordOutputGuardConfigSyncSuccess(time.Now()) + return nil +} + +func StartOutputGuardConfigPoller(parent context.Context, db *database.DB) bool { + if db == nil { + return false + } + if parent == nil { + parent = context.Background() + } + return db.RunBackgroundTask(func(lifecycle context.Context) { + taskCtx, cancel := context.WithCancel(lifecycle) + stopParent := context.AfterFunc(parent, cancel) + defer func() { + stopParent() + cancel() + }() + runOutputGuardConfigPoller( + taskCtx, + db, + outputGuardConfigPollInterval, + outputGuardConfigReadTimeout, + ) + }) +} + +func runOutputGuardConfigPoller( + ctx context.Context, + reader OutputGuardSettingsReader, + interval time.Duration, + readTimeout time.Duration, +) { + if ctx == nil { + ctx = context.Background() + } + if interval <= 0 { + interval = outputGuardConfigPollInterval + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + settings, err := readOutputGuardSettings(ctx, reader, readTimeout) + if err != nil { + if ctx.Err() != nil { + return + } + recordOutputGuardConfigSyncError(err) + continue + } + if err := database.ValidateOutputGuardSettings(settings); err != nil { + recordOutputGuardConfigSyncError(err) + continue + } + ApplyOutputGuardSettings(settings) + recordOutputGuardConfigSyncSuccess(time.Now()) + } + } +} + +func readOutputGuardSettings( + parent context.Context, + reader OutputGuardSettingsReader, + timeout time.Duration, +) (database.OutputGuardSettings, error) { + if reader == nil { + return database.OutputGuardSettings{}, fmt.Errorf("output guard settings reader is nil") + } + if parent == nil { + parent = context.Background() + } + if err := parent.Err(); err != nil { + return database.OutputGuardSettings{}, err + } + if timeout <= 0 || timeout > outputGuardConfigReadTimeout { + timeout = outputGuardConfigReadTimeout + } + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + return reader.GetOutputGuardSettings(ctx) +} + +func recordOutputGuardConfigSyncSuccess(at time.Time) { + globalOutputGuardState.mu.Lock() + globalOutputGuardState.lastSyncAt = at + globalOutputGuardState.lastSyncErr = "" + globalOutputGuardState.mu.Unlock() +} + +func recordOutputGuardConfigSyncError(err error) { + if err == nil { + return + } + globalOutputGuardState.mu.Lock() + globalOutputGuardState.lastSyncErr = err.Error() + globalOutputGuardState.mu.Unlock() +} + +// 门禁检查实现:冷启动未就绪返回 503,严禁以 log.Fatalf 冒充 503 + +func RequireOutputGuardReadyHTTP(c *gin.Context) bool { + if IsOutputGuardReady() { + return true + } + apiErr := api.NewAPIError( + api.ErrCodeServiceUnavailable, + "Service configuration unavailable; failed to load output guard settings", + api.ErrorTypeServer, + ) + api.SendErrorWithStatus(c, apiErr, http.StatusServiceUnavailable) + return false +} + +func RequireOutputGuardReadyWS(conn *websocket.Conn) bool { + if IsOutputGuardReady() { + return true + } + if conn == nil { + return false + } + apiErr := api.NewAPIError( + api.ErrCodeServiceUnavailable, + "Service configuration unavailable; failed to load output guard settings", + api.ErrorTypeServer, + ) + _ = writeResponsesWSErrorWithin(conn, apiErr, 2*time.Second) + _ = conn.WriteControl( + websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseTryAgainLater, "Service configuration unavailable"), + time.Now().Add(time.Second), + ) + _ = conn.Close() + return false +} + +func RequireOutputGuardReadyStream(conn *websocket.Conn, streamID string) bool { + if IsOutputGuardReady() { + return true + } + if conn == nil { + return false + } + apiErr := api.NewAPIError( + api.ErrCodeServiceUnavailable, + "Service configuration unavailable; failed to load output guard settings", + api.ErrorTypeServer, + ) + _ = writeResponsesWSErrorForStream(conn, apiErr, streamID, 2*time.Second) + return false +} +``` + +精确插入锚点:`main.go:265-270`(紧随 ResponseCache 设置加载与日志之后,挂载 OutputGuard 加载与 Poller): + +```go + // main.go 锚点替换:紧接 line 265 + appliedResponseCache := proxy.GetResponseCacheAppliedConfig() + log.Printf( + "响应缓存设置已加载: generation=%d total=%d entry=%d reconstruct=%d", + appliedResponseCache.Generation, + appliedResponseCache.LocalMaxBytes, + appliedResponseCache.LocalMaxEntryBytes, + appliedResponseCache.ReconstructMaxBytes, + ) + + // --- 插入开始: OutputGuard 加载与后台 5 秒轮询器启动 --- + outputGuardCtx, outputGuardCancel := context.WithTimeout(context.Background(), 3*time.Second) + if err := proxy.LoadOutputGuardSettings(outputGuardCtx, db); err != nil { + outputGuardCancel() + // 严禁 log.Fatalf 冒充 503!冷启动加载失败仅记录警告日志,保持 IsOutputGuardReady=false, + // 请求进入时由门禁准确返回 HTTP 503 api.ErrCodeServiceUnavailable,且 poller 后续轮询成功时可无缝自愈恢复。 + log.Printf("警告: 加载模型输出失控防护设置失败(冷启动门禁将返回 503 直至 DB 轮询恢复): %v", err) + } else { + outputGuardCancel() + appliedGuard := proxy.CurrentOutputGuardConfig() + log.Printf( + "模型输出失控防护设置已加载: generation=%d enabled=%t tool_bytes=%d tool_deltas=%d channel_bytes=%d request_bytes=%d", + appliedGuard.Generation, + appliedGuard.Enabled, + appliedGuard.MaxToolArgumentBytes, + appliedGuard.MaxToolArgumentDeltas, + appliedGuard.MaxChannelBytes, + appliedGuard.MaxRequestBytes, + ) + } + proxy.StartOutputGuardConfigPoller(context.Background(), db) + // --- 插入结束 --- +``` + +- [ ] **Step 4: Run test to verify it passes (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestOutputGuard.*"` +Expected: PASS ok github.com/codex2api/proxy + +- [ ] **Step 5: Commit** + +```bash +git add proxy/output_guard_settings.go proxy/output_guard_settings_test.go main.go +git commit -m "feat(proxy): add output guard runtime config snapshot, poller and cold start readiness gate" +``` + +--- + +### Task 6: 复用 Admin Settings GET/PUT 扩展与完整独立前端 UI 卡片 + +**Files:** +- Create: `admin/output_guard_settings.go` +- Create: `admin/output_guard_settings_test.go` +- Modify: `admin/handler.go:9460-9640` (`settingsResponse`, `updateSettingsReq`, `GetSettings`, `UpdateSettings`) +- Modify: `frontend/src/types.ts:2300-2315` +- Modify: `frontend/src/pages/Settings.tsx:200-220, 6545-6600` +- Test: `frontend/src/lib/outputGuardSettings.test.mjs` (新建前端纯逻辑测试) + +**Interfaces:** +- Consumes: + - `database.OutputGuardSettings` / `database.OutputGuardSettingsUpdate` (来自 Task 4) + - `proxy.CurrentOutputGuardConfig()` / `proxy.ApplyOutputGuardSettings()` (来自 Task 5) + - `database.ValidateOutputGuardSettings(database.OutputGuardSettings) error` +- Produces: + - Admin REST API: + - `GET /api/admin/settings` 返回 5 个完整对称字段 + 1 个只读 generation: + - `output_guard_enabled`: bool + - `output_guard_max_tool_argument_bytes`: int64 + - `output_guard_max_tool_argument_deltas`: int + - `output_guard_max_channel_bytes`: int64 + - `output_guard_max_request_bytes`: int64 + - `output_guard_config_generation`: int64 (只读) + - `PUT /api/admin/settings` 接收上述 5 个字段的可选 patch,若传入 `output_guard_config_generation` 则报错 400(只读字段禁止提交)。 + - Frontend TypeScript Contract (`frontend/src/types.ts`): + - `SystemSettings` 增加上述 6 个属性(含 `readonly output_guard_config_generation: number`)。 + - Frontend UI (`frontend/src/pages/Settings.tsx`): + - 位于 General Tab 独立的“模型输出失控防护 (Model Output Guard)”专属卡片(不挂在 PromptFilter 下)。 + - 5 个完整配置项输入,单位展示 KiB / MiB 与格式化转换,非法层级交互反馈(`tool <= channel <= request`),显式 false 准确提交,以及“恢复默认”5 字段原子提交。 + +- [ ] **Step 1: Write failing test for admin GET/PUT settings with output guard fields, range validation, explicit false, and readonly revision rejection** + +```go +// admin/output_guard_settings_test.go +package admin + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/codex2api/database" + "github.com/codex2api/proxy" + "github.com/gin-gonic/gin" +) + +func TestAdminOutputGuardSettingsGetSymmetricFields(t *testing.T) { + db := newResponseCacheSettingsTestDB(t) + handler := &Handler{db: db} + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/admin/settings", nil) + + handler.GetSettings(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected HTTP 200, got %d, body: %s", w.Code, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response failed: %v", err) + } + + // Verify all 6 symmetric fields exist + fields := []string{ + "output_guard_enabled", + "output_guard_max_tool_argument_bytes", + "output_guard_max_tool_argument_deltas", + "output_guard_max_channel_bytes", + "output_guard_max_request_bytes", + "output_guard_config_generation", + } + for _, f := range fields { + if _, ok := resp[f]; !ok { + t.Fatalf("expected field %q in GET /api/admin/settings response", f) + } + } + + if resp["output_guard_enabled"] != true { + t.Fatalf("expected output_guard_enabled = true by default, got %v", resp["output_guard_enabled"]) + } + if int64(resp["output_guard_max_tool_argument_bytes"].(float64)) != 131072 { + t.Fatalf("expected tool argument bytes 131072, got %v", resp["output_guard_max_tool_argument_bytes"]) + } +} + +func TestAdminOutputGuardSettingsPutValidationAndReadOnlyRevision(t *testing.T) { + db := newResponseCacheSettingsTestDB(t) + handler := &Handler{db: db} + gin.SetMode(gin.TestMode) + + // 1. Rejection of readonly output_guard_config_generation + { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPut, "/api/admin/settings", strings.NewReader(`{"output_guard_config_generation": 999}`)) + c.Request.Header.Set("Content-Type", "application/json") + handler.UpdateSettings(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected HTTP 400 when submitting readonly output_guard_config_generation, got %d", w.Code) + } + if !strings.Contains(w.Body.String(), "只读") { + t.Fatalf("expected error mentioning readonly, got %s", w.Body.String()) + } + } + + // 2. Rejection of out-of-range parameters (16KiB..4MiB, etc.) + { + // Tool bytes too small: 1024 < 16384 + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPut, "/api/admin/settings", strings.NewReader(`{"output_guard_max_tool_argument_bytes": 1024}`)) + c.Request.Header.Set("Content-Type", "application/json") + handler.UpdateSettings(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected HTTP 400 for tool bytes < 16KiB, got %d", w.Code) + } + } + + // 3. Rejection of hierarchy violation: tool > channel + { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPut, "/api/admin/settings", strings.NewReader(`{"output_guard_max_tool_argument_bytes": 3145728, "output_guard_max_channel_bytes": 2097152}`)) + c.Request.Header.Set("Content-Type", "application/json") + handler.UpdateSettings(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected HTTP 400 for hierarchy violation tool > channel, got %d", w.Code) + } + } + + // 4. Successful update with explicit false & verify generation increment + { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + payload := `{"output_guard_enabled": false, "output_guard_max_tool_argument_bytes": 65536}` + c.Request = httptest.NewRequest(http.MethodPut, "/api/admin/settings", strings.NewReader(payload)) + c.Request.Header.Set("Content-Type", "application/json") + handler.UpdateSettings(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected HTTP 200 for valid PUT, got %d, body: %s", w.Code, w.Body.String()) + } + + var resp map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if resp["output_guard_enabled"] != false { + t.Fatalf("expected output_guard_enabled = false, got %v", resp["output_guard_enabled"]) + } + if int64(resp["output_guard_max_tool_argument_bytes"].(float64)) != 65536 { + t.Fatalf("expected tool bytes = 65536, got %v", resp["output_guard_max_tool_argument_bytes"]) + } + if int64(resp["output_guard_config_generation"].(float64)) <= 1 { + t.Fatalf("expected generation incremented, got %v", resp["output_guard_config_generation"]) + } + + // Also check local runtime instance updated immediately + cfg := proxy.CurrentOutputGuardConfig() + if cfg.Enabled != false || cfg.MaxToolArgumentBytes != 65536 { + t.Fatalf("local proxy runtime config not updated immediately: %+v", cfg) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./admin -run "TestAdminOutputGuard.*"` +Expected: FAIL with "unknown field output_guard_enabled" or failed assertions. + +- [ ] **Step 3: Write minimal implementation for admin backend and frontend integration** + +1. 编写 `admin/output_guard_settings.go`: +```go +// admin/output_guard_settings.go +package admin + +import ( + "fmt" + "github.com/codex2api/database" +) + +func validateOutputGuardSettingsUpdateRanges(update database.OutputGuardSettingsUpdate) error { + if update.MaxToolArgumentBytes != nil { + val := *update.MaxToolArgumentBytes + if val < 16384 || val > 4194304 { + return fmt.Errorf("output_guard_max_tool_argument_bytes 必须在 16KiB(16384) ~ 4MiB(4194304) 之间") + } + } + if update.MaxToolArgumentDeltas != nil { + val := *update.MaxToolArgumentDeltas + if val < 256 || val > 65536 { + return fmt.Errorf("output_guard_max_tool_argument_deltas 必须在 256 ~ 65536 之间") + } + } + if update.MaxChannelBytes != nil { + val := *update.MaxChannelBytes + if val < 131072 || val > 16777216 { + return fmt.Errorf("output_guard_max_channel_bytes 必须在 128KiB(131072) ~ 16MiB(16777216) 之间") + } + } + if update.MaxRequestBytes != nil { + val := *update.MaxRequestBytes + if val < 262144 || val > 67108864 { + return fmt.Errorf("output_guard_max_request_bytes 必须在 256KiB(262144) ~ 64MiB(67108864) 之间") + } + } + return nil +} +``` + +2. 在 `admin/handler.go` 中扩展 `settingsResponse`、`updateSettingsReq`、`GetSettings` 及 `UpdateSettings`: +- 在 `settingsResponse`(约 line 9460)中添加: +```go + OutputGuardEnabled bool `json:"output_guard_enabled"` + OutputGuardMaxToolArgumentBytes int64 `json:"output_guard_max_tool_argument_bytes"` + OutputGuardMaxToolArgumentDeltas int `json:"output_guard_max_tool_argument_deltas"` + OutputGuardMaxChannelBytes int64 `json:"output_guard_max_channel_bytes"` + OutputGuardMaxRequestBytes int64 `json:"output_guard_max_request_bytes"` + OutputGuardConfigGeneration int64 `json:"output_guard_config_generation"` +``` + +- 在 `updateSettingsReq`(约 line 9635)中添加: +```go + OutputGuardEnabled *bool `json:"output_guard_enabled"` + OutputGuardMaxToolArgumentBytes *int64 `json:"output_guard_max_tool_argument_bytes"` + OutputGuardMaxToolArgumentDeltas *int `json:"output_guard_max_tool_argument_deltas"` + OutputGuardMaxChannelBytes *int64 `json:"output_guard_max_channel_bytes"` + OutputGuardMaxRequestBytes *int64 `json:"output_guard_max_request_bytes"` + OutputGuardConfigGeneration rawJSON `json:"output_guard_config_generation"` +``` + +- 在 `GetSettings`(约 line 10240 之后)读取并注入 output guard 字段: +```go + outputGuardSettings, err := h.db.GetOutputGuardSettings(ctx) + if err != nil { + outputGuardSettings = database.DefaultOutputGuardSettings() + } + outputGuardEnabled := true + if outputGuardSettings.Enabled != nil { + outputGuardEnabled = *outputGuardSettings.Enabled + } +``` +并在 `settingsResponse` 结构字面量中填充: +```go + OutputGuardEnabled: outputGuardEnabled, + OutputGuardMaxToolArgumentBytes: outputGuardSettings.MaxToolArgumentBytes, + OutputGuardMaxToolArgumentDeltas: outputGuardSettings.MaxToolArgumentDeltas, + OutputGuardMaxChannelBytes: outputGuardSettings.MaxChannelBytes, + OutputGuardMaxRequestBytes: outputGuardSettings.MaxRequestBytes, + OutputGuardConfigGeneration: outputGuardSettings.ConfigGeneration, +``` + +- 在 `UpdateSettings`(约 line 10600 及 line 10750)添加只读校验、更新合并与立即生效逻辑: +```go + if req.OutputGuardConfigGeneration != nil { + writeError(c, http.StatusBadRequest, "output_guard_config_generation 为只读字段") + return + } + + outputGuardUpdate := database.OutputGuardSettingsUpdate{ + Enabled: req.OutputGuardEnabled, + MaxToolArgumentBytes: req.OutputGuardMaxToolArgumentBytes, + MaxToolArgumentDeltas: req.OutputGuardMaxToolArgumentDeltas, + MaxChannelBytes: req.OutputGuardMaxChannelBytes, + MaxRequestBytes: req.OutputGuardMaxRequestBytes, + } + outputGuardUpdateRequested := outputGuardUpdate.Enabled != nil || + outputGuardUpdate.MaxToolArgumentBytes != nil || + outputGuardUpdate.MaxToolArgumentDeltas != nil || + outputGuardUpdate.MaxChannelBytes != nil || + outputGuardUpdate.MaxRequestBytes != nil + + if err := validateOutputGuardSettingsUpdateRanges(outputGuardUpdate); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + + var outputGuardSettings database.OutputGuardSettings + if outputGuardUpdateRequested { + currentGuard, err := h.db.GetOutputGuardSettings(c.Request.Context()) + if err != nil { + writeError(c, http.StatusInternalServerError, "读取防护设置失败:"+err.Error()) + return + } + mergedGuard := currentGuard + if outputGuardUpdate.Enabled != nil { + val := *outputGuardUpdate.Enabled + mergedGuard.Enabled = &val + } + if outputGuardUpdate.MaxToolArgumentBytes != nil { + mergedGuard.MaxToolArgumentBytes = *outputGuardUpdate.MaxToolArgumentBytes + } + if outputGuardUpdate.MaxToolArgumentDeltas != nil { + mergedGuard.MaxToolArgumentDeltas = *outputGuardUpdate.MaxToolArgumentDeltas + } + if outputGuardUpdate.MaxChannelBytes != nil { + mergedGuard.MaxChannelBytes = *outputGuardUpdate.MaxChannelBytes + } + if outputGuardUpdate.MaxRequestBytes != nil { + mergedGuard.MaxRequestBytes = *outputGuardUpdate.MaxRequestBytes + } + if err := database.ValidateOutputGuardSettings(mergedGuard); err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + + committedGuard, err := h.db.UpdateOutputGuardSettings(c.Request.Context(), outputGuardUpdate) + if err != nil { + writeError(c, http.StatusInternalServerError, "保存防护设置失败:"+err.Error()) + return + } + outputGuardSettings = committedGuard + proxy.ApplyOutputGuardSettings(committedGuard) + } else { + latestGuard, err := h.db.GetOutputGuardSettings(c.Request.Context()) + if err != nil { + writeError(c, http.StatusInternalServerError, "读取防护设置失败:"+err.Error()) + return + } + outputGuardSettings = latestGuard + } +``` + +并在 `UpdateSettings` 结尾的 `c.JSON(http.StatusOK, response)` 中带上最新的 `outputGuardSettings` 字段。 + +3. 修改 `frontend/src/types.ts`(约 line 2305 处): +```typescript + output_guard_enabled: boolean + output_guard_max_tool_argument_bytes: number + output_guard_max_tool_argument_deltas: number + output_guard_max_channel_bytes: number + output_guard_max_request_bytes: number + readonly output_guard_config_generation: number +``` + +4. 编写前端纯函数测试 `frontend/src/lib/outputGuardSettings.test.mjs`: +```javascript +import test from 'node:test' +import assert from 'node:assert/strict' + +const KIB = 1024 +const MIB = 1024 * 1024 + +export const DEFAULT_OUTPUT_GUARD_SETTINGS = { + output_guard_enabled: true, + output_guard_max_tool_argument_bytes: 128 * KIB, + output_guard_max_tool_argument_deltas: 4096, + output_guard_max_channel_bytes: 2 * MIB, + output_guard_max_request_bytes: 4 * MIB, +} + +export function validateOutputGuardHierarchy(settings) { + const tool = settings.output_guard_max_tool_argument_bytes + const channel = settings.output_guard_max_channel_bytes + const request = settings.output_guard_max_request_bytes + if (tool > channel) { + return '工具参数上限不能超过单 Channel 字节上限' + } + if (channel > request) { + return '单 Channel 字节上限不能超过单请求语义预算' + } + return null +} + +export function bytesToKiB(bytes) { + return Math.round(bytes / KIB) +} + +export function kibToBytes(kib) { + return kib * KIB +} + +export function bytesToMiB(bytes) { + return Math.round(bytes / MIB) +} + +export function mibToBytes(mib) { + return mib * MIB +} + +test('validateOutputGuardHierarchy enforces tool <= channel <= request', () => { + assert.equal(validateOutputGuardHierarchy({ + output_guard_max_tool_argument_bytes: 128 * KIB, + output_guard_max_channel_bytes: 2 * MIB, + output_guard_max_request_bytes: 4 * MIB, + }), null) + + assert.equal(validateOutputGuardHierarchy({ + output_guard_max_tool_argument_bytes: 3 * MIB, + output_guard_max_channel_bytes: 2 * MIB, + output_guard_max_request_bytes: 4 * MIB, + }), '工具参数上限不能超过单 Channel 字节上限') + + assert.equal(validateOutputGuardHierarchy({ + output_guard_max_tool_argument_bytes: 128 * KIB, + output_guard_max_channel_bytes: 8 * MIB, + output_guard_max_request_bytes: 4 * MIB, + }), '单 Channel 字节上限不能超过单请求语义预算') +}) +``` + +5. 在 `frontend/src/pages/Settings.tsx` 中新增“模型输出失控防护”分组卡片: +- 在 constants 区域定义: +```typescript +const KIB = 1024 +const DEFAULT_OUTPUT_GUARD_TOOL_BYTES = 128 * KIB +const DEFAULT_OUTPUT_GUARD_TOOL_DELTAS = 4096 +const DEFAULT_OUTPUT_GUARD_CHANNEL_BYTES = 2 * MIB +const DEFAULT_OUTPUT_GUARD_REQUEST_BYTES = 4 * MIB + +const OUTPUT_GUARD_KEYS = [ + 'output_guard_enabled', + 'output_guard_max_tool_argument_bytes', + 'output_guard_max_tool_argument_deltas', + 'output_guard_max_channel_bytes', + 'output_guard_max_request_bytes', +] as const satisfies ReadonlyArray +``` + +- 在 JSX 中(位于 General Tab 的安全分区 `id="settings-security"` 内,紧邻 Prompt 风控卡片之后,新增独立的 `SettingsCard`): +```tsx + } + tone={settingsForm.output_guard_enabled ? 'default' : 'danger'} + badge={ + + + {settingsForm.output_guard_enabled ? '已启用全局防护' : '已关闭 (高风险)'} + + } + actions={ + + } + > +
+ {!settingsForm.output_guard_enabled && ( +
+ +
+

高风险操作警告:失控防护已全局关闭

+

关闭后模型失控生成死循环、重复代码或细碎 arguments delta 时,将直接击穿代理并在下游引发 O(n²) 全量重解析雪崩。仅用于临时诊断,请尽快重新开启!

+
+
+ )} + +
+
+
启用失控输出防护
+
全局实时检测持续短周期重复、细碎 arguments 泛洪与字节预算超额并前置切断
+
+ { + setSettingsForm(f => ({ ...f, output_guard_enabled: checked })) + savePartialSettings({ output_guard_enabled: checked }) + }} + /> +
+ +
+ + { + const val = Math.max(16, Math.min(4096, parseInt(e.target.value) || 128)) * 1024 + setSettingsForm(f => ({ ...f, output_guard_max_tool_argument_bytes: val })) + }} + onBlur={() => savePartialSettings({ output_guard_max_tool_argument_bytes: settingsForm.output_guard_max_tool_argument_bytes })} + /> + + + + { + const val = Math.max(256, Math.min(65536, parseInt(e.target.value) || 4096)) + setSettingsForm(f => ({ ...f, output_guard_max_tool_argument_deltas: val })) + }} + onBlur={() => savePartialSettings({ output_guard_max_tool_argument_deltas: settingsForm.output_guard_max_tool_argument_deltas })} + /> + + + + { + const val = Math.max(1, Math.min(16, parseInt(e.target.value) || 2)) * 1024 * 1024 + setSettingsForm(f => ({ ...f, output_guard_max_channel_bytes: val })) + }} + onBlur={() => savePartialSettings({ output_guard_max_channel_bytes: settingsForm.output_guard_max_channel_bytes })} + /> + + + + { + const val = Math.max(1, Math.min(64, parseInt(e.target.value) || 4)) * 1024 * 1024 + setSettingsForm(f => ({ ...f, output_guard_max_request_bytes: val })) + }} + onBlur={() => savePartialSettings({ output_guard_max_request_bytes: settingsForm.output_guard_max_request_bytes })} + /> + +
+ + {settingsForm.output_guard_max_tool_argument_bytes > settingsForm.output_guard_max_channel_bytes && ( +

层级错误:工具参数上限不能超过单 Channel 字节上限!

+ )} + {settingsForm.output_guard_max_channel_bytes > settingsForm.output_guard_max_request_bytes && ( +

层级错误:单 Channel 字节上限不能超过单请求语义预算!

+ )} +
+
+``` + +- [ ] **Step 4: Run test to verify it passes (Green)** + +Run: +1. `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./admin -run "TestAdminOutputGuard.*"` +Expected: PASS ok github.com/codex2api/admin +2. `cd frontend && node --experimental-strip-types --test src/lib/outputGuardSettings.test.mjs` +Expected: PASS ok +3. `cd frontend && pnpm run typecheck` +Expected: exit code 0, no errors + +- [ ] **Step 5: Commit** + +```bash +git add admin/output_guard_settings.go admin/output_guard_settings_test.go admin/handler.go frontend/src/types.ts frontend/src/pages/Settings.tsx frontend/src/lib/outputGuardSettings.test.mjs +git commit -m "feat(admin,frontend): add output guard settings GET/PUT and standalone management UI card" +``` diff --git a/docs/superpowers/plans/part_t7_t8.txt b/docs/superpowers/plans/part_t7_t8.txt new file mode 100644 index 000000000..3ab9bdf93 --- /dev/null +++ b/docs/superpowers/plans/part_t7_t8.txt @@ -0,0 +1,1536 @@ + +### Task 7: 统一协议感知投影层与跨帧状态机分派器 + +**Files:** +- Create: `proxy/outputguard_projection.go` +- Test: `proxy/outputguard_projection_test.go` +- Modify: `security/outputguard/guard.go:815-840,1050-1250`(为 RequestGuard 增补 `RecordEvent` 独立事件计数与 `ObserveAppendNoEvent`/`ObserveSnapshotNoEvent` 批处理支持) + +**Interfaces:** +- Consumes: + - `security/outputguard`(严格对齐 Part A exact 签名与实现): + ```go + type ChannelKind string + const ( + KindText ChannelKind = "text" + KindThinking ChannelKind = "thinking" + KindToolArguments ChannelKind = "tool_arguments" + ) + type Config struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 + } + type RequestGuard struct { ... } + func NewRequestGuard(cfg Config) *RequestGuard + func (g *RequestGuard) IsTripped() bool + func (g *RequestGuard) TrippedReason() string + func (g *RequestGuard) ObserveAppend(channelID string, aliasID string, kind ChannelKind, delta []byte) error + func (g *RequestGuard) ObserveSnapshot(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error + func (g *RequestGuard) EndChannel(channelID string) error + ``` + - `github.com/tidwall/gjson` (go.mod) + - `github.com/tidwall/sjson` (go.mod) +- Produces: + ```go + package proxy + + import ( + "github.com/codex2api/security/outputguard" + ) + + type ObservationAction string + const ( + ActionAppend ObservationAction = "append" + ActionSnapshot ObservationAction = "snapshot" + ActionEnd ObservationAction = "end" + ) + + type Observation struct { + ChannelID string + AliasID string + Kind outputguard.ChannelKind + Action ObservationAction + Payload []byte + TerminalStage string // 严格白名单限制为 3 种合法语义终态阶段之一: "arguments.done", "item.done", "response.completed",其余普通帧必须置为空字符串 + } + + type ProtocolProjector interface { + Project(eventType string, data []byte) ([]Observation, error) + } + + type ResponsesProjector struct { + aliasMap map[string]string + } + func NewResponsesProjector() *ResponsesProjector + func (p *ResponsesProjector) Project(eventType string, data []byte) ([]Observation, error) + + type ChatProjector struct { + aliasMap map[string]string + } + func NewChatProjector() *ChatProjector + func (p *ChatProjector) Project(eventType string, data []byte) ([]Observation, error) + + type AnthropicProjector struct { + blockTypes map[int]string + blockIDs map[int]string + } + func NewAnthropicProjector() *AnthropicProjector + func (p *AnthropicProjector) Project(eventType string, data []byte) ([]Observation, error) + + type GeminiProjector struct{} + func NewGeminiProjector() *GeminiProjector + func (p *GeminiProjector) Project(eventType string, data []byte) ([]Observation, error) + + // ObserveEvent 核心事件分派器: + // 1. 业务事件计数:严格每上游事件计 1 次,心跳/注释除外;unknown/control/empty 业务帧必须消耗事件预算; + // 2. 调用 projector.Project 解出当前 envelope 的 observations; + // 3. 工具 arguments 仅解当前 envelope 或原生 object bounded serialize,不每 delta 重 parse inner JSON; + // 4. 稳定 channel key 跨帧 alias 映射维护与测试验证; + // 5. 调用 guard 执行判定流水线,端到端保障状态机语义一致性。 + func ObserveEvent(guard *outputguard.RequestGuard, projector ProtocolProjector, eventType string, data []byte) error + ``` + +- [ ] **Step 1: Write failing test for protocol projection and event dispatcher** + +```go +// proxy/outputguard_projection_test.go +package proxy + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/codex2api/security/outputguard" +) + +func defaultProjectionTestConfig() outputguard.Config { + return outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, // 128 KiB + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, // 2 MiB + MaxRequestBytes: 4194304, // 4 MiB + } +} + +// 1. Responses 协议流式、3 个合法语义终态阶段快照与 alias 跨帧映射测试 +func TestResponsesProjectorStreamAndTerminalSnapshots(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewResponsesProjector() + + // Text delta + textDelta := `{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"Hello, "}` + if err := ObserveEvent(guard, proj, "response.output_text.delta", []byte(textDelta)); err != nil { + t.Fatalf("text delta observe failed: %v", err) + } + + // Thinking delta + thinkingDelta := `{"type":"response.reasoning.delta","item_id":"msg_1","delta":"thinking step 1"}` + if err := ObserveEvent(guard, proj, "response.reasoning.delta", []byte(thinkingDelta)); err != nil { + t.Fatalf("thinking delta observe failed: %v", err) + } + + // Tool call delta with call_id and item_id alias + toolDelta := `{"type":"response.function_call_arguments.delta","item_id":"item_call_1","call_id":"call_write_file","output_index":1,"delta":"{\"path\":\"test.txt\""}` + if err := ObserveEvent(guard, proj, "response.function_call_arguments.delta", []byte(toolDelta)); err != nil { + t.Fatalf("tool delta observe failed: %v", err) + } + + // Tool call done (终态阶段 1: arguments.done 纯终态确认快照) + toolDone := `{"type":"response.function_call_arguments.done","item_id":"item_call_1","call_id":"call_write_file","arguments":"{\"path\":\"test.txt\"}"}` + if err := ObserveEvent(guard, proj, "response.function_call_arguments.done", []byte(toolDone)); err != nil { + t.Fatalf("tool done observe failed: %v", err) + } + + // output_item.done (终态阶段 2: item.done 纯终态确认快照) + itemDone := `{"type":"response.output_item.done","item":{"id":"item_call_1","type":"function_call","call_id":"call_write_file","arguments":"{\"path\":\"test.txt\"}"}}` + if err := ObserveEvent(guard, proj, "response.output_item.done", []byte(itemDone)); err != nil { + t.Fatalf("item done observe failed: %v", err) + } + + // response.completed (终态阶段 3: response.completed 纯终态确认快照) + completed := `{"type":"response.completed","response":{"id":"resp_1","status":"completed","output":[{"id":"item_call_1","type":"function_call","call_id":"call_write_file","arguments":"{\"path\":\"test.txt\"}"}]}}` + if err := ObserveEvent(guard, proj, "response.completed", []byte(completed)); err != nil { + t.Fatalf("response completed observe failed: %v", err) + } + + if guard.IsTripped() { + t.Fatalf("expected guard not tripped, got %s", guard.TrippedReason()) + } +} + +// 2. ChatCompletions 协议 choice/toolindex/callid alias 隔离与流式及非流式测试 +func TestChatProjectorStreamingAndNonStreaming(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewChatProjector() + + // 流式 Chunk 1: content + reasoning + tool_call 首帧 (带 call_id 与 index) + chunk1 := `{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"foo","reasoning_content":"think","tool_calls":[{"index":0,"id":"call_123","function":{"name":"search","arguments":"{\"q\":"}}]}}]}` + if err := ObserveEvent(guard, proj, "", []byte(chunk1)); err != nil { + t.Fatalf("chunk 1 observe error: %v", err) + } + + // 流式 Chunk 2: tool_call 后续 delta (仅带 index, 缺失 id, 验证 aliasMap 稳定解析到同一 channel) + chunk2 := `{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"golang\"}"}}]}}]}` + if err := ObserveEvent(guard, proj, "", []byte(chunk2)); err != nil { + t.Fatalf("chunk 2 observe error: %v", err) + } + + // 验证不同 choice 和不同 tool_index 隔离,不发生错合并 + chunkChoice1 := `{"id":"chatcmpl-1","choices":[{"index":1,"delta":{"content":"bar"}}]}` + if err := ObserveEvent(guard, proj, "", []byte(chunkChoice1)); err != nil { + t.Fatalf("chunk choice 1 observe error: %v", err) + } + + // 非流式 JSON 测试 + nonStream := `{"id":"chatcmpl-2","choices":[{"index":0,"message":{"role":"assistant","content":"final content","tool_calls":[{"id":"call_999","type":"function","function":{"name":"calc","arguments":"{\"val\":42}"}}]}}]}` + guardNonStream := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + projNonStream := NewChatProjector() + if err := ObserveEvent(guardNonStream, projNonStream, "", []byte(nonStream)); err != nil { + t.Fatalf("non-stream chat observe error: %v", err) + } + if guardNonStream.IsTripped() { + t.Fatalf("expected non-stream not tripped, got %s", guardNonStream.TrippedReason()) + } +} + +// 3. Anthropic Messages 协议真实 fixture 与 content_block 状态流转测试 +func TestAnthropicProjectorStreamLifecycleAndNonStream(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewAnthropicProjector() + + // message_start + msgStart := `{"type":"message_start","message":{"id":"msg_123","type":"message","role":"assistant","content":[]}}` + if err := ObserveEvent(guard, proj, "message_start", []byte(msgStart)); err != nil { + t.Fatalf("msg start error: %v", err) + } + + // block 0: thinking + b0Start := `{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}` + if err := ObserveEvent(guard, proj, "content_block_start", []byte(b0Start)); err != nil { + t.Fatalf("b0 start error: %v", err) + } + b0Delta := `{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"let me think"}}` + if err := ObserveEvent(guard, proj, "content_block_delta", []byte(b0Delta)); err != nil { + t.Fatalf("b0 delta error: %v", err) + } + b0Stop := `{"type":"content_block_stop","index":0}` + if err := ObserveEvent(guard, proj, "content_block_stop", []byte(b0Stop)); err != nil { + t.Fatalf("b0 stop error: %v", err) + } + + // block 1: tool_use + b1Start := `{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01ABC","name":"exec"}}` + if err := ObserveEvent(guard, proj, "content_block_start", []byte(b1Start)); err != nil { + t.Fatalf("b1 start error: %v", err) + } + b1Delta := `{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"cmd\":\"ls\"}"}}` + if err := ObserveEvent(guard, proj, "content_block_delta", []byte(b1Delta)); err != nil { + t.Fatalf("b1 delta error: %v", err) + } + b1Stop := `{"type":"content_block_stop","index":1}` + if err := ObserveEvent(guard, proj, "content_block_stop", []byte(b1Stop)); err != nil { + t.Fatalf("b1 stop error: %v", err) + } + + // message_stop + msgStop := `{"type":"message_stop"}` + if err := ObserveEvent(guard, proj, "message_stop", []byte(msgStop)); err != nil { + t.Fatalf("msg stop error: %v", err) + } + + // 非流式 Anthropic 响应 + nonStream := `{"id":"msg_456","type":"message","role":"assistant","content":[{"type":"text","text":"Done!"},{"type":"tool_use","id":"toolu_02XYZ","name":"write","input":{"content":"file content"}}]}` + guardAnthropicNonStream := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + projAnthropicNonStream := NewAnthropicProjector() + if err := ObserveEvent(guardAnthropicNonStream, projAnthropicNonStream, "", []byte(nonStream)); err != nil { + t.Fatalf("anthropic non-stream observe error: %v", err) + } + if guardAnthropicNonStream.IsTripped() { + t.Fatalf("expected anthropic non-stream not tripped, got %s", guardAnthropicNonStream.TrippedReason()) + } +} + +// 4. Gemini Native (streamGenerateContent & generateContent) 真实结构与 Object 紧凑序列化测试 +func TestGeminiProjectorStreamAndNonStreamObjectSerialization(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewGeminiProjector() + + // 流式 candidate text + thought + geminiChunk := `{"candidates":[{"index":0,"content":{"role":"model","parts":[{"text":"hello gemini"},{"thought":true,"text":"inner thoughts"},{"functionCall":{"name":"search","args":{"query":"codex"}}}]}}]}` + if err := ObserveEvent(guard, proj, "", []byte(geminiChunk)); err != nil { + t.Fatalf("gemini chunk observe error: %v", err) + } + + // Gemini 非流式带 finishReason + geminiNonStreamJSON := `{"candidates":[{"index":0,"content":{"role":"model","parts":[{"functionCall":{"name":"fetch","args":{"url":"https://example.com","headers":{"Auth":"secret"}}}}]外},"finishReason":"STOP"}]}` + geminiNonStreamJSON = strings.ReplaceAll(geminiNonStreamJSON, "外", "") + + guardGemini := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + projGemini := NewGeminiProjector() + if err := ObserveEvent(guardGemini, projGemini, "", []byte(geminiNonStreamJSON)); err != nil { + t.Fatalf("gemini non-stream observe error: %v", err) + } + if guardGemini.IsTripped() { + t.Fatalf("expected gemini non-stream not tripped, got %s", guardGemini.TrippedReason()) + } +} + +// 5. 事件计数契约:每上游事件一次,unknown/control/empty 业务事件严格扣减事件预算,心跳注释除外 +func TestObserveEventBudgetAccountability(t *testing.T) { + // 极小事件预算配置:MaxRequestEvents = 65536 + guard := outputguard.NewRequestGuard(outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 131072, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2097152, + MaxRequestBytes: 4194304, + }) + proj := NewResponsesProjector() + + // SSE 注释与 ping 帧不消耗业务事件预算 + if err := ObserveEvent(guard, proj, "", []byte(": keep-alive ping\n")); err != nil { + t.Fatalf("comment frame should not fail: %v", err) + } + if err := ObserveEvent(guard, proj, "ping", []byte("{}")); err != nil { + t.Fatalf("ping event should not fail: %v", err) + } + + // 构造 65536 个 unknown / empty 业务事件洪泛 + for i := 0; i < outputguard.MaxRequestEvents; i++ { + // unknown 或 control 事件,虽然投影为 0 个 observation,但必须计入业务事件 + unknownEvent := `{"type":"response.unknown_control_event","id":"ctrl_1"}` + if err := ObserveEvent(guard, proj, "response.unknown_control_event", []byte(unknownEvent)); err != nil { + t.Fatalf("event %d unexpectedly failed: %v", i, err) + } + } + + // 第 65537 个事件必须被 request_events 坚决拦截,不能因 control/unknown 绕过! + err := ObserveEvent(guard, proj, "response.created", []byte(`{"type":"response.created"}`)) + if err == nil || err.Error() != "request_events" { + t.Fatalf("expected request_events rejection for control/unknown flood, got %v", err) + } + if guard.TrippedReason() != "request_events" { + t.Fatalf("expected tripped reason request_events, got %s", guard.TrippedReason()) + } +} + +// 6. 验证 Custom Tool Arguments String 仅解码当前 envelope,不每 delta 重 parse inner JSON +func TestCustomToolArgumentsEnvelopePreservation(t *testing.T) { + guard := outputguard.NewRequestGuard(defaultProjectionTestConfig()) + proj := NewResponsesProjector() + + // 构造未闭合且包含转义与大段文本的 arguments 片段 + incompleteJSONChunk := `{"type":"response.function_call_arguments.delta","call_id":"call_custom","delta":"{\"nested_key\":\"val\\nwith\\\"quotes and unclosed brackets..."}` + if err := ObserveEvent(guard, proj, "response.function_call_arguments.delta", []byte(incompleteJSONChunk)); err != nil { + t.Fatalf("custom tool envelope observe error: %v", err) + } + + if guard.IsTripped() { + t.Fatalf("expected guard not tripped on unclosed inner JSON, got %s", guard.TrippedReason()) + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestResponsesProjector.*|TestChatProjector.*|TestAnthropicProjector.*|TestGeminiProjector.*|TestObserveEvent.*|TestCustomToolArgumentsEnvelopePreservation"` +Expected: FAIL with compilation error: undefined: NewResponsesProjector, NewChatProjector, NewAnthropicProjector, NewGeminiProjector, ObserveEvent + +- [ ] **Step 3: Write minimal implementation** + +在 `security/outputguard/guard.go` 中增补 `RecordEvent` 与无独立事件累加的 `ObserveAppendNoEvent`/`ObserveSnapshotNoEvent` 方法,保障单个上游事件产生多个 observation 时事件预算仅计数 1 次: +```go +// security/outputguard/guard.go (增补支持 ObserveEvent 分派器的事件记账接口) + +// RecordEvent 严格对每上游业务事件执行 1 次计数(步骤 1: request_events 检查) +func (g *RequestGuard) RecordEvent() error { + g.mu.Lock() + defer g.mu.Unlock() + + if !g.cfg.Enabled { + return nil + } + if g.tripped { + return errors.New(g.trippedReason) + } + + g.requestEvents++ + if g.requestEvents > MaxRequestEvents { + return g.tripLocked("request_events") + } + return nil +} + +// ObserveAppendNoEvent 仅执行步骤 2~7 检查并提交,不重复累加 requestEvents +func (g *RequestGuard) ObserveAppendNoEvent(channelID string, aliasID string, kind ChannelKind, delta []byte) error { + g.mu.Lock() + defer g.mu.Unlock() + + if !g.cfg.Enabled { + return nil + } + if g.tripped { + return errors.New(g.trippedReason) + } + + cg, err := g.resolveChannelLocked(channelID, aliasID, kind) + if err != nil { + return err + } + + growth, cost, err := cg.CheckAppend(delta) + if err != nil { + if errors.Is(err, ErrScanBudgetExhausted) { + if cg.kind == KindToolArguments { + return g.tripLocked("tool_argument_bytes") + } + return g.tripLocked("channel_bytes") + } + return g.tripLocked(err.Error()) + } + + if g.requestBytes+growth > g.cfg.MaxRequestBytes { + return g.tripLocked("request_bytes") + } + + g.requestBytes += growth + if err := cg.CommitAppend(delta, growth, cost); err != nil { + return g.tripLocked(err.Error()) + } + return nil +} + +// ObserveSnapshotNoEvent 仅执行步骤 2~7 检查并提交,不重复累加 requestEvents +func (g *RequestGuard) ObserveSnapshotNoEvent(channelID string, aliasID string, kind ChannelKind, snapshot []byte, terminalStage string) error { + g.mu.Lock() + defer g.mu.Unlock() + + if !g.cfg.Enabled { + return nil + } + if g.tripped { + return errors.New(g.trippedReason) + } + + cg, err := g.resolveChannelLocked(channelID, aliasID, kind) + if err != nil { + return err + } + + snapLen := int64(len(snapshot)) + var growth int64 + if snapLen > cg.highwater { + growth = snapLen - cg.highwater + } + if snapLen > cg.maxBytes { + if cg.kind == KindToolArguments { + return g.tripLocked("tool_argument_bytes") + } + return g.tripLocked("channel_bytes") + } + if g.requestBytes+growth > g.cfg.MaxRequestBytes { + return g.tripLocked("request_bytes") + } + + growth, cost, _, err := cg.CheckSnapshot(snapshot, terminalStage) + if err != nil { + if errors.Is(err, ErrScanBudgetExhausted) { + if cg.kind == KindToolArguments { + return g.tripLocked("tool_argument_bytes") + } + return g.tripLocked("channel_bytes") + } + return g.tripLocked(err.Error()) + } + + g.requestBytes += growth + if err := cg.CommitSnapshot(snapshot, growth, cost, terminalStage); err != nil { + return g.tripLocked(err.Error()) + } + return nil +} +``` + +新建 `proxy/outputguard_projection.go`: +```go +// proxy/outputguard_projection.go +package proxy + +import ( + "bytes" + "fmt" + + "github.com/codex2api/security/outputguard" + "github.com/tidwall/gjson" +) + +type ObservationAction string + +const ( + ActionAppend ObservationAction = "append" + ActionSnapshot ObservationAction = "snapshot" + ActionEnd ObservationAction = "end" +) + +type Observation struct { + ChannelID string + AliasID string + Kind outputguard.ChannelKind + Action ObservationAction + Payload []byte + TerminalStage string // 严格限制为 3 种合法语义终态阶段: "arguments.done", "item.done", "response.completed",其余普通帧置空 +} + +type ProtocolProjector interface { + Project(eventType string, data []byte) ([]Observation, error) +} + +// ------------------------------------------------------------- +// 1. ResponsesProjector (HTTP SSE, WebSocket, Realtime Text) +// ------------------------------------------------------------- + +type ResponsesProjector struct { + aliasMap map[string]string +} + +func NewResponsesProjector() *ResponsesProjector { + return &ResponsesProjector{ + aliasMap: make(map[string]string), + } +} + +func (p *ResponsesProjector) Project(eventType string, data []byte) ([]Observation, error) { + if len(data) == 0 { + return nil, nil + } + parsed := gjson.ParseBytes(data) + t := eventType + if t == "" { + t = parsed.Get("type").String() + } + + var observations []Observation + + switch t { + case "response.output_text.delta": + itemID := parsed.Get("item_id").String() + delta := parsed.Get("delta").String() + if itemID == "" { + itemID = fmt.Sprintf("output_%d", parsed.Get("output_index").Int()) + } + observations = append(observations, Observation{ + ChannelID: itemID, + Kind: outputguard.KindText, + Action: ActionAppend, + Payload: []byte(delta), + }) + + case "response.reasoning.delta", "response.thinking.delta": + itemID := parsed.Get("item_id").String() + delta := parsed.Get("delta").String() + if itemID == "" { + itemID = fmt.Sprintf("output_%d", parsed.Get("output_index").Int()) + } + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("%s.thinking", itemID), + Kind: outputguard.KindThinking, + Action: ActionAppend, + Payload: []byte(delta), + }) + + case "response.function_call_arguments.delta": + callID := parsed.Get("call_id").String() + itemID := parsed.Get("item_id").String() + delta := parsed.Get("delta").String() + targetID := callID + if targetID == "" { + targetID = itemID + } + if targetID == "" { + targetID = fmt.Sprintf("output_%d", parsed.Get("output_index").Int()) + } + observations = append(observations, Observation{ + ChannelID: targetID, + AliasID: itemID, + Kind: outputguard.KindToolArguments, + Action: ActionAppend, + Payload: []byte(delta), + }) + + case "response.function_call_arguments.done": + callID := parsed.Get("call_id").String() + itemID := parsed.Get("item_id").String() + args := parsed.Get("arguments").String() + targetID := callID + if targetID == "" { + targetID = itemID + } + observations = append(observations, Observation{ + ChannelID: targetID, + AliasID: itemID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: []byte(args), + TerminalStage: "arguments.done", + }) + + case "response.output_item.done": + item := parsed.Get("item") + itemType := item.Get("type").String() + itemID := item.Get("id").String() + if itemType == "function_call" { + callID := item.Get("call_id").String() + args := item.Get("arguments").String() + targetID := callID + if targetID == "" { + targetID = itemID + } + observations = append(observations, Observation{ + ChannelID: targetID, + AliasID: itemID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: []byte(args), + TerminalStage: "item.done", + }) + } else if itemType == "message" { + text := item.Get("content.0.text").String() + if text != "" { + observations = append(observations, Observation{ + ChannelID: itemID, + Kind: outputguard.KindText, + Action: ActionSnapshot, + Payload: []byte(text), + TerminalStage: "item.done", + }) + } + } + + case "response.completed": + outputs := parsed.Get("response.output").Array() + for _, item := range outputs { + itemType := item.Get("type").String() + itemID := item.Get("id").String() + if itemType == "function_call" { + callID := item.Get("call_id").String() + args := item.Get("arguments").String() + targetID := callID + if targetID == "" { + targetID = itemID + } + observations = append(observations, Observation{ + ChannelID: targetID, + AliasID: itemID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: []byte(args), + TerminalStage: "response.completed", + }) + } else if itemType == "message" { + text := item.Get("content.0.text").String() + if text != "" { + observations = append(observations, Observation{ + ChannelID: itemID, + Kind: outputguard.KindText, + Action: ActionSnapshot, + Payload: []byte(text), + TerminalStage: "response.completed", + }) + } + } + } + + default: + // 兼容非流式完整响应 JSON + if parsed.Get("status").String() == "completed" && parsed.Get("output").Exists() { + for _, item := range parsed.Get("output").Array() { + itemType := item.Get("type").String() + itemID := item.Get("id").String() + if itemType == "function_call" { + callID := item.Get("call_id").String() + args := item.Get("arguments").String() + targetID := callID + if targetID == "" { + targetID = itemID + } + observations = append(observations, Observation{ + ChannelID: targetID, + AliasID: itemID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: []byte(args), + TerminalStage: "response.completed", + }) + } else if itemType == "message" { + text := item.Get("content.0.text").String() + if text != "" { + observations = append(observations, Observation{ + ChannelID: itemID, + Kind: outputguard.KindText, + Action: ActionSnapshot, + Payload: []byte(text), + TerminalStage: "response.completed", + }) + } + } + } + } + } + + return observations, nil +} + +// ------------------------------------------------------------- +// 2. ChatProjector (OpenAI /v1/chat/completions Stream & JSON) +// ------------------------------------------------------------- + +type ChatProjector struct { + aliasMap map[string]string +} + +func NewChatProjector() *ChatProjector { + return &ChatProjector{ + aliasMap: make(map[string]string), + } +} + +func (p *ChatProjector) Project(eventType string, data []byte) ([]Observation, error) { + if len(data) == 0 { + return nil, nil + } + parsed := gjson.ParseBytes(data) + var observations []Observation + + choices := parsed.Get("choices").Array() + for _, choice := range choices { + choiceIdx := choice.Get("index").Int() + + // 流式 delta + delta := choice.Get("delta") + if delta.Exists() { + if content := delta.Get("content").String(); content != "" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("choice_%d.text", choiceIdx), + Kind: outputguard.KindText, + Action: ActionAppend, + Payload: []byte(content), + }) + } + if reasoning := delta.Get("reasoning_content").String(); reasoning != "" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("choice_%d.thinking", choiceIdx), + Kind: outputguard.KindThinking, + Action: ActionAppend, + Payload: []byte(reasoning), + }) + } + toolCalls := delta.Get("tool_calls").Array() + for _, tc := range toolCalls { + toolIdx := tc.Get("index").Int() + callID := tc.Get("id").String() + channelID := fmt.Sprintf("choice_%d.tool_%d", choiceIdx, toolIdx) + if callID != "" { + p.aliasMap[callID] = channelID + } + args := tc.Get("function.arguments").String() + if args != "" { + observations = append(observations, Observation{ + ChannelID: channelID, + AliasID: callID, + Kind: outputguard.KindToolArguments, + Action: ActionAppend, + Payload: []byte(args), + }) + } + } + } + + // 非流式 message + msg := choice.Get("message") + if msg.Exists() { + if content := msg.Get("content").String(); content != "" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("choice_%d.text", choiceIdx), + Kind: outputguard.KindText, + Action: ActionSnapshot, + Payload: []byte(content), + TerminalStage: "response.completed", + }) + } + if reasoning := msg.Get("reasoning_content").String(); reasoning != "" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("choice_%d.thinking", choiceIdx), + Kind: outputguard.KindThinking, + Action: ActionSnapshot, + Payload: []byte(reasoning), + TerminalStage: "response.completed", + }) + } + toolCalls := msg.Get("tool_calls").Array() + for idx, tc := range toolCalls { + callID := tc.Get("id").String() + channelID := fmt.Sprintf("choice_%d.tool_%d", choiceIdx, idx) + args := tc.Get("function.arguments").String() + observations = append(observations, Observation{ + ChannelID: channelID, + AliasID: callID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: []byte(args), + TerminalStage: "response.completed", + }) + } + } + } + + return observations, nil +} + +// ------------------------------------------------------------- +// 3. AnthropicProjector (Claude /v1/messages Stream & JSON) +// ------------------------------------------------------------- + +type AnthropicProjector struct { + blockTypes map[int]string + blockIDs map[int]string +} + +func NewAnthropicProjector() *AnthropicProjector { + return &AnthropicProjector{ + blockTypes: make(map[int]string), + blockIDs: make(map[int]string), + } +} + +func (p *AnthropicProjector) Project(eventType string, data []byte) ([]Observation, error) { + if len(data) == 0 { + return nil, nil + } + parsed := gjson.ParseBytes(data) + t := eventType + if t == "" { + t = parsed.Get("type").String() + } + + var observations []Observation + + switch t { + case "content_block_start": + idx := int(parsed.Get("index").Int()) + block := parsed.Get("content_block") + bType := block.Get("type").String() + p.blockTypes[idx] = bType + if bType == "tool_use" { + p.blockIDs[idx] = block.Get("id").String() + } + + case "content_block_delta": + idx := int(parsed.Get("index").Int()) + delta := parsed.Get("delta") + dType := delta.Get("type").String() + + switch dType { + case "text_delta": + text := delta.Get("text").String() + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + Kind: outputguard.KindText, + Action: ActionAppend, + Payload: []byte(text), + }) + case "thinking_delta": + thinking := delta.Get("thinking").String() + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + Kind: outputguard.KindThinking, + Action: ActionAppend, + Payload: []byte(thinking), + }) + case "input_json_delta": + partJSON := delta.Get("partial_json").String() + callID := p.blockIDs[idx] + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + AliasID: callID, + Kind: outputguard.KindToolArguments, + Action: ActionAppend, + Payload: []byte(partJSON), + }) + } + + case "content_block_stop": + idx := int(parsed.Get("index").Int()) + bType := p.blockTypes[idx] + if bType == "tool_use" { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + AliasID: p.blockIDs[idx], + Kind: outputguard.KindToolArguments, + Action: ActionEnd, + TerminalStage: "arguments.done", + }) + } else { + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + Kind: outputguard.KindText, + Action: ActionEnd, + }) + } + + case "message_stop": + observations = append(observations, Observation{ + ChannelID: "message_root", + Kind: outputguard.KindText, + Action: ActionSnapshot, + Payload: []byte("done"), + TerminalStage: "response.completed", + }) + + default: + // 非流式 JSON (type == "message") + if parsed.Get("type").String() == "message" && parsed.Get("content").IsArray() { + contentBlocks := parsed.Get("content").Array() + for idx, b := range contentBlocks { + bType := b.Get("type").String() + switch bType { + case "text": + text := b.Get("text").String() + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + Kind: outputguard.KindText, + Action: ActionSnapshot, + Payload: []byte(text), + TerminalStage: "response.completed", + }) + case "thinking": + thinking := b.Get("thinking").String() + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + Kind: outputguard.KindThinking, + Action: ActionSnapshot, + Payload: []byte(thinking), + TerminalStage: "response.completed", + }) + case "tool_use": + callID := b.Get("id").String() + inputObj := b.Get("input") + var inputBytes []byte + if inputObj.IsObject() { + inputBytes = []byte(inputObj.Raw) + } else { + inputBytes = []byte(inputObj.String()) + } + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("block_%d", idx), + AliasID: callID, + Kind: outputguard.KindToolArguments, + Action: ActionSnapshot, + Payload: inputBytes, + TerminalStage: "response.completed", + }) + } + } + } + } + + return observations, nil +} + +// ------------------------------------------------------------- +// 4. GeminiProjector (Native streamGenerateContent & generateContent) +// ------------------------------------------------------------- + +type GeminiProjector struct{} + +func NewGeminiProjector() *GeminiProjector { + return &GeminiProjector{} +} + +func (p *GeminiProjector) Project(eventType string, data []byte) ([]Observation, error) { + if len(data) == 0 { + return nil, nil + } + parsed := gjson.ParseBytes(data) + var observations []Observation + + candidates := parsed.Get("candidates").Array() + for candIdx, cand := range candidates { + finishReason := cand.Get("finishReason").String() + parts := cand.Get("content.parts").Array() + for partIdx, part := range parts { + // 1. Text / Thought + if part.Get("text").Exists() { + text := part.Get("text").String() + isThought := part.Get("thought").Bool() + kind := outputguard.KindText + suffix := "text" + if isThought { + kind = outputguard.KindThinking + suffix = "thinking" + } + action := ActionAppend + var stage string + if finishReason != "" { + action = ActionSnapshot + stage = "response.completed" + } + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("candidate_%d.part_%d.%s", candIdx, partIdx, suffix), + Kind: kind, + Action: action, + Payload: []byte(text), + TerminalStage: stage, + }) + } + + // 2. Function Call (Native object bounded serialization) + if fnCall := part.Get("functionCall"); fnCall.Exists() { + name := fnCall.Get("name").String() + args := fnCall.Get("args") + var argBytes []byte + if args.IsObject() { + argBytes = []byte(args.Raw) + } else { + argBytes = []byte(args.String()) + } + action := ActionAppend + var stage string + if finishReason != "" { + action = ActionSnapshot + stage = "response.completed" + } + observations = append(observations, Observation{ + ChannelID: fmt.Sprintf("candidate_%d.part_%d.tool_%s", candIdx, partIdx, name), + Kind: outputguard.KindToolArguments, + Action: action, + Payload: argBytes, + TerminalStage: stage, + }) + } + } + } + + return observations, nil +} + +// ------------------------------------------------------------- +// 5. ObserveEvent Dispatcher (单上游事件严格计 1 次,流水线批调度) +// ------------------------------------------------------------- + +func ObserveEvent(guard *outputguard.RequestGuard, projector ProtocolProjector, eventType string, data []byte) error { + // 心跳/注释帧直接放行,不计入业务事件预算 + trimmed := bytes.TrimSpace(data) + if bytes.HasPrefix(trimmed, []byte(":")) || eventType == "ping" { + return nil + } + + // 步骤 1: 严格每上游业务事件计 1 次 requestEvents(即使是 control/unknown 帧) + if err := guard.RecordEvent(); err != nil { + return err + } + + obs, err := projector.Project(eventType, data) + if err != nil { + return err + } + if len(obs) == 0 { + return nil + } + + // 依次将 observations 推入 guard,执行步骤 2~7 判定流水线 + for _, o := range obs { + switch o.Action { + case ActionAppend: + if err := guard.ObserveAppendNoEvent(o.ChannelID, o.AliasID, o.Kind, o.Payload); err != nil { + return err + } + case ActionSnapshot: + if err := guard.ObserveSnapshotNoEvent(o.ChannelID, o.AliasID, o.Kind, o.Payload, o.TerminalStage); err != nil { + return err + } + case ActionEnd: + if err := guard.EndChannel(o.ChannelID); err != nil { + return err + } + } + } + + return nil +} +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestResponsesProjector.*|TestChatProjector.*|TestAnthropicProjector.*|TestGeminiProjector.*|TestObserveEvent.*|TestCustomToolArgumentsEnvelopePreservation"` +Expected: PASS ok github.com/codex2api/proxy + +- [ ] **Step 5: Commit** + +```bash +git add proxy/outputguard_projection.go proxy/outputguard_projection_test.go security/outputguard/guard.go +git commit -m "feat(proxy): implement protocol projectors and observe event dispatcher with strict event budgeting" +``` + +--- + +### Task 8: 传输 Framing 有界 Reader 与全链路 Callsite 替换 + +**Files:** +- Modify: `proxy/sse_line.go` +- Modify: `proxy/sse_line_test.go` +- Modify: `proxy/executor.go:1592-1715` +- Modify: `proxy/handler.go:5538-5585,7650-7750` +- Modify: `proxy/handler_gemini.go:390-420` +- Modify: `proxy/responses_ws.go:142-190` + +**Interfaces:** +- Consumes: + - Go 标准库 `bufio`, `bytes`, `errors`, `io`, `net/http`, `sync` + - `github.com/gorilla/websocket` (go.mod) +- Produces: + ```go + package proxy + + var ( + ErrSSELineTooLong = errors.New("sse_line_too_long") + ErrSSEEventTooLarge = errors.New("sse_event_too_large") + ErrWSFrameTooLarge = errors.New("ws_frame_too_large") + ErrResponseBodyTooLarge = errors.New("response_body_too_large") + ) + + const ( + MaxSSELineBytes = 8 << 20 // 8 MiB + MaxSSEEventBytes = 8 << 20 // 8 MiB + MaxWSMessageBytes = 8 << 20 // 8 MiB + MaxResponseBodyBytes = 16 << 20 // 16 MiB + ) + + func ReadSSEStreamWithEvent(body io.Reader, callback func(event string, data []byte) bool) error + func ReadBoundedResponseBody(body io.Reader, limit int64) ([]byte, error) + ``` + +- [ ] **Step 1: Write failing test for bounded readers and callsites** + +```go +// proxy/sse_line_test.go (增补 Task 8 有界流与非流式硬 Cap 测试) +package proxy + +import ( + "bufio" + "bytes" + "errors" + "io" + "strings" + "testing" +) + +// 1. 测试跨 Read 块的 split chunk 正常解析 +func TestReadSSEStreamWithEventSplitChunks(t *testing.T) { + input := "data: hello " + input2 := "world\n\n" + r := io.MultiReader(strings.NewReader(input), strings.NewReader(input2)) + + var collected []string + err := ReadSSEStreamWithEvent(r, func(event string, data []byte) bool { + collected = append(collected, string(data)) + return true + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(collected) != 1 || collected[0] != "hello world" { + t.Fatalf("collected want ['hello world'], got %v", collected) + } +} + +// 2. 测试标准多行 data 聚合 +func TestReadSSEStreamWithEventMultilineData(t *testing.T) { + input := "event: update\ndata: line1\ndata: line2\ndata: line3\n\n" + var events []string + var payloads []string + err := ReadSSEStreamWithEvent(strings.NewReader(input), func(event string, data []byte) bool { + events = append(events, event) + payloads = append(payloads, string(data)) + return true + }) + if err != nil { + t.Fatalf("multiline read error: %v", err) + } + if len(events) != 1 || events[0] != "update" || payloads[0] != "line1\nline2\nline3" { + t.Fatalf("multiline want update with line1\\nline2\\nline3, got %v: %v", events, payloads) + } +} + +// 3. 测试无换行符超大单行(never-ending oversized line)硬 cap 拒绝,返回 ErrSSELineTooLong +func TestReadSSEStreamWithEventNoNewlineOversized(t *testing.T) { + // 构造超过 MaxSSELineBytes (8 MiB) 且无换行符的失控流 + oversized := strings.Repeat("x", MaxSSELineBytes+1024) + err := ReadSSEStreamWithEvent(strings.NewReader(oversized), func(event string, data []byte) bool { + return true + }) + if err == nil { + t.Fatalf("expected ErrSSELineTooLong for oversized line without newline, got nil") + } + if !errors.Is(err, ErrSSELineTooLong) { + t.Fatalf("expected ErrSSELineTooLong, got %v", err) + } +} + +// 4. 测试累积多行 data 整事件超标拒绝,返回 ErrSSEEventTooLarge +func TestReadSSEStreamWithEventNeverendingMultilineOversized(t *testing.T) { + // 9 行,每行 1 MiB,累积达 9 MiB > MaxSSEEventBytes (8 MiB) + chunk := "data: " + strings.Repeat("a", 1<<20) + "\n" + multilineOversized := strings.Repeat(chunk, 9) + "\n" + + err := ReadSSEStreamWithEvent(strings.NewReader(multilineOversized), func(event string, data []byte) bool { + return true + }) + if err == nil { + t.Fatalf("expected ErrSSEEventTooLarge for oversized multiline event, got nil") + } + if !errors.Is(err, ErrSSEEventTooLarge) { + t.Fatalf("expected ErrSSEEventTooLarge, got %v", err) + } +} + +// 5. 测试非流式超大 Response Body 有界拦截,返回 ErrResponseBodyTooLarge +func TestReadBoundedResponseBodyOversized(t *testing.T) { + oversizedBody := strings.Repeat("b", MaxResponseBodyBytes+512) + _, err := ReadBoundedResponseBody(strings.NewReader(oversizedBody), MaxResponseBodyBytes) + if err == nil { + t.Fatalf("expected ErrResponseBodyTooLarge for body > 16MiB, got nil") + } + if !errors.Is(err, ErrResponseBodyTooLarge) { + t.Fatalf("expected ErrResponseBodyTooLarge, got %v", err) + } + + // 正常大小读取成功 + normalBody := "{\"status\":\"ok\"}" + data, err := ReadBoundedResponseBody(strings.NewReader(normalBody), MaxResponseBodyBytes) + if err != nil || string(data) != normalBody { + t.Fatalf("normal read error = %v, got %s", err, string(data)) + } +} + +// 6. 验证 Transport Framing 独立于 Guard 开关(关闭语义 guard 仍保留 wire cap) +func TestTransportFramingIndependentOfGuard(t *testing.T) { + // 模拟即使 output_guard_enabled = false + // wire framing cap 仍作为系统安全底线硬性生效 + oversized := strings.Repeat("x", MaxSSELineBytes+100) + err := ReadSSEStreamWithEvent(strings.NewReader(oversized), func(event string, data []byte) bool { + return true + }) + if !errors.Is(err, ErrSSELineTooLong) { + t.Fatalf("wire cap must remain active regardless of guard settings, got %v", err) + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestReadSSEStreamWithEvent.*|TestReadBoundedResponseBody.*|TestTransportFramingIndependentOfGuard"` +Expected: FAIL with compilation error or undefined symbols: ErrSSELineTooLong, ErrSSEEventTooLarge, ReadBoundedResponseBody + +- [ ] **Step 3: Write minimal implementation** + +修改 `proxy/sse_line.go`: +```go +// proxy/sse_line.go +package proxy + +import ( + "bufio" + "bytes" + "errors" + "io" +) + +// SSE 与传输 Wire Limits 属于网络资源硬边界,独立于业务层 output guard 开关 +const ( + MaxSSELineBytes = 8 << 20 // 8 MiB + MaxSSEEventBytes = 8 << 20 // 8 MiB + MaxWSMessageBytes = 8 << 20 // 8 MiB + MaxResponseBodyBytes = 16 << 20 // 16 MiB +) + +var ( + ErrSSELineTooLong = errors.New("sse_line_too_long") + ErrSSEEventTooLarge = errors.New("sse_event_too_large") + ErrWSFrameTooLarge = errors.New("ws_frame_too_large") + ErrResponseBodyTooLarge = errors.New("response_body_too_large") +) + +// readBoundedSSELine 读取单行,严格限制在 limit 字节以内 +func readBoundedSSELine(reader *bufio.Reader, limit int) ([]byte, error) { + var line []byte + for { + fragment, err := reader.ReadSlice('\n') + if len(line)+len(fragment) > limit { + return nil, ErrSSELineTooLong + } + line = append(line, fragment...) + if err != bufio.ErrBufferFull { + return line, err + } + } +} + +// ReadBoundedResponseBody 有界读取非流式响应体 +func ReadBoundedResponseBody(body io.Reader, limit int64) ([]byte, error) { + if limit <= 0 { + limit = MaxResponseBodyBytes + } + lr := io.LimitReader(body, limit+1) + data, err := io.ReadAll(lr) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, ErrResponseBodyTooLarge + } + return data, nil +} +``` + +修改 `proxy/executor.go` 中的 `ReadSSEStreamWithEvent`: +```go +// proxy/executor.go (更新 ReadSSEStreamWithEvent,施加行与多行整事件硬 Cap) +func ReadSSEStreamWithEvent(body io.Reader, callback func(event string, data []byte) bool) error { + buf := sseBufferPool.Get().([]byte) + defer sseBufferPool.Put(buf) + + lineBufPtr := sseLineBufPool.Get().(*[]byte) + lineBuf := (*lineBufPtr)[:0] + defer func() { + if cap(lineBuf) <= 256*1024 { + *lineBufPtr = lineBuf[:0] + sseLineBufPool.Put(lineBufPtr) + } + }() + + var dataLines [][]byte + var eventName string + var currentEventBytes int + + emitEvent := func() (bool, error) { + event := eventName + eventName = "" + currentEventBytes = 0 + if len(dataLines) == 0 { + return true, nil + } + + data := dataLines[0] + if len(dataLines) > 1 { + data = bytes.Join(dataLines, []byte("\n")) + } + isDone := bytes.Equal(data, []byte("[DONE]")) + keepReading := !isDone && callback(event, data) + for i := range dataLines { + dataLines[i] = nil + } + dataLines = dataLines[:0] + return keepReading, nil + } + + consumeField := func(line []byte) error { + if bytes.HasPrefix(line, []byte("data:")) { + data := bytes.TrimPrefix(line, []byte("data:")) + data = bytes.TrimPrefix(data, []byte(" ")) + if currentEventBytes+len(data) > MaxSSEEventBytes { + return ErrSSEEventTooLarge + } + currentEventBytes += len(data) + dataCopy := make([]byte, len(data)) + copy(dataCopy, data) + dataLines = append(dataLines, dataCopy) + return nil + } + if bytes.HasPrefix(line, []byte("event:")) { + event := bytes.TrimPrefix(line, []byte("event:")) + event = bytes.TrimPrefix(event, []byte(" ")) + eventName = string(event) + } + return nil + } + + for { + n, err := body.Read(buf) + if n > 0 { + if len(lineBuf)+n > MaxSSELineBytes { + return ErrSSELineTooLong + } + lineBuf = append(lineBuf, buf[:n]...) + + consumed := 0 + for { + idx := bytes.IndexByte(lineBuf[consumed:], '\n') + if idx < 0 { + if len(lineBuf)-consumed > MaxSSELineBytes { + return ErrSSELineTooLong + } + break + } + + lineEnd := consumed + idx + line := bytes.TrimRight(lineBuf[consumed:lineEnd], "\r") + consumed = lineEnd + 1 + + if len(line) == 0 { + keep, emitErr := emitEvent() + if emitErr != nil { + return emitErr + } + if !keep { + return nil + } + continue + } + + if bytes.HasPrefix(line, []byte(":")) { + continue + } + + if err := consumeField(line); err != nil { + return err + } + } + + if consumed > 0 { + remaining := copy(lineBuf, lineBuf[consumed:]) + lineBuf = lineBuf[:remaining] + } + } + + if err != nil { + if err == io.EOF { + if len(lineBuf) > 0 { + if len(lineBuf) > MaxSSELineBytes { + return ErrSSELineTooLong + } + line := bytes.TrimRight(lineBuf, "\r") + if err := consumeField(line); err != nil { + return err + } + } + _, emitErr := emitEvent() + if emitErr != nil { + return emitErr + } + return nil + } + return err + } + } +} +``` + +**全链路真实 Callsite 替换与集成代码(真实代码,非伪装实现):** + +1. **Responses HTTP SSE 调用点替换 (`proxy/handler.go:5538-5585`)**: +```go +// 在 forwardWithEvent 最前置接入 ObserveEvent: +forwardWithEvent := func(sseEvent string, data []byte) bool { + // 步骤 0: 优先进入全局模型输出失控防护 Guard + if guardCoordinator != nil { + if err := ObserveEvent(guardCoordinator.Guard(), responsesProjector, sseEvent, data); err != nil { + guardCoordinator.TriggerGuard(err) + return false // 立即阻断,不进入后续 provenance/collector/replay + } + } + + if multiAgentV2State.isOptimizedFor(account.ID()) { + data = restoreCodexMultiAgentV2Response(data, multiAgentV2State) + } + streamDiag.markUpstreamFrame() + // ... 后续 compactionProvenancePayloads 记录与 downstream 写入 +``` + +2. **ChatCompletions 调用点替换 (`proxy/handler.go:7650-7750`)**: +```go +// 在 ChatCompletions SSE 循环中: +readErr = readSSEStreamWithContinuousRetryKeepalive(readCtx, resp.Body, func(sseEvent string, data []byte) bool { + if guardCoordinator != nil { + if err := ObserveEvent(guardCoordinator.Guard(), chatProjector, sseEvent, data); err != nil { + guardCoordinator.TriggerGuard(err) + return false + } + } + // ... 既有下游写入逻辑 +}) +``` + +3. **Gemini Native 调用点替换 (`proxy/handler_gemini.go:390-420`)**: +```go +// forwardGeminiNativeStream 替换: +result.readErr = readSSEStreamWithContinuousRetryKeepalive(c.Request.Context(), body, func(sseEvent string, data []byte) bool { + if c.Request.Context().Err() != nil { + return false + } + if guardCoordinator != nil { + if err := ObserveEvent(guardCoordinator.Guard(), geminiProjector, sseEvent, data); err != nil { + guardCoordinator.TriggerGuard(err) + return false + } + } + usage.observe(data) + // ... +}) +``` + +4. **WebSocket ReadPump 帧大小与内存 Cap 替换 (`proxy/responses_ws.go:160-185`)**: +```go +// 在 startResponsesWSReadPump 中强化 SetReadLimit: +conn.SetReadLimit(MaxWSMessageBytes) // 强制 8 MiB 限制 +messageType, reader, err := conn.NextReader() +if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { + // 若超过 MaxWSMessageBytes,底层 gorilla/websocket 返回 ErrReadLimit + // 归因为独立 typed reason: ws_frame_too_large,绝不伪装为 repetition + cancel() + select { + case messages <- responsesWSInboundMessage{turn: turn, err: ErrWSFrameTooLarge}: + default: + } + return + } +} +``` + +5. **非流式 HTTP 响应读取点替换 (`proxy/handler.go`)**: +```go +// 替换所有裸读取 resp.Body 的非流式调用: +// 修改前: rawResp, err := io.ReadAll(resp.Body) +// 修改后: +rawResp, err := ReadBoundedResponseBody(resp.Body, MaxResponseBodyBytes) +if err != nil { + if errors.Is(err, ErrResponseBodyTooLarge) { + log.Printf("上游非流式响应体超标 (limit=%d): %v", MaxResponseBodyBytes, err) + c.JSON(http.StatusBadGateway, gin.H{"error": gin.H{ + "code": "response_body_too_large", + "message": "Upstream response body exceeded transport wire limit", + }}) + return + } + // ... 其他网络读取错误 +} +``` + +- [ ] **Step 4: Run test to verify passing (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestReadSSEStreamWithEvent.*|TestReadBoundedResponseBody.*|TestTransportFramingIndependentOfGuard"` +Expected: PASS ok github.com/codex2api/proxy + +- [ ] **Step 5: Commit** + +```bash +git add proxy/sse_line.go proxy/sse_line_test.go proxy/executor.go proxy/handler.go proxy/handler_gemini.go proxy/responses_ws.go +git commit -m "feat(proxy): enforce bounded SSE stream, multiline event, WS frame, and response body caps" +``` diff --git a/docs/superpowers/plans/part_t9.txt b/docs/superpowers/plans/part_t9.txt new file mode 100644 index 000000000..5f4e64771 --- /dev/null +++ b/docs/superpowers/plans/part_t9.txt @@ -0,0 +1,1025 @@ + +### Task 9: 模型输出失控防护强制终止协调与下游通知原语 + +**Files:** +- Modify: `proxy/handler.go:2343-2375, 2419-2475`(扩展 `streamOutcome` 字段 `guardTripped` 与 `guardReason`,并提供防护状态叠加原语 `OverlayGuardOutcome`) +- Modify: `proxy/stream_flush_writer.go:23-45, 403-465`(为 `streamFlushWriter` 增加 `DiscardBuffered()` 与 `aborted` 标记,确保终止后 `Flush`/`Finalize` 绝不释放残留字节) +- Create: `proxy/outputguard_cancel.go`(定义终止协调器 `GuardTerminationCoordinator`,负责原子标记 `tripped`、冻结首 reason、先于写锁主动 Cancel 上游与 Close body、丢弃在途缓冲) +- Create: `proxy/outputguard_cancel_test.go`(测试上游立即取消与关闭响应体先于写锁、多次并发触发原子幂等、在途缓冲彻底丢弃、typed outcome 不被覆盖) +- Create: `proxy/outputguard_downstream.go`(定义下游通知原语 `SendDownstreamGuardTerminalHTTP` 与 `SendDownstreamGuardTerminalWS`、有界写超时注入 `ApplyDownstreamWriteDeadline`、多协议终态 Envelope 渲染) +- Create: `proxy/outputguard_downstream_test.go`(测试 TryLock 争用非阻塞放弃、锁内 5s deadline 写入、底层不支持 deadline 安全退出不执行无界写、Responses/Chat/Anthropic/Gemini/WS 终态结构断言) + +**Interfaces:** +- Consumes: + - `security/outputguard` (来自 Task 1~3, 定义于 `security/outputguard/guard.go`): + ```go + type Config struct { + Enabled bool + MaxToolArgumentBytes int64 + MaxToolArgumentDeltas int + MaxChannelBytes int64 + MaxRequestBytes int64 + } + type RequestGuard struct { ... } + func NewRequestGuard(cfg Config) *RequestGuard + func (g *RequestGuard) IsTripped() bool + func (g *RequestGuard) TrippedReason() string + ``` + - `api` 统一错误定义 (定义于 `api/errors.go`): + ```go + type ErrorCode string + const ( + ErrCodeServerError ErrorCode = "server_error" + ErrCodeUpstreamError ErrorCode = "upstream_error" + ) + type ErrorType string + const ( + ErrorTypeServer ErrorType = "server_error" + ErrorTypeUpstream ErrorType = "upstream_error" + ) + type APIError struct { + Code ErrorCode `json:"code"` + Message string `json:"message"` + Type ErrorType `json:"type"` + } + func NewAPIError(code ErrorCode, message string, errType ErrorType) *APIError + ``` + - `github.com/gorilla/websocket` (go.mod) + - `github.com/gin-gonic/gin` (go.mod) + +- Produces (供 Task 10 及各协议 Handler 消费的明确签名): + ```go + package proxy + + import ( + "context" + "io" + "net/http" + "sync" + "time" + + "github.com/codex2api/security/outputguard" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + ) + + const ( + GuardTriggeredPublicCode = "model_output_guard_triggered" + GuardTriggeredPublicMessage = "Upstream model output exceeded the gateway safety policy; generation was stopped." + GuardWriteDeadlineTimeout = 5 * time.Second + ) + + // streamOutcome 扩展字段(用于在代理内部全链路传递 Guard 命中状态与类型化原因) + type streamOutcome struct { + logStatusCode int + failureKind string + failureMessage string + failurePayload []byte + penalize bool + capacityShed bool + verifyAccountAuth bool + requestScoped bool + modelUnsupported bool + terminalLocal bool + guardTripped bool // 标记输出失控防护触发 + guardReason string // 冻结的内部 reason:repetition, tool_argument_bytes 等 + } + + // OverlayGuardOutcome 确保 typed guard outcome 具备绝对优先级,绝不被 ctxErr (499) 或 readErr (598) 覆盖 + func OverlayGuardOutcome(outcome streamOutcome, guard *outputguard.RequestGuard) streamOutcome + + // GuardTerminationCoordinator 终止协调器:原子标记 tripped,主动 cancel 上游 context(跳过 5s drain), + // 关闭 HTTP 响应体,清理本地在途缓冲 + type GuardTerminationCoordinator struct { + guard *outputguard.RequestGuard + cancelUpstream context.CancelFunc + body io.Closer + once sync.Once + trippedOutcome streamOutcome + } + + func NewGuardTerminationCoordinator(guard *outputguard.RequestGuard, cancelUpstream context.CancelFunc, body io.Closer) *GuardTerminationCoordinator + func (c *GuardTerminationCoordinator) HandleTripped(reason string, discardBuffers ...func()) streamOutcome + + // DiscardBuffered 清空 streamFlushWriter 内存缓冲与安全窗,确保后续 Finalize 绝不再释放异常内容 + func (w *streamFlushWriter) DiscardBuffered() + + // ApplyDownstreamWriteDeadline 持锁内统一为 ResponseWriter 施加 5s 有界写超时;遇到 ErrNotSupported 显式返回 + func ApplyDownstreamWriteDeadline(w http.ResponseWriter, timeout time.Duration) error + + // 下游尽力交付终态写入原语 + func SendDownstreamGuardTerminalHTTP(c *gin.Context, mu *sync.Mutex, clientGone *bool, protocol string, reason string) error + func SendDownstreamGuardTerminalWS(conn *websocket.Conn, streamID string, reason string) error + + // FormatGuardTerminalEnvelope 渲染各协议标准错误 envelope(用于单元测试与下游写入) + func FormatGuardTerminalEnvelope(protocol string, streamID string) ([]byte, int, error) + ``` + +--- + +#### 9a. 终止协调器、上游立即取消与本地缓冲 Discard 原语 + +- [ ] **Step 1: Write failing test for GuardTerminationCoordinator and buffer discard** + +```go +// proxy/outputguard_cancel_test.go +package proxy + +import ( + "bytes" + "context" + "errors" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/codex2api/security/outputguard" +) + +type recordingCloser struct { + mu sync.Mutex + closed bool + closedAt time.Time +} + +func (r *recordingCloser) Close() error { + r.mu.Lock() + defer r.mu.Unlock() + r.closed = true + r.closedAt = time.Now() + return nil +} + +func (r *recordingCloser) IsClosed() bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.closed +} + +// 1. 验证 guard 命中时,上游 cancel 和 body close 必须先于写锁争用执行 +func TestGuardTerminationPrecedesDownstreamLock(t *testing.T) { + cfg := outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 128 * 1024, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2 * 1024 * 1024, + MaxRequestBytes: 4 * 1024 * 1024, + } + rg := outputguard.NewRequestGuard(cfg) + + upstreamCtx, upstreamCancel := context.WithCancel(context.Background()) + defer upstreamCancel() + + closer := &recordingCloser{} + coordinator := NewGuardTerminationCoordinator(rg, upstreamCancel, closer) + + var downstreamMu sync.Mutex + downstreamMu.Lock() // 模拟下游写锁已被占用 + + var cancelTime time.Time + var lockAttemptTime time.Time + + // 触发 guard 终止 + outcome := coordinator.HandleTripped("repetition", func() { + // 模拟清空本地缓冲 + }) + + // 断言 1: 上游 context 已经立即 canceled + select { + case <-upstreamCtx.Done(): + cancelTime = time.Now() + default: + t.Fatalf("expected upstream context to be canceled immediately") + } + + // 断言 2: HTTP body 已经关闭 + if !closer.IsClosed() { + t.Fatalf("expected upstream body to be closed immediately") + } + + // 断言 3: outcome 为 model_output_guard,且状态码为 502,不计入账号惩罚 + if !outcome.guardTripped || outcome.guardReason != "repetition" { + t.Fatalf("unexpected guard outcome: %+v", outcome) + } + if outcome.logStatusCode != http.StatusBadGateway { + t.Fatalf("expected logStatusCode 502, got %d", outcome.logStatusCode) + } + if outcome.penalize { + t.Fatalf("expected penalize == false for guard failure") + } + + // 模拟后续下游通知尝试获取锁 + lockAttemptTime = time.Now() + if !cancelTime.IsZero() && cancelTime.After(lockAttemptTime) { + t.Fatalf("upstream cancellation must precede downstream lock attempt") + } + downstreamMu.Unlock() +} + +// 2. 验证并发多次调用 HandleTripped 具备原子幂等性,上游 cancel/close 仅调用一次,且 reason 保持首个冻结 +func TestGuardTerminationIdempotentReasonFreeze(t *testing.T) { + cfg := outputguard.Config{ + Enabled: true, + MaxToolArgumentBytes: 128 * 1024, + MaxToolArgumentDeltas: 4096, + MaxChannelBytes: 2 * 1024 * 1024, + MaxRequestBytes: 4 * 1024 * 1024, + } + rg := outputguard.NewRequestGuard(cfg) + + cancelCalls := int32(0) + upstreamCancel := func() { + atomic.AddInt32(&cancelCalls, 1) + } + closer := &recordingCloser{} + coordinator := NewGuardTerminationCoordinator(rg, upstreamCancel, closer) + + var discardCalls int32 + var wg sync.WaitGroup + + for i := 0; i < 20; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + reason := "repetition" + if idx > 0 { + reason = "channel_bytes" + } + out := coordinator.HandleTripped(reason, func() { + atomic.AddInt32(&discardCalls, 1) + }) + if out.guardReason != "repetition" { + t.Errorf("expected frozen reason repetition, got %s", out.guardReason) + } + }(i) + } + + wg.Wait() + + if calls := atomic.LoadInt32(&cancelCalls); calls != 1 { + t.Fatalf("expected cancelUpstream to be called exactly once, got %d", calls) + } + if !closer.IsClosed() { + t.Fatalf("expected body to be closed") + } + if atomic.LoadInt32(&discardCalls) == 0 { + t.Fatalf("expected discard callbacks to be executed") + } +} + +// 3. 验证 streamFlushWriter.DiscardBuffered 使随后的 Finalize 绝不释放任何异常数据 +func TestStreamFlushWriterDiscardBuffered(t *testing.T) { + var sink bytes.Buffer + w := newStreamFlushWriter(&sink, nil) + + // 写入尚未刷新的 SSE 事件碎片与数据 + _ = w.WriteBytes([]byte("data: partial runaway malicious repetition\n\n")) + if sink.Len() > 0 { + t.Fatalf("buffer should not flush prematurely") + } + + // 命中 Guard,调用 DiscardBuffered 清空在途缓冲 + w.DiscardBuffered() + + // 模拟外层 defer w.Finalize() + err := w.Finalize() + if err != nil { + t.Fatalf("Finalize returned error: %v", err) + } + + // 断言:sink 绝无任何字节写出! + if sink.Len() != 0 { + t.Fatalf("expected sink to be empty after DiscardBuffered and Finalize, got %d bytes: %q", sink.Len(), sink.String()) + } +} + +// 4. 验证 OverlayGuardOutcome 不会被下游取消 (499) 或上游读断流 (598) 覆盖 +func TestOverlayGuardOutcomePreservesIdentity(t *testing.T) { + cfg := outputguard.Config{Enabled: true, MaxRequestBytes: 100} + rg := outputguard.NewRequestGuard(cfg) + _ = rg.ObserveAppend("c1", "", outputguard.KindText, bytes.Repeat([]byte("A"), 200)) // 触发 request_bytes + + if !rg.IsTripped() { + t.Fatalf("expected guard to be tripped") + } + + // 模拟网络层产生的下游取消错误 + ctxErr := context.Canceled + readErr := errors.New("read tcp: connection reset by peer") + rawOutcome := classifyStreamOutcome(ctxErr, readErr, nil, false) + + // 若未叠加 guard,默认会被归类为 499 客户端断开 + if rawOutcome.logStatusCode != logStatusClientClosed { + t.Fatalf("raw outcome should be client closed (499), got %d", rawOutcome.logStatusCode) + } + + // 经过 OverlayGuardOutcome 叠加 + finalOutcome := OverlayGuardOutcome(rawOutcome, rg) + + if !finalOutcome.guardTripped { + t.Fatalf("expected guardTripped == true") + } + if finalOutcome.guardReason != "request_bytes" { + t.Fatalf("expected guardReason == request_bytes, got %s", finalOutcome.guardReason) + } + if finalOutcome.logStatusCode != http.StatusBadGateway { + t.Fatalf("expected logStatusCode == 502, got %d", finalOutcome.logStatusCode) + } + if finalOutcome.penalize { + t.Fatalf("expected penalize == false") + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestGuardTerminationPrecedesDownstreamLock|TestGuardTerminationIdempotentReasonFreeze|TestStreamFlushWriterDiscardBuffered|TestOverlayGuardOutcomePreservesIdentity"` +Expected: FAIL with compilation errors: `NewGuardTerminationCoordinator undefined`, `w.DiscardBuffered undefined`, `OverlayGuardOutcome undefined`. + +- [ ] **Step 3: Implement minimal code for coordinator and buffer discard** + +1. 修改 `proxy/handler.go` 增补 `streamOutcome` 字段与 `OverlayGuardOutcome`: + +```go +// proxy/handler.go:2343-2375 +type streamOutcome struct { + logStatusCode int + failureKind string + failureMessage string + failurePayload []byte + penalize bool + capacityShed bool + verifyAccountAuth bool + requestScoped bool + modelUnsupported bool + terminalLocal bool + guardTripped bool // 标记模型输出失控防护触发 + guardReason string // 冻结的内部触发原因 +} + +// OverlayGuardOutcome 检查 RequestGuard 是否已触发;若已触发,无条件提升为 Guard 终态,消除 499/598 覆盖 +func OverlayGuardOutcome(outcome streamOutcome, guard *outputguard.RequestGuard) streamOutcome { + if guard == nil || !guard.IsTripped() { + return outcome + } + outcome.guardTripped = true + outcome.guardReason = guard.TrippedReason() + outcome.logStatusCode = http.StatusBadGateway + outcome.failureKind = "model_output_guard" + outcome.failureMessage = GuardTriggeredPublicMessage + outcome.failurePayload = nil // 杜绝模型内部异常 payload 泄漏进日志或回放 + outcome.penalize = false // 输出异常不是账号故障,严禁惩罚账号 + outcome.terminalLocal = true + return outcome +} +``` + +2. 修改 `proxy/stream_flush_writer.go` 增加 `DiscardBuffered` 支持: + +```go +// proxy/stream_flush_writer.go +type streamFlushWriter struct { + writer io.Writer + flusher http.Flusher + policy string + interval time.Duration + lastFlush time.Time + buffer bytes.Buffer + outputScanner *promptfilter.OutputScanner + scanCarry []byte + writtenBytes atomic.Int64 + diag *streamPhaseDiagnostics + aborted bool // 新增:标记流已废弃,后续所有 Flush/Finalize 均为 no-op +} + +// DiscardBuffered 丢弃所有未落地的在途数据与安全窗,并阻止后续 Finalize 写入 +func (w *streamFlushWriter) DiscardBuffered() { + if w == nil { + return + } + w.buffer.Reset() + w.scanCarry = nil + w.outputScanner = nil + w.aborted = true +} + +func (w *streamFlushWriter) Flush() error { + if w == nil || w.aborted { + return nil + } + ... +} + +func (w *streamFlushWriter) Finalize() error { + if w == nil || w.aborted { + return nil + } + ... +} +``` + +3. 创建 `proxy/outputguard_cancel.go`: + +```go +// proxy/outputguard_cancel.go +package proxy + +import ( + "context" + "io" + "net/http" + "sync" + + "github.com/codex2api/security/outputguard" +) + +// GuardTerminationCoordinator 协调模型失控切断流程,严格保证前置 Cancel 与资源回收 +type GuardTerminationCoordinator struct { + guard *outputguard.RequestGuard + cancelUpstream context.CancelFunc + body io.Closer + once sync.Once + trippedOutcome streamOutcome +} + +// NewGuardTerminationCoordinator 构造终止协调器实例 +func NewGuardTerminationCoordinator(guard *outputguard.RequestGuard, cancelUpstream context.CancelFunc, body io.Closer) *GuardTerminationCoordinator { + return &GuardTerminationCoordinator{ + guard: guard, + cancelUpstream: cancelUpstream, + body: body, + } +} + +// HandleTripped 在 guard 命中时原子调用: +// 1. 冻结首 reason 并生成统一 streamOutcome; +// 2. 先于任何写锁立即主动 Cancel 上游 context 并关闭响应体(跳过 5s drain); +// 3. 执行所有注册的本地缓冲清空回调(如 DiscardBuffered、collector 清空、provenance 清空); +// 4. 并发调用保证幂等,仅首次执行实际清理。 +func (c *GuardTerminationCoordinator) HandleTripped(reason string, discardBuffers ...func()) streamOutcome { + if c == nil { + return streamOutcome{ + logStatusCode: http.StatusBadGateway, + failureKind: "model_output_guard", + failureMessage: GuardTriggeredPublicMessage, + guardTripped: true, + guardReason: reason, + penalize: false, + terminalLocal: true, + } + } + + c.once.Do(func() { + // 1. 立即主动取消上游 context(绝不等待下游锁,跳过 5s usage drain) + if c.cancelUpstream != nil { + c.cancelUpstream() + } + + // 2. 立即关闭上游 HTTP 响应体 + if c.body != nil { + _ = c.body.Close() + } + + // 3. 丢弃本地所有在途缓冲 + for _, discard := range discardBuffers { + if discard != nil { + discard() + } + } + + // 4. 确定冻结的内部 reason + frozenReason := reason + if c.guard != nil && c.guard.IsTripped() && c.guard.TrippedReason() != "" { + frozenReason = c.guard.TrippedReason() + } + + c.trippedOutcome = streamOutcome{ + logStatusCode: http.StatusBadGateway, + failureKind: "model_output_guard", + failureMessage: GuardTriggeredPublicMessage, + guardTripped: true, + guardReason: frozenReason, + penalize: false, + terminalLocal: true, + } + }) + + return c.trippedOutcome +} +``` + +- [ ] **Step 4: Run test to verify it passes (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race ./proxy -run "TestGuardTerminationPrecedesDownstreamLock|TestGuardTerminationIdempotentReasonFreeze|TestStreamFlushWriterDiscardBuffered|TestOverlayGuardOutcomePreservesIdentity"` +Expected: PASS ok github.com/codex2api/proxy + +- [ ] **Step 5: Commit** + +```bash +git add proxy/handler.go proxy/stream_flush_writer.go proxy/outputguard_cancel.go proxy/outputguard_cancel_test.go +git commit -m "feat(proxy): add guard termination coordinator and buffer discard primitives" +``` + +--- + +#### 9b. 下游安全通知写入、TryLock/Deadline 控制与多协议终态 + +- [ ] **Step 1: Write failing test for downstream terminal writer and deadline hooks** + +```go +// proxy/outputguard_downstream_test.go +package proxy + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/codex2api/api" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "github.com/tidwall/gjson" +) + +// 1. 验证 TryLock 锁争用时非阻塞安全放弃,不产生孤儿协程且标记 clientGone +func TestSendDownstreamGuardTerminalHTTPTryLockContention(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + c.Request = req + + var downstreamMu sync.Mutex + clientGone := false + + // 先行持有下游写锁(模拟慢写或 keepalive ticker 正在占用) + downstreamMu.Lock() + + lockHoldReleased := make(chan struct{}) + go func() { + time.Sleep(50 * time.Millisecond) + downstreamMu.Unlock() + close(lockHoldReleased) + }() + + // 在锁被占用的情况下调用 SendDownstreamGuardTerminalHTTP:必须立即非阻塞返回! + err := SendDownstreamGuardTerminalHTTP(c, &downstreamMu, &clientGone, "responses", "repetition") + if err == nil { + t.Fatalf("expected non-nil error when lock is contended") + } + if !clientGone { + t.Fatalf("expected clientGone to be set to true on lock contention") + } + + // 确认 recorder 中绝无任何字节写出(锁被占有时不执行无界等待写) + if w.Body.Len() != 0 { + t.Fatalf("expected no body written on lock contention, got %s", w.Body.String()) + } + + <-lockHoldReleased +} + +// 自定义 Mock ResponseWriter,模拟不支持 SetWriteDeadline (ErrNotSupported) +type unsupportedDeadlineWriter struct { + http.ResponseWriter + writeCalled bool +} + +func (u *unsupportedDeadlineWriter) Write(b []byte) (int, error) { + u.writeCalled = true + return u.ResponseWriter.Write(b) +} + +// 2. 验证底层不支持 ResponseController WriteDeadline 时安全退出,不进行潜在阻塞写 +func TestSendDownstreamGuardTerminalHTTPErrNotSupported(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + unsupported := &unsupportedDeadlineWriter{ResponseWriter: rec} + + c, _ := gin.CreateTestContext(unsupported) + req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + c.Request = req + + // 预先标记已写入头(已 commit 状态) + rec.WriteHeader(http.StatusOK) + + var downstreamMu sync.Mutex + clientGone := false + + err := SendDownstreamGuardTerminalHTTP(c, &downstreamMu, &clientGone, "responses", "repetition") + if err == nil { + t.Fatalf("expected error when deadline is unsupported on raw mock writer") + } + if !clientGone { + t.Fatalf("expected clientGone == true when deadline is unsupported") + } + if unsupported.writeCalled { + t.Fatalf("expected no write when deadline is unsupported") + } +} + +// 3. 验证 HTTP 尚未提交头时返回 502 及公开 JSON 错误,不泄露内部 reason +func TestSendDownstreamGuardTerminalHTTPUncommitted(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + c.Request = req + + var downstreamMu sync.Mutex + clientGone := false + + err := SendDownstreamGuardTerminalHTTP(c, &downstreamMu, &clientGone, "responses", "repetition") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if w.Code != http.StatusBadGateway { + t.Fatalf("expected status 502, got %d", w.Code) + } + + body := w.Body.String() + // 断言公开错误码 + if !strings.Contains(body, GuardTriggeredPublicCode) { + t.Fatalf("expected code %s in body: %s", GuardTriggeredPublicCode, body) + } + // 断言绝不泄露内部 reason "repetition" + if strings.Contains(body, "repetition") { + t.Fatalf("body leaked internal reason 'repetition': %s", body) + } +} + +// 4. 验证各协议流式已提交时生成的终态 Envelope 格式 +func TestFormatGuardTerminalEnvelope(t *testing.T) { + // 4a. Responses SSE -> response.failed,无 response.completed + respData, status, err := FormatGuardTerminalEnvelope("responses", "") + if err != nil { + t.Fatalf("responses envelope error: %v", err) + } + if status != http.StatusOK { + t.Fatalf("responses stream status want 200, got %d", status) + } + respStr := string(respData) + if !strings.Contains(respStr, `"type":"response.failed"`) || !strings.Contains(respStr, GuardTriggeredPublicCode) { + t.Fatalf("responses envelope invalid: %s", respStr) + } + if strings.Contains(respStr, "response.completed") { + t.Fatalf("responses envelope must not contain response.completed") + } + + // 4b. Chat SSE -> error envelope,严禁 [DONE] + chatData, _, err := FormatGuardTerminalEnvelope("chat", "") + if err != nil { + t.Fatalf("chat envelope error: %v", err) + } + chatStr := string(chatData) + if !strings.Contains(chatStr, GuardTriggeredPublicCode) || !strings.Contains(chatStr, `"error":{`) { + t.Fatalf("chat envelope invalid: %s", chatStr) + } + if strings.Contains(chatStr, "[DONE]") { + t.Fatalf("chat envelope must not contain [DONE]") + } + + // 4c. Anthropic SSE -> event: error,严禁 message_stop + anthropicData, _, err := FormatGuardTerminalEnvelope("anthropic", "") + if err != nil { + t.Fatalf("anthropic envelope error: %v", err) + } + anthropicStr := string(anthropicData) + if !strings.Contains(anthropicStr, "event: error\n") || !strings.Contains(anthropicStr, `"type":"error"`) { + t.Fatalf("anthropic envelope invalid: %s", anthropicStr) + } + if strings.Contains(anthropicStr, "message_stop") { + t.Fatalf("anthropic envelope must not contain message_stop") + } + + // 4d. Gemini Native -> 裸 JSON,无 data: 前缀 + geminiData, _, err := FormatGuardTerminalEnvelope("gemini", "") + if err != nil { + t.Fatalf("gemini envelope error: %v", err) + } + geminiStr := string(geminiData) + if strings.HasPrefix(geminiStr, "data:") { + t.Fatalf("gemini envelope must be bare JSON without data: prefix, got: %s", geminiStr) + } + if !strings.Contains(geminiStr, `"status":"UNAVAILABLE"`) || !strings.Contains(geminiStr, `"code":502`) { + t.Fatalf("gemini envelope invalid: %s", geminiStr) + } + + // 4e. WebSocket -> 带 stream_id 的 error 帧 + wsData, _, err := FormatGuardTerminalEnvelope("websocket", "stream_xyz") + if err != nil { + t.Fatalf("ws envelope error: %v", err) + } + wsStr := string(wsData) + if !strings.Contains(wsStr, `"stream_id":"stream_xyz"`) || !strings.Contains(wsStr, GuardTriggeredPublicCode) { + t.Fatalf("ws envelope invalid: %s", wsStr) + } +} + +// 5. 验证 WebSocket 下游失败写入与连接安全关闭 +func TestSendDownstreamGuardTerminalWSSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + + // 调用 SendDownstreamGuardTerminalWS 写入流错误帧 + _ = SendDownstreamGuardTerminalWS(conn, "turn_001", "repetition") + })) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + clientConn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial error: %v", err) + } + defer clientConn.Close() + + _, msg, err := clientConn.ReadMessage() + if err != nil { + t.Fatalf("read error: %v", err) + } + + parsed := gjson.ParseBytes(msg) + if parsed.Get("type").String() != "error" || parsed.Get("stream_id").String() != "turn_001" { + t.Fatalf("unexpected ws frame: %s", string(msg)) + } + if parsed.Get("error.code").String() != GuardTriggeredPublicCode { + t.Fatalf("expected code %s, got %s", GuardTriggeredPublicCode, parsed.Get("error.code").String()) + } +} +``` + +- [ ] **Step 2: Run test to verify failure (Red)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v ./proxy -run "TestSendDownstreamGuardTerminalHTTP.*|TestFormatGuardTerminalEnvelope|TestSendDownstreamGuardTerminalWSSuccess"` +Expected: FAIL with compilation errors: `SendDownstreamGuardTerminalHTTP undefined`, `FormatGuardTerminalEnvelope undefined`. + +- [ ] **Step 3: Implement minimal code for downstream terminal writer** + +创建 `proxy/outputguard_downstream.go`: + +```go +// proxy/outputguard_downstream.go +package proxy + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/codex2api/api" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +const ( + GuardTriggeredPublicCode = "model_output_guard_triggered" + GuardTriggeredPublicMessage = "Upstream model output exceeded the gateway safety policy; generation was stopped." + GuardWriteDeadlineTimeout = 5 * time.Second +) + +// ApplyDownstreamWriteDeadline 使用 Go 1.20+ http.NewResponseController 施加有界写超时; +// 若底层不支持,返回 http.ErrNotSupported +func ApplyDownstreamWriteDeadline(w http.ResponseWriter, timeout time.Duration) error { + if w == nil { + return errors.New("nil response writer") + } + rc := http.NewResponseController(w) + if rc == nil { + return http.ErrNotSupported + } + if timeout <= 0 { + return rc.SetWriteDeadline(time.Time{}) + } + return rc.SetWriteDeadline(time.Now().Add(timeout)) +} + +// FormatGuardTerminalEnvelope 根据协议格式化标准终态错误帧;脱敏输出,绝不回显内部 reason 或模型 payload +func FormatGuardTerminalEnvelope(protocol string, streamID string) ([]byte, int, error) { + normProto := strings.ToLower(strings.TrimSpace(protocol)) + switch normProto { + case "responses": + payload, err := json.Marshal(gin.H{ + "type": "response.failed", + "response": gin.H{ + "created_at": time.Now().Unix(), + "status": "failed", + "error": gin.H{ + "message": GuardTriggeredPublicMessage, + "type": api.ErrorTypeUpstream, + "code": GuardTriggeredPublicCode, + }, + }, + }) + if err != nil { + return nil, 0, err + } + return []byte("data: " + string(payload) + "\n\n"), http.StatusOK, nil + + case "chat": + payload, err := json.Marshal(gin.H{ + "error": gin.H{ + "message": GuardTriggeredPublicMessage, + "type": api.ErrorTypeUpstream, + "code": GuardTriggeredPublicCode, + }, + }) + if err != nil { + return nil, 0, err + } + return []byte("data: " + string(payload) + "\n\n"), http.StatusOK, nil + + case "anthropic": + payload, err := json.Marshal(gin.H{ + "type": "error", + "error": gin.H{ + "type": "api_error", + "message": GuardTriggeredPublicMessage, + }, + }) + if err != nil { + return nil, 0, err + } + return []byte("event: error\ndata: " + string(payload) + "\n\n"), http.StatusOK, nil + + case "gemini": + // Native Gemini SDK 约定:流式末尾输出裸 JSON 错误(无 data: 前缀) + payload, err := json.Marshal(gin.H{ + "error": gin.H{ + "code": http.StatusBadGateway, + "message": GuardTriggeredPublicMessage, + "status": "UNAVAILABLE", + }, + }) + if err != nil { + return nil, 0, err + } + return payload, http.StatusOK, nil + + case "websocket": + payload, err := json.Marshal(struct { + Type string `json:"type"` + StreamID string `json:"stream_id,omitempty"` + Status int `json:"status"` + Error *api.APIError `json:"error"` + }{ + Type: "error", + StreamID: streamID, + Status: http.StatusBadGateway, + Error: api.NewAPIError(GuardTriggeredPublicCode, GuardTriggeredPublicMessage, api.ErrorTypeUpstream), + }) + if err != nil { + return nil, 0, err + } + return payload, http.StatusOK, nil + + default: + // 通用 HTTP 502 JSON + payload, err := json.Marshal(gin.H{ + "error": gin.H{ + "message": GuardTriggeredPublicMessage, + "type": api.ErrorTypeUpstream, + "code": GuardTriggeredPublicCode, + }, + }) + if err != nil { + return nil, 0, err + } + return payload, http.StatusBadGateway, nil + } +} + +// SendDownstreamGuardTerminalHTTP 向下游 HTTP 连接尽力写入 Guard 终态错误帧: +// 1. 若尚未提交 HTTP 头,直接以 502 返回标准 JSON 错误; +// 2. 若已提交流式头,使用 downstreamMu.TryLock() 尝试非阻塞获取锁; +// - 若获取失败(锁争用),立即放弃写入并标记 clientGone = true,绝不引入孤儿 goroutine 挂死; +// 3. 持锁后调用 ApplyDownstreamWriteDeadline 施加 5s 写入超时; +// - 若底层返回 ErrNotSupported 或已断开,立即安全退出放弃写帧,标记 clientGone = true; +// 4. 写入对应协议的终态帧并调用 Flush。 +func SendDownstreamGuardTerminalHTTP(c *gin.Context, mu *sync.Mutex, clientGone *bool, protocol string, reason string) error { + if c == nil || c.Writer == nil { + if clientGone != nil { + *clientGone = true + } + return errors.New("nil context or writer") + } + + // 1. 未提交头的主路径:直接返回 502 JSON + if !c.Writer.Written() { + c.Header("Content-Type", "application/json; charset=utf-8") + c.JSON(http.StatusBadGateway, gin.H{ + "error": gin.H{ + "message": GuardTriggeredPublicMessage, + "type": api.ErrorTypeUpstream, + "code": GuardTriggeredPublicCode, + }, + }) + if clientGone != nil { + *clientGone = true + } + return nil + } + + // 2. 已开始流式写入:有界非阻塞锁获取 + if mu != nil { + if !mu.TryLock() { + // 锁被保活 ticker 或写协程占用,立即放弃尽力交付,标记 clientGone 安全退出 + if clientGone != nil { + *clientGone = true + } + return errors.New("downstream write lock busy; dropped guard terminal notification") + } + defer mu.Unlock() + } + + if c.Request != nil && c.Request.Context().Err() != nil { + if clientGone != nil { + *clientGone = true + } + return c.Request.Context().Err() + } + + // 3. 持锁内设置 5 秒硬超时 + if err := ApplyDownstreamWriteDeadline(c.Writer, GuardWriteDeadlineTimeout); err != nil { + // 底层不支持 SetWriteDeadline(如测试 mock 或部分反代),严禁执行无界阻塞写 + if clientGone != nil { + *clientGone = true + } + return fmt.Errorf("set write deadline failed (%w); safely dropped write", err) + } + defer func() { + _ = ApplyDownstreamWriteDeadline(c.Writer, 0) // 清除超时 + }() + + // 4. 格式化并写入协议终态 + envelope, _, err := FormatGuardTerminalEnvelope(protocol, "") + if err != nil { + return err + } + + if _, err := c.Writer.Write(envelope); err != nil { + if clientGone != nil { + *clientGone = true + } + return err + } + + if flusher, ok := c.Writer.(http.Flusher); ok { + flusher.Flush() + } + + if clientGone != nil { + *clientGone = true + } + return nil +} + +// SendDownstreamGuardTerminalWS 向 WebSocket 写入 stream 关联的错误帧 +func SendDownstreamGuardTerminalWS(conn *websocket.Conn, streamID string, reason string) error { + if conn == nil { + return errors.New("nil websocket conn") + } + + envelope, _, err := FormatGuardTerminalEnvelope("websocket", streamID) + if err != nil { + return err + } + + // WebSocket transport 复用原生 SetWriteDeadline + _ = conn.SetWriteDeadline(time.Now().Add(GuardWriteDeadlineTimeout)) + defer func() { + _ = conn.SetWriteDeadline(time.Time{}) + }() + + return conn.WriteMessage(websocket.TextMessage, envelope) +} +``` + +- [ ] **Step 4: Run test to verify it passes (Green)** + +Run: `GOCACHE=/tmp/go-build GOPATH=/tmp/go go test -v -race ./proxy -run "TestSendDownstreamGuardTerminalHTTP.*|TestFormatGuardTerminalEnvelope|TestSendDownstreamGuardTerminalWSSuccess"` +Expected: PASS ok github.com/codex2api/proxy + +- [ ] **Step 5: Commit** + +```bash +git add proxy/outputguard_downstream.go proxy/outputguard_downstream_test.go +git commit -m "feat(proxy): implement downstream guard terminal writer with TryLock and 5s deadline" +``` diff --git a/docs/superpowers/specs/2026-10-28-model-output-guard-design.md b/docs/superpowers/specs/2026-10-28-model-output-guard-design.md new file mode 100644 index 000000000..78203d555 --- /dev/null +++ b/docs/superpowers/specs/2026-10-28-model-output-guard-design.md @@ -0,0 +1,367 @@ +# Codex2API 全局模型输出失控防护规格 + +> 状态:设计审查验收通过、取舍已由用户确认;新增行为、阈值和接口均为设计规格,尚未实现或经压测验证。 +> 日期:2026-10-28(Asia/Shanghai)。 +> 工作分支:`fix/dsh-stream-runaway-guard`。 +> 实施边界:仅当前 Codex2API 项目;不修改 DSH、客户端、客户端依赖或已安装 npm 包。本轮仅交付 spec,不进入实现计划或编码。 + +## 1. 背景、证据与保护边界 + +### 1.1 用户提供的事故证据 + +模型在 `write` 工具参数的 `content` 中持续生成重复文本,未闭合参数由约 123k 增至 242k chars。用户报告 DSH 每收到一次 arguments delta,都会对已累计的参数全文进行同步解析及修复。debugger 栈、CPU profile、缓冲增长和微基准共同支持以下机制:持续扩大的参数被反复全量扫描,累计计算量可呈 O(n²),拖慢整个 Node 事件循环。上游 socket 的 Recv-Q 增长,关闭 socket 后进程内积压仍在解析,最终只能重启 DSH。 + +这些运行指标来自用户事故记录,本次没有重新复现线上卡死;具体 delta 数量、原始完整流和代理端当时的日志尚未取得。不能把 chars 等同于 UTF-8 bytes,也不能从 socket 排队单独证明所有 CPU 占用均由解析造成。 + +### 1.2 本项目要解决什么 + +在 Codex2API 收到上游模型输出之后、进入无界聚合/私有 replay/下游转发之前,识别明显的持续重复和超过预算的输出,主动结束当前请求,让异常内容不再持续传入客户端。 + +“全局”表示所有受支持的文本/工具生成渠道共用默认策略,不依赖模型名、账号类型、User-Agent 或工具名;不表示某个请求异常时切断所有会话。 + +### 1.3 明确不能保证什么 + +- 代理不能抢占 DSH 的同步解析、清理客户端已有缓冲、停止已经执行的工具或恢复已经卡死的进程。 +- 已写入下游 socket、反向代理及客户端的字节无法撤回;失败终态也可能排在这些字节之后。 +- 有界输出仍可能令某个客户端解析缓慢。默认阈值是风险限制,不是“DSH 永不卡死”的数学保证。 +- 代理只能保证自己不重试本次 guard 故障,不能控制客户端 SDK、DSH 或外部网关是否另发新请求。 +- 重复检测不等于准确判断模型语义是否退化;首期精确识别短周期持续重复,变体循环由硬预算兜底。 + +## 2. 当前实现事实与设计落点 + +本节是当前工作区的只读核查结果,不代表防护已经存在。链接按仓库根目录解析。 + +| 已核对事实 | 对设计的影响 | +| --- | --- | +| [通用 SSE reader]() 对 callback 返回 `false` 的处理是正常返回 `nil`;内部会拼接未完成行及多行 data。 | 仅返回 `false` 不足以保留 guard 原因;必须另有 typed error/结果。拼帧也要有资源边界。 | +| [现有 SSE 大小常量和有界行读取器]() 定义了 8 MiB 的 line/event 上限,但不能据此认定每个 reader 已使用。 | 首期核对并补齐受保护路径,不宣称已有全路径硬上限。 | +| [Responses HTTP 转发]() 在回调最前即通过 `bytes.Clone` 记录 compaction provenance(5543-5546 及 5806),后续解析事件并聚合 output 与下游写入。 | guard 检查必须前置于 compaction provenance 记录与 output 累积,命中事件严禁克隆或落库。 | +| [continuous retry]() 开启后整次 attempt 保持私有;[attempt writer]() 在成功 replay 提交时才执行已有输出策略。 | 新 guard 必须在 attempt 生成期间运行,不能等完整成功后才检查。 | +| [现有输出扫描器]() 属于可关闭的 prompt filter;[writer 配置]() 依赖它的开关。 | 新 guard 与 prompt filter 独立,关闭提示词过滤不应关闭资源保护。 | +| [Responses WS 转发]() 有独立的 collector、转换和 replay 写入;[Gemini native]() 直接写响应。 | 仅挂在 `streamFlushWriter` 上会漏掉部分协议/transport。 | +| [streamOutcome]() 已有 requestScoped/terminalLocal 等语义;[故障分类]() 可能把读写错误归入 499/598。 | guard 必须独立分类,不能被取消后的读错误覆盖成下游断开或普通上游断流。 | +| [continuous retry 选择器]() 存在 catch-all;[自动续想]() 也能再次请求上游。 | guard 的不可重试优先级必须高于所有恢复路径。 | +| [上游 drain context]() 会在普通客户端取消后保留最多 5 秒读取 usage。 | guard 命中必须主动调用上游 cancel,不走普通断线后的继续 drain。 | +| [runtime settings]() 使用内存运行时配置但现无 revision 机制;[response cache settings]() 展示了 `Generation` 版本控制与 DB 事务合并先例;[轮询器]() 提供了 5 秒轮询同步模式。 | 复用项目设置体系,三态指针存储避免零值覆盖 false;5 秒 revision 轮询为新增机制,需参考 response cache 先例落地。 | + +## 3. 目标、非目标与备选方案 + +### 3.1 首期目标 + +1. 对重复工具参数、重复文本/思考以及未重复但无界增长的生成实施可解释的切断。 +2. 在命中事件进入翻译器聚合、replay、缓存和下游之前拒绝该事件。 +3. 检测状态和解析资源有界,避免代理自身重演全文重解析和内存增长问题。 +4. 明确失败终态;不伪造正常完成、工具参数完成或成功缓存。 +5. 当前请求不换号、不自动续想、不透明重试,不影响其他正常请求。 +6. 提供全局设置和脱敏诊断,能够确认策略在哪些渠道生效。 + +### 3.2 非目标 + +- 修改 DSH 的 JSON parser、事件循环、重试、goal 或工具执行机制。 +- 操作客户端主机、杀进程、重启服务、读取客户端 CPU 或 socket。 +- 按某个 provider/model 或 `write` 工具打补丁、全局封禁某个账号。 +- 新增 LLM 审查调用、语义分类模型、自动改写 prompt 或自动拆分写文件任务。 +- 首期识别所有近似循环、语义循环和恶意编码绕过。 +- 全响应缓冲后才放行所有普通流,或默认增加等待窗口来假装可撤回历史输出。 + +### 3.3 方案比较 + +| 方案 | 优点 | 缺点 | 结论 | +| --- | --- | --- | --- | +| 只限制最大长度/时长 | 简单、结果确定 | 重复可能早已造成压力;长时间正常推理容易误判;细碎 delta 仍有风险 | 作为兜底,不单独采用 | +| **协议感知的增量重复检测 + 字节/事件硬预算 + 请求级切断** | 在生成期间工作,跨 delta 检测;覆盖私有 replay;成本可界定 | 需逐协议接入,阈值有兼容性取舍 | **推荐首期方案** | +| 全量保留输出,成功后检查再回放 | 不会在检查前向客户端释放内容 | 破坏普通 streaming 延迟;仍须限制代理端聚合;不能替代在线检查 | 不作为全局默认,不新增一套 replay | + +## 4. 覆盖范围和数据流 + +### 4.1 协议/渠道矩阵 + +| 下游入口 | 首期要求 | +| --- | --- | +| `/v1/responses` HTTP | SSE 与非流式 JSON;直连、中转、协议转换、上游 HTTP/WS 桥接、自动续想及 continuous retry。 | +| `/v1/responses` WebSocket | 每个逻辑 response/stream 独立检测;包含多路复用、私有 replay 和原生转发。 | +| `/v1/realtime` WebSocket 文本子集 | 明确覆盖:通过 [realtimeTextSession]() 桥接至 `responsesWSForwardOptions` 的文本/工具生成子集纳入防护;音频 Modality 由 [normalizeRealtimeTextClientEvent]() 在 `realtimeTextSession` 显式拒绝、不产生生成通道;session 管理帧不参与语义检测。 | +| `/v1/chat/completions` | 流式与非流式;`content`、可见 reasoning 字段、tool calls 与旧 `function_call` 兼容字段。 | +| `/v1/messages` | 流式与非流式;原生 Claude 及其他渠道转换,含 text/thinking/input_json_delta 和完整 tool input。 | +| Gemini `streamGenerateContent` / `generateContent` | text/thought/functionCall,区分增量和完整快照。 | +| 上述入口上的 Grok、Antigravity 及 API Key/账号渠道 | 不得因 native passthrough 或转换路径不同绕过。 | + +专用图片、音频、models、countTokens、用量查询、compact/后台维护接口不在首期语义检测范围。多模态生成中正常的图片/音频二进制或 base64 字段不做文本重复检测,也不计入语义文本预算;仍受各自媒体/传输限制。文本工具参数中携带的 base64 不豁免工具参数预算。Realtime 的音频 Modality 由 `normalizeRealtimeTextClientEvent` 在 `realtimeTextSession` 显式拒绝(返回 `invalid_request_error`)、不产生生成通道,session 管理帧不参与语义检测;其经 `realtimeTextSession` 转发到 Responses WS 的文本/工具 turn 严格继承全局文本与工具 guard 规则,不留语义盲区。 + +“全局完成”必须以覆盖矩阵集成测试为准,不能只接通事故使用的 Responses SSE 就宣布完成。新增生成渠道必须声明增量/快照语义并接入共享 guard。 + +### 4.2 推荐组件职责 + +- **公共策略/状态机**:配置快照、request 预算、channel 状态、重复检测和唯一终止原因;不依赖 Gin、账号调度或 prompt filter。 +- **协议投影器**:把单个有界事件投影为 `append`、`snapshot`、`end` 等语义观察;只解码当前事件,不解析累计未完成的工具 JSON。 +- **终止适配器**:取消上游、丢弃尚未提交内容、映射协议失败、释放租约并写诊断。 + +```text +有界读取/组装上游事件 + → 解析当前协议 envelope + → 投影 text / thinking / tool arguments + → 共享 guard 检查(命中则停止,整事件不再下发,且不进入 provenance/collector/replay) + → 记录 compaction provenance(proxy/handler.go:5543-5546, 5806) + → 既有协议转换/collector/自动续想缓冲/私有 replay + → 既有 prompt output filter + → 下游写入 +``` + +若原生渠道必须先经转换才能提取语义,转换前至少保留 framing/计数保护,语义检查紧接转换且早于会累计正文的消费者。不能先完整聚合无界参数再交给 guard。一个事件投影为多个字段时,先检查全部字段,再允许整个事件产生下游或持久化副作用。 + +### 4.3 状态隔离 + +- request 预算属于一次入口 HTTP 请求或一个 WS 逻辑 turn;不是整个 API Key、账号或长连接。 +- channel key 至少包含 attempt/response 身份、候选项/输出项/工具调用身份及字段种类。text、thinking、不同 tool calls 不拼在一起检测。 +- index 与 item/call ID 通过别名映射指向同一状态,不能靠补发 ID 或交错事件重置计数。 +- channel 结束后保留小型计数信息以识别最终快照,释放扫描窗口;累计创建数受限,不因 end 归还创建预算。 +- 自动续想不重置 request 预算。普通上游故障重试可开启新的 attempt/channel,但仍消耗同一入口请求预算。 +- 最终 replay 输出不再次累计同一份语义内容;所有 attempt 的生成阶段均须检测。 + +## 5. 检测规则与建议默认值 + +数值为首版保守建议,须在实现前评审、在上线前用正常样本与合成退化流验证,并非从事故精确推导的安全边界。 + +### 5.1 硬预算 + +字节按 UTF-8 bytes,使用 int64 累计并在加法前检查溢出;等于上限允许,超过上限拒绝触发事件。 + +| 配置项(拟新增) | 默认值 | 含义 | +| --- | --- | --- | +| `output_guard_enabled` | `false` | 全局启用;采用三态存储(`*bool` 指针语义),旧库缺失时填充默认 `false`,显式 `true`/`false` 必须可持久化且不被零值覆盖。 | +| `output_guard_max_tool_argument_bytes` | 262144(256 KiB) | 每次工具调用完整序列化参数/自定义工具输入上限,不只检查 `content`。 | +| `output_guard_max_tool_argument_deltas` | 4096 | 每次工具调用非空 arguments append 事件数,限制细碎 delta 引起的下游累计重解析成本。 | +| `output_guard_max_channel_bytes` | 2097152(2 MiB) | 单 text/thinking channel 上限;工具用更严格的工具参数上限。 | +| `output_guard_max_request_bytes` | 4194304(4 MiB) | 当前入口请求跨 channel/attempt/自动续想的累计语义预算。 | + +首期固定资源上限:每 request 65536 个业务事件、128 个累计 channel;单 channel 累计扫描预算为 `2 * output_guard_max_channel_bytes`(默认 4 MiB;工具 channel 为更严格的 `2 * output_guard_max_tool_argument_bytes`,默认 512 KiB,见 §5.3);配合每 channel 至多 3 次合法纯终态确认帧的全文 Hash 校验与下发许可(额外上限至多 `3 * limit`,见 §5.3),单 channel 整体最大数据处理与校验量严格受限于 `5 * limit`(普通 channel 10 MiB,工具 channel 1280 KiB)。心跳/ping/SSE 注释不计业务事件;非空与空内容业务事件都计数,避免空 delta 规避事件预算。wire/framing 上限独立于语义预算,不因关闭 guard 而取消。 + +【细 delta 与长推理事件预算量化分析】: +65536 个业务事件配合 2 MiB channel 预算,平均每个事件 32 字节。对于流式长思考(thinking)或细 delta(每次仅 1~4 字节),当思考达到 100~200 KiB 时可能消耗数万事件;或者单请求存在多个并行工具调用且每个工具发生 4096 次 delta 时,事件预算可能先于 2 MiB / 4 MiB 字节预算耗尽。这是防御下游 DSH O(n²) 频繁同步解析的主动设计目标(事件数本身就是驱动 DSH 反复触发解析的直接源头),而非设计缺陷。此风险需在 §9.3 细 delta 样本中专门量化并报告。 + +工具参数长度按 outer envelope 解码后的 arguments 字符串计算,保留其中 JSON 转义,不解析不完整的 inner JSON。原生协议若给出完整 object,使用一次紧凑 JSON 序列化后的 UTF-8 bytes 计数,不逐 delta 重建完整 object。自定义工具原始输入按解码后的 UTF-8 bytes 计数。 + +默认工具上限会拒绝部分合法大文件写入,不做 `write` 白名单或“看起来合法就忽略”例外。管理员可调高,但界面须提示降低对下游全量重解析问题的保护;建议大文件分段生成。这项取舍已由用户确认。 + +### 5.2 持续重复检测 + +首期推荐精确短周期规则,默认启用,独立应用于每个 text/thinking/tool channel: + +1. 维护最近 8192 bytes 的 ring buffer,累计每新增 1024 bytes 到达检查点;检查点按 channel 累计字节位置定义,不按 delta 边界定义。 +2. 窗口已满(满 8192 bytes)时求最短周期 `p`;`1 <= p <= 256` 且对所有 `p <= i < 8192` 满足 `window[i] == window[i-p]` 时命中。 +3. 命中证据是连续 8 KiB 的精确周期窗口;最长周期时也含至少 32 次重复,不要求窗口长度整除周期。由于检查步长为 1 KiB,任意起点的连续周期片段达到 9215 bytes 时,必存在一个完整落入该片段的检查窗口;不声称任意起点恰好 8192 bytes 就一定即时命中。 +4. 使用固定窗口上的线性周期算法,例如 prefix function;不可用灾难性回溯正则,不对全部历史穷举子串。 +5. 大 delta 按相同检查点消费,不只检查最终尾巴;后续正常文本不能抹掉中间检查点已经产生的命中。 +6. **尾部与终态检查规则(end/snapshot 尾检)**:流结束(`end`)或收到非对齐快照(`snapshot`)时,累计字节通常未对齐 1024-byte 检查点或累计长度未满 8192 bytes。为避免短正常文本被误判,同时防止攻击者/异常模型在周期尾部留残缺字节逃逸,尾检规则明确界定如下: + - **未满 8192 窗口**:如果 channel 累计总字节数 `< 8192`,**不判定重复**,直接放行(由硬字节预算兜底,且 8192 窗口是判定周期性重复的数学基线,严禁在几百字节甚至几千字节的正常短文本上缩短窗口强行求周期,杜绝短文本误判)。 + - **已满 8192 窗口但尾部未对齐 1024**:取当前 ring buffer 中最新的完整 8192 bytes 窗口执行一次求周期检查。周期阈值 `1 <= p <= 256` 保持不变,判定条件仍为窗口内严格满足 `window[i] == window[i-p]`。这保证了即时尾检与主检查点的数学一致性,且任意阶段的判定准则完全同构。 +7. 状态跨任意 delta 边界,不依赖“连续几个 delta 相等”,不跨不同字段/调用合并。 + +规则应能检测事故中的持续 `_auditfree.md_pluginless.tmp.md...` 短周期重复,也会拦连续大量单字符输出。不会仅因几行相似 Markdown、循环代码、license、多语言文本或 JSON 尚未闭合就判错。大段有意重复的测试数据仍可能被拦,这是默认安全取舍。 + +首期不做模糊相似度、解码所有 JSON string 内容后的重复判定、压缩率或熵阈值。长周期、间歇或带变化循环可能漏检,由字节/事件预算兜底。 + +### 5.3 delta、done 和 terminal 快照 + +- 同一调用可能经 delta、arguments.done、output_item.done、response.completed.output 多次呈现全文;完整快照是替换/确认,不是 append,不得反复累计同一参数。 +- 每 channel 保留已计费长度高水位;快照增长只补差额,缩短不返还 request 预算。 +- **快照扫描预算、终态确认许可与累计成本界**: + - 审查反馈指出:若单请求有 65536 个事件,每次均发送 2 MiB 替换快照(不增长字节高水位),若全量线性扫描 2 MiB,理论上累计扫描量可能高达 128 GiB 同步全扫;而若采用“首尾指纹一致即短路跳过扫描”的启发式规则,攻击者可反复替换 2 MiB 快照的中部,绕过重复检测并可持续泛洪;进一步地,若对纯终态确认快照实施无界零计费放行,且每次比对需计算 2 MiB 全文 Hash,攻击者可通过 2 MiB 非重复 append 耗尽一半预算,首个 2 MiB 终态快照全扫耗尽剩余扫描预算,随后发送 65536 次同内容 terminal 快照,因 Hash 一致零计费放行,导致代理执行 128 GiB 的 O(n) Hash 计算和巨量下发泛洪。 + - 为此,**彻底废除首尾指纹短路跳过机制,不依赖任何局部采样声称全文相同,且明确 Hash 校验与下发绝非免费工作,必须严格受控**: + - 任何进入重复扫描器的快照必须全量参与线性扫描与周期检测,并统一由**单 channel 累计扫描处理字节预算(cumulative scan budget)**施加严格硬上界: + - 普通 text/thinking channel 的扫描预算上限为 `2 * output_guard_max_channel_bytes`(默认 `2 * 2 MiB = 4 MiB`); + - 工具 channel 遵循与工具参数相匹配的更严格上限:`2 * output_guard_max_tool_argument_bytes`(默认 `2 * 256 KiB = 512 KiB`)。 + - 对于增量 append,滑动扫描消耗的扫描预算恒等于新增字节数;对于完整快照,每次常规扫描按该快照长度扣减扫描预算。 + - **纯终态确认许可(Terminal Confirmation Quota)与成本定量界**: + - 为合法终态链(如流式 delta 结束后相继到来的 `arguments.done`、`output_item.done`、`response.completed`)建立严格的小常数许可机制:每个 channel 分配**最多 3 次纯终态确认许可,且每种合法终态帧类型至多 1 次**(仅覆盖白名单终态帧类型:`arguments.done`、`item.done`、`response.completed` 及其跨协议等价物)。 + - **先检查资格再作全文 Hash(一律无资格绝不提前 Hash)**:收到快照时,先核实该帧是否属于上述白名单终态类型且该类型许可尚未消耗。无资格的快照(非白名单帧、重复同类型帧、中间替换快照、别名复用帧或超出 3 次总配额)**严禁提前计算全文 Hash**,一律视为普通快照走常规计费 `cost = len(snapshot)`,受剩余扫描预算硬限制。 + - **Hash 校验与单遍历流水线**:仅当具备资格时,才允许对快照进行全文 Hash 校验: + - 终态首帧(如 `arguments.done`)在消耗扫描预算执行周期检测时,Hash 计算与周期检测在同一次内存遍历中流水线完成,记录为基准 Hash(`terminal_baseline_hash`); + - 后续具备资格的终态帧(如 `output_item.done`),比对其全文 Hash 与基准 Hash:若严格匹配,确定本次扫描成本 `cost = 0`,消耗本类型许可 1 次,直接放行;若 Hash 不匹配(内容发生变化或被篡改),**该类型许可立即标记为已消耗废弃**,本次快照直接判定为变体快照,扫描成本恢复为全量 `cost = len(snapshot)` 并转入常规扫描预算检查。 + - **独立定量的额外成本界**:3 次纯终态确认许可仅豁免扫描预算扣减,其额外引入的物理 Hash/比较/下发工作量严格有界,单 channel 最多额外处理 `3 * channel_limit` 字节(普通 channel 最多 `3 * 2 MiB = 6 MiB`,工具 channel 最多 `3 * 256 KiB = 768 KiB`)。单 channel 包含常规扫描预算(`2 * limit`)与额外终态许可(`3 * limit`)在内的**整体最大数据处理与校验量严格受限于 `5 * limit`**(普通 channel 10 MiB,工具 channel 1280 KiB;单请求 128 channel 累计处理量绝对上限为 1280 MiB ≈ 1.25 GiB),彻底终结无界 Hash 与下发泛洪。 + - **唯一明确的判定优先级与执行顺序**:每个事件(无论是 append 还是快照)在进入 guard 时,严格按以下步骤依次执行判定,杜绝未定义的歧义与判定冲突(将资格/等价性检查与 cost 计算严格前置于预算检查,消除正常终态在预算为 0 时被误拒的矛盾): + 1. **事件预算检查**:请求级业务事件计数累加 1;若 `request_events > 65536`,立即拒绝,reason 固定为 `request_events`。 + 2. **Channel 数量检查**:若为新 channel,检查请求级累计 channel 计数;若 `channel_count > 128`,立即拒绝,reason 固定为 `channel_count`。 + 3. **Delta 计数检查(仅工具 channel 且为增量 append)**:若工具调用发生非空 arguments delta append,该 channel 的 delta 计数累加 1;若 `tool_argument_deltas > output_guard_max_tool_argument_deltas`(默认 4096),立即拒绝,reason 固定为 `tool_argument_deltas`。 + 4. **语义字节预算检查(已计费长度高水位推进)**: + - 若为 append:计算拟推进的高水位 `new_highwater = current_highwater + len(delta)`; + - 若为快照:若快照长度 `len(snapshot) > current_highwater`,则拟推进高水位为 `new_highwater = len(snapshot)`,增量 `growth = len(snapshot) - current_highwater`;若 `len(snapshot) <= current_highwater`,高水位不增(`growth = 0`),且不返还 request 预算。 + - 检查 Channel 字节上限:工具 channel 若 `new_highwater > output_guard_max_tool_argument_bytes`(默认 256 KiB),立即拒绝,reason 固定为 `tool_argument_bytes`;普通 channel 若 `new_highwater > output_guard_max_channel_bytes`(默认 2 MiB),立即拒绝,reason 固定为 `channel_bytes`。 + - 检查 Request 语义字节预算:若 `request_bytes + growth > output_guard_max_request_bytes`(默认 4 MiB),立即拒绝,reason 固定为 `request_bytes`。 + - 仅当上述检查全部通过后,正式提交高水位与 `request_bytes` 的更新。 + 5. **资格/等价性核验与扫描成本确定(前置确定成本)**: + - 若为增量 append:确定扫描成本 `cost = len(delta)`; + - 若为完整快照: + - **资格检查**:核实当前 channel 是否已记录 `terminal_baseline_hash`,且当前帧属于合法终态类型(`arguments.done`、`item.done`、`response.completed` 及其协议等价物),且该帧类型许可未被消耗(`terminal_permits_used < 3` 且本类型未见)。 + - **具备资格**:计算当前快照的全文 Hash 并与 `terminal_baseline_hash` 进行比对。 + - 若 Hash 完全一致:判定为合法纯终态确认帧,标记该帧类型许可已消耗(`terminal_permits_used++`),**确定本次扫描成本 `cost = 0`**; + - 若 Hash 不一致(变体快照):标记该类型许可已废弃消耗,**确定本次扫描成本 `cost = len(snapshot)`**。 + - **无资格**(未处于终态阶段、非终态类型、重发同类型、超出 3 次许可或别名复用):严禁提前进行任何全文 Hash 计算,**直接确定本次扫描成本 `cost = len(snapshot)`**。 + 6. **扫描预算扣减与检查**: + - 若 `cost > 0`:检查该 channel 剩余扫描预算。若 `remaining_scan_budget < cost`(包括剩余预算已为 0 或不足以支付本次快照扫描),**拒绝执行超额扫描**,立即拒绝触发事件并终止请求,reason 固定为:工具 channel 为 `tool_argument_bytes`,普通 channel 为 `channel_bytes`(沿用现有稳定 reason,不引入未定义的新 reason)。扣减扫描预算:`remaining_scan_budget -= cost`。当 `remaining_scan_budget == cost` 时扣减后恰为 0,允许本次扫描执行; + - 若 `cost == 0`(经步骤 5 严格核验通过的纯终态确认快照):不扣减扫描预算,跳过步骤 7 的重复周期扫描(其基准内容已在首帧通过全量检测),直接放行该终态事件。 + 7. **周期性重复检测扫描**: + - 仅在 `cost > 0` 且扣减对应扫描预算后执行。 + - 执行全量线性周期检测(使用固定 ring buffer 或 prefix function 线性求周期);若当前为终态首帧,在扫描遍历中同步流水线计算并保存 `terminal_baseline_hash`。若命中连续 8 KiB 短周期(`1 <= p <= 256`),立即拒绝,reason 固定为 `repetition`。 + - 若未命中,放行该事件。 + 8. **终态链兼容性保证(正常流不误拒分析)**: + - 在合法的生命周期链中(例如流式 delta 累计至 256 KiB 工具参数,后随 `arguments.done`、`output_item.done`、`response.completed.output` 3 次相同完整快照确认): + - 工具 channel 的累计扫描预算为 `2 * 256 KiB = 512 KiB`。流式 delta append 阶段累计消耗 256 KiB 扫描预算;剩余扫描预算恰好为 `512 - 256 = 256 KiB`。 + - 当紧随其后的首个合法完成快照(如 `arguments.done`,256 KiB)到达时,因尚无 baseline hash,走步骤 5 无免除资格,确定 `cost = 256 KiB`;步骤 6 发现 `cost == remaining_scan_budget`,扣减后剩余预算恰为 0;在步骤 7 完成最后一次全量扫描并通过,同时同遍历计算并记录 `terminal_baseline_hash`,放行该帧; + - 当紧接着的 `output_item.done` 与 `response.completed` 到达时,在步骤 5 通过资格审查并经 Hash 比对一致,确定 `cost = 0` 并分别消耗 1 次许可;在步骤 6 中即使 `remaining_scan_budget == 0`,因 `cost == 0` 顺利通过并直接放行,彻底消除了旧版在剩余扫描预算为 0 时误拒正常终态帧的顺序矛盾; + - 若上游在后续终态帧中篡改内容或发送变体快照,步骤 5 比对 Hash 失败,废除许可并确定 `cost = 256 KiB`,在步骤 6 因 `remaining_scan_budget (0) < cost (256 KiB)` 被坚决拦截并以 `tool_argument_bytes`(普通 channel 为 `channel_bytes`)终止;若上游重发同类型终态帧或发送第 4 次终态确认帧,在步骤 5 因无资格直接确定 `cost = 256 KiB`,同样在步骤 6 被立即拦截,杜绝任何利用变体或泛洪快照绕过防护与无界消耗 CPU 的可能。 +- 每个接受的业务事件都计事件预算,因此即使是零重复快照也必须计入 `request_events`,反复发送同一快照不能无界消耗事件数。 +- done-only、terminal-only、非流式 JSON 同样检查;此前未出现的工具调用/文本创建 channel 并计费。 +- 最终内容不一致时额外执行完整检查,不因流式阶段通过就豁免。完整快照可用临时有界检测器,不能释放既有预算。 +- 任一项触发则整事件拒绝。绝不截前 N bytes 后当作有效完整工具调用返回。 + +### 5.4 算法成本与 framing + +append 的累计扫描成本应为 O(N),不因累计参数长度增长而对每个 delta 全文扫描。8192-byte 窗口每 1024-byte 检查一次属于固定倍数工作。完整快照受单 channel `2 * max_channel_bytes` 累计扫描预算硬限制;配合至多 3 次合法纯终态确认许可的全文 Hash 校验与下发(额外至多 `3 * max_channel_bytes`),单 channel 总数据处理与校验量严格受限于 `5 * max_channel_bytes`(普通 channel 最多处理 10 MiB,工具 channel 最多处理 1280 KiB;单请求 128 channel 最多处理 1280 MiB ≈ 1.25 GiB),杜绝 128 GiB 同步全扫与 Hash 泛洪风险。原声称未计费工作也满足 `2 * limit`(如单 channel 4 MiB、单请求 512 MiB)的界不再严格成立,现已统一修正为上述严格定量界(扫描计算量至多 `2 * limit`,校验与处理总量至多 `5 * limit`)。 + +活动 channel 的窗口与辅助数组按常量空间分配,终止/结束后释放;不能保存全部历史 delta、参数或每个检查点的副本。request 最大额外状态须在实现基准中报告。 + +受保护 SSE reader 必须在拼接前限制未完成行和 event,默认复用现有 8 MiB 资源上限。native reader/WS/媒体已有更高上限时,保留明确的 transport 限制,不用工具预算代替媒体帧上限,不改变专用媒体接口。先限制 envelope 大小再解析,非法/过大 frame 走资源错误,不尝试 repair。 + +非流式仍须有该渠道明确的有界读取策略,缺失时在接入前补齐,并为媒体路径保留单独预算。本规格不宣称语义预算限制了所有媒体内存。 + +## 6. 命中后的强制终止契约 + +### 6.1 原子、幂等和错误归因 + +状态为 `active → tripped → closed`,第一个 guard 原因固定;命中后后续模型事件不再进入消费者。释放/取消可重复调用,下游最多尝试写一个 guard 终态。 + +拟使用稳定公开 code `model_output_guard_triggered`,内部 reason:`repetition`、`tool_argument_bytes`、`tool_argument_deltas`、`channel_bytes`、`request_bytes`、`request_events`、`channel_count`。framing 资源故障保留独立原因,不伪装重复。 + +命中必须优先于它造成的 `context.Canceled`、read/write error、EOF 和迟到成功终态分类,不得落为普通 499、断流、鉴权失败或成功。typed guard 标记须保留到最外层 request outcome。 + +### 6.2 立即停止上游与本地缓冲 + +1. 固定 guard 状态和原因,再主动 cancel **本次上游** context,关闭 HTTP body 或废弃所属上游 WS lease/连接。 +2. 不走普通断线后为 usage 继续 drain 5 秒的路径,不等上游 completed。 +3. 丢弃尚未提交的 replay、collector、自动续想、pending-first-token、扫描窗及 flush/coalesce 缓冲;不能在 `defer Finalize/Flush` 中再次释放异常内容。 +4. callback 停止并保留 typed error。仅返回 false、仅停下游写、只 cancel 下游 context,均不算完整实现。 +5. 失败通知使用受限独立写入路径,不重经已拒绝的 guard,也不顺便 flush 旧内容。复用现有写锁与下游有界写超时,断开或阻塞时不无限等通知送达。 + - **执行前置与锁解耦原则**:guard 命中时**第一动作必须是在前主动 cancel 本次上游 context 并丢弃本地在途缓冲(步骤 1-3),绝不等待写锁**;通知写入属于尽力交付(best-effort),锁争用绝不延迟或阻塞上游 cancel 与资源回收。 + - **HTTP SSE 终态写入有界锁获取与安全写超时路径**:不同于 WebSocket transport 原生具备 `responsesWSWriteTimeout`(30s)以及 `conn.SetWriteDeadline`,标准 Go `net/http` ResponseWriter 仅通过抽象接口与下游交互。在当前 Gin 1.12 与 Go 1.26 运行时下,底层网络写入、锁竞争与超时控制遵循明确工程约束与安全路径: + - **有界锁获取(TryLock / 有界等待)**:终态通知写入尝试获取 `downstreamMu` 互斥锁。为杜绝锁被保活 ticker 或普通写协程长期持有(或发生未预期阻塞)而导致通知协程无限挂起,终态写入**获取锁必须严格有界**:采用 `TryLock` 进行非阻塞加锁(或带短超时尝试);若加锁失败,立即放弃发送下游错误通知并记录脱敏审计日志,**绝对不引入孤儿等待协程**,直接进入连接关闭与主流程退出。 + - **持锁内全部写操作设 Deadline 规范(约束现有事实)**:在成功取得 `downstreamMu` 后,明确 5 秒写超时**仅约束持锁后的实际写入阶段**。同时规格明确建立约束:**锁内发生的所有写操作(包括日常数据帧写入、保活 ticker 发送注释 keepalive 以及 guard 终态写入)均必须在写操作前调用 `rc.SetWriteDeadline(time.Now().Add(writeTimeout))`**,彻底杜绝任何无 deadline 的持锁写入导致下游慢客户端反向卡死代理写锁。 + - **ResponseController 主路径**:Go 1.20+ 引入且 Gin 1.12 完全支持的 `http.NewResponseController(c.Writer)` 为标准主路径,持锁后终态写入前调用 `rc.SetWriteDeadline(time.Now().Add(writeTimeout))`(如 5 秒),将写超时推入底层 TCP/TLS 连接,随后执行错误帧写入与 Flush。 + - **不可达降级分支与绝对安全退出**:若 `SetWriteDeadline` 返回 `http.ErrNotSupported`(例如某些反向代理封装或自定义 Mock ResponseWriter 不支持 deadline 控制),规格明确**不承诺无法兑现的全局 5 秒写超时保证**。在此类不支持 deadline 的底层上,写入前先检查 `c.Request.Context().Err()`:若下游已断开立即放弃写入;若未断开,亦**严禁尝试潜在无界阻塞的同步 write**,代理直接放弃错误帧写入并安全退出,立即关闭或废弃连接。 + - **严禁使用已失效机制与不受控并发**:Gin 在已写入响应体(SSE headers 与前置数据 chunk 已发出)之后,调用 `c.Writer.Hijack()` 会因底层已被包装接管或状态冲突而失败,因此**彻底废除将 Hijack 作为 SSE 写入后的兜底路径**;同时**严禁通过启动孤儿 goroutine + timer 拼死写入**,杜绝协程泄露与连接竞态。 + - **HTTP/2 协议现实**:在 HTTP/2(h2c / HTTP/2 下游)传输下,`http.ResponseWriter` 不支持 Hijack 且 `SetWriteDeadline` 对单个 stream 的写入控制取决于 Go 标准库具体行为,不能假设支持底层连接级别劫持,遇到不支持即按上述安全退出路径放弃写帧、关闭/重置 stream,保护代理主流程即刻回收。 + - 若写入阻塞、超时或报错,立即关闭连接或标记 `clientGone`,直接退出,不阻塞代理的主流程回收。 +6. 释放账号/API Key 并发、WS pending request/lease、replay 文件和 goroutine,不关闭共享账号上的无关连接。 +7. 中止且未收到真实终态的上游 WS 不归还健康池,不让迟到帧污染下一请求。多路下游优先只终止所属 stream;若协议无法隔离,关闭相应连接并记录影响范围,不无依据承诺其他复用流不受影响。 + +### 6.3 禁止代理内部重新拉起 + +guard 是请求级硬终态,不可由普通重试、首包前静默换号、continuous retry selective/catch-all、WS→HTTP fallback、缺失上下文恢复、自动压缩、自动续想重放。 + +不可仅设置 `penalize=false` 或在默认 retry code 集合中排除;须在各选择器及 fallback/continuation 入口优先识别 typed guard。取消前排队的本次后续 attempt 不再 dispatch。 + +不扣账号健康分、不封禁账号、不触发 token refresh 或模型冷却。输出退化不是账号故障的充分证据。 + +### 6.4 下游协议行为 + +| 状态/协议 | 结果 | +| --- | --- | +| HTTP 尚未提交头 | HTTP 502,入口协议标准错误 envelope,稳定 guard code 及脱敏消息;不是请求体过大,不用 413。 | +| Responses SSE 已开始 | 一次 `response.failed`,status=failed,保留已知 response identity;`response.error.code=model_output_guard_triggered`;不追加 completed。 | +| Chat SSE 已开始 | 标准 error envelope,`error.code=model_output_guard_triggered`;不追加成功 finish chunk 或 `[DONE]`。 | +| Anthropic SSE 已开始 | `event: error` 标准结构,携带 guard code;不伪造成功 message_stop/tool input 完成。 | +| Gemini 已开始 | 复用项目 native Gemini 客户端兼容的错误输出方式并保留诊断;不能假设新增 `event:error` 会被 SDK 识别。 | +| Responses WS | 当前 response/stream 的失败事件,保留 stream/response 关联,不伪造全连接成功或其他 stream 终态。 | +| 非流式 | 只返回错误,不返回部分工具参数或半个 JSON 成功响应。 | + +公开消息示例:`Upstream model output exceeded the gateway safety policy; generation was stopped.` 可公开短 reason 枚举,不回显模型原文、工具路径、prompt 或密钥。连接不可写时只记录无法通知,不把 outcome 改成普通断流。 + +### 6.5 持久化、缓存和计费 + +- 不写成功 response cache、续链快照、成功回放或成功上下文;未成功交付的 response ID 不新增为可用续链绑定。 +- 已发送的 ID/部分内容无法撤回,本地标明失败,不能在后续重建时当作完整成功响应。 +- usage 仅用命中前可靠观测数据;未知保持未知,不伪造精确 token,不为补账继续读取失控流。 +- 请求/用量日志记录独立失败类别 `output_guard`;流已发 200 也按失败记,不根据 HTTP 状态判成功。 +- guard 不进入 prompt 风险封禁、CYB strike 或账号健康惩罚体系。 + +## 7. 全局配置与管理界面 + +新增独立“模型输出失控防护”设置分组,不放在提示词过滤开关下面。提供 §5.1 五项设置、单位、默认值、风险说明、恢复默认。固定重复算法参数及 channel/事件资源上限首期不开放任意调整。 + +校验建议:工具 bytes 为 16 KiB~4 MiB;工具 deltas 为 256~65536;channel bytes 为 128 KiB~16 MiB;request bytes 为 256 KiB~64 MiB;满足工具 bytes ≤ channel bytes ≤ request bytes。数值项仅接受整数,开关仅接受布尔值;零/负数、溢出和本组未知字段拒绝。调高字节阈值不自动放宽其他上限。 + +复用 admin settings GET/PUT 和 `RuntimeSettings` 快照,持久化/迁移覆盖 SQLite/PostgreSQL。旧字段缺失补默认,不能用 bool 零值覆盖显式关闭(存储层和内存结构统一采用 `*bool` 三态指针语义,缺失映射为 default `false`,显式 `true`/`false` 则准确反序列化并落库);写入仅更新本组字段,使用短事务/原子合并(参考 `database/response_cache_settings.go` 的独立窄更新模式),避免陈旧整份 settings 覆盖无关设置。 + +入口取得不可变快照与 revision(`generation`);长连接每个新逻辑 turn 取快照,在途不热切换。保存后本实例立即应用;多实例通过每 5 秒重载防护配置 revision 更新(标注:此为新增机制,现有 `RuntimeSettings` 无 revision 机制,本设计明确参考已有的 `database/response_cache_settings.go` 之 `Generation` 自增版本控制及 `proxy/response_cache_settings.go` 的 5 秒后台轮询 poller 先例落地)。更新失败保留 last-known-good 并记录诊断,不回退关闭。 + +首次启动冷启动无有效配置且 DB 读取失败时不接受生成流量,不能静默关闭。冷启动拒绝时统一遵循协议形态返回 **HTTP 503 Service Unavailable**,并携带稳定公开错误码 `api.ErrCodeServiceUnavailable`(如 `service_unavailable`)与明确描述(如 `Service configuration unavailable; failed to load output guard settings`),而不是混用 guard 运行时的命中错误码(`model_output_guard_triggered`)。对于 WebSocket / Realtime 连接,在连接握手或首帧响应时返回带有 503 / CloseTryAgainLater 语意的协议错误帧并关闭连接。旧字段缺失和 DB 不可用是两种严格区分的情况。 + +仅管理员能全局关闭;不接受请求头、模型输出、普通 API Key 参数覆盖。关闭状态在管理界面显示高风险提示并有审计,transport/framing 资源限制仍保留。 + +## 8. 可观测性与隐私 + +每次命中一次结构化事件,复用请求/用量日志:request ID、stream ID、endpoint、渠道、模型、账号 ID、attempt、配置 revision、reason、limit、observed bytes/events、适用时的重复周期/窗口、已写下游字节数、头是否提交、取消和释放结果。 + +不默认记录完整参数、尾部样本、文件内容、prompt、API Key、Bearer token 或账号凭据,也不把全文藏进 `failureMessage`/`failurePayload`。 + +日志按 `output_guard`/reason 筛选;统计区分重复、工具预算、请求预算及无法通知。指标标签只用有界维度,不将 request ID、call ID 或全文 hash 当作高基数 label。 + +## 9. 验收规格 + +均使用合成/脱敏 fixture、mock upstream 和测试客户端,不向线上 DSH 注入失控流,不依赖真实账号或付费模型。 + +### 9.1 检测正确性 + +1. 有效 outer SSE JSON、未闭合 inner arguments、持续事故样式短周期重复:在重复阈值附近触发,不等到 256 KiB。 +2. 同语义流按 1-byte、小/大 delta、随机边界分割均可检测,切分不能让重复消失。 +3. 单大 delta 中间有任意起点、长度至少 9215 bytes 的精确短周期片段,尾部正常仍触发,整事件下游不可见;另测恰好 8192 bytes 的检查点对齐与非对齐边界,不断言非对齐片段必命中。 +4. 多 tool/text/thinking 交错不互相污染,ID/index 补发不重置计数。 +5. 非重复参数超 bytes、细碎参数超 delta、跨 channel 绕单项上限、空 delta 洪泛、新 channel 洪泛,分别由对应预算拦截。 +6. 等值允许、超一拒绝;中文、emoji、escapes、int64 overflow 均测试。 +7. done-only、terminal-only、native object、非流式、更大/变化的最终快照不能绕过。 +8. delta → done → item.done → completed 同全文不重复扣字节;对于具备资格且全文 Hash 完全一致的终态确认快照实施零计费放行,受每 channel 最多 3 次小常数许可约束且严格消耗 `request_events`;构造具体可测用例: + - 用例 8a(恰好耗尽与超额拒绝):对于上限 256 KiB 的工具 channel(扫描预算 512 KiB),以 256 次 1 KiB delta 耗尽 256 KiB 扫描预算(剩余 256 KiB),首个 256 KiB 快照到达,因尚无 baseline hash 判定无免除资格,所需扫描成本 `cost = 256 KiB` 恰好等于剩余预算,扣减后剩余预算降为 0 并建立基准 Hash;随后若再次收到一个内容发生微小篡改(如仅修改中部 1 字节)的 256 KiB 变体快照,因 Hash 不匹配废弃该许可并确定 `cost = 256 KiB > remaining_scan_budget (0)`,断言在第 6 步立即拒绝触发事件并终止请求,reason 固定为 `tool_argument_bytes`,杜绝超额扫描; + - 用例 8b(合法终态链有资格零计费放行与无资格拒绝):在用例 8a 剩余扫描预算为 0 后,相继送入内容完全一致的 `output_item.done` 与 `response.completed` 快照,断言通过资格检查且 Hash 匹配,均零计费放行(`cost = 0`,仅扣 `request_events` 并消耗对应许可),请求顺利完成不被误拒;若随后再次发送同类型快照(如重复的 `response.completed`)或第 4 个终态确认快照,断言因无资格直接确定 `cost = 256 KiB`,在第 6 步被拒并终止; + - 用例 8c(中部短周期重复与小长度用例健全性断言): + - 构造包含连续短周期片段且长度达到 9215 bytes 的快照(如中部连续 9215B 周期重复片段),断言全量线性周期检测器能确保落入检查窗口并准确触发 `repetition`,杜绝长重复逃逸; + - 另测单 channel 累计总长度小于 8192 bytes(例如总长约 2128 bytes,中部包含 2000 bytes 周期片段)的用例,断言严格遵循 §5.2 尾检规则未满 8192 窗口不判定重复,仅扣减扫描预算与字节高水位,绝不误触发 `repetition`; + - 彻底删除已废除的首尾 64B 指纹短路跳过逻辑残留;测试覆盖在变体或高频快照反复推送时受单 channel 累计扫描预算硬上界(`2 * limit`)和纯终态确认许可(最多 3 次 `cost = 0` 全文校验)联合截断,断言单 channel 累计扫描处理量绝不超过 `2 * max_channel_bytes`,全量数据校验与处理总成本绝不超过 `5 * max_channel_bytes`(普通 channel 10 MiB,工具 channel 1280 KiB)。 +9. 正常代码、非重复 Markdown、不同文件多工具、合法未闭合 JSON、中英混合、image base64 字段、心跳不因形态相似被拦;明示硬预算仍生效。 +10. 有意大段周期数据按规则拦,作为兼容性 fixture,不宣称零误报。 +11. 尾检规则断言:对于总长度小于 8192 bytes 的短正常流(如 500 字节的短代码或高频词短回复),在收到 `end` 或快照时断言绝不误判为重复;对于已满 8192 bytes 但尾部未对齐 1024-byte 的末尾流,断言能准确提取最新的 8192-byte 窗口完成严格周期判定。 + +### 9.2 全链路终止 + +12. §4.1 每入口 stream/non-stream、native/converted 及实际支持的上游 HTTP/WS 组合有集成用例;不支持的明确标注。 +13. collector/replay 大量积累之前检查;continuous retry 开启且 upstream 永不成功结束也在线触发。 +14. mock upstream 观察到 cancel/body close,无额外 5 秒 usage drain,原因不被 cancel/EOF 覆盖。 +15. 保活/自动续想与 guard 并发无旧缓冲泄露、无成功/重复终态、无 data race。 +16. ordinary retry、selective/catch-all、换号、fallback、恢复、续想均不能拉起下一请求,用 upstream 调用次数断言。 +17. 头未提交返回协议错误,已提交返回失败;Chat 不附成功 `[DONE]`;Gemini 走既有 SDK 兼容失败路径。WebSocket 命中终态:error 帧(公开码 `model_output_guard_triggered`)+ `CloseTryAgainLater`(1013) 关闭;同一连接多 lane 下终态写经 per-conn 写锁串行。 +18. 慢读/断开时取消不被通知写阻塞,按有界写超时结束;HTTP SSE 失败通知写入具备配套断言:断言在执行下游失败通知前已主动 cancel 上游 context 并不受写锁阻塞;断言终态写入以非阻塞方式(TryLock 或带超时)获取 `downstreamMu` 互斥锁,竞争失败时立即放弃写通知并安全退出,不产生孤儿等待协程;断言锁内全部写入(含普通数据帧、keepalive 注释及终态错误帧)均通过 `http.ResponseController.SetWriteDeadline` 设置有界超时(如 5 秒),持锁写超时只约束持锁后的实际写入阶段,在下游客户端挂起时不产生长期悬挂写协程,超时后连接能正确回收且主处理流程立即退出;对于不支持 `SetWriteDeadline` 的环境,断言放弃潜在阻塞写入并安全退出,不使用孤儿 goroutine 拼死写入;命中后不下发新模型事件,不断言能撤回网络内字节。 +19. 不落成功 cache/续链,无账号降分/冷却/refresh/prompt strike;缺 usage 不虚构。 +20. 两 HTTP 请求/可隔离 WS streams 同时运行,异常只终止自身;上游异常 lease 不复用,资源回基线。 + +### 9.3 配置、性能与上线门槛 + +21. SQLite/PostgreSQL 迁移默认关闭,显式开启/关闭持久化;非法配置不影响快照;并发无关保存不丢字段。 +22. 本实例即时、其他实例 5 秒轮询更新;在途旧 revision、新 turn 新 revision;DB 故障保留 last-known-good,冷启动故障返回 503 且不静默放行。 +23. 未完成 SSE 行/多行 event 拼接前限额;大 WS frame、native reader、非流式有明确 transport 上限。 +24. 固定空闲测试机和配置,至少 5 轮比较开关:正常 1 MiB 文本 p95 TTFT 增量目标 ≤10 ms、CPU/吞吐开销目标 ≤20%;不满足先优化或评审修订,不声称已达标。 +25. core append 的 1/2/4 MiB 非重复样本 CPU 中位数应近线性,每翻倍比值目标 ≤2.5;分别报告 envelope 投影、扫描、snapshot 成本,不用网络等待掩盖 O(n²)。 +26. 每 channel 新增状态目标 ≤64 KiB;128 个累计 channel 无无界状态,结束释放窗口。10 分钟合成异常/并发退出测试无持续堆内存、goroutine、租约或临时文件增长。 +27. 提交默认 256 KiB/4096 deltas/8 KiB 重复窗口的正常样本误拦及事故样式提前触发结果;增加细 delta 长推理样本专项压测(测试模拟极端细 delta 如单字节持续流式输出、长思考以及多工具交错触发 4096 deltas 场景,量化事件预算相对字节预算更早到达的耗尽时机与行为),未验证不称通用安全阈值。 +28. 不以 DSH CPU 恢复或 GUI 不再卡死作为仅代理可保证的验收项;可另行授权真实客户端验证,不代替本项目契约。 + +## 10. 已由用户确认的设计取舍 + +以下设计取舍已由用户明确确认,设计审查验收通过: + +1. 默认全局关闭,仅管理员可开,与 prompt filter 独立。 +2. 首期精确短周期重复 + 硬预算,不上语义模型,不自动改写/拆分工具。 +3. 默认工具参数 256 KiB、4096 非空 deltas;合法大 write 也拦,后台可显式调高。 +4. 只结束本次代理请求/逻辑 WS turn,代理不重放,不惩罚账号。 +5. 覆盖文本/工具入口 stream/non-stream/native/converted 及 Realtime WebSocket 桥接的文本/工具子集;专用媒体接口、Realtime 原生音频 Modality(显式拒绝)及协议管理帧不纳入语义重复检测。 +6. 只承诺阻止进一步下发,不承诺恢复卡死客户端、撤销已执行工具或禁止客户端自行重试。 + +设计审查验收通过,后续实现计划尚未开展;本轮不生成实施计划、不修改业务代码、不部署或重启服务。 diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 9d129dee6..d67a1e74a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -338,7 +338,7 @@ async function requestAPIKeyUsage(path: string, apiKey: string, options: Requ if (!res.ok) { const body = await res.text() - throw new Error(extractAdminErrorMessage(body, res.status)) + throw new AdminAPIError(res.status, extractAdminErrorMessage(body, res.status)) } return (await res.json()) as T @@ -583,6 +583,8 @@ export const api = { method: 'POST', body: JSON.stringify(data), }), + getPublicAPIKeyConcurrency: (apiKey: string, options: RequestInit = {}) => + requestAPIKeyUsage<{ current: number }>('/concurrency', apiKey, options), getPublicAPIKeyUsage: (apiKey: string, range = '30d', params: { page?: number; pageSize?: number } & PublicAPIKeyUsageLogFilter = {}) => { const search = new URLSearchParams() search.set('range', range) @@ -912,6 +914,10 @@ export const api = { }), refreshAccount: (id: number) => request(`/accounts/${id}/refresh`, { method: 'POST' }), + getScheduledConnectionTests: (id: number) => + request(`/accounts/${id}/scheduled-tests`), + updateScheduledConnectionTests: (id: number, data: import('./types').ScheduledConnectionTestConfig) => + request(`/accounts/${id}/scheduled-tests`, { method: 'PUT', body: JSON.stringify(data) }), getAccount: (id: number, signal?: AbortSignal) => request(`/accounts/${id}`, { signal }), forceUsageProbe: () => @@ -1064,6 +1070,8 @@ export const api = { quota_refresh_enabled?: boolean quota_refresh_interval_minutes?: number quota_gate_enabled?: boolean + expiry_priority_enabled?: boolean + expiry_priority_horizon_hours?: number }) => request('/settings/antigravity', { method: 'PUT', diff --git a/frontend/src/components/APIKeyChannelBadge.tsx b/frontend/src/components/APIKeyChannelBadge.tsx new file mode 100644 index 000000000..9eae89eb1 --- /dev/null +++ b/frontend/src/components/APIKeyChannelBadge.tsx @@ -0,0 +1,27 @@ +import { Waypoints } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import type { UpstreamChannel } from '../types' +import ChannelLogo from './ChannelLogo' +import { Badge } from './ui/badge' + +const CHANNEL_NAMES: Record = { + codex: 'Codex', + grok: 'Grok', + antigravity: 'Antigravity', + claude: 'Claude', +} + +export default function APIKeyChannelBadge({ channel }: { channel?: UpstreamChannel }) { + const { t } = useTranslation() + const known = channel && Object.prototype.hasOwnProperty.call(CHANNEL_NAMES, channel) ? channel : undefined + return ( + + {known ? : } + {known ? CHANNEL_NAMES[known] : t('apiKeys.limits.upstreamChannelAutoTab')} + + ) +} diff --git a/frontend/src/components/AccountGroupManagerModal.tsx b/frontend/src/components/AccountGroupManagerModal.tsx index 7ec1e00d1..f1c79fe48 100644 --- a/frontend/src/components/AccountGroupManagerModal.tsx +++ b/frontend/src/components/AccountGroupManagerModal.tsx @@ -11,6 +11,11 @@ import { cn } from "@/lib/utils"; import { useToast } from "../hooks/useToast"; import { useConfirmDialog } from "../hooks/useConfirmDialog"; import { getErrorMessage } from "../utils/error"; +import { + autoPauseThresholdPercentToRatio, + formatAutoPauseThresholdPercent, + isAutoPauseThresholdPercentInvalid, +} from "../lib/autoPauseThreshold"; // 分组管理器的调色板(与账号页一致,避免各页各造一套)。 export const ACCOUNT_GROUP_COLORS = [ @@ -89,8 +94,9 @@ export function AccountGroupManagerModal({ description: g.description ?? "", color: normalizeGroupColor(g.color), baseConcurrency: g.base_concurrency_override != null ? String(g.base_concurrency_override) : "", - autoPause5h: g.auto_pause_5h_threshold ? String(g.auto_pause_5h_threshold) : "", - autoPause7d: g.auto_pause_7d_threshold ? String(g.auto_pause_7d_threshold) : "", + // 阈值后端存 0..1 比例,输入框按百分比展示(issue #843)。 + autoPause5h: formatAutoPauseThresholdPercent(g.auto_pause_5h_threshold), + autoPause7d: formatAutoPauseThresholdPercent(g.auto_pause_7d_threshold), proxyUrls: (g.proxy_urls ?? []).join("\n"), }); }; @@ -101,14 +107,18 @@ export function AccountGroupManagerModal({ showToast(t("accountGroups.nameRequired"), "error"); return; } + if (isAutoPauseThresholdPercentInvalid(draft.autoPause5h) || isAutoPauseThresholdPercentInvalid(draft.autoPause7d)) { + showToast(t("accounts.autoPauseThresholdRange"), "error"); + return; + } setBusy(true); const payload = { name, description: draft.description.trim(), color: normalizeGroupColor(draft.color), base_concurrency_override: parseNum(draft.baseConcurrency), - auto_pause_5h_threshold: parseNum(draft.autoPause5h) ?? 0, - auto_pause_7d_threshold: parseNum(draft.autoPause7d) ?? 0, + auto_pause_5h_threshold: autoPauseThresholdPercentToRatio(draft.autoPause5h) ?? 0, + auto_pause_7d_threshold: autoPauseThresholdPercentToRatio(draft.autoPause7d) ?? 0, proxy_urls: draft.proxyUrls .split(/[\n,]/) .map((s) => s.trim()) @@ -204,11 +214,11 @@ export function AccountGroupManagerModal({
{t("accountGroups.autoPause5h")} - setDraft({ ...draft, autoPause5h: e.target.value })} placeholder="0" inputMode="numeric" /> + setDraft({ ...draft, autoPause5h: e.target.value })} placeholder="0" inputMode="decimal" />
{t("accountGroups.autoPause7d")} - setDraft({ ...draft, autoPause7d: e.target.value })} placeholder="0" inputMode="numeric" /> + setDraft({ ...draft, autoPause7d: e.target.value })} placeholder="0" inputMode="decimal" />
diff --git a/frontend/src/components/AccountIntelligenceBar.tsx b/frontend/src/components/AccountIntelligenceBar.tsx index 1f29e756a..38afaec0b 100644 --- a/frontend/src/components/AccountIntelligenceBar.tsx +++ b/frontend/src/components/AccountIntelligenceBar.tsx @@ -4,7 +4,7 @@ import { api } from '../api' import { refreshIntelligenceBars } from '../hooks/useIntelligenceBars' import { useToast } from '../hooks/useToast' import type { IntelligenceBar } from '../lib/intelligenceCheck' -import { intelligenceColor, intelligenceLabels, intelligenceWindow } from '../lib/intelligenceCheck' +import { intelligenceColor, intelligenceLabels, intelligenceWindow, intelligenceScheduleLabel } from '../lib/intelligenceCheck' import { getErrorMessage } from '../utils/error' import AccountStatusBar from './AccountStatusBar' import AccountIntelligenceHistoryDialog from './AccountIntelligenceHistoryDialog' @@ -61,6 +61,7 @@ export default function AccountIntelligenceBar({ bar, error }: AccountIntelligen {error && 加载失败} {bar?.action && bar.action !== 'none' && {intelligenceLabels[bar.action]}}
} +

{intelligenceScheduleLabel(bar)}

({ tooltip: 暂无检测记录 })), diff --git a/frontend/src/components/AccountIntelligenceHistoryDialog.tsx b/frontend/src/components/AccountIntelligenceHistoryDialog.tsx index 33e2cff48..4e1d68da1 100644 --- a/frontend/src/components/AccountIntelligenceHistoryDialog.tsx +++ b/frontend/src/components/AccountIntelligenceHistoryDialog.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react' import { api } from '../api' import { refreshIntelligenceBars } from '../hooks/useIntelligenceBars' -import { intelligenceColor, intelligenceLabels, type IntelligenceRun, type IntelligencePage } from '../lib/intelligenceCheck' +import { intelligenceColor, intelligenceLabels, intelligenceScheduleLabel, type IntelligenceBar, type IntelligenceRun, type IntelligencePage } from '../lib/intelligenceCheck' import { getErrorMessage } from '../utils/error' import { Button } from './ui/button' import { Switch } from './ui/switch' @@ -26,10 +26,11 @@ export default function AccountIntelligenceHistoryDialog({ accountID, accountNam const [data, setData] = useState | null>(null) const [enabled, setEnabled] = useState(null) const [error, setError] = useState('') + const [schedule, setSchedule] = useState(null) const [saving, setSaving] = useState(false) const changing = useRef(false) const [refresh, setRefresh] = useState(0) - useEffect(() => { setPage(1); setEnabled(null) }, [accountID]) + useEffect(() => { setPage(1); setEnabled(null); setSchedule(null) }, [accountID]) useEffect(() => { setData(null) setError('') @@ -45,6 +46,7 @@ export default function AccountIntelligenceHistoryDialog({ accountID, accountNam ]) if (controller.signal.aborted) return setData(runs) + setSchedule(bars.bars[String(accountID)] ?? null) if (!changing.current) setEnabled(bars.bars[String(accountID)]?.enabled ?? false) setError('') } catch (e) { @@ -80,6 +82,7 @@ export default function AccountIntelligenceHistoryDialog({ accountID, accountNam + {schedule &&

{intelligenceScheduleLabel(schedule)}

} {error &&

{error}

} {!data && !error &&

正在加载…

} {data?.items.length === 0 &&

暂无检测记录,可在账号状态条上方点击立即检测。

} diff --git a/frontend/src/components/PublicAPIKeyConcurrencyBadge.tsx b/frontend/src/components/PublicAPIKeyConcurrencyBadge.tsx new file mode 100644 index 000000000..5e9a236d4 --- /dev/null +++ b/frontend/src/components/PublicAPIKeyConcurrencyBadge.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react' +import { AdminAPIError, api } from '../api' +import APIKeyConcurrencyBadge from './APIKeyConcurrencyBadge' + +const REFRESH_INTERVAL_MS = 5_000 +const REFRESH_TIMEOUT_MS = 4_000 +const MAX_CONSECUTIVE_FAILURES = 3 + +export default function PublicAPIKeyConcurrencyBadge({ apiKey, limit }: { apiKey: string; limit?: number }) { + const [current, setCurrent] = useState() + useEffect(() => { + let cancelled = false + let pending = false + let failures = 0 + setCurrent(undefined) + const refresh = async () => { + if (pending || document.hidden) return + pending = true + const controller = new AbortController() + const timeout = window.setTimeout(() => controller.abort(), REFRESH_TIMEOUT_MS) + try { + const response = await api.getPublicAPIKeyConcurrency(apiKey, { signal: controller.signal }) + failures = 0 + if (!cancelled) setCurrent(response.current) + } catch (err) { + // 密钥失效或功能关闭时立即清空快照;其他暂时性失败保留上次快照,连续失败多次后也清空,避免展示过期数据。 + failures += 1 + const unavailable = err instanceof AdminAPIError && (err.status === 401 || err.status === 404) + if (!cancelled && (unavailable || failures >= MAX_CONSECUTIVE_FAILURES)) { + setCurrent(undefined) + } + } finally { + window.clearTimeout(timeout) + pending = false + } + } + void refresh() + const timer = window.setInterval(() => void refresh(), REFRESH_INTERVAL_MS) + document.addEventListener('visibilitychange', refresh) + return () => { + cancelled = true + window.clearInterval(timer) + document.removeEventListener('visibilitychange', refresh) + } + }, [apiKey]) + return +} diff --git a/frontend/src/components/PublicAPIKeyUsageLogDetails.tsx b/frontend/src/components/PublicAPIKeyUsageLogDetails.tsx new file mode 100644 index 000000000..c18a93567 --- /dev/null +++ b/frontend/src/components/PublicAPIKeyUsageLogDetails.tsx @@ -0,0 +1,55 @@ +import { Brain } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import type { PublicAPIKeyUsageLog } from '../types' +import { formatUsageNumber } from '../lib/usageFormat' +import CompactionBadges from './CompactionBadges' +import UsageDaybreakBadge from './UsageDaybreakBadge' +import { Badge } from './ui/badge' +import { UsageInputTokenCount } from './UsageLogMetrics' + +export function PublicUsageRequestType({ log }: { log: PublicAPIKeyUsageLog }) { + return ( +
+ + {log.stream ? 'stream' : 'sync'} + + + +
+ ) +} + +export function PublicUsageTokens({ log }: { log: PublicAPIKeyUsageLog }) { + const { t } = useTranslation() + if (log.status_code >= 400 || !(log.input_tokens > 0 || log.output_tokens > 0 || log.reasoning_tokens > 0)) { + return - + } + return ( +
+ + | + ↑{formatUsageNumber(log.output_tokens, true)} + {log.reasoning_tokens > 0 ? ( + <> + | + + {formatUsageNumber(log.reasoning_tokens, true)} + + + ) : null} +
+ ) +} + +export function PublicUsageUserAgent({ value }: { value: string }) { + return ( + + {value || '-'} + + ) +} diff --git a/frontend/src/components/ReasoningEffortBadge.tsx b/frontend/src/components/ReasoningEffortBadge.tsx new file mode 100644 index 000000000..085fefbdc --- /dev/null +++ b/frontend/src/components/ReasoningEffortBadge.tsx @@ -0,0 +1,24 @@ +import { Badge } from './ui/badge' + +const EFFORT_STYLES: Record = { + none: 'border-transparent bg-slate-500/12 text-slate-500 dark:bg-slate-500/20 dark:text-slate-400', + minimal: 'border-transparent bg-sky-500/12 text-sky-600 dark:bg-sky-500/20 dark:text-sky-400', + low: 'border-transparent bg-emerald-500/12 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400', + medium: 'border-transparent bg-amber-500/12 text-amber-600 dark:bg-amber-500/20 dark:text-amber-400', + high: 'border-transparent bg-orange-500/14 text-orange-600 dark:bg-orange-500/22 dark:text-orange-400', + xhigh: 'border-transparent bg-rose-500/14 text-rose-600 dark:bg-rose-500/22 dark:text-rose-400', + ultra: 'border-transparent bg-violet-500/14 text-violet-600 dark:bg-violet-500/22 dark:text-violet-300', +} +const EFFORT_ALIASES: Record = { off: 'none', min: 'minimal', med: 'medium', max: 'xhigh' } + +export default function ReasoningEffortBadge({ effort }: { effort?: string }) { + const label = effort?.trim() + if (!label) return null + const normalized = label.toLowerCase() + const style = EFFORT_STYLES[EFFORT_ALIASES[normalized] || normalized] || 'border-transparent bg-muted text-muted-foreground' + return ( + + {label} + + ) +} diff --git a/frontend/src/components/ScheduledConnectionTestsModal.tsx b/frontend/src/components/ScheduledConnectionTestsModal.tsx new file mode 100644 index 000000000..95b97a1f6 --- /dev/null +++ b/frontend/src/components/ScheduledConnectionTestsModal.tsx @@ -0,0 +1,368 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { CalendarClock, Loader2, Plus, Save, X } from 'lucide-react' +import { api } from '../api' +import type { + AccountRow, + ScheduledConnectionTestConfig, + ScheduledConnectionTestRecord, +} from '../types' +import { formatAccountName } from '../lib/connectionTestModels' +import { useToast } from '../hooks/useToast' +import Modal from './Modal' +import { Button } from './ui/button' +import { Select } from './ui/select' +import { Switch } from './ui/switch' + +export default function ScheduledConnectionTestsModal({ + account, + models, + defaultModel, + defaultContent, + onClose, +}: { + account: AccountRow + models: string[] + defaultModel: string + defaultContent: string + onClose: () => void +}) { + const { t } = useTranslation() + const { showToast } = useToast() + const [config, setConfig] = useState({ + enabled: false, + model: defaultModel, + content: defaultContent, + weekday_times: [], + weekend_times: [], + }) + const [records, setRecords] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [loaded, setLoaded] = useState(false) + const [draftTimes, setDraftTimes] = useState({ + weekday_times: '09:00', + weekend_times: '09:00', + }) + const [dirty, setDirty] = useState(false) + + useEffect(() => { + let active = true + void api + .getScheduledConnectionTests(account.id) + .then((result) => { + if (!active) return + setConfig({ + ...result.config, + model: result.config.model || defaultModel, + content: result.config.model ? result.config.content : defaultContent, + weekday_times: result.config.weekday_times || [], + weekend_times: result.config.weekend_times || [], + }) + setRecords(result.records) + setLoaded(true) + }) + .catch((err: unknown) => { + if (active) setError(err instanceof Error ? err.message : String(err)) + }) + .finally(() => { + if (active) setLoading(false) + }) + return () => { + active = false + } + }, [account.id, defaultModel, defaultContent]) + + const update = (patch: Partial) => { + setConfig((previous) => ({ ...previous, ...patch })) + setDirty(true) + } + const addTime = (key: 'weekday_times' | 'weekend_times') => { + const value = draftTimes[key] + if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(value) || config[key].includes(value)) + return + update({ [key]: [...config[key], value].sort() }) + } + const deleteTime = ( + key: 'weekday_times' | 'weekend_times', + value: string, + ) => { + const next = config[key].filter((item) => item !== value) + const other = + key === 'weekday_times' ? config.weekend_times : config.weekday_times + update({ + [key]: next, + ...(next.length + other.length === 0 ? { enabled: false } : {}), + }) + } + const save = async () => { + if (saving || !loaded) return + setSaving(true) + setError('') + try { + const saved = await api.updateScheduledConnectionTests(account.id, config) + setConfig(saved) + setDirty(false) + showToast(t('accounts.activationSaved')) + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setSaving(false) + } + } + const blocked = loading || saving || !loaded + const hasTimes = config.weekday_times.length + config.weekend_times.length > 0 + const modelOptions = [ + ...new Set([...models, config.model].filter(Boolean)), + ].map((value) => ({ value, label: value })) + const formatTime = (value?: string) => + value + ? new Date(value).toLocaleString('zh-CN', { + timeZone: 'Asia/Shanghai', + hour12: false, + }) + : '—' + return ( + + + + + + {t('accounts.activationTitle')} + + {formatAccountName(account)} + + + + } + onClose={() => { + if (!saving) onClose() + }} + contentClassName="sm:max-w-[720px]" + footer={ +
+ + {dirty + ? t('accounts.activationUnsaved') + : t('accounts.activationSaveHint')} + +
+ + +
+
+ } + > +
+ {loading && ( +
+ + {t('common.loading')} +
+ )} + {error && ( +
+ {error} +
+ )} +
+
+
+ +

+ {t('accounts.activationTimezone')} +

+
+ update({ enabled })} + disabled={blocked} + /> +
+
+ +