From 4a179e2be700cf8bfec441aa3f1e7993424ce9a1 Mon Sep 17 00:00:00 2001 From: MJavad Alavi Date: Mon, 2 Feb 2026 13:43:01 +0330 Subject: [PATCH] Enhance request handling and response structure - Updated `reqwest` dependency to include additional features for improved HTTP requests. - Modified `ChatCompletionsRequest` and `ChatMessage` structs to support new fields for tool calls and reasoning. - Improved content handling in `convert_chat_to_responses` to accommodate tool call messages. - Enhanced error handling in `proxy_request` to provide clearer backend error messages. - Refactored response building to support streaming and structured output for tool calls. --- Cargo.toml | 4 +- src/main.rs | 1066 +++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 787 insertions(+), 283 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 268e766..0d5b330 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,9 +9,9 @@ bytes = "1.0" clap = { version = "4.0", features = ["derive"] } chrono = { version = "0.4", features = ["serde"] } env_logger = "0.10" -reqwest = { version = "0.11", features = ["json"] } +reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls", "gzip", "brotli", "deflate"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tokio = { version = "1.0", features = ["full"] } uuid = { version = "1.0", features = ["v4"] } -warp = "0.3" \ No newline at end of file +warp = "0.3" diff --git a/src/main.rs b/src/main.rs index f830faa..76128ff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,11 @@ -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use clap::Parser; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use warp::{Filter, Reply}; use reqwest::Client; use uuid::Uuid; - -mod improved_response; +use std::collections::HashMap; #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] @@ -25,9 +24,12 @@ struct Args { struct ChatCompletionsRequest { model: String, messages: Vec, + #[allow(dead_code)] temperature: Option, + #[allow(dead_code)] max_tokens: Option, stream: Option, + reasoning: Option, tools: Option>, tool_choice: Option, } @@ -35,7 +37,9 @@ struct ChatCompletionsRequest { #[derive(Deserialize, Debug)] struct ChatMessage { role: String, - content: Value, // Can be string or array + content: Option, // Can be string or array + tool_calls: Option>, + tool_call_id: Option, } /// Chat Completions API response format (what CLINE expects) @@ -60,6 +64,8 @@ struct Choice { struct ChatResponseMessage { role: String, content: String, + #[serde(skip_serializing_if = "Option::is_none")] + tool_calls: Option>, } #[derive(Serialize, Debug)] @@ -69,6 +75,20 @@ struct Usage { total_tokens: i32, } +#[derive(Serialize, Debug, Clone)] +struct ToolCall { + id: String, + #[serde(rename = "type")] + tool_type: String, + function: ToolCallFunction, +} + +#[derive(Serialize, Debug, Clone)] +struct ToolCallFunction { + name: String, + arguments: String, +} + /// Codex Responses API format (what we send to ChatGPT backend) #[derive(Serialize, Debug)] struct ResponsesApiRequest { @@ -76,7 +96,7 @@ struct ResponsesApiRequest { instructions: String, input: Vec, tools: Vec, - tool_choice: String, + tool_choice: Value, parallel_tool_calls: bool, reasoning: Option, store: bool, @@ -99,6 +119,7 @@ enum ResponseItem { #[serde(tag = "type", rename_all = "snake_case")] enum ContentItem { InputText { text: String }, + OutputText { text: String }, } /// Codex auth.json structure @@ -113,29 +134,10 @@ struct AuthData { struct TokenData { access_token: String, account_id: String, + #[allow(dead_code)] refresh_token: Option, } -/// Codex Responses API response format -#[derive(Deserialize, Debug)] -struct ResponsesApiResponse { - response: Option, - id: Option, -} - -#[derive(Deserialize, Debug)] -struct ResponseOutput { - content: Option>, - role: Option, -} - -#[derive(Deserialize, Debug)] -struct ResponseContentItem { - #[serde(rename = "type")] - content_type: String, - text: Option, -} - struct ProxyServer { client: Client, auth_data: AuthData, @@ -154,9 +156,18 @@ impl ProxyServer { .await .context("Failed to read auth.json")?; - let auth_data: AuthData = serde_json::from_str(&auth_content) + let mut auth_data: AuthData = serde_json::from_str(&auth_content) .context("Failed to parse auth.json")?; + if auth_data.api_key.is_none() { + if let Ok(env_key) = std::env::var("OPENAI_API_KEY") { + let trimmed = env_key.trim(); + if !trimmed.is_empty() { + auth_data.api_key = Some(trimmed.to_string()); + } + } + } + // Create client with browser-like configuration let client = Client::builder() .user_agent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") @@ -170,49 +181,71 @@ impl ProxyServer { } fn convert_chat_to_responses(&self, chat_req: ChatCompletionsRequest) -> ResponsesApiRequest { - // Convert messages to ResponseItems + let ChatCompletionsRequest { + model, + messages, + temperature: _, + max_tokens: _, + stream: _stream, + reasoning, + tools, + tool_choice, + } = chat_req; + + // Convert messages to ResponseItems, and map system messages to instructions. let mut input = Vec::new(); - - for msg in chat_req.messages { - // Convert content to string (handle both string and array formats) - let content_text = match &msg.content { - Value::String(s) => s.clone(), - Value::Array(arr) => { - // Extract text from array elements - arr.iter() - .filter_map(|v| { - if let Some(obj) = v.as_object() { - obj.get("text").and_then(|t| t.as_str()).map(|s| s.to_string()) - } else { - v.as_str().map(|s| s.to_string()) - } - }) - .collect::>() - .join(" ") - }, - _ => msg.content.to_string(), - }; - + let mut system_messages = Vec::new(); + + for msg in messages { + let normalized_role = normalize_role(&msg.role); + let mut content_text = content_to_text(&msg.content); + if content_text.is_empty() { + if let Some(tool_calls) = msg.tool_calls.as_deref() { + content_text = tool_calls_to_text(tool_calls); + } + } + if msg.role.trim().eq_ignore_ascii_case("tool") { + if let Some(tool_call_id) = msg.tool_call_id.as_deref() { + if content_text.is_empty() { + content_text = format!("Tool result for {}.", tool_call_id); + } else { + content_text = format!("Tool result for {}: {}", tool_call_id, content_text); + } + } + } + if content_text.is_empty() { + continue; + } + + if normalized_role == "system" || normalized_role == "developer" { + system_messages.push(content_text); + continue; + } + input.push(ResponseItem::Message { id: None, - role: msg.role, - content: vec![ContentItem::InputText { - text: content_text, - }], + role: normalized_role.clone(), + content: vec![content_item_for_role(&normalized_role, content_text)], }); } - // Use proper instructions for ChatGPT Responses API - let instructions = "You are a helpful AI assistant. Provide clear, accurate, and concise responses to user questions and requests.".to_string(); + let instructions = if system_messages.is_empty() { + "You are a helpful AI assistant. Provide clear, accurate, and concise responses to user questions and requests.".to_string() + } else { + system_messages.join("\n\n") + }; + + let tool_choice = normalize_tool_choice(tool_choice); + let tools = normalize_tools(tools.unwrap_or_default()); ResponsesApiRequest { - model: chat_req.model, + model, instructions, input, - tools: chat_req.tools.unwrap_or_default(), - tool_choice: "auto".to_string(), + tools, + tool_choice, parallel_tool_calls: false, - reasoning: None, + reasoning, store: false, stream: true, include: vec![], @@ -221,67 +254,55 @@ impl ProxyServer { async fn proxy_request(&self, chat_req: ChatCompletionsRequest) -> Result { - // For now, return a working response while we implement backend - println!("🔄 Processing CLINE request..."); - println!("🔍 Stream setting: {:?}", chat_req.stream); - - let chat_res = ChatCompletionsResponse { - id: format!("chatcmpl-{}", Uuid::new_v4()), - object: "chat.completion".to_string(), - created: chrono::Utc::now().timestamp(), - model: chat_req.model.clone(), - choices: vec![Choice { - index: 0, - message: ChatResponseMessage { - role: "assistant".to_string(), - content: "I can help you with coding tasks! The proxy connection is working well. What would you like assistance with? (Note: Currently running in development mode while ChatGPT backend integration is being finalized.)".to_string(), - }, - finish_reason: Some("stop".to_string()), - }], - usage: Some(Usage { - prompt_tokens: 50, - completion_tokens: 30, - total_tokens: 80, - }), - }; - - Ok(chat_res) - } - - #[allow(dead_code)] - async fn proxy_request_original(&self, chat_req: ChatCompletionsRequest) -> Result { // Convert to Responses API format let responses_req = self.convert_chat_to_responses(chat_req); - - // Build request to ChatGPT backend with browser-like headers - let mut request_builder = self.client - .post("https://chatgpt.com/backend-api/codex/responses") - .header("Content-Type", "application/json") - .header("Accept", "text/event-stream") - .header("Accept-Language", "en-US,en;q=0.9") - .header("Accept-Encoding", "gzip, deflate, br") - .header("Referer", "https://chatgpt.com/") - .header("Origin", "https://chatgpt.com") - .header("Sec-Fetch-Dest", "empty") - .header("Sec-Fetch-Mode", "cors") - .header("Sec-Fetch-Site", "same-origin") - .header("Cache-Control", "no-cache") - .header("Pragma", "no-cache") - .header("DNT", "1") - .header("OpenAI-Beta", "responses=experimental") - .header("originator", "codex_cli_rs"); - - // Add authentication - if let Some(tokens) = &self.auth_data.tokens { + let accept_header = "text/event-stream"; + let use_openai = self.should_use_openai(&responses_req); + let mut request_builder = if use_openai { + let api_key = self + .auth_data + .api_key + .as_ref() + .context("OPENAI_API_KEY not configured")?; + self.client + .post("https://api.openai.com/v1/responses") + .header("Content-Type", "application/json") + .header("Accept", accept_header) + .header("Authorization", format!("Bearer {}", api_key)) + .header("OpenAI-Beta", "responses=experimental") + } else { + self.client + .post("https://chatgpt.com/backend-api/codex/responses") + .header("Content-Type", "application/json") + .header("Accept", accept_header) + .header("Accept-Language", "en-US,en;q=0.9") + .header("Accept-Encoding", "gzip, deflate, br") + .header("Referer", "https://chatgpt.com/") + .header("Origin", "https://chatgpt.com") + .header("Sec-Fetch-Dest", "empty") + .header("Sec-Fetch-Mode", "cors") + .header("Sec-Fetch-Site", "same-origin") + .header("Cache-Control", "no-cache") + .header("Pragma", "no-cache") + .header("DNT", "1") + .header("OpenAI-Beta", "responses=experimental") + .header("originator", "codex_cli_rs") + }; + + if use_openai { + // OpenAI API uses only the API key; no session headers required. + } else { + let tokens = self + .auth_data + .tokens + .as_ref() + .context("ChatGPT access token not configured")?; request_builder = request_builder.header("Authorization", format!("Bearer {}", tokens.access_token)); request_builder = request_builder.header("chatgpt-account-id", &tokens.account_id); - } else if let Some(api_key) = &self.auth_data.api_key { - request_builder = request_builder.header("Authorization", format!("Bearer {}", api_key)); - } - // Add session ID - let session_id = Uuid::new_v4(); - request_builder = request_builder.header("session_id", session_id.to_string()); + let session_id = Uuid::new_v4(); + request_builder = request_builder.header("session_id", session_id.to_string()); + } // Send request let response = request_builder @@ -290,78 +311,32 @@ impl ProxyServer { .await .context("Failed to send request to ChatGPT backend")?; - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - - // Instead of returning error, create a valid response with error message - let error_message = "Hello! I'm responding from the proxy. The backend API isn't working yet but I can receive and respond to your requests.".to_string(); - - let chat_res = ChatCompletionsResponse { - id: format!("chatcmpl-{}", Uuid::new_v4()), - object: "chat.completion".to_string(), - created: chrono::Utc::now().timestamp(), - model: responses_req.model.clone(), - choices: vec![Choice { - index: 0, - message: ChatResponseMessage { - role: "assistant".to_string(), - content: error_message, - }, - finish_reason: Some("stop".to_string()), - }], - usage: Some(Usage { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }), - }; - - return Ok(chat_res); + let status = response.status(); + let response_text = response.text().await.unwrap_or_default(); + if !status.is_success() { + bail!( + "ChatGPT backend error ({}): {}", + status, + response_text + ); } - // Handle streaming response - let mut response_content = String::new(); - let response_text = response.text().await?; - let lines: Vec<&str> = response_text.lines().collect(); - - for line in lines { - if line.starts_with("data: ") { - let json_data = &line[6..]; // Remove "data: " prefix - if json_data == "[DONE]" { - break; - } - - if let Ok(event) = serde_json::from_str::(json_data) { - if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) { - match event_type { - "response.output_text.delta" => { - if let Some(delta) = event.get("delta").and_then(|v| v.as_str()) { - response_content.push_str(delta); - } - } - "response.output_item.done" => { - if let Some(item) = event.get("item") { - if let Some(content_arr) = item.get("content").and_then(|v| v.as_array()) { - for content_item in content_arr { - if let Some(text) = content_item.get("text").and_then(|v| v.as_str()) { - response_content.push_str(text); - } - } - } - } - } - _ => {} // Ignore other event types - } - } - } - } + let mut backend_result = parse_responses_body(&response_text) + .context("Failed to parse ChatGPT backend response")?; + if backend_result.content.is_empty() && backend_result.tool_calls.is_empty() { + backend_result.content = "No response content received from backend.".to_string(); } - // If no content was collected, use a default message - if response_content.is_empty() { - response_content = "I apologize, but I couldn't process your request due to a backend API format issue. The proxy is receiving your request correctly but needs format refinement.".to_string(); - } + let tool_calls = if backend_result.tool_calls.is_empty() { + None + } else { + Some(backend_result.tool_calls) + }; + let finish_reason = if tool_calls.is_some() { + Some("tool_calls".to_string()) + } else { + Some("stop".to_string()) + }; // Create Chat Completions response let chat_res = ChatCompletionsResponse { @@ -373,19 +348,608 @@ impl ProxyServer { index: 0, message: ChatResponseMessage { role: "assistant".to_string(), - content: response_content, + content: backend_result.content, + tool_calls, }, - finish_reason: Some("stop".to_string()), + finish_reason, }], - usage: Some(Usage { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }), + usage: None, }; Ok(chat_res) } + + fn should_use_openai(&self, responses_req: &ResponsesApiRequest) -> bool { + if self.auth_data.tokens.is_none() && self.auth_data.api_key.is_some() { + return true; + } + + responses_req.reasoning.is_some() && self.auth_data.api_key.is_some() + } +} + +fn content_to_text(content: &Option) -> String { + let Some(content) = content.as_ref() else { + return String::new(); + }; + + match content { + Value::String(s) => s.clone(), + Value::Array(arr) => arr + .iter() + .filter_map(|v| { + if let Some(obj) = v.as_object() { + obj.get("text") + .and_then(|t| t.as_str()) + .map(|s| s.to_string()) + } else { + v.as_str().map(|s| s.to_string()) + } + }) + .collect::>() + .join(" "), + Value::Object(obj) => obj + .get("text") + .and_then(|t| t.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| content.to_string()), + Value::Null => String::new(), + _ => content.to_string(), + } +} + +fn content_item_for_role(role: &str, text: String) -> ContentItem { + let role = role.trim(); + if role.eq_ignore_ascii_case("assistant") || role.eq_ignore_ascii_case("tool") { + ContentItem::OutputText { text } + } else { + ContentItem::InputText { text } + } +} + +fn normalize_role(role: &str) -> String { + let role = role.trim().to_ascii_lowercase(); + match role.as_str() { + "system" => "system".to_string(), + "developer" => "developer".to_string(), + "assistant" => "assistant".to_string(), + "user" => "user".to_string(), + "tool" | "function" | "tool_result" | "tool_response" => "user".to_string(), + _ => "user".to_string(), + } +} + +fn tool_calls_to_text(tool_calls: &[Value]) -> String { + let mut lines = Vec::new(); + for call in tool_calls { + let name = call + .get("name") + .and_then(|v| v.as_str()) + .or_else(|| call.get("function").and_then(|f| f.get("name")).and_then(|v| v.as_str())) + .unwrap_or("unknown"); + let args = call + .get("arguments") + .or_else(|| call.get("function").and_then(|f| f.get("arguments"))) + .map(|v| v.to_string()) + .unwrap_or_else(|| "{}".to_string()); + lines.push(format!("Tool call: {} {}", name, args)); + } + lines.join("\n") +} + +fn normalize_tool_choice(choice: Option) -> Value { + match choice { + Some(Value::String(choice)) if !choice.trim().is_empty() => Value::String(choice), + Some(Value::Object(obj)) => { + if let Some(Value::String(kind)) = obj.get("type") { + if kind == "function" { + if let Some(Value::String(name)) = obj.get("name") { + return json!({ "type": "function", "name": name }); + } + if let Some(Value::Object(function)) = obj.get("function") { + if let Some(Value::String(name)) = function.get("name") { + return json!({ "type": "function", "name": name }); + } + } + } + } + Value::String("auto".to_string()) + } + _ => Value::String("auto".to_string()), + } +} + +fn normalize_tools(tools: Vec) -> Vec { + let mut normalized = Vec::new(); + + for tool in tools { + let Value::Object(obj) = tool else { + continue; + }; + + let tool_type = obj.get("type").and_then(|v| v.as_str()); + if tool_type != Some("function") { + normalized.push(Value::Object(obj)); + continue; + } + + if obj.get("name").and_then(|v| v.as_str()).is_some() { + normalized.push(Value::Object(obj)); + continue; + } + + if let Some(Value::Object(function)) = obj.get("function") { + let name = function.get("name").and_then(|v| v.as_str()); + if let Some(name) = name { + let mut normalized_tool = serde_json::Map::new(); + normalized_tool.insert("type".to_string(), Value::String("function".to_string())); + normalized_tool.insert("name".to_string(), Value::String(name.to_string())); + if let Some(description) = function.get("description") { + normalized_tool.insert("description".to_string(), description.clone()); + } + if let Some(parameters) = function.get("parameters") { + normalized_tool.insert("parameters".to_string(), parameters.clone()); + } + if let Some(strict) = function.get("strict") { + normalized_tool.insert("strict".to_string(), strict.clone()); + } + normalized.push(Value::Object(normalized_tool)); + } + } + } + + normalized +} + +fn fallback_models_response() -> Value { + json!({ + "object": "list", + "data": [ + { + "id": "gpt-5.2-codex", + "object": "model", + "created": 1687882411, + "owned_by": "openai" + }, + { + "id": "gpt-5.1-codex-max", + "object": "model", + "created": 1687882411, + "owned_by": "openai" + }, + { + "id": "gpt-5.2", + "object": "model", + "created": 1687882411, + "owned_by": "openai" + }, + { + "id": "gpt-5.1-codex-mini", + "object": "model", + "created": 1687882411, + "owned_by": "openai" + } + ] + }) +} + +#[derive(Default, Debug)] +struct BackendResult { + content: String, + tool_calls: Vec, +} + +fn parse_responses_body(body: &str) -> Result { + if body.lines().any(|line| line.trim_start().starts_with("data: ")) { + parse_sse_body(body) + } else { + parse_json_body(body) + } +} + +fn parse_sse_body(body: &str) -> Result { + let mut result = BackendResult::default(); + let mut saw_delta = false; + let mut tool_calls = Vec::new(); + let mut tool_call_index = HashMap::new(); + + for line in body.lines() { + let line = line.trim_start(); + if !line.starts_with("data: ") { + continue; + } + + let json_data = line[6..].trim(); + if json_data == "[DONE]" { + break; + } + + let event: Value = match serde_json::from_str(json_data) { + Ok(event) => event, + Err(_) => continue, + }; + + if let Some(error) = event.get("error") { + let message = error + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown backend error"); + bail!("ChatGPT backend error: {}", message); + } + + if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) { + match event_type { + "response.output_text.delta" => { + if let Some(delta) = event.get("delta").and_then(|v| v.as_str()) { + result.content.push_str(delta); + saw_delta = true; + } + } + "response.output_item.added" | "response.output_item.done" => { + if let Some(item) = event.get("item") { + if !saw_delta { + if let Some(text) = extract_text_from_output_item(item) { + result.content.push_str(&text); + } + } + merge_tool_calls( + &mut tool_calls, + &mut tool_call_index, + extract_tool_calls_from_output_item(item), + ); + } + } + _ => {} + } + continue; + } + + if let Some(delta) = event.get("delta").and_then(|v| v.as_str()) { + result.content.push_str(delta); + saw_delta = true; + continue; + } + + if saw_delta { + continue; + } + + if let Some(text) = event.get("text").and_then(|v| v.as_str()) { + result.content.push_str(text); + continue; + } + + if let Some(item) = event.get("item") { + if let Some(text) = extract_text_from_output_item(item) { + result.content.push_str(&text); + } + merge_tool_calls( + &mut tool_calls, + &mut tool_call_index, + extract_tool_calls_from_output_item(item), + ); + } + + if let Some(delta) = event.get("delta") { + if let Some(tool_calls_array) = delta.get("tool_calls").and_then(|v| v.as_array()) { + merge_tool_calls( + &mut tool_calls, + &mut tool_call_index, + extract_tool_calls_from_array(tool_calls_array), + ); + } + } + + if let Some(tool_calls_array) = event.get("tool_calls").and_then(|v| v.as_array()) { + merge_tool_calls( + &mut tool_calls, + &mut tool_call_index, + extract_tool_calls_from_array(tool_calls_array), + ); + } + } + + if !tool_calls.is_empty() { + result.tool_calls = tool_calls + .into_iter() + .filter(|call| !call.function.name.is_empty()) + .collect(); + } + + Ok(result) +} + +fn parse_json_body(body: &str) -> Result { + let response: Value = serde_json::from_str(body).context("Failed to parse backend JSON")?; + Ok(extract_backend_result_from_response(&response)) +} + +fn extract_backend_result_from_response(response: &Value) -> BackendResult { + let mut result = BackendResult::default(); + + if let Some(text) = response.get("output_text").and_then(|v| v.as_str()) { + result.content.push_str(text); + } + + if let Some(inner_response) = response.get("response") { + if let Some(text) = inner_response.get("output_text").and_then(|v| v.as_str()) { + result.content.push_str(text); + } + + if let Some(content_arr) = inner_response.get("content").and_then(|v| v.as_array()) { + result.content.push_str(&extract_text_from_content_array(content_arr)); + result.tool_calls.extend(extract_tool_calls_from_array(content_arr)); + } + + if let Some(output_items) = inner_response.get("output").and_then(|v| v.as_array()) { + for item in output_items { + if let Some(text) = extract_text_from_output_item(item) { + result.content.push_str(&text); + } + result.tool_calls.extend(extract_tool_calls_from_output_item(item)); + } + } + } + + if let Some(content_arr) = response.get("content").and_then(|v| v.as_array()) { + result.content.push_str(&extract_text_from_content_array(content_arr)); + result.tool_calls.extend(extract_tool_calls_from_array(content_arr)); + } + + if let Some(output_items) = response.get("output").and_then(|v| v.as_array()) { + for item in output_items { + if let Some(text) = extract_text_from_output_item(item) { + result.content.push_str(&text); + } + result.tool_calls.extend(extract_tool_calls_from_output_item(item)); + } + } + + if let Some(tool_calls) = response.get("tool_calls").and_then(|v| v.as_array()) { + result.tool_calls.extend(extract_tool_calls_from_array(tool_calls)); + } + + if !result.tool_calls.is_empty() { + result.tool_calls = result + .tool_calls + .into_iter() + .filter(|call| !call.function.name.is_empty()) + .collect(); + } + + result +} + +fn extract_text_from_content_array(content_arr: &[Value]) -> String { + let mut collected = String::new(); + for content in content_arr { + if let Some(kind) = content.get("type").and_then(|v| v.as_str()) { + if kind != "output_text" && kind != "text" { + continue; + } + } + if let Some(text) = content.get("text").and_then(|v| v.as_str()) { + collected.push_str(text); + } + } + collected +} + +fn extract_text_from_output_item(item: &Value) -> Option { + let content_arr = item.get("content").and_then(|v| v.as_array())?; + let mut collected = String::new(); + for content in content_arr { + if let Some(kind) = content.get("type").and_then(|v| v.as_str()) { + if kind != "output_text" && kind != "text" { + continue; + } + } + if let Some(text) = content.get("text").and_then(|v| v.as_str()) { + collected.push_str(text); + } + } + + if collected.is_empty() { + None + } else { + Some(collected) + } +} + +fn extract_tool_calls_from_output_item(item: &Value) -> Vec { + let mut calls = Vec::new(); + if let Some(call) = parse_tool_call_item(item) { + calls.push(call); + } + + if let Some(tool_calls) = item.get("tool_calls").and_then(|v| v.as_array()) { + calls.extend(extract_tool_calls_from_array(tool_calls)); + } + + if let Some(content_arr) = item.get("content").and_then(|v| v.as_array()) { + calls.extend(extract_tool_calls_from_array(content_arr)); + } + + calls +} + +fn extract_tool_calls_from_array(items: &[Value]) -> Vec { + let mut calls = Vec::new(); + for item in items { + if let Some(call) = parse_tool_call_item(item) { + calls.push(call); + } + } + calls +} + +fn merge_tool_calls( + target: &mut Vec, + index: &mut HashMap, + calls: Vec, +) { + for call in calls { + if let Some(existing_index) = index.get(&call.id).copied() { + let existing = &mut target[existing_index]; + if existing.function.name.is_empty() && !call.function.name.is_empty() { + existing.function.name = call.function.name.clone(); + } + if call.function.arguments.len() > existing.function.arguments.len() { + existing.function.arguments = call.function.arguments.clone(); + } + } else { + index.insert(call.id.clone(), target.len()); + target.push(call); + } + } +} + +fn parse_tool_call_item(item: &Value) -> Option { + let item_type = item.get("type").and_then(|v| v.as_str()); + let looks_like_tool_call = matches!(item_type, Some("tool_call" | "function_call")) + || item + .get("function") + .and_then(|f| f.get("name").or_else(|| f.get("arguments"))) + .is_some(); + if !looks_like_tool_call { + return None; + } + + let name = item + .get("name") + .and_then(|v| v.as_str()) + .or_else(|| item.get("function").and_then(|f| f.get("name")).and_then(|v| v.as_str())) + .or_else(|| item.get("tool").and_then(|f| f.get("name")).and_then(|v| v.as_str())) + .map(|s| s.to_string()) + .unwrap_or_default(); + + let args_value = item + .get("arguments") + .or_else(|| item.get("input")) + .or_else(|| item.get("parameters")) + .or_else(|| item.get("function").and_then(|f| f.get("arguments"))); + let arguments = match args_value { + Some(Value::String(s)) => s.clone(), + Some(Value::Object(_)) | Some(Value::Array(_)) => args_value.unwrap().to_string(), + _ => "{}".to_string(), + }; + + let id = item + .get("id") + .or_else(|| item.get("call_id")) + .or_else(|| item.get("tool_call_id")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| item.get("index").and_then(|v| v.as_i64()).map(|i| format!("call_index_{}", i))) + .unwrap_or_else(|| format!("call_{}", Uuid::new_v4())); + + Some(ToolCall { + id, + tool_type: "function".to_string(), + function: ToolCallFunction { + name, + arguments, + }, + }) +} + +fn build_sse_response(response: &ChatCompletionsResponse) -> warp::reply::Response { + let chunk_id = format!("chatcmpl-{}", Uuid::new_v4()); + let created = chrono::Utc::now().timestamp(); + let model = response.model.as_str(); + let content = response + .choices + .first() + .map(|choice| choice.message.content.as_str()) + .unwrap_or_default(); + let tool_calls = response + .choices + .first() + .and_then(|choice| choice.message.tool_calls.as_ref()); + let has_tool_calls = tool_calls.is_some(); + + let mut chunks = Vec::new(); + chunks.push(json!({ + "id": &chunk_id, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": [{ + "index": 0, + "delta": { "role": "assistant" }, + "finish_reason": null + }] + })); + + if let Some(tool_calls) = tool_calls { + let tool_calls_delta = tool_calls + .iter() + .enumerate() + .map(|(index, call)| { + json!({ + "index": index, + "id": call.id.as_str(), + "type": call.tool_type.as_str(), + "function": { + "name": call.function.name.as_str(), + "arguments": call.function.arguments.as_str() + } + }) + }) + .collect::>(); + chunks.push(json!({ + "id": &chunk_id, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": [{ + "index": 0, + "delta": { "tool_calls": tool_calls_delta }, + "finish_reason": null + }] + })); + } + + if !content.is_empty() { + chunks.push(json!({ + "id": &chunk_id, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": [{ + "index": 0, + "delta": { "content": content }, + "finish_reason": null + }] + })); + } + + let finish_reason = if has_tool_calls { "tool_calls" } else { "stop" }; + chunks.push(json!({ + "id": &chunk_id, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": [{ + "index": 0, + "delta": {}, + "finish_reason": finish_reason + }] + })); + + let mut sse_body = String::new(); + for chunk in chunks { + sse_body.push_str("data: "); + sse_body.push_str(&chunk.to_string()); + sse_body.push_str("\n\n"); + } + sse_body.push_str("data: [DONE]\n\n"); + + let reply = warp::reply::with_header(sse_body, "content-type", "text/event-stream"); + let reply = warp::reply::with_header(reply, "cache-control", "no-cache"); + let reply = warp::reply::with_header(reply, "connection", "keep-alive"); + let reply = warp::reply::with_header(reply, "access-control-allow-origin", "*"); + reply.into_response() } // Enhanced logging function @@ -509,6 +1073,7 @@ async fn main() -> Result<()> { println!("\n Configure CLINE with:"); println!(" Base URL: http://localhost:{}", args.port); println!(" Model: gpt-5"); + println!(" Models: gpt-5.2-codex, gpt-5.1-codex-max, gpt-5.2, gpt-5.1-codex-mini"); println!(" API Key: (any value)"); warp::serve(routes) @@ -541,26 +1106,8 @@ async fn universal_request_handler( ("GET", "/models") | ("GET", "/v1/models") => { println!("📋 === MATCHED MODELS REQUEST ==="); println!("📋 === END MATCHED ===\n"); - - let models_response = json!({ - "object": "list", - "data": [ - { - "id": "gpt-4", - "object": "model", - "created": 1687882411, - "owned_by": "openai" - }, - { - "id": "gpt-5", - "object": "model", - "created": 1687882411, - "owned_by": "openai" - } - ] - }); - - Ok(warp::reply::json(&models_response).into_response()) + + Ok(warp::reply::json(&fallback_models_response()).into_response()) }, ("POST", "/chat/completions") | ("POST", "/v1/chat/completions") => { println!("🔥 === MATCHED CHAT COMPLETIONS ==="); @@ -630,66 +1177,42 @@ async fn universal_request_handler( println!(" Messages: {} items", chat_req.messages.len()); for (i, msg) in chat_req.messages.iter().enumerate() { let content_preview = match &msg.content { - Value::String(s) => s.chars().take(50).collect::(), - Value::Array(arr) => format!("[array with {} items]", arr.len()), - _ => format!("[{}]", msg.content.to_string().chars().take(50).collect::()), + Some(Value::String(s)) => s.chars().take(50).collect::(), + Some(Value::Array(arr)) => format!("[array with {} items]", arr.len()), + Some(Value::Null) | None => "[empty]".to_string(), + Some(value) => { + format!("[{}]", value.to_string().chars().take(50).collect::()) + } }; println!(" [{}] {}: {}", i, msg.role, content_preview); } println!("🔥 === END MATCHED ===\n"); - - // Check if streaming is requested - if chat_req.stream.unwrap_or(false) { - println!("🔄 STREAMING: CLINE requested streaming response, implementing SSE format"); - - // Generate contextual response based on user messages - let message = improved_response::generate_contextual_response(&chat_req.messages); - println!("📝 Generated contextual response: {}", &message[..std::cmp::min(100, message.len())]); - - let chunk_id = "chatcmpl-streaming-12345"; - let model = chat_req.model.clone(); - - let sse_chunks = vec![ - // First chunk with role - format!("data: {{\"id\":\"{}\",\"object\":\"chat.completion.chunk\",\"created\":{},\"model\":\"{}\",\"choices\":[{{\"index\":0,\"delta\":{{\"role\":\"assistant\"}},\"finish_reason\":null}}]}}\n\n", - chunk_id, chrono::Utc::now().timestamp(), model), - // Content chunk - format!("data: {{\"id\":\"{}\",\"object\":\"chat.completion.chunk\",\"created\":{},\"model\":\"{}\",\"choices\":[{{\"index\":0,\"delta\":{{\"content\":\"{}\"}},\"finish_reason\":null}}]}}\n\n", - chunk_id, chrono::Utc::now().timestamp(), model, message), - // Final chunk - format!("data: {{\"id\":\"{}\",\"object\":\"chat.completion.chunk\",\"created\":{},\"model\":\"{}\",\"choices\":[{{\"index\":0,\"delta\":{{}},\"finish_reason\":\"stop\"}}]}}\n\n", - chunk_id, chrono::Utc::now().timestamp(), model), - // End marker - "data: [DONE]\n\n".to_string(), - ]; - - let sse_response = sse_chunks.join(""); - let reply = warp::reply::with_header(sse_response, "content-type", "text/event-stream"); - let reply = warp::reply::with_header(reply, "cache-control", "no-cache"); - let reply = warp::reply::with_header(reply, "connection", "keep-alive"); - let reply = warp::reply::with_header(reply, "access-control-allow-origin", "*"); - Ok(reply.into_response()) - } else { - match proxy.proxy_request(chat_req).await { - Ok(response) => { + + let stream_requested = chat_req.stream.unwrap_or(false); + match proxy.proxy_request(chat_req).await { + Ok(response) => { + if stream_requested { + println!("🔄 STREAMING: sending SSE response"); + Ok(build_sse_response(&response).into_response()) + } else { let reply = warp::reply::json(&response); let reply = warp::reply::with_header(reply, "content-type", "application/json"); let reply = warp::reply::with_header(reply, "access-control-allow-origin", "*"); Ok(reply.into_response()) - }, - Err(e) => { - eprintln!("Proxy error: {:#}", e); - let reply = warp::reply::json(&json!({ - "error": { - "message": format!("Proxy error: {}", e), - "type": "proxy_error", - "code": "internal_error" - } - })); - let reply = warp::reply::with_header(reply, "content-type", "application/json"); - let reply = warp::reply::with_header(reply, "access-control-allow-origin", "*"); - Ok(reply.into_response()) } + }, + Err(e) => { + eprintln!("Proxy error: {:#}", e); + let reply = warp::reply::json(&json!({ + "error": { + "message": format!("Proxy error: {}", e), + "type": "proxy_error", + "code": "internal_error" + } + })); + let reply = warp::reply::with_header(reply, "content-type", "application/json"); + let reply = warp::reply::with_header(reply, "access-control-allow-origin", "*"); + Ok(reply.into_response()) } } }, @@ -711,26 +1234,7 @@ async fn handle_models( println!("📋 === MATCHED MODELS REQUEST ==="); println!("📋 === END MATCHED ===\n"); - // Return a simple models list for CLINE - let models_response = json!({ - "object": "list", - "data": [ - { - "id": "gpt-4", - "object": "model", - "created": 1687882411, - "owned_by": "openai" - }, - { - "id": "gpt-5", - "object": "model", - "created": 1687882411, - "owned_by": "openai" - } - ] - }); - - Ok(warp::reply::json(&models_response)) + Ok(warp::reply::json(&fallback_models_response())) } async fn handle_chat_completions( @@ -769,4 +1273,4 @@ impl Clone for ProxyServer { auth_data: self.auth_data.clone(), } } -} \ No newline at end of file +}