From 30390628da6aaa2542ea40a5beda5d0863542fab Mon Sep 17 00:00:00 2001 From: Jonathan Singer Date: Wed, 22 Jul 2026 23:10:57 -0400 Subject: [PATCH] auth: make STRIX_LLM=openai/subscription the single switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the separate STRIX_AUTH_MODE flag with a sentinel model value: STRIX_LLM=openai/subscription selects the authenticated ChatGPT subscription, and any other value is a normal API-key model. The env vars that already run Strix are now the single source of truth — no second mode to keep in sync. Encapsulate the behavior instead of branching everywhere: - StrixProvider.get_model routes the sentinel to a _CodexResponsesModel backed by a cached OAuth client (no global default-client mutation, no per-call client churn). - _CodexResponsesModel self-enforces the backend's requirements — streaming, store=false, encrypted reasoning, and the configured reasoning effort — so the runner, warm-up, and make_model_settings no longer special-case subscription. Remove now-unneeded machinery: STRIX_AUTH_MODE/AuthMode, the "incompatible model" warning, the non-OpenAI model coercion, the make_model_settings codex flag, and the global set_default_openai_client wiring. run.json still records a derived auth_mode so the viewer/telemetry/cost display are unchanged. Switching modes is now just editing STRIX_LLM. Sentinel-only (no per-model override): a subscription run uses gpt-5.4. Co-Authored-By: Claude Fable 5 --- README.md | 10 +-- pyproject.toml | 2 +- strix/auth/codex.py | 80 ++++++++++++------------ strix/config/models.py | 103 +++++++++++++++++++------------ strix/config/settings.py | 8 +-- strix/core/inputs.py | 34 ++-------- strix/core/runner.py | 8 --- strix/interface/auth_cli.py | 87 +++++++++++--------------- strix/interface/main.py | 93 +++++++--------------------- strix/interface/utils.py | 4 +- strix/report/state.py | 10 +-- tests/test_auth_cli.py | 2 +- tests/test_codex_auth.py | 39 +++++------- tests/test_codex_streaming.py | 23 +++++++ tests/test_runner_rate_limit.py | 1 - tests/test_runner_root_prompt.py | 1 - 16 files changed, 220 insertions(+), 285 deletions(-) diff --git a/README.md b/README.md index e97722e9..68f36788 100644 --- a/README.md +++ b/README.md @@ -269,17 +269,19 @@ export STRIX_REASONING_EFFORT="high" # control thinking effort (default: high, #### Sign in with a ChatGPT subscription -Instead of a metered API key, you can run Strix on your ChatGPT Plus/Pro subscription: +Instead of a metered API key, you can run Strix on your ChatGPT Plus/Pro subscription. Set `STRIX_LLM=openai/subscription` — that value is the only switch: ```bash -strix auth login chatgpt # opens your browser to sign in with ChatGPT +strix auth login chatgpt # sign in; sets STRIX_LLM=openai/subscription for you strix --target ./app-directory strix auth status # show the active sign-in -strix auth logout # revert to API-key billing +strix auth logout # forget the sign-in ``` -This uses OpenAI's Codex OAuth flow: inference is billed to your ChatGPT plan rather than per token. Strix defaults to `gpt-5.4` here — newer models apply stricter content moderation that interferes with security-testing prompts, so `gpt-5.4` is recommended for scans. You can override the model with `strix auth login chatgpt --model `. Note that the models a ChatGPT plan exposes are a narrower set than the OpenAI API. If the browser can't open, the command falls back to pasting the redirect URL by hand. Tokens are stored in `~/.strix/subscription-auth.json` (`0600`) and refreshed automatically. +To switch back to a metered API key, just point `STRIX_LLM` at a normal model (e.g. `openai/gpt-5.4`) and set `LLM_API_KEY` — there's no separate mode to toggle. + +This uses OpenAI's Codex OAuth flow: inference is billed to your ChatGPT plan rather than per token, and it runs on `gpt-5.4` (newer models apply stricter content moderation that interferes with security-testing prompts). If the browser can't open, the command falls back to pasting the redirect URL by hand. Tokens are stored in `~/.strix/subscription-auth.json` (`0600`) and refreshed automatically. > [!NOTE] > Using a ChatGPT subscription outside OpenAI's own products is not officially supported by OpenAI and may be subject to its terms of use. For unattended/CI runs, prefer an API key. diff --git a/pyproject.toml b/pyproject.toml index 0aa93ad1..b5bb56eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -252,7 +252,7 @@ ignore = [ "strix/core/runner.py" = ["TC003", "PLR0912", "PLR0915", "PLC0415"] # ReportState carries scan artifact/report fields and # a runtime ``Callable`` annotation on ``vulnerability_found_callback``. -"strix/report/state.py" = ["TC003", "PLR0912", "PLR0915", "E501", "PERF401"] +"strix/report/state.py" = ["TC003", "PLR0912", "PLR0915", "E501", "PERF401", "PLC0415"] "strix/report/usage.py" = ["PLC0415"] "strix/config/models.py" = ["PLC0415"] # Heavy inference deps (httpx, openai) imported lazily so auth-status checks diff --git a/strix/auth/codex.py b/strix/auth/codex.py index 3843428b..4e7ecd30 100644 --- a/strix/auth/codex.py +++ b/strix/auth/codex.py @@ -57,19 +57,16 @@ CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex" ORIGINATOR = "codex_cli_rs" _ACCOUNT_CLAIM = "https://api.openai.com/auth" -# Models the ChatGPT Codex backend serves to subscription accounts (bare names, -# no provider prefix). Note this set is NARROWER than the OpenAI API and is -# account-specific: the ``*-codex`` variants are API-only and are rejected for a -# ChatGPT account ("model is not supported when using Codex with a ChatGPT -# account"). These are the general GPT-5.x models a Plus/Pro plan exposes. -# -# Default is gpt-5.4: gpt-5.5 and newer apply stricter content moderation that -# interferes with security-testing prompts, so we prefer 5.4 for scans. +# Setting STRIX_LLM to this value runs inference on the user's authenticated +# ChatGPT subscription instead of a metered API key. It's the single switch — +# there is no separate auth-mode flag. +SUBSCRIPTION_MODEL = "openai/subscription" + +# The actual model sent to the ChatGPT backend for a subscription run. gpt-5.4 is +# used because gpt-5.5 and newer apply stricter content moderation that +# interferes with security-testing prompts. (The subscription only exposes the +# general GPT-5.x models, not the ``*-codex`` API variants.) DEFAULT_CODEX_MODEL = "gpt-5.4" -CODEX_MODELS: tuple[str, ...] = ( - "gpt-5.4", - "gpt-5.5", -) _TOKEN_TIMEOUT = 30 # Refresh a little before the token actually expires so a request never goes out @@ -394,57 +391,56 @@ def build_openai_client() -> AsyncOpenAI: ) -def is_backend_compatible(model_name: str | None) -> bool: - """True if ``model_name`` could be served by the ChatGPT subscription backend. +_subscription_client: AsyncOpenAI | None = None - A bare name or an ``openai/`` prefix is plausible (the backend validates the - exact name against the account). Any other provider prefix — ``anthropic/``, - ``deepseek/``, ``vertex_ai/``, … — never can be, so it's treated as - incompatible. + +def get_subscription_client() -> AsyncOpenAI: + """Return the process-wide subscription client, building it once. + + One client is reused across every agent in a run: its per-request auth hook + refreshes the token itself, so there's no reason to rebuild it, and building + one per agent would leak httpx clients. """ - name = (model_name or "").strip() - if not name: - return False - if "/" not in name: - return True - return name.split("/", 1)[0].lower() == "openai" + global _subscription_client # noqa: PLW0603 + if _subscription_client is None: + _subscription_client = build_openai_client() + return _subscription_client -def normalize_model(model_name: str | None) -> str: - """Return the bare model name to send to the ChatGPT backend. +def is_subscription(model_name: str | None) -> bool: + """Whether ``STRIX_LLM`` selects the authenticated ChatGPT subscription.""" + return (model_name or "").strip().lower() == SUBSCRIPTION_MODEL - ``openai/gpt-5.4`` → ``gpt-5.4``. A configured-but-unlisted OpenAI/bare name - is passed through as-is (availability is account-specific and the backend is - the authority). An empty value, or a model from another provider that a - ChatGPT subscription can't serve, falls back to the default so a stray - ``STRIX_LLM`` never sends an impossible name to the backend. See - ``is_backend_compatible``. - """ - name = (model_name or "").strip() - if not is_backend_compatible(name): - return DEFAULT_CODEX_MODEL - if "/" in name: - name = name.rsplit("/", 1)[-1] - return name + +def resolve_subscription_model() -> str: + """The concrete model sent to the ChatGPT backend for a subscription run.""" + return DEFAULT_CODEX_MODEL + + +def auth_mode_label(model_name: str | None) -> str: + """How a run authenticated, for recording in run.json / telemetry / the viewer.""" + return "subscription" if is_subscription(model_name) else "api_key" __all__ = [ - "CODEX_MODELS", "DEFAULT_CODEX_MODEL", "PROVIDER", + "SUBSCRIPTION_MODEL", "CodexAuthError", + "auth_mode_label", "build_authorize_url", "build_openai_client", "create_state", "exchange_code", "generate_pkce", + "get_subscription_client", "get_valid_token", "is_authenticated", - "is_backend_compatible", + "is_subscription", "logout", - "normalize_model", "parse_redirect_input", "read_record", "refresh_tokens", + "resolve_subscription_model", "save_record", ] diff --git a/strix/config/models.py b/strix/config/models.py index f38d0e82..115c5008 100644 --- a/strix/config/models.py +++ b/strix/config/models.py @@ -7,10 +7,10 @@ from typing import TYPE_CHECKING, Any from agents import ( set_default_openai_api, - set_default_openai_client, set_default_openai_key, set_tracing_disabled, ) +from agents.model_settings import ModelSettings from agents.models.multi_provider import MultiProvider from agents.models.openai_responses import OpenAIResponsesModel from agents.retry import ( @@ -19,12 +19,16 @@ from agents.retry import ( RetryPolicyContext, retry_policies, ) +from openai.types.shared import Reasoning + +from strix.auth import codex if TYPE_CHECKING: from agents.models.interface import Model, ModelProvider + from openai import AsyncOpenAI - from strix.config.settings import Settings + from strix.config.settings import ReasoningEffort, Settings def request_timeout_extra_args(timeout_s: float | None) -> dict[str, float] | None: @@ -43,17 +47,49 @@ def _retry_statusless_provider_errors(context: RetryPolicyContext) -> bool: class _CodexResponsesModel(OpenAIResponsesModel): - """Responses model for the ChatGPT Codex backend, which rejects non-streamed - requests with ``{"detail": "Stream must be set to true"}``. + """A responses model wired for the ChatGPT subscription backend. - It always calls the API in streaming mode. The natively-streamed run path - (``Runner.run_streamed`` → ``stream_response``) is forwarded unchanged; the - non-streaming ``get_response`` path (warm-up, dedupe) transparently issues a - streamed request and aggregates the events back into a single ``Response``, - so those callers don't need to know the backend only speaks SSE. + It owns everything that backend requires so the rest of Strix doesn't need + to special-case subscription runs: + + - It always calls the API streamed (the backend rejects non-streamed + requests). The natively-streamed run path (``Runner.run_streamed``) is + forwarded unchanged; the non-streaming ``get_response`` path (warm-up, + dedupe) issues a streamed request and aggregates the events back into a + single ``Response``. + - It forces ``store=false`` + encrypted reasoning (the backend is stateless) + and applies the configured reasoning effort, so callers can pass ordinary + ``ModelSettings``. """ + def __init__( + self, + model: str, + openai_client: AsyncOpenAI, + *, + reasoning_effort: ReasoningEffort | None = None, + ) -> None: + super().__init__(model, openai_client) + self._reasoning_effort = reasoning_effort + + def _codex_settings(self, model_settings: ModelSettings) -> ModelSettings: + overrides = ModelSettings(store=False, response_include=["reasoning.encrypted_content"]) + effort = self._reasoning_effort + if effort and effort != "none": + # The ChatGPT backend rejects "minimal" and only some models take + # "xhigh"; clamp to what it accepts. + if effort == "minimal": + effort = "low" + elif effort == "xhigh": + effort = "high" + overrides = overrides.resolve(ModelSettings(reasoning=Reasoning(effort=effort))) + return model_settings.resolve(overrides) + async def _fetch_response(self, *args: Any, stream: bool = False, **kwargs: Any) -> Any: + # model_settings is positional arg 2 for both get_response and + # stream_response; force the backend's requirements onto it. + if len(args) >= 3: + args = (*args[:2], self._codex_settings(args[2]), *args[3:]) # Always call the backend streamed (it rejects non-streamed requests). events = await super()._fetch_response(*args, stream=True, **kwargs) # type: ignore[call-overload] if stream: @@ -93,16 +129,18 @@ class StrixProvider(MultiProvider): return self._get_fallback_provider("litellm"), original_model_name def get_model(self, model_name: str | None) -> Model: - model = super().get_model(model_name) - # In subscription mode the OpenAI route talks to the ChatGPT Codex - # backend, which requires streamed requests. Promote the responses model - # to the streaming-aware subclass (no added state, so the in-place class - # swap is safe) rather than duplicating the SDK's provider wiring. - from strix.config.loader import load_settings + # STRIX_LLM=openai/subscription is self-contained: a model backed by the + # OAuth client that talks to the ChatGPT subscription. Everything else + # goes through the normal (LiteLLM/OpenAI) resolution unchanged. + if codex.is_subscription(model_name): + from strix.config.loader import load_settings - if load_settings().llm.auth_mode == "subscription" and type(model) is OpenAIResponsesModel: - model.__class__ = _CodexResponsesModel - return model + return _CodexResponsesModel( + codex.resolve_subscription_model(), + codex.get_subscription_client(), + reasoning_effort=load_settings().llm.reasoning_effort, + ) + return super().get_model(model_name) DEFAULT_MODEL_RETRY = ModelRetrySettings( @@ -162,8 +200,10 @@ def configure_sdk_model_defaults(settings: Settings) -> None: """Apply Strix config to SDK-native defaults.""" llm = settings.llm set_tracing_disabled(True) - if llm.auth_mode == "subscription": - _configure_subscription_defaults() + if codex.is_subscription(llm.model): + # Subscription runs are self-contained: StrixProvider builds the OAuth + # client and the model enforces the backend's requirements, so there are + # no LiteLLM/API-key globals to configure here. return _configure_litellm_compatibility() _configure_openrouter_attribution(llm.model) @@ -179,23 +219,6 @@ def configure_sdk_model_defaults(settings: Settings) -> None: set_default_openai_api("responses") -def _configure_subscription_defaults() -> None: - """Route inference through a model subscription instead of an API key. - - Builds the subscription-backed OpenAI client (currently ChatGPT/Codex) and - installs it as the SDK default, so ``StrixProvider`` uses it for the OpenAI - route. The client carries the OAuth bearer token and refreshes it per - request; the Codex backend speaks the Responses API. Statelessness - (``store=false`` and encrypted reasoning) is applied per call in - ``make_model_settings``. - """ - from strix.auth import codex - - client = codex.build_openai_client() - set_default_openai_client(client, use_for_tracing=False) - set_default_openai_api("responses") - - def _mirror_api_key_to_provider_env(model_name: str | None, api_key: str) -> None: if not model_name: return @@ -276,9 +299,9 @@ def _configure_litellm_default(name: str, value: str) -> None: def uses_chat_completions_tool_schema(model_name: str, settings: Settings) -> bool: """Return whether the resolved SDK route can only receive JSON function tools.""" - # Subscription mode (ChatGPT/Codex) speaks the Responses API, so it takes the - # native reasoning-model tool schema regardless of the model name's shape. - if settings.llm.auth_mode == "subscription": + # The ChatGPT subscription speaks the Responses API, so it takes the native + # reasoning-model tool schema regardless of the model name's shape. + if codex.is_subscription(model_name): return False model = model_name.strip().lower() if "/" in model and not model.startswith("openai/"): diff --git a/strix/config/settings.py b/strix/config/settings.py index f7a74cb3..27d0bc0a 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -9,7 +9,6 @@ from pydantic_settings import BaseSettings, SettingsConfigDict ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh"] -AuthMode = Literal["api_key", "subscription"] _BASE_CONFIG = SettingsConfigDict( case_sensitive=False, @@ -21,11 +20,10 @@ _BASE_CONFIG = SettingsConfigDict( class LlmSettings(BaseSettings): model_config = _BASE_CONFIG + # Set to ``openai/subscription`` to run inference on an authenticated ChatGPT + # subscription (see ``strix/auth``; ``strix auth login`` sets it); any other + # value is a normal API-key model. model: str | None = Field(default=None, alias="STRIX_LLM") - # "api_key" (default) reads a provider API key from ``api_key`` below. - # "subscription" ignores the API key and authenticates with a model - # subscription instead (see ``strix/auth``); set by ``strix auth login``. - auth_mode: AuthMode = Field(default="api_key", alias="STRIX_AUTH_MODE") api_key: str | None = Field( default=None, validation_alias=AliasChoices("LLM_API_KEY", "OPENAI_API_KEY"), diff --git a/strix/core/inputs.py b/strix/core/inputs.py index a95cc62a..de922d07 100644 --- a/strix/core/inputs.py +++ b/strix/core/inputs.py @@ -128,7 +128,6 @@ def make_model_settings( model_name: str, force_required_tool_choice: bool = False, request_timeout: float | None = None, - codex_subscription: bool = False, ) -> ModelSettings: model_settings = ModelSettings( parallel_tool_calls=False, @@ -136,16 +135,10 @@ def make_model_settings( include_usage=True, extra_args=request_timeout_extra_args(request_timeout), ) - if codex_subscription: - # The ChatGPT Codex backend is stateless: it requires store=false and - # carries reasoning context forward via encrypted reasoning content - # replayed in the input rather than server-side state. - model_settings = model_settings.resolve( - ModelSettings(store=False, response_include=["reasoning.encrypted_content"]), - ) - reasoning_effort = _resolve_reasoning_effort(reasoning_effort, codex_subscription) - if reasoning_effort is not None and ( - codex_subscription or model_supports_reasoning(model_name) + if ( + reasoning_effort is not None + and reasoning_effort != "none" + and model_supports_reasoning(model_name) ): model_settings = model_settings.resolve( ModelSettings(reasoning=Reasoning(effort=reasoning_effort)), @@ -155,25 +148,6 @@ def make_model_settings( return model_settings -def _resolve_reasoning_effort( - reasoning_effort: ReasoningEffort | None, codex_subscription: bool -) -> ReasoningEffort | None: - """Normalize the configured effort, clamping to what the Codex backend accepts. - - The ChatGPT Codex backend rejects ``minimal`` and only some models accept - ``xhigh``; both are clamped rather than passed through, so a valid Strix - config doesn't fail at the provider. - """ - if reasoning_effort is None or reasoning_effort == "none": - return None - if codex_subscription: - if reasoning_effort == "minimal": - return "low" - if reasoning_effort == "xhigh": - return "high" - return reasoning_effort - - def child_initial_input( *, name: str, diff --git a/strix/core/runner.py b/strix/core/runner.py index 701ead00..153d16dd 100644 --- a/strix/core/runner.py +++ b/strix/core/runner.py @@ -148,14 +148,7 @@ async def run_strix_scan( settings = load_settings() configure_sdk_model_defaults(settings) - codex_subscription = settings.llm.auth_mode == "subscription" resolved_model = (model or settings.llm.model or "").strip() - if codex_subscription: - # Map whatever is configured to a name the ChatGPT Codex backend accepts, - # so a stray STRIX_LLM value can't send an unsupported model. - from strix.auth import codex - - resolved_model = codex.normalize_model(resolved_model) if not resolved_model: raise RuntimeError( "No LLM model configured. Set STRIX_LLM env or pass model= to run_strix_scan().", @@ -223,7 +216,6 @@ async def run_strix_scan( model_name=resolved_model, force_required_tool_choice=settings.llm.force_required_tool_choice, request_timeout=settings.llm.timeout, - codex_subscription=codex_subscription, ) run_config = RunConfig( model=resolved_model, diff --git a/strix/interface/auth_cli.py b/strix/interface/auth_cli.py index bc3941a1..fcd839ad 100644 --- a/strix/interface/auth_cli.py +++ b/strix/interface/auth_cli.py @@ -2,17 +2,17 @@ Subcommands: -- ``strix auth login chatgpt [--model NAME] [--manual]`` — OAuth sign-in with a - ChatGPT Plus/Pro subscription. Opens a browser and catches the redirect on a - local server; ``--manual`` (or a failure to open the browser / bind the port) - falls back to pasting the redirect URL by hand. +- ``strix auth login chatgpt [--manual]`` — OAuth sign-in with a ChatGPT + Plus/Pro subscription. Opens a browser and catches the redirect on a local + server; ``--manual`` (or a failure to open the browser / bind the port) falls + back to pasting the redirect URL by hand. - ``strix auth status`` — show whether a subscription sign-in is active. - ``strix auth logout`` — forget the stored sign-in. -Signing in persists ``STRIX_AUTH_MODE=subscription`` and a Codex model to -``~/.strix/cli-config.json`` so subsequent ``strix`` runs use the subscription. -Tokens live separately in ``~/.strix/subscription-auth.json`` (see -``strix/auth/store.py``); they are never written to the env-var config. +Signing in sets ``STRIX_LLM=openai/subscription`` in ``~/.strix/cli-config.json`` +so subsequent ``strix`` runs use the subscription. Tokens live separately in +``~/.strix/subscription-auth.json`` (see ``strix/auth/store.py``); they are never +written to the env-var config. """ from __future__ import annotations @@ -46,12 +46,7 @@ _CALLBACK_TIMEOUT_S = 300 LOGIN_PROVIDER = "chatgpt" _ACCEPTED_PROVIDERS = frozenset({LOGIN_PROVIDER, codex.PROVIDER}) -_USAGE = ( - "Usage:\n" - " strix auth login chatgpt [--model NAME] [--manual]\n" - " strix auth status\n" - " strix auth logout" -) +_USAGE = "Usage:\n strix auth login chatgpt [--manual]\n strix auth status\n strix auth logout" def run_auth(argv: list[str]) -> int: @@ -86,11 +81,6 @@ def _login(console: Console, argv: list[str]) -> int: default=LOGIN_PROVIDER, help="Model provider to sign in with (default: chatgpt).", ) - parser.add_argument( - "--model", - default=codex.DEFAULT_CODEX_MODEL, - help=f"ChatGPT model to use (default: {codex.DEFAULT_CODEX_MODEL}).", - ) parser.add_argument( "--manual", action="store_true", @@ -109,8 +99,8 @@ def _login(console: Console, argv: list[str]) -> int: return 2 # Capture any shell-exported STRIX_LLM before we persist our own, so we can - # warn if it would override the model chosen here (env wins over the config - # file we write). This must be read before _persist_subscription_config sets it. + # warn if it would override subscription mode (env wins over the config file + # we write). This must be read before _persist_subscription_config sets it. preexisting_llm = os.environ.get("STRIX_LLM") verifier, challenge = codex.generate_pkce() @@ -133,30 +123,27 @@ def _login(console: Console, argv: list[str]) -> int: return 130 codex.save_record(record) - _persist_subscription_config(args.model) - _warn_if_env_overrides_model(console, preexisting_llm, args.model) + _persist_subscription_config() + _warn_if_env_overrides(console, preexisting_llm) - stored_model = load_settings().llm.model or f"openai/{codex.normalize_model(args.model)}" - _print_success(console, stored_model) + _print_success(console) return 0 -def _warn_if_env_overrides_model(console: Console, preexisting: str | None, chosen: str) -> None: - """Warn when a shell-exported STRIX_LLM will override the model just saved. +def _warn_if_env_overrides(console: Console, preexisting: str | None) -> None: + """Warn when a shell-exported STRIX_LLM will override subscription mode. The config file we write loses to an environment variable at load time, so a - lingering ``export STRIX_LLM=…`` would silently win. Only warn when it - resolves to a different model than the one chosen here (same value, or an - incompatible one that coerces to the same default, is not a real conflict). + lingering ``export STRIX_LLM=…`` pointing somewhere else would silently win. """ if not preexisting or not preexisting.strip(): return - if codex.normalize_model(preexisting) == codex.normalize_model(chosen): + if codex.is_subscription(preexisting): return console.print( f"[yellow]Note:[/] STRIX_LLM is set in your shell to " - f"[bold]{preexisting.strip()}[/], which overrides the model just saved. " - f"Run [bold cyan]unset STRIX_LLM[/] so the subscription model is used." + f"[bold]{preexisting.strip()}[/], which overrides the subscription just saved. " + f"Run [bold cyan]unset STRIX_LLM[/] to use the subscription." ) @@ -287,27 +274,25 @@ def _first(query: dict[str, list[str]], key: str) -> str | None: return values[0] if values else None -def _persist_subscription_config(model: str) -> None: - """Persist subscription mode + model to cli-config.json for later runs.""" - normalized = codex.normalize_model(model) - os.environ["STRIX_AUTH_MODE"] = "subscription" - os.environ["STRIX_LLM"] = f"openai/{normalized}" +def _persist_subscription_config() -> None: + """Point STRIX_LLM at the subscription and persist it to cli-config.json.""" + os.environ["STRIX_LLM"] = codex.SUBSCRIPTION_MODEL persist_current() def _status(console: Console) -> int: record = codex.read_record() if record is None: - console.print("[yellow]Not signed in.[/] Run [cyan]strix auth login[/] to sign in.") + console.print("[yellow]Not signed in.[/] Run [cyan]strix auth login chatgpt[/] to sign in.") return 1 settings = load_settings() console.print("[green]Signed in[/] with a ChatGPT subscription (Codex).") console.print(f" Account: [bold]{record.get('account_id')}[/]") - console.print(f" Model: [bold]{settings.llm.model or codex.DEFAULT_CODEX_MODEL}[/]") - if settings.llm.auth_mode != "subscription": + if not codex.is_subscription(settings.llm.model): console.print( - " [yellow]Note:[/] STRIX_AUTH_MODE is not 'subscription'; " - "runs will use API-key billing until you re-run [cyan]strix auth login[/]." + " [yellow]Note:[/] STRIX_LLM is not 'openai/subscription', so runs won't use " + "the subscription. Set [cyan]STRIX_LLM=openai/subscription[/] (or re-run " + "[cyan]strix auth login chatgpt[/])." ) return 0 @@ -316,8 +301,8 @@ def _logout(console: Console) -> int: codex.logout() console.print("[green]Signed out.[/] Stored subscription credentials removed.") console.print( - "[dim]Runs still set to subscription mode will ask you to sign in again. " - "Set STRIX_AUTH_MODE=api_key (and LLM_API_KEY) to use metered billing.[/]" + "[dim]Runs with STRIX_LLM=openai/subscription will ask you to sign in again. " + "Point STRIX_LLM at an API-key model (and set LLM_API_KEY) to use metered billing.[/]" ) return 0 @@ -340,22 +325,20 @@ def _fail(console: Console, exc: codex.CodexAuthError) -> int: return 1 -def _print_success(console: Console, model: str) -> None: +def _print_success(console: Console) -> None: text = Text() text.append("Signed in with your ChatGPT subscription", style="bold #22c55e") text.append("\n\n", style="white") - text.append("Model", style="dim") - text.append(" ") - text.append(model, style="bold white") + text.append("STRIX_LLM", style="dim") + text.append(" ") + text.append(codex.SUBSCRIPTION_MODEL, style="bold white") text.append("\n") text.append("Billing", style="dim") - text.append(" ") + text.append(" ") text.append("your ChatGPT plan (no per-token API charges)", style="white") text.append("\n\n", style="white") text.append("Run a scan as usual, e.g. ", style="white") text.append("strix --target https://example.com", style="bold cyan") - text.append("\nChange model with ", style="dim") - text.append("strix auth login chatgpt --model ", style="cyan") console.print() console.print( Panel( diff --git a/strix/interface/main.py b/strix/interface/main.py index d3117a38..71eef6fd 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -18,6 +18,7 @@ from rich.console import Console from rich.panel import Panel from rich.text import Text +from strix.auth import codex from strix.config import ( apply_config_override, load_settings, @@ -85,7 +86,7 @@ def validate_environment() -> None: settings = load_settings() - if settings.llm.auth_mode == "subscription": + if codex.is_subscription(settings.llm.model): _validate_subscription_environment(console) return @@ -208,32 +209,29 @@ def validate_environment() -> None: def _validate_subscription_environment(console: Console) -> None: - """Gate a subscription-mode run on being signed in. + """Gate a ``STRIX_LLM=openai/subscription`` run on being signed in. - In subscription mode there is no API key to check; instead we require a - stored sign-in. The model name is optional here — the runner falls back to a - default model the subscription backend accepts. + There's no API key to check in this mode; instead we require a stored + sign-in. The concrete model is fixed by the subscription backend, so there's + nothing else to validate here. """ - from strix.auth import codex - if codex.is_authenticated(): - _warn_if_incompatible_subscription_model(console) - logger.info("Environment OK (subscription mode, signed in)") + logger.info("Environment OK (subscription, signed in)") return - logger.error("Subscription mode but not signed in") + logger.error("Subscription selected but not signed in") error_text = Text() error_text.append("NOT SIGNED IN", style="bold red") error_text.append("\n\n", style="white") error_text.append( - "Strix is set to use a model subscription, but no sign-in was found.\n", + "STRIX_LLM is set to 'openai/subscription', but no sign-in was found.\n", style="white", ) error_text.append("Sign in with:\n\n", style="white") - error_text.append("strix auth login", style="bold cyan") - error_text.append("\n\nOr switch back to API-key billing with:\n", style="white") - error_text.append("export STRIX_AUTH_MODE=api_key", style="bold cyan") - error_text.append(" # and set LLM_API_KEY\n", style="dim white") + error_text.append("strix auth login chatgpt", style="bold cyan") + error_text.append("\n\nOr set STRIX_LLM to an API-key model (e.g. ", style="white") + error_text.append("openai/gpt-5.4", style="bold cyan") + error_text.append(") and set LLM_API_KEY.\n", style="white") console.print("\n") console.print( @@ -249,44 +247,6 @@ def _validate_subscription_environment(console: Console) -> None: sys.exit(1) -def _warn_if_incompatible_subscription_model(console: Console) -> None: - """Warn (once, at startup) when the configured model can't run on the plan. - - In subscription mode a model from another provider (e.g. ``anthropic/…``) is - coerced to the default Codex model so the run still works; surface that so the - override isn't silent. A bare/``openai/`` name is left alone — the backend - validates it. - """ - from strix.auth import codex - - configured = (load_settings().llm.model or "").strip() - if not configured or codex.is_backend_compatible(configured): - return - - effective = codex.normalize_model(configured) - warn_text = Text() - warn_text.append("MODEL NOT AVAILABLE ON SUBSCRIPTION", style="bold yellow") - warn_text.append("\n\n", style="white") - warn_text.append("STRIX_LLM is set to ", style="white") - warn_text.append(f"'{configured}'", style="bold cyan") - warn_text.append(", which a ChatGPT subscription can't serve.\n", style="white") - warn_text.append("Using ", style="white") - warn_text.append(f"'{effective}'", style="bold cyan") - warn_text.append(" instead.\n\n", style="white") - warn_text.append("Pick a supported model with: ", style="white") - warn_text.append("strix auth login chatgpt --model gpt-5.4", style="bold cyan") - - console.print( - Panel( - warn_text, - title="[bold white]STRIX", - title_align="left", - border_style="yellow", - padding=(1, 2), - ) - ) - - def check_docker_installed() -> None: if shutil.which("docker") is None: logger.error("Docker CLI not found in PATH") @@ -358,14 +318,13 @@ def _subscription_error_hint(exc: BaseException) -> str | None: other conditions, with clear 400s. In subscription mode we translate those into a fix rather than surfacing a raw provider traceback. """ - if load_settings().llm.auth_mode != "subscription": + if not codex.is_subscription(load_settings().llm.model): return None joined = " ".join(_exception_messages(exc)).lower() if "not supported when using codex with a chatgpt account" in joined: return ( - "The selected model isn't available on your ChatGPT subscription.\n" - "Sign in again with a supported model, for example:\n" - " strix auth login chatgpt --model gpt-5.4" + "This model isn't available on your ChatGPT subscription. This is likely " + "an internal Strix issue — please report it." ) if "stream must be set to true" in joined: return ( @@ -394,18 +353,12 @@ async def warm_up_llm(show_model_warning: bool = True) -> None: settings = load_settings() configure_sdk_model_defaults(settings) llm = settings.llm - subscription = llm.auth_mode == "subscription" - + subscription = codex.is_subscription(llm.model) + raw_model = (llm.model or "").strip() + # The subscription model self-enforces the backend's requirements + # (streaming, store=false, encrypted reasoning), so a plain warm-up call + # is fine. warm_model_settings = ModelSettings() - if subscription: - from strix.auth import codex - - raw_model = codex.normalize_model(llm.model) - warm_model_settings = ModelSettings( - store=False, response_include=["reasoning.encrypted_content"] - ) - else: - raw_model = (llm.model or "").strip() if ( not subscription @@ -812,7 +765,7 @@ def _persist_run_record(args: argparse.Namespace) -> None: "status": "running", "start_time": datetime.now(UTC).isoformat(), "end_time": None, - "auth_mode": load_settings().llm.auth_mode, + "auth_mode": codex.auth_mode_label(load_settings().llm.model), "targets_info": args.targets_info, "scan_mode": args.scan_mode, "instruction": args.instruction, @@ -1097,7 +1050,7 @@ def main() -> None: _telemetry_start_kwargs = { "model": load_settings().llm.model, - "auth_mode": load_settings().llm.auth_mode, + "auth_mode": codex.auth_mode_label(load_settings().llm.model), "scan_mode": args.scan_mode, "is_whitebox": is_whitebox_scan(args.targets_info), "interactive": not args.non_interactive, diff --git a/strix/interface/utils.py b/strix/interface/utils.py index dde0571b..0cc4f180 100644 --- a/strix/interface/utils.py +++ b/strix/interface/utils.py @@ -262,7 +262,9 @@ def _is_subscription(report_state: Any) -> bool: record = getattr(report_state, "run_record", None) if isinstance(record, dict) and record.get("auth_mode"): return record.get("auth_mode") == "subscription" - return load_settings().llm.auth_mode == "subscription" + from strix.auth import codex + + return codex.is_subscription(load_settings().llm.model) def _int_stat(usage: dict[str, Any], key: str) -> int: diff --git a/strix/report/state.py b/strix/report/state.py index aab298a6..d0a73b2e 100644 --- a/strix/report/state.py +++ b/strix/report/state.py @@ -10,6 +10,7 @@ from uuid import uuid4 from agents.usage import Usage +from strix.auth import codex from strix.config.loader import load_settings from strix.core.paths import run_dir_for from strix.report.sarif import write_sarif @@ -118,10 +119,11 @@ class ReportState: self.scan_results: dict[str, Any] | None = None self.scan_config: dict[str, Any] | None = None self._llm_usage = LLMUsageLedger() - # In subscription mode inference is covered by the user's plan, so there - # is no metered cost — track tokens but report $0. - auth_mode = load_settings().llm.auth_mode - self._llm_usage.zero_cost = auth_mode == "subscription" + # A subscription run is covered by the user's plan, so there is no metered + # cost — track tokens but report $0. + model = load_settings().llm.model + auth_mode = codex.auth_mode_label(model) + self._llm_usage.zero_cost = codex.is_subscription(model) self.run_record: dict[str, Any] = { "run_id": self.run_id, "run_name": self.run_name, diff --git a/tests/test_auth_cli.py b/tests/test_auth_cli.py index 3c32b89e..054232c1 100644 --- a/tests/test_auth_cli.py +++ b/tests/test_auth_cli.py @@ -97,7 +97,7 @@ def test_login_accepts_provider_aliases(provider: str, monkeypatch: pytest.Monke monkeypatch.setattr(auth_cli, "_run_oauth_flow", _fake_flow) monkeypatch.setattr(codex, "save_record", lambda _record: None) - monkeypatch.setattr(auth_cli, "_persist_subscription_config", lambda _model: None) + monkeypatch.setattr(auth_cli, "_persist_subscription_config", lambda: None) assert auth_cli.run_auth(["login", provider]) == 0 assert reached["flow"] is True diff --git a/tests/test_codex_auth.py b/tests/test_codex_auth.py index 5feacbd0..ea63291b 100644 --- a/tests/test_codex_auth.py +++ b/tests/test_codex_auth.py @@ -69,36 +69,25 @@ def test_parse_redirect_input(value: str, expected: tuple[str | None, str | None @pytest.mark.parametrize( ("model", "expected"), [ - ("openai/gpt-5.5", "gpt-5.5"), - ("gpt-5.4", "gpt-5.4"), - # A configured-but-unlisted OpenAI/bare name is passed through; the backend validates. - ("openai/gpt-5.6", "gpt-5.6"), - # Another provider can't be served by a ChatGPT subscription → coerced to default. - ("anthropic/claude-opus-4-8", codex.DEFAULT_CODEX_MODEL), - ("deepseek/deepseek-v4-pro", codex.DEFAULT_CODEX_MODEL), - ("vertex_ai/gemini-3-pro", codex.DEFAULT_CODEX_MODEL), - (None, codex.DEFAULT_CODEX_MODEL), - ("", codex.DEFAULT_CODEX_MODEL), - ], -) -def test_normalize_model(model: str | None, expected: str) -> None: - assert codex.normalize_model(model) == expected - - -@pytest.mark.parametrize( - ("model", "compatible"), - [ - ("openai/gpt-5.5", True), - ("gpt-5.4", True), - ("gpt-5.1-codex", True), # bare name: backend is the authority + ("openai/subscription", True), + ("OpenAI/Subscription", True), # case-insensitive + (" openai/subscription ", True), # surrounding whitespace + ("openai/gpt-5.4", False), ("anthropic/claude-opus-4-8", False), - ("deepseek/deepseek-v4-pro", False), + ("openai/subscription/gpt-5.5", False), # only the bare sentinel selects it ("", False), (None, False), ], ) -def test_is_backend_compatible(model: str | None, compatible: bool) -> None: - assert codex.is_backend_compatible(model) is compatible +def test_is_subscription(model: str | None, expected: bool) -> None: + assert codex.is_subscription(model) is expected + + +def test_resolve_and_label() -> None: + assert codex.resolve_subscription_model() == codex.DEFAULT_CODEX_MODEL + assert codex.auth_mode_label("openai/subscription") == "subscription" + assert codex.auth_mode_label("openai/gpt-5.4") == "api_key" + assert codex.auth_mode_label(None) == "api_key" def test_account_id_from_jwt() -> None: diff --git a/tests/test_codex_streaming.py b/tests/test_codex_streaming.py index daef061d..b28193cc 100644 --- a/tests/test_codex_streaming.py +++ b/tests/test_codex_streaming.py @@ -63,6 +63,9 @@ def _response_payload() -> dict[str, Any]: } +_CAPTURED: dict[str, Any] = {} + + class _Handler(BaseHTTPRequestHandler): def log_message(self, *args: Any) -> None: pass @@ -70,6 +73,8 @@ class _Handler(BaseHTTPRequestHandler): def do_POST(self) -> None: length = int(self.headers.get("Content-Length", 0)) body = json.loads(self.rfile.read(length) or b"{}") + _CAPTURED.clear() + _CAPTURED.update(body) if not body.get("stream"): payload = json.dumps({"detail": "Stream must be set to true"}).encode() self.send_response(400) @@ -136,3 +141,21 @@ async def test_codex_model_streams_and_aggregates(backend_url: str) -> None: response = await model.get_response(**_call_kwargs()) assert response.output[0].content[0].text == "OK" assert response.usage.total_tokens == 2 + + +@pytest.mark.asyncio +async def test_codex_model_self_enforces_backend_requirements(backend_url: str) -> None: + # The caller passes ordinary settings; the model must impose the backend's + # requirements (stream, store=false, encrypted reasoning) and the configured + # reasoning effort itself. + model = _CodexResponsesModel( + model="gpt-5.4", openai_client=_client(backend_url), reasoning_effort="high" + ) + kwargs = _call_kwargs() + kwargs["model_settings"] = ModelSettings() # nothing special from the caller + await model.get_response(**kwargs) + + assert _CAPTURED["stream"] is True + assert _CAPTURED["store"] is False + assert _CAPTURED["include"] == ["reasoning.encrypted_content"] + assert _CAPTURED["reasoning"] == {"effort": "high"} diff --git a/tests/test_runner_rate_limit.py b/tests/test_runner_rate_limit.py index 7772c653..62242339 100644 --- a/tests/test_runner_rate_limit.py +++ b/tests/test_runner_rate_limit.py @@ -35,7 +35,6 @@ async def test_persistent_rate_limit_stops_gracefully( settings = types.SimpleNamespace( llm=types.SimpleNamespace( model="openai/gpt-4o", - auth_mode="api_key", reasoning_effort="high", force_required_tool_choice=False, timeout=300, diff --git a/tests/test_runner_root_prompt.py b/tests/test_runner_root_prompt.py index 50eb5c8d..aaa8ef6b 100644 --- a/tests/test_runner_root_prompt.py +++ b/tests/test_runner_root_prompt.py @@ -43,7 +43,6 @@ def _patch_engine_scaffold( settings = types.SimpleNamespace( llm=types.SimpleNamespace( model="openai/gpt-4o", - auth_mode="api_key", reasoning_effort="high", force_required_tool_choice=False, timeout=300,