fix(databricks): adapt Responses API payload to Chat Completions format for GPT-5.x models

Databricks' serving-endpoints/responses endpoint expects Chat Completions
format (messages, text content types, function-wrapped tools), but
create_responses_request() builds OpenAI Responses API format (input,
input_text/output_text, flat tool definitions). This caused a 400 error
("Missing required Chat parameter: 'messages'") when using GPT-5.5 or
other responses-capable models via the Databricks provider.

Post-process the payload after create_responses_request() to convert:
- "input" → "messages" with content type renaming (input_text/output_text → text)
- function_call items → assistant tool_calls in Chat Completions format
- function_call_output items → tool-role messages
- Responses API tool format → Chat Completions {type, function: {...}} format
- reasoning object → reasoning_effort string
- max_output_tokens → max_completion_tokens
- Remove store field

Also switch response handler from stream_responses_compat to
stream_openai_compat since Databricks returns Chat Completions SSE format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
Matt Toohey
2026-05-14 22:10:33 +10:00
parent 7fc3537751
commit e7cc82abf5
+250 -2
View File
@@ -18,7 +18,7 @@ use super::formats::openai_responses::create_responses_request;
use super::oauth;
use super::openai_compatible::{
handle_response_openai_compat, handle_status, map_http_error_to_provider_error,
stream_openai_compat, stream_responses_compat,
stream_openai_compat,
};
use super::retry::ProviderRetry;
use super::utils::{ImageFormat, RequestLog};
@@ -383,6 +383,7 @@ impl Provider for DatabricksProvider {
if Self::is_responses_model(&model_config.model_name) {
let mut payload = create_responses_request(model_config, system, messages, tools)?;
adapt_responses_payload_for_databricks(&mut payload);
payload["stream"] = Value::Bool(true);
if let Some(ref client_request_id) = client_request_id {
payload["client_request_id"] = Value::String(client_request_id.clone());
@@ -404,7 +405,7 @@ impl Provider for DatabricksProvider {
let _ = log.error(e);
})?;
stream_responses_compat(response, log)
stream_openai_compat(response, log)
} else {
let mut payload =
create_request(model_config, system, messages, tools, &self.image_format)?;
@@ -585,6 +586,150 @@ impl EmbeddingCapable for DatabricksProvider {
}
}
/// Adapt a payload produced by `create_responses_request` (OpenAI Responses API format)
/// into the Chat Completions format that Databricks' `serving-endpoints/responses`
/// endpoint actually expects.
fn adapt_responses_payload_for_databricks(payload: &mut Value) {
let obj = match payload.as_object_mut() {
Some(o) => o,
None => return,
};
// Remove Responses-API-only fields
obj.remove("store");
// Convert reasoning: {"effort": "high", "summary": "auto"} → "reasoning_effort": "high"
if let Some(reasoning) = obj.remove("reasoning") {
if let Some(effort) = reasoning.get("effort").and_then(|e| e.as_str()) {
obj.insert("reasoning_effort".to_string(), json!(effort));
}
}
// max_output_tokens → max_completion_tokens
if let Some(max) = obj.remove("max_output_tokens") {
obj.insert("max_completion_tokens".to_string(), max);
}
// Rename "input" → "messages" and transform items
if let Some(input) = obj.remove("input") {
if let Some(items) = input.as_array() {
obj.insert(
"messages".to_string(),
json!(convert_responses_input_to_messages(items)),
);
}
}
// Convert Responses-API tool format to Chat Completions tool format:
// {type, name, description, parameters, strict} →
// {type: "function", function: {name, description, parameters}}
if let Some(tools) = obj.get_mut("tools") {
if let Some(tools_array) = tools.as_array_mut() {
for tool in tools_array.iter_mut() {
if let Some(tool_obj) = tool.as_object_mut() {
let name = tool_obj.remove("name");
let description = tool_obj.remove("description");
let parameters = tool_obj.remove("parameters");
tool_obj.remove("strict");
let mut function = serde_json::Map::new();
if let Some(n) = name {
function.insert("name".to_string(), n);
}
if let Some(d) = description {
function.insert("description".to_string(), d);
}
if let Some(p) = parameters {
function.insert("parameters".to_string(), p);
}
tool_obj.insert("function".to_string(), Value::Object(function));
}
}
}
}
}
fn convert_responses_input_to_messages(items: &[Value]) -> Vec<Value> {
let mut messages: Vec<Value> = Vec::new();
for item in items {
// Top-level typed items (function_call, function_call_output)
if let Some(item_type) = item.get("type").and_then(|t| t.as_str()) {
match item_type {
"function_call" => {
let tool_call = json!({
"id": item.get("call_id").cloned().unwrap_or(json!("")),
"type": "function",
"function": {
"name": item.get("name").cloned().unwrap_or(json!("")),
"arguments": item.get("arguments").cloned().unwrap_or(json!("{}"))
}
});
// Merge into the last assistant message if one exists
if let Some(last) = messages.last_mut() {
if last.get("role").and_then(|r| r.as_str()) == Some("assistant") {
let tool_calls = last
.as_object_mut()
.unwrap()
.entry("tool_calls")
.or_insert_with(|| json!([]));
tool_calls.as_array_mut().unwrap().push(tool_call);
continue;
}
}
messages.push(json!({
"role": "assistant",
"content": Value::Null,
"tool_calls": [tool_call]
}));
}
"function_call_output" => {
messages.push(json!({
"role": "tool",
"tool_call_id": item.get("call_id").cloned().unwrap_or(json!("")),
"content": item.get("output").cloned().unwrap_or(json!(""))
}));
}
_ => {}
}
continue;
}
// Role-based message items — convert content types
if item.get("role").is_some() {
let mut msg = item.clone();
if let Some(content) = msg.get_mut("content") {
if let Some(array) = content.as_array_mut() {
for entry in array.iter_mut() {
if let Some(t) = entry.get("type").and_then(|t| t.as_str()) {
match t {
"input_text" | "output_text" => {
entry
.as_object_mut()
.unwrap()
.insert("type".to_string(), json!("text"));
}
"input_image" => {
let obj = entry.as_object_mut().unwrap();
obj.insert("type".to_string(), json!("image_url"));
if let Some(url) = obj.remove("image_url") {
obj.insert("image_url".to_string(), json!({"url": url}));
}
}
_ => {}
}
}
}
}
}
messages.push(msg);
}
}
messages
}
#[cfg(test)]
mod tests {
use super::*;
@@ -613,6 +758,8 @@ mod tests {
for (model_name, expected_path) in [
("gpt-5.4", "serving-endpoints/responses"),
("gpt-5.5", "serving-endpoints/responses"),
("databricks-gpt-5.5-high", "serving-endpoints/responses"),
("databricks-gpt-5.4-high", "serving-endpoints/responses"),
("databricks-gpt-5-4-xhigh", "serving-endpoints/responses"),
("o3-mini", "serving-endpoints/responses"),
@@ -628,4 +775,105 @@ mod tests {
);
}
}
#[test]
fn adapt_responses_payload_converts_to_chat_completions() {
let mut payload = json!({
"model": "gpt-5.5",
"input": [
{
"role": "system",
"content": [{"type": "input_text", "text": "You are helpful."}]
},
{
"role": "user",
"content": [{"type": "input_text", "text": "Hello"}]
},
{
"role": "assistant",
"content": [{"type": "output_text", "text": "Hi there!"}]
},
{
"type": "function_call",
"call_id": "call_1",
"name": "my_tool",
"arguments": "{\"x\":1}"
},
{
"type": "function_call_output",
"call_id": "call_1",
"output": "result"
},
{
"role": "user",
"content": [{"type": "input_text", "text": "Thanks"}]
}
],
"tools": [
{
"type": "function",
"name": "my_tool",
"description": "A tool",
"parameters": {"type": "object", "properties": {}},
"strict": false
}
],
"store": false,
"max_output_tokens": 4096,
"reasoning": {"effort": "high", "summary": "auto"}
});
adapt_responses_payload_for_databricks(&mut payload);
// "store" removed
assert!(payload.get("store").is_none());
// reasoning converted
assert!(payload.get("reasoning").is_none());
assert_eq!(payload["reasoning_effort"], "high");
// max_output_tokens → max_completion_tokens
assert!(payload.get("max_output_tokens").is_none());
assert_eq!(payload["max_completion_tokens"], 4096);
// "input" renamed to "messages"
assert!(payload.get("input").is_none());
let messages = payload["messages"].as_array().unwrap();
// system message content types converted
assert_eq!(messages[0]["role"], "system");
assert_eq!(messages[0]["content"][0]["type"], "text");
assert_eq!(messages[0]["content"][0]["text"], "You are helpful.");
// user message content types converted
assert_eq!(messages[1]["role"], "user");
assert_eq!(messages[1]["content"][0]["type"], "text");
// assistant message with merged tool_calls
assert_eq!(messages[2]["role"], "assistant");
assert_eq!(messages[2]["content"][0]["type"], "text");
assert_eq!(messages[2]["content"][0]["text"], "Hi there!");
let tool_calls = messages[2]["tool_calls"].as_array().unwrap();
assert_eq!(tool_calls.len(), 1);
assert_eq!(tool_calls[0]["id"], "call_1");
assert_eq!(tool_calls[0]["function"]["name"], "my_tool");
assert_eq!(tool_calls[0]["function"]["arguments"], "{\"x\":1}");
// function_call_output → tool role message
assert_eq!(messages[3]["role"], "tool");
assert_eq!(messages[3]["tool_call_id"], "call_1");
assert_eq!(messages[3]["content"], "result");
// trailing user message
assert_eq!(messages[4]["role"], "user");
assert_eq!(messages[4]["content"][0]["type"], "text");
// tools converted to Chat Completions format
let tools = payload["tools"].as_array().unwrap();
assert_eq!(tools[0]["type"], "function");
assert_eq!(tools[0]["function"]["name"], "my_tool");
assert_eq!(tools[0]["function"]["description"], "A tool");
assert!(tools[0].get("name").is_none());
assert!(tools[0].get("strict").is_none());
}
}