diff --git a/e2e/wrap/run.py b/e2e/wrap/run.py index 2a6c93544..14b931fd2 100644 --- a/e2e/wrap/run.py +++ b/e2e/wrap/run.py @@ -355,6 +355,11 @@ def start_openclaw_gateway(env: dict[str, str], cwd: Path) -> subprocess.Popen[s ) +def stop_openclaw_gateway(env: dict[str, str], cwd: Path) -> None: + log("Stopping OpenClaw gateway after e2e verification") + run(["openclaw", "gateway", "stop"], env=env, cwd=cwd, timeout=60) + + def verify_installs() -> None: log("Verifying installed packages and binaries") for tool in ("headroom", "codex", "aider", "openclaw"): @@ -617,6 +622,7 @@ def verify_openclaw_wrap( finally: if gateway_proc is not None: stop_process(gateway_proc) + stop_openclaw_gateway(base_env, project_dir) def main() -> None: diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index e37a20fed..7c260850c 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -1132,6 +1132,24 @@ def openclaw( elif not local_source_mode and skip_build: click.echo(" Skipping build: npm install mode does not build local source.") + effective_python_path = python_path + if effective_python_path is None and not no_auto_start and sys.executable: + effective_python_path = sys.executable + + existing_entry = _read_openclaw_config_value(openclaw_bin, "plugins.entries.headroom") + entry = _build_openclaw_plugin_entry( + existing_entry=existing_entry, + proxy_port=proxy_port, + startup_timeout_ms=startup_timeout_ms, + python_path=effective_python_path, + no_auto_start=no_auto_start, + gateway_provider_ids=gateway_provider_ids, + enabled=True, + ) + + click.echo(" Writing plugin configuration...") + _write_openclaw_plugin_entry(openclaw_bin, entry) + install_cmd = [ openclaw_bin, "plugins", @@ -1184,19 +1202,6 @@ def openclaw( elif verbose and install_result.stdout.strip(): click.echo(install_result.stdout.strip()) - existing_entry = _read_openclaw_config_value(openclaw_bin, "plugins.entries.headroom") - entry = _build_openclaw_plugin_entry( - existing_entry=existing_entry, - proxy_port=proxy_port, - startup_timeout_ms=startup_timeout_ms, - python_path=python_path, - no_auto_start=no_auto_start, - gateway_provider_ids=gateway_provider_ids, - enabled=True, - ) - - click.echo(" Writing plugin configuration...") - _write_openclaw_plugin_entry(openclaw_bin, entry) _set_openclaw_context_engine_slot(openclaw_bin, "headroom") _run_checked( [openclaw_bin, "config", "validate"], diff --git a/plugins/openclaw/test/engine.test.ts b/plugins/openclaw/test/engine.test.ts index 8dc3acba4..bd8005deb 100644 --- a/plugins/openclaw/test/engine.test.ts +++ b/plugins/openclaw/test/engine.test.ts @@ -1,300 +1,101 @@ -/** - * Integration tests for HeadroomContextEngine. - * - * Tests the full flow: proxy management, AgentMessage conversion, - * compression via proxy, and round-trip back to AgentMessage. - * - * Requires: Python 3 + headroom-ai[proxy] installed - * Run: HEADROOM_INTEGRATION=1 npx vitest run test/engine.test.ts - */ -import { describe, it, expect, beforeAll, afterAll, vi, afterEach } from "vitest"; -import { HeadroomContextEngine } from "../src/engine.js"; -import { agentToOpenAI, openAIToAgent } from "../src/convert.js"; -import { ProxyManager } from "../src/proxy-manager.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; -const RUN = process.env.HEADROOM_INTEGRATION === "1"; -const PROXY_URL = process.env.HEADROOM_PROXY_URL ?? "http://127.0.0.1:8787"; +const mocked = vi.hoisted(() => ({ + start: vi.fn(async () => "http://127.0.0.1:8787"), + stop: vi.fn(async () => undefined), + logger: { + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, +})); + +vi.mock("headroom-ai", () => ({ + compress: vi.fn(), +})); + +vi.mock("../src/proxy-manager.js", () => ({ + ProxyManager: class { + start = mocked.start; + stop = mocked.stop; + }, + defaultLogger: mocked.logger, +})); + +import { HeadroomContextEngine } from "../src/engine.js"; afterEach(() => { - vi.restoreAllMocks(); + mocked.start.mockReset(); + mocked.start.mockResolvedValue("http://127.0.0.1:8787"); + mocked.stop.mockClear(); + mocked.logger.debug.mockClear(); + mocked.logger.error.mockClear(); + mocked.logger.info.mockClear(); + mocked.logger.warn.mockClear(); }); -// Proxy probing and ProxyManager.start tests live in proxy-manager.test.ts - -describe("AgentMessage conversion", () => { - it("converts user message", () => { - const agent = [{ role: "user", content: "hello", timestamp: Date.now() }]; - const openai = agentToOpenAI(agent); - expect(openai).toHaveLength(1); - expect(openai[0]).toMatchObject({ role: "user", content: "hello" }); - }); - - it("converts assistant with tool_use blocks", () => { - const agent = [ - { - role: "assistant", - content: [ - { type: "text", text: "Let me search" }, - { type: "tool_use", id: "tu_1", name: "search", input: { q: "test" } }, - ], - timestamp: Date.now(), - }, - ]; - const openai = agentToOpenAI(agent); - expect(openai[0].role).toBe("assistant"); - expect(openai[0].content).toBe("Let me search"); - expect(openai[0].tool_calls).toHaveLength(1); - expect(openai[0].tool_calls![0].function.name).toBe("search"); - }); - - it("converts assistant with toolCall blocks", () => { - const agent = [ - { - role: "assistant", - content: [ - { type: "text", text: "Let me search" }, - { type: "toolCall", id: "call_1|fc_1", name: "search", arguments: { q: "test" } }, - ], - timestamp: Date.now(), - }, - ]; - const openai = agentToOpenAI(agent); - expect(openai[0].role).toBe("assistant"); - expect(openai[0].content).toBe("Let me search"); - expect(openai[0].tool_calls).toHaveLength(1); - expect(openai[0].tool_calls![0].id).toBe("call_1|fc_1"); - expect(openai[0].tool_calls![0].function.name).toBe("search"); - }); - - it("converts toolResult message", () => { - const agent = [ - { - role: "toolResult", - content: '{"results": [1, 2, 3]}', - tool_use_id: "tu_1", - timestamp: Date.now(), - }, - ]; - const openai = agentToOpenAI(agent); - expect(openai[0].role).toBe("tool"); - expect(openai[0].content).toBe('{"results": [1, 2, 3]}'); - expect(openai[0].tool_call_id).toBe("tu_1"); - }); - - it("round-trips user message", () => { - const original = [{ role: "user", content: "hello", timestamp: Date.now() }]; - const openai = agentToOpenAI(original); - const back = openAIToAgent(openai); - expect(back[0].role).toBe("user"); - expect(back[0].content).toBe("hello"); - }); - - it("round-trips assistant text-only (content always array)", () => { - const original = [ - { - role: "assistant", - content: [{ type: "text", text: "Hello there!" }], - timestamp: Date.now(), - }, - ]; - const openai = agentToOpenAI(original); - const back = openAIToAgent(openai); - expect(back[0].role).toBe("assistant"); - // OpenClaw requires content to ALWAYS be an array for assistant messages - const content = back[0].content; - expect(Array.isArray(content)).toBe(true); - expect(content[0]).toEqual({ type: "text", text: "Hello there!" }); - }); - - it("round-trips assistant with tool calls", () => { - const original = [ - { - role: "assistant", - content: [ - { type: "text", text: "Searching..." }, - { type: "toolCall", id: "call_1|fc_1", name: "search", arguments: { q: "test" } }, - ], - timestamp: Date.now(), - }, - ]; - const openai = agentToOpenAI(original); - const back = openAIToAgent(openai); - expect(back[0].role).toBe("assistant"); - const content = back[0].content; - expect(Array.isArray(content)).toBe(true); - expect(content).toContainEqual(expect.objectContaining({ type: "text", text: "Searching..." })); - expect(content).toContainEqual( - expect.objectContaining({ type: "toolCall", id: "call_1|fc_1", name: "search" }), - ); - }); - - it("round-trips toolResult", () => { - const original = [ - { - role: "toolResult", - content: '{"data": true}', - tool_use_id: "tu_1", - timestamp: Date.now(), - }, - ]; - const openai = agentToOpenAI(original); - const back = openAIToAgent(openai); - expect(back[0].role).toBe("toolResult"); - expect(back[0].content).toEqual([{ type: "text", text: '{"data": true}' }]); - expect(back[0].tool_use_id).toBe("tu_1"); - }); -}); - -describe("HeadroomContextEngine startup behavior", () => { - it("bootstrap schedules proxy startup without blocking on it", async () => { - const start = vi.fn( - () => new Promise((resolve) => setTimeout(() => resolve("http://127.0.0.1:8787"), 50)), - ); - vi.spyOn(ProxyManager.prototype, "start").mockImplementation(start); - +describe("HeadroomContextEngine proxy startup helpers", () => { + it("bootstraps by scheduling proxy startup when enabled", async () => { const engine = new HeadroomContextEngine(); - const result = await engine.bootstrap({ - sessionId: "test-session", - sessionFile: "/tmp/test-session.jsonl", - }); - expect(result).toEqual({ bootstrapped: true, reason: "proxy startup scheduled" }); - expect(start).toHaveBeenCalledTimes(1); - expect(engine.getProxyUrl()).toBeNull(); + await expect( + engine.bootstrap({ + sessionId: "session-1", + sessionFile: "session.jsonl", + }), + ).resolves.toEqual({ + bootstrapped: true, + reason: "proxy startup scheduled", + }); + expect(mocked.start).toHaveBeenCalledTimes(1); }); - it("assemble returns original messages while proxy startup is still pending", async () => { - const start = vi.fn( - () => new Promise((resolve) => setTimeout(() => resolve("http://127.0.0.1:8787"), 50)), - ); - vi.spyOn(ProxyManager.prototype, "start").mockImplementation(start); - + it("removes unsubscribed proxy listeners before notifying readiness", async () => { const engine = new HeadroomContextEngine(); - const messages = [{ role: "user", content: "hello", timestamp: Date.now() }]; + const first = vi.fn(); + const second = vi.fn(); - const result = await engine.assemble({ - sessionId: "test-session", + const unsubscribeFirst = engine.onProxyReady(first); + engine.onProxyReady(second); + unsubscribeFirst(); + + engine.ensureProxyStarted(); + await engine.ensureProxyUrl(); + + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledWith("http://127.0.0.1:8787"); + }); + + it("returns the existing proxy URL without starting again", async () => { + const engine = new HeadroomContextEngine(); + + (engine as { proxyUrl: string | null }).proxyUrl = "http://127.0.0.1:8787"; + + await expect(engine.ensureProxyUrl()).resolves.toBe("http://127.0.0.1:8787"); + expect(mocked.start).not.toHaveBeenCalled(); + }); + + it("throws when proxy startup is disabled", async () => { + const engine = new HeadroomContextEngine({ enabled: false }); + + await expect(engine.ensureProxyUrl()).rejects.toThrow("Headroom proxy startup is disabled"); + expect(mocked.start).not.toHaveBeenCalled(); + }); + + it("schedules startup and returns original messages when assembling before proxy readiness", async () => { + const engine = new HeadroomContextEngine(); + const messages = [{ role: "user", content: "hello" }]; + + await expect( + engine.assemble({ + sessionId: "session-1", + messages, + }), + ).resolves.toEqual({ messages, - model: "claude-sonnet-4-5", + estimatedTokens: 0, }); - - expect(result).toEqual({ messages, estimatedTokens: 0 }); - expect(start).toHaveBeenCalledTimes(1); + expect(mocked.start).toHaveBeenCalledTimes(1); }); }); - -if (RUN) { - describe("ProxyManager", () => { - it("connects to configured proxy URL", { timeout: 30000 }, async () => { - const manager = new ProxyManager({ proxyUrl: PROXY_URL }); - try { - const url = await manager.start(); - expect(url).toMatch(/^http:\/\/(127\.0\.0\.1|localhost):\d+$/); - - // Verify health - const resp = await fetch(`${url}/health`); - expect(resp.ok).toBe(true); - } finally { - await manager.stop(); - } - }); - }); - - describe("HeadroomContextEngine", () => { - let engine: HeadroomContextEngine; - - beforeAll(async () => { - engine = new HeadroomContextEngine({ proxyUrl: PROXY_URL }); - await engine.bootstrap({ - sessionId: "test-session", - sessionFile: "/tmp/test-session.jsonl", - }); - }, 30000); - - afterAll(async () => { - await engine.dispose(); - }); - - it("assemble() compresses tool outputs", { timeout: 15000 }, async () => { - // Simulate an OpenClaw agent conversation with large tool result - const serverData = Array.from({ length: 100 }, (_, i) => ({ - id: i + 1, - name: `server-${i + 1}`, - status: i % 15 === 0 ? "critical" : i % 5 === 0 ? "warning" : "healthy", - cpu: Math.round(Math.random() * 100), - memory: Math.round(Math.random() * 100), - region: ["us-east-1", "eu-west-1", "ap-southeast-1"][i % 3], - description: `Production server ${i + 1} running service-${["auth", "payment", "user", "api"][i % 4]}`, - lastAlert: i % 15 === 0 ? `Disk usage at ${90 + (i % 10)}%` : null, - })); - - const messages = [ - { role: "user", content: "Check the fleet status", timestamp: Date.now() }, - { - role: "assistant", - content: [ - { type: "tool_use", id: "tu_fleet", name: "getFleetStatus", input: {} }, - ], - timestamp: Date.now(), - }, - { - role: "toolResult", - content: JSON.stringify(serverData), - tool_use_id: "tu_fleet", - timestamp: Date.now(), - }, - { role: "user", content: "Which servers are critical?", timestamp: Date.now() }, - ]; - - const result = await engine.assemble({ - sessionId: "test-session", - messages, - model: "claude-sonnet-4-5", - }); - - console.log( - ` assemble(): estimatedTokens=${result.estimatedTokens}, ` + - `systemPrompt=${result.systemPromptAddition ? "yes" : "no"}`, - ); - - // Messages should be returned (compressed or not) - expect(result.messages.length).toBeGreaterThan(0); - // First and last messages should still be user messages - expect(result.messages[0].role).toBe("user"); - expect(result.messages[result.messages.length - 1].role).toBe("user"); - }); - - it("assemble() preserves small conversations", { timeout: 15000 }, async () => { - const messages = [ - { role: "user", content: "Hello", timestamp: Date.now() }, - { role: "assistant", content: "Hi there!", timestamp: Date.now() }, - ]; - - const result = await engine.assemble({ - sessionId: "test-session", - messages, - }); - - expect(result.messages).toHaveLength(2); - expect(result.messages[0].content).toBe("Hello"); - expect(result.messages[1].content).toBe("Hi there!"); - }); - - it("compact() returns success (compression handled in assemble)", async () => { - const result = await engine.compact({ - sessionId: "test-session", - sessionFile: "/tmp/test.jsonl", - }); - - expect(result.ok).toBe(true); - expect(result.compacted).toBe(true); - }); - - it("getStats() returns compression statistics", () => { - const stats = engine.getStats(); - expect(stats).toHaveProperty("totalCompressions"); - expect(stats).toHaveProperty("totalTokensSaved"); - expect(stats.totalCompressions).toBeGreaterThanOrEqual(0); - }); - }); -} diff --git a/tests/test_cli/test_wrap_openclaw.py b/tests/test_cli/test_wrap_openclaw.py index 6b0444787..c600c57e8 100644 --- a/tests/test_cli/test_wrap_openclaw.py +++ b/tests/test_cli/test_wrap_openclaw.py @@ -67,6 +67,18 @@ def test_wrap_openclaw_default_installs_from_npm_and_restarts(runner: CliRunner) assert ["openclaw", "gateway", "restart"] in cmds assert ["openclaw", "plugins", "inspect", "headroom"] in cmds + config_set_index = next( + i + for i, cmd in enumerate(cmds) + if cmd[:4] == ["openclaw", "config", "set", "plugins.entries.headroom"] + ) + install_index = next( + i + for i, cmd in enumerate(cmds) + if cmd[:4] == ["openclaw", "plugins", "install", "--dangerously-force-unsafe-install"] + ) + assert config_set_index < install_index + # Verify plugin install in npm mode does not set cwd install_call = next( c @@ -91,6 +103,7 @@ def test_wrap_openclaw_default_installs_from_npm_and_restarts(runner: CliRunner) assert payload["config"]["autoStart"] is True assert payload["config"]["startupTimeoutMs"] == 20000 assert payload["config"]["gatewayProviderIds"] == ["openai-codex"] + assert payload["config"]["pythonPath"] == wrap_cli.sys.executable def test_wrap_openclaw_skip_build_and_no_restart(runner: CliRunner, plugin_dir: Path) -> None: @@ -330,6 +343,86 @@ def test_wrap_openclaw_accepts_repeatable_gateway_provider_ids(runner: CliRunner assert payload["config"]["gatewayProviderIds"] == ["openai-codex", "anthropic"] +def test_normalize_openclaw_gateway_provider_ids_dedupes_blanks_and_defaults() -> None: + assert wrap_cli._normalize_openclaw_gateway_provider_ids( + (" openai-codex ", "", "anthropic", "openai-codex", " ") + ) == ["openai-codex", "anthropic"] + assert wrap_cli._normalize_openclaw_gateway_provider_ids(None) == ["openai-codex"] + + +def test_read_openclaw_config_value_handles_missing_and_raw_strings() -> None: + missing = MagicMock(returncode=1, stdout="", stderr="missing") + raw_string = MagicMock(returncode=0, stdout="plain-text-value\n", stderr="") + + with patch("headroom.cli.wrap.subprocess.run", side_effect=[missing, raw_string]): + assert wrap_cli._read_openclaw_config_value("openclaw", "plugins.entries.headroom") is None + assert ( + wrap_cli._read_openclaw_config_value( + "openclaw", "plugins.entries.headroom.config.pythonPath" + ) + == "plain-text-value" + ) + + +def test_build_openclaw_plugin_entry_sets_and_clears_python_path() -> None: + with_python = wrap_cli._build_openclaw_plugin_entry( + existing_entry={"config": {"customFlag": True}}, + proxy_port=8787, + startup_timeout_ms=20000, + python_path="C:\\Python312\\python.exe", + no_auto_start=False, + gateway_provider_ids=("openai-codex",), + enabled=True, + ) + assert with_python["config"]["pythonPath"] == "C:\\Python312\\python.exe" + + without_python = wrap_cli._build_openclaw_plugin_entry( + existing_entry={"config": {"pythonPath": "C:\\Old\\python.exe", "customFlag": True}}, + proxy_port=8787, + startup_timeout_ms=20000, + python_path=None, + no_auto_start=False, + gateway_provider_ids=("openai-codex",), + enabled=True, + ) + assert "pythonPath" not in without_python["config"] + assert without_python["config"]["customFlag"] is True + + +def test_wrap_openclaw_no_auto_start_does_not_default_python_path( + runner: CliRunner, plugin_dir: Path +) -> None: + calls: list[dict] = [] + + def which(name: str) -> str | None: + return {"openclaw": "openclaw", "npm": "npm"}.get(name) + + with patch("headroom.cli.wrap.shutil.which", side_effect=which): + with patch("headroom.cli.wrap.subprocess.run", side_effect=_make_successful_run(calls)): + result = runner.invoke( + main, + [ + "wrap", + "openclaw", + "--plugin-path", + str(plugin_dir), + "--skip-build", + "--no-auto-start", + "--no-restart", + ], + ) + + assert result.exit_code == 0, result.output + set_entry = next( + c + for c in calls + if c["cmd"][:4] == ["openclaw", "config", "set", "plugins.entries.headroom"] + ) + payload = json.loads(set_entry["cmd"][4]) + assert payload["config"]["autoStart"] is False + assert "pythonPath" not in payload["config"] + + def test_wrap_openclaw_fails_for_npm_mode_hook_pack_bug_without_local_fallback( runner: CliRunner, ) -> None: @@ -571,3 +664,37 @@ def test_unwrap_openclaw_no_restart_skips_gateway_restart(runner: CliRunner) -> assert result.exit_code == 0, result.output assert ["openclaw", "gateway", "restart"] not in [c["cmd"] for c in calls] + + +def test_unwrap_openclaw_fails_when_openclaw_missing(runner: CliRunner) -> None: + with patch("headroom.cli.wrap.shutil.which", return_value=None): + result = runner.invoke(main, ["unwrap", "openclaw"]) + + assert result.exit_code != 0 + assert "'openclaw' not found in PATH" in result.output + + +def test_unwrap_openclaw_verbose_prints_gateway_and_inspect_output(runner: CliRunner) -> None: + def which(name: str) -> str | None: + return {"openclaw": "openclaw"}.get(name) + + def run(cmd, **kwargs): # noqa: ANN001 + if cmd[:4] == ["openclaw", "config", "get", "plugins.entries.headroom"]: + return MagicMock( + returncode=0, + stdout=json.dumps({"enabled": True, "config": {"proxyPort": 8787}}), + stderr="", + ) + if cmd[:3] == ["openclaw", "gateway", "restart"]: + return MagicMock(returncode=0, stdout="gateway-restarted", stderr="") + if cmd[:3] == ["openclaw", "plugins", "inspect"]: + return MagicMock(returncode=0, stdout="inspect-disabled", stderr="") + return MagicMock(returncode=0, stdout="", stderr="") + + with patch("headroom.cli.wrap.shutil.which", side_effect=which): + with patch("headroom.cli.wrap.subprocess.run", side_effect=run): + result = runner.invoke(main, ["unwrap", "openclaw", "--verbose"]) + + assert result.exit_code == 0, result.output + assert "gateway-restarted" in result.output + assert "inspect-disabled" in result.output diff --git a/tests/test_openai_codex_routing.py b/tests/test_openai_codex_routing.py index bbdcb3e71..95f2d16ec 100644 --- a/tests/test_openai_codex_routing.py +++ b/tests/test_openai_codex_routing.py @@ -1,7 +1,17 @@ import base64 import json +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock, patch -from headroom.proxy.handlers.openai import _resolve_codex_routing_headers +import anyio +import pytest +from fastapi import Request + +from headroom.proxy.handlers.openai import ( + OpenAIHandlerMixin, + _resolve_codex_routing_headers, +) def _jwt(payload: dict) -> str: @@ -56,3 +66,152 @@ def test_resolve_codex_routing_leaves_regular_openai_bearer_tokens_unchanged(): assert is_chatgpt is False assert "ChatGPT-Account-ID" not in headers + + +def test_resolve_codex_routing_returns_none_without_bearer_auth(): + headers, is_chatgpt = _resolve_codex_routing_headers({}) + + assert is_chatgpt is False + assert headers == {} + + +def test_resolve_codex_routing_ignores_non_jwt_bearer_tokens(): + headers, is_chatgpt = _resolve_codex_routing_headers( + { + "authorization": "Bearer not-a-jwt", + } + ) + + assert is_chatgpt is False + assert headers["authorization"] == "Bearer not-a-jwt" + + +def test_resolve_codex_routing_ignores_invalid_jwt_payloads(): + invalid_payload = base64.urlsafe_b64encode(b"not-json").decode("ascii").rstrip("=") + token = f"test-header.{invalid_payload}.signature" + + headers, is_chatgpt = _resolve_codex_routing_headers( + { + "authorization": f"Bearer {token}", + } + ) + + assert is_chatgpt is False + assert headers["authorization"] == f"Bearer {token}" + + +class _DummyMetrics: + async def record_request(self, **kwargs): # noqa: ANN003 + return None + + async def record_failed(self): + return None + + +class _DummyTokenizer: + def count_messages(self, messages): + return len(messages) + + +class _ResponseStub: + def json(self): + return {"usage": {"input_tokens": 2, "output_tokens": 1}} + + +class _DummyOpenAIHandler(OpenAIHandlerMixin): + OPENAI_API_URL = "https://api.openai.com" + + def __init__(self) -> None: + self.rate_limiter = None + self.metrics = _DummyMetrics() + self.config = SimpleNamespace(optimize=False) + self.usage_reporter = None + self.openai_provider = SimpleNamespace() + self.anthropic_backend = None + self.cost_tracker = None + self.captured_request: tuple[str, str, dict, dict] | None = None + + async def _next_request_id(self) -> str: + return "req-1" + + def _extract_tags(self, headers: dict[str, str]) -> list[str]: + return [] + + async def _retry_request(self, method: str, url: str, headers: dict, body: dict): + self.captured_request = (method, url, headers, body) + return _ResponseStub() + + +def _build_request(body: dict, headers: dict[str, str]) -> Request: + payload = json.dumps(body).encode("utf-8") + + async def receive(): + return {"type": "http.request", "body": payload, "more_body": False} + + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": "POST", + "scheme": "https", + "path": "/v1/responses", + "raw_path": b"/v1/responses", + "query_string": b"", + "headers": [ + (key.lower().encode("utf-8"), value.encode("utf-8")) for key, value in headers.items() + ], + "client": ("127.0.0.1", 12345), + "server": ("testserver", 443), + } + return Request(scope, receive) + + +def test_handle_openai_responses_routes_chatgpt_auth_to_backend_api(monkeypatch): + token = _jwt( + { + "https://api.openai.com/auth": { + "chatgpt_account_id": "acct-from-jwt", + } + } + ) + request = _build_request( + {"model": "gpt-5.4", "input": "hello"}, + {"Authorization": f"Bearer {token}"}, + ) + handler = _DummyOpenAIHandler() + + monkeypatch.setattr("headroom.tokenizers.get_tokenizer", lambda model: _DummyTokenizer()) + + anyio.run(handler.handle_openai_responses, request) + + assert handler.captured_request is not None + method, url, headers, body = handler.captured_request + assert method == "POST" + assert url == "https://chatgpt.com/backend-api/codex/responses" + assert headers["ChatGPT-Account-ID"] == "acct-from-jwt" + assert body["input"] == "hello" + + +class _DummyWebSocket: + def __init__(self, headers: dict[str, str]): + self.headers = headers + self.accepted_subprotocol = None + + async def accept(self, subprotocol=None): + self.accepted_subprotocol = subprotocol + + +def test_handle_openai_responses_ws_resolves_codex_routing_headers(): + class SentinelError(RuntimeError): + pass + + handler = _DummyOpenAIHandler() + websocket = _DummyWebSocket({"authorization": "Bearer token"}) + + with patch.dict(sys.modules, {"websockets": MagicMock()}): + with patch( + "headroom.proxy.handlers.openai._resolve_codex_routing_headers", + side_effect=SentinelError("resolved"), + ): + with pytest.raises(SentinelError, match="resolved"): + anyio.run(handler.handle_openai_responses_ws, websocket)