mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +00:00
fix(oci): translate response_format json_schema to OCI's accepted shape (#29691)
* fix(oci): translate response_format json_schema to OCI's accepted shape
OCI GenAI rejected every json_schema response_format with HTTP 400
"Please pass in correct format of request", which broke structured-output
callers such as MLflow LLM judges (they always send a json_schema).
The provider forwarded OpenAI's raw json_schema body unchanged. For GENERIC
models OCI's ResponseJsonSchema accepts only name/description/schema/isStrict,
so OpenAI's `strict` key (and any other extra) 400s the request; the key must
be renamed to isStrict and the body whitelisted. For Cohere models there is no
JSON_SCHEMA type at all; the schema has to ride on JSON_OBJECT as
{"type": "JSON_OBJECT", "schema": ...}. Cohere type values must also be the
canonical uppercase TEXT/JSON_OBJECT.
_normalize_response_format now branches by vendor and emits the exact shape
each one accepts (verified live against OCI GenAI for Cohere, Meta, Gemini and
Grok). Drops the unused, incorrect Cohere response-format pydantic models.
Two existing tests asserted the broken behavior (lowercase type, raw
jsonSchema on Cohere); they are rewritten to assert the corrected shape, and
generic/Cohere json_schema regression tests are added.
* fix(oci): raise early on json_schema response_format with no body
A GENERIC model request with {"type": "json_schema"} and no json_schema
object fell through to the JSON_OBJECT branch and emitted a bodyless
{"type": "JSON_SCHEMA"}, which OCI rejects with an opaque HTTP 400. Raise a
descriptive 400 at translation time instead. Cohere is unaffected since it
always maps to JSON_OBJECT.
* test(oci): gateway integration test for response_format json_schema
Added to tests/integration/ (the real-network integration suite) reusing the
existing OCI proxy harness, not tests/llm_translation/ which is mock-only.
---------
Co-authored-by: Sameer Kankute <sameer@berri.ai>
This commit is contained in:
parent
3ff274207e
commit
460f45c3dd
5 changed files with 256 additions and 74 deletions
|
|
@ -199,19 +199,49 @@ def _normalize_response_format(selected_params: Dict, vendor: OCIVendors) -> Non
|
|||
rf = selected_params.get("responseFormat")
|
||||
if not isinstance(rf, dict) or "type" not in rf:
|
||||
return
|
||||
rf_payload = dict(rf)
|
||||
selected_params["responseFormat"] = rf_payload
|
||||
response_type = rf_payload["type"]
|
||||
if "json_schema" in rf_payload:
|
||||
raw_schema = rf_payload.pop("json_schema")
|
||||
rf_payload["jsonSchema"] = (
|
||||
dict(raw_schema) if isinstance(raw_schema, dict) else raw_schema
|
||||
)
|
||||
|
||||
rf_type = str(rf["type"]).lower()
|
||||
raw_schema = rf.get("json_schema")
|
||||
json_schema = raw_schema if isinstance(raw_schema, dict) else None
|
||||
|
||||
if rf_type == "text":
|
||||
selected_params["responseFormat"] = {"type": "TEXT"}
|
||||
return
|
||||
|
||||
if vendor == OCIVendors.COHERE:
|
||||
rf_payload["type"] = response_type
|
||||
else:
|
||||
fmt = response_type.upper()
|
||||
rf_payload["type"] = "JSON_OBJECT" if fmt == "JSON" else fmt
|
||||
# OCI Cohere has no JSON_SCHEMA type; a schema rides on JSON_OBJECT.
|
||||
payload: Dict[str, Any] = {"type": "JSON_OBJECT"}
|
||||
if json_schema is not None and json_schema.get("schema") is not None:
|
||||
payload["schema"] = json_schema["schema"]
|
||||
selected_params["responseFormat"] = payload
|
||||
return
|
||||
|
||||
if rf_type == "json_schema":
|
||||
if json_schema is None:
|
||||
raise OCIError(
|
||||
status_code=400,
|
||||
message="response_format type 'json_schema' requires a 'json_schema' object",
|
||||
)
|
||||
# OCI's ResponseJsonSchema accepts only name/description/schema/isStrict.
|
||||
# OpenAI sends `strict` instead of `isStrict`; forwarding it (or any
|
||||
# other extra key) makes OCI reject the whole request with HTTP 400.
|
||||
oci_schema: Dict[str, Any] = {"name": json_schema.get("name") or "response"}
|
||||
if json_schema.get("description") is not None:
|
||||
oci_schema["description"] = json_schema["description"]
|
||||
if json_schema.get("schema") is not None:
|
||||
oci_schema["schema"] = json_schema["schema"]
|
||||
if json_schema.get("strict") is not None:
|
||||
oci_schema["isStrict"] = json_schema["strict"]
|
||||
selected_params["responseFormat"] = {
|
||||
"type": "JSON_SCHEMA",
|
||||
"jsonSchema": oci_schema,
|
||||
}
|
||||
return
|
||||
|
||||
fmt = rf_type.upper()
|
||||
selected_params["responseFormat"] = {
|
||||
"type": "JSON_OBJECT" if fmt == "JSON" else fmt
|
||||
}
|
||||
|
||||
|
||||
def get_vendor_from_model(model: str) -> OCIVendors:
|
||||
|
|
|
|||
|
|
@ -291,25 +291,6 @@ class CohereToolResult(BaseModel):
|
|||
outputs: List[Dict[str, Any]]
|
||||
|
||||
|
||||
class CohereResponseFormat(BaseModel):
|
||||
"""Response format for Cohere."""
|
||||
|
||||
type: str
|
||||
|
||||
|
||||
class CohereResponseTextFormat(CohereResponseFormat):
|
||||
"""Text response format for Cohere."""
|
||||
|
||||
type: Literal["text"] = "text"
|
||||
|
||||
|
||||
class CohereResponseJSONSchemaFormat(CohereResponseFormat):
|
||||
"""JSON schema response format for Cohere."""
|
||||
|
||||
type: Literal["json_schema"] = "json_schema"
|
||||
jsonSchema: Dict[str, Any]
|
||||
|
||||
|
||||
class CohereChatRequest(BaseModel):
|
||||
"""Cohere chat request model."""
|
||||
|
||||
|
|
@ -336,13 +317,10 @@ class CohereChatRequest(BaseModel):
|
|||
# ``OCIChatConfig.openai_to_oci_cohere_param_map`` which marks
|
||||
# ``tool_choice`` as unsupported. The field is intentionally absent here
|
||||
# so it isn't silently dropped or surfaced as a supported feature.
|
||||
responseFormat: Optional[
|
||||
Union[
|
||||
CohereResponseTextFormat,
|
||||
CohereResponseJSONSchemaFormat,
|
||||
CohereResponseFormat,
|
||||
]
|
||||
] = None
|
||||
# OCI Cohere responseFormat is {"type": "TEXT" | "JSON_OBJECT", "schema"?: ...};
|
||||
# there is no JSON_SCHEMA type. The shape is built in
|
||||
# OCIChatConfig._normalize_response_format.
|
||||
responseFormat: Optional[Dict[str, Any]] = None
|
||||
preambleOverride: Optional[str] = None
|
||||
documents: Optional[List[Dict[str, Any]]] = None
|
||||
searchQueriesOnly: Optional[bool] = None
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ locally-running proxy.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
|
|
@ -41,7 +42,6 @@ from typing import Iterator
|
|||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skip gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -79,7 +79,9 @@ def _wait_for_health(base_url: str, proc: subprocess.Popen, deadline: float) ->
|
|||
except httpx.HTTPError:
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError(f"litellm proxy did not become ready within {STARTUP_TIMEOUT_S}s")
|
||||
raise RuntimeError(
|
||||
f"litellm proxy did not become ready within {STARTUP_TIMEOUT_S}s"
|
||||
)
|
||||
|
||||
|
||||
def _oci_env_from_profile() -> dict[str, str]:
|
||||
|
|
@ -206,9 +208,7 @@ def test_chat_completion_via_proxy(proxy_url: str, model: str) -> None:
|
|||
# Reasoning models may return empty content if their budget covers only
|
||||
# the thinking turn — accept either text or a non-empty reasoning field.
|
||||
has_content = bool(msg.get("content"))
|
||||
has_reasoning = bool(msg.get("reasoning_content")) or bool(
|
||||
msg.get("reasoning")
|
||||
)
|
||||
has_reasoning = bool(msg.get("reasoning_content")) or bool(msg.get("reasoning"))
|
||||
assert has_content or has_reasoning, f"empty assistant message for {model}: {msg}"
|
||||
usage = body.get("usage") or {}
|
||||
assert usage.get("total_tokens", 0) > 0
|
||||
|
|
@ -232,7 +232,7 @@ def test_chat_completion_streaming_via_proxy(proxy_url: str, model: str) -> None
|
|||
continue
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
payload = line[len("data:"):].strip()
|
||||
payload = line[len("data:") :].strip()
|
||||
if payload == "[DONE]":
|
||||
saw_done = True
|
||||
break
|
||||
|
|
@ -274,9 +274,54 @@ def test_model_list_advertises_oci_models(proxy_url: str) -> None:
|
|||
assert expected in advertised, f"{expected} missing from /v1/models: {advertised}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", ["oci-cohere-command", "oci-llama"])
|
||||
def test_response_format_json_schema_via_proxy(proxy_url: str, model: str) -> None:
|
||||
"""A response_format json_schema succeeds through the gateway for both a
|
||||
Cohere and a generic OCI model.
|
||||
Regression for the HTTP 400 ``Please pass in correct format of request``
|
||||
that rejected every json_schema request (which MLflow LLM judges always
|
||||
send): generic models choke on OpenAI's ``strict`` key, and Cohere has no
|
||||
JSON_SCHEMA type.
|
||||
"""
|
||||
r = httpx.post(
|
||||
f"{proxy_url}/v1/chat/completions",
|
||||
headers=_auth_headers(),
|
||||
json={
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Rate the answer 4 to 2+2. Give an integer score and a short rationale.",
|
||||
}
|
||||
],
|
||||
"max_tokens": 200,
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "judgment",
|
||||
"strict": True,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"score": {"type": "integer"},
|
||||
"rationale": {"type": "string"},
|
||||
},
|
||||
"required": ["score", "rationale"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
timeout=REQUEST_TIMEOUT_S,
|
||||
)
|
||||
assert r.status_code == 200, f"{model} json_schema -> {r.status_code}: {r.text}"
|
||||
content = r.json()["choices"][0]["message"]["content"]
|
||||
assert content is not None
|
||||
assert "score" in json.loads(content)
|
||||
|
||||
|
||||
def test_omitted_max_tokens_not_truncated(proxy_url: str) -> None:
|
||||
"""A request that omits max_tokens completes instead of being cut off.
|
||||
|
||||
Regression for OCI's tiny server-side maxTokens default (~20 tokens): without
|
||||
an injected default, a request that doesn't set max_tokens came back with
|
||||
finish_reason "length" after ~19 tokens, so structured outputs (e.g. MLflow
|
||||
|
|
@ -308,4 +353,4 @@ def test_omitted_max_tokens_not_truncated(proxy_url: str) -> None:
|
|||
assert content.strip(), f"empty content: {choice}"
|
||||
# The ~20-token server default truncated well before this; a complete
|
||||
# four-to-five sentence answer comfortably exceeds it.
|
||||
assert body["usage"]["completion_tokens"] > 50, body["usage"]
|
||||
assert body["usage"]["completion_tokens"] > 50, body["usage"]
|
||||
|
|
@ -364,6 +364,137 @@ class TestOCIChatConfig:
|
|||
rf = transformed_request["chatRequest"]["responseFormat"]
|
||||
assert rf["type"] == "JSON_OBJECT"
|
||||
|
||||
def test_transform_request_response_format_json_schema_generic(self):
|
||||
"""A GENERIC json_schema must become OCI's JSON_SCHEMA shape with the
|
||||
OpenAI ``strict`` key renamed to ``isStrict``.
|
||||
|
||||
OCI's ResponseJsonSchema rejects ``strict`` (and any other extra key)
|
||||
with HTTP 400 "Please pass in correct format of request", so the raw
|
||||
OpenAI body must not be forwarded.
|
||||
"""
|
||||
config = OCIChatConfig()
|
||||
optional_params = {
|
||||
"oci_compartment_id": TEST_COMPARTMENT_ID,
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "judgment",
|
||||
"description": "a score and rationale",
|
||||
"strict": True,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"score": {"type": "integer"}},
|
||||
"required": ["score"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
transformed_request = config.transform_request(
|
||||
model=TEST_MODEL_NAME, # xai.grok-4 -> GENERIC
|
||||
messages=TEST_MESSAGES, # type: ignore
|
||||
optional_params=optional_params,
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
rf = transformed_request["chatRequest"]["responseFormat"]
|
||||
assert rf["type"] == "JSON_SCHEMA"
|
||||
assert "strict" not in rf["jsonSchema"]
|
||||
assert rf["jsonSchema"]["isStrict"] is True
|
||||
assert rf["jsonSchema"]["name"] == "judgment"
|
||||
assert rf["jsonSchema"]["description"] == "a score and rationale"
|
||||
assert rf["jsonSchema"]["schema"]["properties"]["score"]["type"] == "integer"
|
||||
|
||||
def test_transform_request_response_format_json_schema_generic_no_strict(self):
|
||||
"""A GENERIC json_schema without ``strict`` must omit ``isStrict``."""
|
||||
config = OCIChatConfig()
|
||||
optional_params = {
|
||||
"oci_compartment_id": TEST_COMPARTMENT_ID,
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {"name": "j", "schema": {"type": "object"}},
|
||||
},
|
||||
}
|
||||
transformed_request = config.transform_request(
|
||||
model=TEST_MODEL_NAME,
|
||||
messages=TEST_MESSAGES, # type: ignore
|
||||
optional_params=optional_params,
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
rf = transformed_request["chatRequest"]["responseFormat"]
|
||||
assert rf["type"] == "JSON_SCHEMA"
|
||||
assert "isStrict" not in rf["jsonSchema"]
|
||||
|
||||
def test_transform_request_response_format_json_schema_cohere(self):
|
||||
"""A Cohere json_schema must fold the schema onto JSON_OBJECT.
|
||||
|
||||
OCI Cohere has no JSON_SCHEMA type; sending one yields HTTP 400.
|
||||
"""
|
||||
config = OCIChatConfig()
|
||||
optional_params = {
|
||||
"oci_compartment_id": TEST_COMPARTMENT_ID,
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "judgment",
|
||||
"strict": True,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"score": {"type": "integer"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
transformed_request = config.transform_request(
|
||||
model="cohere.command-latest",
|
||||
messages=TEST_MESSAGES, # type: ignore
|
||||
optional_params=optional_params,
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
rf = transformed_request["chatRequest"]["responseFormat"]
|
||||
assert rf["type"] == "JSON_OBJECT"
|
||||
assert "jsonSchema" not in rf
|
||||
assert rf["schema"]["properties"]["score"]["type"] == "integer"
|
||||
|
||||
def test_transform_request_response_format_cohere_json_object(self):
|
||||
"""Cohere json_object without a schema stays a bare JSON_OBJECT."""
|
||||
config = OCIChatConfig()
|
||||
optional_params = {
|
||||
"oci_compartment_id": TEST_COMPARTMENT_ID,
|
||||
"response_format": {"type": "json_object"},
|
||||
}
|
||||
transformed_request = config.transform_request(
|
||||
model="cohere.command-latest",
|
||||
messages=TEST_MESSAGES, # type: ignore
|
||||
optional_params=optional_params,
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
rf = transformed_request["chatRequest"]["responseFormat"]
|
||||
assert rf == {"type": "JSON_OBJECT"}
|
||||
|
||||
def test_transform_request_json_schema_without_body_raises_generic(self):
|
||||
"""A GENERIC json_schema with no ``json_schema`` body must raise an early
|
||||
400, not silently emit {"type": "JSON_SCHEMA"} (which OCI rejects)."""
|
||||
from litellm.llms.oci.common_utils import OCIError
|
||||
|
||||
config = OCIChatConfig()
|
||||
optional_params = {
|
||||
"oci_compartment_id": TEST_COMPARTMENT_ID,
|
||||
"response_format": {"type": "json_schema"},
|
||||
}
|
||||
with pytest.raises(OCIError) as exc_info:
|
||||
config.transform_request(
|
||||
model=TEST_MODEL_NAME, # GENERIC
|
||||
messages=TEST_MESSAGES, # type: ignore
|
||||
optional_params=optional_params,
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "json_schema" in str(exc_info.value)
|
||||
|
||||
def test_transform_response_without_token_details(self):
|
||||
"""
|
||||
Tests that responses missing completionTokensDetails and promptTokensDetails
|
||||
|
|
|
|||
|
|
@ -237,25 +237,30 @@ class TestOCICohereToolCalls:
|
|||
assert result.usage.completion_tokens == 22
|
||||
assert result.usage.total_tokens == 48
|
||||
|
||||
def test_cohere_request_preserves_json_schema_response_format(self):
|
||||
"""Ensure Cohere requests retain JSON schema payloads in responseFormat."""
|
||||
def test_cohere_request_folds_json_schema_into_json_object(self):
|
||||
"""A Cohere json_schema must fold the schema onto JSON_OBJECT.
|
||||
|
||||
OCI Cohere has no JSON_SCHEMA type; sending {"type": "JSON_SCHEMA", ...}
|
||||
(or the raw lowercase "json_schema" with a jsonSchema body) is rejected
|
||||
with HTTP 400. The schema rides on JSON_OBJECT instead.
|
||||
"""
|
||||
config = OCIChatConfig()
|
||||
messages = [{"role": "user", "content": "Return structured info"}]
|
||||
response_format = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "test_schema",
|
||||
"strict": True,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"foo": {"type": "string"}},
|
||||
"required": ["foo"],
|
||||
},
|
||||
},
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"foo": {"type": "string"}},
|
||||
"required": ["foo"],
|
||||
}
|
||||
optional_params = {
|
||||
"oci_compartment_id": TEST_COMPARTMENT_ID,
|
||||
"response_format": response_format,
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "test_schema",
|
||||
"strict": True,
|
||||
"schema": schema,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
transformed_request = config.transform_request(
|
||||
|
|
@ -266,18 +271,14 @@ class TestOCICohereToolCalls:
|
|||
headers={},
|
||||
)
|
||||
|
||||
chat_request = transformed_request["chatRequest"]
|
||||
assert chat_request["apiFormat"] == "COHERE"
|
||||
assert "responseFormat" in chat_request
|
||||
|
||||
cohere_response_format = chat_request["responseFormat"]
|
||||
assert cohere_response_format["type"] == "json_schema"
|
||||
cohere_response_format = transformed_request["chatRequest"]["responseFormat"]
|
||||
assert cohere_response_format["type"] == "JSON_OBJECT"
|
||||
assert "jsonSchema" not in cohere_response_format
|
||||
assert "json_schema" not in cohere_response_format
|
||||
assert "jsonSchema" in cohere_response_format
|
||||
assert cohere_response_format["jsonSchema"] == response_format["json_schema"]
|
||||
assert cohere_response_format["schema"] == schema
|
||||
|
||||
def test_cohere_request_response_format_text_stays_lowercase(self):
|
||||
"""Ensure Cohere keeps response_format type lowercase (e.g. 'text' not 'TEXT')."""
|
||||
def test_cohere_request_response_format_text_is_uppercased(self):
|
||||
"""Cohere response_format type 'text' maps to OCI's canonical 'TEXT'."""
|
||||
config = OCIChatConfig()
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
optional_params = {
|
||||
|
|
@ -293,10 +294,7 @@ class TestOCICohereToolCalls:
|
|||
headers={},
|
||||
)
|
||||
|
||||
chat_request = transformed_request["chatRequest"]
|
||||
assert chat_request["apiFormat"] == "COHERE"
|
||||
assert "responseFormat" in chat_request
|
||||
assert chat_request["responseFormat"]["type"] == "text"
|
||||
assert transformed_request["chatRequest"]["responseFormat"] == {"type": "TEXT"}
|
||||
|
||||
def test_cohere_tool_call_only_message_no_text(self):
|
||||
"""Test chat history with an assistant message that has tool calls but no text content."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue