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
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@
# Copy to ~/.applypilot/.env and fill in your values.

# LLM Provider (pick one)
GEMINI_API_KEY= # Gemini 2.0 Flash (recommended, cheapest)
# GEMINI_API_KEY= # Gemini 2.0 Flash (recommended, cheapest)
# OPENAI_API_KEY= # OpenAI (GPT-4o-mini)
ANTHROPIC_API_KEY= # Anthropic Claude (Haiku 4.5)
# USE_CLAUDE_CLI=true # Claude via the `claude` CLI (uses your Claude
# subscription instead of a metered API key;
# requires `claude` on PATH and already logged in)
# LLM_URL=http://127.0.0.1:8080/v1 # Local LLM (llama.cpp, Ollama)
# LLM_MODEL= # Override model name

Expand Down
8 changes: 8 additions & 0 deletions src/applypilot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,13 +382,21 @@ def doctor() -> None:
import os
has_gemini = bool(os.environ.get("GEMINI_API_KEY"))
has_openai = bool(os.environ.get("OPENAI_API_KEY"))
has_claude_cli = bool(os.environ.get("USE_CLAUDE_CLI"))
has_anthropic = bool(os.environ.get("ANTHROPIC_API_KEY"))
has_local = bool(os.environ.get("LLM_URL"))
if has_gemini:
model = os.environ.get("LLM_MODEL", "gemini-2.0-flash")
results.append(("LLM API key", ok_mark, f"Gemini ({model})"))
elif has_openai:
model = os.environ.get("LLM_MODEL", "gpt-4o-mini")
results.append(("LLM API key", ok_mark, f"OpenAI ({model})"))
elif has_claude_cli:
model = os.environ.get("LLM_MODEL", "haiku")
results.append(("LLM API key", ok_mark, f"Claude CLI ({model})"))
elif has_anthropic:
model = os.environ.get("LLM_MODEL", "claude-haiku-4-5")
results.append(("LLM API key", ok_mark, f"Anthropic ({model})"))
elif has_local:
results.append(("LLM API key", ok_mark, f"Local: {os.environ.get('LLM_URL')}"))
else:
Expand Down
10 changes: 8 additions & 2 deletions src/applypilot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,10 @@ def get_tier() -> int:
"""
load_env()

has_llm = any(os.environ.get(k) for k in ("GEMINI_API_KEY", "OPENAI_API_KEY", "LLM_URL"))
has_llm = any(
os.environ.get(k)
for k in ("GEMINI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "USE_CLAUDE_CLI", "LLM_URL")
)
if not has_llm:
return 1

Expand Down Expand Up @@ -238,7 +241,10 @@ def check_tier(required: int, feature: str) -> None:
_console = Console(stderr=True)

missing: list[str] = []
if required >= 2 and not any(os.environ.get(k) for k in ("GEMINI_API_KEY", "OPENAI_API_KEY", "LLM_URL")):
if required >= 2 and not any(
os.environ.get(k)
for k in ("GEMINI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "USE_CLAUDE_CLI", "LLM_URL")
):
missing.append("LLM API key — run [bold]applypilot init[/bold] or set GEMINI_API_KEY")
if required >= 3:
if not shutil.which("claude"):
Expand Down
118 changes: 116 additions & 2 deletions src/applypilot/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@
Auto-detects provider from environment:
GEMINI_API_KEY -> Google Gemini (default: gemini-2.0-flash)
OPENAI_API_KEY -> OpenAI (default: gpt-4o-mini)
ANTHROPIC_API_KEY -> Anthropic Claude (default: claude-haiku-4-5)
USE_CLAUDE_CLI -> Claude via the `claude` CLI (your Claude subscription,
not a metered API key). Requires `claude` on PATH.
LLM_URL -> Local llama.cpp / Ollama compatible endpoint

LLM_MODEL env var overrides the model name for any provider.
"""

import json
import logging
import os
import subprocess
import time

import httpx
Expand All @@ -29,6 +34,8 @@ def _detect_provider() -> tuple[str, str, str]:
"""
gemini_key = os.environ.get("GEMINI_API_KEY", "")
openai_key = os.environ.get("OPENAI_API_KEY", "")
anthropic_key = os.environ.get("ANTHROPIC_API_KEY", "")
use_claude_cli = os.environ.get("USE_CLAUDE_CLI", "")
local_url = os.environ.get("LLM_URL", "")
model_override = os.environ.get("LLM_MODEL", "")

Expand All @@ -46,6 +53,20 @@ def _detect_provider() -> tuple[str, str, str]:
openai_key,
)

if use_claude_cli and not local_url:
return (
_CLAUDE_CLI_MARKER,
model_override or "haiku",
"",
)

if anthropic_key and not local_url:
return (
_ANTHROPIC_BASE,
model_override or "claude-haiku-4-5",
anthropic_key,
)

if local_url:
return (
local_url.rstrip("/"),
Expand All @@ -55,7 +76,7 @@ def _detect_provider() -> tuple[str, str, str]:

raise RuntimeError(
"No LLM provider configured. "
"Set GEMINI_API_KEY, OPENAI_API_KEY, or LLM_URL in your environment."
"Set GEMINI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, or LLM_URL in your environment."
)


Expand All @@ -73,6 +94,9 @@ def _detect_provider() -> tuple[str, str, str]:

_GEMINI_COMPAT_BASE = "https://generativelanguage.googleapis.com/v1beta/openai"
_GEMINI_NATIVE_BASE = "https://generativelanguage.googleapis.com/v1beta"
_ANTHROPIC_BASE = "https://api.anthropic.com/v1"
_CLAUDE_CLI_MARKER = "claude-cli" # not a real URL — signals the subprocess path
_CLAUDE_CLI_TIMEOUT = 120 # seconds


class LLMClient:
Expand All @@ -92,6 +116,8 @@ def __init__(self, base_url: str, model: str, api_key: str) -> None:
# True once we've confirmed the native Gemini API works for this model
self._use_native_gemini: bool = False
self._is_gemini: bool = base_url.startswith(_GEMINI_COMPAT_BASE)
self._is_anthropic: bool = base_url == _ANTHROPIC_BASE
self._is_claude_cli: bool = base_url == _CLAUDE_CLI_MARKER

# -- Native Gemini API --------------------------------------------------

Expand Down Expand Up @@ -144,6 +170,88 @@ def _chat_native_gemini(
data = resp.json()
return data["candidates"][0]["content"]["parts"][0]["text"]

# -- Anthropic Messages API ----------------------------------------------

def _chat_anthropic(
self,
messages: list[dict],
temperature: float,
max_tokens: int,
) -> str:
"""Call the Anthropic Messages API.

Anthropic takes `system` as a top-level field rather than a message
role, so split it out of the OpenAI-style messages list.
"""
system_text = "\n".join(msg.get("content", "") for msg in messages if msg["role"] == "system")
turns = [msg for msg in messages if msg["role"] != "system"]

payload: dict = {
"model": self.model,
"max_tokens": max_tokens,
"temperature": temperature,
"messages": turns,
}
if system_text:
payload["system"] = system_text

resp = self._client.post(
f"{self.base_url}/messages",
json=payload,
headers={
"Content-Type": "application/json",
"x-api-key": self.api_key,
"anthropic-version": "2023-06-01",
},
)
resp.raise_for_status()
data = resp.json()
return data["content"][0]["text"]

# -- Claude CLI (subscription, not a metered API key) --------------------

def _chat_claude_cli(
self,
messages: list[dict],
temperature: float,
max_tokens: int,
) -> str:
"""Call the `claude` CLI in print mode.

Uses whatever the `claude` CLI is already logged into (a Claude
subscription), instead of a separate metered ANTHROPIC_API_KEY.
`claude -p` takes a single prompt string, not a chat history, so
multi-turn messages are flattened (this app's LLM calls are all
single-turn). `--disallowedTools *` keeps this a pure text
completion — no file/bash access.
"""
system_text = "\n".join(msg.get("content", "") for msg in messages if msg["role"] == "system")
turns = [msg for msg in messages if msg["role"] != "system"]
prompt = (
turns[-1]["content"]
if len(turns) == 1
else "\n\n".join(f"{msg['role']}: {msg['content']}" for msg in turns)
)

cmd = ["claude", "-p", "--model", self.model, "--output-format", "json", "--disallowedTools", "*"]
if system_text:
cmd += ["--system-prompt", system_text]
cmd.append(prompt)

result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=_CLAUDE_CLI_TIMEOUT,
check=False,
)
if result.returncode != 0:
raise RuntimeError(f"claude CLI exited {result.returncode}: {result.stderr[:300]}")
data = json.loads(result.stdout)
if data.get("is_error"):
raise RuntimeError(f"claude CLI error: {data.get('result', 'unknown')}")
return data["result"]

# -- OpenAI-compat API --------------------------------------------------

def _chat_compat(
Expand Down Expand Up @@ -205,6 +313,12 @@ def chat(
if self._use_native_gemini:
return self._chat_native_gemini(messages, temperature, max_tokens)

if self._is_anthropic:
return self._chat_anthropic(messages, temperature, max_tokens)

if self._is_claude_cli:
return self._chat_claude_cli(messages, temperature, max_tokens)

return self._chat_compat(messages, temperature, max_tokens)

except _GeminiCompatForbidden as exc:
Expand All @@ -228,7 +342,7 @@ def chat(

except httpx.HTTPStatusError as exc:
resp = exc.response
if resp.status_code in (429, 503) and attempt < _MAX_RETRIES - 1:
if resp.status_code in (429, 503, 529) and attempt < _MAX_RETRIES - 1:
# Respect Retry-After header if provided (Gemini sends this).
retry_after = (
resp.headers.get("Retry-After")
Expand Down