Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 17 additions & 3 deletions scripts/cloud_agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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: ".
Expand All @@ -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.")

Expand Down
22 changes: 22 additions & 0 deletions tests/test_cloud_agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading