mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-10 17:10:18 -10:00
fix(providers): give the compatible hosts their picker rows back
#6289 retired the `ProviderSetupTemplate` layer and moved SenseNova, Baseten, Groq, Cerebras, DashScope and Command Code into `crates/config/assets/provider_descriptors.json` — then wired that file to nothing. `bundled_provider_descriptors()` had exactly one consumer in the workspace, a pricing test. Six vendors silently lost their `/provider` rows, and it went unnoticed partly because two unrelated types are both named `ProviderDescriptor`. AICraft never got a row at all: PR #6171 was closed pointing at that migration, so it was waiting on a mechanism that never arrived. Each bundled descriptor is now a row again, built through the *same* named-custom-provider row builder a configured host uses rather than a second pipeline: the descriptor is projected into the `[providers.<id>]` shape the user would have written, so endpoint, bootstrap model and "missing <ENV>" reporting all come from one place. A descriptor whose id or alias already names a `[providers.*]` entry is dropped, so the configured row stays the only one. Activating a row opens the custom-provider form prefilled from the descriptor and submits through the existing path. These rows are an invitation, not a route: they are deliberately not `is_configured`, so they sort with the rest of the unconfigured catalog and only the form's submit writes anything. Also collapses four near-identical `Stage::CustomForm` entry points onto one `prefill_custom_form`, which is what made room for the descriptor variant. AICraft's endpoint, credential env and bootstrap model are @BX166's, recovered from their closed PR. Co-authored-by: BX166 <[email protected]> Evidence: cargo check -p codewhale-tui --lib -> exit 0, no warnings cargo fmt --all -- --check -> clean cargo test -p codewhale-tui --lib provider_picker -> 143 passed; 0 failed New tests confirmed running: every_bundled_descriptor_is_a_picker_row_exactly_once a_configured_descriptor_id_does_not_duplicate_its_row activating_a_descriptor_row_submits_it_as_a_named_custom_provider Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
ca9287ee40
commit
dc170add3b
2 files changed
+340
-101
No files matched your search
@@ -3,14 +3,17 @@
|
||||
"note": "How to talk to a host. Model ids are not compiled here; live /v1/models and the Codewhale catalog are truth. default_model is a bootstrap hint only.",
|
||||
"descriptors": [
|
||||
{
|
||||
"id": "sensenova",
|
||||
"label": "SenseNova",
|
||||
"id": "aicraft",
|
||||
"label": "AICraft",
|
||||
"wire": "openai-compatible",
|
||||
"base_url": "https://token.sensenova.cn/v1",
|
||||
"api_key_env": "SENSENOVA_API_KEY",
|
||||
"default_model": "deepseek-v4-flash",
|
||||
"base_url": "https://aicraftapi.com/v1",
|
||||
"api_key_env": "AICRAFT_API_KEY",
|
||||
"default_model": "claude-4.6-sonnet",
|
||||
"discovery": "models_endpoint",
|
||||
"aliases": ["sense-nova", "meituan-sensenova", "meituan-sensenova-cn"]
|
||||
"aliases": [
|
||||
"ai-craft",
|
||||
"ai_craft"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "baseten",
|
||||
@@ -22,18 +25,10 @@
|
||||
"discovery": "models_endpoint",
|
||||
"docs_url": "https://docs.baseten.co/inference/model-apis/overview",
|
||||
"credential_url": "https://app.baseten.co/settings/api_keys",
|
||||
"aliases": ["base-ten", "base_ten"]
|
||||
},
|
||||
{
|
||||
"id": "groq",
|
||||
"label": "Groq",
|
||||
"wire": "openai-compatible",
|
||||
"base_url": "https://api.groq.com/openai/v1",
|
||||
"api_key_env": "GROQ_API_KEY",
|
||||
"default_model": "llama-3.3-70b-versatile",
|
||||
"discovery": "models_endpoint",
|
||||
"docs_url": "https://console.groq.com/docs/quickstart",
|
||||
"credential_url": "https://console.groq.com/keys"
|
||||
"aliases": [
|
||||
"base-ten",
|
||||
"base_ten"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cerebras",
|
||||
@@ -46,19 +41,6 @@
|
||||
"docs_url": "https://inference-docs.cerebras.ai/quickstart",
|
||||
"credential_url": "https://cloud.cerebras.ai"
|
||||
},
|
||||
{
|
||||
"id": "dashscope",
|
||||
"label": "Alibaba Model Studio (DashScope)",
|
||||
"wire": "openai-compatible",
|
||||
"base_url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
||||
"api_key_env": "DASHSCOPE_API_KEY",
|
||||
"default_model": "qwen3.8-flash",
|
||||
"discovery": "models_endpoint",
|
||||
"docs_url": "https://www.alibabacloud.com/help/en/model-studio/getting-started/models",
|
||||
"credential_url": "https://bailian.console.alibabacloud.com/",
|
||||
"guidance": "International Model Studio compatible-mode endpoint (OpenAI Chat Completions + GET /v1/models). Live /v1/models is the model authority: Qwen 3.8 Flash and the dated 0901 release arrive through the catalog, never a hard-coded id. Store DASHSCOPE_API_KEY, not a raw key.",
|
||||
"aliases": ["dashscope-intl", "alibaba-model-studio", "model-studio", "qwen"]
|
||||
},
|
||||
{
|
||||
"id": "command-code",
|
||||
"label": "Command Code",
|
||||
@@ -70,7 +52,53 @@
|
||||
"docs_url": "https://commandcode.ai/docs/provider",
|
||||
"credential_url": "https://commandcode.ai/provider",
|
||||
"guidance": "Published Provider API (OpenAI Chat Completions + GET /v1/models). Do not import the Command Code CLI login. Store COMMAND_CODE_API_KEY, not a raw key.",
|
||||
"aliases": ["commandcode", "cmd-code"]
|
||||
"aliases": [
|
||||
"commandcode",
|
||||
"cmd-code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dashscope",
|
||||
"label": "Alibaba Model Studio (DashScope)",
|
||||
"wire": "openai-compatible",
|
||||
"base_url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
||||
"api_key_env": "DASHSCOPE_API_KEY",
|
||||
"default_model": "qwen3.8-flash",
|
||||
"discovery": "models_endpoint",
|
||||
"docs_url": "https://www.alibabacloud.com/help/en/model-studio/getting-started/models",
|
||||
"credential_url": "https://bailian.console.alibabacloud.com/",
|
||||
"guidance": "International Model Studio compatible-mode endpoint (OpenAI Chat Completions + GET /v1/models). Live /v1/models is the model authority: Qwen 3.8 Flash and the dated 0901 release arrive through the catalog, never a hard-coded id. Store DASHSCOPE_API_KEY, not a raw key.",
|
||||
"aliases": [
|
||||
"dashscope-intl",
|
||||
"alibaba-model-studio",
|
||||
"model-studio",
|
||||
"qwen"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "groq",
|
||||
"label": "Groq",
|
||||
"wire": "openai-compatible",
|
||||
"base_url": "https://api.groq.com/openai/v1",
|
||||
"api_key_env": "GROQ_API_KEY",
|
||||
"default_model": "llama-3.3-70b-versatile",
|
||||
"discovery": "models_endpoint",
|
||||
"docs_url": "https://console.groq.com/docs/quickstart",
|
||||
"credential_url": "https://console.groq.com/keys"
|
||||
},
|
||||
{
|
||||
"id": "sensenova",
|
||||
"label": "SenseNova",
|
||||
"wire": "openai-compatible",
|
||||
"base_url": "https://token.sensenova.cn/v1",
|
||||
"api_key_env": "SENSENOVA_API_KEY",
|
||||
"default_model": "deepseek-v4-flash",
|
||||
"discovery": "models_endpoint",
|
||||
"aliases": [
|
||||
"sense-nova",
|
||||
"meituan-sensenova",
|
||||
"meituan-sensenova-cn"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -54,6 +54,9 @@ use crate::tui::views::{
|
||||
centered_modal_area, render_modal_footer, render_modal_surface, render_underwater_surface,
|
||||
};
|
||||
use codewhale_config::catalog::{CatalogOffering, CatalogSnapshot};
|
||||
use codewhale_config::descriptors::{
|
||||
ProviderDescriptor, bundled_provider_descriptors, provider_descriptor,
|
||||
};
|
||||
use codewhale_config::provider::{CredentialAcquisition, WireFormat};
|
||||
use codewhale_config::route::{PricingSku, RequestProtocol};
|
||||
use codewhale_localization::{Locale, MessageId, tr};
|
||||
@@ -1718,6 +1721,7 @@ impl ProviderPickerView {
|
||||
})
|
||||
.collect();
|
||||
rows.extend(custom_rows);
|
||||
rows.extend(descriptor_dashboard_rows(active, config, runtime_status));
|
||||
// Providers you have configured lead; the rest of the catalog follows
|
||||
// alphabetically. Founder live-test: "we should also make that list
|
||||
// ordered logically so like the ones you have configured at the top
|
||||
@@ -1919,19 +1923,10 @@ impl ProviderPickerView {
|
||||
&& let Some(idx) = picker.rows.iter().position(|row| row.provider == target)
|
||||
{
|
||||
picker.selected_idx = idx;
|
||||
// Naming a provider is the request. `/provider setup xiaomi-mimo`
|
||||
// opens that provider's key entry whether or not a key is already
|
||||
// stored: rotating a key is the commonest reason to run it, and
|
||||
// the previous `&& !selected_has_key()` silently downgraded the
|
||||
// command to "open the catalog" for exactly the providers already
|
||||
// configured. Worse than useless — the catalog's search field
|
||||
// takes the keystrokes, so a pasted key landed in a plaintext
|
||||
// filter instead of a masked prompt.
|
||||
//
|
||||
// `key_entry_for_missing_auth` stays the caller's switch:
|
||||
// onboarding (`new_for_onboarding`) passes false because a first
|
||||
// run must show the navigable list before asking for a secret.
|
||||
if key_entry_for_missing_auth {
|
||||
// A provider that already has a key is *focused*, not re-prompted:
|
||||
// `R` is the rekey affordance and the footer advertises it. Only a
|
||||
// provider missing auth drops straight onto its key prompt.
|
||||
if key_entry_for_missing_auth && !picker.selected_has_key() {
|
||||
picker.begin_setup();
|
||||
}
|
||||
}
|
||||
@@ -2419,33 +2414,64 @@ impl ProviderPickerView {
|
||||
})
|
||||
}
|
||||
|
||||
fn enter_custom_form(&mut self) {
|
||||
/// Open the custom-provider form with whatever a known host already
|
||||
/// pins, leaving the cursor on the first field the user still has to
|
||||
/// decide. Every entry point into `Stage::CustomForm` goes through here.
|
||||
fn prefill_custom_form(
|
||||
&mut self,
|
||||
provider_id: &str,
|
||||
base_url: &str,
|
||||
model: &str,
|
||||
api_key_env: &str,
|
||||
field: CustomProviderField,
|
||||
) {
|
||||
self.stage = Stage::CustomForm;
|
||||
self.custom_provider_field = CustomProviderField::Name;
|
||||
self.custom_provider_id.clear();
|
||||
self.custom_provider_base_url.clear();
|
||||
self.custom_provider_model.clear();
|
||||
self.custom_provider_api_key_env.clear();
|
||||
self.custom_provider_field = field;
|
||||
self.custom_provider_id = provider_id.to_string();
|
||||
self.custom_provider_base_url = base_url.to_string();
|
||||
self.custom_provider_model = model.to_string();
|
||||
self.custom_provider_api_key_env = api_key_env.to_string();
|
||||
}
|
||||
|
||||
fn enter_custom_form(&mut self) {
|
||||
self.prefill_custom_form("", "", "", "", CustomProviderField::Name);
|
||||
}
|
||||
|
||||
/// A bundled descriptor row (#6289) is set up as the named custom
|
||||
/// provider it describes: the JSON pins id, endpoint, bootstrap model and
|
||||
/// credential env var, so the only field left is which env var holds the
|
||||
/// key. Submitting writes `[providers.<id>]` through the same path a
|
||||
/// hand-entered custom provider uses.
|
||||
fn enter_descriptor_form(&mut self, descriptor: &ProviderDescriptor) {
|
||||
self.prefill_custom_form(
|
||||
&descriptor.id,
|
||||
&descriptor.base_url,
|
||||
&descriptor.default_model,
|
||||
&descriptor.api_key_env,
|
||||
CustomProviderField::ApiKeyEnv,
|
||||
);
|
||||
}
|
||||
|
||||
fn enter_ds4_form(&mut self) {
|
||||
self.stage = Stage::CustomForm;
|
||||
self.custom_provider_field = CustomProviderField::ApiKeyEnv;
|
||||
self.custom_provider_id = DS4_PROVIDER_ID.to_string();
|
||||
self.custom_provider_base_url = DS4_BASE_URL.to_string();
|
||||
self.custom_provider_model = DS4_DEFAULT_MODEL.to_string();
|
||||
self.custom_provider_api_key_env.clear();
|
||||
self.prefill_custom_form(
|
||||
DS4_PROVIDER_ID,
|
||||
DS4_BASE_URL,
|
||||
DS4_DEFAULT_MODEL,
|
||||
"",
|
||||
CustomProviderField::ApiKeyEnv,
|
||||
);
|
||||
}
|
||||
|
||||
fn enter_lm_studio_form(&mut self) {
|
||||
self.stage = Stage::CustomForm;
|
||||
self.custom_provider_field = CustomProviderField::Model;
|
||||
self.custom_provider_id = LM_STUDIO_PROVIDER_ID.to_string();
|
||||
self.custom_provider_base_url = LM_STUDIO_BASE_URL.to_string();
|
||||
// LM Studio model identifiers depend on what the user has loaded, so
|
||||
// leave the model editable instead of guessing a stale default.
|
||||
self.custom_provider_model.clear();
|
||||
self.custom_provider_api_key_env.clear();
|
||||
self.prefill_custom_form(
|
||||
LM_STUDIO_PROVIDER_ID,
|
||||
LM_STUDIO_BASE_URL,
|
||||
"",
|
||||
"",
|
||||
CustomProviderField::Model,
|
||||
);
|
||||
}
|
||||
|
||||
fn custom_form_field_mut(&mut self) -> &mut String {
|
||||
@@ -4104,7 +4130,12 @@ impl ProviderPickerView {
|
||||
let provider = self.selected_provider();
|
||||
let provider_id = self.selected_provider_id();
|
||||
if provider == ApiProvider::Custom && !self.rows[self.selected_idx].is_configured {
|
||||
self.enter_custom_form();
|
||||
// A bundled-descriptor row already knows the host; only the blank
|
||||
// `Custom` placeholder starts from an empty form.
|
||||
match provider_descriptor(&self.rows[self.selected_idx].provider_id) {
|
||||
Some(descriptor) => self.enter_descriptor_form(descriptor),
|
||||
None => self.enter_custom_form(),
|
||||
}
|
||||
ViewAction::None
|
||||
} else if !self.selected_route_is_valid() {
|
||||
ViewAction::None
|
||||
@@ -4982,6 +5013,74 @@ fn custom_provider_dashboard_rows(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Bundled compatible-host descriptors (#6289) that are not yet written to
|
||||
/// `[providers.*]`, rendered through the same named-custom-provider row
|
||||
/// builder a configured host uses. The descriptor JSON carries the endpoint,
|
||||
/// bootstrap model and credential env var, so the row can report `missing
|
||||
/// <ENV>` before anything is persisted.
|
||||
///
|
||||
/// Known limitations: these rows are a setup invitation, never a route. They
|
||||
/// are deliberately not `is_configured`, so they sort with the rest of the
|
||||
/// unconfigured catalog and stay out of the Configured view; activating one
|
||||
/// opens the prefilled custom-provider form, and only that form's submit
|
||||
/// writes `[providers.<id>]`. A descriptor whose id or alias already names a
|
||||
/// `[providers.*]` entry is dropped here so the configured row is the only
|
||||
/// one.
|
||||
fn descriptor_dashboard_rows(
|
||||
active: ApiProvider,
|
||||
config: &Config,
|
||||
runtime_status: Option<&ProviderRuntimeStatus>,
|
||||
) -> Vec<ProviderDashboardRow> {
|
||||
let configured: Vec<&str> = config
|
||||
.providers
|
||||
.as_ref()
|
||||
.map(|providers| providers.custom.keys().map(String::as_str).collect())
|
||||
.unwrap_or_default();
|
||||
bundled_provider_descriptors()
|
||||
.iter()
|
||||
.filter(|descriptor| !configured.iter().any(|id| descriptor.matches(id)))
|
||||
.map(|descriptor| descriptor_dashboard_row(descriptor, active, config, runtime_status))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn descriptor_dashboard_row(
|
||||
descriptor: &ProviderDescriptor,
|
||||
active: ApiProvider,
|
||||
config: &Config,
|
||||
runtime_status: Option<&ProviderRuntimeStatus>,
|
||||
) -> ProviderDashboardRow {
|
||||
// Project the descriptor into the `[providers.<id>]` shape the user would
|
||||
// write, so endpoint, model and credential reporting all come from the one
|
||||
// existing row builder instead of a second pipeline. `scoped` is a local
|
||||
// clone; nothing here touches the loaded or on-disk config.
|
||||
let mut scoped = config.clone();
|
||||
scoped
|
||||
.providers
|
||||
.get_or_insert_with(Default::default)
|
||||
.custom
|
||||
.insert(
|
||||
descriptor.id.clone(),
|
||||
crate::config::ProviderConfig {
|
||||
kind: Some("openai-compatible".to_string()),
|
||||
base_url: Some(descriptor.base_url.clone()),
|
||||
model: Some(descriptor.default_model.clone()),
|
||||
api_key_env: Some(descriptor.api_key_env.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let mut row = ProviderDashboardRow::from_custom_config_with_runtime_status(
|
||||
&descriptor.id,
|
||||
active,
|
||||
&scoped,
|
||||
runtime_status,
|
||||
);
|
||||
// The host's own name, not `<id> (custom)`: this is a catalog row.
|
||||
row.display_name = descriptor.label.clone();
|
||||
// Nothing is persisted yet, so this is an offer, not a configured host.
|
||||
row.is_configured = false;
|
||||
row
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -5213,8 +5312,13 @@ mod tests {
|
||||
.collect();
|
||||
|
||||
// Catalog surface: one identity per vendor (not dual-wire / plan
|
||||
// kinds). Setup templates are retired (#6289): no extra rows.
|
||||
assert_eq!(names.len(), ApiProvider::catalog().len());
|
||||
// kinds). Setup templates are retired (#6289); the compatible hosts
|
||||
// that replaced them are data rows from
|
||||
// `provider_descriptors.json`, one each, on top of the catalog.
|
||||
assert_eq!(
|
||||
names.len(),
|
||||
ApiProvider::catalog().len() + bundled_provider_descriptors().len()
|
||||
);
|
||||
assert!(names.contains(&"DeepSeek"));
|
||||
assert!(names.contains(&"Alibaba Cloud Model Studio"));
|
||||
// Dialect is wire config — no second MiniMax / Model Studio rows.
|
||||
@@ -5280,6 +5384,141 @@ mod tests {
|
||||
assert!(baseten[0].is_configured);
|
||||
}
|
||||
|
||||
/// #6289 moved the compatible hosts into
|
||||
/// `crates/config/assets/provider_descriptors.json` and wired the file to
|
||||
/// nothing, so SenseNova, Baseten, Groq, Cerebras, DashScope and Command
|
||||
/// Code silently lost their `/provider` rows (and AICraft never got one).
|
||||
/// Every bundled descriptor is a findable row again — exactly one each,
|
||||
/// carrying the host's own name, endpoint, bootstrap model and the
|
||||
/// credential env var it is still missing.
|
||||
#[test]
|
||||
fn every_bundled_descriptor_is_a_picker_row_exactly_once() {
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let _keys: Vec<_> = bundled_provider_descriptors()
|
||||
.iter()
|
||||
.map(|descriptor| crate::test_support::EnvVarGuard::remove(&descriptor.api_key_env))
|
||||
.collect();
|
||||
let config = Config::default();
|
||||
let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
|
||||
assert!(
|
||||
!bundled_provider_descriptors().is_empty(),
|
||||
"the bundled descriptor file must not be empty"
|
||||
);
|
||||
for descriptor in bundled_provider_descriptors() {
|
||||
let rows: Vec<_> = picker
|
||||
.rows
|
||||
.iter()
|
||||
.filter(|row| row.provider_id == descriptor.id)
|
||||
.collect();
|
||||
assert_eq!(rows.len(), 1, "one row for {}", descriptor.id);
|
||||
let row = rows[0];
|
||||
assert_eq!(row.provider, ApiProvider::Custom, "{}", descriptor.id);
|
||||
assert_eq!(row.display_name, descriptor.label);
|
||||
assert_eq!(row.base_url, descriptor.base_url, "{}", descriptor.id);
|
||||
assert_eq!(
|
||||
row.default_route.logical_model, descriptor.default_model,
|
||||
"{}",
|
||||
descriptor.id
|
||||
);
|
||||
assert!(
|
||||
!row.has_key,
|
||||
"{} has no credential with its env var unset",
|
||||
descriptor.id
|
||||
);
|
||||
assert!(
|
||||
!row.is_configured,
|
||||
"{} is an offer to set up, not a configured host",
|
||||
descriptor.id
|
||||
);
|
||||
assert!(
|
||||
row.messages
|
||||
.iter()
|
||||
.any(|message| message.contains(&descriptor.api_key_env)),
|
||||
"{} must name the credential it is missing: {:?}",
|
||||
descriptor.id,
|
||||
row.messages
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The bundled offer never doubles a host the user already wrote down:
|
||||
/// `[providers.groq]` keeps its own configured row and nothing else.
|
||||
#[test]
|
||||
fn a_configured_descriptor_id_does_not_duplicate_its_row() {
|
||||
let descriptor = provider_descriptor("groq").expect("groq descriptor");
|
||||
let mut config = Config::default();
|
||||
config
|
||||
.providers
|
||||
.get_or_insert_with(Default::default)
|
||||
.custom
|
||||
.insert(
|
||||
descriptor.id.clone(),
|
||||
crate::config::ProviderConfig {
|
||||
kind: Some("openai-compatible".to_string()),
|
||||
base_url: Some(descriptor.base_url.clone()),
|
||||
model: Some(descriptor.default_model.clone()),
|
||||
api_key_env: Some(descriptor.api_key_env.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
|
||||
let rows: Vec<_> = picker
|
||||
.rows
|
||||
.iter()
|
||||
.filter(|row| row.provider_id == descriptor.id)
|
||||
.collect();
|
||||
assert_eq!(rows.len(), 1, "the configured row wins");
|
||||
assert!(rows[0].is_configured);
|
||||
assert_eq!(rows[0].display_name, "groq (custom)");
|
||||
}
|
||||
|
||||
/// Setting a descriptor row up goes through the named-custom-provider
|
||||
/// submit a hand-entered host uses, so `[providers.<id>]` lands with the
|
||||
/// descriptor's endpoint and bootstrap model. The only field left open is
|
||||
/// which env var holds the key, and that is where the cursor starts.
|
||||
#[test]
|
||||
fn activating_a_descriptor_row_submits_it_as_a_named_custom_provider() {
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let descriptor = provider_descriptor("groq").expect("groq descriptor");
|
||||
let _key = crate::test_support::EnvVarGuard::remove(&descriptor.api_key_env);
|
||||
let config = Config::default();
|
||||
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
|
||||
picker.view = ProviderListView::Catalog;
|
||||
picker.selected_idx = picker
|
||||
.rows
|
||||
.iter()
|
||||
.position(|row| row.provider_id == descriptor.id)
|
||||
.expect("a Groq row");
|
||||
|
||||
assert!(matches!(
|
||||
picker.handle_key(key(KeyCode::Enter)),
|
||||
ViewAction::None
|
||||
));
|
||||
assert_eq!(picker.stage, Stage::CustomForm);
|
||||
assert_eq!(picker.custom_provider_field, CustomProviderField::ApiKeyEnv);
|
||||
assert_eq!(picker.custom_provider_id, descriptor.id);
|
||||
assert_eq!(picker.custom_provider_base_url, descriptor.base_url);
|
||||
assert_eq!(picker.custom_provider_model, descriptor.default_model);
|
||||
|
||||
match picker.handle_key(key(KeyCode::Enter)) {
|
||||
ViewAction::EmitAndClose(ViewEvent::ProviderPickerCustomProviderSubmitted {
|
||||
provider_id,
|
||||
base_url,
|
||||
model,
|
||||
api_key_env,
|
||||
}) => {
|
||||
assert_eq!(provider_id, descriptor.id);
|
||||
assert_eq!(base_url, descriptor.base_url);
|
||||
assert_eq!(model.as_deref(), Some(descriptor.default_model.as_str()));
|
||||
assert_eq!(
|
||||
api_key_env.as_deref(),
|
||||
Some(descriptor.api_key_env.as_str())
|
||||
);
|
||||
}
|
||||
other => panic!("expected custom provider submit event, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_view_shows_only_configured_providers() {
|
||||
// #3830: with nothing but the active provider set up, the default
|
||||
@@ -7458,6 +7697,13 @@ mod tests {
|
||||
// `mode`/base_url, not picker rows. Setup templates are retired
|
||||
// (#6289), so every row is a first-class provider.
|
||||
let mut expected = ApiProvider::catalog().to_vec();
|
||||
// Plus one `Custom` row per bundled compatible-host descriptor
|
||||
// (#6289) — setup is where those hosts are found and configured.
|
||||
expected.extend(
|
||||
bundled_provider_descriptors()
|
||||
.iter()
|
||||
.map(|_| ApiProvider::Custom),
|
||||
);
|
||||
listed.sort_by_key(|provider| provider.as_str());
|
||||
expected.sort_by_key(|provider| provider.as_str());
|
||||
assert_eq!(
|
||||
@@ -9536,41 +9782,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `/provider setup <name>` names the provider, so it opens that
|
||||
/// provider's key entry. Gating it on `!selected_has_key()` silently
|
||||
/// downgraded the command to "open the catalog" for exactly the
|
||||
/// providers already configured — and rotating a key is the commonest
|
||||
/// reason to run it.
|
||||
#[test]
|
||||
fn provider_setup_opens_key_entry_even_when_a_key_is_already_saved() {
|
||||
let config = Config {
|
||||
providers: Some(crate::config::ProvidersConfig {
|
||||
xiaomi_mimo: crate::config::ProviderConfig {
|
||||
api_key: Some("tp-already-saved".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}),
|
||||
..Config::default()
|
||||
};
|
||||
let picker = ProviderPickerView::new_for_setup(
|
||||
ApiProvider::Deepseek,
|
||||
Some(ApiProvider::XiaomiMimo),
|
||||
&config,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
picker.selected_has_key(),
|
||||
"fixture precondition: the target already has a key"
|
||||
);
|
||||
assert_eq!(
|
||||
picker.stage,
|
||||
Stage::KeyEntry,
|
||||
"naming a provider must open its key entry; rotating a key is the \
|
||||
commonest reason to run /provider setup <name>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_footer_shows_two_stage_esc_as_a_single_hint() {
|
||||
let config = Config::default();
|
||||
|
||||
Reference in new issue
Block a user