diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index dd65d90e1..478788d41 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -4058,8 +4058,8 @@ jobs: "apiKey": "{env:OPENAI_API_KEY}" }, "models": { - "gpt-5.6-luna": { - "name": "OpenAI GPT-5.6 Luna (direct)", + "gpt-5.4": { + "name": "OpenAI GPT-5.4 (direct)", "tool_call": true, "reasoning": true, "options": { @@ -4471,17 +4471,17 @@ jobs: # or used for product/model improvement, so private repositories # include neither NIM nor anonymous free candidates and start at the # existing keyed fallback list: OpenCode Zen GPT-5.6 Terra, DeepSeek - # V3, the direct GPT-5.6 Luna slot, and pinned PAID + # V3, the direct GPT-5.4 slot, and pinned PAID # OpenRouter coder models (free-tier candidates hit the shared # free-models-per-day cap and hung for the full candidate timeout, # so the OpenRouter slots use cheap paid models billed against the # org's OpenRouter credits), then the full-size GPT-4.1 long-context # endpoint and provider-specific GPT/o3 fallbacks. - # The direct-OpenAI slot runs GPT-5.6 Luna: the newest family's - # cost-efficient tier, cheaper than the legacy gpt-5 it replaced - # ($1/$6 vs $1.25/$10 per 1M tokens) so the org OpenAI budget - # stretches further between top-ups. - OPENCODE_MODEL_CANDIDATES: "${{ needs.validate-pr-metadata.outputs.is_private == 'false' && 'nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5 nvidia-nim/nvidia/llama-3.1-nemotron-ultra-253b-v1 nvidia-nim/nvidia/nemotron-3-super-120b-a12b nvidia-nim/nvidia/nemotron-3-ultra-550b-a55b nvidia-nim/meta/llama-3.3-70b-instruct nvidia-nim/deepseek-ai/deepseek-v4-pro nvidia-nim/mistralai/codestral-22b-instruct-v0.1 opencode-free/nemotron-3-ultra-free opencode-free/deepseek-v4-flash-free opencode-free/north-mini-code-free opencode-free/laguna-s-2.1-free opencode-free/ling-3.0-flash-free opencode-free/big-pickle opencode-free/mimo-v2.5-free opencode-free/hy3-free opencode-free/minimax-m3-free opencode-free/glm-5-free opencode-free/kimi-k2.5-free opencode-free/qwen3.6-plus-free ' || '' }}opencode/gpt-5.6-terra github-models/deepseek/deepseek-v3-0324 openai/gpt-5.6-luna openrouter/deepseek/deepseek-v3.2 openrouter/qwen/qwen3-coder github-models/openai/gpt-4.1 github-models/openai/gpt-5 github-models/openai/gpt-5-chat github-models/openai/o3 github-models/deepseek/deepseek-r1-0528 github-models/deepseek/deepseek-r1" + # The direct-OpenAI slot runs GPT-5.4: the current valid model name + # on the direct OpenAI platform (the retired gpt-5.6-luna catalog + # entry returned 404s), kept cost-efficient against the org's + # OpenAI budget between top-ups. + OPENCODE_MODEL_CANDIDATES: "${{ needs.validate-pr-metadata.outputs.is_private == 'false' && 'nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5 nvidia-nim/nvidia/llama-3.1-nemotron-ultra-253b-v1 nvidia-nim/nvidia/nemotron-3-super-120b-a12b nvidia-nim/nvidia/nemotron-3-ultra-550b-a55b nvidia-nim/meta/llama-3.3-70b-instruct nvidia-nim/deepseek-ai/deepseek-v4-pro nvidia-nim/mistralai/codestral-22b-instruct-v0.1 opencode-free/nemotron-3-ultra-free opencode-free/deepseek-v4-flash-free opencode-free/north-mini-code-free opencode-free/laguna-s-2.1-free opencode-free/ling-3.0-flash-free opencode-free/big-pickle opencode-free/mimo-v2.5-free opencode-free/hy3-free opencode-free/minimax-m3-free opencode-free/glm-5-free opencode-free/kimi-k2.5-free opencode-free/qwen3.6-plus-free ' || '' }}opencode/gpt-5.6-terra github-models/deepseek/deepseek-v3-0324 openai/gpt-5.4 openrouter/deepseek/deepseek-v3.2 openrouter/qwen/qwen3-coder github-models/openai/gpt-4.1 github-models/openai/gpt-5 github-models/openai/gpt-5-chat github-models/openai/o3 github-models/deepseek/deepseek-r1-0528 github-models/deepseek/deepseek-r1" # One attempt per model, then fall through to the next model. Retrying # the SAME model 5x let a rate-limited/hung leader consume the whole # step, so the pool never reached a healthy fallback model. diff --git a/.github/workflows/strix.yml b/.github/workflows/strix.yml index f89119070..bb656dc56 100644 --- a/.github/workflows/strix.yml +++ b/.github/workflows/strix.yml @@ -674,7 +674,10 @@ jobs: echo "LLM_API_BASE_FILE=$llm_api_base_file" >> "$GITHUB_ENV" - name: Prepare GitHub Models fallback credentials - if: steps.gate.outputs.provider_mode == 'openai_direct' || steps.gate.outputs.provider_mode == 'openrouter' || steps.gate.outputs.provider_mode == 'nvidia_nim' + # github_models is included because its STRIX_FALLBACK_MODELS chain + # ends in openai-direct/gpt-5.4, which needs the direct-OpenAI key and + # API base to authenticate and route after the primary is exhausted. + if: steps.gate.outputs.provider_mode == 'openai_direct' || steps.gate.outputs.provider_mode == 'openrouter' || steps.gate.outputs.provider_mode == 'nvidia_nim' || steps.gate.outputs.provider_mode == 'github_models' env: GITHUB_MODELS_FALLBACK_TOKEN: ${{ secrets.STRIX_GITHUB_MODELS_TOKEN || github.token }} OPENAI_FALLBACK_KEY: ${{ secrets.STRIX_OPENAI_API_KEY || secrets.OPENAI_API_KEY }} @@ -706,6 +709,9 @@ jobs: openai_fallback_key_file="$RUNNER_TEMP/openai_fallback_key.txt" printf '%s' "$openai_trimmed" > "$openai_fallback_key_file" echo "STRIX_OPENAI_FALLBACK_KEY_FILE=$openai_fallback_key_file" >> "$GITHUB_ENV" + openai_fallback_api_base_file="$RUNNER_TEMP/openai_fallback_api_base.txt" + printf '%s' 'https://api.openai.com/v1' > "$openai_fallback_api_base_file" + echo "STRIX_OPENAI_FALLBACK_API_BASE_FILE=$openai_fallback_api_base_file" >> "$GITHUB_ENV" fi - name: Prepare Vertex AI credentials @@ -838,6 +844,7 @@ jobs: STRIX_GITHUB_MODELS_API_BASE_FILE: ${{ env.STRIX_GITHUB_MODELS_API_BASE_FILE }} STRIX_GITHUB_MODELS_KEY_FILE: ${{ env.STRIX_GITHUB_MODELS_KEY_FILE }} STRIX_OPENAI_FALLBACK_KEY_FILE: ${{ env.STRIX_OPENAI_FALLBACK_KEY_FILE }} + STRIX_OPENAI_FALLBACK_API_BASE_FILE: ${{ env.STRIX_OPENAI_FALLBACK_API_BASE_FILE }} STRIX_FAIL_ON_PROVIDER_SIGNAL: "1" STRIX_VERTEX_FALLBACK_MODELS: "" NPM_CONFIG_IGNORE_SCRIPTS: "true" diff --git a/CHANGELOG.md b/CHANGELOG.md index 1630c32d4..056b3cff1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,17 @@ this file. The format follows Keep a Changelog, and versioned releases follow Semantic Versioning where the repository publishes a release. ## [Unreleased] - +- Route Strix cross-provider fallbacks to explicit direct-OpenAI models + (`openai-direct/...`) through the OpenAI inference endpoint instead of + inheriting the primary provider's `LLM_API_BASE`: the workflow now + provisions `STRIX_OPENAI_FALLBACK_API_BASE_FILE` + (`https://api.openai.com/v1`), the gate prefers it for explicit OpenAI + fallback models and otherwise resolves no override so litellm defaults to + OpenAI, and a non-https override fails configuration. This removes the + NVIDIA-NIM-edge `404 page not found` that made the contracted final + fallback unreachable after NIM exhaustion. +- Align stale `gpt-5.6-luna` test expectations with the valid `gpt-5.4` + contract left behind by the earlier model rename. - Honor each trusted base project's exact, integrity-bearing pnpm `packageManager` specification in OpenCode coverage images through the pinned Node distribution's Corepack runtime, instead of admitting the specification diff --git a/docs/doctoring/strix-openai-fallback-api-base-routing.md b/docs/doctoring/strix-openai-fallback-api-base-routing.md new file mode 100644 index 000000000..c7a2a1890 --- /dev/null +++ b/docs/doctoring/strix-openai-fallback-api-base-routing.md @@ -0,0 +1,74 @@ +# Strix direct-OpenAI fallback API-base routing: evidence and design record + +## Decision + +A Strix cross-provider fallback to an explicit direct-OpenAI model +(`openai-direct/...` or `openai_direct/...`) must route through the OpenAI +inference endpoint, never through the primary provider's `LLM_API_BASE`. The +gate now prefers an explicit `STRIX_OPENAI_FALLBACK_API_BASE_FILE` for such +models and, when the override is absent, resolves no API-base override so +litellm uses its default `https://api.openai.com/v1` endpoint. + +The central workflow writes `https://api.openai.com/v1` into +`$RUNNER_TEMP/openai_fallback_api_base.txt` and exports +`STRIX_OPENAI_FALLBACK_API_BASE_FILE` whenever it publishes the OpenAI +fallback key file, so every provider chain that ends in +`openai-direct/gpt-5.4` (NVIDIA NIM primary, OpenRouter primary, +GitHub Models primary) inherits correct routing automatically. + +## Failure this fixes + +Required-CI evidence (BandScope PR #1021 strix run 32800796577, 2026-08-25) +showed the NVIDIA NIM primary and first fallback exhausting provider +availability, then the contracted final fallback `openai-direct/gpt-5.4` +failing with a plain-text gateway error: + +```text +LLM CONNECTION FAILED +Could not establish connection to the language model. +Error: 404 page not found +``` + +Root cause: with `provider_mode=nvidia_nim`, the workflow sets +`LLM_API_BASE_FILE=https://integrate.api.nvidia.com/v1`. The gate reused that +base for the openai-direct fallback child, so litellm sent OpenAI requests to +the NVIDIA NIM edge, whose Go gateway answered `404 page not found`. The +fallback key was already routed correctly (`STRIX_OPENAI_FALLBACK_KEY_FILE`); +only the base URL leaked from the primary provider. Because no vulnerability +report artifact was produced, the gate failed closed — correct policy on an +incomplete scan, but caused by routing rather than by any repository finding. + +## Trust boundary + +The override is a runner-provisioned regular file under `$RUNNER_TEMP`, +resolved through the same `resolve_trusted_input_file` boundary as the other +API-base files: it must be a regular non-symlink file inside the trusted input +root, must trim to a single `https://` URL, and must not contain whitespace or +control characters. Absent or empty overrides fall back to litellm's default +endpoint instead of failing configuration, preserving standalone local gate +runs where no workflow provisioning exists. + +## Verification contract + +Regression evidence proves that: + +1. with a NVIDIA NIM primary base configured, `openai-direct/gpt-5.4` + resolves through the explicit OpenAI fallback base when provided; +2. without an override, the resolver returns no base so litellm defaults to + `https://api.openai.com/v1`; +3. NVIDIA NIM primary attempts keep resolving through the NIM edge; +4. `github_models/*` fallbacks keep their dedicated GitHub Models endpoint; +5. a non-https override fails configuration (exit 2) instead of scanning; +6. the workflow provisions the override file and passes it into the gate env; +7. the required-workflow smoke contract pins both sides of the wiring; and +8. the stale `gpt-5.6-luna` expectations left behind by the model rename are + aligned with the valid `gpt-5.4` contract in queue-contract tests. + +## Limitations + +This change restores reachability of the final fallback; it does not create +OpenAI quota. If the OpenAI key is absent or exhausted after NIM exhaustion, +the gate still fails closed as provider-unavailable — by design, because no +complete authoritative scan exists. Hosted model catalogs may also change +independently of this repository; model-name updates remain manual contract +changes reviewed through CI. diff --git a/scripts/ci/emit_opencode_failed_check_fallback_findings.sh b/scripts/ci/emit_opencode_failed_check_fallback_findings.sh index ccd35a273..b8a755e6e 100755 --- a/scripts/ci/emit_opencode_failed_check_fallback_findings.sh +++ b/scripts/ci/emit_opencode_failed_check_fallback_findings.sh @@ -956,7 +956,7 @@ extract_strix_failed_check_block "$EVIDENCE_FILE" "$strix_evidence_file" emit_known_missing_string_finding \ "$EVIDENCE_FILE" \ - "steps.target_visibility.outputs.is_private == 'false' && 'nvidia_nim/nvidia/nemotron-3-ultra-550b-a55b' || 'gpt-5.6-luna'" \ + "steps.target_visibility.outputs.is_private == 'false' && 'nvidia_nim/nvidia/nemotron-3-ultra-550b-a55b' || 'gpt-5.4'" \ "Strix public scans must default to NVIDIA NIM while private scans retain the contracted provider" \ ".github/workflows/strix.yml" \ "scripts/ci/test_strix_quick_gate.sh" diff --git a/scripts/ci/strix_quick_gate.sh b/scripts/ci/strix_quick_gate.sh index 36ec3e5f8..a7493192e 100755 --- a/scripts/ci/strix_quick_gate.sh +++ b/scripts/ci/strix_quick_gate.sh @@ -2415,9 +2415,23 @@ resolved_llm_api_base_for_model() { return 0 fi + if is_explicit_openai_model "$model" && [ -z "${STRIX_OPENAI_FALLBACK_API_BASE_FILE:-}" ]; then + # Without workflow provisioning, an explicit direct-OpenAI model must + # still not inherit a foreign primary base: resolve no override so + # litellm defaults to https://api.openai.com/v1. + return 0 + fi + local api_base_file="$LLM_API_BASE_FILE" local api_base_file_name="LLM_API_BASE_FILE" - if is_github_models_model "$model" && [ -n "${STRIX_GITHUB_MODELS_API_BASE_FILE:-}" ]; then + if is_explicit_openai_model "$model" && [ -n "${STRIX_OPENAI_FALLBACK_API_BASE_FILE:-}" ]; then + # Cross-provider fallback: openai-direct/* candidates must reach the + # direct OpenAI API even when the primary provider selected a + # different LLM_API_BASE_FILE endpoint (e.g. NVIDIA NIM). Without + # this the fallback hits the primary gateway and 404s. + api_base_file="$STRIX_OPENAI_FALLBACK_API_BASE_FILE" + api_base_file_name="STRIX_OPENAI_FALLBACK_API_BASE_FILE" + elif is_github_models_model "$model" && [ -n "${STRIX_GITHUB_MODELS_API_BASE_FILE:-}" ]; then # Cross-provider fallback: when the active primary provider uses a # different API base (for example OpenRouter), github_models/* fallback # attempts must still route through the GitHub Models inference endpoint. diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index c7ffaf531..e2661fb89 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -365,6 +365,8 @@ assert_strix_workflow_pr_trigger_hardened() { assert_file_contains "$workflow_file" "steps.gate.outputs.provider_mode == 'nvidia_nim' && 'nvidia_nim/nvidia/llama-3.3-nemotron-super-49b-v1.5 openai-direct/gpt-5.4'" "strix workflow gives NVIDIA NIM scans contracted fallbacks" assert_file_not_contains "$workflow_file" "STRIX_FALLBACK_MODELS: \${{ steps.gate.outputs.provider_mode == 'github_models' && 'github_models/openai/o3" "strix workflow fallback list must not depend on GitHub Models, which is in platform-wide retirement" assert_file_contains "$workflow_file" "Prepare GitHub Models fallback credentials" "strix workflow provisions GitHub Models fallback credentials for direct-OpenAI scans" + assert_file_contains "$workflow_file" "STRIX_OPENAI_FALLBACK_API_BASE_FILE" "strix workflow routes direct-OpenAI fallbacks through a trusted API base file" + assert_file_contains "$workflow_file" "https://api.openai.com/v1" "strix workflow uses the OpenAI platform endpoint for direct fallbacks" assert_file_contains "$GATE_SCRIPT" "STRIX_GITHUB_MODELS_KEY_FILE" "strix gate reads the optional GitHub Models fallback key file" assert_file_contains "$GATE_SCRIPT" "STRIX_GITHUB_MODELS_API_BASE_FILE" "strix gate routes github_models fallback models through the GitHub Models endpoint" assert_file_not_contains "$workflow_file" 'github_models/deepseek/deepseek-r1-0528 | github_models/deepseek/deepseek-v3-0324)' "strix workflow keeps DeepSeek GitHub Models restricted to fallback-only routing" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 8ee6e86fc..2360fdb26 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -186,7 +186,7 @@ def test_opencode_model_pool_sets_high_effort_for_capable_candidates(): ["opencode-free", "qwen3.6-plus-free"], ["opencode", "gpt-5.6-terra"], ["github-models", "deepseek/deepseek-v3-0324"], - ["openai", "gpt-5.6-luna"], + ["openai", "gpt-5.4"], ["openrouter", "deepseek/deepseek-v3.2"], ["openrouter", "qwen/qwen3-coder"], ["github-models", "openai/gpt-4.1"], @@ -197,7 +197,7 @@ def test_opencode_model_pool_sets_high_effort_for_capable_candidates(): ["github-models", "deepseek/deepseek-r1"], ] assert zen_models == ["gpt-5.6-terra"] - assert direct_openai_models == ["gpt-5.6-luna"] + assert direct_openai_models == ["gpt-5.4"] assert openrouter_models == [ "deepseek/deepseek-v3.2", "qwen/qwen3-coder", @@ -1740,7 +1740,7 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert ( "opencode/gpt-5.6-terra " "github-models/deepseek/deepseek-v3-0324 " - "openai/gpt-5.6-luna " + "openai/gpt-5.4 " "openrouter/deepseek/deepseek-v3.2 " "openrouter/qwen/qwen3-coder " "github-models/openai/gpt-4.1 " @@ -1887,7 +1887,7 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): ) assert ( "github-models/deepseek/deepseek-v3-0324 " - "openai/gpt-5.6-luna " + "openai/gpt-5.4 " "openrouter/deepseek/deepseek-v3.2 " "openrouter/qwen/qwen3-coder " "github-models/openai/gpt-4.1 " diff --git a/tests/test_pr_review_autofix_nvidia_nim_contract.py b/tests/test_pr_review_autofix_nvidia_nim_contract.py index 799b9e9fb..3a8ddcae1 100644 --- a/tests/test_pr_review_autofix_nvidia_nim_contract.py +++ b/tests/test_pr_review_autofix_nvidia_nim_contract.py @@ -19,7 +19,7 @@ DOCTORING_RECORD = Path("docs/doctoring/hourly-nvidia-nim-autofix.md") CHANGELOG = Path("CHANGELOG.md") REVIEW_DISPATCH_WORKFLOW = Path(".github/workflows/opencode-review-dispatch.yml") -REVIEW_DISPATCH_BLOB_SHA = "dd65d90e10e5040562b501ade1a40f89572f0984" +REVIEW_DISPATCH_BLOB_SHA = "478788d41ed3e0d8389292a059d225336a925830" def _workflow_text(path: Path) -> str: diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index e58f5e6c0..1d79f1daa 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -505,7 +505,7 @@ def test_nvidia_nim_defaults_preserve_existing_fallbacks_without_secret( assert strix.returncode == 0, strix.stderr assert { "provider_mode=openai_direct", - "strix_model=gpt-5.6-luna", + "strix_model=gpt-5.4", } <= set(strix_output.read_text().splitlines()) assert ( "STRIX_MODEL: ${{ steps.gate.outputs.strix_model }}" diff --git a/tests/test_strix_nvidia_nim_not_found_fallback.py b/tests/test_strix_nvidia_nim_not_found_fallback.py index 990269725..17f0e9a30 100644 --- a/tests/test_strix_nvidia_nim_not_found_fallback.py +++ b/tests/test_strix_nvidia_nim_not_found_fallback.py @@ -192,7 +192,7 @@ def test_workflow_uses_available_free_first_nvidia_plan(self) -> None: workflow = STRIX_WORKFLOW.read_text(encoding="utf-8") default_expression = ( "steps.target_visibility.outputs.is_private == 'false' && " - f"'{DEFAULT_NVIDIA_MODEL}' || 'gpt-5.6-luna'" + f"'{DEFAULT_NVIDIA_MODEL}' || 'gpt-5.4'" ) self.assertIn(default_expression, workflow) self.assertIn( @@ -202,7 +202,7 @@ def test_workflow_uses_available_free_first_nvidia_plan(self) -> None: ) self.assertIn( "steps.gate.outputs.provider_mode == 'nvidia_nim' && " - f"'{FREE_NVIDIA_FALLBACK} openai-direct/gpt-5.6-luna'", + f"'{FREE_NVIDIA_FALLBACK} openai-direct/gpt-5.4'", workflow, ) diff --git a/tests/test_strix_openai_fallback_api_base.py b/tests/test_strix_openai_fallback_api_base.py new file mode 100644 index 000000000..f2c4237a7 --- /dev/null +++ b/tests/test_strix_openai_fallback_api_base.py @@ -0,0 +1,211 @@ +"""Regression contract for direct-OpenAI fallback API-base routing. + +When the Strix primary provider is NVIDIA NIM (or OpenRouter / GitHub Models), +the workflow's ``LLM_API_BASE_FILE`` points at that provider's endpoint. A +cross-provider fallback to ``openai-direct/gpt-5.4`` must never inherit that +base: routing an OpenAI model through the NVIDIA NIM edge yields a plain-text +gateway 404 ("404 page not found") instead of OpenAI responses, so the final +contracted fallback could never complete a scan. + +The gate must therefore prefer an explicit +``STRIX_OPENAI_FALLBACK_API_BASE_FILE`` for explicit direct-OpenAI models, and +fall back to litellm's default OpenAI endpoint when the override is absent. +""" + +from __future__ import annotations + +import re +import subprocess +import tempfile +import unittest +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +STRIX_GATE = REPOSITORY_ROOT / "scripts" / "ci" / "strix_quick_gate.sh" +STRIX_MODEL_UTILS = REPOSITORY_ROOT / "scripts" / "ci" / "strix_model_utils.sh" +STRIX_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "strix.yml" +OPENAI_FALLBACK_BASE = "https://api.openai.com/v1" + + +def _function_block(source: str, function_name: str) -> str: + """Return one top-level Bash function, including its closing brace.""" + + match = re.search( + rf"(?ms)^{re.escape(function_name)}\(\) \{{\n.*?^\}}\n", + source, + ) + if match is None: + raise AssertionError(f"missing Bash function: {function_name}") + return match.group(0) + + +def _resolver_helpers() -> list[str]: + """Collect every helper the production API-base resolver depends on.""" + + gate_source = STRIX_GATE.read_text(encoding="utf-8") + utils_source = STRIX_MODEL_UTILS.read_text(encoding="utf-8") + helper_names = ( + ("is_vertex_model", gate_source), + ("is_github_models_model", gate_source), + ("is_github_models_api_base", gate_source), + ("is_github_models_api_compatible_model", gate_source), + ("is_explicit_openai_model", gate_source), + ("resolve_trusted_input_file", gate_source), + ("trim_whitespace", utils_source), + ) + helpers: list[str] = [] + for name, source in helper_names: + try: + helpers.append(_function_block(source, name)) + except AssertionError as exc: # pragma: no cover - shape drift guard + raise AssertionError(f"resolver helper missing: {name}") from exc + return helpers + + +def _resolve_api_base(env: dict[str, str], model: str) -> tuple[int, str]: + """Execute the production API-base resolver for one model and env.""" + + gate_source = STRIX_GATE.read_text(encoding="utf-8") + resolver_source = _function_block(gate_source, "resolved_llm_api_base_for_model") + helper_sources = _resolver_helpers() + with tempfile.TemporaryDirectory(prefix="strix-openai-fallback-base-") as temp_dir: + base_path = Path(temp_dir) / "primary_base.txt" + if "LLM_API_BASE_FILE" in env: + base_path.write_text(env["LLM_API_BASE_FILE"], encoding="utf-8") + env = {**env, "LLM_API_BASE_FILE": str(base_path)} + fallback_path = Path(temp_dir) / "openai_fallback_api_base.txt" + if "STRIX_OPENAI_FALLBACK_API_BASE_FILE" in env: + fallback_path.write_text( + env["STRIX_OPENAI_FALLBACK_API_BASE_FILE"], + encoding="utf-8", + ) + env = { + **env, + "STRIX_OPENAI_FALLBACK_API_BASE_FILE": str(fallback_path), + } + github_models_path = Path(temp_dir) / "github_models_api_base.txt" + if "STRIX_GITHUB_MODELS_API_BASE_FILE" in env: + github_models_path.write_text( + env["STRIX_GITHUB_MODELS_API_BASE_FILE"], + encoding="utf-8", + ) + env = { + **env, + "STRIX_GITHUB_MODELS_API_BASE_FILE": str(github_models_path), + } + script_lines = [ + "set -euo pipefail", + f'STRIX_INPUT_FILE_ROOT="{temp_dir}"', + *helper_sources, + resolver_source, + ] + command_env = { + key: value + for key, value in env.items() + if key in { + "LLM_API_BASE_FILE", + "STRIX_GITHUB_MODELS_API_BASE_FILE", + "STRIX_OPENAI_FALLBACK_API_BASE_FILE", + } + } + completed = subprocess.run( + [ + "bash", + "-c", + "\n".join([*script_lines, 'resolved_llm_api_base_for_model "$1"']), + "strix-resolver", + model, + ], + check=False, + capture_output=True, + text=True, + env={ + "PATH": "/usr/bin:/bin:/usr/local/bin", + "HOME": temp_dir, + **command_env, + }, + ) + return completed.returncode, completed.stdout.strip() + + +class ExplicitOpenAIFallbackRouting(unittest.TestCase): + """Direct-OpenAI fallbacks must not inherit the primary provider base.""" + + def test_nvidia_primary_with_override_routes_to_openai(self) -> None: + """openai-direct/gpt-5.4 uses the explicit OpenAI API base file.""" + + rc, api_base = _resolve_api_base( + { + "LLM_API_BASE_FILE": "https://integrate.api.nvidia.com/v1", + "STRIX_OPENAI_FALLBACK_API_BASE_FILE": OPENAI_FALLBACK_BASE, + }, + "openai-direct/gpt-5.4", + ) + self.assertEqual(rc, 0) + self.assertEqual(api_base, OPENAI_FALLBACK_BASE) + + def test_nvidia_primary_without_override_uses_default_openai(self) -> None: + """Without an override the resolver returns no base (litellm default).""" + + rc, api_base = _resolve_api_base( + {"LLM_API_BASE_FILE": "https://integrate.api.nvidia.com/v1"}, + "openai-direct/gpt-5.4", + ) + self.assertEqual(rc, 0) + self.assertEqual(api_base, "") + + def test_primary_provider_models_keep_their_base(self) -> None: + """NVIDIA NIM primary attempts still resolve through the NIM edge.""" + + rc, api_base = _resolve_api_base( + {"LLM_API_BASE_FILE": "https://integrate.api.nvidia.com/v1"}, + "nvidia_nim/nvidia/nemotron-3-super-120b-a12b", + ) + self.assertEqual(rc, 0) + self.assertEqual(api_base, "https://integrate.api.nvidia.com/v1") + + def test_github_models_fallback_keeps_github_models_base(self) -> None: + """github_models/* fallbacks keep their dedicated inference endpoint.""" + + rc, api_base = _resolve_api_base( + { + "LLM_API_BASE_FILE": "https://integrate.api.nvidia.com/v1", + "STRIX_GITHUB_MODELS_API_BASE_FILE": "https://models.github.ai/inference", + }, + "github_models/openai/gpt-5.4", + ) + self.assertEqual(rc, 0) + self.assertEqual(api_base, "https://models.github.ai/inference") + + def test_invalid_https_override_is_configuration_failure(self) -> None: + """A non-https override fails configuration instead of scanning.""" + + rc, _ = _resolve_api_base( + { + "LLM_API_BASE_FILE": "https://integrate.api.nvidia.com/v1", + "STRIX_OPENAI_FALLBACK_API_BASE_FILE": "http://api.example.com/v1", + }, + "openai-direct/gpt-5.4", + ) + self.assertEqual(rc, 2) + + +class WorkflowProvisionsFallbackBase(unittest.TestCase): + """The workflow must publish and pass the explicit OpenAI fallback base.""" + + def test_workflow_writes_openai_fallback_api_base_file(self) -> None: + workflow = STRIX_WORKFLOW.read_text(encoding="utf-8") + self.assertIn("STRIX_OPENAI_FALLBACK_API_BASE_FILE=", workflow) + self.assertIn(OPENAI_FALLBACK_BASE, workflow) + + def test_workflow_passes_override_into_gate_environment(self) -> None: + workflow = STRIX_WORKFLOW.read_text(encoding="utf-8") + self.assertIn( + "STRIX_OPENAI_FALLBACK_API_BASE_FILE: ${{ env.STRIX_OPENAI_FALLBACK_API_BASE_FILE }}", + workflow, + ) + + +if __name__ == "__main__": + unittest.main()