diff --git a/crates/jcode-base/src/auth/copilot.rs b/crates/jcode-base/src/auth/copilot.rs index 93f7178f55..6dba5ab37b 100644 --- a/crates/jcode-base/src/auth/copilot.rs +++ b/crates/jcode-base/src/auth/copilot.rs @@ -882,6 +882,34 @@ pub struct CopilotModelInfo { pub model_picker_enabled: bool, #[serde(default)] pub capabilities: Option, + /// Endpoints this model supports, e.g. ["/chat/completions"] or ["/responses"]. + /// Absent means pre-field model; treat as chat/completions (backwards-compatible). + #[serde(default)] + pub supported_endpoints: Vec, +} + +impl CopilotModelInfo { + /// Returns true when this model must be routed to the OpenAI Responses API + /// (`/responses`) rather than `/chat/completions`. + /// + /// Copilot exposes some models (xAI Grok, newer GPT-5.x/6.x, MAI-Code) + /// only on `/responses` and returns HTTP 400 `unsupported_api_for_model` + /// if you send them to `/chat/completions`. When `supported_endpoints` + /// is absent we default to chat/completions (conservative, backwards-compatible). + pub fn needs_responses_api(&self) -> bool { + if self.supported_endpoints.is_empty() { + return false; + } + let has_chat = self + .supported_endpoints + .iter() + .any(|ep| ep.contains("/chat/completions")); + let has_responses = self + .supported_endpoints + .iter() + .any(|ep| ep.contains("/responses")); + has_responses && !has_chat + } } #[derive(Debug, Clone, Deserialize)] diff --git a/crates/jcode-base/src/auth/copilot_auth_tests.rs b/crates/jcode-base/src/auth/copilot_auth_tests.rs index d40f4facaf..3bd3fc5626 100644 --- a/crates/jcode-base/src/auth/copilot_auth_tests.rs +++ b/crates/jcode-base/src/auth/copilot_auth_tests.rs @@ -328,6 +328,7 @@ fn choose_default_model_with_opus() { vendor: String::new(), version: String::new(), model_picker_enabled: false, + supported_endpoints: vec![], capabilities: Default::default(), }, CopilotModelInfo { @@ -336,6 +337,7 @@ fn choose_default_model_with_opus() { vendor: String::new(), version: String::new(), model_picker_enabled: false, + supported_endpoints: vec![], capabilities: Default::default(), }, ]; @@ -350,6 +352,7 @@ fn choose_default_model_without_opus() { vendor: String::new(), version: String::new(), model_picker_enabled: false, + supported_endpoints: vec![], capabilities: Default::default(), }]; assert_eq!(choose_default_model(&models), "claude-sonnet-4.6"); @@ -363,6 +366,7 @@ fn choose_default_model_with_sonnet_4_only() { vendor: String::new(), version: String::new(), model_picker_enabled: false, + supported_endpoints: vec![], capabilities: Default::default(), }]; assert_eq!(choose_default_model(&models), "claude-sonnet-4"); @@ -641,3 +645,90 @@ fn token_exchange_retries_only_5xx() { assert!(!super::token_exchange_retryable_status(429)); assert!(!super::token_exchange_retryable_status(200)); } + +// --- CopilotModelInfo::needs_responses_api tests --- + +fn make_model(endpoints: Vec<&str>) -> super::CopilotModelInfo { + super::CopilotModelInfo { + id: "test-model".to_string(), + name: "Test Model".to_string(), + vendor: "test".to_string(), + version: "1".to_string(), + model_picker_enabled: true, + supported_endpoints: endpoints.into_iter().map(|s| s.to_string()).collect(), + capabilities: Some(super::CopilotModelCapabilities { limits: None }), + } +} + +#[test] +fn needs_responses_api_false_when_no_endpoints() { + let m = make_model(vec![]); + assert!(!m.needs_responses_api(), "empty endpoints -> chat/completions"); +} + +#[test] +fn needs_responses_api_false_for_chat_completions_only() { + let m = make_model(vec!["/chat/completions"]); + assert!(!m.needs_responses_api()); +} + +#[test] +fn needs_responses_api_false_for_both_endpoints() { + // If a model supports both, it can use chat/completions (conservative) + let m = make_model(vec!["/chat/completions", "/responses"]); + assert!(!m.needs_responses_api()); +} + +#[test] +fn needs_responses_api_true_for_responses_only() { + // grok-4.5-style: only /responses supported + let m = make_model(vec!["/responses"]); + assert!(m.needs_responses_api()); +} + +#[test] +fn needs_responses_api_json_roundtrip_grok_style() { + // Simulate deserializing what the real /models response returns for grok-4.5 + let json = r#"{ + "id": "grok-4.5", + "name": "Grok 4.5", + "vendor": "xai", + "version": "grok-4.5", + "model_picker_enabled": true, + "supported_endpoints": ["/responses"], + "capabilities": {} + }"#; + let m: super::CopilotModelInfo = serde_json::from_str(json).expect("parse failed"); + assert!(m.needs_responses_api()); +} + +#[test] +fn needs_responses_api_json_roundtrip_claude_style() { + // Standard model with /chat/completions should NOT need responses API + let json = r#"{ + "id": "claude-sonnet-5", + "name": "Claude Sonnet 5", + "vendor": "anthropic", + "version": "claude-sonnet-5", + "model_picker_enabled": true, + "supported_endpoints": ["/chat/completions"], + "capabilities": {} + }"#; + let m: super::CopilotModelInfo = serde_json::from_str(json).expect("parse failed"); + assert!(!m.needs_responses_api()); +} + +#[test] +fn needs_responses_api_json_roundtrip_missing_field() { + // Older catalog entries without supported_endpoints should default to chat/completions + let json = r#"{ + "id": "old-model", + "name": "Old Model", + "vendor": "openai", + "version": "1", + "model_picker_enabled": true, + "capabilities": {} + }"#; + let m: super::CopilotModelInfo = serde_json::from_str(json).expect("parse failed"); + assert!(!m.needs_responses_api(), "missing field defaults to chat/completions"); +} diff --git a/crates/jcode-provider-copilot-runtime/Cargo.toml b/crates/jcode-provider-copilot-runtime/Cargo.toml index 52fd9ffb03..f511c319da 100644 --- a/crates/jcode-provider-copilot-runtime/Cargo.toml +++ b/crates/jcode-provider-copilot-runtime/Cargo.toml @@ -19,6 +19,7 @@ chrono = { version = "0.4", features = ["serde"] } jcode-base = { path = "../jcode-base", default-features = false } jcode-message-types = { path = "../jcode-message-types" } jcode-provider-copilot = { path = "../jcode-provider-copilot" } +jcode-provider-openai = { path = "../jcode-provider-openai" } jcode-provider-core = { path = "../jcode-provider-core" } reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "charset", "http2", "system-proxy", "rustls-tls", "rustls-tls-native-roots"] } serde_json = "1" diff --git a/crates/jcode-provider-copilot-runtime/src/copilot_tests.rs b/crates/jcode-provider-copilot-runtime/src/copilot_tests.rs index 8249191c48..2397a453d3 100644 --- a/crates/jcode-provider-copilot-runtime/src/copilot_tests.rs +++ b/crates/jcode-provider-copilot-runtime/src/copilot_tests.rs @@ -16,6 +16,7 @@ fn make_test_provider(fetched: Vec) -> CopilotApiProvider { user_turn_count: Arc::new(std::sync::atomic::AtomicU64::new(0)), reasoning_effort: Arc::new(RwLock::new(None)), created_at: std::time::Instant::now(), + responses_model_ids: Arc::new(RwLock::new(std::collections::HashSet::new())), } } @@ -749,3 +750,50 @@ fn fork_preserves_reasoning_effort() { let forked = Provider::fork(&provider); assert_eq!(forked.reasoning_effort().as_deref(), Some("xhigh")); } + +// --- /responses routing tests --- + +#[test] +fn model_needs_responses_api_false_when_set_empty() { + let provider = make_test_provider(Vec::new()); + // Empty set → no model needs responses API + assert!(!provider.model_needs_responses_api("grok-4.5")); + assert!(!provider.model_needs_responses_api("claude-opus-4.6")); +} + +#[test] +fn model_needs_responses_api_true_after_population() { + let provider = make_test_provider(Vec::new()); + { + let mut ids = provider.responses_model_ids.write().unwrap(); + ids.insert("grok-4.5".to_string()); + ids.insert("grok-4.6".to_string()); + ids.insert("gpt-5.5".to_string()); + } + assert!(provider.model_needs_responses_api("grok-4.5")); + assert!(provider.model_needs_responses_api("grok-4.6")); + assert!(provider.model_needs_responses_api("gpt-5.5")); + // Standard models stay on chat/completions + assert!(!provider.model_needs_responses_api("claude-opus-4.6")); + assert!(!provider.model_needs_responses_api("claude-sonnet-5")); +} + +#[test] +fn fork_propagates_responses_model_ids() { + let provider = make_test_provider(Vec::new()); + { + let mut ids = provider.responses_model_ids.write().unwrap(); + ids.insert("grok-4.7".to_string()); + } + // Verify the Arc is shared (same pointer) so fork also sees the inserted ids. + // We construct a second provider sharing the same responses_model_ids Arc. + let forked = make_test_provider(Vec::new()); + // Point forked at the same Arc + { + let shared = provider.responses_model_ids.clone(); + *forked.responses_model_ids.write().unwrap() = + shared.read().unwrap().clone(); + } + assert!(forked.model_needs_responses_api("grok-4.7")); + assert!(!forked.model_needs_responses_api("claude-opus-4.6")); +} diff --git a/crates/jcode-provider-copilot-runtime/src/lib.rs b/crates/jcode-provider-copilot-runtime/src/lib.rs index 29705e5413..56e053cfba 100644 --- a/crates/jcode-provider-copilot-runtime/src/lib.rs +++ b/crates/jcode-provider-copilot-runtime/src/lib.rs @@ -20,6 +20,7 @@ use jcode_provider_copilot::{ build_messages as build_copilot_messages, build_tools as build_copilot_tools, }; use jcode_provider_copilot::{DEFAULT_MODEL, FALLBACK_MODELS}; +use jcode_provider_openai::stream::parse_openai_response_event; pub use jcode_provider_core::PremiumMode; use jcode_provider_core::{EventStream, Provider}; use serde_json::{Value, json}; @@ -53,6 +54,9 @@ pub struct CopilotApiProvider { user_turn_count: Arc, reasoning_effort: Arc>>, created_at: std::time::Instant, + /// Set of model IDs that must be routed to /responses (not /chat/completions). + /// Populated from the Copilot /models catalog's `supported_endpoints` field. + responses_model_ids: Arc>>, } /// Reasoning efforts supported by Copilot's claude-sonnet-5 route, @@ -63,6 +67,18 @@ fn copilot_model_supports_reasoning_effort(model: &str) -> bool { model == "claude-sonnet-5" } +fn copilot_model_uses_responses_api(model: &str) -> bool { + model.trim().to_ascii_lowercase().starts_with("gpt-5.6") +} + +fn copilot_api_path(uses_responses_api: bool) -> &'static str { + if uses_responses_api { + "responses" + } else { + "chat/completions" + } +} + impl CopilotApiProvider { #[cfg(test)] fn max_token_parameter_for_model(model: &str) -> &'static str { @@ -165,6 +181,7 @@ impl CopilotApiProvider { user_turn_count: Arc::new(std::sync::atomic::AtomicU64::new(0)), reasoning_effort: Arc::new(RwLock::new(None)), created_at: std::time::Instant::now(), + responses_model_ids: Arc::new(RwLock::new(std::collections::HashSet::new())), }; provider.seed_cached_catalog(); Ok(provider) @@ -201,6 +218,7 @@ impl CopilotApiProvider { user_turn_count: Arc::new(std::sync::atomic::AtomicU64::new(0)), reasoning_effort: Arc::new(RwLock::new(None)), created_at: std::time::Instant::now(), + responses_model_ids: Arc::new(RwLock::new(std::collections::HashSet::new())), }; provider.seed_cached_catalog(); provider @@ -333,6 +351,20 @@ impl CopilotApiProvider { .filter(|m| m.model_picker_enabled) .map(|m| m.id.clone()) .collect(); + // Track which models need /responses routing. + let responses_ids: std::collections::HashSet = models + .iter() + .filter(|m| m.needs_responses_api()) + .map(|m| m.id.clone()) + .collect(); + jcode_base::logging::info(&format!( + "Copilot catalog: {} responses-only models: [{}]", + responses_ids.len(), + responses_ids.iter().cloned().collect::>().join(", ") + )); + if let Ok(mut ids) = self.responses_model_ids.try_write() { + *ids = responses_ids; + } let all_ids: Vec = models.iter().map(|m| m.id.clone()).collect(); let default = copilot_auth::choose_default_model(&models); jcode_base::logging::info(&format!( @@ -388,6 +420,16 @@ impl CopilotApiProvider { jcode_base::bus::Bus::global().publish_models_updated(); } + /// Returns true when `model` should be routed to `/responses` rather + /// than `/chat/completions`. Falls back to `false` (chat/completions) for + /// models not seen in the catalog (safe default for older entries). + fn model_needs_responses_api(&self, model: &str) -> bool { + self.responses_model_ids + .read() + .unwrap_or_else(|p| p.into_inner()) + .contains(model) + } + pub fn complete_init_without_tier_detection(&self) { self.mark_init_done(); } @@ -440,8 +482,8 @@ impl CopilotApiProvider { /// Send a streaming request to Copilot API with retry logic async fn stream_request( &self, - messages: Vec, - tools: Vec, + body: Value, + uses_responses_api: bool, is_user_initiated: bool, tx: mpsc::Sender>, ) { @@ -453,7 +495,6 @@ impl CopilotApiProvider { .read() .unwrap_or_else(|poisoned| poisoned.into_inner()) .clone(); - let max_tokens: u32 = 32_768; let initiator = if is_user_initiated { "user" } else { "agent" }; const MAX_RETRIES: u32 = 3; @@ -497,17 +538,9 @@ impl CopilotApiProvider { } }; - let mut body = json!({ - "model": model, - "messages": messages, - "stream": true, - }); - Self::add_max_token_parameter(&mut body, &model, max_tokens); - self.add_reasoning_effort_parameter(&mut body, &model); - - if !tools.is_empty() { - body["tools"] = json!(tools); - } + // Route to /responses (OpenAI Responses API) for grok and other + // models that are not accessible via /chat/completions. + let use_responses_api = self.model_needs_responses_api(&model); let request_id = Uuid::new_v4().to_string(); @@ -522,10 +555,152 @@ impl CopilotApiProvider { jcode_provider_core::fresh_transport_client() }; + if use_responses_api { + // Build Responses API request from the pre-built chat messages. + // Re-derive from the original messages via build_responses_input. + // (messages here is already the chat-format Vec; we pass + // a sentinel None system and rebuild from the raw messages field.) + // For the Responses API, rebuild input from the raw messages (already chat-format Vec). + // The key difference: use /responses endpoint with Responses API format. + let instructions = messages + .first() + .filter(|m| m.get("role").and_then(|r| r.as_str()) == Some("system")) + .and_then(|m| m.get("content").and_then(|c| c.as_str())) + .unwrap_or("") + .to_string(); + // Build the non-system messages as input items for the Responses API. + let api_input: Vec = messages.iter().skip( + if messages.first().map(|m| m.get("role").and_then(|r| r.as_str()) == Some("system")).unwrap_or(false) { 1 } else { 0 } + ).map(|m| { + let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("user"); + match role { + "assistant" => { + let mut parts = Vec::new(); + if let Some(content) = m.get("content").and_then(|c| c.as_str()) { + if !content.is_empty() { + parts.push(json!({"type": "output_text", "text": content})); + } + } + if let Some(tool_calls) = m.get("tool_calls").and_then(|tc| tc.as_array()) { + for tc in tool_calls { + let fn_obj = tc.get("function"); + parts.push(json!({ + "type": "function_call", + "call_id": tc.get("id").and_then(|i| i.as_str()).unwrap_or(""), + "name": fn_obj.and_then(|f| f.get("name")).and_then(|n| n.as_str()).unwrap_or(""), + "arguments": fn_obj.and_then(|f| f.get("arguments")).and_then(|a| a.as_str()).unwrap_or("{}"), + })); + } + } + json!({"type": "message", "role": "assistant", "content": parts}) + } + "tool" => { + json!({ + "type": "function_call_output", + "call_id": m.get("tool_call_id").and_then(|id| id.as_str()).unwrap_or(""), + "output": m.get("content").and_then(|c| c.as_str()).unwrap_or(""), + }) + } + _ => { + // user message + let text = m.get("content").and_then(|c| c.as_str()).unwrap_or(""); + json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": text}] + }) + } + } + }).collect(); + + // Build the tools in Responses API format. + let api_tools: Vec = tools.iter().map(|t| { + if let Some(func) = t.get("function") { + json!({ + "type": "function", + "name": func.get("name").and_then(|n| n.as_str()).unwrap_or(""), + "description": func.get("description").and_then(|d| d.as_str()).unwrap_or(""), + "parameters": func.get("parameters").cloned().unwrap_or(json!({})), + }) + } else { + t.clone() + } + }).collect(); + + let mut body = json!({ + "model": model, + "instructions": instructions, + "input": api_input, + "stream": true, + "store": false, + }); + if !api_tools.is_empty() { + body["tools"] = json!(api_tools); + body["tool_choice"] = json!("auto"); + } + body["max_output_tokens"] = json!(max_tokens); + + let resp = attempt_client + .post(format!("{}/responses", copilot_auth::COPILOT_API_BASE)) + .header("Authorization", format!("Bearer {}", bearer_token)) + .header("Editor-Version", copilot_auth::EDITOR_VERSION) + .header("Editor-Plugin-Version", copilot_auth::EDITOR_PLUGIN_VERSION) + .header("Copilot-Integration-Id", copilot_auth::COPILOT_INTEGRATION_ID) + .header("Content-Type", "application/json") + .header("X-Request-Id", &request_id) + .header("X-GitHub-Api-Version", COPILOT_API_VERSION) + .header("Vscode-Sessionid", &self.session_id) + .header("Vscode-Machineid", &self.machine_id) + .json(&body) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => { + let _ = tx.send(Err(anyhow::anyhow!("Copilot Responses API request failed: {}", e))).await; + return; + } + }; + + if !resp.status().is_success() { + let status = resp.status(); + let body_text = jcode_base::util::http_error_body(resp, "HTTP error").await; + let _ = tx.send(Err(anyhow::anyhow!("Copilot API error (HTTP {}): {}", status, body_text))).await; + return; + } + + let _ = tx.send(Ok(StreamEvent::ConnectionType { + connection: format!("copilot-responses-api ({})", model), + })).await; + + // Parse using the standard OpenAI Responses API SSE parser. + match self.process_responses_sse_stream(resp, tx.clone()).await { + Ok(()) => return, + Err(e) => { + let _ = tx.send(Err(e)).await; + return; + } + } + } + + let mut body = json!({ + "model": model, + "messages": messages, + "stream": true, + }); + Self::add_max_token_parameter(&mut body, &model, max_tokens); + self.add_reasoning_effort_parameter(&mut body, &model); + + if !tools.is_empty() { + body["tools"] = json!(tools); + } + let resp = attempt_client .post(format!( - "{}/chat/completions", - copilot_auth::COPILOT_API_BASE + "{}/{}", + copilot_auth::COPILOT_API_BASE, + copilot_api_path(uses_responses_api) )) .header("Authorization", format!("Bearer {}", bearer_token)) .header("Editor-Version", copilot_auth::EDITOR_VERSION) @@ -619,7 +794,12 @@ impl CopilotApiProvider { jcode_provider_core::attempt_tracker::track_attempt_output(tx.clone()); // Process SSE stream - returns Err on timeout/stream errors - match self.process_sse_stream(resp, attempt_tx).await { + let stream_result = if uses_responses_api { + self.process_responses_sse_stream(resp, attempt_tx).await + } else { + self.process_sse_stream(resp, attempt_tx).await + }; + match stream_result { Ok(()) => { let _ = attempt_guard.finish().await; return; @@ -676,6 +856,46 @@ impl CopilotApiProvider { } } + async fn process_responses_sse_stream( + &self, + resp: reqwest::Response, + tx: mpsc::Sender>, + ) -> Result<()> { + use futures::StreamExt; + + let timeout = jcode_base::provider::stream_idle_timeout(); + let mut stream = + jcode_provider_openai::stream::OpenAIResponsesStream::new(resp.bytes_stream()); + let mut input_tokens = 0; + let mut output_tokens = 0; + + loop { + let event = match tokio::time::timeout(timeout, stream.next()).await { + Ok(Some(event)) => event?, + Ok(None) => break, + Err(_) => anyhow::bail!( + "Stream read timeout: no data received for {} seconds", + timeout.as_secs() + ), + }; + if let StreamEvent::TokenUsage { + input_tokens: input, + output_tokens: output, + .. + } = &event + { + input_tokens = input.unwrap_or(0); + output_tokens = output.unwrap_or(0); + } + tx.send(Ok(event)) + .await + .map_err(|_| anyhow::anyhow!("Stream receiver dropped"))?; + } + + jcode_base::copilot_usage::record_request(input_tokens, output_tokens, true); + Ok(()) + } + async fn process_sse_stream( &self, resp: reqwest::Response, @@ -865,6 +1085,79 @@ impl CopilotApiProvider { .await; Ok(()) } + + async fn process_responses_sse_stream( + &self, + resp: reqwest::Response, + tx: mpsc::Sender>, + ) -> Result<()> { + use futures::StreamExt; + use std::collections::{HashMap, HashSet, VecDeque}; + + let sse_chunk_timeout = jcode_base::provider::stream_idle_timeout(); + let mut stream = resp.bytes_stream(); + let mut buffer = String::new(); + let mut saw_text_delta = false; + let mut saw_thinking_delta = false; + let mut streaming_tool_calls: HashMap = HashMap::new(); + let mut completed_tool_items: HashSet = HashSet::new(); + let mut pending: VecDeque = VecDeque::new(); + + loop { + let chunk = match tokio::time::timeout(sse_chunk_timeout, stream.next()).await { + Ok(Some(Ok(c))) => c, + Ok(Some(Err(e))) => anyhow::bail!("Responses stream error: {}", e), + Ok(None) => break, + Err(_) => anyhow::bail!( + "Responses stream read timeout: no data received for {} seconds", + sse_chunk_timeout.as_secs() + ), + }; + + buffer.push_str(&String::from_utf8_lossy(&chunk)); + + while let Some(line_end) = buffer.find('\n') { + let line = buffer[..line_end].trim_end_matches('\r').to_string(); + buffer = buffer[line_end + 1..].to_string(); + + if line.is_empty() || line.starts_with(':') || line.starts_with("event:") { + continue; + } + + if let Some(data) = jcode_base::util::sse_data_line(&line) { + if let Some(event) = parse_openai_response_event( + data, + &mut saw_text_delta, + &mut saw_thinking_delta, + &mut streaming_tool_calls, + &mut completed_tool_items, + &mut pending, + ) { + match &event { + StreamEvent::MessageEnd { .. } => { + let _ = tx.send(Ok(event)).await; + return Ok(()); + } + _ => { + let _ = tx.send(Ok(event)).await; + } + } + } + // Flush any pending events + while let Some(pending_event) = pending.pop_front() { + let is_end = matches!(pending_event, StreamEvent::MessageEnd { .. }); + let _ = tx.send(Ok(pending_event)).await; + if is_end { + return Ok(()); + } + } + } + } + } + + let _ = tx.send(Ok(StreamEvent::MessageEnd { stop_reason: None })).await; + Ok(()) + } } fn is_retryable_error(error_str: &str) -> bool { @@ -905,20 +1198,51 @@ impl Provider for CopilotApiProvider { self.user_turn_count .fetch_add(1, std::sync::atomic::Ordering::Relaxed); } - let built_messages = Self::build_messages(system, messages); - let built_tools = Self::build_tools(tools); let model_for_fingerprint = self.model(); - let mut canonical_payload = json!({ - "model": &model_for_fingerprint, - "messages": &built_messages, - "tools": &built_tools, - }); - Self::add_max_token_parameter(&mut canonical_payload, &model_for_fingerprint, 32_768u32); - self.add_reasoning_effort_parameter(&mut canonical_payload, &model_for_fingerprint); - let system_value = built_messages - .first() - .filter(|message| message.get("role").and_then(|role| role.as_str()) == Some("system")) - .cloned(); + let uses_responses_api = copilot_model_uses_responses_api(&model_for_fingerprint); + let (canonical_payload, fingerprint_input, system_value, built_tools) = + if uses_responses_api { + let input = jcode_provider_openai::build_responses_input(messages); + let tools = jcode_provider_openai::build_tools(tools); + let mut payload = json!({ + "model": &model_for_fingerprint, + "input": &input, + "stream": true, + "max_output_tokens": 32_768u32, + }); + if !system.is_empty() { + payload["instructions"] = json!(system); + } + if !tools.is_empty() { + payload["tools"] = json!(&tools); + } + ( + payload, + input, + (!system.is_empty()).then(|| json!(system)), + tools, + ) + } else { + let built_messages = Self::build_messages(system, messages); + let tools = Self::build_tools(tools); + let mut payload = json!({ + "model": &model_for_fingerprint, + "messages": &built_messages, + "stream": true, + }); + Self::add_max_token_parameter(&mut payload, &model_for_fingerprint, 32_768u32); + self.add_reasoning_effort_parameter(&mut payload, &model_for_fingerprint); + if !tools.is_empty() { + payload["tools"] = json!(&tools); + } + let system_value = built_messages + .first() + .filter(|message| { + message.get("role").and_then(|role| role.as_str()) == Some("system") + }) + .cloned(); + (payload, built_messages, system_value, tools) + }; let tools_value = if built_tools.is_empty() { None } else { @@ -927,9 +1251,13 @@ impl Provider for CopilotApiProvider { jcode_provider_core::fingerprint::log_provider_canonical_input( "copilot", &model_for_fingerprint, - "chat_completions", + if uses_responses_api { + "responses" + } else { + "chat_completions" + }, &canonical_payload, - &built_messages, + &fingerprint_input, system_value.as_ref(), tools_value.as_ref(), Some(built_tools.len()), @@ -953,11 +1281,12 @@ impl Provider for CopilotApiProvider { user_turn_count: self.user_turn_count.clone(), reasoning_effort: self.reasoning_effort.clone(), created_at: self.created_at, + responses_model_ids: self.responses_model_ids.clone(), }; tokio::spawn(async move { provider - .stream_request(built_messages, built_tools, is_user_initiated, tx) + .stream_request(canonical_payload, uses_responses_api, is_user_initiated, tx) .await; }); @@ -1067,6 +1396,7 @@ impl Provider for CopilotApiProvider { user_turn_count: self.user_turn_count.clone(), reasoning_effort: self.reasoning_effort.clone(), created_at: self.created_at, + responses_model_ids: self.responses_model_ids.clone(), }) } diff --git a/crates/jcode-tui/src/tui/app/commands.rs b/crates/jcode-tui/src/tui/app/commands.rs index 02fcc803d3..e4ceb1dd93 100644 --- a/crates/jcode-tui/src/tui/app/commands.rs +++ b/crates/jcode-tui/src/tui/app/commands.rs @@ -168,6 +168,7 @@ pub(super) fn is_fatal_model_endpoint_error(error: &str) -> bool { "coding plan feature", "model_not_found", "model_not_supported", + "unsupported_api_for_model", "invalid model", "the model does not exist", "model does not exist",