Skip to content

feat(llm): try Codex and Claude CLIs before the API provider - #429

Merged
spalen0 merged 4 commits into
mainfrom
cliai
Oct 20, 2026
Merged

spalen0 merged 4 commits into
mainfrom
cliai

Conversation

@spalen0

@spalen0 spalen0 commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

The AI explainer now tries local agent CLIs before the metered API:

  1. Codex CLI: codex exec --yolo
  2. Claude Code CLI: claude -p --dangerously-skip-permissions (bypass-permissions mode)
  3. API provider: the existing LLM_PROVIDER / LLM_API_KEY config, used when both CLIs fail (no credits, auth, timeout) or aren't installed

How it works

  • utils/llm/cli_provider.py adds CodexCLIProvider and ClaudeCLIProvider.
    • The prompt goes in on stdin.
    • Structured output uses each CLI's native schema flag: --output-schema for Codex, --json-schema for Claude (which returns structured_output). The risk_tag enum is enforced the same way as on the API path.
    • Every call runs in a fresh temp dir with --ephemeral / --no-session-persistence / --strict-mcp-config, so this repo's AGENTS.md/CLAUDE.md and MCP servers never reach the analysis.
    • Codex has no system-prompt flag, so the system prompt is prepended in a <system_instructions> block. Claude gets it via --system-prompt-file.
  • utils/llm/fallback.py adds FallbackProvider, which tries providers in order.
    • Process-level failures (non-zero exit, timeout, missing binary, Claude is_error) raise a new LLMUnavailableError. That provider is then disabled for the rest of the run, so a CLI with no credits doesn't add latency to every stage of every alert.
    • A bad response (empty text, invalid JSON) only falls through for that one call.
    • model_name reports the provider that actually answered, so the AI summary using … log line is accurate.
  • factory.py builds the chain.
    • CLIs not on PATH are skipped.
    • A single available provider is returned unwrapped.
    • LLM_API_KEY is now optional when a CLI exists.

New env vars: LLM_CLI_PROVIDERS (default codex,claude; none turns the CLIs off), LLM_CLI_TIMEOUT (default 300s), LLM_CODEX_MODEL, LLM_CLAUDE_MODEL. When the model vars are unset, each CLI uses its own configured default.

Context parity with the API

The CLIs get exactly the same per-transaction prompt and system prompts as the API path. Host-level agent config is stripped where the CLI supports it, verified live:

  • Claude: --setting-sources "" --disable-slash-commands --strict-mcp-config. No user rules, CLAUDE.md, skills or MCP servers reach the model.
  • Codex: every server from codex mcp list --json is disabled by name (--config mcp_servers.<name>.enabled=false), plus --disable apps --disable plugins. mcp_servers={} turned out to be a no-op merge. Listing failures and names the -c parser can't address fail closed: Codex is skipped. Verified live: a probe stdio server starts when enabled and doesn't when disabled. A regression test runs the real codex binary against a synthetic config. Codex has no supported switch for the global ~/.codex/AGENTS.md or ~/.agents/skills, and both still load. Keep them empty for the user that runs monitoring.

⚠️ Things to decide before merging

  • Prompt injection with approvals off. Both CLIs run as full agents with approvals and sandbox disabled, as requested. The prompt contains attacker-influenced text: proposal descriptions, verified source, labels. An injection could run shell commands as the monitoring user, which has access to .env secrets.
    • Suggested hardening, if acceptable: for Claude, --tools "" disables tools entirely. It never asks for approval either way, so nothing hangs. Codex would need --sandbox read-only instead of --yolo.
    • Happy to switch to either.
  • VPS deploy is unchanged, so prod still uses the API. deploy/systemd/monitoring.service sets PATH=<repo>/.venv/bin:/usr/local/bin:/usr/bin:/bin, which excludes ~/.local/bin, and sets ProtectHome=read-only. Both CLIs need write access to ~/.codex / ~/.claude.
    • Turning this on in prod needs a unit change (PATH + ReadWritePaths) plus a logged-in CLI on the host. I left that out because it loosens the unit's hardening.
  • Venice credit alert (credits.py) still runs. Venice is the last-resort fallback, so it should stay funded.

Test plan

  • uv run pytest -q tests: 1825 passed, 4 skipped. That includes the new tests/test_llm_cli.py (CLI argv/stdin/schema handling, error classification, fallback ordering/disabling, factory chain wiring). The existing factory tests now patch shutil.which so they don't depend on installed CLIs.
  • ruff check, ruff format --check, ty check are clean.
  • Live, using the real SUMMARY_SCHEMA and system prompts:
    • Codex answers structured (~13s) and text (~6s).
    • With CODEX_HOME pointing at an empty dir (401), Codex is disabled after one failure and Claude answers both calls.
    • With Codex broken and LLM_CLI_PROVIDERS=codex, it falls through to Venice.

🤖 Generated with Claude Code

spalen0 and others added 4 commits October 20, 2026 09:04
get_llm_provider() now builds a fallback chain: `codex exec --yolo`,
then `claude -p --dangerously-skip-permissions`, then the configured API
provider. CLIs missing from PATH are skipped. A CLI that exits non-zero,
times out, or reports an error (auth, no credits) is disabled for the
rest of the process so later stages don't wait on it again.

Both CLIs use native JSON-schema output for the structured summary and
run in a fresh temp dir so repo AGENTS.md/CLAUDE.md never reach the prompt.

Configurable via LLM_CLI_PROVIDERS (order, or "none"), LLM_CLI_TIMEOUT,
LLM_CODEX_MODEL and LLM_CLAUDE_MODEL. LLM_API_KEY is now optional when a
CLI is available.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude now runs with --setting-sources "" and --disable-slash-commands,
so user rules, CLAUDE.md and skills no longer reach the model. Codex runs
with mcp_servers={} so the prompt can't be sent to the user's MCP servers.
Codex still loads the global ~/.codex/AGENTS.md and ~/.agents/skills, which
have no supported switch; that limitation is documented.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…broken API config

- Run each CLI as a new process group and SIGKILL the group on timeout and
  after exit, so agent-spawned commands can't outlive the call. Capture
  output via temp files so a backgrounded child holding stdout can't stall a
  finished call until the timeout.
- Always wrap CLIs in FallbackProvider, even a lone one with no API key, so an
  unavailable CLI is disabled after its first failure instead of retried by
  every structured/text stage of every summary.
- When a CLI is available, log and drop an API provider that fails to
  initialize (missing SDK, missing base URL) instead of raising before any
  CLI is tried.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`--config mcp_servers={}` is merged into the loaded config, so every
configured MCP server stayed enabled for explainer runs. Codex now lists the
configured servers once (`codex mcp list --json`) and passes
`mcp_servers.<name>.enabled=false` for each, plus `--disable apps` and
`--disable plugins`. If the list can't be read, or a name can't be addressed
by codex's `-c` dotted-path parser, Codex fails closed as unavailable.

Adds a regression test that runs the real codex CLI against a synthetic
CODEX_HOME and asserts every server reports disabled (skipped without codex).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@spalen0
spalen0 marked this pull request as ready for review October 20, 2026 08:07
@spalen0
spalen0 merged commit ac88468 into main Oct 20, 2026
3 checks passed
@spalen0
spalen0 deleted the cliai branch October 20, 2026 10:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant