fix(llm): no-cloud extraction — redirect ADK agents off Gemini + keep JSON mode on vLLM/Qwen (RES-944) - #337
fix(llm): no-cloud extraction — redirect ADK agents off Gemini + keep JSON mode on vLLM/Qwen (RES-944)#337jhkchan wants to merge 3 commits into
Conversation
…en) extraction works (RES-944)
The fact/entity extractors request structured output via
`GenerateContentConfig(response_mime_type="application/json")`. ADK's native
Gemini path honours that, so cloud extraction produced facts. But ADK's LiteLlm
wrapper builds `response_format` ONLY from `response_schema` and ignores
`response_mime_type` (confirmed in adk 2.2.0 `_get_completion_inputs`). The
extractors deliberately avoid `response_schema`/`output_schema` (ADK raises on
those before the recovery callback runs), so once an extractor is routed to a
self-hosted OpenAI-compatible endpoint (vLLM/Qwen) it goes out with no JSON
directive → the model returns prose → the recovery parser finds no fact array →
extraction persists 0 facts. This is the code blocker for the no-cloud path
(the `llm_use_litellm_for_gemini=False` default comment already documents the
same mime-drop for the Gemini-via-litellm case).
Add `JsonAwareLiteLlm(LiteLlm)` (lazily defined in model_resolver to keep ADK
off the import path) that bakes `response_format={"type": "json_object"}` into
`_additional_args`. ADK merges `_additional_args` OVER the computed args, so it
wins even when the computed `response_format` is None, on every completion for
that per-agent model object — immutable after construction, so concurrency-safe
across the pipeline's parallel batches. vLLM honours `json_object` via guided
decoding; litellm maps it back to `response_mime_type` for Gemini, so the cloud
path is unaffected.
Thread `force_json_object` through `resolve_model_object` → `resolve_model`;
the two extractors opt in. No-op for the native-Gemini bare-string path (which
already honours the mime type).
Adds tests/llm/test_json_aware_litellm.py (7): subclass/singleton, response_format
baked, explicit-format wins, vLLM forces json on both gemini-flag paths, plain
LiteLlm unforced, native-Gemini no-op. All pass.
NOTE: this is the code + unit-test half. Making live Qwen extraction produce
facts>0 also needs the operational step — Assignments pointing fact_extractor /
entity_extractor at the self-hosted vLLM endpoint (`vllm.votee.dev/v1`) — plus
the F2 token cap (RES-945) so the prompt fits Qwen's 64k window. End-to-end
"facts>0 on Qwen" must be validated on the POC box against a rebuilt stack, NOT
against prod.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMMM6KQXmzAEA42UxpyMUm
…i + fail closed (RES-944) The other half of RES-944. Even with `LLM_FAST_MODEL=openai/vllm-qwen`, the ADK ingestion agents kept calling cloud Gemini: `LLMProvider.resolve_model` resolves an agent's model as Assignment → `DEFAULT_AGENT_MODELS` → `llm_fast_model`, and `DEFAULT_AGENT_MODELS` hardcodes `gemini-*`, so the env var was shadowed and never took effect (matches the live repro: Settings loaded openai/vllm-qwen but extraction still hit `google.genai` 400s). Add `llm_self_hosted_only` (default False → no behaviour change): - Bypasses the hardcoded Gemini default map; every un-assigned agent uses the operator-configured `llm_fast_model`. An explicit per-agent Assignment still wins. Extracted the shared string resolution into `_base_model_string` so `resolve_model` and `get_model_string` stay in sync. - Routes un-assigned agents through `llm_default_endpoint_id` for the base_url + credential of the self-hosted endpoint. - FAILS CLOSED: `resolve_model` raises `ConfigurationError` if an agent still resolves to a cloud model, and `assert_self_hosted_only()` (startup assertion) reports every offending agent at once — so a no-cloud misconfig can never silently leak inference to Gemini. Adds tests/llm/test_self_hosted_only.py (15): _is_cloud_model matrix (incl. openai/gemini shim), default-map-shadows-env (the bug), cloud-mode bypass, explicit-assignment-wins, resolve+assert fail closed on cloud, pass when all self-hosted, no-op when flag off. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PMMM6KQXmzAEA42UxpyMUm
… review follow-up) Adversarial review found a hole in the `llm_self_hosted_only` "never leak" invariant: the fail-closed guard only rejected Gemini/Vertex models. If `llm_default_endpoint_id` is unset, typo'd, or not yet loaded into `_endpoint_meta`, an `openai/…` model reaches `resolve_model_object` with `api_base=None` — and litellm's `openai/` provider then DEFAULTS to https://api.openai.com, silently shipping on-prem document text to OpenAI cloud. Extend the no-cloud guard: also raise `ConfigurationError` when a resolved `openai/`-prefixed model has no `base_url`. A loaded self-hosted endpoint (which attaches base_url) passes; a missing/misconfigured one fails closed instead of leaking. Tests: +2 (openai-shim-no-base raises; loaded-endpoint-base passes). 24 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PMMM6KQXmzAEA42UxpyMUm
|
Independent adversarial review — the core fix is confirmed correct; one P1 egress hole found and now fixed in this PR. Verified against the installed ADK ( P1 (fixed in Remaining nits (follow-up, non-blocking):
|
RES-944 has two root causes that together kept extraction on cloud Gemini and producing 0 facts on the no-cloud path. This PR fixes both. All changes are gated behind default-off flags / opt-in params → zero behaviour change for existing cloud installs.
Part 1 — config could not redirect the ADK agents (primary ticket symptom)
Even with
LLM_FAST_MODEL=openai/vllm-qwen(andSettings()confirming it loaded), extraction still emittedgoogle.genai400s.LLMProvider.resolve_modelresolves an agent's model as Assignment →DEFAULT_AGENT_MODELS→llm_fast_model, andDEFAULT_AGENT_MODELShardcodesgemini-*, so the env var was shadowed and never took effect.Fix —
llm_self_hosted_only(default False):llm_fast_model. Explicit per-agent Assignments still win. Shared resolution extracted into_base_model_stringsoresolve_modelandget_model_stringcan't drift.llm_default_endpoint_idfor the self-hosted endpoint's base_url + credential.resolve_modelraisesConfigurationErrorif an agent still resolves to a cloud model;assert_self_hosted_only()(startup assertion) reports every offender at once — a no-cloud misconfig can never silently leak to Gemini.Part 2 — JSON mode was dropped once on vLLM (0 facts)
The fact/entity extractors request structured output via
response_mime_type="application/json". ADK's native Gemini path honours it, but ADK's LiteLlm wrapper buildsresponse_formatonly fromresponse_schemaand ignores the mime type (verified inadk==2.2.0_get_completion_inputs). So once an extractor reaches a self-hosted OpenAI-compatible endpoint the request carries no JSON directive → prose → recovery parser finds no fact array → 0 facts.Fix —
JsonAwareLiteLlm: lazily-definedLiteLlmsubclass that bakesresponse_format={"type":"json_object"}into_additional_args(ADK merges those over the computed args, so it wins even when the computedresponse_formatis None). Immutable after construction → concurrency-safe. Threaded viaforce_json_objectthroughresolve_model_object→resolve_model; the two extractors opt in. No-op for native Gemini. vLLM honoursjson_objectvia guided decoding; litellm maps it back toresponse_mime_typefor Gemini.Tests
tests/llm/test_self_hosted_only.py(15):_is_cloud_modelmatrix (incl.openai/geminishim), default-map-shadows-env (the bug), cloud-mode bypass, explicit-assignment-wins, resolve + assert fail-closed on cloud, pass when all self-hosted, no-op when flag off.tests/llm/test_json_aware_litellm.py(7): subclass/lazy-singleton,response_formatbaked, explicit format wins, vLLM forces json on both gemini-flag paths, plain LiteLlm unforced, native-Gemini no-op.tests/llmsuite otherwise green (the onetest_embedding_runtime::test_cache_returns_within_ttlfailure is a pre-existing flaky real-clock TTL test — reproduced identically onmainwith this PR's changes stashed; thetest_provider_failover_seamerrors are a pre-existingCREDENTIAL_MASTER_KEY-not-set env prerequisite, all pass once the key is exported).Operational note (not code)
Making live Qwen extraction produce
facts>0also requires: (1) settingllm_self_hosted_only=true+llm_default_endpoint_id(or per-agent Assignments) pointing atvllm.votee.dev/v1; (2) the F2 token cap (RES-945, #336) so the prompt fits Qwen's 64k window. End-to-end validation must run on the POC box against a rebuilt stack, never against prod.Part of epic RES-943 (RLP full-corpus scale + no-cloud gaps).
🤖 Generated with Claude Code
https://claude.ai/code/session_01PMMM6KQXmzAEA42UxpyMUm