gproxy-transform 3.0.0-alpha.0

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

use super::{content, convert_request, convert_response};

mod defaults;

#[test]
fn defaults_survive_empty_provider_replies() {
    let responses = content(Operation::GenerateContent, Kind::OpenAiResponses);
    let chat = content(Operation::GenerateContent, Kind::OpenAiChat);
    let claude = content(Operation::GenerateContent, Kind::ClaudeMessages);
    let gemini = content(Operation::GenerateContent, Kind::GeminiGenerateContent);

    let empty_responses = convert_response(
        responses,
        chat,
        json!({
            "id":"chat_empty","object":"chat.completion","created":42,
            "model":"gpt","choices":[]
        }),
    );
    assert_eq!(empty_responses["status"], "completed");
    assert_eq!(empty_responses["completed_at"], 42);
    assert_eq!(empty_responses["output"], json!([]));

    let incomplete = convert_response(
        responses,
        chat,
        json!({
            "id":"chat_length","object":"chat.completion","created":43,
            "model":"gpt","choices":[{
                "index":0,"finish_reason":"length",
                "message":{"role":"assistant","content":"partial"}
            }]
        }),
    );
    assert_eq!(incomplete["status"], "incomplete");
    assert_eq!(incomplete["completed_at"], 43);
    assert_eq!(
        incomplete["incomplete_details"]["reason"],
        "max_output_tokens"
    );
    assert_eq!(incomplete["output_text"], "partial");

    let filtered = convert_response(
        responses,
        chat,
        json!({
            "id":"chat_filtered","object":"chat.completion","model":"gpt",
            "choices":[{
                "index":0,"finish_reason":"content_filter",
                "message":{"role":"assistant","tool_calls":[{
                    "id":"vendor id","type":"function",
                    "function":{"name":"lookup","arguments":"{}"}
                }]}
            }]
        }),
    );
    assert_eq!(filtered["status"], "incomplete");
    assert_eq!(filtered["incomplete_details"]["reason"], "content_filter");
    let call = &filtered["output"][0];
    assert!(call["call_id"].as_str().unwrap().starts_with("call_"));
    assert!(call["id"].as_str().unwrap().starts_with("fc_"));
    assert_ne!(call["call_id"], "vendor id");

    let empty_claude = convert_response(
        claude,
        chat,
        json!({
            "id":"chat_claude_empty","object":"chat.completion",
            "model":"gpt","choices":[]
        }),
    );
    assert_eq!(empty_claude["content"][0]["type"], "text");
    assert_eq!(empty_claude["content"][0]["text"], "");
    assert_eq!(empty_claude["stop_reason"], "end_turn");
    assert_eq!(empty_claude["usage"]["input_tokens"], 0);
    assert_eq!(empty_claude["usage"]["output_tokens"], 0);

    let empty_gemini_for_claude = convert_response(claude, gemini, json!({"candidates":[]}));
    assert_eq!(empty_gemini_for_claude["id"], "");
    assert_eq!(empty_gemini_for_claude["model"], "");
    assert_eq!(empty_gemini_for_claude["content"][0]["text"], "");
    assert_eq!(empty_gemini_for_claude["stop_reason"], "end_turn");
    assert_eq!(empty_gemini_for_claude["usage"]["input_tokens"], 0);
    assert_eq!(empty_gemini_for_claude["usage"]["output_tokens"], 0);
}

#[test]
fn responses_text_annotations_and_fallback_reach_chat() {
    let chat = content(Operation::GenerateContent, Kind::OpenAiChat);
    let responses = content(Operation::GenerateContent, Kind::OpenAiResponses);
    let rich = convert_response(
        chat,
        responses,
        json!({
            "id":"resp_rich","object":"response","model":"gpt","status":"completed",
            "output":[
                {"type":"message","id":"msg_1","role":"assistant","status":"completed",
                 "content":[{"type":"output_text","text":"one","annotations":[{
                    "type":"url_citation","start_index":0,"end_index":3,
                    "title":"source","url":"https://example.invalid"
                 }]}]},
                {"type":"message","id":"msg_2","role":"assistant","status":"completed",
                 "content":[{"type":"output_text","text":"two","annotations":[]}]}
            ]
        }),
    );
    let message = &rich["choices"][0]["message"];
    assert_eq!(message["content"], "one\ntwo");
    assert_eq!(message["annotations"][0]["type"], "url_citation");
    assert_eq!(
        message["annotations"][0]["url_citation"]["url"],
        "https://example.invalid"
    );

    let fallback = convert_response(
        chat,
        responses,
        json!({
            "id":"resp_fallback","object":"response","model":"gpt",
            "status":"completed","output":[],"output_text":"fallback"
        }),
    );
    assert_eq!(fallback["choices"][0]["message"]["content"], "fallback");
}

#[test]
fn nullable_chat_logprobs_reach_responses() {
    let responses = content(Operation::GenerateContent, Kind::OpenAiResponses);
    let chat = content(Operation::GenerateContent, Kind::OpenAiChat);
    let converted = convert_response(
        responses,
        chat,
        json!({
            "id":"chat_nullable_logprobs","object":"chat.completion","created":42,
            "model":"gpt","choices":[{
                "index":0,"finish_reason":"stop",
                "logprobs":{"content":null,"refusal":null},
                "message":{"role":"assistant","content":"ok"}
            }]
        }),
    );
    assert_eq!(converted["output_text"], "ok");
}

#[test]
fn responses_history_keeps_media_reasoning_and_patch_calls_in_chat() {
    let chat = content(Operation::GenerateContent, Kind::OpenAiChat);
    let responses = content(Operation::GenerateContent, Kind::OpenAiResponses);
    let request = convert_request(
        responses,
        chat,
        json!({
            "model":"route",
            "input":[
                {"type":"message","role":"user","content":[
                    {"type":"input_image","image_url":"https://example.invalid/image.png","detail":"high"},
                    {"type":"input_file","file_url":"https://example.invalid/report.pdf"}
                ]},
                {"type":"reasoning","id":"rs_1",
                 "summary":[{"type":"summary_text","text":"plan"}],
                 "content":[{"type":"reasoning_text","text":"details"}]},
                {"type":"message","id":"msg_1","role":"assistant","status":"completed",
                 "content":[{"type":"output_text","text":"answer","annotations":[]}]},
                {"type":"apply_patch_call","id":"item_patch","call_id":"call_patch",
                 "operation":{"type":"create_file","path":"new.txt","diff":"hello"},
                 "status":"completed"}
            ]
        }),
    );
    assert_eq!(
        request["messages"][0]["content"][0]["image_url"]["detail"],
        "high"
    );
    assert_eq!(request["messages"][0]["content"][1]["type"], "text");
    assert_eq!(
        request["messages"][0]["content"][1]["text"],
        "Attachment URL: https://example.invalid/report.pdf"
    );
    assert_eq!(request["messages"][1]["content"][0]["text"], "answer");
    assert_eq!(request["messages"][1]["reasoning_content"], "plan\ndetails");
    assert_eq!(request["messages"][2]["tool_calls"][0]["id"], "call_patch");
    assert_eq!(
        request["messages"][2]["tool_calls"][0]["function"]["name"],
        "apply_patch"
    );
}

#[test]
fn request_fields_map_or_drop_without_blocking_convertible_turns() {
    let chat = content(Operation::GenerateContent, Kind::OpenAiChat);
    let responses = content(Operation::GenerateContent, Kind::OpenAiResponses);
    let claude = content(Operation::GenerateContent, Kind::ClaudeMessages);
    let gemini = content(Operation::GenerateContent, Kind::GeminiGenerateContent);

    for (source, value) in [
        (
            chat,
            json!({
                "model":"route","messages":[{"role":"user","content":"hi"}],
                "n":2,"seed":7,"prediction":{"type":"content","content":"expected"},
                "web_search_options":{"search_context_size":"high"}
            }),
        ),
        (responses, json!({"model":"route","input":"hi"})),
        (
            gemini,
            json!({
                "cachedContent":"cachedContents/stable",
                "safetySettings":[{"category":"HARM_CATEGORY_HATE_SPEECH","threshold":"BLOCK_NONE"}],
                "generationConfig":{"candidateCount":2,"seed":7,"responseModalities":["TEXT"]},
                "contents":[{"role":"user","parts":[{"text":"hi"}]}]
            }),
        ),
    ] {
        let request = convert_request(source, claude, value);
        assert_eq!(request["max_tokens"], 16_384);
    }

    let responses_request = convert_request(
        chat,
        responses,
        json!({
            "model":"route","messages":[{"role":"user","content":"hi"}],
            "stop":["END"],"n":2,"seed":7,
            "function_call":"auto",
            "functions":[{"name":"legacy","parameters":{"type":"object"}}],
            "prediction":{"type":"content","content":"expected"},
            "web_search_options":{"search_context_size":"high"}
        }),
    );
    assert_eq!(responses_request["input"][0]["role"], "user");
    for dropped in ["stop", "n", "seed", "functions", "prediction"] {
        assert!(responses_request.get(dropped).is_none(), "kept {dropped}");
    }

    let gemini_request = convert_request(
        chat,
        gemini,
        json!({
            "model":"route","messages":[{"role":"user","content":"hi"}],
            "prompt_cache_key":"cachedContents/stable","metadata":{"trace":"x"},
            "prediction":{"type":"content","content":"expected"},
            "web_search_options":{
                "search_context_size":"high",
                "user_location":{"type":"approximate","approximate":{"country":"US"}}
            }
        }),
    );
    assert_eq!(gemini_request["cachedContent"], "cachedContents/stable");
    assert!(gemini_request["tools"][0].get("googleSearch").is_some());

    let cached_chat = convert_request(
        gemini,
        chat,
        json!({
            "cachedContent":"cachedContents/stable",
            "safetySettings":[{"category":"HARM_CATEGORY_HATE_SPEECH","threshold":"BLOCK_NONE"}],
            "contents":[{"role":"user","parts":[{"text":"hi"}]}]
        }),
    );
    assert_eq!(cached_chat["prompt_cache_key"], "cachedContents/stable");
}

#[test]
fn chat_history_uses_output_messages_and_normalized_tool_correlation() {
    let chat = content(Operation::GenerateContent, Kind::OpenAiChat);
    let responses = content(Operation::GenerateContent, Kind::OpenAiResponses);
    let request = convert_request(
        chat,
        responses,
        json!({
            "model":"route",
            "messages":[
                {"role":"assistant","content":"working","tool_calls":[{
                    "id":"vendor-id","type":"function",
                    "function":{"name":"lookup","arguments":"{}"}
                }]},
                {"role":"tool","tool_call_id":"vendor-id","content":"done"},
                {"role":"assistant","content":[{
                    "type":"text","text":"cached",
                    "prompt_cache_breakpoint":{"mode":"explicit"}
                }]}
            ]
        }),
    );
    assert_eq!(request["input"][0]["id"], "msg_0");
    assert_eq!(request["input"][0]["status"], "completed");
    assert_eq!(request["input"][0]["content"][0]["type"], "output_text");
    let call_id = request["input"][1]["call_id"].as_str().unwrap();
    assert!(call_id.starts_with("call_"));
    assert_ne!(call_id, "vendor-id");
    assert!(
        request["input"][1]["id"]
            .as_str()
            .is_some_and(|id| id.starts_with("fc_"))
    );
    assert_eq!(request["input"][2]["call_id"], call_id);
    assert!(request["input"][3].get("id").is_none());
}

#[test]
fn gemini_missing_call_ids_use_v2_name_fallbacks_and_correlate_results() {
    let gemini = content(Operation::GenerateContent, Kind::GeminiGenerateContent);
    let responses = content(Operation::GenerateContent, Kind::OpenAiResponses);
    let request = convert_request(
        gemini,
        responses,
        json!({
            "model":"gemini",
            "contents":[
                {"role":"model","parts":[
                    {"functionCall":{"name":"alpha","args":{}}},
                    {"functionCall":{"name":"beta","args":{}}}
                ]},
                {"role":"user","parts":[
                    {"functionResponse":{"name":"alpha","response":{"ok":1}}},
                    {"functionResponse":{"name":"beta","response":{"ok":2}}}
                ]}
            ]
        }),
    );
    assert_eq!(request["input"][0]["call_id"], "call_alpha");
    assert_eq!(request["input"][1]["call_id"], "call_beta");
    assert_eq!(request["input"][2]["call_id"], "call_alpha");
    assert_eq!(request["input"][3]["call_id"], "call_beta");
}