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
12 changes: 10 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()

use_claude_code_llm = os.environ.get("LLM_PROVIDER", "").strip().lower() == "claude_code"
has_llm = any(os.environ.get(k) for k in ("GEMINI_API_KEY", "OPENAI_API_KEY", "LLM_URL"))
if use_claude_code_llm and shutil.which("claude") is not None:
has_llm = True
if not has_llm:
return 1

Expand Down Expand Up @@ -238,8 +241,13 @@ 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")):
missing.append("LLM API key — run [bold]applypilot init[/bold] or set GEMINI_API_KEY")
use_claude_code_llm = os.environ.get("LLM_PROVIDER", "").strip().lower() == "claude_code"
has_llm = any(os.environ.get(k) for k in ("GEMINI_API_KEY", "OPENAI_API_KEY", "LLM_URL"))
if use_claude_code_llm and shutil.which("claude") is not None:
has_llm = True

if required >= 2 and not has_llm:
missing.append("LLM backend — run [bold]applypilot init[/bold], set GEMINI_API_KEY, or set LLM_PROVIDER=claude_code")
if required >= 3:
if not shutil.which("claude"):
missing.append("Claude Code CLI — install from [bold]https://claude.ai/code[/bold]")
Expand Down
76 changes: 75 additions & 1 deletion src/applypilot/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Unified LLM client for ApplyPilot.

Auto-detects provider from environment:
LLM_PROVIDER=claude_code -> Claude Code CLI (no API key required)
GEMINI_API_KEY -> Google Gemini (default: gemini-2.0-flash)
OPENAI_API_KEY -> OpenAI (default: gpt-4o-mini)
LLM_URL -> Local llama.cpp / Ollama compatible endpoint
Expand All @@ -11,6 +12,8 @@

import logging
import os
import shutil
import subprocess
import time

import httpx
Expand All @@ -31,6 +34,19 @@ def _detect_provider() -> tuple[str, str, str]:
openai_key = os.environ.get("OPENAI_API_KEY", "")
local_url = os.environ.get("LLM_URL", "")
model_override = os.environ.get("LLM_MODEL", "")
provider_override = os.environ.get("LLM_PROVIDER", "").strip().lower()

if provider_override == "claude_code":
if shutil.which("claude") is None:
raise RuntimeError(
"LLM_PROVIDER=claude_code is set, but Claude Code CLI is not on PATH. "
"Install from https://claude.ai/code"
)
return (
"claude-code-cli",
model_override or "haiku",
"",
)

if gemini_key and not local_url:
return (
Expand All @@ -55,7 +71,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 LLM_PROVIDER=claude_code, GEMINI_API_KEY, OPENAI_API_KEY, or LLM_URL in your environment."
)


Expand All @@ -69,6 +85,7 @@ def _detect_provider() -> tuple[str, str, str]:
# Base wait on first 429/503 (doubles each retry, caps at 60s).
# Gemini free tier is 15 RPM = 4s minimum between requests; 10s gives headroom.
_RATE_LIMIT_BASE_WAIT = 10
_CLAUDE_CLI_TIMEOUT = 300


_GEMINI_COMPAT_BASE = "https://generativelanguage.googleapis.com/v1beta/openai"
Expand All @@ -92,6 +109,60 @@ 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_claude_code: bool = base_url == "claude-code-cli"

# -- Claude Code CLI ----------------------------------------------------

@staticmethod
def _messages_to_prompt(messages: list[dict]) -> str:
"""Convert OpenAI-style messages into one deterministic text prompt."""
parts: list[str] = []
for msg in messages:
role = msg.get("role", "user").upper()
content = msg.get("content", "")
parts.append(f"{role}:\n{content}\n")
parts.append("ASSISTANT:\n")
return "\n".join(parts)

def _chat_claude_code(
self,
messages: list[dict],
) -> str:
"""Call Claude Code CLI as the LLM backend for scoring/tailoring stages."""
prompt = self._messages_to_prompt(messages)
cmd = [
"claude",
"--print",
"--model",
self.model,
"--output-format",
"text",
"--input-format",
"text",
"--permission-mode",
"bypassPermissions",
"--no-session-persistence",
"--tools",
"",
"-",
]

proc = subprocess.run(
cmd,
input=prompt,
text=True,
capture_output=True,
timeout=_CLAUDE_CLI_TIMEOUT,
check=False,
)

if proc.returncode != 0:
stderr = (proc.stderr or "").strip()
stdout = (proc.stdout or "").strip()
detail = stderr or stdout or f"exit code {proc.returncode}"
raise RuntimeError(f"Claude Code CLI failed: {detail[:500]}")

return (proc.stdout or "").strip()

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

Expand Down Expand Up @@ -201,6 +272,9 @@ def chat(

for attempt in range(_MAX_RETRIES):
try:
if self._is_claude_code:
return self._chat_claude_code(messages)

# Route to native Gemini if we've already confirmed it's needed
if self._use_native_gemini:
return self._chat_native_gemini(messages, temperature, max_tokens)
Expand Down
8 changes: 6 additions & 2 deletions src/applypilot/wizard/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,10 +245,10 @@ def _setup_ai_features() -> None:
console.print("[dim]Discovery-only mode. You can configure AI later with [bold]applypilot init[/bold].[/dim]")
return

console.print("Supported providers: [bold]Gemini[/bold] (recommended, free tier), OpenAI, local (Ollama/llama.cpp)")
console.print("Supported providers: [bold]Gemini[/bold] (recommended, free tier), OpenAI, local (Ollama/llama.cpp), [bold]Claude Code CLI[/bold]")
provider = Prompt.ask(
"Provider",
choices=["gemini", "openai", "local"],
choices=["gemini", "openai", "local", "claude_code"],
default="gemini",
)

Expand All @@ -269,6 +269,10 @@ def _setup_ai_features() -> None:
model = Prompt.ask("Model name", default="local-model")
env_lines.append(f"LLM_URL={url}")
env_lines.append(f"LLM_MODEL={model}")
elif provider == "claude_code":
model = Prompt.ask("Claude model alias", default="haiku")
env_lines.append("LLM_PROVIDER=claude_code")
env_lines.append(f"LLM_MODEL={model}")

env_lines.append("")
ENV_PATH.write_text("\n".join(env_lines), encoding="utf-8")
Expand Down