Files
opensquilla--opensquilla/tests/test_provider_openai_codex.py
2026-07-13 13:12:33 +08:00

424 lines
15 KiB
Python

"""Contract tests for the openai_codex (ChatGPT OAuth) provider."""
from __future__ import annotations
import asyncio
import base64
import json
from pathlib import Path
from typing import Any
import httpx
import pytest
from opensquilla.provider.codex_auth import (
CodexAuthError,
load_codex_credentials,
refresh_codex_credentials,
)
from opensquilla.provider.openai_codex import OpenAICodexProvider
from opensquilla.provider.registry import get_provider_spec
from opensquilla.provider.selector import build_provider
from opensquilla.provider.types import (
ChatConfig,
DoneEvent,
ErrorEvent,
Message,
ReasoningDeltaEvent,
TextDeltaEvent,
ToolDefinition,
ToolInputSchema,
ToolUseDeltaEvent,
ToolUseEndEvent,
ToolUseStartEvent,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _write_auth(path: Path, *, access="tok-access", refresh="tok-refresh", account="acct-1"):
payload = {
"auth_mode": "chatgpt",
"OPENAI_API_KEY": None,
"tokens": {
"id_token": "",
"access_token": access,
"refresh_token": refresh,
"account_id": account,
},
"last_refresh": "2026-06-29T21:53:00Z",
}
path.write_text(json.dumps(payload))
return path
def _sse(events: list[dict[str, Any]]) -> bytes:
return b"".join(f"data: {json.dumps(ev)}\n\n".encode() for ev in events)
def _happy_sse() -> bytes:
return _sse(
[
{"type": "response.created", "response": {"id": "resp-1"}},
{
"type": "response.output_item.added",
"item": {
"type": "function_call",
"id": "fc-1",
"call_id": "call-1",
"name": "search",
},
},
{"type": "response.function_call_arguments.delta", "item_id": "fc-1", "delta": '{"que'},
{
"type": "response.function_call_arguments.delta",
"item_id": "fc-1",
"delta": 'ry": "x"}',
},
{
"type": "response.output_item.done",
"item": {
"type": "function_call",
"id": "fc-1",
"call_id": "call-1",
"name": "search",
"arguments": '{"query": "x"}',
},
},
{"type": "response.output_text.delta", "delta": "done"},
{
"type": "response.completed",
"response": {
"id": "resp-1",
"model": "gpt-5.2-codex",
"usage": {
"input_tokens": 40,
"output_tokens": 9,
"input_tokens_details": {"cached_tokens": 12},
"output_tokens_details": {"reasoning_tokens": 3},
},
},
},
]
)
def _patch_codex_transport(monkeypatch, handler) -> None:
transport = httpx.MockTransport(handler)
real = httpx.AsyncClient
def patched(*args: Any, **kwargs: Any) -> httpx.AsyncClient:
kwargs["transport"] = transport
return real(*args, **kwargs)
monkeypatch.setattr("opensquilla.provider.openai_codex.httpx.AsyncClient", patched)
def _collect(provider: OpenAICodexProvider, *, tools=None, cfg=None):
async def _run():
return [
ev
async for ev in provider.chat(
[Message(role="user", content="hi")], tools=tools, config=cfg or ChatConfig()
)
]
return asyncio.run(_run())
_SEARCH_TOOL = ToolDefinition(
name="search",
description="Search things.",
input_schema=ToolInputSchema(properties={"query": {"type": "string"}}),
)
# ---------------------------------------------------------------------------
# Auth
# ---------------------------------------------------------------------------
def test_load_credentials_from_auth_file(tmp_path: Path) -> None:
auth = _write_auth(tmp_path / "auth.json")
creds = load_codex_credentials(auth)
assert creds.access_token == "tok-access"
assert creds.refresh_token == "tok-refresh"
assert creds.account_id == "acct-1"
def test_missing_auth_file_points_at_codex_login(tmp_path: Path) -> None:
with pytest.raises(CodexAuthError, match="codex login"):
load_codex_credentials(tmp_path / "missing.json")
def test_account_id_falls_back_to_jwt_claim(tmp_path: Path) -> None:
claims = {"https://api.openai.com/auth": {"chatgpt_account_id": "acct-jwt"}}
payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip("=")
jwt = f"h.{payload}.s"
auth = tmp_path / "auth.json"
auth.write_text(
json.dumps({"tokens": {"access_token": "tok", "id_token": jwt, "account_id": None}})
)
assert load_codex_credentials(auth).account_id == "acct-jwt"
def test_refresh_persists_new_tokens(tmp_path: Path, monkeypatch) -> None:
auth = _write_auth(tmp_path / "auth.json")
creds = load_codex_credentials(auth)
captured: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["body"] = json.loads(request.content)
return httpx.Response(
200,
json={"access_token": "tok-new", "refresh_token": "refresh-new", "id_token": ""},
)
transport = httpx.MockTransport(handler)
real = httpx.AsyncClient
monkeypatch.setattr(
"opensquilla.provider.codex_auth.httpx.AsyncClient",
lambda *a, **kw: real(*a, **{**kw, "transport": transport}),
)
updated = asyncio.run(refresh_codex_credentials(creds, path=auth))
assert updated.access_token == "tok-new"
assert captured["body"]["grant_type"] == "refresh_token"
assert captured["body"]["client_id"].startswith("app_")
on_disk = json.loads(auth.read_text())
assert on_disk["tokens"]["access_token"] == "tok-new"
assert on_disk["tokens"]["refresh_token"] == "refresh-new"
assert on_disk["last_refresh"].endswith("Z")
# ---------------------------------------------------------------------------
# Provider wire behavior
# ---------------------------------------------------------------------------
def test_stream_maps_responses_events(tmp_path: Path, monkeypatch) -> None:
auth = _write_auth(tmp_path / "auth.json")
captured: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["headers"] = dict(request.headers)
captured["payload"] = json.loads(request.content)
return httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=_happy_sse()
)
_patch_codex_transport(monkeypatch, handler)
provider = OpenAICodexProvider(
model="gpt-5.2-codex",
base_url="https://chatgpt.com", # normalization adds /backend-api
auth_path=str(auth),
)
events = _collect(provider, tools=[_SEARCH_TOOL], cfg=ChatConfig(system="be brief"))
assert captured["url"] == "https://chatgpt.com/backend-api/codex/responses"
assert captured["headers"]["authorization"] == "Bearer tok-access"
assert captured["headers"]["chatgpt-account-id"] == "acct-1"
assert captured["headers"]["originator"] == "codex_cli_rs"
payload = captured["payload"]
assert payload["instructions"] == "be brief"
assert payload["store"] is False and payload["stream"] is True
assert payload["include"] == ["reasoning.encrypted_content"]
# Protocol fact (verified live): the ChatGPT codex backend rejects
# max_output_tokens with HTTP 400 "Unsupported parameter" — it must
# never be sent; subscription turns carry no client-set output cap.
assert "max_output_tokens" not in payload
(tool,) = payload["tools"]
assert tool["type"] == "function" and tool["name"] == "search"
assert "function" not in tool # flat Responses shape, not chat-completions nesting
starts = [e for e in events if isinstance(e, ToolUseStartEvent)]
deltas = [e for e in events if isinstance(e, ToolUseDeltaEvent)]
ends = [e for e in events if isinstance(e, ToolUseEndEvent)]
texts = [e for e in events if isinstance(e, TextDeltaEvent)]
(done,) = [e for e in events if isinstance(e, DoneEvent)]
assert [s.tool_use_id for s in starts] == ["call-1"]
assert [d.json_fragment for d in deltas] == ['{"que', 'ry": "x"}']
assert ends[0].arguments == {"query": "x"}
assert [t.text for t in texts] == ["done"]
assert done.stop_reason == "tool_use"
assert (done.input_tokens, done.output_tokens) == (40, 9)
assert done.cached_tokens == 12 and done.reasoning_tokens == 3
assert done.model == "gpt-5.2-codex"
def test_burst_function_call_without_deltas(tmp_path: Path, monkeypatch) -> None:
auth = _write_auth(tmp_path / "auth.json")
body = _sse(
[
{
"type": "response.output_item.done",
"item": {
"type": "function_call",
"id": "fc-9",
"call_id": "call-9",
"name": "search",
"arguments": '{"query": "y"}',
},
},
{"type": "response.completed", "response": {"id": "r", "usage": {}}},
]
)
_patch_codex_transport(
monkeypatch,
lambda request: httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=body
),
)
provider = OpenAICodexProvider(auth_path=str(auth))
events = _collect(provider, tools=[_SEARCH_TOOL])
ends = [e for e in events if isinstance(e, ToolUseEndEvent)]
assert len(ends) == 1 and ends[0].arguments == {"query": "y"}
assert any(isinstance(e, ToolUseStartEvent) for e in events)
def test_reasoning_deltas_reach_done_event(tmp_path: Path, monkeypatch) -> None:
auth = _write_auth(tmp_path / "auth.json")
body = _sse(
[
{
"type": "response.reasoning_summary_text.delta",
"delta": "think ",
"summary_index": 0,
},
{"type": "response.reasoning_text.delta", "delta": "hard", "content_index": 0},
{"type": "response.output_text.delta", "delta": "hi"},
{"type": "response.completed", "response": {"id": "r", "usage": {}}},
]
)
_patch_codex_transport(
monkeypatch,
lambda request: httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=body
),
)
provider = OpenAICodexProvider(auth_path=str(auth))
events = _collect(provider)
assert [e.text for e in events if isinstance(e, ReasoningDeltaEvent)] == ["think ", "hard"]
(done,) = [e for e in events if isinstance(e, DoneEvent)]
assert done.reasoning_content == "think hard"
def test_401_refreshes_and_retries_once(tmp_path: Path, monkeypatch) -> None:
auth = _write_auth(tmp_path / "auth.json")
calls = {"stream": 0, "refresh": 0}
# One handler for both endpoints: the provider and the auth module share
# the global httpx module, so separate transports would fight over it.
def handler(request: httpx.Request) -> httpx.Response:
if "auth.openai.com" in str(request.url):
calls["refresh"] += 1
return httpx.Response(200, json={"access_token": "tok-new"})
calls["stream"] += 1
if request.headers.get("authorization") == "Bearer tok-access":
return httpx.Response(401, json={"error": {"message": "expired"}})
return httpx.Response(
200,
headers={"content-type": "text/event-stream"},
content=_sse(
[
{"type": "response.output_text.delta", "delta": "ok"},
{"type": "response.completed", "response": {"id": "r", "usage": {}}},
]
),
)
_patch_codex_transport(monkeypatch, handler)
transport = httpx.MockTransport(handler)
real = httpx.AsyncClient
monkeypatch.setattr(
"opensquilla.provider.codex_auth.httpx.AsyncClient",
lambda *a, **kw: real(*a, **{**kw, "transport": transport}),
)
provider = OpenAICodexProvider(auth_path=str(auth))
events = _collect(provider)
assert calls["stream"] == 2
assert calls["refresh"] == 1
assert any(isinstance(e, TextDeltaEvent) and e.text == "ok" for e in events)
assert any(isinstance(e, DoneEvent) for e in events)
assert json.loads(auth.read_text())["tokens"]["access_token"] == "tok-new"
def test_response_failed_yields_error_event(tmp_path: Path, monkeypatch) -> None:
auth = _write_auth(tmp_path / "auth.json")
body = _sse(
[
{
"type": "response.failed",
"response": {"error": {"code": "usage_limit_reached", "message": "limit"}},
}
]
)
_patch_codex_transport(
monkeypatch,
lambda request: httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=body
),
)
provider = OpenAICodexProvider(auth_path=str(auth))
events = _collect(provider)
(error,) = [e for e in events if isinstance(e, ErrorEvent)]
assert error.code == "usage_limit_reached"
assert not any(isinstance(e, DoneEvent) for e in events)
def test_truncated_stream_still_terminates_with_done(tmp_path: Path, monkeypatch) -> None:
auth = _write_auth(tmp_path / "auth.json")
body = _sse(
[
{
"type": "response.output_item.added",
"item": {
"type": "function_call",
"id": "fc-1",
"call_id": "call-1",
"name": "search",
},
},
{"type": "response.function_call_arguments.delta", "item_id": "fc-1", "delta": "{}"},
# stream drops before output_item.done / response.completed
]
)
_patch_codex_transport(
monkeypatch,
lambda request: httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=body
),
)
provider = OpenAICodexProvider(auth_path=str(auth))
events = _collect(provider, tools=[_SEARCH_TOOL])
assert any(isinstance(e, ToolUseEndEvent) for e in events)
assert any(isinstance(e, DoneEvent) for e in events)
def test_missing_credentials_is_auth_error_event(tmp_path: Path) -> None:
provider = OpenAICodexProvider(auth_path=str(tmp_path / "missing.json"))
events = _collect(provider)
(error,) = [e for e in events if isinstance(e, ErrorEvent)]
assert error.code == "401"
assert "codex login" in error.message
# ---------------------------------------------------------------------------
# Registry / selector wiring
# ---------------------------------------------------------------------------
def test_registry_and_selector_wire_openai_codex() -> None:
spec = get_provider_spec("openai_codex")
assert spec.backend == "openai_codex"
assert spec.runtime_supported is True
assert spec.requires_api_key() is False # OAuth, not an API key field
assert spec.requires_base_url() is False
provider = build_provider("openai_codex", "gpt-5.2-codex")
assert isinstance(provider, OpenAICodexProvider)