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:
Benjiyaya
2026-08-20 01:09:22 +08:00
co-authored by Cursor
parent 210e8998b8
commit 152234ac9b
7 changed files with 253 additions and 23 deletions
+1 -1
View File
@@ -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 = [
+82 -14
View File
@@ -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()
+2 -2
View File
@@ -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.
+3 -3
View File
@@ -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():
+162
View File
@@ -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"}])
+2 -2
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "calliope-web",
"version": "1.1.0",
"version": "1.1.1",
"private": true,
"scripts": {
"dev": "vite dev",