generated from coulomb/repo-seed
Pass --output-format json with --json-schema and unwrap CLI envelope
The Claude Code adapter previously passed --json-schema alone. On Claude
CLI 2.1.160 that combination still emits the model's conversational
preamble on stdout while the schema-enforced structured payload ships on
a sidecar channel the adapter cannot read. Result: callers requesting
structured output got prose that fails JSON parsing downstream — exactly
the failure mode the activity-core CUST-WP-0045 daily triage canary hit
on 2026-06-01 ("Triage report generated and returned via structured
output. Key signals:..." → json.loads error at column 1).
Fix: when --json-schema is set, also pass --output-format json. The CLI
then writes a JSON envelope on stdout. The adapter unwraps it by
probing a small allowlist of known text-bearing fields (result,
result_text, content, text, output). Unknown envelope shapes fall
through to raw stdout so the operator can introspect the structure and
extend the allowlist.
The unwrap path is only triggered when --json-schema was set, so non-
schema callers keep the existing raw-stdout behavior.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -2,12 +2,12 @@
|
|||||||
Claude Code CLI adapter — runs the ``claude`` CLI as a subprocess.
|
Claude Code CLI adapter — runs the ``claude`` CLI as a subprocess.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from llm_connect.adapter import LLMAdapter
|
from llm_connect.adapter import LLMAdapter
|
||||||
from llm_connect.models import RunConfig, LLMResponse
|
from llm_connect.models import RunConfig, LLMResponse
|
||||||
@@ -26,23 +26,23 @@ class ClaudeCodeAdapter(LLMAdapter):
|
|||||||
length limits (compiled prompts can exceed 30 KB).
|
length limits (compiled prompts can exceed 30 KB).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
cli_path: Optional[str] = None,
|
cli_path: Optional[str] = None,
|
||||||
model: Optional[str] = None,
|
model: Optional[str] = None,
|
||||||
config: Optional[LLMConfig] = None,
|
config: Optional[LLMConfig] = None,
|
||||||
):
|
):
|
||||||
self._config = config or LLMConfig(provider="claude-code")
|
self._config = config or LLMConfig(provider="claude-code")
|
||||||
self._cli_path = cli_path or self._resolve_cli_path()
|
self._cli_path = cli_path or self._resolve_cli_path()
|
||||||
self._model = model
|
self._model = model
|
||||||
|
|
||||||
# ── LLMAdapter interface ────────────────────────────────────────
|
# ── LLMAdapter interface ────────────────────────────────────────
|
||||||
|
|
||||||
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
|
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
|
||||||
self._preflight_budget(config)
|
self._preflight_budget(config)
|
||||||
cmd = self._build_command(config)
|
cmd = self._build_command(config)
|
||||||
|
|
||||||
timeout = config.timeout_seconds or self._config.timeout_seconds
|
timeout = config.timeout_seconds or self._config.timeout_seconds
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -65,7 +65,7 @@ class ClaudeCodeAdapter(LLMAdapter):
|
|||||||
stderr=result.stderr,
|
stderr=result.stderr,
|
||||||
)
|
)
|
||||||
|
|
||||||
content = result.stdout
|
content = _unwrap_cli_json_envelope(result.stdout, config)
|
||||||
prompt_tokens = estimate_tokens(prompt)
|
prompt_tokens = estimate_tokens(prompt)
|
||||||
completion_tokens = estimate_tokens(content)
|
completion_tokens = estimate_tokens(content)
|
||||||
|
|
||||||
@@ -86,10 +86,10 @@ class ClaudeCodeAdapter(LLMAdapter):
|
|||||||
self._consume_budget(config, response)
|
self._consume_budget(config, response)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
async def async_execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
|
async def async_execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
|
||||||
"""Native async implementation using asyncio.create_subprocess_exec."""
|
"""Native async implementation using asyncio.create_subprocess_exec."""
|
||||||
self._preflight_budget(config)
|
self._preflight_budget(config)
|
||||||
cmd = self._build_command(config)
|
cmd = self._build_command(config)
|
||||||
|
|
||||||
timeout = config.timeout_seconds or self._config.timeout_seconds
|
timeout = config.timeout_seconds or self._config.timeout_seconds
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ class ClaudeCodeAdapter(LLMAdapter):
|
|||||||
stderr=stderr_bytes.decode(),
|
stderr=stderr_bytes.decode(),
|
||||||
)
|
)
|
||||||
|
|
||||||
content = stdout_bytes.decode()
|
content = _unwrap_cli_json_envelope(stdout_bytes.decode(), config)
|
||||||
prompt_tokens = estimate_tokens(prompt)
|
prompt_tokens = estimate_tokens(prompt)
|
||||||
completion_tokens = estimate_tokens(content)
|
completion_tokens = estimate_tokens(content)
|
||||||
|
|
||||||
@@ -139,49 +139,92 @@ class ClaudeCodeAdapter(LLMAdapter):
|
|||||||
self._consume_budget(config, response)
|
self._consume_budget(config, response)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def validate_config(self, config: RunConfig) -> bool:
|
def validate_config(self, config: RunConfig) -> bool:
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[self._cli_path, "--version"],
|
[self._cli_path, "--version"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
return result.returncode == 0
|
return result.returncode == 0
|
||||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _build_command(self, config: RunConfig) -> list[str]:
|
def _build_command(self, config: RunConfig) -> list[str]:
|
||||||
cmd = [self._cli_path, "--print"]
|
cmd = [self._cli_path, "--print"]
|
||||||
if self._model:
|
if self._model:
|
||||||
cmd.extend(["--model", self._model])
|
cmd.extend(["--model", self._model])
|
||||||
|
|
||||||
json_schema = _json_schema_arg(config)
|
json_schema = _json_schema_arg(config)
|
||||||
if json_schema:
|
if json_schema:
|
||||||
cmd.extend(["--json-schema", json_schema])
|
cmd.extend(["--json-schema", json_schema])
|
||||||
return cmd
|
# With --json-schema alone the CLI prints conversational text on
|
||||||
|
# stdout while the structured payload ships on a sidecar channel
|
||||||
def _resolve_cli_path(self) -> str:
|
# callers cannot reach. --output-format json forces the structured
|
||||||
configured = (
|
# response (wrapped in an envelope) onto stdout.
|
||||||
os.environ.get("LLM_CONNECT_CLAUDE_CLI_PATH")
|
cmd.extend(["--output-format", "json"])
|
||||||
or os.environ.get("CLAUDE_CLI_PATH")
|
return cmd
|
||||||
or self._config.claude_cli_path
|
|
||||||
)
|
def _resolve_cli_path(self) -> str:
|
||||||
if configured and configured != "claude":
|
configured = (
|
||||||
return configured
|
os.environ.get("LLM_CONNECT_CLAUDE_CLI_PATH")
|
||||||
|
or os.environ.get("CLAUDE_CLI_PATH")
|
||||||
local_cli = Path.home() / ".local" / "bin" / "claude"
|
or self._config.claude_cli_path
|
||||||
if local_cli.exists():
|
)
|
||||||
return str(local_cli)
|
if configured and configured != "claude":
|
||||||
return configured or "claude"
|
return configured
|
||||||
|
|
||||||
|
local_cli = Path.home() / ".local" / "bin" / "claude"
|
||||||
def _json_schema_arg(config: RunConfig) -> str | None:
|
if local_cli.exists():
|
||||||
schema = (config.model_params or {}).get("json_schema")
|
return str(local_cli)
|
||||||
if not schema:
|
return configured or "claude"
|
||||||
return None
|
|
||||||
if isinstance(schema, str):
|
|
||||||
return schema
|
def _json_schema_arg(config: RunConfig) -> str | None:
|
||||||
if isinstance(schema, dict):
|
schema = (config.model_params or {}).get("json_schema")
|
||||||
return json.dumps(schema, separators=(",", ":"))
|
if not schema:
|
||||||
return None
|
return None
|
||||||
|
if isinstance(schema, str):
|
||||||
|
return schema
|
||||||
|
if isinstance(schema, dict):
|
||||||
|
return json.dumps(schema, separators=(",", ":"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Field names Claude Code's `--output-format json` envelope is known to use
|
||||||
|
# for the model's primary textual response. Probed in order; the first match
|
||||||
|
# wins. If none match (because the envelope shape is something we haven't
|
||||||
|
# seen), we return the raw envelope string so the caller still gets the data
|
||||||
|
# and can introspect it.
|
||||||
|
_ENVELOPE_TEXT_FIELDS = ("result", "result_text", "content", "text", "output")
|
||||||
|
|
||||||
|
|
||||||
|
def _unwrap_cli_json_envelope(stdout: str, config: RunConfig) -> str:
|
||||||
|
"""Extract the model's payload from Claude CLI's --output-format json envelope.
|
||||||
|
|
||||||
|
Only attempts unwrap when --json-schema was set, because that's the only
|
||||||
|
code path that adds --output-format json to the CLI invocation. Other
|
||||||
|
paths keep raw stdout (current behavior preserved).
|
||||||
|
"""
|
||||||
|
if not _json_schema_arg(config):
|
||||||
|
return stdout
|
||||||
|
text = stdout.strip()
|
||||||
|
if not text:
|
||||||
|
return stdout
|
||||||
|
try:
|
||||||
|
envelope = json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return stdout
|
||||||
|
if not isinstance(envelope, dict):
|
||||||
|
return stdout
|
||||||
|
for key in _ENVELOPE_TEXT_FIELDS:
|
||||||
|
if key in envelope:
|
||||||
|
value = envelope[key]
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
if isinstance(value, (dict, list)):
|
||||||
|
return json.dumps(value)
|
||||||
|
# Unknown envelope shape — surface it raw so the operator can see it
|
||||||
|
# in the validation error and we can update _ENVELOPE_TEXT_FIELDS.
|
||||||
|
return stdout
|
||||||
|
|||||||
@@ -39,15 +39,65 @@ def test_execute_prompt_passes_json_schema_to_claude_cli(monkeypatch):
|
|||||||
"--print",
|
"--print",
|
||||||
"--json-schema",
|
"--json-schema",
|
||||||
'{"type":"object"}',
|
'{"type":"object"}',
|
||||||
|
"--output-format",
|
||||||
|
"json",
|
||||||
],
|
],
|
||||||
"input": "Produce a report.",
|
"input": "Produce a report.",
|
||||||
"capture_output": True,
|
"capture_output": True,
|
||||||
"text": True,
|
"text": True,
|
||||||
"timeout": 42,
|
"timeout": 42,
|
||||||
}
|
}
|
||||||
|
# Stdout shape that does not match any known envelope field is returned
|
||||||
|
# verbatim so the caller can introspect and we can extend the field list.
|
||||||
assert response.content == '{"summary":"ok","recommendations":[]}'
|
assert response.content == '{"summary":"ok","recommendations":[]}'
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_prompt_unwraps_cli_json_envelope_result_field(monkeypatch):
|
||||||
|
"""With --output-format json the CLI wraps the model payload in an
|
||||||
|
envelope. The adapter unwraps the textual result so the caller still
|
||||||
|
sees the model's structured-output JSON, not the envelope."""
|
||||||
|
def fake_run(cmd, input, capture_output, text, timeout): # noqa: ANN001
|
||||||
|
envelope = {
|
||||||
|
"type": "result",
|
||||||
|
"result": '{"summary":"ok","recommendations":[]}',
|
||||||
|
"total_cost_usd": 0.001,
|
||||||
|
}
|
||||||
|
import json as _json
|
||||||
|
return SimpleNamespace(
|
||||||
|
returncode=0,
|
||||||
|
stdout=_json.dumps(envelope),
|
||||||
|
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(model_params={"json_schema": {"type": "object"}}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.content == '{"summary":"ok","recommendations":[]}'
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_prompt_no_unwrap_without_json_schema(monkeypatch):
|
||||||
|
"""Without --json-schema we do not pass --output-format json, so the
|
||||||
|
envelope unwrap path stays inert and raw stdout passes through."""
|
||||||
|
def fake_run(cmd, input, capture_output, text, timeout): # noqa: ANN001
|
||||||
|
return SimpleNamespace(
|
||||||
|
returncode=0,
|
||||||
|
stdout='{"result":"this is just stdout, not an envelope"}',
|
||||||
|
stderr="",
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr("llm_connect.claude_code.subprocess.run", fake_run)
|
||||||
|
adapter = ClaudeCodeAdapter(cli_path="/custom/claude")
|
||||||
|
|
||||||
|
response = adapter.execute_prompt("Plain prompt.", RunConfig())
|
||||||
|
|
||||||
|
assert response.content == '{"result":"this is just stdout, not an envelope"}'
|
||||||
|
|
||||||
|
|
||||||
def test_claude_code_adapter_prefers_env_cli_path(monkeypatch):
|
def test_claude_code_adapter_prefers_env_cli_path(monkeypatch):
|
||||||
monkeypatch.setenv("LLM_CONNECT_CLAUDE_CLI_PATH", "/home/me/bin/claude")
|
monkeypatch.setenv("LLM_CONNECT_CLAUDE_CLI_PATH", "/home/me/bin/claude")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user