diff --git a/.env.example b/.env.example index df7cc386b..42e45cd16 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/src/applypilot/cli.py b/src/applypilot/cli.py index 6c8be9128..99955f4b3 100644 --- a/src/applypilot/cli.py +++ b/src/applypilot/cli.py @@ -382,6 +382,8 @@ 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") @@ -389,6 +391,12 @@ def doctor() -> None: 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: diff --git a/src/applypilot/config.py b/src/applypilot/config.py index 8c3978073..13634bd40 100644 --- a/src/applypilot/config.py +++ b/src/applypilot/config.py @@ -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 @@ -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"): diff --git a/src/applypilot/llm.py b/src/applypilot/llm.py index 1fb7be647..5c5d7b8b3 100644 --- a/src/applypilot/llm.py +++ b/src/applypilot/llm.py @@ -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 @@ -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", "") @@ -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("/"), @@ -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." ) @@ -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: @@ -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 -------------------------------------------------- @@ -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( @@ -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: @@ -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")