Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
21 changes: 20 additions & 1 deletion bin/llm
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions bin/shellm
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion docs/shellm.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|
Expand All @@ -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
Expand Down Expand Up @@ -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:**
Expand Down
34 changes: 34 additions & 0 deletions tests/test_codex_provider.sh
Original file line number Diff line number Diff line change
@@ -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'
149 changes: 149 additions & 0 deletions tools/codex-app-server-llm
Original file line number Diff line number Diff line change
@@ -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())