feat(auth): sign in with a ChatGPT subscription for inference

Add an OAuth-based path to run Strix on a user's ChatGPT Plus/Pro
subscription instead of a metered API key, modeled on OpenAI's Codex CLI.

Auth:
- strix/auth: Codex OAuth login (authorization-code + PKCE), a 0600 token
  store, refresh-on-expiry, and an AsyncOpenAI client that routes inference
  through the ChatGPT backend (chatgpt.com/backend-api/codex) with a
  per-request auth hook so long scans survive token expiry.
- `strix auth login|logout|status` CLI (browser loopback on :1455 with a
  manual-paste fallback); STRIX_AUTH_MODE=subscription persisted to config.

Inference wiring:
- Subscription branch in configure_sdk_model_defaults installs the Codex
  client and the Responses API.
- _CodexResponsesModel always streams (the backend rejects non-streamed
  requests) and aggregates back for the non-streaming get_response path.
- store=false + encrypted reasoning for the stateless backend; models
  coerced to plan-available names (default gpt-5.4 — 5.5+ apply stricter
  content moderation that interferes with security testing).

UX / reporting:
- Track tokens but report $0.00 in the TUI, completion panel, and web
  viewer run details; record auth_mode in run.json and PostHog/Scarf.
- Graceful, actionable errors for unavailable models and expired sign-in.
- Restyled OAuth callback page (Strix branding + link to strix.ai).

Tests: PKCE/URL/redirect parsing, token refresh + account-id, streaming
aggregation, cost zeroing, CLI routing/provider aliasing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jonathan Singer
2026-07-22 16:03:24 -04:00
co-authored by Claude Fable 5
parent 89a707ff51
commit d35af02e47
26 changed files with 1839 additions and 88 deletions
+138
View File
@@ -0,0 +1,138 @@
"""Regression test for the ChatGPT Codex backend's streaming requirement.
The backend rejects non-streamed requests with ``{"detail": "Stream must be set
to true"}``. ``_CodexResponsesModel`` must therefore issue a streamed request
even from the non-streaming ``get_response`` path and aggregate the events into
a single response. A local server that mimics that behaviour proves the wrapper
works where the stock responses model would fail.
"""
from __future__ import annotations
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import TYPE_CHECKING, Any
import pytest
from agents.model_settings import ModelSettings
from agents.models.interface import ModelTracing
from agents.models.openai_responses import OpenAIResponsesModel
from openai import AsyncOpenAI, BadRequestError
from strix.config.models import _CodexResponsesModel
if TYPE_CHECKING:
from collections.abc import Iterator
def _response_payload() -> dict[str, Any]:
return {
"id": "resp_1",
"object": "response",
"created_at": 0,
"status": "completed",
"model": "gpt-5.5",
"output": [
{
"type": "message",
"id": "m1",
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": "OK", "annotations": []}],
}
],
"usage": {
"input_tokens": 1,
"output_tokens": 1,
"total_tokens": 2,
"input_tokens_details": {"cached_tokens": 0},
"output_tokens_details": {"reasoning_tokens": 0},
},
"parallel_tool_calls": False,
"tool_choice": "auto",
"tools": [],
"metadata": {},
"temperature": 1.0,
"top_p": 1.0,
"error": None,
"incomplete_details": None,
"instructions": None,
"max_output_tokens": None,
}
class _Handler(BaseHTTPRequestHandler):
def log_message(self, *args: Any) -> None:
pass
def do_POST(self) -> None:
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length) or b"{}")
if not body.get("stream"):
payload = json.dumps({"detail": "Stream must be set to true"}).encode()
self.send_response(400)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
return
event = {
"type": "response.completed",
"sequence_number": 0,
"response": _response_payload(),
}
frame = f"event: response.completed\ndata: {json.dumps(event)}\n\n".encode()
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.end_headers()
self.wfile.write(frame)
@pytest.fixture
def backend_url() -> Iterator[str]:
server = HTTPServer(("127.0.0.1", 0), _Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_address[1]}/backend-api/codex"
finally:
server.shutdown()
server.server_close()
def _client(base_url: str) -> AsyncOpenAI:
return AsyncOpenAI(api_key="tok", base_url=base_url)
def _call_kwargs() -> dict[str, Any]:
return {
"system_instructions": "s",
"input": "hi",
"model_settings": ModelSettings(
store=False, response_include=["reasoning.encrypted_content"]
),
"tools": [],
"output_schema": None,
"handoffs": [],
"tracing": ModelTracing.DISABLED,
"previous_response_id": None,
"conversation_id": None,
"prompt": None,
}
@pytest.mark.asyncio
async def test_stock_model_fails_on_non_streamed_backend(backend_url: str) -> None:
model = OpenAIResponsesModel(model="gpt-5.5", openai_client=_client(backend_url))
with pytest.raises(BadRequestError, match="Stream must be set to true"):
await model.get_response(**_call_kwargs())
@pytest.mark.asyncio
async def test_codex_model_streams_and_aggregates(backend_url: str) -> None:
model = _CodexResponsesModel(model="gpt-5.5", openai_client=_client(backend_url))
response = await model.get_response(**_call_kwargs())
assert response.output[0].content[0].text == "OK"
assert response.usage.total_tokens == 2