diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aff2ba..2ff6bd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Renamed the active call-budget, fallback, run-log, and telemetry fields from OpenRouter-specific names to AI Gateway names. - AI Gateway requests rely on the existing JSON-only prompt and strict parser instead of `response_format=json_object`, which DeepSeek V4 rejects through the Gateway. - Added an explicit 32,768-token output ceiling and low-cost fallback recovery for HTTP 200 responses whose model content is truncated or otherwise invalid JSON. +- Normalize pure Markdown JSON fences before strict parsing so a valid fenced response does not spend a fallback call. - A zero AI Gateway call budget now completes as a real dry run instead of failing the empty-update guard. ## v0.19.5 - 2026-07-18 diff --git a/scripts/cloud_agent_runner.py b/scripts/cloud_agent_runner.py index aa021e3..e689fd3 100644 --- a/scripts/cloud_agent_runner.py +++ b/scripts/cloud_agent_runner.py @@ -1296,11 +1296,16 @@ def screening_schema_text(root: Path | None = None) -> str: ) -def parse_screening_json(text: str) -> dict[str, Any]: +def normalize_model_json_text(text: str) -> str: cleaned = text.strip() if cleaned.startswith("```"): - cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned) + cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned, flags=re.IGNORECASE) cleaned = re.sub(r"\s*```$", "", cleaned) + return cleaned.strip() + + +def parse_screening_json(text: str) -> dict[str, Any]: + cleaned = normalize_model_json_text(text) try: data = json.loads(cleaned) except json.JSONDecodeError: @@ -5523,7 +5528,7 @@ def call_ai_gateway_model(prompt: str, model: str) -> dict[str, Any]: if isinstance(parsed, dict) and parsed.get("error") and "choices" not in parsed: last_error = f"Vercel AI Gateway error envelope for {candidate_model}: {str(parsed.get('error'))[:300]}" continue - content = response_output_text(parsed).strip() + content = normalize_model_json_text(response_output_text(parsed)) if not content: # A 200 with empty content (provider hiccup): retry the fallback # chain instead of failing later with "Model did not return valid JSON: ". @@ -5544,6 +5549,15 @@ def call_ai_gateway_model(prompt: str, model: str) -> dict[str, Any]: f" (finish_reason={finish_reason or 'unknown'}): {content[:300]}" ) continue + # Models sometimes wrap an otherwise valid JSON object in a Markdown + # fence despite the system instruction. Normalize it once here so all + # downstream strict parsers receive the same valid JSON text without + # spending another fallback call. + choices = parsed.get("choices") if isinstance(parsed, dict) else None + if isinstance(choices, list) and choices and isinstance(choices[0], dict): + message = choices[0].get("message") + if isinstance(message, dict): + message["content"] = content return parsed raise SystemExit(last_error or "Vercel AI Gateway API error.") diff --git a/tests/test_cloud_agent_runner.py b/tests/test_cloud_agent_runner.py index 38eb950..db8b5de 100644 --- a/tests/test_cloud_agent_runner.py +++ b/tests/test_cloud_agent_runner.py @@ -2469,6 +2469,28 @@ def test_ai_gateway_call_falls_back_on_invalid_json_content(self) -> None: self.assertEqual(json.loads(cloud_agent_runner.response_output_text(parsed))["summary"], "ok") self.assertIn("model-a->model-b", cloud_agent_runner.RUN_AUDIT["fallbacks"]) + def test_ai_gateway_accepts_fenced_json_without_fallback(self) -> None: + response = mock.MagicMock() + response.__enter__.return_value.read.return_value = json.dumps( + { + "choices": [ + { + "message": {"content": '```json\n{"summary": "ok"}\n```'}, + "finish_reason": "stop", + } + ] + } + ).encode("utf-8") + with mock.patch.dict( + os.environ, + {"AI_GATEWAY_API_KEY": "x", "AI_GATEWAY_FALLBACK_MODELS": "model-b"}, + clear=False, + ): + with mock.patch.object(urllib.request, "urlopen", return_value=response) as urlopen_mock: + parsed = cloud_agent_runner.call_ai_gateway_model("prompt", "model-a") + self.assertEqual(json.loads(cloud_agent_runner.response_output_text(parsed))["summary"], "ok") + urlopen_mock.assert_called_once() + def test_max_response_chars_generous_for_all_tasks(self) -> None: # Issue #59 round 3: the daily legitimately produced 75.3k chars under # the v0.19 funnel; every task now gets the 96k cap by default.