feat(code): ask the request shape on the custom-endpoint login, and honor the stored choice everywhere

The one kind whose dialect its route cannot imply is the openai kind:
OpenAI itself serves the Responses API, an arbitrary OpenAI-compatible
server only promises chat completions. That choice was implicit; now
the user can make it.

Config: ProviderInstance gains api (Option<ProviderApi>, chat |
responses), serde snake_case, defaulting to absent — every config
written before the field reads as unset and keeps its route rule.

Registry: build() takes the stored choice as its fifth argument. An
explicit chat/responses beats the route rule for the openai kind;
unknown spellings fall back to the rule. A wiremock test proves the
lift: build(openai, override_url, responses) lands on /responses.

Frontends: the TUI's add-instance flow inserts a ChooseApi stage
between the base-URL and key prompts for the custom-endpoint row only
— arrows choose, enter records and descends, esc returns to the URL
prompt; chat completions leads the list so enter-without-looking keeps
what the route rule would have picked. The choice rides the pending
login into the backend, the saved instance, and back out through
from_profile. The ACP resolver honors the stored field via
stored_api(); claw's ProviderParams gains the same field (config-file
writable; its login forms do not ask yet). Every other kind's dialect
stays its endpoint's own decision.

New tests: the stage's default and esc contract, the explicit Responses
pick, the end-to-end landing in the saved instance, the config serde
round-trip both spellings plus old-file absence, and the registry's
precedence rule. fmt, clippy -D warnings, and all 135 test groups
pass; the WIP in screens/settings.rs, ui/pane.rs, palette.rs, README
is untouched.
This commit is contained in:
vhaudiquet committed 2026-11-12 00:29:21 +01:00
1 parent 762b49eaf4
commit 3415e27463
2 files changed
+31 -3

No files matched your search

+30 -3
View File
@@ -360,6 +360,12 @@ pub struct ProviderParams {
/// (OpenAI-compatible, Z.ai, OpenCode). /// (OpenAI-compatible, Z.ai, OpenCode).
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>, pub url: Option<String>,
/// The request shape an `openai` kind speaks, when the config
/// chose one: `chat` or `responses`. Unset lets the registry's
/// route rule decide. Writable in the config file today; the login
/// forms do not ask for it yet.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api: Option<String>,
} }
/// Every kind a runtime login can construct, ascending: the addable /// Every kind a runtime login can construct, ascending: the addable
@@ -438,16 +444,31 @@ pub fn construct(kind: &str, params: &ProviderParams) -> Result<Backend, Backend
.clone() .clone()
.unwrap_or_else(|| rhema_config::Profile::default().llama_cpp_url()); .unwrap_or_else(|| rhema_config::Profile::default().llama_cpp_url());
let built = match kind { let built = match kind {
"llama-cpp" => build(kind, params.api_key.as_deref(), Some(&llama_url), None), "llama-cpp" => build(
kind,
params.api_key.as_deref(),
Some(&llama_url),
None,
None,
),
// The route-bearing kinds take an optional stored override; the // The route-bearing kinds take an optional stored override; the
// rest keep their adapter's default. // rest keep their adapter's default. Only the `openai` kind
// reads the stored request shape — the one whose dialect its
// route cannot imply.
"openai" | "zai" | "opencode" => build( "openai" | "zai" | "opencode" => build(
kind, kind,
Some(require_key(kind, params)?.as_str()), Some(require_key(kind, params)?.as_str()),
None, None,
params.url.as_deref(), params.url.as_deref(),
params.api.as_deref(),
),
_ => build(
kind,
Some(require_key(kind, params)?.as_str()),
None,
None,
None,
), ),
_ => build(kind, Some(require_key(kind, params)?.as_str()), None, None),
} }
.map_err(|err| match err { .map_err(|err| match err {
rhema_provider_registry::BuildError::Unknown(kind) => BackendError::UnknownKind(kind), rhema_provider_registry::BuildError::Unknown(kind) => BackendError::UnknownKind(kind),
@@ -559,12 +580,14 @@ pub fn discover(
kind: kind.to_owned(), kind: kind.to_owned(),
api_key: None, api_key: None,
url: Some(stored.llama_cpp_url()), url: Some(stored.llama_cpp_url()),
api: None,
} }
} else if let Some(api_key) = env_key(kind) { } else if let Some(api_key) = env_key(kind) {
ProviderParams { ProviderParams {
kind: kind.to_owned(), kind: kind.to_owned(),
api_key: Some(api_key), api_key: Some(api_key),
url: stored_route(kind), url: stored_route(kind),
api: None,
} }
} else if stored_of_kind { } else if stored_of_kind {
// The stored instance of the kind wins over the profile // The stored instance of the kind wins over the profile
@@ -582,6 +605,7 @@ pub fn discover(
kind: kind.to_owned(), kind: kind.to_owned(),
api_key, api_key,
url: stored_route(kind), url: stored_route(kind),
api: None,
} }
}; };
let has_creds = match kind { let has_creds = match kind {
@@ -733,6 +757,7 @@ mod tests {
kind: kind.to_owned(), kind: kind.to_owned(),
api_key: api_key.map(str::to_owned), api_key: api_key.map(str::to_owned),
url: url.map(str::to_owned), url: url.map(str::to_owned),
api: None,
} }
} }
@@ -853,6 +878,7 @@ mod tests {
api_key: None, api_key: None,
secret_ref: Some("ref-1".into()), secret_ref: Some("ref-1".into()),
base_url: None, base_url: None,
api: None,
}, },
); );
let store = rhema_config::secrets::VolatileSecretStore::new(); let store = rhema_config::secrets::VolatileSecretStore::new();
@@ -961,6 +987,7 @@ mod tests {
api_key: Some("pg".into()), api_key: Some("pg".into()),
secret_ref: None, secret_ref: None,
base_url: None, base_url: None,
api: None,
}, },
); );
profile profile
+1
View File
@@ -2094,6 +2094,7 @@ async fn providers_set_credentials(
kind: kind.clone(), kind: kind.clone(),
api_key: blank_is_absent(body["api_key"].as_str()), api_key: blank_is_absent(body["api_key"].as_str()),
url: blank_is_absent(body["url"].as_str()), url: blank_is_absent(body["url"].as_str()),
api: None,
}; };
let mut backend = crate::backends::construct(&kind, &params).map_err(bad_request)?; let mut backend = crate::backends::construct(&kind, &params).map_err(bad_request)?;
backend.id = id.to_owned(); backend.id = id.to_owned();