Support Claude Code JSON schema execution
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled

This commit is contained in:
2026-05-21 03:19:27 +02:00
parent 82e3c07928
commit b12d1af8bb
2 changed files with 123 additions and 31 deletions

View File

@@ -2,9 +2,12 @@
Claude Code CLI adapter — runs the ``claude`` CLI as a subprocess.
"""
import asyncio
import subprocess
from typing import Optional
import asyncio
import json
import os
import subprocess
from pathlib import Path
from typing import Optional
from llm_connect.adapter import LLMAdapter
from llm_connect.models import RunConfig, LLMResponse
@@ -23,25 +26,23 @@ class ClaudeCodeAdapter(LLMAdapter):
length limits (compiled prompts can exceed 30 KB).
"""
def __init__(
self,
cli_path: str = "claude",
model: Optional[str] = None,
config: Optional[LLMConfig] = None,
):
self._config = config or LLMConfig(provider="claude-code")
self._cli_path = cli_path or self._config.claude_cli_path
self._model = model
def __init__(
self,
cli_path: Optional[str] = None,
model: Optional[str] = None,
config: Optional[LLMConfig] = None,
):
self._config = config or LLMConfig(provider="claude-code")
self._cli_path = cli_path or self._resolve_cli_path()
self._model = model
# ── LLMAdapter interface ────────────────────────────────────────
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
self._preflight_budget(config)
cmd = [self._cli_path, "--print"]
if self._model:
cmd.extend(["--model", self._model])
timeout = config.timeout_seconds or self._config.timeout_seconds
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
self._preflight_budget(config)
cmd = self._build_command(config)
timeout = config.timeout_seconds or self._config.timeout_seconds
try:
result = subprocess.run(
@@ -85,12 +86,10 @@ class ClaudeCodeAdapter(LLMAdapter):
self._consume_budget(config, response)
return response
async def async_execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
"""Native async implementation using asyncio.create_subprocess_exec."""
self._preflight_budget(config)
cmd = [self._cli_path, "--print"]
if self._model:
cmd.extend(["--model", self._model])
async def async_execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
"""Native async implementation using asyncio.create_subprocess_exec."""
self._preflight_budget(config)
cmd = self._build_command(config)
timeout = config.timeout_seconds or self._config.timeout_seconds
@@ -140,14 +139,49 @@ class ClaudeCodeAdapter(LLMAdapter):
self._consume_budget(config, response)
return response
def validate_config(self, config: RunConfig) -> bool:
try:
result = subprocess.run(
[self._cli_path, "--version"],
def validate_config(self, config: RunConfig) -> bool:
try:
result = subprocess.run(
[self._cli_path, "--version"],
capture_output=True,
text=True,
timeout=10,
)
return result.returncode == 0
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return False
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return False
def _build_command(self, config: RunConfig) -> list[str]:
cmd = [self._cli_path, "--print"]
if self._model:
cmd.extend(["--model", self._model])
json_schema = _json_schema_arg(config)
if json_schema:
cmd.extend(["--json-schema", json_schema])
return cmd
def _resolve_cli_path(self) -> str:
configured = (
os.environ.get("LLM_CONNECT_CLAUDE_CLI_PATH")
or os.environ.get("CLAUDE_CLI_PATH")
or self._config.claude_cli_path
)
if configured and configured != "claude":
return configured
local_cli = Path.home() / ".local" / "bin" / "claude"
if local_cli.exists():
return str(local_cli)
return configured or "claude"
def _json_schema_arg(config: RunConfig) -> str | None:
schema = (config.model_params or {}).get("json_schema")
if not schema:
return None
if isinstance(schema, str):
return schema
if isinstance(schema, dict):
return json.dumps(schema, separators=(",", ":"))
return None

58
tests/test_claude_code.py Normal file
View File

@@ -0,0 +1,58 @@
from __future__ import annotations
from types import SimpleNamespace
from llm_connect.claude_code import ClaudeCodeAdapter
from llm_connect.config import LLMConfig
from llm_connect.models import RunConfig
def test_execute_prompt_passes_json_schema_to_claude_cli(monkeypatch):
calls: dict[str, object] = {}
def fake_run(cmd, input, capture_output, text, timeout): # noqa: ANN001
calls["cmd"] = cmd
calls["input"] = input
calls["capture_output"] = capture_output
calls["text"] = text
calls["timeout"] = timeout
return SimpleNamespace(
returncode=0,
stdout='{"summary":"ok","recommendations":[]}',
stderr="",
)
monkeypatch.setattr("llm_connect.claude_code.subprocess.run", fake_run)
adapter = ClaudeCodeAdapter(cli_path="/custom/claude")
response = adapter.execute_prompt(
"Produce a report.",
RunConfig(
timeout_seconds=42,
model_params={"json_schema": {"type": "object"}},
),
)
assert calls == {
"cmd": [
"/custom/claude",
"--print",
"--json-schema",
'{"type":"object"}',
],
"input": "Produce a report.",
"capture_output": True,
"text": True,
"timeout": 42,
}
assert response.content == '{"summary":"ok","recommendations":[]}'
def test_claude_code_adapter_prefers_env_cli_path(monkeypatch):
monkeypatch.setenv("LLM_CONNECT_CLAUDE_CLI_PATH", "/home/me/bin/claude")
adapter = ClaudeCodeAdapter(
config=LLMConfig(provider="claude-code", claude_cli_path="claude")
)
assert adapter._cli_path == "/home/me/bin/claude"