Skip to content

Commit 5812c0c

Browse files
author
root
committed
fix: align claude and gemini response conversions
1 parent a2691b5 commit 5812c0c

2 files changed

Lines changed: 453 additions & 0 deletions

File tree

src/response.rs

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ pub fn maybe_transform_json_response(
1313
(Some(RequestFormat::OpenAiChat), Some(RequestFormat::OpenAiResponses)) => {
1414
openai_chat_to_openai_responses_response(body)
1515
}
16+
(Some(RequestFormat::ClaudeChat), Some(RequestFormat::OpenAiResponses)) => {
17+
claude_to_openai_responses_response(body)
18+
}
19+
(Some(RequestFormat::GeminiChat), Some(RequestFormat::OpenAiResponses)) => {
20+
gemini_to_openai_responses_response(body)
21+
}
1622
(Some(RequestFormat::OpenAiChat), Some(RequestFormat::ClaudeChat)) => {
1723
openai_chat_to_claude_response(body)
1824
}
@@ -262,6 +268,291 @@ fn openai_chat_to_openai_responses_response(body: Value) -> Result<Value, ApiErr
262268
Ok(response)
263269
}
264270

271+
fn claude_to_openai_responses_response(body: Value) -> Result<Value, ApiError> {
272+
let object = body.as_object().ok_or_else(|| {
273+
ApiError::new(StatusCode::BAD_GATEWAY, "response body is not a JSON object")
274+
.with_code("PROXY_ERROR")
275+
})?;
276+
277+
let content_blocks = object
278+
.get("content")
279+
.and_then(Value::as_array)
280+
.ok_or_else(|| {
281+
ApiError::new(StatusCode::BAD_GATEWAY, "claude response content is missing")
282+
.with_code("PROXY_ERROR")
283+
})?;
284+
285+
let response_id = object.get("id").and_then(Value::as_str).unwrap_or("resp");
286+
let mut output = Vec::new();
287+
let mut message_content = Vec::new();
288+
let mut reasoning_parts = Vec::new();
289+
290+
for block in content_blocks.iter().filter_map(Value::as_object) {
291+
match block.get("type").and_then(Value::as_str) {
292+
Some("text") => {
293+
if let Some(text) = block.get("text").and_then(Value::as_str) {
294+
if !text.is_empty() {
295+
message_content.push(json!({
296+
"type": "output_text",
297+
"text": text
298+
}));
299+
}
300+
}
301+
}
302+
Some("thinking") => {
303+
if let Some(text) = block.get("thinking").and_then(Value::as_str) {
304+
if !text.is_empty() {
305+
reasoning_parts.push(text.to_string());
306+
}
307+
}
308+
}
309+
Some("tool_use") => {
310+
let arguments = stringify_function_arguments(
311+
block.get("input").unwrap_or(&Value::Null),
312+
);
313+
output.push(json!({
314+
"type": "function_call",
315+
"id": block.get("id").cloned().unwrap_or_else(|| json!("")),
316+
"call_id": block.get("id").cloned().unwrap_or_else(|| json!("")),
317+
"name": block.get("name").cloned().unwrap_or_else(|| json!("")),
318+
"arguments": arguments
319+
}));
320+
}
321+
_ => {}
322+
}
323+
}
324+
325+
if !message_content.is_empty() {
326+
output.insert(0, json!({
327+
"type": "message",
328+
"id": format!("{response_id}_msg_0"),
329+
"role": "assistant",
330+
"content": message_content
331+
}));
332+
}
333+
334+
if !reasoning_parts.is_empty() {
335+
let reasoning_text = reasoning_parts.join("\n");
336+
let insert_at = usize::from(!output.is_empty() && output[0].get("type").and_then(Value::as_str) == Some("message"));
337+
output.insert(insert_at, json!({
338+
"type": "reasoning",
339+
"id": format!("{response_id}_reasoning_0"),
340+
"summary": [{
341+
"type": "summary_text",
342+
"text": reasoning_text
343+
}],
344+
"content": [{
345+
"type": "reasoning_text",
346+
"text": reasoning_text
347+
}]
348+
}));
349+
}
350+
351+
let stop_reason = object.get("stop_reason").and_then(Value::as_str);
352+
let status = match stop_reason {
353+
Some("max_tokens") => "incomplete",
354+
Some("error") => "failed",
355+
_ => "completed",
356+
};
357+
358+
let mut response = json!({
359+
"id": object.get("id").cloned().unwrap_or_else(|| json!("")),
360+
"object": "response",
361+
"model": object.get("model").cloned().unwrap_or_else(|| json!("")),
362+
"created_at": numeric_response_timestamp(object.get("created_at")),
363+
"status": status,
364+
"output": output
365+
});
366+
367+
if let Some(usage) = object.get("usage").and_then(Value::as_object) {
368+
let input_tokens = usage.get("input_tokens").cloned().unwrap_or_else(|| json!(0));
369+
let output_tokens = usage.get("output_tokens").cloned().unwrap_or_else(|| json!(0));
370+
let total_tokens = usage
371+
.get("total_tokens")
372+
.cloned()
373+
.unwrap_or_else(|| {
374+
json!(
375+
input_tokens.as_i64().unwrap_or(0) + output_tokens.as_i64().unwrap_or(0)
376+
)
377+
});
378+
response["usage"] = json!({
379+
"input_tokens": input_tokens,
380+
"output_tokens": output_tokens,
381+
"total_tokens": total_tokens
382+
});
383+
}
384+
385+
if let Some(response_obj) = response.as_object_mut() {
386+
for (key, value) in object {
387+
if matches!(
388+
key.as_str(),
389+
"id" | "type" | "role" | "content" | "model" | "created_at" | "stop_reason" | "usage"
390+
) {
391+
continue;
392+
}
393+
response_obj.insert(key.clone(), value.clone());
394+
}
395+
}
396+
397+
Ok(response)
398+
}
399+
400+
fn gemini_to_openai_responses_response(body: Value) -> Result<Value, ApiError> {
401+
let object = body.as_object().ok_or_else(|| {
402+
ApiError::new(StatusCode::BAD_GATEWAY, "response body is not a JSON object")
403+
.with_code("PROXY_ERROR")
404+
})?;
405+
406+
let candidate = object
407+
.get("candidates")
408+
.and_then(Value::as_array)
409+
.and_then(|candidates| candidates.first())
410+
.and_then(Value::as_object)
411+
.ok_or_else(|| {
412+
ApiError::new(StatusCode::BAD_GATEWAY, "gemini response candidates[0] is missing")
413+
.with_code("PROXY_ERROR")
414+
})?;
415+
416+
let response_id = object
417+
.get("responseId")
418+
.and_then(Value::as_str)
419+
.unwrap_or("gemini-response");
420+
let mut output = Vec::new();
421+
let mut message_content = Vec::new();
422+
let mut reasoning_parts = Vec::new();
423+
424+
if let Some(parts) = candidate
425+
.get("content")
426+
.and_then(Value::as_object)
427+
.and_then(|content| content.get("parts"))
428+
.and_then(Value::as_array)
429+
{
430+
for part in parts.iter().filter_map(Value::as_object) {
431+
if part.get("thought").and_then(Value::as_bool) == Some(true) {
432+
if let Some(text) = part.get("text").and_then(Value::as_str) {
433+
if !text.is_empty() {
434+
reasoning_parts.push(text.to_string());
435+
}
436+
}
437+
continue;
438+
}
439+
440+
if let Some(text) = part.get("text").and_then(Value::as_str) {
441+
if !text.is_empty() {
442+
message_content.push(json!({
443+
"type": "output_text",
444+
"text": text
445+
}));
446+
}
447+
continue;
448+
}
449+
450+
if let Some(function_call) = part.get("functionCall").and_then(Value::as_object) {
451+
let call_id = function_call
452+
.get("id")
453+
.cloned()
454+
.unwrap_or_else(|| json!("gemini_call"));
455+
output.push(json!({
456+
"type": "function_call",
457+
"id": call_id.clone(),
458+
"call_id": call_id,
459+
"name": function_call.get("name").cloned().unwrap_or_else(|| json!("")),
460+
"arguments": stringify_function_arguments(
461+
function_call.get("args").unwrap_or(&Value::Null)
462+
)
463+
}));
464+
}
465+
}
466+
}
467+
468+
if !message_content.is_empty() {
469+
output.insert(0, json!({
470+
"type": "message",
471+
"id": format!("{response_id}_msg_0"),
472+
"role": "assistant",
473+
"content": message_content
474+
}));
475+
}
476+
477+
if !reasoning_parts.is_empty() {
478+
let reasoning_text = reasoning_parts.join("\n");
479+
let insert_at = usize::from(!output.is_empty() && output[0].get("type").and_then(Value::as_str) == Some("message"));
480+
output.insert(insert_at, json!({
481+
"type": "reasoning",
482+
"id": format!("{response_id}_reasoning_0"),
483+
"summary": [{
484+
"type": "summary_text",
485+
"text": reasoning_text
486+
}],
487+
"content": [{
488+
"type": "reasoning_text",
489+
"text": reasoning_text
490+
}]
491+
}));
492+
}
493+
494+
let finish_reason = candidate.get("finishReason").and_then(Value::as_str);
495+
let status = match finish_reason {
496+
Some("MAX_TOKENS") => "incomplete",
497+
Some("ERROR") => "failed",
498+
_ => "completed",
499+
};
500+
501+
let mut response = json!({
502+
"id": response_id,
503+
"object": "response",
504+
"model": object.get("modelVersion").cloned().unwrap_or_else(|| json!("gemini")),
505+
"created_at": numeric_response_timestamp(object.get("createTime")),
506+
"status": status,
507+
"output": output
508+
});
509+
510+
if let Some(usage) = object.get("usageMetadata").and_then(Value::as_object) {
511+
let input_tokens = usage
512+
.get("promptTokenCount")
513+
.cloned()
514+
.unwrap_or_else(|| json!(0));
515+
let candidate_tokens = usage
516+
.get("candidatesTokenCount")
517+
.cloned()
518+
.unwrap_or_else(|| json!(0));
519+
let thought_tokens = usage
520+
.get("thoughtsTokenCount")
521+
.cloned()
522+
.unwrap_or_else(|| json!(0));
523+
let output_tokens = json!(
524+
candidate_tokens.as_i64().unwrap_or(0) + thought_tokens.as_i64().unwrap_or(0)
525+
);
526+
let total_tokens = usage
527+
.get("totalTokenCount")
528+
.cloned()
529+
.unwrap_or_else(|| {
530+
json!(
531+
input_tokens.as_i64().unwrap_or(0) + output_tokens.as_i64().unwrap_or(0)
532+
)
533+
});
534+
response["usage"] = json!({
535+
"input_tokens": input_tokens,
536+
"output_tokens": output_tokens,
537+
"total_tokens": total_tokens
538+
});
539+
}
540+
541+
if let Some(response_obj) = response.as_object_mut() {
542+
for (key, value) in object {
543+
if matches!(
544+
key.as_str(),
545+
"candidates" | "responseId" | "modelVersion" | "createTime" | "usageMetadata"
546+
) {
547+
continue;
548+
}
549+
response_obj.insert(key.clone(), value.clone());
550+
}
551+
}
552+
553+
Ok(response)
554+
}
555+
265556
fn openai_responses_to_openai_chat(body: Value) -> Result<Value, ApiError> {
266557
let object = body.as_object().ok_or_else(|| {
267558
ApiError::new(StatusCode::BAD_GATEWAY, "response body is not a JSON object")
@@ -563,3 +854,11 @@ fn parse_tool_arguments(arguments: &Value) -> Value {
563854
_ => json!({}),
564855
}
565856
}
857+
858+
fn numeric_response_timestamp(value: Option<&Value>) -> Value {
859+
match value {
860+
Some(Value::Number(number)) => Value::Number(number.clone()),
861+
Some(Value::String(text)) => text.parse::<i64>().map_or_else(|_| json!(0), |value| json!(value)),
862+
_ => json!(0),
863+
}
864+
}

0 commit comments

Comments
 (0)