diff --git a/.env.example b/.env.example index cae00ffb..f15d25af 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,41 @@ +# ----------------------------------------------------------------------------- # LLM Provider Configuration -# Options: "ollama" or "gemini" -LLM_PROVIDER=ollama +# Options: "ollama" (default), "gemini", "claude_code", or "codex" +# ----------------------------------------------------------------------------- -# Default model to use -# For Ollama: "gemma3:4b", "qwen3:4b", "mistral:7b", etc. -# For Gemini: "gemini-2.5-pro", "gemini-2.5-flash", etc. +# Ollama mode - fully local models. Requires `ollama serve` and a pulled model. +LLM_PROVIDER=ollama DEFAULT_MODEL=gemma3:4b -# Google Gemini API Key (required if using Gemini provider) -GEMINI_API_KEY=your_gemini_api_key_here +# ----------------------------------------------------------------------------- +# Alternative: Claude Code CLI (no API key - uses your authenticated session) +# ----------------------------------------------------------------------------- +# LLM_PROVIDER=claude_code +# DEFAULT_MODEL=claude-code + +# Optional: command or full path to the Claude Code CLI. +# CLAUDE_CODE_COMMAND=claude + +# Optional: per-call timeout in seconds. +# CLAUDE_CODE_TIMEOUT_SECONDS=300 + +# Optional: pin a specific model / alias for the nested calls, e.g. "sonnet", +# "opus", or a full id like "claude-sonnet-4-6". Leave unset to use your Claude +# Code session's default model. A cheaper/faster alias lowers cost per resume. +# CLAUDE_CODE_MODEL=sonnet + +# ----------------------------------------------------------------------------- +# Alternative: Codex CLI (no API key - uses your authenticated Codex session) +# ----------------------------------------------------------------------------- +# LLM_PROVIDER=codex +# DEFAULT_MODEL=codex-cli +# CODEX_COMMAND=codex +# CODEX_TIMEOUT_SECONDS=300 +# CODEX_MODEL= + +# ----------------------------------------------------------------------------- +# Alternative: Google Gemini (requires an API key) +# ----------------------------------------------------------------------------- +# LLM_PROVIDER=gemini +# DEFAULT_MODEL=gemini-2.5-flash +# GEMINI_API_KEY=your_gemini_api_key_here diff --git a/.gitignore b/.gitignore index a2e75f9e..9bb704b9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ resume/*.pdf run/*.pdf test_*.py +!tests/ +!tests/test_*.py cache/ resume_evaluations.csv greenhouse_resumes/* diff --git a/README.md b/README.md index 0396ef45..cab5f39f 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ ## Overview -Hiring Agent parses a resume PDF to Markdown, extracts sectioned JSON using a local or hosted LLM, augments the data with GitHub profile and repository signals, then produces an objective evaluation with category scores, evidence, bonus points, and deductions. You can run fully local with Ollama or use Google Gemini. +Hiring Agent parses a resume PDF to Markdown, extracts sectioned JSON using a local or hosted LLM, augments the data with GitHub profile and repository signals, then produces an objective evaluation with category scores, evidence, bonus points, and deductions. It defaults to fully local **Ollama** and can also run with **Google Gemini** or, with no API key, through an already-authenticated local **Claude Code CLI** or **Codex CLI**. --- @@ -85,11 +85,16 @@ Hiring Agent parses a resume PDF to Markdown, extracts sectioned JSON using a lo The repository pins `.python-version` to 3.11.13. -- **One LLM backend** (either of them) +- **One LLM backend** (any one of them) - **Ollama** for local models Install from the [official site](https://ollama.com/), then run `ollama serve`. - **Google Gemini** if you have an API key, get it from [here](https://aistudio.google.com/api-keys). + - **Claude Code CLI** (no API key) - uses your existing Claude Code + subscription / session. Install from [claude.com/claude-code](https://claude.com/claude-code), + then run `claude` once and sign in. Verify with `claude --version`. + - **Codex CLI** (no API key) - uses your existing Codex session. Verify with + `codex --version` and sign in with `codex login` if needed. ### Quick setup with pip @@ -136,12 +141,18 @@ $ cp .env.example .env **Environment variables** -| Variable | Values | Description | -| ---------------- | ------------------------------------------- | ---------------------------------------------------------------------- | -| `LLM_PROVIDER` | `ollama` or `gemini` | Chooses provider. Defaults to Ollama. | -| `DEFAULT_MODEL` | for example `gemma3:4b` or `gemini-2.5-pro` | Model name passed to the provider. | -| `GEMINI_API_KEY` | string | Required when `LLM_PROVIDER=gemini`. | -| `GITHUB_TOKEN` | optional | Inherits from your shell environment, improves GitHub API rate limits. | +| Variable | Values | Description | +| ----------------------------- | ----------------------------------------------------- | --------------------------------------------------------------------------- | +| `LLM_PROVIDER` | `ollama`, `gemini`, `claude_code`, or `codex` | Chooses the provider. CLI providers are forced when set, regardless of model. | +| `DEFAULT_MODEL` | e.g. `gemma3:4b`, `gemini-2.5-pro`, `claude-code`, or `codex-cli` | Model name passed to the provider. Use `claude-code` or `codex-cli` for CLI mode. | +| `CLAUDE_CODE_COMMAND` | optional, default `claude` | Command or full path to the Claude Code CLI. | +| `CLAUDE_CODE_TIMEOUT_SECONDS` | optional, default `300` | Per-call timeout for the Claude Code CLI. | +| `CLAUDE_CODE_MODEL` | optional, e.g. `sonnet` | Pin a specific model for nested calls. Empty = your session default. | +| `CODEX_COMMAND` | optional, default `codex` | Command or full path to the Codex CLI. | +| `CODEX_TIMEOUT_SECONDS` | optional, default `300` | Per-call timeout for the Codex CLI. | +| `CODEX_MODEL` | optional | Pin a specific Codex model for nested calls. Empty = Codex CLI default. | +| `GEMINI_API_KEY` | string | Required when `LLM_PROVIDER=gemini`. | +| `GITHUB_TOKEN` | optional | Inherits from your shell environment, improves GitHub API rate limits. | Provider mapping lives in `prompt.py` and `models.py`. The `config.py` file has a single flag: @@ -209,6 +220,61 @@ Provide a path to a resume PDF. $ python score.py /path/to/resume.pdf ``` +> On systems where `python` is not on `PATH`, use `python3` instead: +> +> ```bash +> $ python3 score.py /path/to/resume.pdf +> ``` + +#### Run with Claude Code (no API key) + +Set these values in `.env`: + +```bash +LLM_PROVIDER=claude_code +DEFAULT_MODEL=claude-code +CLAUDE_CODE_COMMAND=claude +CLAUDE_CODE_TIMEOUT_SECONDS=300 +``` + +Then run: + +```bash +$ claude --version # confirm the CLI is installed and signed in +$ python3 score.py /path/to/resume.pdf +``` + +Each pipeline run makes **multiple** LLM calls per resume (one per resume +section, GitHub project selection, and the final evaluation), and every call +shells out to `claude`. Expect it to be **slower** than Ollama and to consume +Claude Code usage accordingly. Pin a cheaper model with `CLAUDE_CODE_MODEL` +(for example `sonnet`) to reduce cost. + +#### Run with Codex (no API key) + +Set these values in `.env`: + +```bash +LLM_PROVIDER=codex +DEFAULT_MODEL=codex-cli +CODEX_COMMAND=codex +CODEX_TIMEOUT_SECONDS=300 +# CODEX_MODEL= +``` + +Then run: + +```bash +$ codex --version # confirm the CLI is installed +$ codex login # sign in if needed +$ python3 score.py /path/to/resume.pdf +``` + +Codex mode uses the same shared CLI provider wrapper as Claude Code mode, but +builds Codex-specific commands with `codex exec`, `--ephemeral`, read-only +sandboxing, and `--output-schema` for structured calls. Leave `CODEX_MODEL` +unset to use the Codex CLI's configured default model. + What happens: 1. If development mode is on, the PDF extraction result is cached to `cache/resumecache_.json`. @@ -253,6 +319,35 @@ What happens: ## Provider details +### Claude Code (no API key) + +- Set `LLM_PROVIDER=claude_code` and `DEFAULT_MODEL=claude-code` +- Requires the `claude` CLI installed and authenticated; no `ANTHROPIC_API_KEY` + or any other key is used +- `models.CliLLMProvider` shells out to `claude -p` in non-interactive print + mode with all built-in tools disabled (`--tools ""`) and project config + isolated (`--safe-mode`), so the nested Claude only answers the prompt; it + never edits files, runs commands, or acts as a recursive coding agent +- System messages are passed through `--system-prompt`; JSON schemas + (`kwargs["format"]`) are passed through Claude Code's native `--json-schema` + flag; the CLI's stdout is adapted to the unified + `{"message": {"content": ...}}` shape +- Tune with `CLAUDE_CODE_COMMAND`, `CLAUDE_CODE_TIMEOUT_SECONDS`, and the + optional `CLAUDE_CODE_MODEL` + +### Codex CLI (no API key) + +- Set `LLM_PROVIDER=codex` and `DEFAULT_MODEL=codex-cli` +- Requires the `codex` CLI installed and authenticated with `codex login`; no + separate API key is required +- `models.CliLLMProvider` shells out to `codex exec` with `--ephemeral`, + `--ignore-rules`, `--ignore-user-config`, and read-only sandboxing, so each + nested call behaves like a constrained text-generation step +- JSON schemas (`kwargs["format"]`) are written to a temporary schema file and + passed through Codex's native `--output-schema` flag +- Tune with `CODEX_COMMAND`, `CODEX_TIMEOUT_SECONDS`, and the optional + `CODEX_MODEL` + ### Ollama - Set `LLM_PROVIDER=ollama` diff --git a/github.py b/github.py index 0e195685..cce71f56 100644 --- a/github.py +++ b/github.py @@ -363,7 +363,7 @@ def generate_projects_json(projects: List[Dict]) -> List[Dict]: ) print( - f"🤖 Using LLM to select top 5 projects from {len(projects)} repositories..." + f"🤖 Using LLM to select top 7 projects from {len(projects)} repositories..." ) # Initialize the LLM provider diff --git a/llm_utils.py b/llm_utils.py index 7e1d96d3..d3e7d5fd 100644 --- a/llm_utils.py +++ b/llm_utils.py @@ -4,8 +4,23 @@ import logging from typing import Any, Dict, Optional -from models import ModelProvider, OllamaProvider, GeminiProvider -from prompt import MODEL_PROVIDER_MAPPING, GEMINI_API_KEY +from models import ( + ModelProvider, + OllamaProvider, + GeminiProvider, + CliLLMProvider, +) +from prompt import ( + MODEL_PROVIDER_MAPPING, + GEMINI_API_KEY, + PROVIDER, + CLAUDE_CODE_COMMAND, + CLAUDE_CODE_MODEL, + CLAUDE_CODE_TIMEOUT_SECONDS, + CODEX_COMMAND, + CODEX_MODEL, + CODEX_TIMEOUT_SECONDS, +) logger = logging.getLogger(__name__) @@ -37,26 +52,70 @@ def extract_json_from_response(response_text: str) -> str: return response_text +CLI_PROVIDER_SETTINGS = { + ModelProvider.CLAUDE_CODE.value: { + "command": CLAUDE_CODE_COMMAND, + "timeout": CLAUDE_CODE_TIMEOUT_SECONDS, + "model": CLAUDE_CODE_MODEL, + "label": "Claude Code CLI", + }, + ModelProvider.CODEX.value: { + "command": CODEX_COMMAND, + "timeout": CODEX_TIMEOUT_SECONDS, + "model": CODEX_MODEL, + "label": "Codex CLI", + }, +} + + +def _build_cli_provider(provider_name: str) -> Any: + """Construct a shared CLI provider from environment configuration.""" + settings = CLI_PROVIDER_SETTINGS[provider_name] + logger.info(f"Using {settings['label']} provider (local authenticated session)") + return CliLLMProvider( + backend=provider_name, + command=settings["command"], + timeout=settings["timeout"], + model=settings["model"], + ) + + def initialize_llm_provider(model_name: str) -> Any: """ - Initialize the appropriate LLM provider based on the model name. + Initialize the appropriate LLM provider. + + Selection order: + 1. An explicit CLI provider (``LLM_PROVIDER=claude_code`` or + ``LLM_PROVIDER=codex``) wins regardless of the model name. (Previously + provider selection was driven solely by the model mapping, so + LLM_PROVIDER was ignored.) + 2. Otherwise the provider is inferred from ``MODEL_PROVIDER_MAPPING``: + Gemini (requires ``GEMINI_API_KEY``, else falls back to Ollama), + Claude Code, or Ollama (the default fallback). Args: - model_name: The name of the model to use + model_name: The name of the model to use. Returns: - An initialized LLM provider (either OllamaProvider or GeminiProvider) + An initialized LLM provider implementing the ``chat`` interface. """ - # Default to Ollama provider - provider = OllamaProvider() - # If using Gemini and API key is available, use Gemini provider + # 1. Explicit provider selection wins; this is what makes CLI-backed + # providers take effect even when DEFAULT_MODEL is just a logical label. + if PROVIDER in CLI_PROVIDER_SETTINGS: + return _build_cli_provider(PROVIDER) + + # 2. Infer the provider from the model mapping (legacy behavior). model_provider = MODEL_PROVIDER_MAPPING.get(model_name, ModelProvider.OLLAMA) + + if model_provider.value in CLI_PROVIDER_SETTINGS: + return _build_cli_provider(model_provider.value) + if model_provider == ModelProvider.GEMINI: if not GEMINI_API_KEY: - logger.warning("⚠️ Gemini API key not found. Falling back to Ollama.") - else: - logger.info(f"🔄 Using Google Gemini API provider with model {model_name}") - provider = GeminiProvider(api_key=GEMINI_API_KEY) - else: - logger.info(f"🔄 Using Ollama provider with model {model_name}") - return provider + logger.warning("Gemini API key not found. Falling back to Ollama.") + return OllamaProvider() + logger.info(f"Using Google Gemini API provider with model {model_name}") + return GeminiProvider(api_key=GEMINI_API_KEY) + + logger.info(f"Using Ollama provider with model {model_name}") + return OllamaProvider() diff --git a/models.py b/models.py index e7146005..289677ae 100644 --- a/models.py +++ b/models.py @@ -1,3 +1,8 @@ +import json +import os +import shutil +import subprocess +import tempfile from typing import List, Optional, Dict, Tuple, Any, Protocol, runtime_checkable from pydantic import BaseModel, Field, field_validator from enum import Enum @@ -8,6 +13,8 @@ class ModelProvider(Enum): OLLAMA = "ollama" GEMINI = "gemini" + CLAUDE_CODE = "claude_code" + CODEX = "codex" @runtime_checkable @@ -19,7 +26,7 @@ def chat( model: str, messages: List[Dict[str, str]], options: Dict[str, Any] = None, - **kwargs + **kwargs, ) -> Dict[str, Any]: """Send a chat request to the LLM provider.""" ... @@ -281,7 +288,7 @@ def chat( model: str, messages: List[Dict[str, str]], options: Dict[str, Any] = None, - **kwargs + **kwargs, ) -> Dict[str, Any]: """Send a chat request to Ollama.""" @@ -324,7 +331,7 @@ def chat( model: str, messages: List[Dict[str, str]], options: Dict[str, Any] = None, - **kwargs + **kwargs, ) -> Dict[str, Any]: """Send a chat request to Google Gemini API.""" import re @@ -375,7 +382,7 @@ def chat( api_hint = float(match.group(1)) if match else None # Exponential backoff: BASE_DELAY * 2^attempt, capped at MAX_DELAY - exp_delay = min(BASE_DELAY * (2 ** attempt), MAX_DELAY) + exp_delay = min(BASE_DELAY * (2**attempt), MAX_DELAY) # Prefer the API hint when it is shorter than our computed delay delay = api_hint if (api_hint and api_hint < exp_delay) else exp_delay @@ -389,3 +396,316 @@ def chat( f"Retrying in {sleep_time}s..." ) time.sleep(sleep_time) + + +class CliLLMProvider: + """LLM provider that routes chat requests through authenticated local CLIs. + + This lets the pipeline run using an already-authenticated local agent CLI + session instead of Ollama, Gemini, or any API key. Backend-specific command + shapes are kept in this class so Claude Code and Codex share the same + prompt/schema/subprocess wrapper. + + Responses are wrapped to match Ollama's response shape so every existing + consumer keeps working unchanged:: + + {"message": {"content": "...model output..."}} + """ + + BACKENDS = { + ModelProvider.CLAUDE_CODE.value: { + "label": "Claude Code CLI", + "default_command": "claude", + "missing_help": ( + "Install it and sign in - an interactive `claude` session must " + "be authenticated first (see https://claude.com/claude-code) - " + "or set CLAUDE_CODE_COMMAND to the CLI's full path." + ), + "schema_output": "json_envelope", + "supports_system_prompt": True, + }, + ModelProvider.CODEX.value: { + "label": "Codex CLI", + "default_command": "codex", + "missing_help": ( + "Install it and sign in with `codex login`, or set " + "CODEX_COMMAND to the CLI's full path." + ), + "schema_output": "raw_json", + "supports_system_prompt": False, + }, + } + + def __init__( + self, + backend: str, + command: Optional[str] = None, + timeout: int = 300, + model: Optional[str] = None, + ): + if backend not in self.BACKENDS: + supported = ", ".join(sorted(self.BACKENDS)) + raise ValueError( + f"Unsupported CLI backend '{backend}'. Use one of: {supported}" + ) + + self.backend = backend + self.backend_config = self.BACKENDS[backend] + self.command = command or self.backend_config["default_command"] + self.timeout = timeout + # Optional concrete model / alias. When None, the CLI uses its default. + self.model = model or None + + # Fail fast with a clear, actionable message if the CLI is missing. + command_path = shutil.which(self.command) + if command_path is None: + raise RuntimeError( + f"{self.backend_config['label']} '{self.command}' was not found " + f"on PATH. {self.backend_config['missing_help']}" + ) + self.command_path = os.path.abspath(command_path) + + def _build_system_prompt(self, messages: List[Dict[str, str]]) -> Optional[str]: + """Extract system messages for Claude's native system prompt flag.""" + system_parts = [ + m.get("content", "") + for m in messages + if m.get("role") == "system" and m.get("content") + ] + return "\n\n".join(system_parts) if system_parts else None + + def _format_json_schema(self, json_schema: Any = None) -> Optional[str]: + """Return a compact, CLI-compatible JSON schema string.""" + if json_schema is None: + return None + if isinstance(json_schema, str): + try: + json_schema = json.loads(json_schema) + except json.JSONDecodeError: + return json_schema + normalized_schema = self._normalize_json_schema(json_schema) + return json.dumps(normalized_schema, separators=(",", ":")) + + def _normalize_json_schema(self, node: Any) -> Any: + """Tighten Pydantic schemas for strict CLI structured-output validators.""" + if isinstance(node, list): + return [self._normalize_json_schema(item) for item in node] + if not isinstance(node, dict): + return node + + normalized = { + key: self._normalize_json_schema(value) for key, value in node.items() + } + properties = normalized.get("properties") + if isinstance(properties, dict): + normalized.setdefault("additionalProperties", False) + normalized["required"] = list(properties.keys()) + return normalized + + def _build_prompt( + self, + messages: List[Dict[str, str]], + requires_json: bool = False, + include_system_prompt: bool = False, + ) -> str: + """Flatten non-system chat messages into a single prompt. + + Agent CLIs take a single prompt on stdin. For backends without a native + system prompt flag, system messages are folded into the prompt. + """ + convo_parts = [] + if include_system_prompt: + system_prompt = self._build_system_prompt(messages) + if system_prompt: + convo_parts.append(f"System instructions:\n{system_prompt}") + + for m in messages: + role = m.get("role") + content = m.get("content", "") + if not content or role == "system": + continue + if role == "assistant": + convo_parts.append(f"Assistant (previous turn):\n{content}") + else: + convo_parts.append(content) + + sections = ["\n\n".join(convo_parts)] if convo_parts else [] + + if requires_json: + sections.append( + "Respond with a SINGLE valid JSON object and nothing else. " + "Do not add explanations, comments, or prose. " + "Do NOT wrap the JSON in Markdown code fences (no triple " + "backticks of any kind). The response must strictly conform to " + "the JSON Schema supplied to the CLI." + ) + + return "\n\n".join(sections) + + def _build_command( + self, + json_schema: Optional[str], + system_prompt: Optional[str], + temp_dir: str, + ) -> tuple[list[str], str]: + """Build backend-specific argv while sharing provider orchestration.""" + if self.backend == ModelProvider.CLAUDE_CODE.value: + output_format = "json" if json_schema else "text" + cmd = [ + self.command_path, + "-p", + "--input-format", + "text", + "--output-format", + output_format, + "--no-session-persistence", + "--safe-mode", + ] + if system_prompt: + cmd += ["--system-prompt", system_prompt] + if json_schema: + cmd += ["--json-schema", json_schema] + if self.model: + cmd += ["--model", self.model] + # Keep --tools last: "" disables all tools and is variadic-safe at + # the end of the argument list. + cmd += ["--tools", ""] + return cmd, os.path.expanduser("~") + + if self.backend == ModelProvider.CODEX.value: + cmd = [ + self.command_path, + "exec", + "--ephemeral", + "--ignore-user-config", + "--ignore-rules", + "--sandbox", + "read-only", + "--skip-git-repo-check", + "-C", + temp_dir, + ] + if json_schema: + schema_path = os.path.join(temp_dir, "output_schema.json") + with open(schema_path, "w", encoding="utf-8") as schema_file: + schema_file.write(json_schema) + cmd += ["--output-schema", schema_path] + if self.model: + cmd += ["--model", self.model] + cmd.append("-") + return cmd, temp_dir + + raise RuntimeError(f"Unsupported CLI backend '{self.backend}'") + + def _extract_structured_content(self, content: str) -> str: + """Normalize backend-specific structured-output envelopes to raw JSON.""" + if self.backend_config["schema_output"] == "json_envelope": + try: + cli_response = json.loads(content) + except json.JSONDecodeError as e: + raise RuntimeError( + f"{self.backend_config['label']} returned invalid JSON output " + "while structured output was requested.\n" + f"Stdout:\n{content}" + ) from e + + if cli_response.get("is_error"): + raise RuntimeError( + f"{self.backend_config['label']} reported an error while " + f"structured output was requested.\nResponse:\n{content}" + ) + + structured_output = cli_response.get("structured_output") + if structured_output is None: + result_text = cli_response.get("result") + if result_text: + return str(result_text).strip() + raise RuntimeError( + f"{self.backend_config['label']} did not include " + f"structured_output in its JSON response.\nResponse:\n{content}" + ) + return json.dumps(structured_output, ensure_ascii=False) + + if self.backend_config["schema_output"] == "raw_json": + try: + return json.dumps(json.loads(content), ensure_ascii=False) + except json.JSONDecodeError as e: + raise RuntimeError( + f"{self.backend_config['label']} returned invalid JSON output " + "while structured output was requested.\n" + f"Stdout:\n{content}" + ) from e + + raise RuntimeError( + f"Unsupported schema output mode '{self.backend_config['schema_output']}'" + ) + + def chat( + self, + model: str, + messages: List[Dict[str, str]], + options: Dict[str, Any] = None, + **kwargs, + ) -> Dict[str, Any]: + """Send a chat request through an authenticated local CLI. + + ``options`` (temperature / top_p / stream) is accepted for interface + parity but ignored by CLI backends. ``kwargs["format"]`` is treated as + a JSON schema and passed through each CLI's native schema mechanism. + """ + json_schema = self._format_json_schema(kwargs.get("format")) + supports_system_prompt = self.backend_config["supports_system_prompt"] + prompt_text = self._build_prompt( + messages, + requires_json=bool(json_schema), + include_system_prompt=not supports_system_prompt, + ) + system_prompt = ( + self._build_system_prompt(messages) if supports_system_prompt else None + ) + + try: + with tempfile.TemporaryDirectory( + prefix=f"{self.backend}_provider_" + ) as temp_dir: + cmd, cwd = self._build_command(json_schema, system_prompt, temp_dir) + result = subprocess.run( + cmd, + input=prompt_text, + capture_output=True, + text=True, + timeout=self.timeout, + cwd=cwd, + ) + except FileNotFoundError as e: + raise RuntimeError( + f"{self.backend_config['label']} '{self.command}' was not found. " + f"{self.backend_config['missing_help']}" + ) from e + except subprocess.TimeoutExpired as e: + raise RuntimeError( + f"{self.backend_config['label']} timed out after {self.timeout}s. " + "Increase the provider timeout if your prompts are large." + ) from e + + if result.returncode != 0: + stderr = (result.stderr or "").strip() + raise RuntimeError( + f"{self.backend_config['label']} exited with code " + f"{result.returncode}.\n" + f"Stderr:\n{stderr}" + ) + + content = (result.stdout or "").strip() + if not content: + stderr = (result.stderr or "").strip() + detail = f"\nStderr:\n{stderr}" if stderr else "" + raise RuntimeError( + f"{self.backend_config['label']} returned an empty response.{detail}" + ) + + if json_schema: + content = self._extract_structured_content(content) + + # Match Ollama's response shape so all consumers work unchanged. + return {"message": {"role": "assistant", "content": content}} diff --git a/prompt.py b/prompt.py index 108e970c..ce20760f 100644 --- a/prompt.py +++ b/prompt.py @@ -41,6 +41,10 @@ "gemini-2.5-flash-lite": {"temperature": 0.1, "top_p": 0.9}, "gemini-3.5-flash": {"temperature": 0.1, "top_p": 0.9}, "gemini-3.1-flash-lite": {"temperature": 0.1, "top_p": 0.9}, + # Claude Code CLI (sampling params are ignored by the CLI; kept for parity) + "claude-code": {"temperature": 0.1, "top_p": 0.9}, + # Codex CLI (sampling params are ignored by the CLI; kept for parity) + "codex-cli": {"temperature": 0.1, "top_p": 0.9}, } # Model provider mapping @@ -61,7 +65,39 @@ "gemini-2.5-pro": ModelProvider.GEMINI, "gemini-3.5-flash": ModelProvider.GEMINI, "gemini-3.1-flash-lite": ModelProvider.GEMINI, + # Claude Code CLI (uses your authenticated local Claude Code session) + "claude-code": ModelProvider.CLAUDE_CODE, + # Codex CLI (uses your authenticated local Codex session) + "codex-cli": ModelProvider.CODEX, } # Get API keys from environment GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "") + +# Claude Code CLI configuration (used when LLM_PROVIDER=claude_code). +# No API key is required; the CLI uses your authenticated Claude Code session. +CLAUDE_CODE_COMMAND = os.getenv("CLAUDE_CODE_COMMAND", "claude") + +# Optional concrete model / alias for nested calls (e.g. "sonnet", "opus", +# "claude-sonnet-4-6"). When empty, the CLI uses your session's default model. +CLAUDE_CODE_MODEL = os.getenv("CLAUDE_CODE_MODEL", "").strip() or None + +# Per-call timeout for the Claude Code CLI, in seconds (defaults to 300). +try: + CLAUDE_CODE_TIMEOUT_SECONDS = int(os.getenv("CLAUDE_CODE_TIMEOUT_SECONDS", "300")) +except ValueError: + CLAUDE_CODE_TIMEOUT_SECONDS = 300 + +# Codex CLI configuration (used when LLM_PROVIDER=codex). +# No API key is required; the CLI uses your authenticated Codex session. +CODEX_COMMAND = os.getenv("CODEX_COMMAND", "codex") + +# Optional concrete model / alias for nested calls. When empty, the CLI uses +# its configured default model. +CODEX_MODEL = os.getenv("CODEX_MODEL", "").strip() or None + +# Per-call timeout for the Codex CLI, in seconds (defaults to 300). +try: + CODEX_TIMEOUT_SECONDS = int(os.getenv("CODEX_TIMEOUT_SECONDS", "300")) +except ValueError: + CODEX_TIMEOUT_SECONDS = 300 diff --git a/tests/test_cli_provider_smoke.py b/tests/test_cli_provider_smoke.py new file mode 100644 index 00000000..b91b89e3 --- /dev/null +++ b/tests/test_cli_provider_smoke.py @@ -0,0 +1,69 @@ +import json +import os +import shutil +import stat +import tempfile +import unittest + +from models import CliLLMProvider, ModelProvider + + +class CliLLMProviderSmokeTests(unittest.TestCase): + def _fake_cli(self, stdout: str) -> str: + temp_dir = tempfile.mkdtemp(prefix="cli_provider_smoke_") + script_path = os.path.join(temp_dir, "fake_cli") + with open(script_path, "w", encoding="utf-8") as script: + script.write("#!/bin/sh\n") + script.write("cat >/dev/null\n") + script.write(f"printf '%s' {json.dumps(stdout)}\n") + os.chmod( + script_path, + os.stat(script_path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH, + ) + self.addCleanup(lambda: os.path.exists(temp_dir) and shutil.rmtree(temp_dir)) + return script_path + + def test_claude_code_structured_output_envelope_is_unwrapped(self): + command = self._fake_cli('{"structured_output":{"name":"Ada"}}') + provider = CliLLMProvider( + backend=ModelProvider.CLAUDE_CODE.value, + command=command, + ) + + response = provider.chat( + model="claude-code", + messages=[{"role": "user", "content": "Return a name."}], + format={ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, + ) + + self.assertEqual(json.loads(response["message"]["content"]), {"name": "Ada"}) + + def test_codex_structured_output_accepts_raw_json(self): + command = self._fake_cli('{"name":"Ada"}') + provider = CliLLMProvider( + backend=ModelProvider.CODEX.value, + command=command, + ) + + response = provider.chat( + model="codex-cli", + messages=[ + {"role": "system", "content": "Only return JSON."}, + {"role": "user", "content": "Return a name."}, + ], + format={ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, + ) + + self.assertEqual(json.loads(response["message"]["content"]), {"name": "Ada"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/transform.py b/transform.py index 25eab1d3..03413362 100644 --- a/transform.py +++ b/transform.py @@ -3,6 +3,16 @@ from models import JSONResume +def _as_list(value) -> List: + if value is None: + return [] + if isinstance(value, list): + return value + if isinstance(value, dict): + return [value] + return [] + + def transform_parsed_data(parsed_data: Dict) -> Dict: try: if isinstance(parsed_data, dict): @@ -173,6 +183,7 @@ def extract_username_from_url(url: str, domain: str) -> str: def transform_work_experience(work_list: List) -> List[Dict]: + work_list = _as_list(work_list) transformed = [] for item in work_list: if isinstance(item, dict): @@ -222,6 +233,7 @@ def transform_work_experience(work_list: List) -> List[Dict]: def transform_organizations(org_list: List) -> List[Dict]: + org_list = _as_list(org_list) transformed = [] for item in org_list: if isinstance(item, dict): @@ -240,6 +252,7 @@ def transform_organizations(org_list: List) -> List[Dict]: def transform_education(edu_list: List) -> List[Dict]: + edu_list = _as_list(edu_list) transformed = [] for item in edu_list: if isinstance(item, dict): @@ -275,6 +288,7 @@ def transform_education(edu_list: List) -> List[Dict]: def transform_achievements(achievements_list: List) -> List[Dict]: + achievements_list = _as_list(achievements_list) transformed = [] for item in achievements_list: if isinstance(item, dict): @@ -294,6 +308,7 @@ def transform_achievements(achievements_list: List) -> List[Dict]: def transform_skills(skills_list: List) -> List[Dict]: + skills_list = _as_list(skills_list) transformed = [] for item in skills_list: if isinstance(item, dict): @@ -311,6 +326,7 @@ def transform_skills(skills_list: List) -> List[Dict]: def transform_projects(projects_list: List) -> List[Dict]: + projects_list = _as_list(projects_list) transformed = [] for item in projects_list: if isinstance(item, dict): @@ -382,7 +398,7 @@ def transform_projects_comprehensive(parsed_data: Dict) -> List[Dict]: projects.extend(transform_projects(parsed_data["projects"])) if "projectsOpenSource" in parsed_data: - for item in parsed_data["projectsOpenSource"]: + for item in _as_list(parsed_data["projectsOpenSource"]): if isinstance(item, dict): skills = [] project_name = item.get("name", "")