gproxy-transform 3.0.0-alpha.0

GPROXY v3 pairwise protocol transforms
Documentation
use bytes::Bytes;
use gproxy_protocol::{ContentGenerationKind as Kind, Operation, WireFamily};
use serde_json::json;

use super::super::{content, convert_response, family, request};

#[test]
fn responses_to_claude_defaults_and_wraps_lossy_blocks() {
    let converted = convert_response(
        content(Operation::GenerateContent, Kind::ClaudeMessages),
        content(Operation::GenerateContent, Kind::OpenAiResponses),
        json!({
            "id":"resp_defaults","object":"response","status":"completed",
            "output":[
                {"type":"reasoning","id":"rs_1","summary":[],
                 "content":[{"type":"reasoning_text","text":"visible"}],
                 "status":"completed"},
                {"type":"function_call","id":"fc_1","call_id":"call_1",
                 "name":"broken","arguments":"not-json","status":"completed"},
                {"type":"custom_tool_call","id":"ctc_1","call_id":"call_2",
                 "name":"raw","input":"plain text"}
            ]
        }),
    );
    assert_eq!(converted["model"], "unknown");
    assert_eq!(converted["usage"]["input_tokens"], 0);
    assert_eq!(converted["usage"]["output_tokens"], 0);
    assert_eq!(converted["content"][0]["type"], "text");
    assert_eq!(converted["content"][0]["text"], "visible");
    assert_eq!(converted["content"][1]["input"], json!({}));
    assert_eq!(
        converted["content"][2]["input"],
        json!({"input":"plain text"})
    );
}

#[test]
fn claude_to_responses_collects_text_after_non_text_and_maps_refusal_tier() {
    let converted = convert_response(
        content(Operation::GenerateContent, Kind::OpenAiResponses),
        content(Operation::GenerateContent, Kind::ClaudeMessages),
        json!({
            "id":"msg_order","type":"message","role":"assistant","model":"claude",
            "content":[
                {"type":"text","text":"before"},
                {"type":"thinking","thinking":"plan","signature":"opaque"},
                {"type":"text","text":"after"},
                {"type":"tool_use","id":"toolu_1","name":"lookup","input":{}}
            ],
            "stop_reason":"refusal","stop_sequence":null,
            "usage":{"input_tokens":2,"output_tokens":3,"service_tier":"priority"}
        }),
    );
    assert_eq!(converted["output"][0]["type"], "reasoning");
    assert_eq!(converted["output"][1]["type"], "function_call");
    assert_eq!(converted["output"][2]["type"], "message");
    assert_eq!(converted["output"][2]["content"][0]["text"], "before");
    assert_eq!(converted["output"][2]["content"][1]["text"], "after");
    assert_eq!(converted["incomplete_details"]["reason"], "content_filter");
    assert_eq!(converted["service_tier"], "priority");
}

#[test]
fn empty_claude_thinking_does_not_create_forbidden_reasoning_content() {
    let converted = request(
        content(Operation::GenerateContent, Kind::ClaudeMessages),
        content(Operation::GenerateContent, Kind::OpenAiResponses),
        Bytes::from(
            serde_json::to_vec(&json!({
                "model":"route","max_tokens":32,
                "messages":[{"role":"assistant","content":[{
                    "type":"thinking","thinking":"","signature":"opaque"
                }]}]
            }))
            .unwrap(),
        ),
        "upstream-model",
        false,
    )
    .unwrap();
    let value: serde_json::Value = serde_json::from_slice(&converted).unwrap();
    assert!(value["input"][0].get("content").is_none());
    assert_eq!(value["input"][0]["encrypted_content"], "opaque");
}

#[test]
fn gemini_sdk_system_instruction_role_is_accepted_as_system_context() {
    let converted = request(
        content(Operation::GenerateContent, Kind::GeminiGenerateContent),
        content(Operation::GenerateContent, Kind::ClaudeMessages),
        Bytes::from_static(
            br#"{"systemInstruction":{"role":"user","parts":[{"text":"system policy"}]},"contents":[{"role":"user","parts":[{"text":"hello"}]}],"generationConfig":{"topK":40,"thinkingConfig":{"includeThoughts":true}}}"#,
        ),
        "upstream-model",
        false,
    )
    .unwrap();
    let value: serde_json::Value = serde_json::from_slice(&converted).unwrap();
    assert_eq!(value["system"][0]["text"], "system policy");
    assert_eq!(value["messages"][0]["content"][0]["text"], "hello");
    assert!(value.get("top_k").is_none());
    assert_eq!(value["thinking"]["type"], "enabled");
    assert_eq!(value["thinking"]["budget_tokens"], 4096);
}

#[test]
fn responses_custom_tool_does_not_leak_native_metadata_to_claude() {
    let converted = request(
        content(Operation::GenerateContent, Kind::OpenAiResponses),
        content(Operation::GenerateContent, Kind::ClaudeMessages),
        Bytes::from_static(
            br#"{"model":"route","input":[{"type":"message","role":"system","content":[{"type":"input_text","text":"system policy"}]},{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}],"tools":[{"type":"custom","name":"raw","description":"raw input"},{"type":"web_search_preview","external_web_access":true,"allowed_callers":["direct","programmatic"]}]}"#,
        ),
        "upstream-model",
        false,
    )
    .unwrap();
    let value: serde_json::Value = serde_json::from_slice(&converted).unwrap();
    assert_eq!(value["tools"][0]["name"], "raw");
    assert_eq!(value["system"], "system policy");
    assert_eq!(value["messages"][0]["role"], "user");
    assert!(value["tools"][1].get("allowed_callers").is_none());
    assert!(!value.to_string().contains("openai_native_tool"));
    assert!(!value.to_string().contains("external_web_access"));
}

#[test]
fn gemini_candidates_become_ordered_responses_messages() {
    let converted = convert_response(
        content(Operation::GenerateContent, Kind::OpenAiResponses),
        content(Operation::GenerateContent, Kind::GeminiGenerateContent),
        json!({
            "modelVersion":"gemini",
            "candidates":[
                {"index":0,"content":{"role":"model","parts":[{"text":"first"}]},"finishReason":"STOP"},
                {"index":1,"content":{"role":"model","parts":[{"text":"second"}]},"finishReason":"STOP"}
            ]
        }),
    );
    assert_eq!(converted["id"], "");
    assert_eq!(converted["created_at"], 0);
    assert_eq!(converted["completed_at"], 0);
    assert_eq!(converted["output"][0]["content"][0]["text"], "first");
    assert_eq!(converted["output"][1]["content"][0]["text"], "second");
    assert_eq!(converted["output_text"], "first");
}

#[test]
fn responses_terminal_defaults_do_not_reject_convertible_gemini_replies() {
    let gemini = content(Operation::GenerateContent, Kind::GeminiGenerateContent);
    let responses = content(Operation::GenerateContent, Kind::OpenAiResponses);
    let missing = convert_response(
        gemini,
        responses,
        json!({"id":"missing","object":"response","status":"incomplete","output":[]}),
    );
    assert_eq!(missing["candidates"][0]["finishReason"], "MAX_TOKENS");

    let extra = convert_response(
        gemini,
        responses,
        json!({
            "id":"extra","object":"response","status":"completed","output":[],
            "incomplete_details":{"reason":"max_output_tokens"}
        }),
    );
    assert_eq!(extra["candidates"][0]["finishReason"], "STOP");
}

#[test]
fn claude_blocks_join_and_native_calls_reach_buffered_chat() {
    let converted = convert_response(
        content(Operation::GenerateContent, Kind::OpenAiChat),
        content(Operation::GenerateContent, Kind::ClaudeMessages),
        json!({
            "id":"msg_chat","type":"message","role":"assistant","model":"claude",
            "content":[
                {"type":"text","text":"before"},
                {"type":"thinking","thinking":"plan","signature":"opaque"},
                {"type":"text","text":"after"},
                {"type":"server_tool_use","id":"srv_1","name":"web_search","input":{"query":"x"}},
                {"type":"mcp_tool_use","id":"mcp_1","server_name":"remote","name":"lookup","input":{}}
            ],
            "stop_reason":"tool_use","stop_sequence":null,
            "usage":{"input_tokens":1,"output_tokens":2,"service_tier":"priority"}
        }),
    );
    let message = &converted["choices"][0]["message"];
    assert_eq!(message["content"], "before\nplan\nafter");
    assert!(message.get("reasoning_content").is_none());
    assert_eq!(message["tool_calls"][0]["type"], "custom");
    assert_eq!(message["tool_calls"][0]["custom"]["name"], "web_search");
    assert_eq!(
        message["tool_calls"][1]["custom"]["name"],
        "mcp:remote:lookup"
    );
    assert_eq!(converted["service_tier"], "priority");
    assert_eq!(converted["created"], 0);
}

#[test]
fn chat_refusal_becomes_claude_text_and_refusal_status() {
    let converted = convert_response(
        content(Operation::GenerateContent, Kind::ClaudeMessages),
        content(Operation::GenerateContent, Kind::OpenAiChat),
        json!({
            "id":"chat_refusal","object":"chat.completion","model":"gpt",
            "choices":[{
                "index":0,"finish_reason":"stop",
                "message":{"role":"assistant","refusal":"blocked"}
            }]
        }),
    );
    assert_eq!(converted["content"][0]["type"], "text");
    assert_eq!(converted["content"][0]["text"], "blocked");
    assert_eq!(converted["stop_reason"], "refusal");
}

#[test]
fn claude_and_gemini_buffered_replies_skip_unrenderable_blocks() {
    let claude = content(Operation::GenerateContent, Kind::ClaudeMessages);
    let gemini = content(Operation::GenerateContent, Kind::GeminiGenerateContent);
    let to_gemini = convert_response(
        gemini,
        claude,
        json!({
            "id":"msg_skip","type":"message","role":"assistant","model":"claude",
            "content":[
                {"type":"text","text":"kept"},
                {"type":"mcp_tool_use","id":"mcp_1","server_name":"remote","name":"lookup","input":{}}
            ],
            "stop_reason":"end_turn","stop_sequence":null,
            "usage":{"input_tokens":1,"output_tokens":1}
        }),
    );
    assert_eq!(
        to_gemini["candidates"][0]["content"]["parts"][0]["text"],
        "kept"
    );

    let to_claude = convert_response(
        claude,
        gemini,
        json!({
            "responseId":"gemini_skip","modelVersion":"gemini",
            "candidates":[{
                "content":{"role":"model","parts":[
                    {"text":"kept"},
                    {"functionResponse":{"name":"lookup","response":{"ok":true}}}
                ]},
                "finishReason":"STOP"
            }]
        }),
    );
    assert_eq!(to_claude["content"][0]["text"], "kept");
}

#[test]
fn gemini_to_chat_supplies_v2_defaults_and_usage_counts() {
    let converted = convert_response(
        content(Operation::GenerateContent, Kind::OpenAiChat),
        content(Operation::GenerateContent, Kind::GeminiGenerateContent),
        json!({
            "candidates":[{}],
            "usageMetadata":{
                "promptTokenCount":10,"candidatesTokenCount":5,
                "thoughtsTokenCount":7,"totalTokenCount":22
            }
        }),
    );
    assert_eq!(converted["id"], "");
    assert_eq!(converted["model"], "unknown");
    assert_eq!(converted["created"], 0);
    assert_eq!(converted["choices"][0]["finish_reason"], "stop");
    assert_eq!(converted["choices"][0]["message"]["content"], "");
    assert_eq!(converted["usage"]["completion_tokens"], 5);
    assert_eq!(
        converted["usage"]["completion_tokens_details"]["reasoning_tokens"],
        7
    );
}

#[test]
fn chat_to_gemini_uses_saturating_v2_usage_projection() {
    let converted = convert_response(
        content(Operation::GenerateContent, Kind::GeminiGenerateContent),
        content(Operation::GenerateContent, Kind::OpenAiChat),
        json!({
            "id":"chat_usage","object":"chat.completion","model":"gpt",
            "choices":[{
                "index":0,"finish_reason":"stop",
                "message":{"role":"assistant","content":"ok"}
            }],
            "usage":{
                "prompt_tokens":10,"completion_tokens":7,"total_tokens":99,
                "completion_tokens_details":{"reasoning_tokens":9}
            }
        }),
    );
    assert_eq!(converted["usageMetadata"]["promptTokenCount"], 10);
    assert_eq!(converted["usageMetadata"]["candidatesTokenCount"], 7);
    assert_eq!(converted["usageMetadata"]["thoughtsTokenCount"], 9);
    assert_eq!(converted["usageMetadata"]["totalTokenCount"], 99);
}

#[test]
fn compact_response_puts_message_first_and_marks_incomplete() {
    let converted = convert_response(
        family(Operation::CompactContent, WireFamily::OpenAi),
        content(Operation::GenerateContent, Kind::ClaudeMessages),
        json!({
            "id":"compact_order","type":"message","role":"assistant","model":"claude",
            "content":[
                {"type":"thinking","thinking":"plan","signature":"opaque"},
                {"type":"text","text":"summary"},
                {"type":"tool_use","id":"toolu_1","name":"lookup","input":{}}
            ],
            "stop_reason":"refusal","stop_sequence":null,
            "usage":{}
        }),
    );
    assert_eq!(converted["output"][0]["type"], "message");
    assert_eq!(converted["output"][0]["status"], "incomplete");
    assert_eq!(converted["output"][1]["type"], "reasoning");
    assert_eq!(converted["output"][2]["type"], "function_call");
    assert_eq!(converted["usage"]["input_tokens"], 0);
    assert_eq!(converted["usage"]["output_tokens"], 0);
}

#[test]
fn model_transforms_restore_compatibility_defaults_and_ignore_bodies() {
    let openai = family(Operation::ListModels, WireFamily::OpenAi);
    let claude = family(Operation::ListModels, WireFamily::Claude);
    let to_claude = convert_response(
        claude,
        openai,
        json!({"data":[{"id":"model-a","object":"model"}],"object":"list"}),
    );
    assert_eq!(to_claude["data"][0]["created_at"], "1970-01-01T00:00:00Z");
    assert_eq!(to_claude["data"][0]["display_name"], "model-a");

    let to_openai = convert_response(
        openai,
        claude,
        json!({
            "data":[{"id":"model-b","type":"model","display_name":"Model B"}],
            "has_more":false
        }),
    );
    assert_eq!(to_openai["data"][0]["owned_by"], "unknown");

    assert_eq!(
        request(openai, claude, Bytes::from_static(b"{}"), "unused", false).unwrap(),
        Bytes::new()
    );
}