diff --git a/CHANGELOG.md b/CHANGELOG.md index 250990011..40185e30b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## Unreleased + +- Feature: a new `openai-cli` backend (`--backend openai-cli`) runs semantic extraction and community labeling through the locally authenticated OpenAI Codex CLI (`codex exec`), so the work rides a ChatGPT OAuth subscription instead of a metered `OPENAI_API_KEY`. The prompt travels via stdin (Linux caps a single argv entry at 128 KB; real chunks reach 240–306 KB), the sandbox is read-only so an agentic Codex cannot write to the corpus it reads, every configured MCP server is disabled per call through Codex's own per-server `enabled` override (a blanket `mcp_servers={}` deep-merges away; the measured cost was four ~152 MB servers per `codex exec`), token usage is read from the last `turn.completed` JSONL event without double-counting cached input, an empty-but-valid graph raises instead of triggering the hollow-response bisect (which once burned 87% of an hourly quota), and calls are forced serial unless `GRAPHIFY_OPENAI_CLI_PARALLEL=1`. `GRAPHIFY_OPENAI_CLI_MODEL` (default `gpt-5.6-sol`) and `GRAPHIFY_OPENAI_CLI_EFFORT` (default `ultra`) configure it; the credential gate accepts a present `codex` CLI in place of an API key, mirroring `claude-cli`. + ## 0.9.49 (2026-08-24) - Feature: `graphify merge-graphs` now links a type declaration that two repos share — same fully-qualified namespace and name, from different repos — with a `same_type_as` edge, so a shared contract type is navigable across the repo boundary; two unrelated types that merely share a short name are not linked (#3007, thanks @durmazoguzhan). diff --git a/graphify/__main__.py b/graphify/__main__.py index 155501a98..7b04ad361 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -544,13 +544,13 @@ def _run_cli() -> None: print(" --no-label keep 'Community N' placeholders (skip LLM community naming)") print(" --backend= backend to use for community naming (default: auto-detect)") print(" --model= model to use for community naming") - print(" --max-concurrency=N parallel community-labeling LLM calls (default 4; forced to 1 for ollama/claude-cli)") + print(" --max-concurrency=N parallel community-labeling LLM calls (default 4; forced to 1 for ollama/claude-cli/openai-cli)") print(" --batch-size=N communities per labeling LLM call (default 100)") print(" label (re)name communities with the configured LLM backend, regenerate report") print(" --missing-only keep existing labels and only name missing/placeholder communities") print(" --backend= backend to use (default: auto-detect from API keys)") print(" --model= model to use for community naming") - print(" --max-concurrency=N parallel labeling LLM calls (default 4; forced to 1 for ollama/claude-cli)") + print(" --max-concurrency=N parallel labeling LLM calls (default 4; forced to 1 for ollama/claude-cli/openai-cli)") print(" --batch-size=N communities per labeling LLM call (default 100)") print(" query \"\" BFS traversal of graph.json for a question") print(" --dfs use depth-first instead of breadth-first") diff --git a/graphify/cli.py b/graphify/cli.py index 5b7339726..19f04829b 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -3527,6 +3527,17 @@ def _parse_float(name: str, raw: str) -> float: file=sys.stderr, ) sys.exit(1) + elif backend == "openai-cli": + import shutil as _shutil + allow_no_key = _shutil.which("codex") is not None + if not allow_no_key: + print( + "error: backend 'openai-cli' requires the `codex` CLI on $PATH " + "(npm install -g @openai/codex, then run `codex` once to " + "authenticate with your ChatGPT account).", + file=sys.stderr, + ) + sys.exit(1) if not allow_no_key: print( f"error: backend '{backend}' requires {_format_backend_env_keys(backend)} to be set.", diff --git a/graphify/llm.py b/graphify/llm.py index ae2119773..e8ceeac46 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -216,6 +216,18 @@ def _resolve_ollama_base_url(default: str) -> str: # CLI's Read tool rather than as inline base64 (see `_call_claude_cli`). "vision": True, }, + # Routes extraction and labeling through the locally authenticated Codex + # CLI, so the work rides a ChatGPT subscription instead of a metered + # OPENAI_API_KEY. Mirrors the claude-cli entry above. + "openai-cli": { + "default_model": "gpt-5.6-sol", + "model_env_key": "GRAPHIFY_OPENAI_CLI_MODEL", + # Subscription usage is not metered API spend; zero pricing is intentional. + "pricing": {"input": 0.0, "output": 0.0}, + "temperature": None, + "max_tokens": 16384, + "vision": False, + }, } @@ -1628,6 +1640,52 @@ def _claude_cli_supports_json_schema(claude_cmd: str) -> bool: return supported +def _openai_cli_turn_usage(stdout: str) -> tuple[int, int]: + """Return token counts from the last Codex ``turn.completed`` JSONL event.""" + completed_usage: dict | None = None + for line in (stdout or "").splitlines(): + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except (json.JSONDecodeError, TypeError): + # Codex stdout is JSONL, but tolerate diagnostics or malformed lines; + # losing accounting must never discard an otherwise valid extraction. + continue + if isinstance(event, dict) and event.get("type") == "turn.completed": + usage = event.get("usage") + completed_usage = usage if isinstance(usage, dict) else {} + + if completed_usage is None: + # Without a completion event the exact counts are unknown; report zero + # rather than estimating or inventing usage. + return 0, 0 + + def _count(name: str) -> int: + try: + return int(completed_usage.get(name, 0) or 0) + except (TypeError, ValueError): + return 0 + + # Codex input_tokens already appears to include cached_input_tokens, so do + # not add the cached count again or usage will be double-counted. + return _count("input_tokens"), _count("output_tokens") + + +def _openai_cli_vendor_detail(stderr: str, stdout: str) -> str: + """Return bounded Codex diagnostics, preserving stderr and stdout's tail.""" + parts: list[str] = [] + stderr_text = (stderr or "").strip() + stdout_text = (stdout or "").strip() + if stderr_text: + parts.append(f"stderr: {stderr_text[-400:]}") + if stdout_text: + # API errors, including HTTP 400 responses, can appear only in JSONL stdout. + parts.append(f"stdout tail: {stdout_text[-400:]}") + return " | ".join(parts) or "(no stderr or stdout)" + + def _call_claude_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bool = False, images: list[_ImageRef] | None = None) -> dict: """Call Claude via the locally-installed Claude Code CLI (`claude -p`). @@ -1774,6 +1832,157 @@ def _call_claude_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bo return result +def _codex_disable_mcp_args(codex_cmd: str) -> list[str]: + """`-c mcp_servers..enabled=false` for every server Codex has configured. + + Extraction never calls a tool, and each configured MCP server is started per + `codex exec` invocation. Codex config overrides deep-merge, so a blanket + `-c mcp_servers={}` leaves the servers enabled; its per-server `enabled` field + (visible in `codex mcp get `) is the switch that works. The server list + comes from `codex mcp list --json`, so no server name is hardcoded. Best effort: + if that call is unavailable (older Codex, no config), extraction proceeds with + whatever the user configured. + """ + import subprocess + + try: + proc = subprocess.run( + [codex_cmd, "mcp", "list", "--json"], + capture_output=True, text=True, encoding="utf-8", errors="replace", + timeout=30, check=False, **_no_window_kwargs(), + ) + if proc.returncode != 0 or not proc.stdout.strip(): + return [] + servers = json.loads(proc.stdout) + except Exception: + return [] + args: list[str] = [] + for entry in servers if isinstance(servers, list) else []: + name = (entry or {}).get("name") if isinstance(entry, dict) else None + if isinstance(name, str) and name and all(ch.isalnum() or ch in "-_" for ch in name): + args += ["-c", f"mcp_servers.{name}.enabled=false"] + return args + + +def _call_openai_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bool = False, images: list[_ImageRef] | None = None) -> dict: + """Call OpenAI through the locally authenticated Codex CLI.""" + import shutil + import subprocess + import tempfile + + codex_cmd = shutil.which("codex") + if codex_cmd is None: + raise RuntimeError( + "OpenAI Codex CLI not found on $PATH. Install it with " + "`npm install -g @openai/codex` and run `codex` once to authenticate " + "with your ChatGPT account." + ) + + mdl = os.environ.get("GRAPHIFY_OPENAI_CLI_MODEL", "").strip() or "gpt-5.6-sol" + if images: + # This backend is text-only: preserve image source references without + # exposing absolute paths or claiming that Codex received pixel data. + user_message = _with_image_notes(user_message, images, with_paths=False) + + # Deliver the schema and imperative together in the CLI's user turn, just as + # claude-cli does. An agentic CLI given only raw source may answer in prose; + # that would parse hollow and trigger the adaptive retry path. + combined_message = ( + _extraction_system(deep=deep_mode) + + "\n\n" + + "Now extract the knowledge graph from the following source file(s) " + + "and output ONLY the JSON object described above. No prose, no " + + "preamble, no markdown fences.\n\n" + + user_message + ) + + output_file = tempfile.NamedTemporaryFile(delete=False, suffix=".json") + output_path = output_file.name + output_file.close() + try: + # Linux MAX_ARG_STRLEN caps a single argv entry at 128 KB. Real chunks + # reach 240-306 KB, well past that cap; putting the prompt in argv raises + # Errno 7 in subprocess before Codex starts, so there are no vendor + # diagnostics at all. + cli_args = [ + codex_cmd, + "exec", + "--skip-git-repo-check", + "--json", + # Codex can execute shell commands. Extraction must not be able to + # write to the corpus it is reading, so the sandbox is read-only. + "--sandbox", + "read-only", + # Extraction is text-to-JSON: it asks no questions of any tool. Without + # this, every `codex exec` starts the user's configured MCP servers per + # call (measured: four graph servers at ~152 MB each during one run, + # more memory than the extraction itself). `-c mcp_servers={}` does NOT + # work — Codex merges config overrides into the table rather than + # replacing it, so the servers stay enabled (verified with + # `codex mcp list -c mcp_servers={}`). The mechanism that does work is + # Codex's own per-server `enabled` field, one override per server. + *_codex_disable_mcp_args(codex_cmd), + # Reasoning effort for the extraction calls. Defaults to "ultra"; + # GRAPHIFY_OPENAI_CLI_EFFORT overrides it without a source edit. + "-c", + "model_reasoning_effort=%s" % (os.environ.get("GRAPHIFY_OPENAI_CLI_EFFORT", "").strip() or "ultra"), + "--model", + mdl, + "-o", + output_path, + "-", + ] + proc = subprocess.run( + cli_args, + # input= keeps the prompt out of argv and closes the pipe; the prompt + # cannot ride in argv, and an open harness pipe hangs Codex forever. + input=combined_message, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + # A measured single-document extraction took 361 seconds, so the + # 600-second default is tight; users should raise --api-timeout. + timeout=_resolve_api_timeout(), + check=False, + **_no_window_kwargs(), + ) + if proc.returncode != 0: + detail = _openai_cli_vendor_detail(proc.stderr, proc.stdout) + raise RuntimeError(f"codex exec exited {proc.returncode}: {detail}") + if not os.path.exists(output_path): + detail = _openai_cli_vendor_detail(proc.stderr, proc.stdout) + raise RuntimeError(f"codex exec produced no -o output file: {detail}") + try: + raw_content = Path(output_path).read_text(encoding="utf-8", errors="replace") + except OSError as exc: + detail = _openai_cli_vendor_detail(proc.stderr, proc.stdout) + raise RuntimeError(f"codex exec could not read its -o output: {exc}; {detail}") from exc + if not raw_content.strip(): + detail = _openai_cli_vendor_detail(proc.stderr, proc.stdout) + raise RuntimeError(f"codex exec produced an empty -o output file: {detail}") + + result = _parse_llm_json(raw_content) + if not result.get("nodes") and not result.get("edges") and not result.get("hyperedges"): + # Never return an empty graph here: _response_is_hollow would treat it + # as truncation and bisect the chunk into as many as 15 more calls. That + # exact failure mode previously consumed 87% of an hourly backend quota. + vendor_text = raw_content.strip()[:800] + raise RuntimeError(f"codex exec returned no graph content: {vendor_text}") + + input_tokens, output_tokens = _openai_cli_turn_usage(proc.stdout) + result["input_tokens"] = input_tokens + result["output_tokens"] = output_tokens + result["model"] = mdl + result["finish_reason"] = "stop" + return result + finally: + try: + os.unlink(output_path) + except OSError: + pass + + def _azure_client(api_key: str, endpoint: str): """Construct an AzureOpenAI client with env-driven api_version and timeout.""" try: @@ -1931,7 +2140,7 @@ def extract_files_direct( file=sys.stderr, ) key = "ollama" - if not key and backend not in ("bedrock", "claude-cli"): + if not key and backend not in ("bedrock", "claude-cli", "openai-cli"): raise ValueError( f"No API key for backend '{backend}'. " f"Set {_format_backend_env_keys(backend)} or pass api_key=." @@ -1955,6 +2164,8 @@ def extract_files_direct( result = _call_claude(key, mdl, user_msg, max_tokens=max_out, deep_mode=deep_mode, images=image_refs) elif backend == "claude-cli": result = _call_claude_cli(user_msg, max_tokens=max_out, deep_mode=deep_mode, images=image_refs) + elif backend == "openai-cli": + result = _call_openai_cli(user_msg, max_tokens=max_out, deep_mode=deep_mode, images=image_refs) elif backend == "bedrock": result = _call_bedrock(mdl, user_msg, max_tokens=max_out, deep_mode=deep_mode, images=image_refs) elif backend == "azure": @@ -2613,6 +2824,8 @@ def _run_one(idx: int, chunk: list[Path]) -> tuple[int, dict | None, Exception | # over session state. Force serial unless the user explicitly opts in. if backend == "claude-cli" and os.environ.get("GRAPHIFY_CLAUDE_CLI_PARALLEL", "").strip() != "1": max_concurrency = 1 + if backend == "openai-cli" and os.environ.get("GRAPHIFY_OPENAI_CLI_PARALLEL", "").strip() != "1": + max_concurrency = 1 def _checkpoint_chunk(result: dict, chunk: "list[Path | FileSlice]") -> None: # Persist each chunk's semantic results to the cache as soon as it # completes. Without this, the semantic cache is only written once, at @@ -2855,7 +3068,7 @@ def _call_llm( ollama_url = _resolve_ollama_base_url(cfg.get("base_url", "")) _validate_ollama_base_url(ollama_url) key = "ollama" - if not key and backend not in ("bedrock", "claude-cli"): + if not key and backend not in ("bedrock", "claude-cli", "openai-cli"): raise ValueError( f"No API key for backend '{backend}'. Set {_format_backend_env_keys(backend)}." ) @@ -2929,6 +3142,71 @@ def _rec(inp, out) -> None: ) return envelope.get("result", "") + if backend == "openai-cli": + import shutil, subprocess, tempfile + + codex_cmd = shutil.which("codex") + if codex_cmd is None: + raise RuntimeError( + "OpenAI Codex CLI not found on $PATH. Install it with " + "`npm install -g @openai/codex` and run `codex` once to authenticate " + "with your ChatGPT account." + ) + output_file = tempfile.NamedTemporaryFile(delete=False, suffix=".json") + output_path = output_file.name + output_file.close() + try: + cli_args = [ + codex_cmd, + "exec", + "--skip-git-repo-check", + "--json", + # Labeling is also an agentic Codex pass, so it must not be able + # to mutate the corpus or any other file in the working tree. + "--sandbox", + "read-only", + "--model", + mdl, + "-o", + output_path, + "-", + ] + proc = subprocess.run( + cli_args, + # input= keeps the prompt out of argv and closes the pipe; the prompt + # cannot ride in argv, and an open harness pipe hangs Codex forever. + input=prompt, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=_resolve_api_timeout(), + check=False, + **_no_window_kwargs(), + ) + if proc.returncode != 0: + detail = _openai_cli_vendor_detail(proc.stderr, proc.stdout) + raise RuntimeError(f"codex exec exited {proc.returncode}: {detail}") + if not os.path.exists(output_path): + detail = _openai_cli_vendor_detail(proc.stderr, proc.stdout) + raise RuntimeError(f"codex exec produced no -o output file: {detail}") + try: + raw_content = Path(output_path).read_text(encoding="utf-8", errors="replace") + except OSError as exc: + detail = _openai_cli_vendor_detail(proc.stderr, proc.stdout) + raise RuntimeError(f"codex exec could not read its -o output: {exc}; {detail}") from exc + if not raw_content.strip(): + detail = _openai_cli_vendor_detail(proc.stderr, proc.stdout) + raise RuntimeError(f"codex exec produced an empty -o output file: {detail}") + input_tokens, output_tokens = _openai_cli_turn_usage(proc.stdout) + _rec(input_tokens, output_tokens) + return raw_content + finally: + try: + os.unlink(output_path) + except OSError: + pass + if backend == "bedrock": try: @@ -3127,7 +3405,7 @@ def detect_backend() -> str | None: _validate_ollama_base_url(ollama_url) return "ollama" for name in BACKENDS: - if name not in ("gemini", "kimi", "claude", "openai", "deepseek", "azure", "bedrock", "ollama", "claude-cli"): + if name not in ("gemini", "kimi", "claude", "openai", "deepseek", "azure", "bedrock", "ollama", "claude-cli", "openai-cli"): if _get_backend_api_key(name): return name return None @@ -3345,6 +3623,8 @@ def label_communities( max_concurrency = 1 if backend == "claude-cli" and os.environ.get("GRAPHIFY_CLAUDE_CLI_PARALLEL", "").strip() != "1": max_concurrency = 1 + if backend == "openai-cli" and os.environ.get("GRAPHIFY_OPENAI_CLI_PARALLEL", "").strip() != "1": + max_concurrency = 1 workers = max(1, min(max_concurrency, n_batches)) def _run_batch(batch_idx: int): diff --git a/tests/test_openai_cli_backend.py b/tests/test_openai_cli_backend.py new file mode 100644 index 000000000..193b9a955 --- /dev/null +++ b/tests/test_openai_cli_backend.py @@ -0,0 +1,219 @@ +"""Tests for the openai-cli backend (_call_openai_cli). + +Covers the argv contract, the happy JSON parse, the failure paths (non-zero +exit, missing/empty -o output, hollow graph), and turn-usage accounting. +No network, no Codex binary: shutil.which and subprocess.run are monkeypatched. +""" +import json + +import pytest + +from graphify import llm + + +class _Captured: + def __init__(self): + self.args = None + self.kwargs = None + self.calls = [] + + +def _arm(monkeypatch, response=None, servers=("graphify", "docs"), + exec_returncode=0, exec_stdout="", exec_stderr="", write_output=True): + """Fake a Codex CLI: `mcp list --json` returns servers, `exec` writes the -o file.""" + cap = _Captured() + import shutil + monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/codex" if name == "codex" else None) + + class P: + def __init__(self, stdout="", returncode=0, stderr=""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + def fake_run(args, **kwargs): + cap.calls.append(list(args)) + if "mcp" in args and "list" in args: + return P(json.dumps([{"name": n, "enabled": True} for n in servers])) + cap.args = list(args) + cap.kwargs = kwargs + # write the -o file the way codex exec does + out_idx = args.index("-o") + 1 + if write_output: + payload = response if response is not None else {"nodes": [{"id": "f", "type": "function"}], "edges": []} + if isinstance(payload, str): + with open(args[out_idx], "w", encoding="utf-8") as fh: + fh.write(payload) + else: + with open(args[out_idx], "w", encoding="utf-8") as fh: + json.dump(payload, fh) + else: + # simulate codex never producing the file (the backend pre-creates + # the temp path, so "missing" means it is gone by the time we look) + import os as _os + try: + _os.unlink(args[out_idx]) + except OSError: + pass + return P(stdout=exec_stdout, returncode=exec_returncode, stderr=exec_stderr) + + import subprocess as _sp + monkeypatch.setattr(_sp, "run", fake_run) + return cap + + +def test_argv_contract_defaults(monkeypatch): + monkeypatch.delenv("GRAPHIFY_OPENAI_CLI_MODEL", raising=False) + monkeypatch.delenv("GRAPHIFY_OPENAI_CLI_EFFORT", raising=False) + cap = _arm(monkeypatch) + llm._call_openai_cli("def f(): pass", max_tokens=64) + a = cap.args + assert a[0] == "/usr/bin/codex" and a[1] == "exec" + assert "--skip-git-repo-check" in a and "--json" in a + assert a[a.index("--sandbox") + 1] == "read-only" + assert a[a.index("--model") + 1] == "gpt-5.6-sol" # default model + assert "model_reasoning_effort=ultra" in a # default effort + assert a[-1] == "-" # prompt via stdin + assert cap.kwargs.get("input") # not argv (MAX_ARG_STRLEN) + + +def test_every_configured_mcp_server_is_disabled_per_call(monkeypatch): + """A blanket `mcp_servers={}` is merged away by Codex; per-server `enabled` works.""" + cap = _arm(monkeypatch, servers=("graphify", "docs")) + llm._call_openai_cli("def f(): pass", max_tokens=64) + a = cap.args + assert "mcp_servers.graphify.enabled=false" in a + assert "mcp_servers.docs.enabled=false" in a + assert "mcp_servers={}" not in a + # the server list came from Codex itself, no hardcoded names + assert any("mcp" in c and "list" in c and "--json" in c for c in cap.calls) + + +def test_no_configured_servers_adds_no_overrides(monkeypatch): + cap = _arm(monkeypatch, servers=()) + llm._call_openai_cli("def f(): pass", max_tokens=64) + assert not [x for x in cap.args if str(x).startswith("mcp_servers.")] + + +def test_argv_env_overrides(monkeypatch): + monkeypatch.setenv("GRAPHIFY_OPENAI_CLI_MODEL", "gpt-5.6-luna") + monkeypatch.setenv("GRAPHIFY_OPENAI_CLI_EFFORT", "high") + cap = _arm(monkeypatch) + llm._call_openai_cli("def f(): pass", max_tokens=64) + a = cap.args + assert a[a.index("--model") + 1] == "gpt-5.6-luna" + assert "model_reasoning_effort=high" in a + assert "model_reasoning_effort=ultra" not in a + + +def test_missing_binary_raises(monkeypatch): + import shutil + monkeypatch.setattr(shutil, "which", lambda name: None) + with pytest.raises(RuntimeError, match="Codex CLI not found"): + llm._call_openai_cli("x", max_tokens=16) + + +# --------------------------------------------------------------------------- +# Happy path: the -o JSON becomes the result dict, usage rides the JSONL stdout +# --------------------------------------------------------------------------- + +def test_happy_parse_returns_graph_and_usage(monkeypatch): + monkeypatch.delenv("GRAPHIFY_OPENAI_CLI_MODEL", raising=False) + stdout = "\n".join([ + json.dumps({"type": "turn.started"}), + "not-json diagnostics line", + json.dumps({"type": "turn.completed", + "usage": {"input_tokens": 1200, "cached_input_tokens": 1000, + "output_tokens": 345}}), + ]) + payload = {"nodes": [{"id": "acme.f", "type": "function"}], + "edges": [{"source": "acme.f", "target": "acme.g", "relation": "calls"}]} + _arm(monkeypatch, response=payload, exec_stdout=stdout) + result = llm._call_openai_cli("def f(): g()", max_tokens=64) + assert result["nodes"] == payload["nodes"] + assert result["edges"] == payload["edges"] + # input_tokens already includes cached_input_tokens; must not be re-added + assert result["input_tokens"] == 1200 + assert result["output_tokens"] == 345 + assert result["model"] == "gpt-5.6-sol" + assert result["finish_reason"] == "stop" + + +# --------------------------------------------------------------------------- +# Failure paths: every raise carries the vendor detail (stderr + stdout tail) +# --------------------------------------------------------------------------- + +def test_nonzero_exit_raises_with_vendor_detail(monkeypatch): + _arm(monkeypatch, exec_returncode=2, + exec_stderr="ERROR: 400 Bad Request: model not found", + exec_stdout=json.dumps({"type": "error", "message": "http 400"})) + with pytest.raises(RuntimeError) as exc: + llm._call_openai_cli("x", max_tokens=16) + msg = str(exc.value) + assert "codex exec exited 2" in msg + assert "400 Bad Request" in msg # stderr preserved + assert "http 400" in msg # JSONL stdout tail preserved + + +def test_missing_output_file_raises(monkeypatch): + _arm(monkeypatch, write_output=False, exec_stderr="boom") + with pytest.raises(RuntimeError, match="produced no -o output file"): + llm._call_openai_cli("x", max_tokens=16) + + +def test_empty_output_file_raises(monkeypatch): + _arm(monkeypatch, response=" \n", exec_stderr="quota hint on stderr") + with pytest.raises(RuntimeError) as exc: + llm._call_openai_cli("x", max_tokens=16) + msg = str(exc.value) + assert "empty -o output file" in msg + assert "quota hint on stderr" in msg + + +def test_empty_graph_raises_instead_of_hollow_bisect(monkeypatch): + # An empty-but-valid graph must raise, or _response_is_hollow would bisect + # the chunk into up to 15 more subscription calls. + _arm(monkeypatch, response={"nodes": [], "edges": []}) + with pytest.raises(RuntimeError, match="returned no graph content"): + llm._call_openai_cli("x", max_tokens=16) + + +# --------------------------------------------------------------------------- +# _openai_cli_turn_usage: JSONL accounting +# --------------------------------------------------------------------------- + +def test_turn_usage_reads_last_completed_event(): + stdout = "\n".join([ + json.dumps({"type": "turn.completed", "usage": {"input_tokens": 1, "output_tokens": 2}}), + json.dumps({"type": "turn.completed", "usage": {"input_tokens": 30, "output_tokens": 40}}), + ]) + assert llm._openai_cli_turn_usage(stdout) == (30, 40) + + +def test_turn_usage_tolerates_malformed_lines(): + stdout = "\n".join([ + "plain diagnostics", + "{not json", + json.dumps({"type": "turn.completed", "usage": {"input_tokens": 7, "output_tokens": 8}}), + ]) + assert llm._openai_cli_turn_usage(stdout) == (7, 8) + + +def test_turn_usage_without_completed_event_reports_zero(): + assert llm._openai_cli_turn_usage("") == (0, 0) + assert llm._openai_cli_turn_usage(json.dumps({"type": "turn.started"})) == (0, 0) + + +def test_turn_usage_non_numeric_counts_are_zero(): + stdout = json.dumps({"type": "turn.completed", + "usage": {"input_tokens": "many", "output_tokens": None}}) + assert llm._openai_cli_turn_usage(stdout) == (0, 0) + + +def test_vendor_detail_bounds_and_labels(): + detail = llm._openai_cli_vendor_detail("E" * 1000, "O" * 1000) + assert detail.startswith("stderr: ") + assert "stdout tail: " in detail + # both sides bounded to their last 400 chars + assert len(detail) < 900 + assert llm._openai_cli_vendor_detail("", "") == "(no stderr or stdout)"