Skip to content

Commit c4bf716

Browse files
authored
Merge pull request #6 from h2oai/mckinney/feat/guided-json-structured-output
feat(h2o): translate guided_json into per-provider structured output
2 parents 0a39d88 + 65c7145 commit c4bf716

2 files changed

Lines changed: 502 additions & 0 deletions

File tree

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
#!/usr/bin/env python3
2+
"""
3+
LiteLLM Hook to translate vLLM-style ``guided_json`` into per-provider structured output.
4+
5+
h2oGPT expresses "return JSON matching this schema" with a single vLLM-native
6+
``guided_json`` parameter (a JSON Schema dict), passed through ``extra_body``.
7+
That parameter only means something to a real vLLM server. When the same request
8+
is routed through the LiteLLM proxy to a non-vLLM provider (OpenAI/Azure, Gemini,
9+
Anthropic/Bedrock, ...), ``guided_json`` is meaningless and is silently stripped
10+
by ``AnthropicCachingHook._remove_vllm_params`` — so the model is never told the
11+
schema and can legitimately return ``{}``.
12+
13+
This hook runs FIRST (register it before the caching/params hooks) and converts
14+
``guided_json`` into the best structured-output mechanism the target model/provider
15+
actually supports, in three tiers:
16+
17+
1. **Strict schema** — if ``litellm.supports_response_schema(model)``:
18+
set ``response_format={"type":"json_schema","json_schema":{...}}``. LiteLLM
19+
then translates natively per provider (OpenAI/Azure structured outputs, Gemini
20+
``response_schema``, Anthropic/Bedrock ``json_tool_call`` tool).
21+
22+
2. **JSON mode** — elif the provider lists ``response_format`` in
23+
``get_supported_openai_params``: set ``response_format={"type":"json_object"}``
24+
and inject the schema (with required keys) into the prompt so the model knows
25+
the shape.
26+
27+
3. **Prompt only** — else: inject the schema + required-keys instruction into the
28+
prompt and leave ``response_format`` off (the provider can't enforce it).
29+
30+
In every case the raw ``guided_json`` (and sibling vLLM-only ``guided_*`` /
31+
``stop_token_ids``) is removed from the request so it cannot error downstream.
32+
33+
This is the proxy-side root-cause fix that complements the prompt-only stopgap in
34+
h2oai/h2ogpt_internal#889.
35+
"""
36+
37+
import os
38+
import json
39+
from typing import Any, Dict, List, Optional, Tuple
40+
41+
from litellm.integrations.custom_logger import CustomLogger
42+
43+
verbose = os.getenv('H2OGPT_VERBOSE', '0') == '1'
44+
verbose_full = os.getenv('H2OGPT_VERBOSE_FULL', '0') == '1'
45+
46+
# vLLM-only guided-decoding params that are meaningless to non-vLLM providers.
47+
VLLM_GUIDED_PARAMS = (
48+
'guided_json',
49+
'guided_regex',
50+
'guided_choice',
51+
'guided_grammar',
52+
'guided_whitespace_pattern',
53+
'guided_decoding_backend',
54+
)
55+
56+
# Prompt wording kept identical to h2ogpt's src/enums.py (json_schema_instruction0
57+
# / format_required_keys_instruction) so prompt-only behavior matches the backend.
58+
JSON_SCHEMA_INSTRUCTION = (
59+
'Ensure you follow this JSON schema, and ensure to use the same key names as '
60+
'the schema:\n```json\n%s\n```'
61+
)
62+
63+
64+
class GuidedJsonHook(CustomLogger):
65+
"""Translate ``guided_json`` into per-provider structured output before the call."""
66+
67+
def __init__(self):
68+
super().__init__()
69+
self.enabled = True
70+
if verbose or verbose_full:
71+
print(f"🧩 GuidedJsonHook: Initialized", flush=True)
72+
73+
# ------------------------------------------------------------------ helpers
74+
75+
def _extra_body_locations(self, data: Dict[str, Any]) -> List[Dict[str, Any]]:
76+
"""Return the dict containers that may hold extra_body params, in priority order.
77+
78+
h2ogpt sends params via the OpenAI client ``extra_body``, which the SDK
79+
flattens into the top-level request body; LiteLLM may also surface them
80+
under ``extra_body`` or ``litellm_params.extra_body``. Mirror the three
81+
locations AnthropicCachingHook checks.
82+
"""
83+
locations: List[Dict[str, Any]] = [data]
84+
extra_body = data.get('extra_body')
85+
if isinstance(extra_body, dict):
86+
locations.append(extra_body)
87+
litellm_params = data.get('litellm_params')
88+
if isinstance(litellm_params, dict):
89+
lb = litellm_params.get('extra_body')
90+
if isinstance(lb, dict):
91+
locations.append(lb)
92+
return locations
93+
94+
def _pop_guided_json(self, data: Dict[str, Any]) -> Optional[Any]:
95+
"""Pop guided_json (and sibling vLLM-only guided params) from all locations.
96+
97+
Returns the guided_json schema (first one found), or None.
98+
"""
99+
guided_json = None
100+
for container in self._extra_body_locations(data):
101+
for param in VLLM_GUIDED_PARAMS:
102+
if param in container:
103+
value = container.pop(param)
104+
if param == 'guided_json' and guided_json is None:
105+
guided_json = value
106+
# vLLM-only sampling param that also errors on most providers
107+
container.pop('stop_token_ids', None)
108+
return guided_json
109+
110+
@staticmethod
111+
def _coerce_schema(guided_json: Any) -> Optional[Dict[str, Any]]:
112+
"""Coerce guided_json (dict or JSON string) into a schema dict, or None."""
113+
if isinstance(guided_json, str):
114+
try:
115+
guided_json = json.loads(guided_json)
116+
except (json.JSONDecodeError, TypeError):
117+
return None
118+
return guided_json if isinstance(guided_json, dict) else None
119+
120+
@staticmethod
121+
def _properties_only(schema: Dict[str, Any]) -> Any:
122+
"""Reduce a wrapped object schema to just its ``properties`` for prompting.
123+
124+
Mirrors src/gen.py: the validation scaffolding (type/required/$defs/...)
125+
confuses weaker prompt-only models, so show only the shape.
126+
"""
127+
if isinstance(schema, dict) and 'properties' in schema:
128+
return schema['properties']
129+
return schema
130+
131+
@staticmethod
132+
def _required_keys(schema: Dict[str, Any]) -> List[str]:
133+
required = schema.get('required') if isinstance(schema, dict) else None
134+
if isinstance(required, (list, tuple)):
135+
return [k for k in required if isinstance(k, str)]
136+
return []
137+
138+
@classmethod
139+
def _is_strict_safe(cls, schema: Dict[str, Any]) -> bool:
140+
"""True only if the schema satisfies OpenAI strict mode requirements.
141+
142+
OpenAI strict structured outputs require ``additionalProperties: false`` and
143+
every property listed in ``required``. Setting strict on a schema with
144+
optional keys (e.g. the classifier's optional ``rationale``) would error,
145+
so only enable strict when it's provably safe.
146+
"""
147+
if not isinstance(schema, dict):
148+
return False
149+
props = schema.get('properties')
150+
if not isinstance(props, dict) or not props:
151+
return False
152+
if schema.get('additionalProperties', True) is not False:
153+
return False
154+
return set(cls._required_keys(schema)) >= set(props.keys())
155+
156+
def _inject_schema_into_prompt(self, data: Dict[str, Any], schema: Dict[str, Any]) -> None:
157+
"""Append a schema instruction (+ required keys) to the last user message.
158+
159+
Appending to the user turn (rather than adding a system message) is the most
160+
portable choice — some reasoning models reject system turns.
161+
"""
162+
properties_json = json.dumps(self._properties_only(schema))
163+
instruction = '\n\n' + (JSON_SCHEMA_INSTRUCTION % properties_json)
164+
required_keys = self._required_keys(schema)
165+
if required_keys:
166+
instruction += (
167+
'\nAll of these keys are required and must be present with a valid '
168+
'value: ' + ', '.join('"%s"' % k for k in required_keys) + '.'
169+
)
170+
171+
messages = data.get('messages')
172+
if not isinstance(messages, list) or not messages:
173+
data['messages'] = [{'role': 'user', 'content': instruction.lstrip()}]
174+
return
175+
176+
# Append to the last user-role message; fall back to the last message.
177+
target_idx = None
178+
for i in range(len(messages) - 1, -1, -1):
179+
if isinstance(messages[i], dict) and messages[i].get('role') == 'user':
180+
target_idx = i
181+
break
182+
if target_idx is None:
183+
target_idx = len(messages) - 1
184+
185+
msg = messages[target_idx]
186+
if not isinstance(msg, dict):
187+
messages.append({'role': 'user', 'content': instruction.lstrip()})
188+
return
189+
content = msg.get('content')
190+
if isinstance(content, str):
191+
msg['content'] = content + instruction
192+
elif isinstance(content, list):
193+
# Multimodal content: append a text part.
194+
content.append({'type': 'text', 'text': instruction})
195+
else:
196+
msg['content'] = instruction.lstrip()
197+
198+
@staticmethod
199+
def _provider_for(model: str) -> Optional[str]:
200+
try:
201+
import litellm
202+
_, provider, _, _ = litellm.get_llm_provider(model=model)
203+
return provider
204+
except Exception:
205+
return None
206+
207+
@staticmethod
208+
def _supports_response_schema(model: str, provider: Optional[str]) -> bool:
209+
try:
210+
import litellm
211+
return bool(litellm.supports_response_schema(model=model, custom_llm_provider=provider))
212+
except Exception:
213+
return False
214+
215+
@staticmethod
216+
def _supports_json_mode(model: str, provider: Optional[str]) -> bool:
217+
try:
218+
import litellm
219+
params = litellm.get_supported_openai_params(model=model, custom_llm_provider=provider) or []
220+
return 'response_format' in params
221+
except Exception:
222+
return False
223+
224+
# --------------------------------------------------------------- main hook
225+
226+
async def async_pre_call_hook(
227+
self,
228+
user_api_key_dict: Dict[str, Any],
229+
cache: Any,
230+
data: Dict[str, Any],
231+
call_type: str,
232+
) -> Dict[str, Any]:
233+
"""Translate guided_json -> per-provider structured output. Mutates and returns data."""
234+
try:
235+
if call_type not in ('completion', 'text_completion', None):
236+
return data
237+
238+
guided_json_raw = self._pop_guided_json(data)
239+
if guided_json_raw is None:
240+
return data # nothing to do; sibling params already cleaned
241+
242+
model = data.get('model', '') or ''
243+
schema = self._coerce_schema(guided_json_raw)
244+
245+
# Respect an explicit json_schema response_format from the caller — we
246+
# only removed the (now redundant) guided_json above.
247+
existing_rf = data.get('response_format')
248+
if isinstance(existing_rf, dict) and existing_rf.get('type') == 'json_schema':
249+
if verbose or verbose_full:
250+
print(f"🧩 GuidedJsonHook: caller already set json_schema for {model}; only removed guided_json", flush=True)
251+
return data
252+
253+
if schema is None:
254+
if verbose or verbose_full:
255+
print(f"🧩 GuidedJsonHook: guided_json not a usable schema for {model}; removed", flush=True)
256+
return data
257+
258+
provider = self._provider_for(model)
259+
260+
if self._supports_response_schema(model, provider):
261+
# Tier 1: native strict/structured schema.
262+
strict = self._is_strict_safe(schema)
263+
data['response_format'] = {
264+
'type': 'json_schema',
265+
'json_schema': {
266+
'name': 'response',
267+
'schema': schema,
268+
'strict': strict,
269+
},
270+
}
271+
if verbose or verbose_full:
272+
print(f"🧩 GuidedJsonHook: {model} ({provider}) -> json_schema (strict={strict})", flush=True)
273+
elif self._supports_json_mode(model, provider):
274+
# Tier 2: JSON mode + schema in prompt.
275+
data['response_format'] = {'type': 'json_object'}
276+
self._inject_schema_into_prompt(data, schema)
277+
if verbose or verbose_full:
278+
print(f"🧩 GuidedJsonHook: {model} ({provider}) -> json_object + prompt schema", flush=True)
279+
else:
280+
# Tier 3: prompt only; provider can't enforce response_format.
281+
data.pop('response_format', None)
282+
self._inject_schema_into_prompt(data, schema)
283+
if verbose or verbose_full:
284+
print(f"🧩 GuidedJsonHook: {model} ({provider}) -> prompt-only schema", flush=True)
285+
286+
return data
287+
288+
except Exception as e:
289+
print(f"🧩 GuidedJsonHook: Error in async_pre_call_hook: {e}", flush=True)
290+
import traceback
291+
traceback.print_exc()
292+
# On error, return data unchanged to not break the request.
293+
return data
294+
295+
296+
# Create the hook instance that LiteLLM will use
297+
if verbose or verbose_full:
298+
print(f"🧩 HOOK EXPORT: Creating guided_json_hook instance", flush=True)
299+
300+
guided_json_hook = GuidedJsonHook()

0 commit comments

Comments
 (0)