mirror of
https://github.com/usestrix/strix.git
synced 2026-08-20 18:38:57 +02:00
Sign in with a ChatGPT subscription for inference (#854)
Co-authored-by: Jonathan Singer <jonathansinger@Jonathans-MacBook-Pro.local> Co-authored-by: Jonathan Singer <jonathansinger@Mac-3004.lan> Co-authored-by: Ahmed Allam <ahmed39652003@gmail.com>
This commit is contained in:
co-authored by
Jonathan Singer
Jonathan Singer
Ahmed Allam
parent
93af2b94a2
commit
cd8270c98b
@@ -0,0 +1,106 @@
|
||||
"""Tests for the `strix auth` CLI: subcommand routing and provider naming."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.config import codex
|
||||
from strix.interface import auth_cli
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _tmp_store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(codex, "AUTH_PATH", tmp_path / "home" / ".strix" / "subscription-auth.json")
|
||||
|
||||
|
||||
def test_login_provider_is_chatgpt() -> None:
|
||||
assert auth_cli.LOGIN_PROVIDER == "chatgpt"
|
||||
assert codex.PROVIDER in auth_cli._ACCEPTED_PROVIDERS
|
||||
assert "chatgpt" in auth_cli._ACCEPTED_PROVIDERS
|
||||
|
||||
|
||||
def test_unknown_subcommand_returns_usage_error() -> None:
|
||||
assert auth_cli.run_auth(["bogus"]) == 2
|
||||
|
||||
|
||||
def test_help_returns_zero() -> None:
|
||||
assert auth_cli.run_auth(["--help"]) == 0
|
||||
|
||||
|
||||
def test_status_not_signed_in() -> None:
|
||||
assert auth_cli.run_auth(["status"]) == 1
|
||||
|
||||
|
||||
def test_login_rejects_unsupported_provider(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def _should_not_run(*_args: Any, **_kwargs: Any) -> dict[str, Any]:
|
||||
msg = "OAuth flow must not start for an unsupported provider"
|
||||
raise AssertionError(msg)
|
||||
|
||||
monkeypatch.setattr(auth_cli, "_run_oauth_flow", _should_not_run)
|
||||
assert auth_cli.run_auth(["login", "gemini"]) == 2
|
||||
|
||||
|
||||
def test_finish_requires_state_on_loopback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(codex, "exchange_code", lambda *_: {"ok": True})
|
||||
|
||||
# Loopback (require_state=True): missing or mismatched state is rejected.
|
||||
with pytest.raises(codex.CodexAuthError) as missing:
|
||||
auth_cli._finish("code", None, "verifier", "expected", require_state=True)
|
||||
assert missing.value.code == "state_mismatch"
|
||||
with pytest.raises(codex.CodexAuthError) as mismatch:
|
||||
auth_cli._finish("code", "wrong", "verifier", "expected", require_state=True)
|
||||
assert mismatch.value.code == "state_mismatch"
|
||||
|
||||
# Matching state proceeds to the exchange.
|
||||
assert auth_cli._finish("code", "expected", "verifier", "expected", require_state=True) == {
|
||||
"ok": True
|
||||
}
|
||||
|
||||
|
||||
def test_finish_manual_paste_allows_absent_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(codex, "exchange_code", lambda *_: {"ok": True})
|
||||
# Manual paste (require_state=False): a bare code with no state is accepted,
|
||||
# but a present-and-wrong state is still rejected.
|
||||
assert auth_cli._finish("code", None, "verifier", "expected", require_state=False) == {
|
||||
"ok": True
|
||||
}
|
||||
with pytest.raises(codex.CodexAuthError):
|
||||
auth_cli._finish("code", "wrong", "verifier", "expected", require_state=False)
|
||||
|
||||
|
||||
def test_finish_rejects_missing_code() -> None:
|
||||
with pytest.raises(codex.CodexAuthError) as exc:
|
||||
auth_cli._finish(None, "expected", "verifier", "expected", require_state=True)
|
||||
assert exc.value.code == "no_code"
|
||||
|
||||
|
||||
def test_model_subcommand_removed() -> None:
|
||||
assert auth_cli.run_auth(["model", "gpt-5.5"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider", ["chatgpt", "codex", "ChatGPT"])
|
||||
def test_login_accepts_provider_aliases(provider: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
reached = {"flow": False}
|
||||
|
||||
def _fake_flow(*_args: Any, **_kwargs: Any) -> dict[str, Any]:
|
||||
reached["flow"] = True
|
||||
return {
|
||||
"type": "oauth",
|
||||
"provider": "codex",
|
||||
"access": "a",
|
||||
"refresh": "r",
|
||||
"account_id": "acct",
|
||||
"expires_at": 0,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(auth_cli, "_run_oauth_flow", _fake_flow)
|
||||
monkeypatch.setattr(codex, "save_record", lambda _record: None)
|
||||
|
||||
assert auth_cli.run_auth(["login", provider]) == 0
|
||||
assert reached["flow"] is True
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Tests for ChatGPT (Codex) subscription auth: PKCE, token handling, store."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.config import codex
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _fake_jwt(account_id: str) -> str:
|
||||
def seg(obj: dict[str, Any]) -> str:
|
||||
return base64.urlsafe_b64encode(json.dumps(obj).encode()).rstrip(b"=").decode()
|
||||
|
||||
header = seg({"alg": "none"})
|
||||
payload = seg({"https://api.openai.com/auth": {"chatgpt_account_id": account_id}})
|
||||
return f"{header}.{payload}.sig"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _tmp_store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
path = tmp_path / "home" / ".strix" / "subscription-auth.json"
|
||||
monkeypatch.setattr(codex, "AUTH_PATH", path)
|
||||
return path
|
||||
|
||||
|
||||
def test_pkce_challenge_matches_verifier_and_is_unpadded() -> None:
|
||||
verifier, challenge = codex.generate_pkce()
|
||||
expected = (
|
||||
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
||||
)
|
||||
assert challenge == expected
|
||||
assert "=" not in verifier
|
||||
assert "=" not in challenge
|
||||
|
||||
|
||||
def test_authorize_url_carries_pkce_and_client() -> None:
|
||||
url = codex.build_authorize_url("chal", "st8")
|
||||
assert codex.AUTHORIZE_URL in url
|
||||
assert "code_challenge=chal" in url
|
||||
assert "code_challenge_method=S256" in url
|
||||
assert f"client_id={codex.CLIENT_ID}" in url
|
||||
assert "state=st8" in url
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("http://localhost:1455/auth/callback?code=AAA&state=BBB", ("AAA", "BBB")),
|
||||
("AAA#BBB", ("AAA", "BBB")),
|
||||
("code=AAA&state=BBB", ("AAA", "BBB")),
|
||||
("AAA", ("AAA", None)),
|
||||
("", (None, None)),
|
||||
],
|
||||
)
|
||||
def test_parse_redirect_input(value: str, expected: tuple[str | None, str | None]) -> None:
|
||||
assert codex.parse_redirect_input(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "expected"),
|
||||
[
|
||||
("chatgpt/gpt-5.4", "gpt-5.4"),
|
||||
("ChatGPT/GPT-5.5", "GPT-5.5"),
|
||||
(" chatgpt/gpt-5.4 ", "gpt-5.4"),
|
||||
("openai/gpt-5.4", None), # metered API path
|
||||
("anthropic/claude-opus-4-8", None),
|
||||
("gpt-5.4", None),
|
||||
("chatgpt/", None),
|
||||
("", None),
|
||||
(None, None),
|
||||
],
|
||||
)
|
||||
def test_subscription_model(model: str | None, expected: str | None) -> None:
|
||||
assert codex.subscription_model(model) == expected
|
||||
|
||||
|
||||
def test_auth_mode() -> None:
|
||||
assert codex.auth_mode("chatgpt/gpt-5.4") == "subscription"
|
||||
assert codex.auth_mode("openai/gpt-5.4") == "api_key"
|
||||
assert codex.auth_mode("anthropic/claude-opus-4-8") == "api_key"
|
||||
assert codex.auth_mode(None) == "api_key"
|
||||
|
||||
|
||||
def test_is_content_guardrail_error() -> None:
|
||||
# The backend's real wording (from a live gpt-5.6-sol block).
|
||||
raw = RuntimeError(
|
||||
"This content was flagged for possible cybersecurity risk. If this seems "
|
||||
"wrong, try rephrasing. To get authorized, join the Trusted Access for Cyber program."
|
||||
)
|
||||
assert codex.is_content_guardrail_error(raw) is True
|
||||
# The already-typed error is recognized regardless of its message wording.
|
||||
assert codex.is_content_guardrail_error(codex.CodexContentGuardrailError("gpt-5.6-sol")) is True
|
||||
# Unrelated errors are not misclassified.
|
||||
assert codex.is_content_guardrail_error(RuntimeError("rate limit exceeded")) is False
|
||||
|
||||
|
||||
def test_content_guardrail_error_message() -> None:
|
||||
err = codex.CodexContentGuardrailError("gpt-5.6-sol")
|
||||
assert err.model == "gpt-5.6-sol"
|
||||
assert "gpt-5.6-sol" in str(err)
|
||||
assert "STRIX_LLM" in str(err)
|
||||
|
||||
|
||||
def test_account_id_from_jwt() -> None:
|
||||
assert codex._account_id_from_jwt(_fake_jwt("acct-42")) == "acct-42"
|
||||
assert codex._account_id_from_jwt("not-a-jwt") is None
|
||||
assert codex._account_id_from_jwt("") is None
|
||||
|
||||
|
||||
def test_store_roundtrip_and_logout() -> None:
|
||||
assert codex.read_record() is None
|
||||
assert codex.is_authenticated() is False
|
||||
|
||||
codex.save_record(
|
||||
{
|
||||
"type": "oauth",
|
||||
"provider": "codex",
|
||||
"access": _fake_jwt("acct-42"),
|
||||
"refresh": "r1",
|
||||
"account_id": "acct-42",
|
||||
"expires_at": time.time() + 3600,
|
||||
}
|
||||
)
|
||||
record = codex.read_record()
|
||||
assert record is not None
|
||||
assert record["account_id"] == "acct-42"
|
||||
assert codex.is_authenticated() is True
|
||||
|
||||
codex.logout()
|
||||
assert codex.read_record() is None
|
||||
codex.logout() # no-op when already gone
|
||||
|
||||
|
||||
def test_read_record_rejects_incomplete_records() -> None:
|
||||
codex.save_record({"type": "oauth", "access": "a"}) # missing refresh/account
|
||||
assert codex.read_record() is None
|
||||
assert codex.is_authenticated() is False
|
||||
|
||||
|
||||
def test_get_valid_token_returns_stored_when_fresh(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def _boom(_payload: dict[str, str]) -> dict[str, Any]:
|
||||
msg = "should not refresh a fresh token"
|
||||
raise AssertionError(msg)
|
||||
|
||||
monkeypatch.setattr(codex, "_post_form", _boom)
|
||||
codex.save_record(
|
||||
{
|
||||
"type": "oauth",
|
||||
"provider": "codex",
|
||||
"access": "access-fresh",
|
||||
"refresh": "r1",
|
||||
"account_id": "acct-42",
|
||||
"expires_at": time.time() + 3600,
|
||||
}
|
||||
)
|
||||
assert codex.get_valid_token() == ("access-fresh", "acct-42")
|
||||
|
||||
|
||||
def test_get_valid_token_refreshes_and_persists_rotation(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls = {"n": 0}
|
||||
|
||||
def _fake_post(payload: dict[str, str]) -> dict[str, Any]:
|
||||
calls["n"] += 1
|
||||
assert payload["grant_type"] == "refresh_token"
|
||||
assert payload["refresh_token"] == "r1"
|
||||
return {"access_token": _fake_jwt("acct-42"), "refresh_token": "r2", "expires_in": 3600}
|
||||
|
||||
monkeypatch.setattr(codex, "_post_form", _fake_post)
|
||||
codex.save_record(
|
||||
{
|
||||
"type": "oauth",
|
||||
"provider": "codex",
|
||||
"access": "stale",
|
||||
"refresh": "r1",
|
||||
"account_id": "acct-42",
|
||||
"expires_at": time.time() - 10, # already expired
|
||||
}
|
||||
)
|
||||
_access, account_id = codex.get_valid_token()
|
||||
assert calls["n"] == 1
|
||||
assert account_id == "acct-42"
|
||||
# Rotated refresh token was written back to the store.
|
||||
record = codex.read_record()
|
||||
assert record is not None
|
||||
assert record["refresh"] == "r2"
|
||||
|
||||
|
||||
def test_get_valid_token_uses_token_rotated_by_another_process(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Simulate a parallel Strix process rotating the token while we wait for the
|
||||
# refresh guard: the pre-guard read sees the stale token, the in-guard read
|
||||
# sees the winner's fresh one, so we must NOT exchange the now-dead refresh.
|
||||
records = [
|
||||
{
|
||||
"type": "oauth",
|
||||
"provider": "codex",
|
||||
"access": "stale",
|
||||
"refresh": "r1",
|
||||
"account_id": "acct",
|
||||
"expires_at": time.time() - 10,
|
||||
},
|
||||
{
|
||||
"type": "oauth",
|
||||
"provider": "codex",
|
||||
"access": "fresh-from-other-process",
|
||||
"refresh": "r2",
|
||||
"account_id": "acct",
|
||||
"expires_at": time.time() + 3600,
|
||||
},
|
||||
]
|
||||
calls = {"n": 0}
|
||||
|
||||
def _fake_read() -> dict[str, Any]:
|
||||
record = records[min(calls["n"], len(records) - 1)]
|
||||
calls["n"] += 1
|
||||
return record
|
||||
|
||||
def _boom(_payload: dict[str, str]) -> dict[str, Any]:
|
||||
msg = "must not refresh a token another process already rotated"
|
||||
raise AssertionError(msg)
|
||||
|
||||
monkeypatch.setattr(codex, "read_record", _fake_read)
|
||||
monkeypatch.setattr(codex, "_post_form", _boom)
|
||||
|
||||
access, account_id = codex.get_valid_token()
|
||||
assert access == "fresh-from-other-process"
|
||||
assert account_id == "acct"
|
||||
|
||||
|
||||
def _expired_record(refresh: str, access: str) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "oauth",
|
||||
"provider": "codex",
|
||||
"access": access,
|
||||
"refresh": refresh,
|
||||
"account_id": "acct-42",
|
||||
"expires_at": time.time() - 10,
|
||||
}
|
||||
|
||||
|
||||
def test_get_valid_token_recovers_when_refresh_loses_race(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Lock failed open: our in-guard read still saw the stale token, so we tried to
|
||||
# refresh and lost the race (invalid_grant). By then a peer has saved a fresh
|
||||
# token — recover from it instead of failing the scan on the dead one.
|
||||
codex.save_record(_expired_record("r1", "stale"))
|
||||
|
||||
def _fake_post(_payload: dict[str, str]) -> dict[str, Any]:
|
||||
codex.save_record(
|
||||
{
|
||||
"type": "oauth",
|
||||
"provider": "codex",
|
||||
"access": "fresh-from-peer",
|
||||
"refresh": "r2",
|
||||
"account_id": "acct-42",
|
||||
"expires_at": time.time() + 3600,
|
||||
}
|
||||
)
|
||||
raise codex.CodexAuthError("token_http_error", "HTTP 400: invalid_grant")
|
||||
|
||||
monkeypatch.setattr(codex, "_post_form", _fake_post)
|
||||
assert codex.get_valid_token() == ("fresh-from-peer", "acct-42")
|
||||
|
||||
|
||||
def test_get_valid_token_reraises_refresh_error_without_rotation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Refresh fails and no peer rotated the token: surface the error, don't mask it.
|
||||
codex.save_record(_expired_record("r1", "stale"))
|
||||
|
||||
def _fake_post(_payload: dict[str, str]) -> dict[str, Any]:
|
||||
raise codex.CodexAuthError("token_http_error", "HTTP 400: invalid_grant")
|
||||
|
||||
monkeypatch.setattr(codex, "_post_form", _fake_post)
|
||||
with pytest.raises(codex.CodexAuthError):
|
||||
codex.get_valid_token()
|
||||
|
||||
|
||||
def test_get_valid_token_raises_when_not_signed_in() -> None:
|
||||
with pytest.raises(codex.CodexAuthError) as exc:
|
||||
codex.get_valid_token()
|
||||
assert exc.value.code == "not_authenticated"
|
||||
@@ -0,0 +1,225 @@
|
||||
"""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 openai.types.responses import ResponseOutputMessage, ResponseOutputText
|
||||
|
||||
from strix.config import codex
|
||||
from strix.config.models import _CodexResponsesModel
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, 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,
|
||||
}
|
||||
|
||||
|
||||
_CAPTURED: dict[str, Any] = {}
|
||||
|
||||
|
||||
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"{}")
|
||||
_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)
|
||||
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())
|
||||
message = response.output[0]
|
||||
assert isinstance(message, ResponseOutputMessage)
|
||||
text = message.content[0]
|
||||
assert isinstance(text, ResponseOutputText)
|
||||
assert text.text == "OK"
|
||||
assert response.usage.total_tokens == 2
|
||||
|
||||
|
||||
class _TrackingStream:
|
||||
"""An async iterator that yields, then raises, and records if it was closed."""
|
||||
|
||||
def __init__(self, events: list[Any], error: Exception | None) -> None:
|
||||
self._events = iter(events)
|
||||
self._error = error
|
||||
self.closed = False
|
||||
|
||||
def __aiter__(self) -> _TrackingStream:
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> Any:
|
||||
try:
|
||||
return next(self._events)
|
||||
except StopIteration:
|
||||
if self._error is not None:
|
||||
raise self._error from None
|
||||
raise StopAsyncIteration from None
|
||||
|
||||
async def aclose(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
async def _drain(gen: AsyncIterator[Any]) -> list[Any]:
|
||||
return [event async for event in gen]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_guarded_converts_guardrail_error() -> None:
|
||||
# A mid-stream backend rejection becomes a typed, model-tagged error.
|
||||
model = _CodexResponsesModel(model="gpt-5.6-sol", openai_client=_client("http://x/backend-api"))
|
||||
guardrail = RuntimeError("This content was flagged for possible cybersecurity risk.")
|
||||
stream = _TrackingStream(["a", "b"], guardrail)
|
||||
with pytest.raises(codex.CodexContentGuardrailError) as exc_info:
|
||||
await _drain(model._guarded(stream))
|
||||
assert exc_info.value.model == "gpt-5.6-sol"
|
||||
assert stream.closed is True # underlying stream is released
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_guarded_passes_through_other_errors() -> None:
|
||||
# A non-guardrail error propagates unchanged (still not swallowed).
|
||||
model = _CodexResponsesModel(model="gpt-5.5", openai_client=_client("http://x/backend-api"))
|
||||
boom = RuntimeError("some unrelated failure")
|
||||
stream = _TrackingStream(["a"], boom)
|
||||
with pytest.raises(RuntimeError, match="some unrelated failure"):
|
||||
await _drain(model._guarded(stream))
|
||||
assert stream.closed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_guarded_yields_all_events_when_clean() -> None:
|
||||
model = _CodexResponsesModel(model="gpt-5.4", openai_client=_client("http://x/backend-api"))
|
||||
stream = _TrackingStream(["a", "b", "c"], None)
|
||||
assert await _drain(model._guarded(stream)) == ["a", "b", "c"]
|
||||
assert stream.closed is True
|
||||
|
||||
|
||||
@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"}
|
||||
@@ -13,12 +13,15 @@ import asyncio
|
||||
|
||||
from agents.retry import ModelRetryNormalizedError, RetryPolicyContext
|
||||
|
||||
from strix.config import codex
|
||||
from strix.config.models import DEFAULT_MODEL_RETRY, _retry_statusless_provider_errors
|
||||
|
||||
|
||||
def _context(normalized: ModelRetryNormalizedError) -> RetryPolicyContext:
|
||||
def _context(
|
||||
normalized: ModelRetryNormalizedError, error: Exception | None = None
|
||||
) -> RetryPolicyContext:
|
||||
return RetryPolicyContext(
|
||||
error=RuntimeError("boom"),
|
||||
error=error or RuntimeError("boom"),
|
||||
attempt=1,
|
||||
max_retries=5,
|
||||
stream=True,
|
||||
@@ -27,11 +30,11 @@ def _context(normalized: ModelRetryNormalizedError) -> RetryPolicyContext:
|
||||
)
|
||||
|
||||
|
||||
def _retries(normalized: ModelRetryNormalizedError) -> bool:
|
||||
def _retries(normalized: ModelRetryNormalizedError, error: Exception | None = None) -> bool:
|
||||
"""Evaluate the composed DEFAULT_MODEL_RETRY policy for a normalized error."""
|
||||
policy = DEFAULT_MODEL_RETRY.policy
|
||||
assert policy is not None
|
||||
decision = asyncio.run(policy(_context(normalized)))
|
||||
decision = asyncio.run(policy(_context(normalized, error)))
|
||||
return bool(getattr(decision, "retry", decision))
|
||||
|
||||
|
||||
@@ -63,6 +66,16 @@ def test_timeout_error_is_retried() -> None:
|
||||
assert _retries(ModelRetryNormalizedError(is_network_error=True)) is True
|
||||
|
||||
|
||||
def test_content_guardrail_error_is_not_retried() -> None:
|
||||
# A guardrail block is status-less, so it would match the statusless policy;
|
||||
# the guard must keep it from being retried (retrying never clears it).
|
||||
guardrail = codex.CodexContentGuardrailError("gpt-5.6-sol")
|
||||
assert _retries(ModelRetryNormalizedError(status_code=None), guardrail) is False
|
||||
# A raw provider error carrying the backend's wording is excluded too.
|
||||
raw = RuntimeError("This content was flagged for possible cybersecurity risk.")
|
||||
assert _retries(ModelRetryNormalizedError(status_code=None), raw) is False
|
||||
|
||||
|
||||
def test_policy_helper_matches_statusless_only() -> None:
|
||||
assert _retry_statusless_provider_errors(_context(ModelRetryNormalizedError())) is True
|
||||
assert (
|
||||
|
||||
@@ -42,9 +42,11 @@ def test_recommended_models_are_matched_case_insensitively() -> None:
|
||||
"model_name",
|
||||
[
|
||||
"gpt-5.5",
|
||||
"chatgpt/gpt-5.4",
|
||||
"litellm/openai/gpt-5.4-pro",
|
||||
"azure_ai/gpt-5.5-pro",
|
||||
"bedrock_mantle/openai.gpt-5.5",
|
||||
"anthropic/claude-opus-5",
|
||||
"anthropic/claude-opus-4-8",
|
||||
"anthropic.claude-opus-4-8",
|
||||
"anthropic/claude-opus-4-7",
|
||||
@@ -60,8 +62,10 @@ def test_recommended_models_are_matched_case_insensitively() -> None:
|
||||
"deepseek/deepseek-reasoner",
|
||||
"dashscope/qwen3-max-2026-01-23",
|
||||
"qwen3.7-max",
|
||||
"dashscope/qwen3.8-max",
|
||||
"moonshot/kimi-k2.6",
|
||||
"kimi-k2.7-code",
|
||||
"moonshot/kimi-k3",
|
||||
],
|
||||
)
|
||||
def test_frontier_model_families_are_accepted(model_name: str) -> None:
|
||||
|
||||
@@ -46,7 +46,8 @@ def _patch_engine_scaffold(
|
||||
reasoning_effort="high",
|
||||
force_required_tool_choice=False,
|
||||
timeout=300,
|
||||
)
|
||||
),
|
||||
runtime=types.SimpleNamespace(max_context_images=3),
|
||||
)
|
||||
monkeypatch.setattr(runner, "load_settings", lambda: settings)
|
||||
monkeypatch.setattr(runner, "configure_sdk_model_defaults", lambda _settings: None)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Subscription runs track tokens but report zero cost."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agents.usage import Usage
|
||||
|
||||
from strix.report.usage import LLMUsageLedger
|
||||
|
||||
|
||||
def _usage() -> Usage:
|
||||
usage = Usage()
|
||||
usage.requests = 1
|
||||
usage.input_tokens = 1000
|
||||
usage.output_tokens = 200
|
||||
usage.total_tokens = 1200
|
||||
return usage
|
||||
|
||||
|
||||
def test_zero_cost_ledger_keeps_tokens_but_reports_no_cost() -> None:
|
||||
ledger = LLMUsageLedger()
|
||||
ledger.zero_cost = True
|
||||
ledger.record(agent_id="a", usage=_usage(), agent_name="strix", model="gpt-5.5")
|
||||
|
||||
record = ledger.to_record()
|
||||
assert record["cost"] == 0.0
|
||||
assert record["total_tokens"] == 1200
|
||||
assert record["input_tokens"] == 1000
|
||||
assert record["output_tokens"] == 200
|
||||
assert ledger.total_cost == 0.0
|
||||
|
||||
|
||||
def test_zero_cost_ledger_ignores_observed_cost() -> None:
|
||||
ledger = LLMUsageLedger()
|
||||
ledger.zero_cost = True
|
||||
ledger.record_observed_cost(4.20)
|
||||
assert ledger.total_cost == 0.0
|
||||
|
||||
|
||||
def test_normal_ledger_still_estimates_cost() -> None:
|
||||
# Sanity check the flag is opt-in: without it, an OpenAI-native model still
|
||||
# accrues an estimated cost (proves zeroing is what suppresses it).
|
||||
ledger = LLMUsageLedger()
|
||||
ledger.record(agent_id="a", usage=_usage(), agent_name="strix", model="gpt-5.5")
|
||||
assert ledger.to_record()["total_tokens"] == 1200
|
||||
# Cost estimation depends on litellm's cost map; it should be >= 0 and not error.
|
||||
assert ledger.total_cost >= 0.0
|
||||
Reference in New Issue
Block a user