diff --git a/README.md b/README.md index 92e58a2a..95800f07 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,8 @@ life, and opens a dashboard where you can watch its mind run: curl -fsSL https://headlong.ai/install.sh | bash ``` -You'll need bash 3.2+, git, curl, jq, and an LLM API key (Anthropic, -OpenAI, Gemini, or OpenRouter); the dashboard also needs +You'll need bash 3.2+, git, curl, jq, and either an LLM API key (Anthropic, +OpenAI, Gemini, or OpenRouter) or an authenticated Codex CLI installation; the dashboard also needs [uv](https://docs.astral.sh/uv/) and bun or node, and the installer offers to fetch those. diff --git a/bin/llm b/bin/llm index f132b154..014dc2c9 100755 --- a/bin/llm +++ b/bin/llm @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + # llm — Minimal multi-provider LLM CLI tool # Supports the Anthropic, OpenAI, Gemini, and OpenRouter APIs. @@ -146,7 +148,7 @@ Options: -t, --max-tokens N Max output tokens. Default: model-specific cap -M, --messages JSON Messages array JSON, e.g. [{"role":"user","content":"hi"}] --no-stream Disable streaming (streaming is on by default) - --provider NAME Provider: anthropic, openai, gemini, or openrouter + --provider NAME Provider: anthropic, openai, gemini, openrouter, or codex --thinking [LEVEL] Enable thinking (Anthropic: adaptive; Gemini: minimal/low/medium/high) --effort LEVEL Thinking effort/provider output effort --raw Print raw non-streaming API response JSON @@ -190,6 +192,7 @@ Thinking & effort: Environment: ANTHROPIC_API_KEY Required for Anthropic models OPENAI_API_KEY Required for OpenAI models + CODEX_APP_SERVER_BIN Codex executable for the codex provider (default: codex) GEMINI_API_KEY Required for Gemini models OPENROUTER_API_KEY Required for OpenRouter models LLM_API_URL Override provider API URL @@ -843,6 +846,22 @@ _llm_cleanup() { return 0 } trap '_llm_cleanup' EXIT +if [[ "$LLM_PROVIDER" == "codex" ]]; then + printf '%s' "$LLM_MESSAGES" > "$payload_file" + system_file="" + if [[ -n "$LLM_SYSTEM" ]]; then + system_file=$(mktemp "${TMPDIR:-/tmp}/llm-system.XXXXXX") + printf '%s' "$LLM_SYSTEM" > "$system_file" + fi + codex_args=(--messages-file "$payload_file" --model "$LLM_MODEL" --usage-file "$LLM_USAGE_FILE") + [[ -n "$system_file" ]] && codex_args+=(--system-prompt-file "$system_file") + _llm_note ok + "$SCRIPT_DIR/../tools/codex-app-server-llm" "${codex_args[@]}" + _codex_rc=$? + [[ -n "$system_file" ]] && rm -f "$system_file" + [[ "$_codex_rc" -eq 0 ]] && _llm_ledger_append + exit "$_codex_rc" +fi "build_payload_${LLM_PROVIDER}" > "$payload_file" # Get URL and headers diff --git a/bin/shellm b/bin/shellm index 11b8343c..2e908a6f 100755 --- a/bin/shellm +++ b/bin/shellm @@ -1294,6 +1294,7 @@ call_llm() { local -a args=(-m "$SHELLM_MODEL" --effort "$SHELLM_EFFORT" --thinking --system-prompt "$system_prompt" -M "$messages_json") + [[ -n "${SHELLM_PROVIDER:-}" ]] && args+=(--provider "$SHELLM_PROVIDER") [[ -n "$SHELLM_MAX_TOKENS" ]] && args+=(-t "$SHELLM_MAX_TOKENS") local text_file stderr_file diff --git a/docs/shellm.md b/docs/shellm.md index d8a73f37..5c4f1fcb 100644 --- a/docs/shellm.md +++ b/docs/shellm.md @@ -269,7 +269,7 @@ llm -m openai/gpt-oss-120b "what wakes you up in the morning?" llm -m claude-opus-4-7 -M '[{"role":"user","content":"hi"},{"role":"assistant","content":"hello!"},{"role":"user","content":"what did I just say?"}]' ``` -**Provider auto-detection:** +**Provider selection:** | Model pattern | Provider | |---|---| @@ -278,6 +278,16 @@ llm -m claude-opus-4-7 -M '[{"role":"user","content":"hi"},{"role":"assistant"," | `gemini-*` | Gemini (`GEMINI_API_KEY`) | | `vendor/model` (any slash) | OpenRouter (`OPENROUTER_API_KEY`) | +For ChatGPT-managed Codex authentication, select the official Codex app-server +explicitly. Headlong does not read or copy OAuth tokens: + +```bash +SHELLM_PROVIDER=codex SHELLM_MODEL=gpt-5.6-luna shellm "say OK" +``` + +The Codex CLI must already be signed in with `codex login`. The adapter keeps +the API-key providers available and does not send their keys to Codex. + **Output contract:** stdout = text response, stderr = thinking tokens (Anthropic only), exit 0 = success. This makes it composable with pipes and subshells. ## mem and skills @@ -351,6 +361,14 @@ SHELLM_MODEL=claude-opus-4-7-20250715 SHELLM_MAX_ITERATIONS=10 ``` +For a Codex-backed configuration, use this instead: + +```bash +SHELLM_PROVIDER=codex +SHELLM_MODEL=gpt-5.6-luna +SHELLM_MAX_ITERATIONS=10 +``` + ## Prerequisites **Required:** diff --git a/tests/test_codex_provider.sh b/tests/test_codex_provider.sh new file mode 100755 index 00000000..771948bb --- /dev/null +++ b/tests/test_codex_provider.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Verify the Codex provider speaks the app-server JSONL protocol without an API key. + +set -uo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO="$(dirname "$HERE")" +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +cat > "$WORK/codex" <<'MOCK' +#!/usr/bin/env python3 +import json +import sys + +for line in sys.stdin: + request = json.loads(line) + if request.get("id") == 1: + print(json.dumps({"id": 1, "result": {}}), flush=True) + elif request.get("id") == 2: + print(json.dumps({"id": 2, "result": {"thread": {"id": "test-thread"}}}), flush=True) + elif request.get("id") == 3: + print(json.dumps({"method": "item/agentMessage/delta", "params": {"delta": "subscription reply"}}), flush=True) + print(json.dumps({"method": "turn/completed", "params": {"turn": {"status": "completed"}}}), flush=True) +MOCK +chmod +x "$WORK/codex" + +export HEADLONG_HOME="$WORK/home" +export CODEX_APP_SERVER_BIN="$WORK/codex" +mkdir -p "$HEADLONG_HOME" + +output=$(env -u OPENAI_API_KEY "$REPO/bin/llm" --provider codex -m gpt-5.6-luna 'test prompt') +test "$output" = 'subscription reply' || { echo "unexpected output: $output" >&2; exit 1; } +echo 'ok codex provider uses app-server and does not require OPENAI_API_KEY' diff --git a/tools/codex-app-server-llm b/tools/codex-app-server-llm new file mode 100755 index 00000000..64c5356c --- /dev/null +++ b/tools/codex-app-server-llm @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Small Headlong adapter for the official Codex app-server protocol.""" + +import argparse +import json +import os +import subprocess +import sys + + +def send(proc, method, request_id, params=None): + message = {"method": method} + if request_id is not None: + message["id"] = request_id + if params is not None: + message["params"] = params + proc.stdin.write((json.dumps(message) + "\n").encode()) + proc.stdin.flush() + + +def fail(message): + print(f"codex provider: {message}", file=sys.stderr) + return 1 + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--messages-file", required=True) + parser.add_argument("--system-prompt-file") + parser.add_argument("--model", required=True) + parser.add_argument("--usage-file") + args = parser.parse_args() + + try: + messages = json.load(open(args.messages_file, encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return fail(f"cannot read messages: {exc}") + + system = "" + if args.system_prompt_file: + try: + system = open(args.system_prompt_file, encoding="utf-8").read() + except OSError as exc: + return fail(f"cannot read system prompt: {exc}") + + prompt_parts = [] + for message in messages: + role = message.get("role", "user") + content = message.get("content", "") + if isinstance(content, list): + content = "\n".join( + part.get("text", "") if isinstance(part, dict) else str(part) + for part in content + ) + prompt_parts.append(f"[{role}]\n{content}") + prompt = "\n\n".join(prompt_parts) + + command = os.environ.get("CODEX_APP_SERVER_BIN", "codex") + try: + proc = subprocess.Popen( + [command, "app-server", "--stdio"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + except OSError as exc: + return fail(f"cannot start {command}: {exc}") + + try: + send(proc, "initialize", 1, {"clientInfo": { + "name": "headlong", + "title": "Headlong", + "version": "0.1.0", + }}) + send(proc, "initialized", None) + send(proc, "thread/start", 2, { + "model": args.model, + "cwd": os.getcwd(), + "ephemeral": True, + "developerInstructions": system or None, + }) + + thread_id = None + for line in proc.stdout: + try: + message = json.loads(line) + except json.JSONDecodeError: + continue + if message.get("id") == 1 and "error" in message: + return fail(message["error"].get("message", "initialize failed")) + if message.get("id") == 2: + if "error" in message: + return fail(message["error"].get("message", "thread/start failed")) + thread_id = message.get("result", {}).get("thread", {}).get("id") + if not thread_id: + return fail("thread/start returned no thread id") + send(proc, "turn/start", 3, { + "threadId": thread_id, + "input": [{"type": "text", "text": prompt}], + "model": args.model, + "effort": os.environ.get("LLM_EFFORT") or None, + }) + continue + if message.get("method") == "item/agentMessage/delta": + delta = message.get("params", {}).get("delta", "") + if delta: + sys.stdout.write(delta) + sys.stdout.flush() + continue + if message.get("method") in { + "item/reasoningSummaryTextDelta", + "item/reasoningTextDelta", + }: + delta = message.get("params", {}).get("delta", "") + if delta: + print(delta, file=sys.stderr, end="", flush=True) + continue + if message.get("method") == "turn/completed": + turn = message.get("params", {}).get("turn", {}) + if turn.get("status") != "completed": + return fail(turn.get("error", {}).get("message", "turn failed")) + if args.usage_file: + with open(args.usage_file, "w", encoding="utf-8") as usage: + json.dump({ + "billing_source": "chatgpt_subscription", + "usage_available": False, + }, usage) + return 0 + if message.get("id") == 3 and "error" in message: + return fail(message["error"].get("message", "turn/start failed")) + if message.get("method") in { + "item/commandExecution/requestApproval", + "item/fileChange/requestApproval", + "item/permissions/requestApproval", + "item/tool/requestUserInput", + }: + return fail("interactive approval is not supported by this adapter") + finally: + try: + proc.kill() + except OSError: + pass + proc.wait(timeout=2) + + return fail("app-server exited before completing the turn") + + +if __name__ == "__main__": + raise SystemExit(main())