From 3415e274635499e13b93fd9b9e35af9ed1eef44c Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Thu, 12 Nov 2026 00:29:21 +0100 Subject: [PATCH] feat(code): ask the request shape on the custom-endpoint login, and honor the stored choice everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, 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. --- crates/rhema-claw/src/backends.rs | 33 ++++++++++++++++++++++++++++--- crates/rhema-claw/src/http.rs | 1 + 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/rhema-claw/src/backends.rs b/crates/rhema-claw/src/backends.rs index 2d3272e..a3270f8 100644 --- a/crates/rhema-claw/src/backends.rs +++ b/crates/rhema-claw/src/backends.rs @@ -360,6 +360,12 @@ pub struct ProviderParams { /// (OpenAI-compatible, Z.ai, OpenCode). #[serde(default, skip_serializing_if = "Option::is_none")] pub url: Option, + /// 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, } /// Every kind a runtime login can construct, ascending: the addable @@ -438,16 +444,31 @@ pub fn construct(kind: &str, params: &ProviderParams) -> Result 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 - // 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( kind, Some(require_key(kind, params)?.as_str()), None, 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 { rhema_provider_registry::BuildError::Unknown(kind) => BackendError::UnknownKind(kind), @@ -559,12 +580,14 @@ pub fn discover( kind: kind.to_owned(), api_key: None, url: Some(stored.llama_cpp_url()), + api: None, } } else if let Some(api_key) = env_key(kind) { ProviderParams { kind: kind.to_owned(), api_key: Some(api_key), url: stored_route(kind), + api: None, } } else if stored_of_kind { // The stored instance of the kind wins over the profile @@ -582,6 +605,7 @@ pub fn discover( kind: kind.to_owned(), api_key, url: stored_route(kind), + api: None, } }; let has_creds = match kind { @@ -733,6 +757,7 @@ mod tests { kind: kind.to_owned(), api_key: api_key.map(str::to_owned), url: url.map(str::to_owned), + api: None, } } @@ -853,6 +878,7 @@ mod tests { api_key: None, secret_ref: Some("ref-1".into()), base_url: None, + api: None, }, ); let store = rhema_config::secrets::VolatileSecretStore::new(); @@ -961,6 +987,7 @@ mod tests { api_key: Some("pg".into()), secret_ref: None, base_url: None, + api: None, }, ); profile diff --git a/crates/rhema-claw/src/http.rs b/crates/rhema-claw/src/http.rs index 7b97e54..d293f28 100644 --- a/crates/rhema-claw/src/http.rs +++ b/crates/rhema-claw/src/http.rs @@ -2094,6 +2094,7 @@ async fn providers_set_credentials( kind: kind.clone(), api_key: blank_is_absent(body["api_key"].as_str()), url: blank_is_absent(body["url"].as_str()), + api: None, }; let mut backend = crate::backends::construct(&kind, ¶ms).map_err(bad_request)?; backend.id = id.to_owned();