Source code for fractal.impl.codex

"""Codex CLI agent implementation."""

from __future__ import annotations

import json
import os
import pathlib
import subprocess
import time
import tomllib
from typing import Any, Optional

import fractal.core.pricing
from fractal.core.agent import Agent, Invocation, StreamEvent, StreamParser

__all__ = [
    'CodexParser',
    'CodexAgent',
]

# codex exec has no fork (`exec resume` mutates the thread)
_NO_FORK = (
    'codex cannot fork a session (no `codex exec fork`):'
    ' use --session with --resume to continue one in place,'
    ' or omit --session/--current for a fresh thread.'
)

# bound the model preflight probe so a hung codex (network/auth stall)
# cannot wedge a run start
_PREFLIGHT_TIMEOUT = 60


[docs] class CodexParser(StreamParser): """Parser for codex ``exec --json`` output.""" def __init__(self: CodexParser, *, model: Optional[str] = None) -> None: """Initialize ``CodexParser``. Bind the configured-model fallback and start the wall clock. """ super().__init__(model=model) # codex reports no per-turn cost on the chat stream, so the result # closes on wall time self._started = time.monotonic()
[docs] def feed(self: CodexParser, line: str) -> list[StreamEvent]: """Parse one codex JSONL line into normalized events.""" # tolerate blank, malformed, and non-object lines (wire noise) line = line.strip() if not line: return [] try: event = json.loads(line) except json.JSONDecodeError: return [] if not isinstance(event, dict): return [] # coerce nested payload fields with `or {}`/`or []`, not a .get # default: a present-null field is None (the default only fills an # absent key), so wire-noise tolerance must reach nested nulls too event_type = event.get('type') # capture the real session (codex calls it a thread id) for resume # and cost grouping -- it rides thread.started only if event_type == 'thread.started': session = event.get('thread_id') if session: self.session = session return [StreamEvent(kind='session', session=session, model=self.model)] # command executions map to tool headers elif event_type == 'item.started': item = event.get('item') or {} if item.get('type') == 'command_execution': return [StreamEvent(kind='tool', tool=item.get('command', '?'))] # agent messages elif event_type == 'item.completed': item = event.get('item') or {} if item.get('type') == 'agent_message' and item.get('text'): # codex sends whole messages, not deltas -- each closes a line return [StreamEvent(kind='text', text=item['text'] + '\n')] # turn summary -- codex usage is cumulative per thread and only grows, # so keep the max: a zero/empty terminal usage frame (codex emits # usage:{} on some error/cancel paths) must not reset the running # total and drive the per-step delta negative, nor move it off None # (a genuine turn always consumes tokens, so a computed 0.0 would # record a known $0 for an unknowable cost); flush per turn so a # stream killed by signal still has the last increment recorded elif event_type == 'turn.completed': events: list[StreamEvent] = [] usage = event.get('usage') or {} cost = _compute_cost(usage, self.model) if cost and (self.cost is None or cost > self.cost): self.cost = cost events.append(StreamEvent(kind='cost', cost=self.cost)) # the result closes on wall time and the recorded turn cost (no # authoritative cost rides the stream, so the running estimate -- # None when unpriced -- is the turn's whole cost fact) wall = time.monotonic() - self._started events.append(StreamEvent(kind='result', cost=self.cost, duration=wall)) return events # surface errors -- codex reports these on the JSON stream, not # stderr, so without this a failed turn leaves no explanation in the # output; the errors list fails the step after the stream drains elif event_type in ('error', 'turn.failed'): error = event.get('error') error_message = error.get('message') if isinstance(error, dict) else error detail = event.get('message') or error_message or 'unknown error' self.errors.append(str(detail)) return [StreamEvent(kind='error', message=str(detail))] return []
[docs] class CodexAgent(Agent): """Codex CLI backend (token-priced, agent-minted threads, no fork). Todo: Support forking once codex ships ``codex exec fork``: https://github.com/openai/codex/issues/11750 and https://github.com/openai/codex/issues/17568. """ name = 'codex' config_file = 'config.toml' can_fork = False mints_session = True needs_pricing = True cost_scope = 'thread' enforces_budget = False providers = ('openrouter',) __parser__ = CodexParser def _invocation( self: CodexAgent, prompt: str, *, mode: str, session: Optional[str], model: Optional[str], effort: Optional[str], budget: Optional[float], ) -> Invocation: """Build the codex ``exec --json`` invocation.""" # codex exec can resume a thread in place but cannot fork it if mode == 'fork': raise NotImplementedError(_NO_FORK) argv = [*self.parts, 'exec'] # exec resume takes no -C -- the shell cwd carries the worktree if mode == 'resume': argv += ['resume', session] # resume the thread in place else: argv += ['-C', f'{self.node.worktree}'] # fresh thread argv += ['--json'] # route through openrouter: define the provider table inline so one # config template serves both routes and `config set provider=null` # takes effect next launch (values parse as TOML) if self.provider == 'openrouter': argv += [ '-c', 'model_provider="openrouter"', '-c', 'model_providers.openrouter.name="OpenRouter"', '-c', 'model_providers.openrouter.base_url="https://openrouter.ai/api/v1"', '-c', 'model_providers.openrouter.env_key="OPENROUTER_API_KEY"', ] if model: argv += ['-m', model] # the -c override outranks the config.toml default if effort: argv += ['-c', f'model_reasoning_effort="{effort}"'] # a '--' sentinel ends option parsing (codex exec uses clap), so a # message whose first char is '-' ('-1 on that', '--continue looks # right') is the message, never mistaken for a flag that fails the run # or silently flips a boolean option argv += ['--', prompt] # run in the worktree (the project): CODEX_HOME supplies # config/auth/skills, so the cwd is the project not the node dir; the # env carries only the reserved CODEX_HOME; invocation() composes it # over os.environ and the caller overlay env = {'CODEX_HOME': str(self.config_dir)} return Invocation( agent=self.name, argv=tuple(argv), cwd=self.node.worktree, env=env, session=session, ) def _config_model(self: CodexAgent) -> Optional[str]: """Resolve the model codex's own config defaults to. Codex reads its ``CODEX_HOME`` ``config.toml`` (the node's ``.codex``); ``None`` when it names no top-level model. """ config = self.config_dir / self.config_file if not config.is_file(): return None # an unreadable or malformed config names no model try: with open(config, 'rb') as file: data = tomllib.load(file) except (OSError, tomllib.TOMLDecodeError): return None model = data.get('model') if isinstance(model, str) and model: return model return None def _transcript(self: CodexAgent, session: str) -> Optional[pathlib.Path]: """Newest dated rollout under the node's own codex home.""" # codex rollouts nest by date under the node's own codex home sessions_dir = self.config_dir / 'sessions' found = sorted(sessions_dir.glob(f'*/*/*/rollout-*-{session}.jsonl')) if found: return found[-1] return None def _rates(self: CodexAgent, model: str) -> Optional[dict[str, Any]]: """Resolve pricing through the codex slug-alias chain.""" return _rates(model) def _preflight(self: CodexAgent, model: Optional[str]) -> None: """Probe codex's acceptance of an explicit model for this account. Some codex accounts reject some explicit models (e.g. a ChatGPT-plan account returning a 400 for a model outside its entitlement; a cost cap forces an explicit model, but one can also be set without a cap); the pricing cache only proves the model priceable, not that codex accepts it. One bounded probe, built and spawned through the standard triads so a host's ``_spawn`` override covers it too; an uncapped codex with no model skips the probe and runs fine. """ # the openrouter route runs on the key alone -- fail fast when the # environment cannot possibly authenticate if self.provider == 'openrouter' and not os.environ.get('OPENROUTER_API_KEY'): raise RuntimeError( 'OPENROUTER_API_KEY is not set\n' 'export it in the shell that runs fractal node start' ' (start.sh forwards it into the node tmux session)' ) # only an explicit model can be rejected if model is None: return # capture the probe's output (codex emits the authoritative cause -- # e.g. a 400 'model not supported with a ChatGPT account' -- on its # --json stream) so a rejection relays codex's reason rather than a # hedged guess, merging stderr in so it rides alongside invocation = self.invocation('reply with: ok', model=model) process = self.spawn(invocation, stderr=subprocess.STDOUT) try: output, _ = process.communicate(timeout=_PREFLIGHT_TIMEOUT) except subprocess.TimeoutExpired as e: # a hung probe never responded -- distinct from an actual # rejection; the first line is the short reason callers persist process.kill() process.communicate() raise RuntimeError( 'codex preflight timed out\n' f'after {_PREFLIGHT_TIMEOUT}s for model {model!r};' ' codex did not respond.' ) from e if process.returncode != 0: # lead with codex's own message (the authoritative cause), then a # neutral cause list -- the probe fails for auth, network, # rate-limit, or entitlement reasons, not only model rejection; # the first line is the short reason callers persist detail = f'\n{output.strip()}' if output.strip() else '' # name the openrouter causes when routed -- codex login advice # would misdirect a key problem if self.provider == 'openrouter': raise RuntimeError( f'codex preflight failed for model {model!r}\n' f'(exit {process.returncode}):{detail}\n' "Check codex's output for the cause -- common ones:" ' an invalid or expired OPENROUTER_API_KEY (check the' ' OpenRouter dashboard), account data-policy settings' ' excluding the model, or a slug OpenRouter does not' ' carry (use author-prefixed ids, e.g. openai/gpt-5.3-codex)' ) raise RuntimeError( f'codex preflight failed for model {model!r}\n' f'(exit {process.returncode}):{detail}\n' "Check codex's output for the cause -- common ones:" ' expired/invalid auth (re-run codex login), network or' ' rate-limit errors (retry), or a model unavailable to this' ' account (some ChatGPT-plan accounts lack access to some' ' models; API-key auth is an alternative)' ) @classmethod def _seed(cls: type[CodexAgent], node_dir: pathlib.Path) -> None: """Link the node's codex auth through to the global home.""" # codex auth must stay global: CODEX_HOME points at this node dir, but # the credential is shared via a symlink to the global codex home -- # codex writes auth.json in-place through the link (token refresh # updates the global file), so the secret is never copied into the node link = node_dir / f'.{cls.name}' / 'auth.json' if link.is_symlink(): return global_home = os.environ.get('CODEX_HOME') or pathlib.Path.home() / '.codex' auth = pathlib.Path(global_home) / 'auth.json' # CODEX_HOME may be inherited from a parent node whose auth.json is # itself a symlink to the real ~/.codex/auth.json; canonicalize to # that real file so this link never dangles when an intermediate node # is reset or deleted (non-strict resolve canonicalizes a pre-auth # chain too, before the real file exists) auth = auth.resolve() link.symlink_to(auth)
# ------ helper functions def _rates(model: str) -> Optional[dict[str, Any]]: """Pricing lookup chain: exact -> openrouter/-prefixed -> author-stripped. Native ids hit the exact key first; an openrouter slug (``openai/gpt-5.3-codex``) resolves via the LiteLLM ``openrouter/`` prefix or, failing that, its bare model name -- an unmatched model returns ``None`` (unpriced), never a guessed entry. """ for key in (model, f'openrouter/{model}', model.partition('/')[2] or model): entry = fractal.core.pricing.rates(key) if entry is not None: return entry return None def _compute_cost( usage: dict[str, Any], model: Optional[str] = None, ) -> Optional[float]: """Compute cost from codex token usage and LiteLLM pricing. Returns ``None`` if the model is unknown or unpriced. The usage shape is codex/OpenAI-specific (see the note below) -- a future token-reporting agent on a different convention needs its own cost helper. """ if model is None: return None # look up per-token rates (cached input falls back to the input rate) rates = _rates(model) if rates is None: return None input_rate = rates.get('input_cost_per_token', 0.0) cached_rate = rates.get('cache_read_input_token_cost', input_rate) output_rate = rates.get('output_cost_per_token', 0.0) # codex reports OpenAI-style usage: cached_input_tokens is a subset of # input_tokens and reasoning is folded into output_tokens, so non-cached # input is input - cached and output is already whole; coerce each with # `or 0.0` (not a .get default), since a present-null bucket skips the # default and would poison the arithmetic input_tokens = usage.get('input_tokens') or 0.0 cached_tokens = usage.get('cached_input_tokens') or 0.0 output_tokens = usage.get('output_tokens') or 0.0 # floor at 0: cached is a subset of input, but this is external stream data uncached = max(0.0, input_tokens - cached_tokens) return ( uncached * input_rate + cached_tokens * cached_rate + output_tokens * output_rate )