|
| 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