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:
@@ -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 => {
|
||||
|
||||
@@ -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"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user