gephyr 1.16.18

Gephyr is a headless local AI relay/proxy API handling OpenAI, Claude, and Gemini-compatible APIs
Documentation
use crate::proxy::mappers::claude::models::*;
use crate::proxy::mappers::signature_store::get_thought_signature;
use crate::proxy::session_manager::SessionManager;
use serde_json::Value;
use std::collections::HashMap;

pub(super) struct RequestContext {
    pub cleaned_req: ClaudeRequest,
    pub session_id: String,
    pub has_web_search_tool: bool,
    pub has_mcp_tools: bool,
    pub mapped_model: String,
    pub tool_name_to_schema: HashMap<String, Value>,
    pub is_thinking_enabled: bool,
    pub allow_dummy_thought: bool,
}

pub(super) fn prepare_request_context(claude_req: &ClaudeRequest) -> RequestContext {
    let mut cleaned_req = claude_req.clone();
    super::merge_consecutive_messages(&mut cleaned_req.messages);
    super::clean_cache_control_from_messages(&mut cleaned_req.messages);
    super::sort_thinking_blocks_first(&mut cleaned_req.messages);

    let session_id = SessionManager::extract_session_id(&cleaned_req);
    tracing::debug!("[Claude-Request] Session ID: {}", session_id);

    let has_web_search_tool = detect_web_search_tool(&cleaned_req);
    let has_mcp_tools = detect_mcp_tools(&cleaned_req);
    let tool_name_to_schema = collect_tool_name_to_schema(&cleaned_req);
    let mapped_model = resolve_mapped_model(&cleaned_req.model, has_web_search_tool);
    let is_thinking_enabled = resolve_thinking_enabled(&cleaned_req, &mapped_model, &session_id);

    RequestContext {
        cleaned_req,
        session_id,
        has_web_search_tool,
        has_mcp_tools,
        mapped_model,
        tool_name_to_schema,
        is_thinking_enabled,
        allow_dummy_thought: false,
    }
}

fn detect_web_search_tool(claude_req: &ClaudeRequest) -> bool {
    claude_req
        .tools
        .as_ref()
        .map(|tools| {
            tools.iter().any(|t| {
                t.is_web_search()
                    || t.name.as_deref() == Some("google_search")
                    || t.type_.as_deref() == Some("web_search_20250305")
            })
        })
        .unwrap_or(false)
}

fn detect_mcp_tools(claude_req: &ClaudeRequest) -> bool {
    claude_req
        .tools
        .as_ref()
        .map(|tools| {
            tools.iter().any(|t| {
                t.name
                    .as_deref()
                    .map(|n| n.starts_with("mcp__"))
                    .unwrap_or(false)
            })
        })
        .unwrap_or(false)
}

fn collect_tool_name_to_schema(claude_req: &ClaudeRequest) -> HashMap<String, Value> {
    let mut tool_name_to_schema = HashMap::new();
    if let Some(tools) = &claude_req.tools {
        for tool in tools {
            if let (Some(name), Some(schema)) = (&tool.name, &tool.input_schema) {
                tool_name_to_schema.insert(name.clone(), schema.clone());
            }
        }
    }
    tool_name_to_schema
}

fn resolve_mapped_model(original_model: &str, has_web_search_tool: bool) -> String {
    let web_search_fallback_model =
        crate::proxy::common::model_mapping::web_search_fallback_model();

    if has_web_search_tool {
        tracing::debug!(
            "[Claude-Request] Web search tool detected, using fallback model: {}",
            web_search_fallback_model
        );
        web_search_fallback_model.to_string()
    } else {
        crate::proxy::common::model_mapping::map_claude_model_to_gemini(original_model)
    }
}

fn resolve_thinking_enabled(
    claude_req: &ClaudeRequest,
    mapped_model: &str,
    session_id: &str,
) -> bool {
    let mut is_thinking_enabled = claude_req
        .thinking
        .as_ref()
        .map(|t| t.type_ == "enabled")
        .unwrap_or_else(|| super::thinking::should_enable_thinking_by_default(&claude_req.model));
    let target_model_supports_thinking =
        crate::proxy::common::model_mapping::model_supports_thinking(mapped_model);

    if is_thinking_enabled && !target_model_supports_thinking {
        tracing::warn!(
            "[Thinking-Mode] Target model '{}' does not support thinking. Force disabling thinking mode.",
            mapped_model
        );
        is_thinking_enabled = false;
    }

    if is_thinking_enabled
        && super::thinking::should_disable_thinking_due_to_history(&claude_req.messages)
    {
        tracing::warn!("[Thinking-Mode] Automatically disabling thinking checks due to incompatible tool-use history (mixed application)");
        is_thinking_enabled = false;
    }

    if !is_thinking_enabled {
        return false;
    }

    let global_sig = get_thought_signature();
    let (has_thinking_history, has_function_calls) = inspect_message_history(&claude_req.messages);

    if !has_thinking_history {
        tracing::info!(
            "[Thinking-Mode] First thinking request detected. Using permissive mode - \
             signature validation will be handled by upstream API."
        );
    }

    if has_function_calls
        && !super::thinking::has_valid_signature_for_function_calls(
            &claude_req.messages,
            &global_sig,
            session_id,
        )
    {
        tracing::warn!(
            "[Thinking-Mode] No valid signature found for function calls. \
             Disabling thinking to prevent Gemini 3 Pro rejection."
        );
        return false;
    }

    true
}

fn inspect_message_history(messages: &[Message]) -> (bool, bool) {
    let has_thinking_history = messages.iter().any(|m| {
        if m.role == "assistant" {
            if let MessageContent::Array(blocks) = &m.content {
                return blocks
                    .iter()
                    .any(|b| matches!(b, ContentBlock::Thinking { .. }));
            }
        }
        false
    });

    let has_function_calls = messages.iter().any(|m| {
        if let MessageContent::Array(blocks) = &m.content {
            blocks
                .iter()
                .any(|b| matches!(b, ContentBlock::ToolUse { .. }))
        } else {
            false
        }
    });

    (has_thinking_history, has_function_calls)
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn mk_req(model: &str) -> ClaudeRequest {
        ClaudeRequest {
            model: model.to_string(),
            messages: vec![Message {
                role: "user".to_string(),
                content: MessageContent::String("hello".to_string()),
            }],
            system: None,
            tools: None,
            stream: false,
            max_tokens: None,
            temperature: None,
            top_p: None,
            top_k: None,
            thinking: None,
            metadata: None,
            output_config: None,
            size: None,
            quality: None,
        }
    }

    #[test]
    fn detect_web_search_tool_works_for_type_and_name() {
        let mut req = mk_req("gpt-5");
        req.tools = Some(vec![Tool {
            type_: Some("web_search_20250305".to_string()),
            name: None,
            description: None,
            input_schema: None,
        }]);
        assert!(detect_web_search_tool(&req));

        req.tools = Some(vec![Tool {
            type_: None,
            name: Some("google_search".to_string()),
            description: None,
            input_schema: None,
        }]);
        assert!(detect_web_search_tool(&req));

        req.tools = None;
        assert!(!detect_web_search_tool(&req));
    }

    #[test]
    fn resolve_mapped_model_uses_web_search_fallback_when_tool_detected() {
        let fallback = crate::proxy::common::model_mapping::web_search_fallback_model();
        let mapped = resolve_mapped_model("claude-sonnet-4-5", true);
        assert_eq!(mapped, fallback);
    }

    #[test]
    fn resolve_mapped_model_uses_normal_mapping_without_web_search_tool() {
        let fallback = crate::proxy::common::model_mapping::web_search_fallback_model();
        let mapped = resolve_mapped_model("claude-sonnet-4-5", false);
        assert_eq!(
            mapped,
            crate::proxy::common::model_mapping::map_claude_model_to_gemini("claude-sonnet-4-5")
        );
        assert_ne!(mapped, fallback);
    }

    #[test]
    fn resolve_thinking_enabled_disables_when_target_model_lacks_thinking() {
        let mut req = mk_req("claude-sonnet-4-5");
        req.thinking = Some(ThinkingConfig {
            type_: "enabled".to_string(),
            budget_tokens: Some(4096),
        });

        let fallback = crate::proxy::common::model_mapping::web_search_fallback_model();
        let enabled = resolve_thinking_enabled(&req, fallback, "session-1");
        assert!(!enabled);
    }

    #[test]
    fn prepare_request_context_sets_expected_flags_and_schema_map() {
        let mut req = mk_req("claude-sonnet-4-5");
        req.thinking = Some(ThinkingConfig {
            type_: "enabled".to_string(),
            budget_tokens: Some(2048),
        });
        req.tools = Some(vec![
            Tool {
                type_: Some("web_search_20250305".to_string()),
                name: None,
                description: None,
                input_schema: None,
            },
            Tool {
                type_: None,
                name: Some("mcp__fs_read".to_string()),
                description: Some("read files".to_string()),
                input_schema: Some(
                    json!({"type":"object","properties":{"path":{"type":"string"}}}),
                ),
            },
        ]);

        let ctx = prepare_request_context(&req);
        assert!(ctx.has_web_search_tool);
        assert!(ctx.has_mcp_tools);
        assert_eq!(
            ctx.mapped_model,
            crate::proxy::common::model_mapping::web_search_fallback_model()
        );
        assert!(ctx.tool_name_to_schema.contains_key("mcp__fs_read"));
        assert!(!ctx.allow_dummy_thought);
    }
}