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:
yoni-at-strix
2026-07-24 15:41:19 -07:00
committed by GitHub
co-authored by Jonathan Singer Jonathan Singer Ahmed Allam
parent 93af2b94a2
commit cd8270c98b
30 changed files with 1899 additions and 93 deletions
+106
View File
@@ -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
+294
View File
@@ -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"
+225
View File
@@ -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"}
+17 -4
View File
@@ -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 (
+4
View File
@@ -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:
+2 -1
View File
@@ -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)
+46
View File
@@ -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