From dfca8f81d6b5a79976534c063d6000e69e21dfd9 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:32:44 +0000 Subject: [PATCH] feat(translation): openai_compat serialize_request mirroring the v1 five-touch passthrough Body assembly = {model, messages, **params} with the original max-tokens key re-emitted, recursive cache_control strip on tool parameters (v1 filter_value_from_dict parity incl. the recursion cap), and fail-closed gates: o-series/gpt-5 families, top_k/thinking/ reasoning_effort, the user param's model-list gate, and response_format on gpt-4/gpt-3.5-turbo-16k. --- .../providers/openai_compat/params.py | 22 +++ .../providers/openai_compat/serialize.py | 168 ++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 litellm/translation/providers/openai_compat/serialize.py diff --git a/litellm/translation/providers/openai_compat/params.py b/litellm/translation/providers/openai_compat/params.py index 34f7bcb23f..5d3648d9ab 100644 --- a/litellm/translation/providers/openai_compat/params.py +++ b/litellm/translation/providers/openai_compat/params.py @@ -13,6 +13,12 @@ from __future__ import annotations from ...ir import ChatRequest +_RESPONSE_FORMAT_UNSUPPORTED_MODELS = ("gpt-4", "gpt-3.5-turbo-16k") +"""v1's get_supported_openai_params excludes response_format for exactly +these two model names (gpt_transformation.py:172-175) and then DROPS or +RAISES on it in get_optional_params; fail closed instead of re-deriving the +drop_params interplay.""" + def unsupported_model_family(model: str) -> str | None: base = model.split("/")[-1] @@ -35,4 +41,20 @@ def unsupported_params(request: ChatRequest) -> str | None: "reasoning_effort on a plain-GPT OpenAI model; " "v1's get_optional_params raises or drops it" ) + if request.user.is_some(): + return ( + "user param is gated on litellm.open_ai_chat_completion_models " + "membership in v1 (gpt_transformation.py:177-187); v1 handles it" + ) + return None + + +def unsupported_response_format(request: ChatRequest) -> str | None: + if request.response_format.is_none(): + return None + if request.model in _RESPONSE_FORMAT_UNSUPPORTED_MODELS: + return ( + f"response_format on {request.model}: outside v1's supported set " + "(gpt_transformation.py:172-175); v1 raises or drops it" + ) return None diff --git a/litellm/translation/providers/openai_compat/serialize.py b/litellm/translation/providers/openai_compat/serialize.py new file mode 100644 index 0000000000..a276722df4 --- /dev/null +++ b/litellm/translation/providers/openai_compat/serialize.py @@ -0,0 +1,168 @@ +"""Serialize the IR into an OpenAI ``/v1/chat/completions`` request body. + +v1's ``OpenAIGPTConfig.map_openai_params`` + ``transform_request`` +(llms/openai/chat/gpt_transformation.py:429-456) is a near-passthrough with +exactly five touches: string ``image_url`` -> object (+ litellm ``format`` +strip), pdf-URL ``file`` inlining (sync I/O, fails closed before this module: +the inbound schema has no file part), ``cache_control`` stripped recursively +from messages and tools, ``max_retries`` popped (an SDK kwarg, never in the +IR), and body assembly ``{model, messages, **optional_params}``. The shapes +this serializer admits are the ones the raw guard proved round-trip +losslessly; o-series and gpt-5 models fail closed until their param families +(``OpenAIOSeriesConfig``/``OpenAIGPT5Config``) are ported. +""" + +from __future__ import annotations + +from expression import Error, Ok, Option, Result +from expression.collections import Block +from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH +from typing_extensions import assert_never + +from ...deps import TranslationDeps +from ...errors import TranslationError +from ...ir import Body, ChatRequest, PlainJson, ResponseFormat, ToolChoice, ToolDef +from . import params as p +from .messages import serialize_messages + +_SerializeResult = Result[Body, TranslationError] + + +def serialize_request(request: ChatRequest, deps: TranslationDeps) -> _SerializeResult: + reason = ( + p.unsupported_model_family(request.model) + or p.unsupported_params(request) + or p.unsupported_response_format(request) + ) + if reason is not None: + return Error(TranslationError.of_unsupported(reason)) + messages = serialize_messages(request) + if isinstance(messages, TranslationError): + return Error(messages) + body: Body = { + "model": request.model, + "messages": messages, + **_present( + stream=True if request.stream else None, + **_max_tokens_fields(request), + temperature=request.params.temperature.default_value(None), + top_p=request.params.top_p.default_value(None), + stop=list(request.params.stop) if len(request.params.stop) > 0 else None, + tools=_tools_json(request.tools), + tool_choice=_tool_choice_json(request.tool_choice), + parallel_tool_calls=request.parallel_tool_calls.default_value(None), + response_format=_response_format_json(request.response_format), + ), + } + return Ok(body) + + +def _present(**fields: PlainJson | None) -> dict[str, PlainJson]: + return {key: value for key, value in fields.items() if value is not None} + + +def _max_tokens_fields(request: ChatRequest) -> dict[str, PlainJson | None]: + """Re-emit the caller's original key; the raw guard rejects requests + carrying both, so ``max_completion_tokens`` being set means it was the + one sent (``max_tokens`` then only holds the collapsed copy).""" + match request.params.max_completion_tokens: + case Option(tag="some", some=value): + return {"max_completion_tokens": value} + case _: + return {"max_tokens": request.params.max_tokens.default_value(None)} + + +def _tools_json(tools: Block[ToolDef]) -> PlainJson | None: + if len(tools) == 0: + return None + return [_tool_json(tool) for tool in tools] + + +def _tool_json(tool: ToolDef) -> PlainJson: + function: dict[str, PlainJson] = {"name": tool.name} + match tool.description: + case Option(tag="some", some=description): + function = {**function, "description": description} + case _: + pass + match tool.parameters: + case Option(tag="some", some=parameters): + function = {**function, "parameters": _strip_cache(parameters.value, 0)} + case _: + pass + match tool.strict: + case Option(tag="some", some=strict): + function = {**function, "strict": strict} + case _: + pass + return {"type": "function", "function": function} + + +def _strip_cache(value: PlainJson, depth: int) -> PlainJson: + """Mirror v1's ``filter_value_from_dict(tool, "cache_control")``: the key + is removed at every nesting level, with the same recursion cap.""" + if depth > DEFAULT_MAX_RECURSE_DEPTH: + return value + if isinstance(value, dict): + return { + key: _strip_cache(item, depth + 1) + for key, item in value.items() + if key != "cache_control" + } + if isinstance(value, list): + return [_strip_cache(item, depth + 1) for item in value] + return value + + +def _tool_choice_json(choice_opt: Option[ToolChoice]) -> PlainJson | None: + match choice_opt: + case Option(tag="some", some=choice): + pass + case _: + return None + match choice.tag: + case "auto": + return "auto" + case "required": + return "required" + case "none": + return "none" + case "specific": + return {"type": "function", "function": {"name": choice.specific}} + assert_never(choice.tag) + + +def _response_format_json( + response_format_opt: Option[ResponseFormat], +) -> PlainJson | None: + match response_format_opt: + case Option(tag="some", some=response_format): + pass + case _: + return None + match response_format.tag: + case "text": + return {"type": "text"} + case "json_object": + return {"type": "json_object"} + case "json_schema": + spec = response_format.json_schema + inner: dict[str, PlainJson] = {} + match spec.name: + case Option(tag="some", some=name): + inner = {**inner, "name": name} + case _: + pass + match spec.description: + case Option(tag="some", some=description): + inner = {**inner, "description": description} + case _: + pass + inner = {**inner, "schema": spec.schema.value} + match spec.strict: + case Option(tag="some", some=strict): + inner = {**inner, "strict": strict} + case _: + pass + return {"type": "json_schema", "json_schema": inner} + assert_never(response_format.tag)