auth: make STRIX_LLM=openai/subscription the single switch

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 <noreply@anthropic.com>
This commit is contained in:
Jonathan Singer
2026-07-22 23:10:57 -04:00
co-authored by Claude Fable 5
parent 9f54b2f144
commit 30390628da
16 changed files with 220 additions and 285 deletions
+6 -4
View File
@@ -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 <name>`. 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.
+1 -1
View File
@@ -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
+38 -42
View File
@@ -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",
]
+63 -40
View File
@@ -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/"):
+3 -5
View File
@@ -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"),
+4 -30
View File
@@ -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,
-8
View File
@@ -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,
+35 -52
View File
@@ -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 <name>", style="cyan")
console.print()
console.print(
Panel(
+23 -70
View File
@@ -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,
+3 -1
View File
@@ -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:
+6 -4
View File
@@ -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,
+1 -1
View File
@@ -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
+14 -25
View File
@@ -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:
+23
View File
@@ -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"}
-1
View File
@@ -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,
-1
View File
@@ -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,