gephyr 1.16.8

Gephyr headless AI relay service for Google AI services
Documentation
use crate::proxy::mappers::claude::models::{ClaudeRequest, MessageContent};
use crate::proxy::mappers::openai::models::{OpenAIContent, OpenAIRequest};
use axum::http::HeaderMap;
use serde_json::Value;
use sha2::{Digest, Sha256};
pub struct SessionManager;

impl SessionManager {
    fn normalize_explicit_session_id(raw: &str) -> Option<String> {
        let trimmed = raw.trim();
        if trimmed.is_empty() {
            return None;
        }
        if trimmed.len() <= 256 {
            return Some(trimmed.to_string());
        }

        let mut hasher = Sha256::new();
        hasher.update(trimmed.as_bytes());
        let hash = format!("{:x}", hasher.finalize());
        let sid = format!("sid-explicit-{}", &hash[..16]);
        tracing::warn!(
            "[SessionManager] Explicit session id too long ({}), hashed to {}",
            trimmed.len(),
            sid
        );
        Some(sid)
    }

    fn extract_explicit_session_id_from_headers(headers: Option<&HeaderMap>) -> Option<String> {
        let headers = headers?;

        for key in [
            "x-session-id",
            "x-client-session-id",
            "x-gephyr-session-id",
            "x-conversation-id",
            "x-thread-id",
        ] {
            if let Some(value) = headers.get(key).and_then(|v| v.to_str().ok()) {
                if let Some(sid) = Self::normalize_explicit_session_id(value) {
                    tracing::debug!(
                        "[SessionManager] Using explicit session id from header {}",
                        key
                    );
                    return Some(sid);
                }
            }
        }

        None
    }

    fn extract_explicit_session_id_from_json(raw: Option<&Value>) -> Option<String> {
        let raw = raw?;

        for key in [
            "session_id",
            "sessionId",
            "conversation_id",
            "conversationId",
            "thread_id",
            "threadId",
        ] {
            if let Some(value) = raw.get(key).and_then(|v| v.as_str()) {
                if let Some(sid) = Self::normalize_explicit_session_id(value) {
                    tracing::debug!(
                        "[SessionManager] Using explicit session id from payload field {}",
                        key
                    );
                    return Some(sid);
                }
            }
        }

        if let Some(metadata) = raw.get("metadata") {
            for key in ["session_id", "sessionId", "user_id", "userId"] {
                if let Some(value) = metadata.get(key).and_then(|v| v.as_str()) {
                    if let Some(sid) = Self::normalize_explicit_session_id(value) {
                        tracing::debug!(
                            "[SessionManager] Using explicit session id from metadata.{}",
                            key
                        );
                        return Some(sid);
                    }
                }
            }
        }

        None
    }

    fn hash_from_text_or_fallback(texts: impl Iterator<Item = String>, fallback: String) -> String {
        let mut hasher = Sha256::new();

        let mut content_found = false;
        for text in texts {
            let clean_text = text.trim().to_string();
            if clean_text.len() > 10 && !clean_text.contains("<system-reminder>") {
                hasher.update(clean_text.as_bytes());
                content_found = true;
                break;
            }
        }

        if !content_found {
            hasher.update(fallback.as_bytes());
        }

        let hash = format!("{:x}", hasher.finalize());
        format!("sid-{}", &hash[..16])
    }

    pub fn extract_session_id(request: &ClaudeRequest) -> String {
        if let Some(metadata) = &request.metadata {
            if let Some(user_id) = &metadata.user_id {
                if !user_id.is_empty() && !user_id.contains("session-") {
                    tracing::debug!("[SessionManager] Using explicit user_id: {}", user_id);
                    return user_id.clone();
                }
            }
        }
        let mut hasher = Sha256::new();

        let mut content_found = false;
        for msg in &request.messages {
            if msg.role != "user" {
                continue;
            }

            let text = match &msg.content {
                MessageContent::String(s) => s.clone(),
                MessageContent::Array(blocks) => blocks
                    .iter()
                    .filter_map(|block| match block {
                        crate::proxy::mappers::claude::models::ContentBlock::Text { text } => {
                            Some(text.as_str())
                        }
                        _ => None,
                    })
                    .collect::<Vec<_>>()
                    .join(" "),
            };

            let clean_text = text.trim();
            if clean_text.len() > 10 && !clean_text.contains("<system-reminder>") {
                hasher.update(clean_text.as_bytes());
                content_found = true;
                break;
            }
        }

        if !content_found {
            if let Some(last_msg) = request.messages.last() {
                hasher.update(format!("{:?}", last_msg.content).as_bytes());
            }
        }

        let hash = format!("{:x}", hasher.finalize());
        let sid = format!("sid-{}", &hash[..16]);

        tracing::debug!(
            "[SessionManager] Generated session_id: {} (content_found: {}, model: {})",
            sid,
            content_found,
            request.model
        );
        sid
    }

    pub fn extract_openai_session_id_with_overrides(
        request: &OpenAIRequest,
        headers: Option<&HeaderMap>,
        raw_body: Option<&Value>,
    ) -> String {
        if let Some(sid) = Self::extract_explicit_session_id_from_headers(headers)
            .or_else(|| Self::extract_explicit_session_id_from_json(raw_body))
        {
            return sid;
        }

        Self::extract_openai_session_id(request)
    }

    pub fn extract_openai_session_id(request: &OpenAIRequest) -> String {
        let texts = request.messages.iter().filter_map(|msg| {
            if msg.role != "user" {
                return None;
            }
            let content = msg.content.as_ref()?;
            let text = match content {
                OpenAIContent::String(s) => s.clone(),
                OpenAIContent::Array(blocks) => blocks
                    .iter()
                    .filter_map(|block| match block {
                        crate::proxy::mappers::openai::models::OpenAIContentBlock::Text {
                            text,
                        } => Some(text.as_str()),
                        _ => None,
                    })
                    .collect::<Vec<_>>()
                    .join(" "),
            };
            Some(text)
        });

        let fallback = request
            .messages
            .last()
            .map(|last_msg| format!("{:?}", last_msg.content))
            .unwrap_or_default();

        let sid = Self::hash_from_text_or_fallback(texts, fallback);
        tracing::debug!("[SessionManager-OpenAI] Generated fingerprint: {}", sid);
        sid
    }

    pub fn extract_gemini_session_id_with_overrides(
        request: &Value,
        model_name: &str,
        headers: Option<&HeaderMap>,
    ) -> String {
        if let Some(sid) = Self::extract_explicit_session_id_from_headers(headers)
            .or_else(|| Self::extract_explicit_session_id_from_json(Some(request)))
        {
            return sid;
        }

        Self::extract_gemini_session_id(request, model_name)
    }

    pub fn extract_gemini_session_id(request: &Value, _model_name: &str) -> String {
        let texts = request
            .get("contents")
            .and_then(|v| v.as_array())
            .into_iter()
            .flatten()
            .filter(|content| content.get("role").and_then(|v| v.as_str()) == Some("user"))
            .filter_map(|content| {
                let parts = content.get("parts").and_then(|v| v.as_array())?;
                let combined = parts
                    .iter()
                    .filter_map(|part| part.get("text").and_then(|v| v.as_str()))
                    .collect::<Vec<_>>()
                    .join(" ");
                Some(combined)
            });

        let sid = Self::hash_from_text_or_fallback(texts, request.to_string());
        tracing::debug!("[SessionManager-Gemini] Generated fingerprint: {}", sid);
        sid
    }
}

#[cfg(test)]
mod tests {
    use super::SessionManager;
    use crate::proxy::mappers::openai::models::OpenAIRequest;
    use axum::http::HeaderMap;
    use serde_json::json;

    fn build_openai_request(user_text: &str) -> OpenAIRequest {
        serde_json::from_value(json!({
            "model": "gemini-3-flash",
            "messages": [
                { "role": "user", "content": user_text }
            ]
        }))
        .expect("valid OpenAIRequest")
    }

    #[test]
    fn test_openai_explicit_session_id_from_header_wins() {
        let req = build_openai_request("hello session hashing fallback");
        let mut headers = HeaderMap::new();
        headers.insert("x-session-id", "stable-session-123".parse().unwrap());

        let sid = SessionManager::extract_openai_session_id_with_overrides(
            &req,
            Some(&headers),
            Some(&json!({"session_id":"ignored-by-header"})),
        );
        assert_eq!(sid, "stable-session-123");
    }

    #[test]
    fn test_openai_explicit_session_id_from_payload_used_when_no_header() {
        let req = build_openai_request("hello session hashing fallback");
        let sid = SessionManager::extract_openai_session_id_with_overrides(
            &req,
            None,
            Some(&json!({"session_id":"payload-session-42"})),
        );
        assert_eq!(sid, "payload-session-42");
    }

    #[test]
    fn test_gemini_explicit_session_id_from_payload_used() {
        let body = json!({
            "sessionId": "gemini-session-7",
            "contents": [
                { "role": "user", "parts": [{ "text": "ignored due to explicit id" }] }
            ]
        });

        let sid =
            SessionManager::extract_gemini_session_id_with_overrides(&body, "gemini-3-flash", None);
        assert_eq!(sid, "gemini-session-7");
    }
}