gproxy-protocol 4.1.11

GPROXY v4 connection modelling, operation declarations, vendor wire types and pairwise conversion
Documentation
use super::ResponseSupplement;
use super::util::parse_result_arguments;
use crate::transform::generate::reasoning_details as rd;
use crate::{
    Rest,
    transform::{Converted, Report, TransformError},
    wire::{claude::generate_content as cg, openai::chat},
};

pub fn claude_response_to_openai(
    input: &cg::GenerateContentResponseBody,
    target_model: impl Into<String>,
    supplement: &ResponseSupplement,
) -> Result<Converted<chat::GenerateContentResponseBody>, TransformError> {
    let mut report = Report::default();
    let mut text = Vec::new();
    let mut reasoning = Vec::new();
    let mut details = Vec::new();
    let mut tool_calls = Vec::new();
    for (index, block) in input.content.iter().enumerate() {
        match block {
            cg::ResponseContentBlock::Text(block) => {
                if block.citations.is_some() {
                    report.omitted(
                        "content.text.citations",
                        "Claude citation locations need a target citation adapter",
                    );
                }
                text.push(block.text.clone());
            }
            cg::ResponseContentBlock::ToolUse(v)
                if v.toolset_name.as_ref().is_some_and(Option::is_some) =>
            {
                report.omitted(
                    "toolset_name",
                    "native toolset member has no target definition",
                );
                continue;
            }
            cg::ResponseContentBlock::ToolUse(block) => {
                tool_calls.push(chat::MessageToolCall::Function(chat::ChatToolCall {
                    id: super::util::response_id(&block.id)?,
                    function: chat::FunctionCall {
                        name: block.name.clone(),
                        arguments: serde_json::to_string(&block.input)?,
                        rest: Rest::new(),
                    },
                    type_: chat::ChatToolCallType::Function,
                    rest: Rest::new(),
                }))
            }
            cg::ResponseContentBlock::Thinking(block) => {
                reasoning.push(block.thinking.clone());
                details.push(rd::from_thinking(block, index as i64));
            }
            cg::ResponseContentBlock::RedactedThinking(block) => {
                details.push(rd::from_redacted(block, index as i64))
            }
            cg::ResponseContentBlock::ServerToolUse(_)
            | cg::ResponseContentBlock::WebSearchToolResult(_)
            | cg::ResponseContentBlock::WebFetchToolResult(_)
            | cg::ResponseContentBlock::AdvisorToolResult(_)
            | cg::ResponseContentBlock::CodeExecutionToolResult(_)
            | cg::ResponseContentBlock::BashCodeExecutionToolResult(_)
            | cg::ResponseContentBlock::TextEditorCodeExecutionToolResult(_)
            | cg::ResponseContentBlock::ToolSearchToolResult(_)
            | cg::ResponseContentBlock::McpToolUse(_)
            | cg::ResponseContentBlock::McpToolResult(_)
            | cg::ResponseContentBlock::ContainerUpload(_)
            | cg::ResponseContentBlock::McpToolListing(_)
            | cg::ResponseContentBlock::Compaction(_)
            | cg::ResponseContentBlock::Fallback(_) => {
                continue;
            }
        }
    }
    let finish_reason = match input.stop_reason {
        cg::StopReason::MaxTokens | cg::StopReason::ModelContextWindowExceeded => {
            chat::FinishReason::Length
        }
        cg::StopReason::ToolUse if tool_calls.is_empty() && input.content.iter().any(|block| matches!(block, cg::ResponseContentBlock::ToolUse(v) if v.toolset_name.as_ref().is_some_and(Option::is_some))) => chat::FinishReason::Stop,
        cg::StopReason::ToolUse => chat::FinishReason::ToolCalls,
        cg::StopReason::Refusal => chat::FinishReason::ContentFilter,
        cg::StopReason::PauseTurn | cg::StopReason::Compaction => chat::FinishReason::Stop,
        cg::StopReason::EndTurn | cg::StopReason::StopSequence => chat::FinishReason::Stop,
    };
    for (present, field) in [
        (input.container.is_some(), "container"),
        (input.context_management.is_some(), "context_management"),
        (input.diagnostics.is_some(), "diagnostics"),
        (
            input.input_transformations.is_some(),
            "input_transformations",
        ),
        (input.stop_details.is_some(), "stop_details"),
        (input.stop_sequence.is_some(), "stop_sequence"),
    ] {
        if present {
            report.omitted(field, "Chat response has no equivalent metadata field");
        }
    }
    let created = supplement
        .created_unix_seconds
        .ok_or_else(|| TransformError::missing_metadata("response.created"))?;
    let usage = super::usage::to_chat(&input.usage, &mut report)?;
    Ok(Converted {
        value: chat::GenerateContentResponseBody {
            id: input.id.clone(),
            choices: vec![chat::Choice {
                finish_reason,
                index: 0,
                logprobs: None,
                message: chat::ResponseMessage {
                    reasoning_details: (!details.is_empty()).then_some(Some(details)),
                    reasoning_content: (!reasoning.is_empty()).then(|| Some(reasoning.join(""))),
                    reasoning: None,
                    content: (!text.is_empty()
                        && !matches!(input.stop_reason, cg::StopReason::Refusal))
                    .then_some(text.join("")),
                    refusal: (matches!(input.stop_reason, cg::StopReason::Refusal))
                        .then_some(text.join("")),
                    role: chat::ResponseRole::Assistant,
                    annotations: None,
                    audio: None,
                    function_call: None,
                    tool_calls: (!tool_calls.is_empty()).then_some(tool_calls),
                    rest: Rest::new(),
                },
                rest: Rest::new(),
            }],
            created,
            model: target_model.into(),
            object: chat::CompletionObject::ChatCompletion,
            service_tier: None,
            system_fingerprint: None,
            usage: Some(usage),
            moderation: None,
            rest: Rest::new(),
        },
        report,
    })
}

pub fn openai_response_to_claude(
    input: &chat::GenerateContentResponseBody,
    target_model: impl Into<String>,
    _supplement: &ResponseSupplement,
) -> Result<Converted<cg::GenerateContentResponseBody>, TransformError> {
    let mut report = Report::default();
    let choice = input
        .choices
        .first()
        .ok_or_else(|| TransformError::invalid_result("choices", "Chat response has no choices"))?;
    if input.choices.len() != 1 {
        report.omitted("choices", "target carries the first choice");
    }
    if choice.index != 0 {
        return Err(TransformError::invalid_result(
            "choices.index",
            "single response choice must have index zero",
        ));
    }
    if choice.message.audio.is_some() {
        report.omitted("choices.message.audio", "no target audio block");
    }
    if choice.message.function_call.is_some() {
        report.omitted("message.function_call", "call identity is unavailable");
    }
    for (present, field) in [
        (choice.logprobs.is_some(), "choices.logprobs"),
        (
            choice.message.annotations.is_some(),
            "choices.message.annotations",
        ),
        (input.service_tier.is_some(), "service_tier"),
        (input.system_fingerprint.is_some(), "system_fingerprint"),
        (input.moderation.is_some(), "moderation"),
    ] {
        if present {
            report.omitted(field, "Claude response has no equivalent metadata field");
        }
    }
    let mut content = Vec::new();
    let restored = rd::to_claude(
        choice
            .message
            .reasoning_details
            .as_ref()
            .and_then(Option::as_deref)
            .unwrap_or(&[]),
    );
    let has_restored = !restored.is_empty();
    for block in restored {
        match block {
            crate::wire::claude::content::ContentBlock::Thinking(block) => {
                content.push(cg::ResponseContentBlock::Thinking(block))
            }
            crate::wire::claude::content::ContentBlock::RedactedThinking(block) => {
                content.push(cg::ResponseContentBlock::RedactedThinking(block))
            }
            _ => unreachable!(),
        }
    }
    if !has_restored
        && let Some(text) = chat::visible_reasoning(
            &choice.message.reasoning_content,
            &choice.message.reasoning,
            &choice.message.reasoning_details,
        )
    {
        content.push(cg::ResponseContentBlock::Thinking(
            crate::wire::claude::content::ThinkingBlock::builder(
                crate::wire::claude::content::ThinkingBlockType::Tag,
                String::new(),
                text,
            )
            .build(),
        ));
        report.changed(
            "message.reasoning_content",
            "plain reasoning has no replayable Claude signature",
        );
    }

    if let Some(text) = &choice.message.content {
        content.push(cg::ResponseContentBlock::Text(cg::ResponseTextBlock {
            type_: cg::ResponseTextBlockType::Tag,
            citations: None,
            text: text.clone(),
            rest: Rest::new(),
        }));
    }
    if let Some(refusal) = &choice.message.refusal {
        content.push(cg::ResponseContentBlock::Text(cg::ResponseTextBlock {
            type_: cg::ResponseTextBlockType::Tag,
            citations: None,
            text: refusal.clone(),
            rest: Rest::new(),
        }));
    }
    if let Some(calls) = &choice.message.tool_calls {
        for call in calls {
            if let chat::MessageToolCall::Function(call) = call {
                let input = parse_result_arguments(
                    &call.function.arguments,
                    "choices.message.tool_calls.arguments",
                )?;
                let input = input.as_object().cloned().expect("object checked");
                content.push(cg::ResponseContentBlock::ToolUse(
                    cg::ResponseToolUseBlock {
                        toolset_name: None,
                        type_: cg::ResponseToolUseBlockType::Tag,
                        id: super::util::response_id(&call.id)?,
                        input,
                        name: call.function.name.clone(),
                        caller: None,
                        rest: Rest::new(),
                    },
                ));
            } else {
                continue;
            }
        }
    }
    let usage = input
        .usage
        .as_ref()
        .ok_or_else(|| TransformError::missing_metadata("response.usage"))?;
    let stop_reason = match choice.finish_reason {
        chat::FinishReason::Length => cg::StopReason::MaxTokens,
        chat::FinishReason::ToolCalls | chat::FinishReason::FunctionCall => cg::StopReason::ToolUse,
        chat::FinishReason::ContentFilter => cg::StopReason::Refusal,
        chat::FinishReason::Stop if choice.message.refusal.is_some() => cg::StopReason::Refusal,
        chat::FinishReason::Stop => cg::StopReason::EndTurn,
    };
    Ok(Converted {
        value: cg::GenerateContentResponseBody {
            input_transformations: None,
            type_: cg::GenerateContentResponseBodyType::Tag,
            id: input.id.clone(),
            container: None,
            content,
            context_management: None,
            diagnostics: None,
            model: target_model.into(),
            role: cg::ResponseRole::Assistant,
            stop_details: None,
            stop_reason,
            stop_sequence: None,
            usage: super::usage::to_claude(usage, &mut report)?,
            rest: Rest::new(),
        },
        report,
    })
}