From 17e90ad8180dba80519df0b1c42d5b654805bd90 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 11:50:41 +0000 Subject: [PATCH] fix(strix): disable SDK streaming for the contextual-orchestrator gateway Confirmed scope: every Strix run that actually reaches contextual-orchestrator now fails closed with openai.BadRequestError 400 invalid_stream_options ("stream_options.include_usage=true is not supported with tools or response_format"), on all 3 bounded retry attempts (run 33307905354, job 99247611184, 2026-08-30T11:02-11:16 UTC). This is newly exposed, not long-standing: every earlier push-triggered run on 2026-08-29/30 failed upstream of the model call (sidecar provisioning, e.g. run 33306377959 failed at "Provision contextual-orchestrator Strix sidecar" before "Run Strix" even started). Once #1436/#1439/#1440 fixed sidecar provisioning/timeout, this became the sole remaining blocker, and it is structural rather than incidental: strix-agent 1.5.3 always drives its scan loop through Runner.run_streamed (strix/core/execution.py) and always registers function tools, and strix/core/inputs.py:make_model_settings hardcodes ModelSettings(include_usage=True, ...) for every call regardless of client or provider. The openai-agents SDK (0.19.4) only omits stream_options when the call is non-streaming (agents/models/chatcmpl_helpers.py:get_stream_options_param), so every tool-using turn against this gateway hits the rejection immediately - no Strix report has ever completed via contextual-orchestrator. Strix already ships a documented, first-class opt-out for exactly this shape of gateway: LLM_DISABLE_STREAMING=true (strix/config/settings.py, strix/config/models.py's _NonStreamingModel) wraps the resolved model so each turn issues one non-streaming get_response (stream:false on the wire, so stream_options is never sent) and replays it as a single terminal stream event - the run loop, tool execution, and everything else are unchanged. No Strix CLI flag or config file exposes this per-invocation; it is an environment-variable opt-in, which scripts/ci/strix_quick_gate.sh already controls when it shells out to the trusted Strix executable. Fix: in run_strix_once(), set LLM_DISABLE_STREAMING=true in the child environment only when the resolved LLM_API_BASE is the pinned contextual-orchestrator loopback (is_contextual_orchestrator_api_base). Every other provider attempt (NVIDIA NIM, OpenRouter, GitHub Models, direct OpenAI, Vertex, Gemini fallbacks) keeps real SSE streaming untouched. This is a Strix-invocation-level fix only: contextual-orchestrator's deliberate stream+tools rejection (a correctness guarantee against silently incomplete usage accounting) is untouched, and no pool-selection logic changes. Tests: extends test_strix_quick_gate.sh's existing fake-Strix runtime-env capture with LLM_DISABLE_STREAMING and asserts (a) the contextual-orchestrator-gateway-model-qualification scenario's child process receives LLM_DISABLE_STREAMING=true, (b) the unrelated runtime-env-forwarding scenario (gemini backend) keeps it unset, and (c) static assertions pin the new bash/python source lines. Full suite: scripts/ci/test_strix_quick_gate.sh PASS; coverage run -m pytest tests -q: 1896 passed, 1 skipped (pre-existing pingora_edge_policy.py:345 gap, owned by #1398, unrelated to this change); interrogate 100%; bash -n clean on both touched scripts. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BV96rXhqoR3tYZ9AeAVur4 --- scripts/ci/strix_quick_gate.sh | 21 +++++++++++++++++++++ scripts/ci/test_strix_quick_gate.sh | 25 +++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/scripts/ci/strix_quick_gate.sh b/scripts/ci/strix_quick_gate.sh index 66be927b5..b7fb59bfe 100755 --- a/scripts/ci/strix_quick_gate.sh +++ b/scripts/ci/strix_quick_gate.sh @@ -2651,6 +2651,20 @@ run_strix_once() { if ! resolved_target_path="$(resolve_current_target_path "$TARGET_PATH")"; then return 1 fi + # contextual-orchestrator's gateway deliberately rejects any request that + # combines stream_options.include_usage=true with tools (a correctness + # guarantee against silently-incomplete usage accounting; out of scope to + # change here). Strix's agent loop always streams and always sends tools, + # so every call through that gateway hits the rejection immediately. + # Strix itself ships an opt-in for exactly this: LLM_DISABLE_STREAMING=true + # makes each turn a single non-streaming get_response (stream:false on the + # wire, so stream_options is never sent) replayed as one terminal stream + # event; nothing else about the run loop changes. Scope it narrowly to the + # contextual-orchestrator loopback so other providers keep real streaming. + local strix_disable_streaming="false" + if is_contextual_orchestrator_api_base "$llm_api_base_value"; then + strix_disable_streaming="true" + fi local start_epoch start_epoch="$(date +%s)" local child_llm_api_key="" @@ -2682,6 +2696,7 @@ run_strix_once() { STRIX_CHILD_EXECUTABLE_ROOT="$STRIX_EXECUTABLE_ROOT" \ STRIX_CHILD_EXECUTABLE_SHA256="$STRIX_EXECUTABLE_SHA256" \ STRIX_CHILD_REQUIRE_EXECUTABLE_INTEGRITY="${IS_PR_EVIDENCE_RUN:-false}" \ + STRIX_CHILD_DISABLE_STREAMING="$strix_disable_streaming" \ python3 - "$timeout_seconds" "$resolved_target_path" "$SCAN_MODE" "$STRIX_LOG" "$STRIX_SCAN_WORKING_DIR" <<'PY' import hashlib import hmac @@ -2736,6 +2751,12 @@ child_env["LLM_MODEL"] = os.environ["STRIX_CHILD_MODEL"] if os.environ.get("STRIX_CHILD_LLM_API_KEY"): child_env["LLM_API_KEY"] = os.environ["STRIX_CHILD_LLM_API_KEY"] child_env["STRIX_REPORTS_DIR"] = os.environ["STRIX_CHILD_REPORTS_DIR"] +if os.environ.get("STRIX_CHILD_DISABLE_STREAMING", "").strip().lower() == "true": + # See the comment above strix_disable_streaming's assignment in bash: + # this routes only the contextual-orchestrator gateway through Strix's + # own non-streaming fallback so stream_options is never sent alongside + # tools, without touching how Strix talks to any other provider. + child_env["LLM_DISABLE_STREAMING"] = "true" for key, value in os.environ.items(): if key.startswith("FAKE_STRIX_") and value: child_env[key] = value diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index b528e8baf..afd2546e8 100644 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -327,6 +327,9 @@ assert_strix_workflow_pr_trigger_hardened() { assert_file_contains "$GATE_SCRIPT" 'child_env["PNPM_CONFIG_IGNORE_SCRIPTS"] = "true"' "strix gate child process disables pnpm lifecycle scripts" assert_file_contains "$GATE_SCRIPT" 'child_env["YARN_ENABLE_SCRIPTS"] = "false"' "strix gate child process disables yarn lifecycle scripts" assert_file_contains "$GATE_SCRIPT" 'child_env["PYTHONWARNINGS"] = "ignore:Pydantic serializer warnings:UserWarning:pydantic.main"' "strix gate child env narrowly filters the known third-party Pydantic serializer warning" + assert_file_contains "$GATE_SCRIPT" 'if is_contextual_orchestrator_api_base "$llm_api_base_value"; then' "strix gate scopes the non-streaming opt-in to the contextual-orchestrator loopback gateway" + assert_file_contains "$GATE_SCRIPT" 'STRIX_CHILD_DISABLE_STREAMING="$strix_disable_streaming"' "strix gate threads the streaming opt-in through to the child process environment" + assert_file_contains "$GATE_SCRIPT" 'child_env["LLM_DISABLE_STREAMING"] = "true"' "strix gate disables Strix's own SDK streaming for the contextual-orchestrator gateway, which rejects stream_options.include_usage alongside tools" assert_file_contains "$GATE_SCRIPT" '[[ "$normalized_changed_file" =~ ^backend/.+\.py$ ]]' "strix gate detects nested backend Python files for PR-scoped import context" assert_file_contains "$GATE_SCRIPT" '[[ "$normalized_changed_file" == scripts/ci/test_*.sh || "$normalized_changed_file" == scripts/ci/*_test.sh ]]' "strix gate excludes large CI test harness scripts from model scan input" assert_file_contains "$GATE_SCRIPT" "Materialized PR-head changed-file scope for Strix scan" "strix gate avoids copying the full PR head tree into privileged scan targets by default" @@ -3289,7 +3292,7 @@ set -euo pipefail printf '%s\n' "${STRIX_LLM:-}" >> "${FAKE_STRIX_CALL_LOG:?}" printf '%s\n' "${LLM_API_BASE:-}" >> "${FAKE_STRIX_API_BASE_LOG:?}" if [ -n "${FAKE_STRIX_RUNTIME_ENV_LOG:-}" ]; then - printf 'LLM_TIMEOUT=%s;STRIX_MEMORY_COMPRESSOR_TIMEOUT=%s;STRIX_REASONING_EFFORT=%s;STRIX_LLM_MAX_RETRIES=%s;GEMINI_LOCATION=%s;PYTHONWARNINGS=%s;NPM_CONFIG_IGNORE_SCRIPTS=%s;PNPM_CONFIG_IGNORE_SCRIPTS=%s;YARN_ENABLE_SCRIPTS=%s;UNRELATED_SECRET=%s\n' \ + printf 'LLM_TIMEOUT=%s;STRIX_MEMORY_COMPRESSOR_TIMEOUT=%s;STRIX_REASONING_EFFORT=%s;STRIX_LLM_MAX_RETRIES=%s;GEMINI_LOCATION=%s;PYTHONWARNINGS=%s;NPM_CONFIG_IGNORE_SCRIPTS=%s;PNPM_CONFIG_IGNORE_SCRIPTS=%s;YARN_ENABLE_SCRIPTS=%s;UNRELATED_SECRET=%s;LLM_DISABLE_STREAMING=%s\n' \ "${LLM_TIMEOUT:-}" \ "${STRIX_MEMORY_COMPRESSOR_TIMEOUT:-}" \ "${STRIX_REASONING_EFFORT:-}" \ @@ -3299,7 +3302,8 @@ if [ -n "${FAKE_STRIX_RUNTIME_ENV_LOG:-}" ]; then "${NPM_CONFIG_IGNORE_SCRIPTS:-}" \ "${PNPM_CONFIG_IGNORE_SCRIPTS:-}" \ "${YARN_ENABLE_SCRIPTS:-}" \ - "${UNRELATED_SECRET:-}" >> "${FAKE_STRIX_RUNTIME_ENV_LOG:?}" + "${UNRELATED_SECRET:-}" \ + "${LLM_DISABLE_STREAMING:-}" >> "${FAKE_STRIX_RUNTIME_ENV_LOG:?}" fi target_path="" @@ -5929,6 +5933,13 @@ PY "$runtime_env_log" \ "LLM_TIMEOUT=90;STRIX_MEMORY_COMPRESSOR_TIMEOUT=10;STRIX_REASONING_EFFORT=minimal;STRIX_LLM_MAX_RETRIES=1;GEMINI_LOCATION=GLOBAL;PYTHONWARNINGS=ignore:Pydantic serializer warnings:UserWarning:pydantic.main;NPM_CONFIG_IGNORE_SCRIPTS=true;PNPM_CONFIG_IGNORE_SCRIPTS=true;YARN_ENABLE_SCRIPTS=false;UNRELATED_SECRET=" \ "scenario=$scenario runtime env forwarding" + # Non-contextual-orchestrator providers (gemini here) never see the + # stream-disabling opt-in: it is scoped narrowly to the gateway that + # rejects stream_options.include_usage alongside tools. + assert_file_contains \ + "$runtime_env_log" \ + "LLM_DISABLE_STREAMING=" \ + "scenario=$scenario non-gateway providers keep real streaming" fi if [ "$scenario" = "custom-openai-compatible-preserves-effort" ]; then assert_file_contains \ @@ -5936,6 +5947,16 @@ PY "STRIX_REASONING_EFFORT=minimal" \ "scenario=$scenario custom compatible endpoint effort" fi + if [ "$scenario" = "contextual-orchestrator-gateway-model-qualification" ]; then + # contextual-orchestrator rejects stream_options.include_usage=true + # alongside tools; Strix's agent loop always sends both, so the gate + # routes this gateway through Strix's own LLM_DISABLE_STREAMING opt-in + # (single non-streaming get_response per turn) instead of streaming. + assert_file_contains \ + "$runtime_env_log" \ + "LLM_DISABLE_STREAMING=true" \ + "scenario=$scenario contextual-orchestrator gateway disables SDK streaming to avoid the stream_options+tools rejection" + fi if [ "$scenario" = "report-known-internal-warning-sanitized" ]; then assert_file_not_contains \