From 28ba1f171f4411da640af8f633c5be72e3aef9d1 Mon Sep 17 00:00:00 2001 From: Thierry Date: Tue, 12 May 2026 10:37:53 +0200 Subject: [PATCH 1/2] fix(sidecar): force JSON response_format on OpenRouter calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a brief mentioned an inaccessible resource (e.g. a local file path like `story.md`), OpenRouter models replied with a plain-text refusal ("Je suis conçu pour générer uniquement des captions Instagram en JSON — je ne peux pas analyser ou décrire des fichiers comme `story.md`."), which poisoned `_parse_json_response` downstream and surfaced as a malformed-JSON error in the Composer UI. Add `response_format={"type": "json_object"}` to the three OpenAI-compat methods that expect a JSON response (`_generate_openai_compat`, `_carousel_openai_compat`, `_extract_visual_openai_compat`) so the model is contractually forced to emit valid JSON regardless of the brief content. The synthesize path (`_synthesize_openai_compat`) is left untouched — it returns plain text on purpose (the ProductTruth gets pasted directly into a textarea). The flag is gated to `provider == "openrouter"` via a new `_openrouter_json_kwargs()` helper so Ollama (local, support is patchy) and Anthropic native (different request shape) are unaffected. OpenRouter's docs list `json_mode` in the `supported_features` array for Anthropic Claude models, so the routing is safe across the compat matrix. Add `TestOpenRouterJsonMode` (6 tests) that mock `ai_client.OpenAI` and verify the gate behavior: param IS passed for OpenRouter, NOT for Ollama, NOT for the synthesize path even on OpenRouter. All 66 sidecar tests pass locally. Co-Authored-By: Claude Opus 4.7 --- sidecar/ai_client.py | 15 +++++ sidecar/tests/test_ai_client.py | 100 ++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/sidecar/ai_client.py b/sidecar/ai_client.py index fb7bdb6..29601cb 100644 --- a/sidecar/ai_client.py +++ b/sidecar/ai_client.py @@ -35,6 +35,18 @@ def __init__( self.model = model self.base_url = base_url + def _openrouter_json_kwargs(self) -> dict[str, Any]: + """Force JSON mode on OpenRouter so models can't refuse in plain text. + + Without this, briefs that mention inaccessible resources (e.g. local + file paths like `story.md`) made models reply with a plain-text + refusal, poisoning `_parse_json_response`. Gated on provider because + Ollama support is patchy and Anthropic native uses a different shape. + """ + if self.provider == "openrouter": + return {"response_format": {"type": "json_object"}} + return {} + def generate_caption( self, brief: str, network: str, system_prompt: str ) -> dict[str, Any]: @@ -155,6 +167,7 @@ def _extract_visual_openai_compat( ], }, ], + **self._openrouter_json_kwargs(), ) return _sanitize_surrogates((response.choices[0].message.content or "").strip()) @@ -209,6 +222,7 @@ def _carousel_openai_compat( {"role": "system", "content": system_prompt}, {"role": "user", "content": brief}, ], + **self._openrouter_json_kwargs(), ) raw = response.choices[0].message.content or "" return _parse_carousel_response(raw, slide_count) @@ -254,6 +268,7 @@ def _generate_openai_compat(self, brief: str, system_prompt: str, max_tokens: in {"role": "system", "content": system_prompt}, {"role": "user", "content": brief}, ], + **self._openrouter_json_kwargs(), ) raw = response.choices[0].message.content or "" diff --git a/sidecar/tests/test_ai_client.py b/sidecar/tests/test_ai_client.py index ccb3a43..cf02c6c 100644 --- a/sidecar/tests/test_ai_client.py +++ b/sidecar/tests/test_ai_client.py @@ -9,6 +9,7 @@ import sys import io from pathlib import Path +from unittest.mock import MagicMock, patch import pytest @@ -16,6 +17,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from ai_client import ( + AIClient, _sanitize_surrogates, _parse_json_response, _parse_carousel_response, @@ -539,3 +541,101 @@ def test_rejects_non_object_root(self): except ValueError: raised = True assert raised, "must reject non-object roots" + + +# ── OpenRouter JSON mode gate ──────────────────────────────────────────────── +# +# Regression for the bug where models replied with a plain-text refusal +# ("Je ne peux pas analyser ce fichier story.md…") when the brief mentioned +# inaccessible resources, breaking _parse_json_response downstream. +# `response_format={"type": "json_object"}` forces the model to emit valid +# JSON. Gated to OpenRouter only — Ollama support is patchy and Anthropic +# native uses a different shape. + +class TestOpenRouterJsonMode: + def _mock_client(self, content: str) -> MagicMock: + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = content + mock_response.usage = None + client = MagicMock() + client.chat.completions.create.return_value = mock_response + return client + + def test_generate_openrouter_sets_response_format(self): + client = self._mock_client('{"caption": "ok", "hashtags": []}') + with patch("ai_client.OpenAI", return_value=client): + ai = AIClient( + provider="openrouter", + api_key="sk-test", + model="anthropic/claude-sonnet-4.6", + ) + ai._generate_openai_compat("brief", "system", max_tokens=600) + call = client.chat.completions.create.call_args + assert call.kwargs.get("response_format") == {"type": "json_object"} + + def test_generate_ollama_omits_response_format(self): + client = self._mock_client('{"caption": "ok", "hashtags": []}') + with patch("ai_client.OpenAI", return_value=client): + ai = AIClient( + provider="ollama", + api_key=None, + model="qwen2:0.5b", + base_url="http://localhost:11434/v1", + ) + ai._generate_openai_compat("brief", "system", max_tokens=600) + call = client.chat.completions.create.call_args + assert "response_format" not in call.kwargs + + def test_carousel_openrouter_sets_response_format(self): + client = self._mock_client('[{"emoji":"💡","title":"S1","body":"B"}]') + with patch("ai_client.OpenAI", return_value=client): + ai = AIClient( + provider="openrouter", + api_key="sk-test", + model="anthropic/claude-sonnet-4.6", + ) + ai._carousel_openai_compat("brief", 1, "system") + call = client.chat.completions.create.call_args + assert call.kwargs.get("response_format") == {"type": "json_object"} + + def test_carousel_ollama_omits_response_format(self): + client = self._mock_client('[{"emoji":"💡","title":"S1","body":"B"}]') + with patch("ai_client.OpenAI", return_value=client): + ai = AIClient( + provider="ollama", + api_key=None, + model="qwen2:0.5b", + base_url="http://localhost:11434/v1", + ) + ai._carousel_openai_compat("brief", 1, "system") + call = client.chat.completions.create.call_args + assert "response_format" not in call.kwargs + + def test_visual_extract_openrouter_sets_response_format(self): + client = self._mock_client( + '{"colors":[],"typography":{},"mood":[],"layout":"x"}' + ) + with patch("ai_client.OpenAI", return_value=client): + ai = AIClient( + provider="openrouter", + api_key="sk-test", + model="anthropic/claude-sonnet-4.6", + ) + ai._extract_visual_openai_compat("base64data", "system") + call = client.chat.completions.create.call_args + assert call.kwargs.get("response_format") == {"type": "json_object"} + + def test_synthesize_never_sets_response_format(self): + # ProductTruth synthesis returns plain text — JSON mode would force the + # model to wrap output in a JSON envelope, defeating the textarea paste. + client = self._mock_client("plain text product truth") + with patch("ai_client.OpenAI", return_value=client): + ai = AIClient( + provider="openrouter", + api_key="sk-test", + model="anthropic/claude-sonnet-4.6", + ) + ai._synthesize_openai_compat("scraped content", "system") + call = client.chat.completions.create.call_args + assert "response_format" not in call.kwargs From 25fe9b19683050356b4c9d7304000b000e506bb4 Mon Sep 17 00:00:00 2001 From: Thierry Date: Tue, 12 May 2026 10:41:25 +0200 Subject: [PATCH 2/2] =?UTF-8?q?test(sidecar):=20lock=20gate=20contract=20?= =?UTF-8?q?=E2=80=94=20non-openrouter=20providers=20omit=20response=5Fform?= =?UTF-8?q?at=20(Sourcery=20on=20PR=20#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sourcery flagged that the gate behavior was only asserted negatively for `provider="ollama"`. Add a third negative case for `provider="openai"` to prevent a future OpenAI-direct route, Anthropic-compat proxy, or any other compat-path provider from accidentally inheriting JSON mode. Locks the contract: only the literal string `"openrouter"` triggers `response_format={"type": "json_object"}`. Co-Authored-By: Claude Opus 4.7 --- sidecar/tests/test_ai_client.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/sidecar/tests/test_ai_client.py b/sidecar/tests/test_ai_client.py index cf02c6c..ffecf65 100644 --- a/sidecar/tests/test_ai_client.py +++ b/sidecar/tests/test_ai_client.py @@ -587,6 +587,22 @@ def test_generate_ollama_omits_response_format(self): call = client.chat.completions.create.call_args assert "response_format" not in call.kwargs + def test_generate_unknown_provider_omits_response_format(self): + # Lock the contract: only the literal string "openrouter" triggers JSON + # mode. Any other provider that uses the OpenAI-compat path (a future + # OpenAI-direct route, an Anthropic-compat proxy, etc.) must not + # accidentally inherit it. + client = self._mock_client('{"caption": "ok", "hashtags": []}') + with patch("ai_client.OpenAI", return_value=client): + ai = AIClient( + provider="openai", + api_key="sk-test", + model="gpt-4o-mini", + ) + ai._generate_openai_compat("brief", "system", max_tokens=600) + call = client.chat.completions.create.call_args + assert "response_format" not in call.kwargs + def test_carousel_openrouter_sets_response_format(self): client = self._mock_client('[{"emoji":"💡","title":"S1","body":"B"}]') with patch("ai_client.OpenAI", return_value=client):