Fix LM Studio JSON mode failure and malformed LLM JSON parsing (v1.1.1)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "calliope"
|
||||
version = "1.1.0"
|
||||
version = "1.1.1"
|
||||
description = "Local-first AI story-to-video studio"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -24,7 +24,12 @@ class LLMClient:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
return headers
|
||||
|
||||
async def chat(self, messages: list[dict[str, str]], temperature: float = 0.7, response_format: dict[str, str] | None = None) -> str:
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
temperature: float = 0.7,
|
||||
response_format: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
payload: dict[str, Any] = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
@@ -36,6 +41,14 @@ class LLMClient:
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
logger.info("LLM request to %s with model %s", url, self.model)
|
||||
resp = await self.client.post(url, headers=self._headers(), json=payload)
|
||||
if resp.status_code == 400 and "response_format" in payload:
|
||||
# Some OpenAI-compatible servers (e.g. LM Studio) reject the
|
||||
# response_format field outright — retry without it.
|
||||
logger.warning(
|
||||
"Server rejected response_format (HTTP 400); retrying without it"
|
||||
)
|
||||
payload.pop("response_format")
|
||||
resp = await self.client.post(url, headers=self._headers(), json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
@@ -46,24 +59,79 @@ class LLMClient:
|
||||
|
||||
|
||||
def extract_json(text: str) -> dict[str, Any]:
|
||||
"""Extract JSON object from a markdown code block or raw text."""
|
||||
"""Extract a JSON object from a model reply.
|
||||
|
||||
Handles the messy shapes local models actually produce: raw JSON, fenced
|
||||
code blocks, JSON embedded in prose, and valid JSON followed by trailing
|
||||
chatter ("Extra data: line 1 column N" failures).
|
||||
"""
|
||||
text = text.strip()
|
||||
if text.startswith("```"):
|
||||
# Strip fenced code block
|
||||
if not text:
|
||||
raise ValueError("LLM returned empty content")
|
||||
|
||||
# Fast path: clean, single JSON document
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Strip fenced code blocks (```json ... ``` or ``` ... ```)
|
||||
if "```" in text:
|
||||
lines = text.splitlines()
|
||||
if lines[0].startswith("```"):
|
||||
lines = lines[1:]
|
||||
if lines and lines[-1].startswith("```"):
|
||||
lines = lines[:-1]
|
||||
text = "\n".join(lines).strip()
|
||||
return json.loads(text)
|
||||
chunks: list[str] = []
|
||||
inside = False
|
||||
for line in lines:
|
||||
if not inside and line.strip().startswith("```"):
|
||||
inside = True
|
||||
continue
|
||||
if inside and line.strip().startswith("```"):
|
||||
inside = False
|
||||
continue
|
||||
if inside:
|
||||
chunks.append(line)
|
||||
if chunks:
|
||||
candidate = "\n".join(chunks).strip()
|
||||
try:
|
||||
parsed = json.loads(candidate)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Last resort: scan for the first balanced {...} object and ignore
|
||||
# whatever prose or chatter follows it.
|
||||
decoder = json.JSONDecoder()
|
||||
start = text.find("{")
|
||||
while start != -1:
|
||||
try:
|
||||
parsed, _ = decoder.raw_decode(text[start:])
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
start = text.find("{", start + 1)
|
||||
raise ValueError(f"No JSON object found in LLM reply (len={len(text)})")
|
||||
|
||||
|
||||
async def generate_structured(messages: list[dict[str, str]], temperature: float = 0.7) -> dict[str, Any]:
|
||||
async def generate_structured(
|
||||
messages: list[dict[str, str]], temperature: float = 0.7
|
||||
) -> dict[str, Any]:
|
||||
client = LLMClient()
|
||||
try:
|
||||
response_format = {"type": "json_object"}
|
||||
text = await client.chat(messages, temperature=temperature, response_format=response_format)
|
||||
return extract_json(text)
|
||||
# JSON mode is off by default: several OpenAI-compatible servers
|
||||
# (notably LM Studio) reject response_format, and the prompts already
|
||||
# instruct the model to answer with a single JSON object.
|
||||
text = await client.chat(messages, temperature=temperature)
|
||||
try:
|
||||
return extract_json(text)
|
||||
except ValueError:
|
||||
# One retry with JSON mode requested, for servers that support it
|
||||
logger.warning("LLM reply was not parseable JSON; retrying with json_object mode")
|
||||
text = await client.chat(
|
||||
messages, temperature=temperature, response_format={"type": "json_object"}
|
||||
)
|
||||
return extract_json(text)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@@ -58,7 +58,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title="Calliope", version="1.1.0", lifespan=lifespan)
|
||||
app = FastAPI(title="Calliope", version="1.1.1", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -80,7 +80,7 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health() -> dict:
|
||||
return {"status": "ok", "version": "1.1.0", "dry_run": settings.dry_run}
|
||||
return {"status": "ok", "version": "1.1.1", "dry_run": settings.dry_run}
|
||||
|
||||
# Catch unknown /api/* before StaticFiles — otherwise POST falls through and
|
||||
# returns a confusing 405 Method Not Allowed from the file server.
|
||||
|
||||
@@ -10,8 +10,8 @@ from calliope.comfyui.profiles import detect_prompt_profile
|
||||
from calliope.comfyui.smart_fill import ref_image_slots, smart_fill_inputs
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
H3_WORKFLOW = REPO_ROOT / "ComfyAPI" / "video_minimax_h3_r2v_API.json"
|
||||
KREA_WORKFLOW = REPO_ROOT / "ComfyAPI" / "krea2_t2i_2pass-nsfwEnable-API.json"
|
||||
H3_WORKFLOW = REPO_ROOT / "example_ComfyUI_workflows" / "video_minimax_h3_r2v_2ref_API.json"
|
||||
KREA_WORKFLOW = REPO_ROOT / "example_ComfyUI_workflows" / "Krea2_t2i_20260818_API.json"
|
||||
|
||||
|
||||
def _load(path: Path) -> dict:
|
||||
@@ -29,7 +29,7 @@ def test_h3_workflow_parses_expected_roles():
|
||||
assert image_slots == ["148", "149"] # node-id order = ref wiring order
|
||||
|
||||
outputs = parse_dynamic_outputs(workflow)
|
||||
assert any(o["nodeId"] == "153" and o["kind"] == "video" for o in outputs)
|
||||
assert any(o["nodeId"] == "176" and o["kind"] == "video" for o in outputs)
|
||||
|
||||
|
||||
def test_detect_prompt_profile():
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Regression tests for LLM JSON handling (user-reported bugs).
|
||||
|
||||
Covers:
|
||||
1. LM Studio-style servers rejecting `response_format` (HTTP 400) — the
|
||||
client must retry without the field instead of failing the whole request.
|
||||
2. "Extra data: line 1 column 308" — models emitting valid JSON followed by
|
||||
trailing prose/chatter; extract_json must recover the object.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from calliope.agent.llm import LLMClient, extract_json
|
||||
|
||||
# ---------- extract_json ----------
|
||||
|
||||
def test_extract_json_plain():
|
||||
assert extract_json('{"a": 1}') == {"a": 1}
|
||||
|
||||
|
||||
def test_extract_json_fenced_block():
|
||||
text = 'Here is your JSON:\n```json\n{"a": 1, "b": [2, 3]}\n```\nDone!'
|
||||
assert extract_json(text) == {"a": 1, "b": [2, 3]}
|
||||
|
||||
|
||||
def test_extract_json_trailing_prose_extra_data():
|
||||
# The exact failure class the user hit: valid object + trailing text
|
||||
text = '{"title": "The Long Road", "beats": []} I hope this helps!'
|
||||
assert extract_json(text)["title"] == "The Long Road"
|
||||
|
||||
|
||||
def test_extract_json_leading_prose():
|
||||
text = 'Sure! Here is the storyline you asked for: {"title": "X"}'
|
||||
assert extract_json(text)["title"] == "X"
|
||||
|
||||
|
||||
def test_extract_json_two_objects_takes_first():
|
||||
text = '{"first": true} {"second": true}'
|
||||
assert extract_json(text) == {"first": True}
|
||||
|
||||
|
||||
def test_extract_json_rejects_garbage():
|
||||
with pytest.raises(ValueError):
|
||||
extract_json("no json here at all")
|
||||
|
||||
|
||||
def test_extract_json_empty():
|
||||
with pytest.raises(ValueError):
|
||||
extract_json(" ")
|
||||
|
||||
|
||||
# ---------- LLMClient response_format fallback ----------
|
||||
|
||||
class _FakeRouter:
|
||||
"""httpx mock transport handler that 400s any request containing response_format."""
|
||||
|
||||
def __init__(self, content: str, reject_response_format: bool) -> None:
|
||||
self.content = content
|
||||
self.reject = reject_response_format
|
||||
self.requests: list[dict] = []
|
||||
|
||||
async def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||
body = json.loads(request.content.decode())
|
||||
self.requests.append(body)
|
||||
if self.reject and "response_format" in body:
|
||||
return httpx.Response(
|
||||
400,
|
||||
json={"error": {"message": "response_format is not supported"}},
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"choices": [{"message": {"content": self.content}}]},
|
||||
)
|
||||
|
||||
|
||||
class _SequenceRouter(_FakeRouter):
|
||||
"""Returns each reply in order instead of a fixed one."""
|
||||
|
||||
def __init__(self, contents: list[str], reject_response_format: bool) -> None:
|
||||
super().__init__(contents[0], reject_response_format)
|
||||
self.contents = contents
|
||||
self.calls = 0
|
||||
|
||||
async def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||
content = self.contents[min(self.calls, len(self.contents) - 1)]
|
||||
self.calls += 1
|
||||
self.content = content
|
||||
return await super().__call__(request)
|
||||
|
||||
|
||||
async def test_chat_retries_without_response_format_on_400(monkeypatch):
|
||||
router = _FakeRouter(content='{"ok": true}', reject_response_format=True)
|
||||
transport = httpx.MockTransport(router)
|
||||
client = LLMClient()
|
||||
monkeypatch.setattr(client, "client", httpx.AsyncClient(transport=transport))
|
||||
|
||||
text = await client.chat(
|
||||
[{"role": "user", "content": "hi"}],
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
|
||||
assert text == '{"ok": true}'
|
||||
assert len(router.requests) == 2
|
||||
assert "response_format" in router.requests[0]
|
||||
assert "response_format" not in router.requests[1]
|
||||
|
||||
|
||||
async def test_generate_structured_no_response_format_by_default(monkeypatch):
|
||||
# Default path must NOT send response_format (LM Studio compatibility)
|
||||
router = _FakeRouter(content='{"a": 1}', reject_response_format=True)
|
||||
transport = httpx.MockTransport(router)
|
||||
client = LLMClient()
|
||||
monkeypatch.setattr(client, "client", httpx.AsyncClient(transport=transport))
|
||||
monkeypatch.setattr("calliope.agent.llm.LLMClient", lambda: client)
|
||||
|
||||
result = await generate_structured_public([{"role": "user", "content": "hi"}])
|
||||
|
||||
assert result == {"a": 1}
|
||||
assert len(router.requests) == 1
|
||||
assert "response_format" not in router.requests[0]
|
||||
|
||||
|
||||
async def generate_structured_public(messages):
|
||||
from calliope.agent.llm import generate_structured
|
||||
|
||||
return await generate_structured(messages)
|
||||
|
||||
|
||||
async def test_generate_structured_recovers_via_json_mode_retry(monkeypatch):
|
||||
# First reply is garbage prose; the json_object retry must rescue it.
|
||||
router = _SequenceRouter(
|
||||
["sorry, I cannot do that", '{"title": "Saved"}'],
|
||||
reject_response_format=False,
|
||||
)
|
||||
transport = httpx.MockTransport(router)
|
||||
client = LLMClient()
|
||||
monkeypatch.setattr(client, "client", httpx.AsyncClient(transport=transport))
|
||||
monkeypatch.setattr("calliope.agent.llm.LLMClient", lambda: client)
|
||||
|
||||
result = await generate_structured_public([{"role": "user", "content": "hi"}])
|
||||
|
||||
assert result == {"title": "Saved"}
|
||||
assert len(router.requests) == 2
|
||||
assert "response_format" not in router.requests[0]
|
||||
assert router.requests[1].get("response_format") == {"type": "json_object"}
|
||||
|
||||
|
||||
async def test_generate_structured_raises_when_both_attempts_fail(monkeypatch):
|
||||
router = _SequenceRouter(
|
||||
["all prose", "still prose"],
|
||||
reject_response_format=False,
|
||||
)
|
||||
transport = httpx.MockTransport(router)
|
||||
client = LLMClient()
|
||||
monkeypatch.setattr(client, "client", httpx.AsyncClient(transport=transport))
|
||||
monkeypatch.setattr("calliope.agent.llm.LLMClient", lambda: client)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await generate_structured_public([{"role": "user", "content": "hi"}])
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "calliope-web",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "calliope-web",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"dependencies": {
|
||||
"@tanstack/svelte-query": "^5.66.0"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "calliope-web",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
Reference in New Issue
Block a user