feat(runtime-api): add provider registry + switch endpoints

Expose the static provider registry and an atomic provider-switch endpoint
through new runtime API routes so the GUI can render a dynamic
provider/model picker and switch providers without the fragile
setConfig+reload flow that historically clobbered per-provider model
settings.

New endpoints:

- GET /v1/providers — returns all 30 built-in providers with id,
  display_name, default_base_url, default_model, has_model_catalog,
  and env_vars.
- GET /v1/providers/{id}/models — returns the built-in model catalog
  for a provider (empty array for pass-through providers like
  Ollama/Custom).
- POST /v1/providers/{id}/switch — atomic provider switch that
  mirrors the TUI's `/provider` slash command and
  `AppAction::SwitchProvider` (tui/ui.rs::switch_provider).

  Critical TUI-parity rule: when called without a `model` field, the
  endpoint persists only `provider` and does NOT write a `model` key,
  preserving the user's per-provider `[providers.<id>].model` config
  (e.g. `glm-2` on volcengine is not clobbered with the catalog default
  `deepseek-v4-pro`). When `model` is provided, it is normalized,
  persisted via `persist_provider_model_key`, and (for DeepSeek
  providers) also pinned as `default_text_model`. Config is reloaded
  from disk and synced to active engines. The resolved active model is
  returned in the response for the GUI to display.

Config endpoint changes:

- POST /v1/config now accepts key "provider" with validation against
  the static registry, persisting to config.toml's provider field.

Bug fixes (pre-existing):

- config_persistence.rs: removed duplicate `provider_model_table_key`
  definition that caused a compile error on HEAD c37658df4.
- runtime_api/tests.rs: fixed `config_path` → `overrides.config_path`
  reference in `set_config_model_for_custom_provider_route_persists_to_named_table`.

Tests: 7 new endpoint tests + 1 existing UI picker test:
- switch_provider_without_model_arg_preserves_user_per_provider_model
- switch_provider_with_explicit_model_arg_persists_model
- switch_provider_with_deepseek_and_explicit_model_updates_default_text_model
- switch_provider_empty_model_string_treated_as_no_override
- switch_provider_persists_provider_key_on_disk
- switch_provider_rejects_unknown_provider_id
- switch_provider_rejects_legacy_deepseek_cn_alias
This commit is contained in:
Ben Gao
2026-07-15 23:00:25 +08:00
committed by Hunter B
parent b6bb0fb690
commit ebc567decc
3 changed files with 1024 additions and 9 deletions
+15
View File
@@ -266,6 +266,21 @@ pub(crate) fn persist_provider_model_key(
Ok(path)
}
fn provider_model_table_key(provider: ApiProvider) -> anyhow::Result<&'static str> {
match provider {
ApiProvider::Deepseek | ApiProvider::DeepseekCN => {
anyhow::bail!("DeepSeek uses the root default_text_model setting")
}
ApiProvider::Custom => {
anyhow::bail!("custom providers store model in their named [providers.<name>] table")
}
_ => provider
.metadata()
.map(|metadata| metadata.provider_config_key())
.context("provider config key"),
}
}
fn provider_base_url_table_key(provider: ApiProvider) -> anyhow::Result<&'static str> {
match provider {
ApiProvider::Deepseek | ApiProvider::DeepseekCN => {
+386 -1
View File
@@ -686,6 +686,9 @@ pub fn build_router(state: RuntimeApiState) -> Router {
.route("/v1/usage", get(get_usage))
.route("/v1/snapshots", get(list_snapshots))
.route("/v1/snapshots/{id}/restore", post(restore_snapshot))
.route("/v1/providers", get(list_providers))
.route("/v1/providers/{id}/models", get(list_provider_models))
.route("/v1/providers/{id}/switch", post(switch_provider))
.route("/v1/config", get(get_config).post(set_config))
.route("/v1/config/reload", post(reload_config))
.route_layer(middleware::from_fn_with_state(
@@ -3037,6 +3040,361 @@ fn snapshot_entries_for_workspace(
.collect())
}
// ── Provider / Model catalog endpoints ──
/// Entry in `GET /v1/providers`.
///
/// Exposes the static provider registry so the GUI can render a dynamic
/// provider picker instead of hard-coding `deepseek` only. The `id` matches
/// `ApiProvider::as_str()` and is the value the GUI should send back via
/// `POST /v1/config { key: "provider", value: <id> }`.
#[derive(Debug, Clone, Serialize)]
struct ProviderEntry {
/// Stable identifier — matches `ApiProvider::as_str()` and the TOML
/// `provider = "<id>"` key. Use this as the canonical value when
/// persisting or comparing.
id: String,
/// Human-friendly name for picker UIs (e.g. "DeepSeek", "OpenAI").
display_name: String,
/// Default base URL for this provider ( informational; the live base URL
/// may be overridden in config.toml).
default_base_url: String,
/// Default model id for this provider, if any. Empty for pass-through
/// providers (Ollama / Custom) that expose no built-in catalog.
default_model: String,
/// Whether this provider exposes a built-in model list. When false, the
/// GUI should render a free-text input instead of calling
/// `/v1/providers/{id}/models`.
has_model_catalog: bool,
/// API key environment variable candidates, e.g. `["DEEPSEEK_API_KEY"]`.
/// The GUI may surface these in a tooltip when auth is missing.
env_vars: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
struct ProvidersResponse {
/// Currently active provider id (matches `GET /v1/config`'s `provider`).
current: String,
providers: Vec<ProviderEntry>,
}
/// Entry in `GET /v1/providers/{id}/models`.
#[derive(Debug, Clone, Serialize)]
struct ProviderModelEntry {
/// Canonical model id (suitable for `default_text_model` or
/// `POST /v1/threads/{id}` `model` field).
id: String,
}
#[derive(Debug, Clone, Serialize)]
struct ProviderModelsResponse {
provider: String,
models: Vec<ProviderModelEntry>,
}
fn push_unique_model(models: &mut Vec<String>, model: &str) {
let model = model.trim();
if !model.is_empty()
&& !models
.iter()
.any(|existing| existing.eq_ignore_ascii_case(model))
{
models.push(model.to_string());
}
}
fn normalize_api_base_url(base_url: &str) -> String {
base_url.trim().trim_end_matches('/').to_ascii_lowercase()
}
fn provider_uses_custom_route_for_api(config: &Config, provider: ApiProvider) -> bool {
config
.provider_config_for(provider)
.and_then(|entry| entry.base_url.as_deref())
.is_some_and(|base_url| {
normalize_api_base_url(base_url) != normalize_api_base_url(provider.default_base_url())
})
}
fn provider_models_for_api(
config: &Config,
active_provider: ApiProvider,
provider: ApiProvider,
) -> Vec<String> {
let mut models = Vec::new();
if let Some(model) = config
.provider_config_for(provider)
.and_then(|entry| entry.model.as_deref())
{
push_unique_model(&mut models, model);
}
if provider == active_provider {
let active_model = config.default_model();
if !active_model.trim().eq_ignore_ascii_case("auto") {
push_unique_model(&mut models, &active_model);
}
if config.model_ids_pass_through() {
return models;
}
}
if provider_uses_custom_route_for_api(config, provider) {
return models;
}
for model in crate::provider_lake::models_for_provider(config, active_provider, provider) {
push_unique_model(&mut models, &model);
}
models
}
fn provider_default_model_for_api(
config: &Config,
active_provider: ApiProvider,
provider: ApiProvider,
) -> String {
if provider == active_provider {
return config.default_model();
}
provider_models_for_api(config, active_provider, provider)
.into_iter()
.next()
.unwrap_or_default()
}
async fn list_providers(
State(state): State<RuntimeApiState>,
) -> Result<Json<ProvidersResponse>, ApiError> {
let config = state.config.read().clone();
let active_provider = config.api_provider();
let current = active_provider.as_str().to_string();
let mut providers = Vec::new();
for api_provider in ApiProvider::sorted_for_display() {
let default_model = provider_default_model_for_api(&config, active_provider, api_provider);
let has_model_catalog =
!crate::provider_lake::all_catalog_models_for_provider(api_provider).is_empty();
providers.push(ProviderEntry {
id: api_provider.as_str().to_string(),
display_name: api_provider.display_name().to_string(),
default_base_url: api_provider.default_base_url().to_string(),
default_model,
has_model_catalog,
env_vars: api_provider
.env_vars()
.iter()
.map(std::string::ToString::to_string)
.collect(),
});
}
Ok(Json(ProvidersResponse { current, providers }))
}
#[derive(Debug, Deserialize)]
struct ListProviderModelsParams {
/// Optional filter: when provided, models whose id contains this
/// substring (case-insensitive) are returned. Currently informational —
/// the catalog is small enough to filter client-side.
#[serde(default)]
#[allow(dead_code)]
filter: Option<String>,
}
async fn list_provider_models(
State(state): State<RuntimeApiState>,
Path(id): Path<String>,
_params: Query<ListProviderModelsParams>,
) -> Result<Json<ProviderModelsResponse>, ApiError> {
let config = state.config.read().clone();
let active_provider = config.api_provider();
let api_provider = ApiProvider::parse(&id)
.ok_or_else(|| ApiError::bad_request(format!("Unknown provider id '{id}'")))?;
// Reject requests for the legacy deepseek-cn alias that has no
// ProviderKind metadata — the GUI should use `deepseek` instead.
if api_provider == ApiProvider::DeepseekCN {
return Err(ApiError::bad_request(
"provider 'deepseek-cn' is a legacy alias; use 'deepseek' instead",
));
}
let models = provider_models_for_api(&config, active_provider, api_provider)
.into_iter()
.map(|id| ProviderModelEntry { id: id.to_string() })
.collect();
Ok(Json(ProviderModelsResponse {
provider: api_provider.as_str().to_string(),
models,
}))
}
/// Request body for `POST /v1/providers/{id}/switch`.
///
/// Mirrors the TUI's `AppAction::SwitchProvider { provider, model }` payload
/// (see `tui/ui.rs::switch_provider`). `model` is optional: when omitted,
/// the runtime resolves the active model from `[providers.<id>].model` (or
/// the provider's built-in default) and **does not** persist a `model` key,
/// so the user's per-provider config is preserved. When provided, the model
/// is normalized, persisted for the target provider, and (for DeepSeek
/// providers) also pinned as `default_text_model`.
#[derive(Debug, Deserialize, Default)]
struct SwitchProviderRequest {
#[serde(default)]
model: Option<String>,
}
/// Response for `POST /v1/providers/{id}/switch`.
#[derive(Debug, Serialize)]
struct SwitchProviderResponse {
/// The provider id that was switched to (echoes the path).
provider: String,
/// The resolved active model after the switch. This is the model the
/// runtime will use for new turns — either the user-supplied override
/// or the value resolved from `[providers.<id>].model` / the
/// provider's built-in default. The GUI should display *this* value,
/// not `ProviderEntry.default_model`, to avoid showing the catalog
/// default when the user has configured a different model.
model: String,
/// Human-readable status message for logging/toasts.
message: String,
/// Whether the new provider + model were persisted to config.toml.
persisted: bool,
}
/// `POST /v1/providers/{id}/switch` — switch the active provider, optionally
/// overriding the model.
///
/// This is the GUI-facing counterpart of the TUI's `/provider` slash command
/// (`commands/groups/core/provider.rs`) and `AppAction::SwitchProvider`
/// (`tui/ui.rs::switch_provider`). It exists so the GUI does not have to
/// simulate the switch with multiple `POST /v1/config` calls + a reload,
/// which historically led to two bugs:
///
/// 1. The GUI persisted `model = <catalog default>` even when the user
/// clicked the picker without choosing a model, clobbering a user-set
/// `[providers.<id>].model` (e.g. `glm-2` overwritten with
/// `deepseek-v4-pro`).
/// 2. The GUI then displayed the catalog default instead of the actually
/// resolved model, because it never asked the backend what model was
/// selected.
///
/// Persistence mirrors `switch_provider` (ui.rs:9390-9410):
/// - `provider` is always persisted (root `provider` key).
/// - `model` is persisted **only** when `model_override.is_some()`, via
/// `persist_provider_model_key` (writes `[providers.<id>].model`, or the
/// root `default_text_model` for DeepSeek). The `Settings` provider-model
/// map is updated the same way, including the DeepSeek-specific
/// `default_model` pin.
/// - Config is reloaded from disk and synced to active engines via
/// `runtime_threads.reload_config`, exactly like `POST /v1/config/reload`.
async fn switch_provider(
State(state): State<RuntimeApiState>,
Path(id): Path<String>,
Json(req): Json<SwitchProviderRequest>,
) -> Result<Json<SwitchProviderResponse>, ApiError> {
use crate::config_persistence;
let target = ApiProvider::parse(&id)
.ok_or_else(|| ApiError::bad_request(format!("Unknown provider id '{id}'")))?;
// Reject the legacy deepseek-cn alias — same guard as list_provider_models.
if target == ApiProvider::DeepseekCN {
return Err(ApiError::bad_request(
"provider 'deepseek-cn' is a legacy alias; use 'deepseek' instead",
));
}
// Normalize the optional model override against the *target* provider.
// Mirrors `set_config`'s `model` branch, which validates against the
// active route — except here we validate against the target provider,
// because the active route is about to change.
let model_override: Option<String> = match req.model.as_deref().map(str::trim) {
None | Some("") => None,
Some(raw) => Some(normalize_runtime_config_model(target, raw)?),
};
// Resolve the target provider identity *before* mutating config, so
// persistence uses the same key the TUI's switch_provider would.
let (provider_identity, _active_provider) = {
let config = state.config.read();
(config.provider_identity_for(target), config.api_provider())
};
// Persist `provider` (always) + `model` (only when explicitly given).
// This is the critical TUI-parity rule: a bare `/provider <id>` (no
// model arg) MUST NOT write a `model` key, otherwise the user's
// per-provider `[providers.<id>].model` config gets overwritten with
// whatever the runtime resolves as the default.
config_persistence::persist_root_string_key(
state.config_path.as_deref(),
"provider",
&provider_identity,
)
.map_err(|e| ApiError::internal(format!("Failed to persist provider: {e}")))?;
if let Some(ref model) = model_override {
config_persistence::persist_provider_model_key(
state.config_path.as_deref(),
target,
&provider_identity,
model,
)
.map_err(|e| ApiError::internal(format!("Failed to persist model: {e}")))?;
// Mirror the TUI's Settings update (ui.rs:9398-9406): record the
// provider→model mapping, and for DeepSeek also pin the global
// `default_model`. Failures here are non-fatal — the config.toml
// write above is the source of truth.
if let Ok(mut settings) = crate::settings::Settings::load_persisted() {
settings.set_model_for_provider(target.as_str(), model);
if matches!(target, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
let _ = settings.set("default_model", model);
}
let _ = settings.save();
}
}
// Reload config from disk and sync to active engines. This matches
// `POST /v1/config/reload` exactly: load → validate thread routes →
// swap in the new config. A failure here means an active thread's
// route is invalid under the new provider — surface it so the GUI can
// tell the user to fix their config.
let reloaded = Config::load(state.config_path.clone(), state.config_profile.as_deref())
.map_err(|e| ApiError::internal(format!("Failed to reload config: {e}")))?;
state
.runtime_threads
.reload_config(reloaded.clone())
.await
.map_err(|err| ApiError::bad_request(format!("Config reload rejected: {err}")))?;
{
let mut config = state.config.write();
*config = reloaded;
}
// Read the resolved active model + provider from the freshly reloaded
// config. This is the value the GUI must display — NOT the catalog
// default and NOT the previously-active model.
let (active_provider, active_model) = {
let config = state.config.read();
(config.api_provider(), config.default_model())
};
let message = if model_override.is_some() {
format!(
"Provider switched to {} (model: {}).",
active_provider.as_str(),
active_model
)
} else {
format!(
"Provider switched to {} (model: {}, resolved from config).",
active_provider.as_str(),
active_model
)
};
Ok(Json(SwitchProviderResponse {
provider: active_provider.as_str().to_string(),
model: active_model,
message,
persisted: true,
}))
}
// ── Config endpoints ──
/// GUI-relevant config snapshot returned by `GET /v1/config`.
@@ -3099,6 +3457,20 @@ fn persist_runtime_tui_setting(key: &str, value: &str) -> Result<(), ApiError> {
.map_err(|e| ApiError::internal(format!("Failed to save settings: {e}")))
}
fn persisted_or_active_provider(state: &RuntimeApiState) -> ApiProvider {
if let Ok(config) = Config::load(state.config_path.clone(), None) {
return config.api_provider();
}
state.config.read().api_provider()
}
fn effective_model_for_provider(config: &Config, provider: ApiProvider) -> String {
if provider == config.api_provider() {
return config.default_model();
}
provider_default_model_for_api(config, config.api_provider(), provider)
}
/// Response for `POST /v1/config/reload`.
#[derive(Debug, Serialize)]
struct ReloadConfigResponse {
@@ -3115,6 +3487,7 @@ async fn get_config(
let model = config.default_model();
let provider = config.provider_identity_for(config.api_provider());
let approval_mode = config
.approval_policy
.as_deref()
@@ -3225,6 +3598,18 @@ async fn set_config(
"deepseek_base_url",
&value,
),
"provider" => {
// Validate the provider id against the static registry so the
// GUI gets a clear error instead of silently persisting an
// unknown value that `Config::api_provider()` would later
// ignore (falling back to DeepSeek).
if ApiProvider::parse(&value).is_none() {
return Err(ApiError::bad_request(format!(
"Unknown provider '{value}'. Call GET /v1/providers for the list of supported ids."
)));
}
config_persistence::persist_root_string_key(config_path, "provider", &value)
}
"provider_url" | "provider_base_url" => {
let provider = state.config.read().api_provider();
config_persistence::persist_provider_base_url_key(config_path, provider, &value)
@@ -3340,7 +3725,7 @@ async fn set_config(
}
_ => {
return Err(ApiError::bad_request(format!(
"Unknown config key '{key}'. Supported keys: model, default_model, reasoning_effort, approval_mode, base_url, provider_url, cost_currency, default_mode, auto_compact, allow_shell, mcp_config_path, show_thinking, show_tool_details, locale, max_history, calm_mode, prefer_external_pdftotext, workspace_follow_symlinks, subagents_enabled, subagents_max_depth, sandbox_mode, strict_tool_mode, memory_enabled, search_provider, prompt_suggestion"
"Unknown config key '{key}'. Supported keys: model, default_model, reasoning_effort, approval_mode, base_url, provider, provider_url, cost_currency, default_mode, auto_compact, allow_shell, mcp_config_path, show_thinking, show_tool_details, locale, max_history, calm_mode, prefer_external_pdftotext, workspace_follow_symlinks, subagents_enabled, subagents_max_depth, sandbox_mode, strict_tool_mode, memory_enabled, search_provider, prompt_suggestion"
)));
}
};
+623 -8
View File
@@ -671,15 +671,18 @@ async fn spawn_test_server_with_root_token_mobile_workspace_and_overrides(
let _ = rustls::crypto::ring::default_provider().install_default();
fs::create_dir_all(&sessions_dir)?;
fs::create_dir_all(&workspace)?;
let config = Config {
// Runtime-API tests that exercise a real turn boundary must pass the
// same synchronous client preflight as production. Keep the client
// hermetic; any later request fails fast against loopback.
api_key: Some("runtime-api-test-key".to_string()),
base_url: Some("http://127.0.0.1:1/v1".to_string()),
mcp_config_path: Some(root.join("mcp.json").to_string_lossy().to_string()),
..Config::default()
let mut config = if let Some(path) = overrides.config_path.clone() {
Config::load(Some(path), None)?
} else {
Config {
api_key: Some("runtime-api-test-key".to_string()),
base_url: Some("http://127.0.0.1:1/v1".to_string()),
..Config::default()
}
};
config.mcp_config_path = Some(root.join("mcp.json").to_string_lossy().to_string());
config.mcp_config_path = Some(root.join("mcp.json").to_string_lossy().to_string());
let manager = TaskManager::start_with_executor(
TaskManagerConfig {
data_dir: root.join("tasks"),
@@ -5596,6 +5599,182 @@ async fn get_config(client: &reqwest::Client, addr: &SocketAddr) -> serde_json::
.expect("GET /v1/config should return valid JSON")
}
async fn get_providers(client: &reqwest::Client, addr: &SocketAddr) -> serde_json::Value {
client
.get(format!("http://{addr}/v1/providers"))
.send()
.await
.expect("GET /v1/providers should not fail at transport level")
.error_for_status()
.expect("GET /v1/providers should return 200")
.json()
.await
.expect("GET /v1/providers should return valid JSON")
}
async fn get_provider_models(
client: &reqwest::Client,
addr: &SocketAddr,
provider: &str,
) -> serde_json::Value {
client
.get(format!("http://{addr}/v1/providers/{provider}/models"))
.send()
.await
.expect("GET /v1/providers/{id}/models should not fail at transport level")
.error_for_status()
.expect("GET /v1/providers/{id}/models should return 200")
.json()
.await
.expect("GET /v1/providers/{id}/models should return valid JSON")
}
#[tokio::test]
async fn get_config_returns_active_provider_model() -> Result<()> {
let root = std::env::temp_dir().join(format!(
"codewhale-config-active-provider-{}",
Uuid::new_v4()
));
fs::create_dir_all(&root)?;
let config_file = root.join("custom-config.toml");
fs::write(
&config_file,
format!(
"default_text_model = \"deepseek-v4-pro\"\nprovider = \"volcengine\"\n\n[providers.volcengine]\nmodel = \"{}\"\n",
crate::config::DEFAULT_VOLCENGINE_FLASH_MODEL
),
)?;
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_config_path(config_file).await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
let body = get_config(&client, &addr).await;
assert_eq!(body["provider"].as_str(), Some("volcengine"));
assert_eq!(
body["model"].as_str(),
Some(crate::config::DEFAULT_VOLCENGINE_FLASH_MODEL),
"GET /v1/config should expose the active provider model, not the root DeepSeek default"
);
handle.abort();
Ok(())
}
#[tokio::test]
async fn api_surfaces_only_configured_model_for_custom_provider_route() -> Result<()> {
let root = std::env::temp_dir().join(format!(
"codewhale-config-custom-provider-model-{}",
Uuid::new_v4()
));
fs::create_dir_all(&root)?;
let config_file = root.join("custom-config.toml");
fs::write(
&config_file,
"provider = \"volcengine\"\n\n[providers.volcengine]\nbase_url = \"https://ark.cn-beijing.volces.com/api/plan/v3\"\nmodel = \"glm-5.2\"\napi_key = \"ark-test\"\n",
)?;
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_config_path(config_file).await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
let config_body = get_config(&client, &addr).await;
assert_eq!(config_body["provider"].as_str(), Some("volcengine"));
assert_eq!(
config_body["model"].as_str(),
Some("glm-5.2"),
"GET /v1/config should preserve the active provider's explicit custom model"
);
let providers = get_providers(&client, &addr).await;
let volcengine = providers["providers"]
.as_array()
.and_then(|providers| {
providers
.iter()
.find(|entry| entry["id"].as_str() == Some("volcengine"))
})
.expect("volcengine provider entry");
assert_eq!(providers["current"].as_str(), Some("volcengine"));
assert_eq!(
volcengine["default_model"].as_str(),
Some("glm-5.2"),
"GET /v1/providers should mirror the /provider default route when a saved model override exists"
);
let provider_models = get_provider_models(&client, &addr, "volcengine").await;
let model_ids: Vec<_> = provider_models["models"]
.as_array()
.expect("models array")
.iter()
.filter_map(|entry| entry["id"].as_str())
.collect();
assert_eq!(
model_ids.first().copied(),
Some("glm-5.2"),
"configured volcengine model should be the only model exposed for a custom provider route"
);
assert_eq!(model_ids, vec!["glm-5.2"]);
handle.abort();
Ok(())
}
#[tokio::test]
async fn api_surfaces_only_active_model_when_runtime_route_passes_ids_through() -> Result<()> {
let root = std::env::temp_dir().join(format!(
"codewhale-config-runtime-pass-through-{}",
Uuid::new_v4()
));
fs::create_dir_all(&root)?;
let config_file = root.join("custom-config.toml");
fs::write(
&config_file,
"provider = \"volcengine\"\nbase_url = \"https://ark.cn-beijing.volces.com/api/plan/v3\"\n\n[providers.volcengine]\nmodel = \"glm-5.2\"\napi_key = \"ark-test\"\n",
)?;
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_config_path(config_file).await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
let config_body = get_config(&client, &addr).await;
assert_eq!(config_body["provider"].as_str(), Some("volcengine"));
assert_eq!(config_body["model"].as_str(), Some("glm-5.2"));
let providers = get_providers(&client, &addr).await;
let volcengine = providers["providers"]
.as_array()
.and_then(|providers| {
providers
.iter()
.find(|entry| entry["id"].as_str() == Some("volcengine"))
})
.expect("volcengine provider entry");
assert_eq!(providers["current"].as_str(), Some("volcengine"));
assert_eq!(volcengine["default_model"].as_str(), Some("glm-5.2"));
let provider_models = get_provider_models(&client, &addr, "volcengine").await;
let model_ids: Vec<_> = provider_models["models"]
.as_array()
.expect("models array")
.iter()
.filter_map(|entry| entry["id"].as_str())
.collect();
assert_eq!(model_ids, vec!["glm-5.2"]);
handle.abort();
Ok(())
}
#[tokio::test]
async fn reload_config_reads_from_config_path_and_updates_in_memory_state() -> Result<()> {
// Fix #2 + reload behavior: This test proves that reload reads from the
@@ -5691,6 +5870,384 @@ async fn reload_config_reads_from_config_path_and_updates_in_memory_state() -> R
Ok(())
}
// ---------------------------------------------------------------------------
// POST /v1/providers/{id}/switch endpoint tests
//
// These tests pin down the TUI-parity contract for the GUI's provider
// picker: a bare switch (no model arg) MUST NOT overwrite the user's
// `[providers.<id>].model` config. Regression for the bug where clicking
// volcengine in the picker forced `model = "deepseek-v4-pro"` even when
// the user had configured `model = "glm-2"`.
// ---------------------------------------------------------------------------
/// Helper: POST to `/v1/providers/{id}/switch` and return the response
/// status + body JSON.
async fn post_switch_provider(
client: &reqwest::Client,
addr: &SocketAddr,
provider: &str,
body: &serde_json::Value,
) -> (reqwest::StatusCode, serde_json::Value) {
let resp = client
.post(format!("http://{addr}/v1/providers/{provider}/switch"))
.json(body)
.send()
.await
.expect("POST /v1/providers/{id}/switch should not fail at transport level");
let status = resp.status();
let body: serde_json::Value = resp
.json()
.await
.unwrap_or_else(|_| serde_json::json!({"_error": "non-json response body"}));
(status, body)
}
#[tokio::test]
async fn switch_provider_without_model_arg_preserves_user_per_provider_model() -> Result<()> {
// Regression: clicking volcengine in the GUI picker used to send
// `POST /v1/config { key: "model", value: "deepseek-v4-pro" }` (the
// catalog default), clobbering the user's `[providers.volcengine].model
// = "glm-2"`. The new /v1/providers/{id}/switch endpoint MUST NOT
// touch the model key when no model arg is provided — mirroring the
// TUI's `/provider volcengine` (model: None) flow in
// `commands/groups/core/provider.rs` + `tui/ui.rs::switch_provider`.
let root = std::env::temp_dir().join(format!("codewhale-switch-no-model-{}", Uuid::new_v4()));
fs::create_dir_all(&root)?;
let config_file = root.join("custom-config.toml");
fs::write(
&config_file,
r#"provider = "deepseek"
default_text_model = "deepseek-v4-pro"
[providers.volcengine]
api_key = "ark-test"
base_url = "https://ark.cn-beijing.volces.com/api/plan/v3"
model = "glm-2"
"#,
)?;
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_config_path(config_file.clone()).await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
// Switch to volcengine WITHOUT a model arg — simulates a picker click.
let (status, body) =
post_switch_provider(&client, &addr, "volcengine", &serde_json::json!({})).await;
assert_eq!(
status,
StatusCode::OK,
"switch should succeed, body: {body}"
);
// Response must report the user's configured model, NOT the catalog
// default "deepseek-v4-pro".
assert_eq!(
body["provider"].as_str(),
Some("volcengine"),
"response should echo the switched-to provider"
);
assert_eq!(
body["model"].as_str(),
Some("glm-2"),
"resolved model must be the user's `[providers.volcengine].model`, \
not the catalog default — if this fails the switch endpoint is \
clobbering per-provider config"
);
// The config file on disk must NOT contain a `model = "deepseek-v4-pro"`
// override for volcengine — `glm-2` must be preserved verbatim.
let persisted = fs::read_to_string(&config_file)?;
assert!(
persisted.contains("model = \"glm-2\""),
"user's `[providers.volcengine].model = \"glm-2\"` must be preserved on disk. \
Actual config:\n{persisted}"
);
assert!(
!persisted
.matches("model = \"deepseek-v4-pro\"")
.count()
.ge(&2),
"switch must not add a second `model = \"deepseek-v4-pro\"` line for volcengine. \
Actual config:\n{persisted}"
);
handle.abort();
Ok(())
}
#[tokio::test]
async fn switch_provider_with_explicit_model_arg_persists_model() -> Result<()> {
// When the user explicitly chooses a model (e.g. `/provider volcengine
// glm-2.5` or a model-picker selection), the switch endpoint MUST
// persist that model — mirroring `switch_provider`'s
// `if model_override.is_some()` branch (ui.rs:9400-9405).
let root = std::env::temp_dir().join(format!("codewhale-switch-with-model-{}", Uuid::new_v4()));
fs::create_dir_all(&root)?;
let config_file = root.join("custom-config.toml");
fs::write(
&config_file,
r#"provider = "deepseek"
default_text_model = "deepseek-v4-pro"
[providers.volcengine]
api_key = "ark-test"
base_url = "https://ark.cn-beijing.volces.com/api/plan/v3"
model = "glm-2"
"#,
)?;
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_config_path(config_file.clone()).await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
// Switch to volcengine WITH an explicit model arg.
let (status, body) = post_switch_provider(
&client,
&addr,
"volcengine",
&serde_json::json!({ "model": "deepseek-v4-flash" }),
)
.await;
assert_eq!(
status,
StatusCode::OK,
"switch with explicit model should succeed, body: {body}"
);
// The persisted config must reflect the explicit override.
let persisted = fs::read_to_string(&config_file)?;
assert!(
persisted.contains("model = \"deepseek-v4-flash\""),
"explicit model arg must be persisted to `[providers.volcengine].model`. \
Actual config:\n{persisted}"
);
handle.abort();
Ok(())
}
#[tokio::test]
async fn switch_provider_rejects_unknown_provider_id() -> Result<()> {
let root = std::env::temp_dir().join(format!("codewhale-switch-unknown-{}", Uuid::new_v4()));
fs::create_dir_all(&root)?;
let config_file = root.join("custom-config.toml");
fs::write(&config_file, "# empty\n")?;
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_config_path(config_file.clone()).await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
let (status, _body) = post_switch_provider(
&client,
&addr,
"not-a-real-provider",
&serde_json::json!({}),
)
.await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"unknown provider id should return 400"
);
handle.abort();
Ok(())
}
#[tokio::test]
async fn switch_provider_rejects_legacy_deepseek_cn_alias() -> Result<()> {
// The legacy `deepseek-cn` alias has no ProviderKind metadata; the
// GUI must use `deepseek` instead. Same guard as list_provider_models.
let root = std::env::temp_dir().join(format!("codewhale-switch-cn-alias-{}", Uuid::new_v4()));
fs::create_dir_all(&root)?;
let config_file = root.join("custom-config.toml");
fs::write(&config_file, "# empty\n")?;
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_config_path(config_file.clone()).await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
let (status, body) =
post_switch_provider(&client, &addr, "deepseek-cn", &serde_json::json!({})).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"deepseek-cn should be rejected, body: {body}"
);
handle.abort();
Ok(())
}
#[tokio::test]
async fn switch_provider_with_deepseek_and_explicit_model_updates_default_text_model() -> Result<()>
{
// When switching TO a DeepSeek provider with an explicit model, the
// endpoint must persist `default_text_model` (the DeepSeek-specific
// root key) in addition to the provider change, mirroring
// `switch_provider` in ui.rs which pins `default_model` for DeepSeek.
let root = std::env::temp_dir().join(format!(
"codewhale-switch-deepseek-model-{}",
Uuid::new_v4()
));
fs::create_dir_all(&root)?;
let config_file = root.join("custom-config.toml");
fs::write(
&config_file,
r#"provider = "volcengine"
default_text_model = "old-model"
[providers.volcengine]
api_key = "ark-test"
base_url = "https://ark.cn-beijing.volces.com/api/plan/v3"
model = "glm-2"
"#,
)?;
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_config_path(config_file.clone()).await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
// Switch to deepseek WITH an explicit model override.
let (status, body) = post_switch_provider(
&client,
&addr,
"deepseek",
&serde_json::json!({ "model": "deepseek-v4-pro" }),
)
.await;
assert_eq!(
status,
StatusCode::OK,
"switch to deepseek with model should succeed, body: {body}"
);
// The persisted config must have provider = "deepseek" and
// default_text_model updated to the explicit model.
let persisted = fs::read_to_string(&config_file)?;
assert!(
persisted.contains("provider = \"deepseek\""),
"provider should be persisted as deepseek. Actual config:\n{persisted}"
);
assert!(
persisted.contains("default_text_model = \"deepseek-v4-pro\""),
"DeepSeek explicit model must be persisted as default_text_model. \
Actual config:\n{persisted}"
);
handle.abort();
Ok(())
}
#[tokio::test]
async fn switch_provider_empty_model_string_treated_as_no_override() -> Result<()> {
// An empty string model (`{ "model": "" }`) must be treated the same
// as no model at all — the endpoint should NOT persist a model key,
// matching the TUI's behavior where a blank model arg is ignored.
let root =
std::env::temp_dir().join(format!("codewhale-switch-empty-model-{}", Uuid::new_v4()));
fs::create_dir_all(&root)?;
let config_file = root.join("custom-config.toml");
fs::write(
&config_file,
r#"provider = "deepseek"
default_text_model = "deepseek-v4-pro"
[providers.volcengine]
api_key = "ark-test"
base_url = "https://ark.cn-beijing.volces.com/api/plan/v3"
model = "glm-2"
"#,
)?;
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_config_path(config_file.clone()).await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
let (status, body) = post_switch_provider(
&client,
&addr,
"volcengine",
&serde_json::json!({ "model": "" }),
)
.await;
assert_eq!(
status,
StatusCode::OK,
"switch with empty model should succeed, body: {body}"
);
// The user's `model = "glm-2"` must NOT be overwritten.
let persisted = fs::read_to_string(&config_file)?;
assert!(
persisted.contains("model = \"glm-2\""),
"user's model must be preserved when empty-string model is sent. \
Actual config:\n{persisted}"
);
handle.abort();
Ok(())
}
#[tokio::test]
async fn switch_provider_persists_provider_key_on_disk() -> Result<()> {
// Verify that the root `provider = "..."` key is correctly written to
// the config file on disk, not just in the response body.
let root =
std::env::temp_dir().join(format!("codewhale-switch-provider-disk-{}", Uuid::new_v4()));
fs::create_dir_all(&root)?;
let config_file = root.join("custom-config.toml");
fs::write(
&config_file,
r#"provider = "deepseek"
default_text_model = "deepseek-v4-pro"
[providers.volcengine]
api_key = "ark-test"
base_url = "https://ark.cn-beijing.volces.com/api/plan/v3"
model = "glm-2"
"#,
)?;
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_config_path(config_file.clone()).await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
let (status, _body) =
post_switch_provider(&client, &addr, "volcengine", &serde_json::json!({})).await;
assert_eq!(status, StatusCode::OK);
let persisted = fs::read_to_string(&config_file)?;
assert!(
persisted.contains("provider = \"volcengine\""),
"root `provider` key must be updated on disk. Actual config:\n{persisted}"
);
handle.abort();
Ok(())
}
#[tokio::test]
async fn zai_model_update_is_provider_scoped_and_preserves_deepseek_fallback() -> Result<()> {
let root = std::env::temp_dir().join(format!(
@@ -6072,6 +6629,64 @@ async fn reload_config_with_malformed_file_returns_error() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn set_config_model_follows_persisted_provider_before_reload() -> Result<()> {
let root = std::env::temp_dir().join(format!(
"codewhale-config-provider-model-{}",
Uuid::new_v4()
));
fs::create_dir_all(&root)?;
let config_file = root.join("custom-config.toml");
fs::write(
&config_file,
format!(
"provider = \"deepseek\"\ndefault_text_model = \"deepseek-v4-pro\"\n\n[providers.volcengine]\nmodel = \"{}\"\n",
crate::config::DEFAULT_VOLCENGINE_MODEL
),
)?;
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_config_path(config_file.clone()).await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
let (status, body) = post_set_config(&client, &addr, "provider", "volcengine", true).await;
assert_eq!(status, StatusCode::OK, "body: {body}");
let target_model = crate::config::DEFAULT_VOLCENGINE_FLASH_MODEL;
let (status, body) = post_set_config(&client, &addr, "model", target_model, true).await;
assert_eq!(status, StatusCode::OK, "body: {body}");
let config_body = fs::read_to_string(&config_file)?;
assert!(
config_body.contains("provider = \"volcengine\""),
"provider should be persisted before reload"
);
assert!(
config_body.contains(&format!("model = \"{target_model}\"")),
"volcengine model should be written to the provider table"
);
assert!(
config_body.contains("default_text_model = \"deepseek-v4-pro\""),
"switching provider model must not overwrite DeepSeek's root default_text_model"
);
let reload_resp = client
.post(format!("http://{addr}/v1/config/reload"))
.send()
.await?;
assert_eq!(reload_resp.status(), StatusCode::OK);
let after_reload = get_config(&client, &addr).await;
assert_eq!(after_reload["provider"].as_str(), Some("volcengine"));
assert_eq!(after_reload["model"].as_str(), Some(target_model));
handle.abort();
Ok(())
}
#[tokio::test]
async fn reload_config_applies_multiple_persisted_keys() -> Result<()> {
// Verify that multiple set_config calls accumulate on disk and a single