Skip to content

fix(sidecar): force JSON response_format on OpenRouter calls#72

Merged
thierryvm merged 2 commits into
mainfrom
fix/ai-response-format-json
May 12, 2026
Merged

fix(sidecar): force JSON response_format on OpenRouter calls#72
thierryvm merged 2 commits into
mainfrom
fix/ai-response-format-json

Conversation

@thierryvm

@thierryvm thierryvm commented May 12, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds response_format={\"type\": \"json_object\"} to the three OpenAI-compat methods that expect JSON (_generate_openai_compat, _carousel_openai_compat, _extract_visual_openai_compat).
  • Gated on provider == \"openrouter\" via new _openrouter_json_kwargs() helper. Ollama, Anthropic native, and the plain-text _synthesize_openai_compat path are intentionally untouched.
  • 6 new regression tests (TestOpenRouterJsonMode) mock ai_client.OpenAI and pin the gate behavior.

The bug

User report: Composer broke when a brief mentioned a local file (story.md). The model returned a plain-text refusal instead of JSON:

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.

This poisoned _parse_json_response and surfaced as a malformed-JSON error in the UI. Forcing response_format makes the model contractually emit valid JSON regardless of what the brief asks for.

Why this is safe across the compat matrix

OpenRouter's models endpoint advertises json_mode in the supported_features array for Anthropic Claude models (per docs). The CLAUDE.md compat-matrix entries (Sonnet 4.6, Haiku, Opus 4.7, Opus 4.6-fast, GPT-4o, GPT-4o-mini, DeepSeek, Gemini 2.0 Flash) all support it natively.

Models flagged ⚠️ jsonUnreliable (Mistral Small) and *:free endpoints may surface a clearer 400 instead of returning malformed JSON downstream — acceptable trade.

Why we keep _synthesize_openai_compat plain

ProductTruth synthesis returns plain text that gets pasted into the Settings textarea. Forcing JSON mode there would defeat the UX.

Tests

66 passed in 1.75s

Includes 6 new tests covering: OpenRouter sets the flag (3 methods), Ollama omits it (2 methods), synthesize never sets it even on OpenRouter.

Out of scope (deferred)

  • Adding extra_body={\"provider\": {\"require_parameters\": True}} for strict routing — defense in depth; could restrict provider fallbacks for partial-support models. Consider in a follow-up.
  • Sourcery follow-up on PR feat(scheduler): background auto-publish task (PR F2) #71 (scheduler.rs:133 bug_risk) — separate dedicated PR.

🤖 Generated with Claude Code

Summary by Sourcery

Forcer les réponses en mode JSON pour les workflows JSON basés sur OpenRouter et ajouter une couverture de régression pour le contrôle spécifique au fournisseur.

Corrections de bugs :

  • Garantir que les workflows JSON d’OpenRouter reçoivent toujours des réponses JSON au lieu de refus en texte brut qui cassent l’analyse JSON.

Tests :

  • Ajouter des tests de régression vérifiant que les appels OpenRouter définissent response_format sur JSON pour les méthodes produisant du JSON, tandis que les appels Ollama et de synthèse l’omettent.
Original summary in English

Summary by Sourcery

Force JSON-mode responses for OpenRouter-backed JSON workflows and add regression coverage for the provider-specific gating.

Bug Fixes:

  • Ensure OpenRouter JSON workflows always receive JSON responses instead of plain-text refusals that break JSON parsing.

Tests:

  • Add regression tests validating that OpenRouter calls set JSON response_format for JSON-producing methods, while Ollama and synthesis calls omit it.

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 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented May 12, 2026

Copy link
Copy Markdown

Guide du relecteur

Pour les appels OpenRouter compatibles OpenAI qui sont censés renvoyer du JSON, cette PR centralise et impose response_format={"type": "json_object"} via un nouveau helper, et ajoute des tests de régression pour figer le comportement de « gating » entre les fournisseurs et les types d’appels.

Modifications par fichier

Changement Détails Fichiers
Forcer les réponses en mode JSON pour OpenRouter sur les méthodes compatibles OpenAI qui attendent du JSON, via un helper dédié.
  • Introduire _openrouter_json_kwargs sur AIClient pour renvoyer {"response_format": {"type": "json_object"}} lorsque provider == "openrouter", sinon un dictionnaire vide.
  • Appliquer ce helper à _extract_visual_openai_compat, _carousel_openai_compat et _generate_openai_compat afin que les appels OpenRouter sur ces chemins demandent toujours des réponses au format objet JSON.
  • Documenter dans la docstring du helper que les formes natives Ollama et Anthropic restent inchangées en raison d’une prise en charge partielle ou différente du mode JSON.
sidecar/ai_client.py
Ajouter des tests de régression pour vérifier le comportement de « gating » du mode JSON entre les fournisseurs et les méthodes.
  • Ajouter la classe de test TestOpenRouterJsonMode qui « mock » ai_client.OpenAI et vérifie que response_format est défini à {"type": "json_object"} pour OpenRouter dans _generate_openai_compat, _carousel_openai_compat et _extract_visual_openai_compat.
  • Ajouter des tests garantissant que les appels Ollama pour _generate_openai_compat et _carousel_openai_compat n’incluent pas response_format.
  • Ajouter un test qui vérifie que _synthesize_openai_compat ne définit jamais response_format, même lorsque provider == "openrouter", afin de préserver l’UX de synthèse ProductTruth en texte brut.
sidecar/tests/test_ai_client.py

Conseils et commandes

Interagir avec Sourcery

  • Déclencher une nouvelle revue : Commentez @sourcery-ai review sur la pull request.
  • Poursuivre les discussions : Répondez directement aux commentaires de revue de Sourcery.
  • Générer un ticket GitHub à partir d’un commentaire de revue : Demandez à Sourcery de créer un
    ticket à partir d’un commentaire de revue en y répondant. Vous pouvez aussi répondre à un
    commentaire de revue avec @sourcery-ai issue pour créer un ticket à partir de celui-ci.
  • Générer un titre de pull request : Écrivez @sourcery-ai n’importe où dans le titre de la pull
    request pour générer un titre à tout moment. Vous pouvez également commenter
    @sourcery-ai title sur la pull request pour (re)générer le titre à tout moment.
  • Générer un résumé de pull request : Écrivez @sourcery-ai summary n’importe où dans
    le corps de la pull request pour générer un résumé de PR à tout moment, exactement à
    l’endroit souhaité. Vous pouvez aussi commenter @sourcery-ai summary sur la pull request pour
    (re)générer le résumé à tout moment.
  • Générer le guide du relecteur : Commentez @sourcery-ai guide sur la pull
    request pour (re)générer le guide du relecteur à tout moment.
  • Résoudre tous les commentaires Sourcery : Commentez @sourcery-ai resolve sur la
    pull request pour résoudre tous les commentaires Sourcery. Utile si vous avez déjà
    traité tous les commentaires et ne souhaitez plus les voir.
  • Ignorer toutes les revues Sourcery : Commentez @sourcery-ai dismiss sur la pull
    request pour ignorer toutes les revues Sourcery existantes. Particulièrement utile si vous
    voulez repartir de zéro avec une nouvelle revue – n’oubliez pas de commenter
    @sourcery-ai review pour déclencher une nouvelle revue !

Personnaliser votre expérience

Accédez à votre tableau de bord pour :

  • Activer ou désactiver des fonctionnalités de revue telles que le résumé de pull request
    généré par Sourcery, le guide du relecteur, et d’autres.
  • Changer la langue de revue.
  • Ajouter, supprimer ou modifier des instructions de revue personnalisées.
  • Ajuster d’autres paramètres de revue.

Obtenir de l’aide

Original review guide in English

Reviewer's Guide

For OpenRouter-based OpenAI-compatible calls that are expected to return JSON, this PR centralizes and enforces response_format={"type": "json_object"} via a new helper, and adds regression tests to pin the gating behavior across providers and call types.

File-Level Changes

Change Details Files
Force JSON-mode responses for OpenRouter on JSON-expecting OpenAI-compat methods via a dedicated helper.
  • Introduce _openrouter_json_kwargs on AIClient to return {"response_format": {"type": "json_object"}} when provider == "openrouter", otherwise an empty dict.
  • Apply the helper to _extract_visual_openai_compat, _carousel_openai_compat, and _generate_openai_compat so OpenRouter calls for these paths always request JSON-object responses.
  • Document in the helper docstring that Ollama and Anthropic-native shapes remain unchanged due to partial or different support for JSON mode.
sidecar/ai_client.py
Add regression tests to verify JSON-mode gating behavior across providers and methods.
  • Add TestOpenRouterJsonMode test class that mocks ai_client.OpenAI and asserts response_format is set to {"type": "json_object"} for OpenRouter in _generate_openai_compat, _carousel_openai_compat, and _extract_visual_openai_compat.
  • Add tests ensuring Ollama calls for _generate_openai_compat and _carousel_openai_compat do not include response_format.
  • Add a test asserting _synthesize_openai_compat never sets response_format, even when provider == "openrouter", to preserve plain-text ProductTruth synthesis UX.
sidecar/tests/test_ai_client.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - j'ai trouvé 1 problème

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="sidecar/tests/test_ai_client.py" line_range="577-586" />
<code_context>
+        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):
</code_context>
<issue_to_address>
**suggestion (testing):** Ajouter un test négatif pour un fournisseur qui n’est ni Ollama ni OpenRouter mais qui utilise tout de même le chemin de compatibilité

Actuellement, nous vérifions uniquement l’omission de `response_format` pour `provider="ollama"` (ainsi que pour le cas `synthesize`). Merci d’ajouter également un test négatif pour un autre fournisseur qui utilise le même chemin `*_openai_compat` (par exemple `provider="openai"` ou un fournisseur compatible Anthropic) afin de vérifier qu’il ne reçoit pas non plus `response_format` dans `call.kwargs`. Cela verrouillera le contrat selon lequel seul `provider=="openrouter"` bénéficie du mode JSON et évitera d’élargir accidentellement ce comportement.
</issue_to_address>

Sourcery est gratuit pour l’open source - si nos reviews vous plaisent, pensez à les partager ✨
Aidez-moi à être plus utile ! Cliquez sur 👍 ou 👎 sur chaque commentaire et j’utiliserai vos retours pour améliorer vos reviews.
Original comment in English

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="sidecar/tests/test_ai_client.py" line_range="577-586" />
<code_context>
+        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):
</code_context>
<issue_to_address>
**suggestion (testing):** Add a negative test for a non-Ollama, non-OpenRouter provider that still uses the compat path

Currently we only assert omission of `response_format` for `provider="ollama"` (plus the `synthesize` case). Please also add a negative test for another provider that uses the same `*_openai_compat` path (e.g. `provider="openai"` or an Anthropic-compat provider) to verify it also does not receive `response_format` in `call.kwargs`. This will lock in the contract that only `provider=="openrouter"` gets JSON mode and prevent accidental broadening of this behavior.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +577 to +586
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Ajouter un test négatif pour un fournisseur qui n’est ni Ollama ni OpenRouter mais qui utilise tout de même le chemin de compatibilité

Actuellement, nous vérifions uniquement l’omission de response_format pour provider="ollama" (ainsi que pour le cas synthesize). Merci d’ajouter également un test négatif pour un autre fournisseur qui utilise le même chemin *_openai_compat (par exemple provider="openai" ou un fournisseur compatible Anthropic) afin de vérifier qu’il ne reçoit pas non plus response_format dans call.kwargs. Cela verrouillera le contrat selon lequel seul provider=="openrouter" bénéficie du mode JSON et évitera d’élargir accidentellement ce comportement.

Original comment in English

suggestion (testing): Add a negative test for a non-Ollama, non-OpenRouter provider that still uses the compat path

Currently we only assert omission of response_format for provider="ollama" (plus the synthesize case). Please also add a negative test for another provider that uses the same *_openai_compat path (e.g. provider="openai" or an Anthropic-compat provider) to verify it also does not receive response_format in call.kwargs. This will lock in the contract that only provider=="openrouter" gets JSON mode and prevent accidental broadening of this behavior.

…ponse_format (Sourcery on PR #72)

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 <noreply@anthropic.com>
@thierryvm thierryvm merged commit 3df4641 into main May 12, 2026
5 checks passed
@thierryvm thierryvm deleted the fix/ai-response-format-json branch May 12, 2026 08:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant