gproxy-protocol 4.1.2

GPROXY v4 connection modelling, operation declarations, vendor wire types and pairwise conversion
Documentation
use claude_chat::ResponseSupplement;
use gproxy_protocol::{
    claude::generate_content as cg, openai::chat, transform::generate::claude_chat,
};
use serde_json::json;

#[test]
fn claude_response_maps_text_tool_use_stop_and_usage() {
    let input: cg::GenerateContentResponseBody = serde_json::from_value(json!({
        "type":"message","id":"msg_1","model":"claude-source","role":"assistant","stop_reason":"tool_use","stop_sequence":null,
        "content":[{"type":"text","text":"answer"},{"type":"tool_use","id":"toolu_1","name":"lookup","input":{"q":"x"}}],
        "usage":{"input_tokens":11,"output_tokens":7,"cache_read_input_tokens":3}
    })).unwrap();
    let converted = claude_chat::claude_response_to_openai(
        &input,
        "gpt-target",
        &ResponseSupplement {
            created_unix_seconds: Some(1),
        },
    )
    .unwrap();
    assert_eq!(converted.value.model, "gpt-target");
    assert_eq!(
        converted.value.choices[0].finish_reason,
        chat::FinishReason::ToolCalls
    );
    assert_eq!(converted.value.usage.as_ref().unwrap().prompt_tokens, 14);
    assert_eq!(converted.value.usage.as_ref().unwrap().total_tokens, 21);
    assert!(converted.value.choices[0].message.tool_calls.is_some());
    assert!(converted.value.rest.is_empty());
}

#[test]
fn claude_response_requires_factual_created_time() {
    let input: cg::GenerateContentResponseBody = serde_json::from_value(json!({
        "type":"message","id":"msg","model":"claude","role":"assistant","stop_reason":"end_turn","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}
    })).unwrap();
    let error =
        claude_chat::claude_response_to_openai(&input, "gpt", &ResponseSupplement::default())
            .unwrap_err();
    assert_eq!(error.context(), "response.created");
}

#[test]
fn openai_response_maps_refusal_tool_calls_usage_and_unknown_rest_is_dropped() {
    let input: chat::GenerateContentResponseBody = serde_json::from_value(json!({
        "id":"chatcmpl_1","object":"chat.completion","created":1,"model":"gpt-source",
        "choices":[{"index":0,"finish_reason":"tool_calls","logprobs":null,"message":{"role":"assistant","content":null,"refusal":"no","tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"x\"}"}}]}}],
        "usage":{"prompt_tokens":4,"completion_tokens":3,"total_tokens":7,"prompt_tokens_details":{"cached_tokens":1}},
        "unknown":{"nested":true}
    })).unwrap();
    let converted = claude_chat::openai_response_to_claude(
        &input,
        "claude-target",
        &ResponseSupplement::default(),
    )
    .unwrap();
    assert_eq!(converted.value.stop_reason, cg::StopReason::ToolUse);
    assert_eq!(converted.value.usage.input_tokens, 3);
    let encoded = serde_json::to_value(&converted.value).unwrap();
    assert!(encoded.get("unknown").is_none());
    assert!(encoded.to_string().contains("tool_use"));
}

#[test]
fn malformed_response_arguments_are_invalid_results() {
    let input: chat::GenerateContentResponseBody = serde_json::from_value(json!({
        "id":"chatcmpl_1","object":"chat.completion","created":1,"model":"gpt",
        "choices":[{"index":0,"finish_reason":"tool_calls","logprobs":null,"message":{"role":"assistant","content":null,"refusal":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"[]"}}]}}]
    })).unwrap();
    let error =
        claude_chat::openai_response_to_claude(&input, "claude", &ResponseSupplement::default())
            .unwrap_err();
    assert_eq!(
        error.kind(),
        gproxy_protocol::transform::TransformErrorKind::InvalidResult
    );
}

#[test]
fn chat_response_requires_actual_usage() {
    let input: chat::GenerateContentResponseBody = serde_json::from_value(json!({
        "id":"chat","object":"chat.completion","created":1,"model":"gpt",
        "choices":[{"index":0,"finish_reason":"stop","logprobs":null,"message":{"role":"assistant","content":"ok","refusal":null}}]
    })).unwrap();
    let error =
        claude_chat::openai_response_to_claude(&input, "claude", &ResponseSupplement::default())
            .unwrap_err();
    assert_eq!(error.context(), "response.usage");
}

#[test]
fn cache_accounting_roundtrips_and_refusal_keeps_both_texts() {
    let input: chat::GenerateContentResponseBody=serde_json::from_value(json!({
        "id":"chat","object":"chat.completion","created":123,"model":"actual",
        "choices":[{"index":0,"finish_reason":"content_filter","logprobs":null,"message":{"role":"assistant","content":"visible","refusal":"refusal"}}],
        "usage":{"prompt_tokens":10,"completion_tokens":4,"total_tokens":14,"prompt_tokens_details":{"cached_tokens":3,"cache_write_tokens":2},"completion_tokens_details":{"reasoning_tokens":1}}
    })).unwrap();
    let converted =
        claude_chat::openai_response_to_claude(&input, "actual", &ResponseSupplement::default())
            .unwrap()
            .value;
    assert_eq!(converted.usage.input_tokens, 5);
    assert_eq!(converted.usage.cache_creation_input_tokens, Some(Some(2)));
    assert_eq!(converted.usage.cache_read_input_tokens, Some(Some(3)));
    let texts: Vec<_> = converted
        .content
        .iter()
        .filter_map(|block| match block {
            cg::ResponseContentBlock::Text(text) => Some(text.text.as_str()),
            _ => None,
        })
        .collect();
    assert_eq!(texts, ["visible", "refusal"]);
    let back = claude_chat::claude_response_to_openai(
        &converted,
        "actual",
        &ResponseSupplement {
            created_unix_seconds: Some(123),
        },
    )
    .unwrap()
    .value;
    assert_eq!(back.usage.as_ref().unwrap().prompt_tokens, 10);
    assert_eq!(back.usage.as_ref().unwrap().total_tokens, 14);
    assert_eq!(
        back.usage
            .as_ref()
            .unwrap()
            .completion_tokens_details
            .as_ref()
            .unwrap()
            .reasoning_tokens,
        Some(1)
    );
    assert_eq!(
        back.choices[0].message.refusal.as_deref(),
        Some("visiblerefusal")
    );
    assert!(back.choices[0].message.content.is_none());
}

#[test]
fn invalid_usage_overflow_breakdown_and_multiple_choices_fail() {
    let base = json!({"type":"message","id":"msg","model":"actual","role":"assistant","stop_reason":"end_turn","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}});
    for usage in [
        json!({"input_tokens":-1,"output_tokens":1}),
        json!({"input_tokens":i64::MAX,"output_tokens":1}),
        json!({"input_tokens":1,"output_tokens":1,"cache_creation_input_tokens":2,"cache_creation":{"ephemeral_1h_input_tokens":1,"ephemeral_5m_input_tokens":2}}),
    ] {
        let overflow = usage["input_tokens"] == json!(i64::MAX);
        let mut input = base.clone();
        input["usage"] = usage;
        let input = serde_json::from_value(input).unwrap();
        let result = claude_chat::claude_response_to_openai(
            &input,
            "actual",
            &ResponseSupplement {
                created_unix_seconds: Some(1),
            },
        );
        assert_eq!(result.is_err(), overflow);
    }
    let mut input:chat::GenerateContentResponseBody=serde_json::from_value(json!({"id":"chat","object":"chat.completion","created":1,"model":"actual","choices":[{"index":0,"finish_reason":"stop","logprobs":null,"message":{"role":"assistant","content":"a","refusal":null}},{"index":1,"finish_reason":"stop","logprobs":null,"message":{"role":"assistant","content":"b","refusal":null}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}})).unwrap();
    assert!(
        claude_chat::openai_response_to_claude(&input, "actual", &ResponseSupplement::default())
            .is_ok()
    );
    input.choices.truncate(1);
    input.usage.as_mut().unwrap().prompt_tokens_details = Some(
        chat::PromptTokensDetails::builder()
            .cached_tokens(2)
            .build(),
    );
    assert!(
        claude_chat::openai_response_to_claude(&input, "actual", &ResponseSupplement::default())
            .is_ok()
    );
}

#[test]
fn visible_thinking_roundtrips_without_becoming_answer_text() {
    let source:cg::GenerateContentResponseBody=serde_json::from_value(json!({"type":"message","id":"msg","role":"assistant","model":"m","content":[{"type":"thinking","thinking":"plan","signature":"source-signature"},{"type":"text","text":"answer"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":3,"output_tokens":4}})).unwrap();
    let chat = claude_chat::claude_response_to_openai(
        &source,
        "m",
        &ResponseSupplement {
            created_unix_seconds: Some(1),
        },
    )
    .unwrap()
    .value;
    assert_eq!(chat.choices[0].message.content.as_deref(), Some("answer"));
    assert_eq!(
        chat.choices[0].message.reasoning_content,
        Some(Some("plan".into()))
    );
    let restored =
        claude_chat::openai_response_to_claude(&chat, "m", &ResponseSupplement::default())
            .unwrap()
            .value;
    let cg::ResponseContentBlock::Thinking(thinking) = &restored.content[0] else {
        panic!("thinking")
    };
    assert_eq!(thinking.thinking, "plan");
    assert_eq!(
        thinking.signature, "source-signature",
        "the native signature travels in formal reasoning details"
    );
}