ktxd 0.1.0

A local Responses API compatibility proxy for Codex CLI and Chat Completions backends.
Documentation
use crate::domain::{
    CanonicalItem, FunctionOutput, FunctionOutputContentItem, MessageRole, ProvenanceTag,
    TaggedItem,
};
use crate::error::{ProxyError, Result};
use crate::ids::{CallId, ResponseId};
use crate::wire::responses::{ResponsesInput, ResponsesRequest};
use serde_json::Value;

#[derive(Debug, Clone)]
pub struct NormalizedTurnInput {
    pub model: String,
    pub instructions: String,
    pub previous_response_id: Option<ResponseId>,
    pub request_items: Vec<TaggedItem>,
    pub tools: Vec<Value>,
    pub tool_choice: String,
    pub parallel_tool_calls: bool,
    pub stream: bool,
    pub preserved: PreservedRequestFields,
}

#[derive(Debug, Clone, Default)]
pub struct PreservedRequestFields {
    pub reasoning: Option<Value>,
    pub store: Option<bool>,
    pub include: Vec<String>,
    pub service_tier: Option<String>,
    pub prompt_cache_key: Option<String>,
    pub text: Option<Value>,
}

pub fn normalize_request(request: ResponsesRequest) -> Result<NormalizedTurnInput> {
    let request_items = match request.input {
        ResponsesInput::String(text) => vec![TaggedItem::new(
            CanonicalItem::Message {
                role: MessageRole::User,
                text,
            },
            ProvenanceTag::user_trusted(),
        )],
        ResponsesInput::Items(items) => normalize_items(items)?,
    };

    validate_tools(&request.tools)?;

    Ok(NormalizedTurnInput {
        model: request.model,
        instructions: request.instructions,
        previous_response_id: request.previous_response_id.map(ResponseId::from_string),
        request_items,
        tools: request.tools,
        tool_choice: request.tool_choice,
        parallel_tool_calls: request.parallel_tool_calls,
        stream: request.stream.unwrap_or(false),
        preserved: PreservedRequestFields {
            reasoning: request.reasoning,
            store: request.store,
            include: request.include,
            service_tier: request.service_tier,
            prompt_cache_key: request.prompt_cache_key,
            text: request.text,
        },
    })
}

fn normalize_items(items: Vec<Value>) -> Result<Vec<TaggedItem>> {
    let mut normalized = Vec::new();
    for item in items {
        let item_type = item
            .get("type")
            .and_then(Value::as_str)
            .unwrap_or("message");
        match item_type {
            "message" => normalize_message_item(&item, &mut normalized)?,
            "function_call" => normalized.push(normalize_function_call(&item)?),
            "function_call_output" => normalized.push(normalize_function_output(&item)?),
            "reasoning" => normalized.push(TaggedItem::new(
                CanonicalItem::Reasoning { raw: item },
                ProvenanceTag::model_semi(),
            )),
            other => return Err(ProxyError::UnsupportedInputItem(other.to_string())),
        }
    }
    Ok(normalized)
}

fn normalize_message_item(item: &Value, normalized: &mut Vec<TaggedItem>) -> Result<()> {
    let role = item.get("role").and_then(Value::as_str).unwrap_or("user");
    let content = item
        .get("content")
        .and_then(Value::as_array)
        .ok_or_else(|| {
            ProxyError::UnsupportedInputItem("message_without_content_array".to_string())
        })?;

    match role {
        "user" => {
            let text = collect_content_text(content, "input_text")?;
            normalized.push(TaggedItem::new(
                CanonicalItem::Message {
                    role: MessageRole::User,
                    text,
                },
                ProvenanceTag::user_trusted(),
            ));
        }
        "developer" => {
            let text = collect_content_text(content, "input_text")?;
            normalized.push(TaggedItem::new(
                CanonicalItem::Message {
                    role: MessageRole::User,
                    text,
                },
                ProvenanceTag::user_trusted(),
            ));
        }
        "assistant" => {
            let text = collect_content_text(content, "output_text")?;
            normalized.push(TaggedItem::new(
                CanonicalItem::Message {
                    role: MessageRole::Assistant,
                    text,
                },
                ProvenanceTag::model_semi(),
            ));
        }
        other => {
            return Err(ProxyError::UnsupportedInputItem(format!(
                "message_role_{other}"
            )));
        }
    }
    Ok(())
}

fn collect_content_text(content: &[Value], text_type: &str) -> Result<String> {
    let mut parts = Vec::new();
    for content_item in content {
        let content_type = content_item
            .get("type")
            .and_then(Value::as_str)
            .unwrap_or_default();
        if content_type == text_type {
            if let Some(text) = content_item.get("text").and_then(Value::as_str) {
                if !text.is_empty() {
                    parts.push(text.to_string());
                }
            }
        } else if content_type == "input_image" {
            continue;
        } else {
            return Err(ProxyError::UnsupportedInputItem(content_type.to_string()));
        }
    }
    Ok(parts.join("\n"))
}

fn normalize_function_call(item: &Value) -> Result<TaggedItem> {
    let name = required_string(item, "name")?;
    let call_id = required_string(item, "call_id")?;
    let arguments = required_string(item, "arguments")?;
    Ok(TaggedItem::new(
        CanonicalItem::FunctionCall {
            call_id: CallId::from_string(call_id),
            name,
            arguments,
        },
        ProvenanceTag::model_semi(),
    ))
}

fn normalize_function_output(item: &Value) -> Result<TaggedItem> {
    let call_id = required_string(item, "call_id")?;
    let output_value = item.get("output").ok_or_else(|| {
        ProxyError::UnsupportedInputItem("function_call_output_without_output".to_string())
    })?;
    let output = if let Some(text) = output_value.as_str() {
        FunctionOutput::Text {
            text: text.to_string(),
        }
    } else if let Some(items) = output_value.as_array() {
        let mut content_items = Vec::new();
        for content_item in items {
            match content_item
                .get("type")
                .and_then(Value::as_str)
                .unwrap_or_default()
            {
                "input_text" => content_items.push(FunctionOutputContentItem::InputText {
                    text: content_item
                        .get("text")
                        .and_then(Value::as_str)
                        .unwrap_or_default()
                        .to_string(),
                }),
                "input_image" => content_items.push(FunctionOutputContentItem::InputImage {
                    image_url: content_item
                        .get("image_url")
                        .and_then(Value::as_str)
                        .unwrap_or_default()
                        .to_string(),
                }),
                other => return Err(ProxyError::UnsupportedInputItem(other.to_string())),
            }
        }
        FunctionOutput::ContentItems {
            items: content_items,
        }
    } else {
        return Err(ProxyError::UnsupportedInputItem(
            "function_call_output_output".to_string(),
        ));
    };

    Ok(TaggedItem::new(
        CanonicalItem::FunctionCallOutput {
            call_id: CallId::from_string(call_id),
            output,
        },
        ProvenanceTag::tool_output_semi(),
    ))
}

fn required_string(item: &Value, field: &str) -> Result<String> {
    item.get(field)
        .and_then(Value::as_str)
        .map(ToString::to_string)
        .ok_or_else(|| ProxyError::UnsupportedInputItem(format!("missing_{field}")))
}

fn validate_tools(tools: &[Value]) -> Result<()> {
    for tool in tools {
        let tool_type = tool.get("type").and_then(Value::as_str).unwrap_or_default();
        if tool_type != "function" {
            return Err(ProxyError::UnsupportedTool(tool_type.to_string()));
        }
        if tool.get("name").is_none() && tool.get("function").is_none() {
            return Err(ProxyError::UnsupportedTool(
                "function_without_name".to_string(),
            ));
        }
    }
    Ok(())
}