Skip to content
Closed
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 crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ impl ProviderKind {
pub struct ProviderConfigToml {
pub api_key: Option<String>,
pub base_url: Option<String>,
pub path_suffix: Option<String>,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Missing CLI Config Support for path_suffix

Since path_suffix is added to ProviderConfigToml, it should be supported by the configuration CLI helper methods in ConfigToml (get_value, set_value, unset_value, and list_values).

Currently, if a user runs codewhale config set providers.openai.path_suffix "", it will bypass the structured providers.openai table and get written as a flat key "providers.openai.path_suffix" = "" in self.extras at the root of the TOML. When the config is loaded, this flat key will not be deserialized into self.providers.openai.path_suffix, rendering the CLI configuration ineffective.

Please add path_suffix support for the providers in crates/config/src/lib.rs. For example, for openai (and similarly for other providers):

In get_value:

"providers.openai.path_suffix" => self.providers.openai.path_suffix.clone(),

In set_value:

"providers.openai.path_suffix" => self.providers.openai.path_suffix = Some(value.to_string()),

In unset_value:

"providers.openai.path_suffix" => self.providers.openai.path_suffix = None,

In list_values:

if let Some(v) = self.providers.openai.path_suffix.as_ref() {
    out.insert("providers.openai.path_suffix".to_string(), v.clone());
}

pub model: Option<String>,
pub auth_mode: Option<String>,
#[serde(default)]
Expand Down Expand Up @@ -1355,6 +1356,9 @@ impl ConfigToml {
}

fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) {
if source.path_suffix.is_some() {
target.path_suffix = source.path_suffix.clone();
}
if source.model.is_some() {
target.model = source.model.clone();
}
Expand Down
63 changes: 58 additions & 5 deletions crates/tui/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ pub struct DeepSeekClient {
pub(super) http_client: reqwest::Client,
api_key: String,
pub(super) base_url: String,
api_path_suffix: Option<String>,
pub(super) api_provider: ApiProvider,
retry: RetryPolicy,
default_model: String,
Expand Down Expand Up @@ -291,6 +292,7 @@ impl Clone for DeepSeekClient {
http_client: self.http_client.clone(),
api_key: self.api_key.clone(),
base_url: self.base_url.clone(),
api_path_suffix: self.api_path_suffix.clone(),
api_provider: self.api_provider,
retry: self.retry.clone(),
default_model: self.default_model.clone(),
Expand Down Expand Up @@ -390,8 +392,25 @@ fn is_version_segment(segment: &str) -> bool {
.is_some_and(|rest| !rest.is_empty() && rest.chars().all(|ch| ch.is_ascii_digit()))
}

#[cfg(test)]
pub(super) fn api_url(base_url: &str, path: &str) -> String {
api_url_with_path_suffix(base_url, None, path)
}

pub(super) fn api_url_with_path_suffix(
base_url: &str,
path_suffix: Option<&str>,
path: &str,
) -> String {
let path = path.trim_start_matches('/');
if let Some(path_suffix) = path_suffix {
let base = base_url.trim_end_matches('/');
let suffix = path_suffix.trim().trim_matches('/');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 api_path_suffix() already calls .trim().trim_matches('/') before returning the value, so the same normalization here is redundant. This is harmless but may surprise callers who pass raw values directly (as the new tests do), since the function behaves correctly for both pre-trimmed and raw inputs.

Suggested change
let suffix = path_suffix.trim().trim_matches('/');
let suffix = path_suffix.trim_matches('/');

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code Fix in Cursor

if suffix.is_empty() {
return format!("{base}/{path}");
}
return format!("{base}/{suffix}/{path}");
}
if path.starts_with("beta/") {
Comment on lines +406 to 414
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 When path_suffix is explicitly set, the function short-circuits before the beta/ prefix handling, so a DeepSeek user who adds path_suffix = "v1" would get https://api.deepseek.com/v1/beta/completions instead of the correct https://api.deepseek.com/beta/completions. The FIM (fim_completion) call site already routes through self.api_url("beta/completions"), making it subject to this bypass.

Suggested change
if let Some(path_suffix) = path_suffix {
let base = base_url.trim_end_matches('/');
let suffix = path_suffix.trim().trim_matches('/');
if suffix.is_empty() {
return format!("{base}/{path}");
}
return format!("{base}/{suffix}/{path}");
}
if path.starts_with("beta/") {
if path.starts_with("beta/") && path_suffix.is_none() {
return format!("{}/{}", unversioned_base_url(base_url), path);
}
if let Some(path_suffix) = path_suffix {
let base = base_url.trim_end_matches('/');
let suffix = path_suffix.trim().trim_matches('/');
if suffix.is_empty() {
return format!("{base}/{path}");
}
return format!("{base}/{suffix}/{path}");
}
if path.starts_with("beta/") {

Fix in Codex Fix in Claude Code Fix in Cursor

return format!("{}/{}", unversioned_base_url(base_url), path);
}
Expand Down Expand Up @@ -465,10 +484,15 @@ fn add_extra_root_certs(
}

impl DeepSeekClient {
fn api_url(&self, path: &str) -> String {
api_url_with_path_suffix(&self.base_url, self.api_path_suffix.as_deref(), path)
}

/// Create a DeepSeek client from CLI configuration.
pub fn new(config: &Config) -> Result<Self> {
let api_key = config.deepseek_api_key()?;
let base_url = config.deepseek_base_url();
let api_path_suffix = config.api_path_suffix();
let api_provider = config.api_provider();
validate_base_url_security(&base_url)?;
let retry = config.retry_policy();
Expand All @@ -494,6 +518,7 @@ impl DeepSeekClient {
http_client,
api_key,
base_url,
api_path_suffix,
api_provider,
retry,
default_model,
Expand Down Expand Up @@ -585,7 +610,7 @@ impl DeepSeekClient {
model: &str,
target_language: &str,
) -> Result<String> {
let url = api_url(&self.base_url, "chat/completions");
let url = self.api_url("chat/completions");
let model = wire_model_for_provider(self.api_provider, model);
let mut body = serde_json::json!({
"model": model,
Expand Down Expand Up @@ -632,7 +657,7 @@ impl DeepSeekClient {

/// List available models from the provider.
pub async fn list_models(&self) -> Result<Vec<AvailableModel>> {
let url = api_url(&self.base_url, "models");
let url = self.api_url("models");
let response = self.send_with_retry(|| self.http_client.get(&url)).await?;

let status = response.status();
Expand Down Expand Up @@ -679,7 +704,7 @@ impl DeepSeekClient {
if !should_probe {
return;
}
let health_url = api_url(&self.base_url, "models");
let health_url = self.api_url("models");
let probe = self.http_client.get(health_url).send().await;
match probe {
Ok(resp) if resp.status().is_success() => {
Expand Down Expand Up @@ -790,7 +815,7 @@ impl LlmClient for DeepSeekClient {
}

async fn health_check(&self) -> Result<bool> {
let health_url = api_url(&self.base_url, "models");
let health_url = self.api_url("models");
self.wait_for_rate_limit().await;
let response = self.http_client.get(health_url).send().await;
match response {
Expand Down Expand Up @@ -1096,7 +1121,7 @@ impl DeepSeekClient {
suffix: &str,
max_tokens: u32,
) -> anyhow::Result<String> {
let url = api_url(&self.base_url, "beta/completions");
let url = self.api_url("beta/completions");
let model = wire_model_for_provider(self.api_provider, model);
let body = json!({
"model": model,
Expand Down Expand Up @@ -1236,6 +1261,34 @@ mod tests {
);
}

#[test]
fn api_url_respects_explicit_path_suffix() {
assert_eq!(
api_url_with_path_suffix(
"https://openai-compatible.example",
Some(""),
"chat/completions"
),
"https://openai-compatible.example/chat/completions"
);
assert_eq!(
api_url_with_path_suffix(
"https://openai-compatible.example",
Some("/openai/v2/"),
"chat/completions"
),
"https://openai-compatible.example/openai/v2/chat/completions"
);
assert_eq!(
api_url_with_path_suffix(
"https://openai-compatible.example",
None,
"chat/completions"
),
"https://openai-compatible.example/v1/chat/completions"
);
}

#[test]
fn api_url_routes_beta_paths_from_any_deepseek_base() {
assert_eq!(
Expand Down
6 changes: 3 additions & 3 deletions crates/tui/src/client/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ use crate::models::{

use super::{
DeepSeekClient, ERROR_BODY_MAX_BYTES, SSE_BACKPRESSURE_HIGH_WATERMARK,
SSE_BACKPRESSURE_SLEEP_MS, SSE_MAX_LINES_PER_CHUNK, acquire_stream_buffer, api_url,
SSE_BACKPRESSURE_SLEEP_MS, SSE_MAX_LINES_PER_CHUNK, acquire_stream_buffer,
apply_reasoning_effort, bounded_error_text, from_api_tool_name, parse_usage,
release_stream_buffer, system_to_instructions, to_api_tool_name,
};
Expand Down Expand Up @@ -136,7 +136,7 @@ impl DeepSeekClient {
self.api_provider,
);

let url = api_url(&self.base_url, "chat/completions");
let url = self.api_url("chat/completions");
let open_timeout = stream_open_timeout();
let response = match tokio_timeout(
open_timeout,
Expand Down Expand Up @@ -239,7 +239,7 @@ impl DeepSeekClient {
self.api_provider,
);

let url = api_url(&self.base_url, "chat/completions");
let url = self.api_url("chat/completions");
let response = self
.send_with_retry(|| self.http_client.post(&url).json(&body))
.await?;
Expand Down
11 changes: 11 additions & 0 deletions crates/tui/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1663,6 +1663,7 @@ impl LspConfigToml {
pub struct ProviderConfig {
pub api_key: Option<String>,
pub base_url: Option<String>,
pub path_suffix: Option<String>,
pub model: Option<String>,
pub auth_mode: Option<String>,
pub http_headers: Option<HashMap<String, String>>,
Expand Down Expand Up @@ -2166,6 +2167,13 @@ impl Config {
normalize_base_url(&base)
}

#[must_use]
pub fn api_path_suffix(&self) -> Option<String> {
self.provider_config()
.and_then(|provider| provider.path_suffix.as_deref())
.map(|suffix| suffix.trim().trim_matches('/').to_string())
}

fn active_provider_preserves_custom_base_url_model(&self) -> bool {
let provider = self.api_provider();
provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url())
Expand Down Expand Up @@ -3830,6 +3838,7 @@ fn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) ->
ProviderConfig {
api_key: override_cfg.api_key.or(base.api_key),
base_url: override_cfg.base_url.or(base.base_url),
path_suffix: override_cfg.path_suffix.or(base.path_suffix),
model: override_cfg.model.or(base.model),
auth_mode: override_cfg.auth_mode.or(base.auth_mode),
http_headers: override_cfg.http_headers.or(base.http_headers),
Expand Down Expand Up @@ -7169,6 +7178,7 @@ model = "account-model-id"
[providers.openai]
api_key = "openai-table-key"
base_url = "https://openai-compatible.example/api/coding/paas/v4"
path_suffix = ""
model = "glm-5"
"#,
)?;
Expand All @@ -7180,6 +7190,7 @@ model = "glm-5"
config.deepseek_base_url(),
"https://openai-compatible.example/api/coding/paas/v4"
);
assert_eq!(config.api_path_suffix().as_deref(), Some(""));
assert_eq!(config.default_model(), "glm-5");
Ok(())
}
Expand Down
11 changes: 11 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,16 @@ api_key = "YOUR_OPENAI_COMPATIBLE_API_KEY"
base_url = "https://your-gateway.example/v1"
```

If your gateway expects unversioned paths such as `/chat/completions` instead
of `/v1/chat/completions`, keep `base_url` at the host root and set an explicit
empty path suffix:

```toml
[providers.openai]
base_url = "https://your-gateway.example"
path_suffix = ""
```

Do not invent a custom provider name; `provider` must be one of the known
providers listed above. Put the endpoint under `[providers.openai]`, not the
legacy top-level `base_url`, so the OpenAI-compatible provider receives it.
Expand Down Expand Up @@ -590,6 +600,7 @@ If you are upgrading from older releases:
- `provider` (string, optional): `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `moonshot`, `sglang`, `vllm`, or `ollama`. Legacy `deepseek-cn` configs are still accepted as an alias for `deepseek`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `openrouter` targets `https://openrouter.ai/api/v1`; `xiaomi-mimo` targets Xiaomi MiMo's OpenAI-compatible endpoint at `https://api.xiaomimimo.com/v1`; `novita` targets `https://api.novita.ai/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `siliconflow` targets SiliconFlow, defaulting to `https://api.siliconflow.com/v1`; `moonshot` targets Moonshot/Kimi, defaulting to `https://api.moonshot.ai/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`.
- `api_key` (string, required for hosted providers): must be non-empty for DeepSeek/hosted providers (or set the provider API key env var). Self-hosted SGLang, vLLM, and Ollama can omit it.
- `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs. Other defaults are `https://integrate.api.nvidia.com/v1` for `nvidia-nim`, `https://api.openai.com/v1` for `openai`, `https://api.atlascloud.ai/v1` for `atlascloud`, `https://maas-openapi.wanjiedata.com/api/v1` for `wanjie-ark`, `https://openrouter.ai/api/v1` for `openrouter`, `https://api.xiaomimimo.com/v1` for `xiaomi-mimo`, `https://api.novita.ai/v1` for `novita`, `https://api.fireworks.ai/inference/v1` for `fireworks`, `https://api.siliconflow.com/v1` for `siliconflow`, `https://api.moonshot.ai/v1` for `moonshot`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, and `http://localhost:11434/v1` for `ollama`. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features.
- `path_suffix` (string, optional): provider-table override for the path segment inserted between `base_url` and API paths. When unset, CodeWhale keeps the current `/v1` behavior. Set `path_suffix = ""` for gateways that expect `/chat/completions` directly under `base_url`.
- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek and generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `deepseek/deepseek-v4-pro` for OpenRouter and Novita, `mimo-v2.5-pro` for Xiaomi MiMo, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `deepseek-ai/DeepSeek-V4-Pro` for SiliconFlow, `kimi-k2.6` for Moonshot, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `deepseek-coder:1.3b` for Ollama. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026, except SiliconFlow maps `deepseek-reasoner` and `deepseek-r1` to its Pro model while `deepseek-chat` and `deepseek-v3` map to Flash. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. OpenRouter also recognizes recent large IDs such as `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-35b-a3b`, `google/gemma-4-31b-it`, and `moonshotai/kimi-k2.6`. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, and Ollama model IDs are passed through unchanged. OpenRouter and SiliconFlow provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `codewhale models` to discover live IDs from your configured endpoint. `CODEWHALE_MODEL` overrides this for a single process; `DEEPSEEK_MODEL` is the legacy alias.
- `reasoning_effort` (string, optional): `off`, `low`, `medium`, `high`, or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`.
- `allow_shell` (bool, optional): defaults to `true` (sandboxed).
Expand Down
Loading