Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added `completion_sound = "file"` with `[notifications].sound_file` so
Windows users can play a custom WAV file for turn-completion sounds without
changing the global Windows sound scheme (#2484, #2512).
- Added `[tui].stream_chunk_timeout_secs` and `/config stream_chunk_timeout_secs`
so slow local or OpenAI-compatible model servers can extend the SSE idle
timeout without mutating process environment. The legacy
`DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var remains a fallback (#2365, #2507).

### Changed

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,7 @@ Key environment variables:
| `DEEPSEEK_BASE_URL` | API base URL |
| `DEEPSEEK_HTTP_HEADERS` | Optional custom model request headers, e.g. `X-Model-Provider-Id=your-model-provider` |
| `DEEPSEEK_MODEL` | Default model |
| `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | Stream idle timeout in seconds, default `300`, clamped to `1..=3600` |
| `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | Legacy stream idle timeout env override, default `300`, clamped to `1..=3600`; `[tui].stream_chunk_timeout_secs` takes precedence when configured |
| `CODEWHALE_PROVIDER` / `DEEPSEEK_PROVIDER` | `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `siliconflow-CN`, `arcee`, `moonshot`, `sglang`, `vllm`, `ollama`, `huggingface` |
| `DEEPSEEK_PROFILE` | Config profile name |
| `DEEPSEEK_MEMORY` | Set to `on` to enable user memory |
Expand Down
1 change: 1 addition & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,7 @@ max_subagents = 10 # optional (1-20)
alternate_screen = "auto" # auto/always use the TUI screen; never uses terminal scrollback
mouse_capture = true # true copies only transcript user/assistant text; false uses raw terminal selection/copy
terminal_probe_timeout_ms = 500 # optional startup terminal-mode timeout (100-5000ms)
stream_chunk_timeout_secs = 300 # optional SSE idle timeout per chunk (0 = default, 1-3600)
osc8_links = true # emit OSC 8 escapes around URLs (Cmd+click in iTerm2/Ghostty/Kitty/WezTerm/Terminal.app 13+); set false for terminals that misrender
# Ordered footer chips shown in the TUI status line. Omit the key to use the
# built-in default; set [] to hide all configurable chips. You can also edit
Expand Down
4 changes: 4 additions & 0 deletions crates/tui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added `completion_sound = "file"` with `[notifications].sound_file` so
Windows users can play a custom WAV file for turn-completion sounds without
changing the global Windows sound scheme (#2484, #2512).
- Added `[tui].stream_chunk_timeout_secs` and `/config stream_chunk_timeout_secs`
so slow local or OpenAI-compatible model servers can extend the SSE idle
timeout without mutating process environment. The legacy
`DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var remains a fallback (#2365, #2507).

### Changed

Expand Down
19 changes: 19 additions & 0 deletions crates/tui/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ pub struct DeepSeekClient {
connection_health: Arc<AsyncMutex<ConnectionHealth>>,
rate_limiter: Arc<AsyncMutex<TokenBucket>>,
path_suffix: Option<String>,
pub(super) stream_idle_timeout: Duration,
}

const CONNECTION_FAILURE_THRESHOLD: u32 = 2;
Expand Down Expand Up @@ -325,6 +326,7 @@ impl Clone for DeepSeekClient {
connection_health: self.connection_health.clone(),
rate_limiter: self.rate_limiter.clone(),
path_suffix: self.path_suffix.clone(),
stream_idle_timeout: self.stream_idle_timeout,
}
}
}
Expand Down Expand Up @@ -581,6 +583,7 @@ impl DeepSeekClient {
validate_base_url_security(&base_url)?;
let retry = config.retry_policy();
let default_model = config.default_model();
let stream_idle_timeout = Duration::from_secs(config.stream_chunk_timeout_secs());
let http_headers = config.http_headers();
let path_suffix = config
.provider_config_for(api_provider)
Expand Down Expand Up @@ -615,6 +618,7 @@ impl DeepSeekClient {
connection_health: Arc::new(AsyncMutex::new(ConnectionHealth::default())),
rate_limiter: Arc::new(AsyncMutex::new(TokenBucket::from_env())),
path_suffix,
stream_idle_timeout,
})
}

Expand Down Expand Up @@ -1683,6 +1687,21 @@ mod tests {
assert!(headers.get("x-blank").is_none());
}

#[test]
fn client_stream_idle_timeout_uses_tui_config() {
let client = DeepSeekClient::new(&Config {
api_key: Some("sk-test".to_string()),
tui: Some(crate::config::TuiConfig {
stream_chunk_timeout_secs: Some(777),
..crate::config::TuiConfig::default()
}),
..Config::default()
})
.expect("client");

assert_eq!(client.stream_idle_timeout, Duration::from_secs(777));
}

#[test]
fn xiaomi_mimo_token_plan_endpoint_uses_api_key_header() {
let headers = DeepSeekClient::default_headers_for_provider(
Expand Down
19 changes: 2 additions & 17 deletions crates/tui/src/client/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,6 @@ use tokio::time::timeout as tokio_timeout;

use crate::config::wire_model_for_provider;

/// Default idle timeout for SSE stream reads (300 seconds = 5 minutes).
/// After this period with no data, the stream is considered stalled and
/// yields a recoverable error so the caller can retry.
const DEFAULT_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(300);

/// Default timeout for the initial streaming response headers.
///
/// `doctor` uses a bounded non-streaming request, but normal TUI turns first
Expand Down Expand Up @@ -48,17 +43,6 @@ fn stream_open_timeout_from_env(value: Option<&str>) -> Duration {
Duration::from_secs(secs)
}

/// Reads the `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var, falling back to
/// the default 300s. The parsed value is clamped to [1, 3600] seconds.
fn stream_idle_timeout() -> Duration {
let secs = std::env::var("DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_STREAM_IDLE_TIMEOUT.as_secs())
.clamp(1, 3600);
Duration::from_secs(secs)
}

use crate::config::ApiProvider;
use crate::llm_client::StreamEventBox;
use crate::llm_client::sanitize_http_error_body;
Expand Down Expand Up @@ -283,6 +267,7 @@ impl DeepSeekClient {
// gzip-compressor failure when investigating #103.
let response_headers = format_stream_headers(response.headers());
let byte_stream = response.bytes_stream();
let stream_idle_timeout = self.stream_idle_timeout;

let stream = async_stream::stream! {
use futures_util::StreamExt;
Expand Down Expand Up @@ -315,7 +300,7 @@ impl DeepSeekClient {
let is_reasoning_model = is_reasoning_model_for_stream(api_provider, &model);

let mut byte_stream = std::pin::pin!(byte_stream);
let idle = stream_idle_timeout();
let idle = stream_idle_timeout;

// Telemetry for #103 stream-decode diagnostics: bytes received
// since the start of this stream and last successful event time.
Expand Down
206 changes: 205 additions & 1 deletion crates/tui/src/commands/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ use std::time::Duration;
use super::CommandResult;
use crate::client::DeepSeekClient;
use crate::config::{
ApiProvider, COMMON_DEEPSEEK_MODELS, Config, DEFAULT_XIAOMI_MIMO_BASE_URL,
ApiProvider, COMMON_DEEPSEEK_MODELS, Config, DEFAULT_STREAM_CHUNK_TIMEOUT_SECS,
DEFAULT_XIAOMI_MIMO_BASE_URL, MAX_STREAM_CHUNK_TIMEOUT_SECS, MIN_STREAM_CHUNK_TIMEOUT_SECS,
XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL, clear_active_provider_api_key, effective_home_dir,
expand_path, normalize_model_name_for_provider,
};
Expand Down Expand Up @@ -152,6 +153,7 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult {
};
Some(config.deepseek_base_url())
}
"stream_chunk_timeout_secs" => Some(app.stream_chunk_timeout_secs.to_string()),
"locale" | "language" => Some(locale_display(app.ui_locale).to_string()),
"theme" | "ui_theme" => {
Some(crate::palette::theme_label_for_mode(app.ui_theme.mode).to_string())
Expand Down Expand Up @@ -417,6 +419,45 @@ fn persist_root_bool_key(
Ok(path)
}

fn persist_tui_integer_key(
config_path: Option<&Path>,
key: &str,
value: u64,
) -> anyhow::Result<PathBuf> {
use anyhow::Context;
use std::fs;

let path = config_toml_path(config_path)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create config directory {}", parent.display()))?;
}

let mut doc: toml::Value = if path.exists() {
let raw = fs::read_to_string(&path)
.with_context(|| format!("failed to read config at {}", path.display()))?;
toml::from_str(&raw)
.with_context(|| format!("failed to parse config at {}", path.display()))?
} else {
toml::Value::Table(toml::value::Table::new())
};
let table = doc
.as_table_mut()
.context("config.toml root must be a table")?;
let tui_entry = table
.entry("tui".to_string())
.or_insert_with(|| toml::Value::Table(toml::value::Table::new()));
let tui_table = tui_entry
.as_table_mut()
.context("`tui` section in config.toml must be a table")?;
let value = i64::try_from(value).context("integer value is too large for TOML")?;
tui_table.insert(key.to_string(), toml::Value::Integer(value));
let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?;
fs::write(&path, body)
.with_context(|| format!("failed to write config at {}", path.display()))?;
Ok(path)
}

fn persist_provider_base_url_key(
config_path: Option<&Path>,
provider: ApiProvider,
Expand Down Expand Up @@ -525,6 +566,14 @@ fn parse_config_bool(value: &str) -> Result<bool, String> {
}
}

fn stream_chunk_timeout_value_label(raw: u64, resolved: u64) -> String {
if raw == 0 {
format!("0 (default {resolved})")
} else {
resolved.to_string()
}
}

/// Resolve the path to `~/.codewhale/config.toml` (or
/// `$CODEWHALE_CONFIG_PATH` / `$DEEPSEEK_CONFIG_PATH`). Mirrors what `Config::load` accepts so we
/// never write to a different file than the one we read.
Expand Down Expand Up @@ -729,6 +778,55 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
"provider_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving.",
);
}
"stream_chunk_timeout_secs" => {
let raw = match value.trim().parse::<u64>() {
Ok(value) => value,
Err(_) => {
return CommandResult::error(
"stream_chunk_timeout_secs must be a whole number",
);
}
};
if raw != 0
&& !(MIN_STREAM_CHUNK_TIMEOUT_SECS..=MAX_STREAM_CHUNK_TIMEOUT_SECS).contains(&raw)
{
return CommandResult::error(format!(
"stream_chunk_timeout_secs must be 0 or {}..={}",
MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS
));
}
let resolved = if raw == 0 {
DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
} else {
raw
};
app.stream_chunk_timeout_secs = resolved;
let value_label = stream_chunk_timeout_value_label(raw, resolved);
if persist {
match persist_tui_integer_key(
app.config_path.as_deref(),
"stream_chunk_timeout_secs",
raw,
) {
Ok(path) => {
return CommandResult::with_message_and_action(
format!(
"stream_chunk_timeout_secs = {value_label} (saved to {}; affects subsequent turns in this session)",
path.display()
),
AppAction::UpdateStreamChunkTimeout(resolved),
);
}
Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
}
}
return CommandResult::with_message_and_action(
format!(
"stream_chunk_timeout_secs = {value_label} (session only; affects subsequent turns in this session)"
),
AppAction::UpdateStreamChunkTimeout(resolved),
);
}
_ => {}
}

Expand Down Expand Up @@ -2371,6 +2469,112 @@ mod tests {
assert!(saved.contains("base_url = \"https://example.session.local/v1\""));
}

#[test]
fn config_command_stream_chunk_timeout_session_query_uses_live_value() {
let _lock = lock_test_env();
let mut app = create_test_app();

let result = config_command(&mut app, Some("stream_chunk_timeout_secs 90"));
assert!(!result.is_error);
assert_eq!(app.stream_chunk_timeout_secs, 90);
assert!(matches!(
result.action,
Some(AppAction::UpdateStreamChunkTimeout(90))
));

let query = config_command(&mut app, Some("stream_chunk_timeout_secs"));
assert_eq!(
query.message.as_deref(),
Some("stream_chunk_timeout_secs = 90")
);
}

#[test]
fn config_command_stream_chunk_timeout_save_persists_tui_key() {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"codewhale-tui-stream-timeout-test-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);

let config_path = temp_root.join("custom-config.toml");
let mut app = create_test_app();
app.config_path = Some(config_path.clone());

let result = config_command(&mut app, Some("stream_chunk_timeout_secs 120 --save"));
let msg = result.message.unwrap();
let saved = fs::read_to_string(&config_path).unwrap();

assert_eq!(
msg,
format!(
"stream_chunk_timeout_secs = 120 (saved to {}; affects subsequent turns in this session)",
config_path.display()
)
);
assert!(saved.contains("[tui]"));
assert!(saved.contains("stream_chunk_timeout_secs = 120"));
assert_eq!(app.stream_chunk_timeout_secs, 120);
assert!(matches!(
result.action,
Some(AppAction::UpdateStreamChunkTimeout(120))
));
}

#[test]
fn config_command_stream_chunk_timeout_rejects_invalid_input() {
let _lock = lock_test_env();
let mut app = create_test_app();

let text = config_command(&mut app, Some("stream_chunk_timeout_secs abc"));
assert!(text.is_error);
assert!(
text.message
.unwrap()
.contains("stream_chunk_timeout_secs must be a whole number")
);

let high = config_command(&mut app, Some("stream_chunk_timeout_secs 3601"));
assert!(high.is_error);
assert!(
high.message
.unwrap()
.contains("stream_chunk_timeout_secs must be 0 or 1..=3600")
);
}

#[test]
fn config_command_stream_chunk_timeout_zero_reports_effective_default() {
let _lock = lock_test_env();
let mut app = create_test_app();

let result = config_command(&mut app, Some("stream_chunk_timeout_secs 0"));

assert!(!result.is_error);
assert_eq!(
app.stream_chunk_timeout_secs,
DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
);
assert_eq!(
result.message.as_deref(),
Some(
"stream_chunk_timeout_secs = 0 (default 300) (session only; affects subsequent turns in this session)"
)
);
assert!(matches!(
result.action,
Some(AppAction::UpdateStreamChunkTimeout(
DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
))
));
}

#[test]
fn config_command_provider_url_token_plan_persists_provider_base_url() {
let temp_root = env::temp_dir().join(format!(
Expand Down
Loading
Loading