Skip to content
This repository was archived by the owner on Jul 16, 2026. It is now read-only.

Commit 2fa43c0

Browse files
committed
provider: fix Mistral 422 (#261) and make SSE idle timeout configurable (#196)
#261: Mistral strictly enforces the OpenAI schema and 422s on the non-standard reasoning_content message field and top-level thinking request field. Add strict_openai_schema_endpoint() (matches the mistral profile id or *.mistral.ai api_base) and suppress both reasoning_content injection and the thinking field for those endpoints. #196: Replace the hardcoded 180s SSE_CHUNK_TIMEOUT with a configurable [provider] stream_idle_timeout_secs (default 180), overridable via JCODE_STREAM_IDLE_TIMEOUT_SECS, so slow reasoning models that think silently for minutes don't trip a premature timeout.
1 parent 0835959 commit 2fa43c0

7 files changed

Lines changed: 104 additions & 4 deletions

File tree

crates/jcode-base/src/config/default_file.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,11 @@ cross_provider_failover = "countdown"
230230
# Copilot premium mode: "normal" (default), "one" (first msg only), "zero" (all free)
231231
# Set to "zero" if you have premium Copilot and want free requests
232232
# copilot_premium = "zero"
233+
# Max seconds to wait for streaming data before timing out a request with no
234+
# data received. Raise this for slow reasoning models (e.g. DeepSeek) that think
235+
# silently for minutes before emitting tokens. Default: 180.
236+
# Also overridable per-launch via JCODE_STREAM_IDLE_TIMEOUT_SECS.
237+
# stream_idle_timeout_secs = 600
233238
234239
[ambient]
235240
# Ambient mode: background agent that maintains your codebase

crates/jcode-base/src/config/env_overrides.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,13 @@ impl Config {
548548
self.provider.same_provider_account_failover = enabled;
549549
}
550550
}
551+
if let Ok(v) = std::env::var("JCODE_STREAM_IDLE_TIMEOUT_SECS") {
552+
if let Ok(parsed) = v.trim().parse::<u64>() {
553+
if parsed > 0 {
554+
self.provider.stream_idle_timeout_secs = parsed;
555+
}
556+
}
557+
}
551558

552559
// Copilot premium mode: env var overrides config
553560
// If set in config but not in env, propagate config -> env

crates/jcode-base/src/provider/openrouter.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1185,6 +1185,20 @@ impl OpenRouterProvider {
11851185
}
11861186
}
11871187

1188+
/// Detect providers that strictly enforce the OpenAI-compatible schema and
1189+
/// reject the non-standard `reasoning_content` message field and top-level
1190+
/// `thinking` request field. Mistral's API returns 422 "Extra inputs are
1191+
/// not permitted" when either is present (issue #261).
1192+
fn strict_openai_schema_endpoint(profile_id: Option<&str>, api_base: &str) -> bool {
1193+
if profile_id
1194+
.map(|id| id.eq_ignore_ascii_case("mistral"))
1195+
.unwrap_or(false)
1196+
{
1197+
return true;
1198+
}
1199+
api_base.to_ascii_lowercase().contains("mistral.ai")
1200+
}
1201+
11881202
pub fn new() -> Result<Self> {
11891203
let autodetected_profile = autodetected_openai_compatible_profile();
11901204
let api_base = configured_api_base();

crates/jcode-base/src/provider/openrouter_provider_impl.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ impl Provider for OpenRouterProvider {
2525
let include_reasoning_content =
2626
thinking_enabled == Some(true) || (allow_reasoning && Self::is_kimi_model(&model));
2727

28+
// Some OpenAI-compatible providers (e.g. Mistral) strictly enforce the
29+
// OpenAI schema and reject the non-standard `reasoning_content` message
30+
// field and top-level `thinking` request field with a 422 error
31+
// ("Extra inputs are not permitted"). Suppress both for those endpoints
32+
// regardless of any thinking override (issue #261).
33+
let strict_openai_schema =
34+
Self::strict_openai_schema_endpoint(self.profile_id.as_deref(), &self.api_base);
35+
let allow_reasoning = allow_reasoning && !strict_openai_schema;
36+
let include_reasoning_content = include_reasoning_content && !strict_openai_schema;
37+
2838
let mut effective_messages: Vec<Message> = messages.to_vec();
2939
let cache_supported = self.model_supports_cache(&model).await;
3040
let cache_control_added = if cache_supported {
@@ -548,8 +558,11 @@ impl Provider for OpenRouterProvider {
548558
}
549559

550560
// Optional thinking override for OpenRouter (provider-specific).
561+
// Skip for strict OpenAI-schema endpoints (e.g. Mistral) which reject
562+
// the non-standard top-level `thinking` field with a 422 (issue #261).
551563
if let Some(enable) = thinking_enabled
552564
&& !sent_reasoning_config
565+
&& !strict_openai_schema
553566
{
554567
request["thinking"] = serde_json::json!({
555568
"type": if enable { "enabled" } else { "disabled" }

crates/jcode-base/src/provider/openrouter_sse_stream.rs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -187,10 +187,19 @@ async fn stream_response(
187187

188188
let mut stream = OpenRouterStream::new(response.bytes_stream(), model.clone(), provider_pin);
189189

190-
const SSE_CHUNK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180);
190+
// Idle timeout between streamed chunks. Configurable so slow reasoning
191+
// models (e.g. DeepSeek) that think silently for minutes before emitting
192+
// tokens don't trip a premature timeout (issue #196). Resolved from
193+
// `[provider] stream_idle_timeout_secs` / `JCODE_STREAM_IDLE_TIMEOUT_SECS`,
194+
// defaulting to 180s.
195+
let idle_timeout_secs = crate::config::config()
196+
.provider
197+
.stream_idle_timeout_secs
198+
.max(1);
199+
let sse_chunk_timeout = std::time::Duration::from_secs(idle_timeout_secs);
191200

192201
loop {
193-
let event = match tokio::time::timeout(SSE_CHUNK_TIMEOUT, stream.next()).await {
202+
let event = match tokio::time::timeout(sse_chunk_timeout, stream.next()).await {
194203
Ok(Some(Ok(event))) => event,
195204
Ok(Some(Err(e))) => anyhow::bail!(
196205
"OpenAI-compatible stream error\n endpoint: {}\n model: {}\n auth: {}\n error: {}",
@@ -201,12 +210,16 @@ async fn stream_response(
201210
),
202211
Ok(None) => break, // stream ended normally
203212
Err(_) => {
204-
crate::logging::warn("OpenRouter SSE stream timed out (no data for 180s)");
213+
crate::logging::warn(&format!(
214+
"OpenRouter SSE stream timed out (no data for {}s)",
215+
idle_timeout_secs
216+
));
205217
anyhow::bail!(
206-
"OpenAI-compatible stream timeout\n endpoint: {}\n model: {}\n auth: {}\n timeout: no data received for 180 seconds\n{}",
218+
"OpenAI-compatible stream timeout\n endpoint: {}\n model: {}\n auth: {}\n timeout: no data received for {} seconds\n{}",
207219
url,
208220
model,
209221
auth.label(),
222+
idle_timeout_secs,
210223
local_endpoint_troubleshooting_hint(&api_base, &model)
211224
);
212225
}

crates/jcode-base/src/provider/openrouter_tests.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1505,3 +1505,45 @@ fn test_endpoint_detail_string() {
15051505
detail
15061506
);
15071507
}
1508+
1509+
#[test]
1510+
fn strict_openai_schema_endpoint_detects_mistral_profile() {
1511+
// Mistral direct profile rejects non-standard reasoning_content/thinking
1512+
// fields with a 422 (issue #261), so it must be flagged strict.
1513+
assert!(OpenRouterProvider::strict_openai_schema_endpoint(
1514+
Some("mistral"),
1515+
"https://api.mistral.ai/v1"
1516+
));
1517+
assert!(OpenRouterProvider::strict_openai_schema_endpoint(
1518+
Some("MISTRAL"),
1519+
"https://example.com/v1"
1520+
));
1521+
}
1522+
1523+
#[test]
1524+
fn strict_openai_schema_endpoint_detects_mistral_api_base() {
1525+
assert!(OpenRouterProvider::strict_openai_schema_endpoint(
1526+
None,
1527+
"https://api.mistral.ai/v1"
1528+
));
1529+
assert!(OpenRouterProvider::strict_openai_schema_endpoint(
1530+
Some("custom"),
1531+
"https://API.MISTRAL.AI/v1"
1532+
));
1533+
}
1534+
1535+
#[test]
1536+
fn strict_openai_schema_endpoint_allows_other_providers() {
1537+
assert!(!OpenRouterProvider::strict_openai_schema_endpoint(
1538+
Some("deepseek"),
1539+
"https://api.deepseek.com"
1540+
));
1541+
assert!(!OpenRouterProvider::strict_openai_schema_endpoint(
1542+
None,
1543+
"https://openrouter.ai/api/v1"
1544+
));
1545+
assert!(!OpenRouterProvider::strict_openai_schema_endpoint(
1546+
Some("openai"),
1547+
"https://api.openai.com/v1"
1548+
));
1549+
}

crates/jcode-config-types/src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,11 @@ pub struct ProviderConfig {
727727
/// Copilot premium request mode: "normal", "one", or "zero"
728728
/// "zero" means all requests are free (no premium requests consumed)
729729
pub copilot_premium: Option<String>,
730+
/// Max seconds to wait for streaming data before timing out a request with
731+
/// no data received. Raise this for slow reasoning models (e.g. DeepSeek)
732+
/// that think silently for minutes before emitting tokens. Default: 180.
733+
/// Overridable per-launch via `JCODE_STREAM_IDLE_TIMEOUT_SECS`.
734+
pub stream_idle_timeout_secs: u64,
730735
}
731736

732737
impl Default for ProviderConfig {
@@ -744,6 +749,7 @@ impl Default for ProviderConfig {
744749
cross_provider_failover: CrossProviderFailoverMode::Countdown,
745750
same_provider_account_failover: true,
746751
copilot_premium: None,
752+
stream_idle_timeout_secs: 180,
747753
}
748754
}
749755
}

0 commit comments

Comments
 (0)