From fac9d11e99f618ce0d053966ca2c8628396b763e Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:04:21 +0200 Subject: [PATCH 1/9] fix: refresh the providers page when the profile list changes A provider-profiles answer updated the machines store without the persist that tells the views the slice changed, so a saved or deleted profile only appeared once something else touched the machine. The profiles are still never written: the machines slice strips them when it serializes. Co-Authored-By: Claude Code --- crates/client-runtime/src/dispatch.rs | 35 ++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/client-runtime/src/dispatch.rs b/crates/client-runtime/src/dispatch.rs index 28cfa631..bfec6adf 100644 --- a/crates/client-runtime/src/dispatch.rs +++ b/crates/client-runtime/src/dispatch.rs @@ -408,7 +408,10 @@ impl<'a> Router<'a> { BridgeToPhone::ProviderProfiles(m) => { self.stores.machines.apply_provider_profiles(machine, m); self.stores.machines.fetches.answered(machine, Fetch::ProviderProfiles, self.now); - // CDX-062: provider profiles are never persisted. + // CDX-062: provider profiles are never written to disk — the + // machines slice strips them when it serializes — but this + // persist is what tells the views the slice changed. + r.persist(StoreId::Machines); } BridgeToPhone::CredentialsAck(m) => { self.stores.ui.apply_credentials_ack( @@ -1326,6 +1329,36 @@ mod tests { ); } + /// A new profile list must reach the open providers page at once: the + /// machines persist is the views' refresh signal, even though the + /// profiles themselves are never written. + #[tokio::test] + async fn provider_profiles_refresh_the_machines_view() { + let (mut s, ts, kp) = stores().await; + s.machines.register_machine(MACHINE, "laptop", None, None, &[]); + let mut r = Router::new(&mut s, &ts, &kp, 1_000); + let out = r + .route( + MACHINE, + &BridgeToPhone::ProviderProfiles(protocol::events::ProviderProfilesMsg { + machine: MACHINE.into(), + profiles: vec![protocol::common::ProviderProfileInfo { + id: "home".into(), + agent: "opencode".into(), + label: "Home".into(), + base_url: "http://192.168.1.2:3456".into(), + models: vec![], + models_from_provider: false, + default_model: None, + has_token: true, + }], + }), + ) + .await; + assert_eq!(out.persist, vec![StoreId::Machines]); + assert_eq!(s.machines.machine(MACHINE).unwrap().provider_profiles.as_ref().unwrap()[0].id, "home"); + } + #[tokio::test] async fn option_confirmed_writes_through_to_the_session_info() { let (mut s, ts, kp) = stores().await; From cd35578544a91637d5f3b66529570190e46871af Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:05:37 +0200 Subject: [PATCH 2/9] fix: ask for an agent's model list every time a screen needs it A model list was held for five minutes, so a provider profile saved a moment ago, which adds models to its agent, did not show in the picker until the hold ran out or the app restarted. Model lists are not pushed by the bridge, so they are now asked for on every screen open; only a request still in flight is not repeated. Provider profiles, which the bridge pushes on every change, are still held for the connection. Co-Authored-By: Claude Code --- crates/client-core/src/stores/fetches.rs | 69 ++++++++++-------------- crates/client-runtime/src/dispatch.rs | 15 ++---- crates/client-runtime/src/intent.rs | 15 +++--- 3 files changed, 40 insertions(+), 59 deletions(-) diff --git a/crates/client-core/src/stores/fetches.rs b/crates/client-core/src/stores/fetches.rs index cc91cb75..e19a2eea 100644 --- a/crates/client-core/src/stores/fetches.rs +++ b/crates/client-core/src/stores/fetches.rs @@ -1,4 +1,4 @@ -//! `fetches` — which on-request answers from a bridge are still fresh, so a +//! `fetches` — which on-request answers from a bridge are still current, so a //! screen that asks again sends nothing. //! //! A bridge answers model lists and provider profiles only when asked; the @@ -6,16 +6,16 @@ //! for depends on how it can change: //! - provider profiles: the bridge pushes the new list to every phone //! whenever one changes, so an answer holds for the whole connection; -//! - a model list can change without a push (the agent learns of a new -//! model), so it holds for [`MODELS_FRESH_FOR_MS`] — and not at all after -//! the machine's credentials change ([`Fetches::forget_models`]). +//! - a model list can change without a push (a provider profile was added, +//! the agent learned of a new model), so it is asked for every time a +//! screen needs it — only a request still in flight is not repeated. //! //! A reconnect may have missed a push, so it forgets everything //! ([`Fetches::forget_all`]). In memory only: a fresh process asks again. use std::collections::BTreeMap; -/// A request whose answer is worth keeping for a while. +/// A request whose answer, or whose pending answer, is worth remembering. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum Fetch { ProviderProfiles, @@ -23,9 +23,6 @@ pub enum Fetch { Models(String), } -/// How long an agent's model list counts as current. -pub const MODELS_FRESH_FOR_MS: u64 = 5 * 60_000; - /// A request unanswered for this long may have been lost: asking again is /// allowed. pub const FETCH_RETRY_AFTER_MS: u64 = 15_000; @@ -34,8 +31,8 @@ pub const FETCH_RETRY_AFTER_MS: u64 = 15_000; enum State { /// Sent at this time (ms), no answer yet. InFlight(u64), - /// Answered at this time (ms). - Answered(u64), + /// Answered, and every change since will be pushed. + Current, } #[derive(Debug, Default, Clone, PartialEq)] @@ -45,16 +42,12 @@ pub struct Fetches { impl Fetches { /// Whether to send `fetch` to `machine` now; when it says yes it counts - /// the request as sent. No while a fresh answer is held or a request is + /// the request as sent. No while a pushed answer is held or a request is /// under [`FETCH_RETRY_AFTER_MS`] old. pub fn should_request(&mut self, machine: &str, fetch: Fetch, now: u64) -> bool { - let fresh_for = match fetch { - Fetch::ProviderProfiles => u64::MAX, - Fetch::Models(_) => MODELS_FRESH_FOR_MS, - }; let key = (machine.to_string(), fetch); let skip = match self.entries.get(&key) { - Some(State::Answered(at)) => now.saturating_sub(*at) < fresh_for, + Some(State::Current) => true, Some(State::InFlight(at)) => now.saturating_sub(*at) < FETCH_RETRY_AFTER_MS, None => false, }; @@ -64,19 +57,18 @@ impl Fetches { !skip } - /// `machine` answered `fetch` at `now`. - pub fn answered(&mut self, machine: &str, fetch: Fetch, now: u64) { - self.entries.insert((machine.to_string(), fetch), State::Answered(now)); - } - - /// Ask again next time (an answer that was an error, say). - pub fn forget(&mut self, machine: &str, fetch: Fetch) { - self.entries.remove(&(machine.to_string(), fetch)); - } - - /// `machine`'s model lists may have changed (its credentials did). - pub fn forget_models(&mut self, machine: &str) { - self.entries.retain(|(m, f), _| !(m == machine && matches!(f, Fetch::Models(_)))); + /// `machine` answered `fetch`. Only an answer the bridge keeps current + /// by pushing is held; any other is asked for again next time. + pub fn answered(&mut self, machine: &str, fetch: Fetch) { + let key = (machine.to_string(), fetch); + match key.1 { + Fetch::ProviderProfiles => { + self.entries.insert(key, State::Current); + } + Fetch::Models(_) => { + self.entries.remove(&key); + } + } } /// A new connection: pushes may have been missed while away. @@ -95,8 +87,8 @@ mod tests { assert!(f.should_request("m", Fetch::ProviderProfiles, 0)); assert!(!f.should_request("m", Fetch::ProviderProfiles, 1_000), "in flight"); assert!(f.should_request("m", Fetch::ProviderProfiles, FETCH_RETRY_AFTER_MS), "presumed lost"); - f.answered("m", Fetch::ProviderProfiles, FETCH_RETRY_AFTER_MS); - assert!(!f.should_request("m", Fetch::ProviderProfiles, 100 * MODELS_FRESH_FOR_MS), "pushed on change"); + f.answered("m", Fetch::ProviderProfiles); + assert!(!f.should_request("m", Fetch::ProviderProfiles, u64::MAX), "pushed on change"); assert!(f.should_request("other", Fetch::ProviderProfiles, 0), "per machine"); f.forget_all(); @@ -104,18 +96,13 @@ mod tests { } #[test] - fn a_model_list_goes_stale_and_new_credentials_retire_it() { + fn a_model_list_is_asked_for_again_once_answered() { let mut f = Fetches::default(); let claude = || Fetch::Models("claude".into()); - f.answered("m", claude(), 0); - f.answered("m", Fetch::ProviderProfiles, 0); - assert!(!f.should_request("m", claude(), MODELS_FRESH_FOR_MS - 1)); - assert!(f.should_request("m", claude(), MODELS_FRESH_FOR_MS), "stale"); - assert!(f.should_request("m", Fetch::Models("opencode".into()), 0), "per agent"); - - f.answered("m", claude(), 0); - f.forget_models("m"); assert!(f.should_request("m", claude(), 0)); - assert!(!f.should_request("m", Fetch::ProviderProfiles, 0), "profiles are kept"); + assert!(!f.should_request("m", claude(), 1_000), "in flight"); + assert!(f.should_request("m", Fetch::Models("opencode".into()), 1_000), "per agent"); + f.answered("m", claude()); + assert!(f.should_request("m", claude(), 1_001), "answered: the next screen asks again"); } } diff --git a/crates/client-runtime/src/dispatch.rs b/crates/client-runtime/src/dispatch.rs index bfec6adf..3ccfbc8e 100644 --- a/crates/client-runtime/src/dispatch.rs +++ b/crates/client-runtime/src/dispatch.rs @@ -339,13 +339,10 @@ impl<'a> Router<'a> { // --- slice C: machines-slice updates + fire-and-answer acks --- BridgeToPhone::Models(m) => { self.stores.machines.apply_models(machine, m); - // An empty list comes with the reason: ask again next time. - let fetch = Fetch::Models(m.agent.clone()); - if m.models.is_empty() { - self.stores.machines.fetches.forget(machine, fetch); - } else { - self.stores.machines.fetches.answered(machine, fetch, self.now); - } + self.stores + .machines + .fetches + .answered(machine, Fetch::Models(m.agent.clone())); r.persist(StoreId::Machines); } BridgeToPhone::Usage(m) => { @@ -407,7 +404,7 @@ impl<'a> Router<'a> { } BridgeToPhone::ProviderProfiles(m) => { self.stores.machines.apply_provider_profiles(machine, m); - self.stores.machines.fetches.answered(machine, Fetch::ProviderProfiles, self.now); + self.stores.machines.fetches.answered(machine, Fetch::ProviderProfiles); // CDX-062: provider profiles are never written to disk — the // machines slice strips them when it serializes — but this // persist is what tells the views the slice changed. @@ -430,8 +427,6 @@ impl<'a> Router<'a> { m.agent.as_deref(), &m.credentials, ); - // New credentials can change what the agents list. - self.stores.machines.fetches.forget_models(machine); r.persist(StoreId::Machines); } } diff --git a/crates/client-runtime/src/intent.rs b/crates/client-runtime/src/intent.rs index 533c9302..8165bf66 100644 --- a/crates/client-runtime/src/intent.rs +++ b/crates/client-runtime/src/intent.rs @@ -1465,9 +1465,10 @@ mod tests { )); } - /// Screens ask each time they open; only what is not fresh goes out. + /// Screens ask each time they open; the profiles, which the bridge + /// pushes on every change, are not asked for twice. #[tokio::test] - async fn screens_reopening_send_only_the_requests_whose_answers_are_not_fresh() { + async fn screens_reopening_ask_again_for_everything_not_pushed() { let (mut s, kp) = stores().await; let mut sent = 0; let mut open_screen = |s: &mut CoreStores, now: u64| { @@ -1481,13 +1482,11 @@ mod tests { }; assert_eq!(open_screen(&mut s, 0), 2, "first open asks for both"); assert_eq!(open_screen(&mut s, 1_000), 0, "both still in flight"); - s.machines.fetches.answered("m", Fetch::ProviderProfiles, 2_000); - s.machines.fetches.answered("m", Fetch::Models("claude".into()), 2_000); - assert_eq!(open_screen(&mut s, 60_000), 0, "both answered and fresh"); - let later = 2_000 + client_core::stores::fetches::MODELS_FRESH_FOR_MS; - assert_eq!(open_screen(&mut s, later), 1, "the model list went stale; profiles are pushed"); + s.machines.fetches.answered("m", Fetch::ProviderProfiles); + s.machines.fetches.answered("m", Fetch::Models("claude".into())); + assert_eq!(open_screen(&mut s, 3_000), 1, "the model list is asked for again; profiles are pushed"); s.machines.fetches.forget_all(); - assert_eq!(open_screen(&mut s, later + 1), 2, "a reconnect asks again"); + assert_eq!(open_screen(&mut s, 4_000), 2, "a reconnect asks again"); } #[tokio::test] From f9acb09bd29dbb6077a032577196d70bf37f4cc8 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:14:08 +0200 Subject: [PATCH 3/9] feat: keep the provider a gateway routes each profile model to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A profile's models were stored as an id and a label, so a gateway's "OpenCode Go/deepseek-v4.1-flash" showed under the profile's name alone and the upstream provider was lost. A model read from an endpoint now keeps the provider before the first slash and the context window, when the endpoint says. Phones group such a model under "profile · provider" and name it without the provider the group already shows; OpenCode's own list groups its profile models the same way, and OpenCode is given the context window so it can compact before a model overflows. Co-Authored-By: Claude Code --- .../main/java/uniffi/client_ffi/client_ffi.kt | 10 +++- .../com/codedeck/plus/ui/DesignFixtures.kt | 18 +++---- .../ui/components/ModelPickerOptionsTest.kt | 6 +-- crates/bridge-core/src/settings.rs | 2 +- crates/bridge-core/tests/settings.rs | 2 +- crates/client-ffi/src/intent.rs | 4 +- crates/client-ffi/src/views.rs | 51 ++++++++++++++++--- crates/protocol/fixtures/corpus.json | 2 +- crates/protocol/src/common.rs | 11 +++- docs/PROTOCOL.md | 7 ++- .../__tests__/opencodeProviders.test.ts | 12 ++++- .../agent-host/src/drivers/opencode/driver.ts | 8 ++- .../src/drivers/opencode/providers.ts | 22 +++++++- packages/agent-host/src/generated/protocol.ts | 17 +++++++ packages/agent-host/src/host/host.ts | 12 ++++- packages/agent-host/src/sdk/types.ts | 1 + 16 files changed, 152 insertions(+), 33 deletions(-) diff --git a/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt b/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt index 7897d0b9..d6567eb0 100644 --- a/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt +++ b/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt @@ -6114,6 +6114,11 @@ data class UniffiModelEntry ( * Who serves the model, when the agent says. */ var `provider`: kotlin.String? + , + /** + * How many tokens the model takes in, when known. + */ + var `contextWindow`: kotlin.UInt? ){ @@ -6133,19 +6138,22 @@ public object FfiConverterTypeUniffiModelEntry: FfiConverterRustBuffer Option<(String, String, String)> { } fn listed(ids: &[&str]) -> HostMessage { - HostMessage::ProviderModels { models: ids.iter().map(|id| ProviderModel { id: (*id).into(), label: None }).collect() } + HostMessage::ProviderModels { models: ids.iter().map(|id| ProviderModel { id: (*id).into(), ..Default::default() }).collect() } } fn stored_profile(rig: &mut Rig) -> Option { diff --git a/crates/client-ffi/src/intent.rs b/crates/client-ffi/src/intent.rs index 41740b30..043c6345 100644 --- a/crates/client-ffi/src/intent.rs +++ b/crates/client-ffi/src/intent.rs @@ -527,7 +527,7 @@ impl TryFrom for Intent { label: p.label, base_url: p.base_url, auth_token: p.auth_token.into(), - models: p.models.into_iter().map(|m| ProviderModel { id: m.id, label: m.label }).collect(), + models: p.models.into_iter().map(|m| ProviderModel { id: m.id, label: m.label, ..Default::default() }).collect(), models_from_provider: p.models_from_provider, default_model: p.default_model, }), @@ -770,7 +770,7 @@ mod tests { label: "Kimi K3".into(), base_url: "https://api.moonshot.ai/anthropic".into(), auth_token: Tristate::Set("sk-1".into()), - models: vec![ProviderModel { id: "kimi-k3".into(), label: Some("Kimi K3".into()) }], + models: vec![ProviderModel { id: "kimi-k3".into(), label: Some("Kimi K3".into()), ..Default::default() }], models_from_provider: true, default_model: Some("kimi-k3".into()), }), diff --git a/crates/client-ffi/src/views.rs b/crates/client-ffi/src/views.rs index 3ca5c05c..ca08310c 100644 --- a/crates/client-ffi/src/views.rs +++ b/crates/client-ffi/src/views.rs @@ -328,10 +328,35 @@ pub struct UniffiModelEntry { pub label: Option, /// Who serves the model, when the agent says. pub provider: Option, + /// How many tokens the model takes in, when known. + pub context_window: Option, } fn to_uniffi_model_entries(models: &[protocol::events::ModelEntry]) -> Vec { - models.iter().map(|m| UniffiModelEntry { id: m.id.clone(), label: m.label.clone(), provider: m.provider.clone() }).collect() + models + .iter() + .map(|m| UniffiModelEntry { id: m.id.clone(), label: m.label.clone(), provider: m.provider.clone(), context_window: None }) + .collect() +} + +/// A provider profile's model, grouped under the profile — and under the +/// provider a gateway routes it to, when the endpoint names one, the same +/// group the agent's own list shows it under. Unnamed, it goes by its id +/// without that provider, which the group already shows. +fn to_uniffi_profile_model(profile: &str, m: &protocol::common::ProviderModel) -> UniffiModelEntry { + let upstream = m.provider.as_deref().filter(|p| !p.is_empty()); + let label = m.label.clone().or_else(|| { + upstream.and_then(|p| m.id.strip_prefix(p)).and_then(|rest| rest.strip_prefix('/')).map(str::to_string) + }); + UniffiModelEntry { + id: m.id.clone(), + label, + provider: Some(match upstream { + Some(p) => format!("{profile} · {p}"), + None => profile.to_string(), + }), + context_window: m.context_window, + } } /// A custom AI provider profile the bridge has stored — gated on the @@ -758,11 +783,7 @@ pub fn build_uniffi_machines_view(v: &MachinesView) -> UniffiMachinesView { agent: p.agent.clone(), label: p.label.clone(), base_url: p.base_url.clone(), - models: p - .models - .iter() - .map(|m| UniffiModelEntry { id: m.id.clone(), label: m.label.clone(), provider: Some(p.label.clone()) }) - .collect(), + models: p.models.iter().map(|m| to_uniffi_profile_model(&p.label, m)).collect(), models_from_provider: p.models_from_provider, default_model: p.default_model.clone(), has_token: p.has_token, @@ -1377,6 +1398,24 @@ mod tests { cache.as_ref().map(|c| c.entries().iter().map(|e| e.seq).collect()).unwrap_or_default() } + #[test] + fn a_profile_model_is_grouped_under_the_profile_and_its_upstream() { + let routed = protocol::common::ProviderModel { + id: "OpenCode Go/deepseek-v4.1-flash".into(), + provider: Some("OpenCode Go".into()), + context_window: Some(1_000_000), + ..Default::default() + }; + let m = to_uniffi_profile_model("CCR", &routed); + assert_eq!(m.provider.as_deref(), Some("CCR · OpenCode Go")); + assert_eq!(m.label.as_deref(), Some("deepseek-v4.1-flash"), "named without the group's provider"); + assert_eq!(m.context_window, Some(1_000_000)); + + let own = protocol::common::ProviderModel { id: "kimi-k3".into(), label: Some("Kimi K3".into()), ..Default::default() }; + let m = to_uniffi_profile_model("Moonshot", &own); + assert_eq!((m.provider.as_deref(), m.label.as_deref()), (Some("Moonshot"), Some("Kimi K3"))); + } + #[test] fn the_cache_takes_in_only_what_was_stored_since() { let mut cache = None; diff --git a/crates/protocol/fixtures/corpus.json b/crates/protocol/fixtures/corpus.json index 2ffe748c..173b4924 100644 --- a/crates/protocol/fixtures/corpus.json +++ b/crates/protocol/fixtures/corpus.json @@ -148,7 +148,7 @@ { "type": "credentials-ack", "machine": "m", "success": false, "credentials": [], "error": "not writable" }, { "type": "pair-ack", "machine": "m", "ok": true }, { "type": "pair-ack", "machine": "m", "ok": false, "reason": "bad-token", "relays": ["wss://r"], "host": "cli" }, - { "type": "provider-profiles", "machine": "m", "profiles": [{ "id": "p", "agent": "claude-code", "label": "L", "baseUrl": "https://x", "models": [{ "id": "m" }], "hasToken": true }, { "id": "q", "agent": "opencode", "label": "Q", "baseUrl": "https://y", "models": [{ "id": "a/b", "label": "B" }], "modelsFromProvider": true, "hasToken": true }] }, + { "type": "provider-profiles", "machine": "m", "profiles": [{ "id": "p", "agent": "claude-code", "label": "L", "baseUrl": "https://x", "models": [{ "id": "m" }], "hasToken": true }, { "id": "q", "agent": "opencode", "label": "Q", "baseUrl": "https://y", "models": [{ "id": "a/b", "label": "B" }, { "id": "OpenCode Go/deepseek-v4.1-flash", "label": "deepseek-v4.1-flash", "provider": "OpenCode Go", "contextWindow": 1000000 }], "modelsFromProvider": true, "hasToken": true }] }, { "type": "provider-profile-ack", "machine": "m", "profileId": "p", "success": true, "tokenValid": false } ], "rejected": [ diff --git a/crates/protocol/src/common.rs b/crates/protocol/src/common.rs index f7fe68d6..f1031cca 100644 --- a/crates/protocol/src/common.rs +++ b/crates/protocol/src/common.rs @@ -540,11 +540,20 @@ fn is_private_network_address(host: &str) -> bool { false } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +/// A model a provider profile offers. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] pub struct ProviderModel { pub id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub label: Option, + /// The provider a gateway routes the model to (`OpenCode Go` for a + /// router's `OpenCode Go/deepseek-v4.1-flash`), when the endpoint says. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + /// How many tokens the model takes in, when the endpoint says. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_window: Option, } /// The REDACTED wire shape of a stored provider profile (`hasToken` only — the diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 642a7f38..b63aa338 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -239,7 +239,10 @@ Messages: redirect is followed, and the save is refused when the list cannot be read or is empty. A `defaultModel` the list does not name is dropped. The stored profile reports the flag back, so a later save (with the token kept) reads - the list again. + the list again. A model read so keeps, when the endpoint says, the + `provider` a gateway routes it to (the part of the id before its first + `/`, which stays in the id) and its `contextWindow`; phones group such a + model under "profile · provider". - The token is checked by the profile's agent, with the smallest request on the API it speaks (`tokenValid` in the ack). - A profile stored before profiles named their agent has an empty `agent`: @@ -515,7 +518,7 @@ The bridge's ids are `b1, b2, …`; the host's are `h1, h2, …`. | `session-mcp-toggle {sessionId, name, enabled}` | `session-mcp {…}` once done, or `error` | | `check-credential {agent, credential, value}` | `credential-checked {valid?}` | | `check-provider {agent, provider, model}` | `credential-checked {valid?}`: a provider profile's token, checked on the API `agent` speaks | -| `list-provider-models {agent, baseUrl, authToken}` | `provider-models {models}` (never empty), read the way `agent` signs in; or `error` with the reason there is none | +| `list-provider-models {agent, baseUrl, authToken}` | `provider-models {models}` (never empty; each `{id, label?, provider?, contextWindow?}`), read the way `agent` signs in; or `error` with the reason there is none | | `set-providers {agent, providers}` | `ack` once an agent with `supports.providerModels` offers these profiles' models (all of its profiles, sent after `initialize` and on every change), or `error` | `AgentInfo` is the catalog entry minus credential status (the bridge adds diff --git a/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts b/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts index 37a11fa2..03219108 100644 --- a/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts +++ b/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { OpencodeClient } from '@opencode-ai/sdk/v2/client'; import { OpenCodeDriver } from '../driver'; -import { providersConfig, serverSetup } from '../providers'; +import { profileModelGroup, providersConfig, serverSetup } from '../providers'; import type { StartOpenCodeServerOptions } from '../server'; import type { ProviderBinding } from '../../../sdk/types'; @@ -37,6 +37,16 @@ describe('providersConfig', () => { expect(JSON.stringify(config)).not.toContain('tok-secret'); }); + it('names a routed model without its upstream, which its group shows, and gives OpenCode a known context window', () => { + const routed = router({ models: [{ id: 'OpenCode Go/deepseek-v4.1-flash', provider: 'OpenCode Go', contextWindow: 1_000_000 }] }); + const provider = (providersConfig([routed]).provider as Record }>)['codedeck-router']!; + expect(provider.models).toEqual({ + 'OpenCode Go/deepseek-v4.1-flash': { name: 'deepseek-v4.1-flash', limit: { context: 1_000_000, output: 0 } }, + }); + expect(profileModelGroup('CCR', routed.models[0])).toBe('CCR · OpenCode Go'); + expect(profileModelGroup('CCR', { id: 'kimi-k3' })).toBe('CCR'); + }); + it('does not restrict the providers OpenCode already has', () => { const config = providersConfig([router()]); expect(config).not.toHaveProperty('enabled_providers'); diff --git a/packages/agent-host/src/drivers/opencode/driver.ts b/packages/agent-host/src/drivers/opencode/driver.ts index bc8c3a7b..6dd038bd 100644 --- a/packages/agent-host/src/drivers/opencode/driver.ts +++ b/packages/agent-host/src/drivers/opencode/driver.ts @@ -53,7 +53,7 @@ import type { import { opencodeEventToEntries, toolCallDiffs, type OpenCodeEvent } from './adapter'; import { OpenCodeMcp, openCodeSessionMcp, toggleOpenCodeMcp } from './mcp'; import { OpenCodePlugins } from './plugins'; -import { providersFingerprint, serverSetup } from './providers'; +import { profileModelGroup, profileProviderId, providersFingerprint, serverSetup } from './providers'; import { resolveOpenCodePath, startOpenCodeServer, @@ -1185,10 +1185,14 @@ export class OpenCodeDriver implements Driver { if (error || !data) return { models: [] }; const models: ModelEntry[] = []; const contextLimits: Record = {}; + const profiles = new Map(this.providers.map((p) => [profileProviderId(p), p])); for (const provider of data.providers) { + const profile = profiles.get(provider.id); for (const model of Object.values(provider.models)) { const id = `${provider.id}/${model.id}`; - models.push({ id, label: model.name, provider: provider.name || provider.id }); + const name = provider.name || provider.id; + const group = profile ? profileModelGroup(name, profile.models.find((m) => m.id === model.id)) : name; + models.push({ id, label: model.name, provider: group }); const window = model.limit?.context ?? 0; if (window > 0) contextLimits[id] = window; } diff --git a/packages/agent-host/src/drivers/opencode/providers.ts b/packages/agent-host/src/drivers/opencode/providers.ts index e970a2a7..6ca9d948 100644 --- a/packages/agent-host/src/drivers/opencode/providers.ts +++ b/packages/agent-host/src/drivers/opencode/providers.ts @@ -22,7 +22,7 @@ */ import { createHash, randomBytes } from 'node:crypto'; import { providerApiRoot } from '../../sdk/providerModels'; -import type { ProviderBinding } from '../../sdk/types'; +import type { ProviderBinding, ProviderModel } from '../../sdk/types'; /** What a profile's provider id starts with: no OpenCode provider (models.dev * catalog or the operator's) is named so. */ @@ -51,13 +51,31 @@ export function providersConfig(profiles: ProviderBinding[], base: Record [m.id, m.label ? { name: m.label } : {}])), + models: Object.fromEntries(profile.models.map((m) => [m.id, modelConfig(m)])), }, ]), ); return { ...base, provider: { ...own, ...added } }; } +/** A profile model as OpenCode's config holds it: named without the + * upstream provider its group already shows. A known context window + * lets OpenCode compact before the model overflows; the output size is + * never listed by endpoints, so it stays 0, OpenCode's own "unknown". */ +function modelConfig(model: ProviderModel): Record { + const name = model.label ?? (model.provider && model.id.startsWith(`${model.provider}/`) ? model.id.slice(model.provider.length + 1) : undefined); + return { + ...(name ? { name } : {}), + ...(model.contextWindow ? { limit: { context: model.contextWindow, output: 0 } } : {}), + }; +} + +/** The group a profile model is listed under: the profile, and the + * provider a gateway routes the model to when the endpoint names one. */ +export function profileModelGroup(profile: string, model: ProviderModel | undefined): string { + return model?.provider ? `${profile} · ${model.provider}` : profile; +} + /** What the server is started with to serve `profiles`, and how its client * signs in. */ export interface ServerSetup { diff --git a/packages/agent-host/src/generated/protocol.ts b/packages/agent-host/src/generated/protocol.ts index c4d2f054..56bcfcc8 100644 --- a/packages/agent-host/src/generated/protocol.ts +++ b/packages/agent-host/src/generated/protocol.ts @@ -1192,16 +1192,33 @@ export type ProviderBinding_Serialize = { defaultModel?: string | null, }; +/** A model a provider profile offers. */ export type ProviderModel = ProviderModel_Serialize | ProviderModel_Deserialize; +/** A model a provider profile offers. */ export type ProviderModel_Deserialize = { id: string, label?: string | null, + /** + * The provider a gateway routes the model to (`OpenCode Go` for a + * router's `OpenCode Go/deepseek-v4.1-flash`), when the endpoint says. + */ + provider?: string | null, + /** How many tokens the model takes in, when the endpoint says. */ + contextWindow?: number | null, }; +/** A model a provider profile offers. */ export type ProviderModel_Serialize = { id: string, label?: string | null, + /** + * The provider a gateway routes the model to (`OpenCode Go` for a + * router's `OpenCode Go/deepseek-v4.1-flash`), when the endpoint says. + */ + provider?: string | null, + /** How many tokens the model takes in, when the endpoint says. */ + contextWindow?: number | null, }; export type QuestionOption = QuestionOption_Serialize | QuestionOption_Deserialize; diff --git a/packages/agent-host/src/host/host.ts b/packages/agent-host/src/host/host.ts index b359c0a5..614f0b80 100644 --- a/packages/agent-host/src/host/host.ts +++ b/packages/agent-host/src/host/host.ts @@ -238,7 +238,17 @@ export class AgentHost { const driver = this.driver(agent); if (!driver.listProviderModels) throw new Error(`${driver.info().displayName} cannot read a provider's models`); const models = await driver.listProviderModels(baseUrl, authToken); - return { kind: 'provider-models', payload: { models: models.map((m) => ({ id: m.id, ...(m.label ? { label: m.label } : {}) })) } }; + return { + kind: 'provider-models', + payload: { + models: models.map((m) => ({ + id: m.id, + ...(m.label ? { label: m.label } : {}), + ...(m.provider ? { provider: m.provider } : {}), + ...(m.contextWindow !== undefined ? { contextWindow: m.contextWindow } : {}), + })), + }, + }; } case 'set-providers': { const driver = this.driver(message.payload.agent); diff --git a/packages/agent-host/src/sdk/types.ts b/packages/agent-host/src/sdk/types.ts index 97c3022d..e5fcfb8e 100644 --- a/packages/agent-host/src/sdk/types.ts +++ b/packages/agent-host/src/sdk/types.ts @@ -12,6 +12,7 @@ export type BridgeFrame = G.Frame_Serialize; export type BridgeMessage = G.BridgeMessage_Serialize; export type StartSession = G.StartSession_Serialize; export type ProviderBinding = G.ProviderBinding_Serialize; +export type ProviderModel = G.ProviderModel_Serialize; export type SelectOutcome = G.SelectOutcome; export type PlanOutcome = G.PlanOutcome_Serialize; export type QuestionOutcome = G.QuestionOutcome; From 75aec7c138acc425f722cce4dd512d451d9ffb3e Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:14:54 +0200 Subject: [PATCH 4/9] fix: wait for OpenCode's restart instead of answering with no models While the server was restarted for a changed profile list, the driver had no client until the old server had closed: a model list asked for in that window came back empty, and a session started in it could start a second server. The replacement is now the pending client from the start, closing the old server as its first step. Co-Authored-By: Claude Code --- .../__tests__/opencodeProviders.test.ts | 30 +++++++++++++++++++ .../agent-host/src/drivers/opencode/driver.ts | 10 +++---- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts b/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts index 03219108..063ddc13 100644 --- a/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts +++ b/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts @@ -126,6 +126,36 @@ describe('an OpenCode driver given provider profiles', () => { expect(closed).toEqual([1, 2, 3]); }); + it('has a model list asked for during the restart wait for the new server', async () => { + let release!: () => void; + const closing = new Promise((resolve) => (release = resolve)); + let starts = 0; + const client = (n: number) => + ({ + config: { + providers: async () => ({ data: { providers: [{ id: 'zen', name: 'Zen', models: { [`m${n}`]: { id: `m${n}`, name: `M${n}` } } }], default: {} } }), + get: async () => ({ data: {} }), + }, + }) as unknown as OpencodeClient; + const driver = await OpenCodeDriver.create({ + autoStart: true, + binaryPath: process.execPath, + log: () => {}, + startServer: async () => { + const n = ++starts; + return { url: `http://127.0.0.1:${4100 + n}`, pid: n, exited: new Promise(() => {}), close: () => closing }; + }, + connect: () => client(starts), + }); + const restart = driver.setProviders([router()]); + await new Promise((resolve) => setTimeout(resolve, 0)); + const listed = driver.listModels(); + release(); + await restart; + expect((await listed).models.map((m) => m.id)).toEqual(['zen/m2']); + expect(starts).toBe(2); + }); + it('refuses them for a server it does not start', async () => { const driver = await OpenCodeDriver.create({ serverUrl: 'http://127.0.0.1:4096', log: () => {}, connect: () => ({}) as OpencodeClient }); expect(driver.info().supports?.providerModels).toBe(false); diff --git a/packages/agent-host/src/drivers/opencode/driver.ts b/packages/agent-host/src/drivers/opencode/driver.ts index 6dd038bd..3574ec5b 100644 --- a/packages/agent-host/src/drivers/opencode/driver.ts +++ b/packages/agent-host/src/drivers/opencode/driver.ts @@ -1076,9 +1076,11 @@ export class OpenCodeDriver implements Driver { } /** Find (or install) `opencode`, start its server and connect — sessions - * started meanwhile wait on the same promise. */ - private launch(): void { + * and model lists asked for meanwhile wait on the same promise. `first` + * runs before the start (closing the server being replaced). */ + private launch(first?: () => Promise): void { const attempt = (async (): Promise => { + await first?.(); this.bin ??= await this.options.installOpenCode!(); return this.startServer(this.bin); })(); @@ -1117,9 +1119,7 @@ export class OpenCodeDriver implements Driver { this.options.log('[opencode] restarting the server: its provider profiles changed'); const old = this.server; this.server = null; - this.clientPromise = null; - await old.close(); - this.launch(); + this.launch(() => old.close()); await this.clientPromise; } From 9f4272bdb9434d307281f5e6decd94c28cd5c702 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:28:26 +0200 Subject: [PATCH 5/9] feat: OpenCode providers keep the name their profile was given A profile reached OpenCode as the provider "codedeck-". Its provider id is now the profile's name in lower case ("CCR" is "ccr"), shown as the user wrote it. A name one of OpenCode's own providers has (built in, in the operator's config, or signed in to) is never shadowed, and of two profiles with the same name the older one keeps it. The agent host answers set-providers with the profiles it left out and why. A save now waits on that answer: a profile left out is not saved, the earlier version stands, and the ack carries the reason. A stored profile the agent leaves out later carries the reason in the profile list, and the providers page shows it. Co-Authored-By: Claude Code --- .../plus/ui/screens/ProvidersScreen.kt | 2 + .../main/java/uniffi/client_ffi/client_ffi.kt | 10 +- .../com/codedeck/plus/ui/DesignFixtures.kt | 5 +- ..._DesignSnapshotTest_providers_opencode.png | Bin 72754 -> 74399 bytes crates/agent-protocol/src/codec.rs | 3 +- crates/agent-protocol/src/messages.rs | 19 ++- crates/bridge-core/src/engine/host.rs | 14 ++- crates/bridge-core/src/engine/mod.rs | 8 ++ crates/bridge-core/src/engine/settings.rs | 113 ++++++++++++++---- crates/bridge-core/src/settings.rs | 8 +- crates/bridge-core/tests/settings.rs | 78 +++++++++++- crates/client-core/src/stores/machines.rs | 1 + crates/client-ffi/src/views.rs | 3 + crates/client-runtime/src/dispatch.rs | 1 + crates/protocol/fixtures/corpus.json | 2 +- crates/protocol/src/common.rs | 4 + docs/PROTOCOL.md | 8 +- .../__tests__/opencodeProviders.test.ts | 59 ++++++++- .../agent-host/src/drivers/opencode/driver.ts | 51 ++++++-- .../src/drivers/opencode/providers.ts | 53 ++++++-- packages/agent-host/src/generated/protocol.ts | 37 +++++- packages/agent-host/src/host/host.ts | 4 +- packages/agent-host/src/sdk/driver.ts | 10 +- packages/agent-host/src/sdk/types.ts | 1 + 24 files changed, 418 insertions(+), 76 deletions(-) diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/ProvidersScreen.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/ProvidersScreen.kt index 1e0b2e1e..428dcf6a 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/ProvidersScreen.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/ProvidersScreen.kt @@ -262,6 +262,8 @@ private fun ProfileSummary(p: UniffiProviderProfileInfo) { color = Tokens.TextMuted, fontSize = Tokens.TextSm, ) + // Why the agent does not offer its models (its name is taken, say). + p.error?.let { Text(it, color = Tokens.Danger, fontSize = Tokens.TextSm) } } /** The last save's outcome, in words. */ diff --git a/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt b/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt index d6567eb0..a5ac35d5 100644 --- a/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt +++ b/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt @@ -6811,6 +6811,11 @@ data class UniffiProviderProfileInfo ( var `defaultModel`: kotlin.String? , var `hasToken`: kotlin.Boolean + , + /** + * Why the agent does not offer this profile's models, when it does not. + */ + var `error`: kotlin.String? ){ @@ -6835,6 +6840,7 @@ public object FfiConverterTypeUniffiProviderProfileInfo: FfiConverterRustBuffer< FfiConverterBoolean.read(buf), FfiConverterOptionalString.read(buf), FfiConverterBoolean.read(buf), + FfiConverterOptionalString.read(buf), ) } @@ -6846,7 +6852,8 @@ public object FfiConverterTypeUniffiProviderProfileInfo: FfiConverterRustBuffer< FfiConverterSequenceTypeUniffiModelEntry.allocationSize(value.`models`) + FfiConverterBoolean.allocationSize(value.`modelsFromProvider`) + FfiConverterOptionalString.allocationSize(value.`defaultModel`) + - FfiConverterBoolean.allocationSize(value.`hasToken`) + FfiConverterBoolean.allocationSize(value.`hasToken`) + + FfiConverterOptionalString.allocationSize(value.`error`) ) override fun write(value: UniffiProviderProfileInfo, buf: ByteBuffer) { @@ -6858,6 +6865,7 @@ public object FfiConverterTypeUniffiProviderProfileInfo: FfiConverterRustBuffer< FfiConverterBoolean.write(value.`modelsFromProvider`, buf) FfiConverterOptionalString.write(value.`defaultModel`, buf) FfiConverterBoolean.write(value.`hasToken`, buf) + FfiConverterOptionalString.write(value.`error`, buf) } } diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignFixtures.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignFixtures.kt index d9fc7712..44101adf 100644 --- a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignFixtures.kt +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignFixtures.kt @@ -96,17 +96,18 @@ internal object DesignFixtures { UniffiModelEntry("Z.ai/glm-5.3-flash", "glm-5.3-flash", "Home gateway · Z.ai", 200_000u), UniffiModelEntry("qwen3.8-coder", null, "Home gateway", null), ), - modelsFromProvider = true, defaultModel = "OpenCode Go/deepseek-v4.1-flash", hasToken = true, + modelsFromProvider = true, defaultModel = "OpenCode Go/deepseek-v4.1-flash", hasToken = true, error = null, ), UniffiProviderProfileInfo( id = "openrouter", agent = "opencode", label = "OpenRouter", baseUrl = "https://openrouter.ai/api", models = (1..42).map { UniffiModelEntry("vendor/model-$it", "model-$it", "OpenRouter · vendor", null) }, modelsFromProvider = true, defaultModel = null, hasToken = false, + error = "OpenCode already has a provider called 'openrouter'. Give this profile another name.", ), UniffiProviderProfileInfo( id = "kimi-k3", agent = "", label = "Kimi K3", baseUrl = "https://api.moonshot.ai/anthropic", models = listOf(UniffiModelEntry("kimi-k3", "Kimi K3", "Kimi K3", null)), - modelsFromProvider = false, defaultModel = "kimi-k3", hasToken = true, + modelsFromProvider = false, defaultModel = "kimi-k3", hasToken = true, error = null, ), ) diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_opencode.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_opencode.png index b1c5ff50ed75c8662561b4626ba805c8b1b3998d..55130c51d4237532f30d9ccd727e81732c3d36b9 100644 GIT binary patch delta 45888 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 delta 43992 zcmbrmWmr`07xsM%NJtJLjPwAaf~1s`;Lu3uEg=n3(hUx1R)>hPl3aOlq~IK(~xk!5C&F*h60 zf$V|Cdd)h+iQN6M+`6$(^_(%yr-o%aTZW|w%yGlypj|wBV4&IqCB;vHyo@-kFIeu% zs-Q5f=R1hVHBVG#E}QP~!Ftr`;6 ^z^jYq4%0sX3BElJFCM&sQV|PUA6w=AgI3M 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 Rzm9>A{#7GXv6fBn{{kisW(NQO diff --git a/crates/agent-protocol/src/codec.rs b/crates/agent-protocol/src/codec.rs index cb043e4b..f970703f 100644 --- a/crates/agent-protocol/src/codec.rs +++ b/crates/agent-protocol/src/codec.rs @@ -96,7 +96,8 @@ impl HostMessage { | Self::McpServers { .. } | Self::SessionMcp { .. } | Self::CredentialChecked { .. } - | Self::ProviderModels { .. } => true, + | Self::ProviderModels { .. } + | Self::ProvidersSet { .. } => true, Self::SessionEvent { .. } | Self::RequestPermission(_) | Self::AskQuestion(_) diff --git a/crates/agent-protocol/src/messages.rs b/crates/agent-protocol/src/messages.rs index e2db4d1c..6f26d665 100644 --- a/crates/agent-protocol/src/messages.rs +++ b/crates/agent-protocol/src/messages.rs @@ -130,6 +130,14 @@ pub struct ProviderBinding { pub default_model: Option, } +/// A provider profile an agent left out of `set-providers`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +pub struct RefusedProvider { + /// The profile's id. + pub id: String, + pub reason: String, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct StartSession { @@ -415,9 +423,11 @@ pub enum BridgeMessage { auth_token: Secret, }, /// The provider profiles of an agent whose catalog entry `supports` - /// `providerModels`, all of them: sent after `initialize` and whenever - /// one changes. The agent offers their models beside its own. Reply: - /// `ack`. + /// `providerModels`, all of them, oldest saved first: sent after + /// `initialize` and whenever one changes. The agent offers their models + /// beside its own. One it cannot add (its name is taken by one of the + /// agent's own providers, or by an earlier profile) is left out. Reply: + /// `providers-set`. SetProviders { agent: String, providers: Vec, @@ -494,6 +504,9 @@ pub enum HostMessage { }, /// Reply to `list-provider-models`: never empty. ProviderModels { models: Vec }, + /// Reply to `set-providers`: the profiles left out, with the reason in + /// words for a person; every other one is offered. + ProvidersSet { refused: Vec }, /// A notification: no frame id, no reply. SessionEvent { session_id: String, diff --git a/crates/bridge-core/src/engine/host.rs b/crates/bridge-core/src/engine/host.rs index 79b57e9d..3e39d5d7 100644 --- a/crates/bridge-core/src/engine/host.rs +++ b/crates/bridge-core/src/engine/host.rs @@ -59,7 +59,8 @@ pub(crate) enum HostCall { CheckProvider { ticket: u64 }, /// A provider's model list, for the save waiting on it. ListProviderModels { ticket: u64 }, - SetProviders { agent: String }, + /// An agent's profiles, for the save waiting on them when there is one. + SetProviders { agent: String, save: Option }, DeleteConversation(ConversationDelete), } @@ -254,10 +255,13 @@ impl Engine { }; self.on_provider_models_fetched(ticket, models); } - HostCall::SetProviders { agent } => { - if let Err(err) = result { - log::warn!("[Engine] {agent} did not take its provider profiles: {err}"); - } + HostCall::SetProviders { agent, save } => { + let refused = match result { + Ok(HostMessage::ProvidersSet { refused }) => Ok(refused), + Ok(_) => Err("the agent host answered with the wrong reply".to_string()), + Err(err) => Err(err), + }; + self.on_providers_set(&agent, save, refused); } HostCall::DeleteConversation(delete) => match result { Ok(_) => log::info!("[Engine] Conversation {} of {} deleted", delete.conversation_id, delete.session_id), diff --git a/crates/bridge-core/src/engine/mod.rs b/crates/bridge-core/src/engine/mod.rs index a63a6dbe..9269dd0b 100644 --- a/crates/bridge-core/src/engine/mod.rs +++ b/crates/bridge-core/src/engine/mod.rs @@ -151,6 +151,12 @@ pub struct Engine { /// set-provider-profile waiting on the provider's model list: ticket → /// (phone, the profile to store once its models are in). model_fetches: BTreeMap, + /// set-provider-profile waiting on its agent taking the new list: + /// ticket → the save, undone if the agent leaves the profile out. + profile_saves: BTreeMap, + /// Why an agent leaves a profile out of its models, by profile id, from + /// its last answer to `set-providers`. Not persisted. + profile_refusals: BTreeMap, next_ticket: u64, sync: SyncServer, pairing: Option, @@ -195,6 +201,8 @@ impl Engine { credential_acks: BTreeMap::new(), profile_acks: BTreeMap::new(), model_fetches: BTreeMap::new(), + profile_saves: BTreeMap::new(), + profile_refusals: BTreeMap::new(), next_ticket: 0, sync, pairing: None, diff --git a/crates/bridge-core/src/engine/settings.rs b/crates/bridge-core/src/engine/settings.rs index d9bcae1b..d0349b81 100644 --- a/crates/bridge-core/src/engine/settings.rs +++ b/crates/bridge-core/src/engine/settings.rs @@ -4,7 +4,7 @@ //! starts, never logged and never sent back — a phone learns only whether a //! value is set and whether its provider accepted it. -use agent_protocol::{AgentInfo, BridgeMessage, ProviderBinding, Secret}; +use agent_protocol::{AgentInfo, BridgeMessage, ProviderBinding, RefusedProvider, Secret}; use protocol::commands::{SetCredentialsMsg, SetProviderProfileMsg}; use protocol::common::{is_valid_provider_base_url, CredentialStatus, ProviderModel, PROVIDER_BASE_URL_ERROR}; use protocol::events::{BridgeToPhone, CredentialsAckMsg, ProviderProfileAckMsg, ProviderProfilesMsg}; @@ -14,6 +14,14 @@ use super::{Engine, HostCall}; use crate::io::store_keys; use crate::settings::{ProviderProfile, StoredProfiles, GITHUB_PAT, GITHUB_PAT_LABEL}; +/// A saved provider profile waiting on its agent to take it. +pub(crate) struct ProfileSave { + phone: String, + profile: ProviderProfile, + /// What the profile was before, restored if the agent leaves it out. + previous: Option, +} + /// A `credentials-ack` waiting on credential checks. pub(crate) struct CredentialAck { phone: String, @@ -207,26 +215,25 @@ impl Engine { pub(super) fn provider_profiles_msg(&self) -> BridgeToPhone { BridgeToPhone::ProviderProfiles(ProviderProfilesMsg { machine: self.config.machine.clone(), - profiles: self.profiles.values().map(ProviderProfile::redacted).collect(), + profiles: self.profiles.values().map(|p| p.redacted(self.profile_refusals.get(&p.id).map(String::as_str))).collect(), }) } /// Hand an agent whose catalog entry `supports.provider_models` its /// profiles — every usable one, so a deleted or emptied profile goes - /// away too. - pub(super) fn push_providers(&mut self, agent: &str) { + /// away too — oldest saved first, so of two that clash the older stays. + /// `save` is the save waiting on the answer. Whether it was sent. + pub(super) fn push_providers(&mut self, agent: &str, save: Option) -> bool { if !self.host.initialized || !self.catalog.get(agent).is_some_and(|a| a.supports.provider_models) { - return; + return false; } - let providers: Vec = self - .profiles - .values() - .filter(|p| p.agent == agent && is_valid_provider_base_url(&p.base_url)) - .filter_map(ProviderProfile::binding) - .collect(); + let mut profiles: Vec<&ProviderProfile> = + self.profiles.values().filter(|p| p.agent == agent && is_valid_provider_base_url(&p.base_url)).collect(); + profiles.sort_by(|a, b| (&a.updated_at, &a.id).cmp(&(&b.updated_at, &b.id))); + let providers: Vec = profiles.into_iter().filter_map(ProviderProfile::binding).collect(); log::info!("[Engine] {agent} gets {} provider profile(s)", providers.len()); let message = BridgeMessage::SetProviders { agent: agent.to_string(), providers }; - self.call(HostCall::SetProviders { agent: agent.to_string() }, message); + self.call(HostCall::SetProviders { agent: agent.to_string(), save }, message).is_some() } /// Every agent's profiles, once the host is (back) up. @@ -234,8 +241,59 @@ impl Engine { let agents: Vec = self.catalog.all().iter().filter(|a| a.supports.provider_models).map(|a| a.id.clone()).collect(); for agent in agents { - self.push_providers(&agent); + self.push_providers(&agent, None); + } + } + + /// `agent` answered `set-providers`: it offers every profile but the + /// `refused` ones. A save waiting on the answer whose profile was left + /// out is undone and refused with the agent's reason; any other goes on + /// to its token check. + pub(super) fn on_providers_set(&mut self, agent: &str, save: Option, refused: Result, String>) { + let save = save.and_then(|ticket| self.profile_saves.remove(&ticket)); + let refused = match refused { + Ok(refused) => refused, + Err(err) => { + log::warn!("[Engine] {agent} did not take its provider profiles: {err}"); + if let Some(save) = save { + self.check_saved_profile(&save.phone, &save.profile); + } + return; + } + }; + let before = self.profile_refusals.clone(); + let profiles = &self.profiles; + self.profile_refusals.retain(|id, _| profiles.get(id).is_some_and(|p| p.agent != agent)); + for r in &refused { + log::info!("[Engine] {agent} leaves provider profile '{}' out: {}", r.id, r.reason); + self.profile_refusals.insert(r.id.clone(), r.reason.clone()); + } + let Some(save) = save else { + if self.profile_refusals != before { + let list = self.provider_profiles_msg(); + self.publish_all(list); + } + return; + }; + let id = save.profile.id.clone(); + let Some(reason) = refused.into_iter().find(|r| r.id == id).map(|r| r.reason) else { + return self.check_saved_profile(&save.phone, &save.profile); + }; + let moved_from = save.previous.as_ref().map(|p| p.agent.clone()).filter(|a| a != agent); + let mut next = self.profiles.clone(); + match save.previous { + Some(previous) => next.insert(id.clone(), previous), + None => next.remove(&id), + }; + self.profile_refusals.remove(&id); + if let Err(err) = self.store_profiles(next) { + log::error!("[Engine] Could not undo the save of provider profile '{id}': {err}"); + } + self.push_providers(agent, None); + if let Some(moved_from) = moved_from { + self.push_providers(&moved_from, None); } + self.send_profile_ack(&save.phone, &id, Err(reason)); } /// Create, update or delete one profile. An upsert whose models come @@ -246,8 +304,9 @@ impl Engine { let id = m.profile_id; log::info!("[Engine] set-provider-profile '{id}' from {}...", phone.get(..8).unwrap_or(phone)); // The newest write of a profile is the one that stands: a model list - // still on its way for an older one is dropped. + // or an agent's answer still on its way for an older one is dropped. self.model_fetches.retain(|_, (_, pending)| pending.id != id); + self.profile_saves.retain(|_, pending| pending.profile.id != id); let Some(write) = m.profile else { // Deleting is never refused, whatever the stored URL: a profile // that needs fixing must stay removable. Sessions bound to it @@ -259,9 +318,10 @@ impl Engine { return self.send_profile_ack(phone, &id, Err(err)); } log::info!("[Engine] Provider profile '{id}' deleted (existed={})", removed.is_some()); + self.profile_refusals.remove(&id); self.send_profile_ack(phone, &id, Ok(None)); if let Some(removed) = removed { - self.push_providers(&removed.agent); + self.push_providers(&removed.agent, None); } return; }; @@ -341,8 +401,9 @@ impl Engine { } } - /// Store an upserted profile, hand it to its agent (and take it from the - /// agent it was for before), then have its token checked before acking. + /// Store an upserted profile and hand it to its agent (taking it from + /// the agent it was for before); once that agent has taken it, have its + /// token checked before acking. fn save_profile(&mut self, phone: &str, profile: ProviderProfile) { let id = profile.id.clone(); let mut next = self.profiles.clone(); @@ -359,10 +420,22 @@ impl Engine { if profile.models_from_provider { " from the provider" } else { "" }, profile.auth_token.is_some() ); - self.push_providers(&profile.agent); - if let Some(previous) = previous.filter(|p| p.agent != profile.agent) { - self.push_providers(&previous.agent); + if let Some(moved) = previous.as_ref().filter(|p| p.agent != profile.agent) { + let agent = moved.agent.clone(); + self.push_providers(&agent, None); } + self.next_ticket += 1; + let ticket = self.next_ticket; + if self.push_providers(&profile.agent, Some(ticket)) { + self.profile_saves.insert(ticket, ProfileSave { phone: phone.to_string(), profile, previous }); + } else { + self.check_saved_profile(phone, &profile); + } + } + + /// Have a saved profile's token checked by its agent, then ack. + fn check_saved_profile(&mut self, phone: &str, profile: &ProviderProfile) { + let id = profile.id.clone(); let check = profile.binding().zip(profile.fallback_model().map(str::to_string)); let Some((provider, model)) = check else { return self.send_profile_ack(phone, &id, Ok(None)); diff --git a/crates/bridge-core/src/settings.rs b/crates/bridge-core/src/settings.rs index feb68b4e..1a6b21c0 100644 --- a/crates/bridge-core/src/settings.rs +++ b/crates/bridge-core/src/settings.rs @@ -96,8 +96,9 @@ impl ProviderProfile { }) } - /// The phone's view: whether a token is set, never the token. - pub fn redacted(&self) -> ProviderProfileInfo { + /// The phone's view: whether a token is set, never the token; and why + /// its agent does not offer it, when it does not. + pub fn redacted(&self, error: Option<&str>) -> ProviderProfileInfo { ProviderProfileInfo { id: self.id.clone(), agent: self.agent.clone(), @@ -107,6 +108,7 @@ impl ProviderProfile { models_from_provider: self.models_from_provider, default_model: self.default_model.clone(), has_token: self.auth_token.is_some(), + error: error.map(str::to_string), } } } @@ -166,7 +168,7 @@ mod tests { default_model: None, updated_at: None, }; - let info = profile.redacted(); + let info = profile.redacted(None); assert!(info.has_token); assert!(!serde_json::to_string(&info).unwrap().contains("\"t\"")); assert_eq!(profile.fallback_model(), Some("m1")); diff --git a/crates/bridge-core/tests/settings.rs b/crates/bridge-core/tests/settings.rs index e1839eca..4dfad186 100644 --- a/crates/bridge-core/tests/settings.rs +++ b/crates/bridge-core/tests/settings.rs @@ -3,7 +3,7 @@ mod support; -use agent_protocol::{BridgeMessage, HostMessage, SessionEvent}; +use agent_protocol::{BridgeMessage, HostMessage, RefusedProvider, SessionEvent}; use bridge_core::ports::memory::{MemoryStore, MemoryTranscripts}; use bridge_core::ports::Transcripts as _; use bridge_core::{Effect, Input, PairingCloseReason, Via}; @@ -433,6 +433,82 @@ fn an_agent_that_adds_profile_models_gets_its_profiles_whenever_they_change() { assert!(!rig.has_host_request(|m| matches!(m, BridgeMessage::SetProviders { agent, .. } if agent == "alpha"))); } +/// Answer the pending `set-providers` to delta, leaving out `refused` +/// (profile id, reason). +fn delta_takes(rig: &mut Rig, refused: &[(&str, &str)]) { + let (id, _) = rig.host_request(|m| matches!(m, BridgeMessage::SetProviders { agent, .. } if agent == "delta")); + let refused = refused.iter().map(|(id, reason)| RefusedProvider { id: (*id).into(), reason: (*reason).into() }).collect(); + rig.host_reply(&id, HostMessage::ProvidersSet { refused }); +} + +fn delta_kimi(label: &str) -> serde_json::Value { + json!({"agent":"delta","label":label,"baseUrl":"https://k.test","authToken":"t","models":[{"id":"m"}]}) +} + +#[test] +fn a_save_the_agent_leaves_out_is_undone_and_refused_with_its_reason() { + let mut rig = rig_with_providers(); + set_profile(&mut rig, delta_kimi("Home")); + assert!(!answer_token_check(&mut rig, None), "the token waits on the agent taking the profile"); + delta_takes(&mut rig, &[]); + assert!(answer_token_check(&mut rig, Some(true))); + assert_eq!(stored_profile(&mut rig).map(|p| p.label), Some("Home".into())); + + // Renamed to a name the agent already has: the save is undone. + set_profile(&mut rig, delta_kimi("DeepSeek")); + let reason = "OpenCode already has a provider called 'deepseek'. Give this profile another name."; + delta_takes(&mut rig, &[("kimi", reason)]); + assert!(!answer_token_check(&mut rig, None)); + assert_eq!(ack_error(&mut rig).as_deref(), Some(reason)); + assert!(rig.store.snapshot()["providerProfiles"].contains("\"Home\""), "the stored profile is the one before"); + let (_, again) = rig.host_request(|m| matches!(m, BridgeMessage::SetProviders { agent, .. } if agent == "delta")); + match again { + BridgeMessage::SetProviders { providers, .. } => assert_eq!(providers[0].label, "Home", "and the agent gets it back"), + _ => unreachable!(), + } + + // A new profile the agent leaves out is not stored at all. + rig.take(); + rig.send(json!({"type":"set-provider-profile","profileId":"other","profile":delta_kimi("DeepSeek")})); + delta_takes(&mut rig, &[("other", reason)]); + assert_eq!(ack_error(&mut rig).as_deref(), Some(reason)); + assert!(!rig.store.snapshot()["providerProfiles"].contains("\"other\"")); +} + +#[test] +fn a_profile_the_agent_leaves_out_later_shows_why() { + let mut rig = rig_with_providers(); + set_profile(&mut rig, delta_kimi("Home")); + delta_takes(&mut rig, &[]); + answer_token_check(&mut rig, Some(true)); + rig.take(); + // The host restarts and the agent no longer takes it. + rig.input(Input::HostDown { reason: "test".into() }); + rig.host_up_with(vec![alpha(), beta(), delta()]); + delta_takes(&mut rig, &[("kimi", "taken")]); + assert_eq!(stored_profile(&mut rig).and_then(|p| p.error).as_deref(), Some("taken")); + // Taken again: the reason goes. + set_profile(&mut rig, delta_kimi("Home 2")); + delta_takes(&mut rig, &[]); + answer_token_check(&mut rig, None); + assert_eq!(stored_profile(&mut rig).map(|p| p.error), Some(None)); +} + +#[test] +fn profiles_reach_an_agent_oldest_saved_first() { + let mut rig = rig_with_providers(); + for id in ["zeta", "alpha-1"] { + rig.send(json!({"type":"set-provider-profile","profileId":id,"profile":delta_kimi(id)})); + delta_takes(&mut rig, &[]); + answer_token_check(&mut rig, None); + rig.advance(1_000); + } + rig.take(); + rig.input(Input::HostDown { reason: "test".into() }); + rig.host_up_with(vec![alpha(), beta(), delta()]); + assert_eq!(pushed_to_delta(&mut rig), Some(vec!["zeta".to_string(), "alpha-1".to_string()])); +} + fn with_kimi() -> Rig { let mut rig = rig_with_providers(); set_profile(&mut rig, kimi(Some("tok"))); diff --git a/crates/client-core/src/stores/machines.rs b/crates/client-core/src/stores/machines.rs index cfdb1ac6..8a45bedc 100644 --- a/crates/client-core/src/stores/machines.rs +++ b/crates/client-core/src/stores/machines.rs @@ -1854,6 +1854,7 @@ mod tests { models_from_provider: false, default_model: None, has_token: true, + error: None, }], }, ); diff --git a/crates/client-ffi/src/views.rs b/crates/client-ffi/src/views.rs index ca08310c..255c5f03 100644 --- a/crates/client-ffi/src/views.rs +++ b/crates/client-ffi/src/views.rs @@ -374,6 +374,8 @@ pub struct UniffiProviderProfileInfo { pub models_from_provider: bool, pub default_model: Option, pub has_token: bool, + /// Why the agent does not offer this profile's models, when it does not. + pub error: Option, } /// One selectable value of an agent option (a mode, an effort level, a plan @@ -787,6 +789,7 @@ pub fn build_uniffi_machines_view(v: &MachinesView) -> UniffiMachinesView { models_from_provider: p.models_from_provider, default_model: p.default_model.clone(), has_token: p.has_token, + error: p.error.clone(), }) .collect(), plugins: m.plugins.iter().map(|(agent, p)| to_uniffi_agent_plugins(agent, p)).collect(), diff --git a/crates/client-runtime/src/dispatch.rs b/crates/client-runtime/src/dispatch.rs index 3ccfbc8e..a88508d4 100644 --- a/crates/client-runtime/src/dispatch.rs +++ b/crates/client-runtime/src/dispatch.rs @@ -1346,6 +1346,7 @@ mod tests { models_from_provider: false, default_model: None, has_token: true, + error: None, }], }), ) diff --git a/crates/protocol/fixtures/corpus.json b/crates/protocol/fixtures/corpus.json index 173b4924..5438f6eb 100644 --- a/crates/protocol/fixtures/corpus.json +++ b/crates/protocol/fixtures/corpus.json @@ -148,7 +148,7 @@ { "type": "credentials-ack", "machine": "m", "success": false, "credentials": [], "error": "not writable" }, { "type": "pair-ack", "machine": "m", "ok": true }, { "type": "pair-ack", "machine": "m", "ok": false, "reason": "bad-token", "relays": ["wss://r"], "host": "cli" }, - { "type": "provider-profiles", "machine": "m", "profiles": [{ "id": "p", "agent": "claude-code", "label": "L", "baseUrl": "https://x", "models": [{ "id": "m" }], "hasToken": true }, { "id": "q", "agent": "opencode", "label": "Q", "baseUrl": "https://y", "models": [{ "id": "a/b", "label": "B" }, { "id": "OpenCode Go/deepseek-v4.1-flash", "label": "deepseek-v4.1-flash", "provider": "OpenCode Go", "contextWindow": 1000000 }], "modelsFromProvider": true, "hasToken": true }] }, + { "type": "provider-profiles", "machine": "m", "profiles": [{ "id": "p", "agent": "claude-code", "label": "L", "baseUrl": "https://x", "models": [{ "id": "m" }], "hasToken": true }, { "id": "q", "agent": "opencode", "label": "Q", "baseUrl": "https://y", "models": [{ "id": "a/b", "label": "B" }, { "id": "OpenCode Go/deepseek-v4.1-flash", "label": "deepseek-v4.1-flash", "provider": "OpenCode Go", "contextWindow": 1000000 }], "modelsFromProvider": true, "hasToken": true }, { "id": "r", "agent": "opencode", "label": "DeepSeek", "baseUrl": "https://z", "models": [{ "id": "c" }], "hasToken": true, "error": "OpenCode already has a provider called 'deepseek'. Give this profile another name." }] }, { "type": "provider-profile-ack", "machine": "m", "profileId": "p", "success": true, "tokenValid": false } ], "rejected": [ diff --git a/crates/protocol/src/common.rs b/crates/protocol/src/common.rs index f1031cca..ccb9663a 100644 --- a/crates/protocol/src/common.rs +++ b/crates/protocol/src/common.rs @@ -574,6 +574,10 @@ pub struct ProviderProfileInfo { #[serde(default, skip_serializing_if = "Option::is_none")] pub default_model: Option, pub has_token: bool, + /// Why the agent does not offer this profile's models, when it does not + /// (one of its own providers has the profile's name, say). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, } // --- transcript entries --- diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index b63aa338..b3615802 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -245,6 +245,12 @@ Messages: model under "profile · provider". - The token is checked by the profile's agent, with the smallest request on the API it speaks (`tokenValid` in the ack). +- For an agent with `supports.providerModels`, a save waits on the agent + taking the new list: a profile it leaves out (its own provider, or an + older profile, has the name) is not saved — the earlier version stands — + and the ack carries the agent's reason. A stored profile the agent leaves + out later (after an operator adds a provider of that name) carries the + reason as `error` in `provider-profiles`. - A profile stored before profiles named their agent has an empty `agent`: no agent uses it until a save names one. - `provider-profiles-request` → `provider-profiles {profiles[]}` to the asking @@ -519,7 +525,7 @@ The bridge's ids are `b1, b2, …`; the host's are `h1, h2, …`. | `check-credential {agent, credential, value}` | `credential-checked {valid?}` | | `check-provider {agent, provider, model}` | `credential-checked {valid?}`: a provider profile's token, checked on the API `agent` speaks | | `list-provider-models {agent, baseUrl, authToken}` | `provider-models {models}` (never empty; each `{id, label?, provider?, contextWindow?}`), read the way `agent` signs in; or `error` with the reason there is none | -| `set-providers {agent, providers}` | `ack` once an agent with `supports.providerModels` offers these profiles' models (all of its profiles, sent after `initialize` and on every change), or `error` | +| `set-providers {agent, providers}` | `providers-set {refused: [{id, reason}]}` once an agent with `supports.providerModels` offers these profiles' models (all of its profiles, oldest saved first, sent after `initialize` and on every change) — all but the refused ones, which it cannot add (a name one of its own providers, or an earlier profile, has); or `error` | `AgentInfo` is the catalog entry minus credential status (the bridge adds that), plus `credentials[].envVar` and `unavailableReason`. diff --git a/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts b/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts index 063ddc13..70a0e42e 100644 --- a/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts +++ b/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { OpencodeClient } from '@opencode-ai/sdk/v2/client'; import { OpenCodeDriver } from '../driver'; -import { profileModelGroup, providersConfig, serverSetup } from '../providers'; +import { admitProfiles, profileModelGroup, profileProviderId, providersConfig, serverSetup } from '../providers'; import type { StartOpenCodeServerOptions } from '../server'; import type { ProviderBinding } from '../../../sdk/types'; @@ -26,7 +26,7 @@ describe('providersConfig', () => { theme: 'dark', provider: { mine: { npm: 'x' }, - 'codedeck-router': { + 'home-router': { npm: '@ai-sdk/openai-compatible', name: 'Home router', options: { baseURL: 'http://192.168.1.2:3458/v1', apiKey: '{env:CODEDECK_PROVIDER_KEY_0}' }, @@ -39,7 +39,7 @@ describe('providersConfig', () => { it('names a routed model without its upstream, which its group shows, and gives OpenCode a known context window', () => { const routed = router({ models: [{ id: 'OpenCode Go/deepseek-v4.1-flash', provider: 'OpenCode Go', contextWindow: 1_000_000 }] }); - const provider = (providersConfig([routed]).provider as Record }>)['codedeck-router']!; + const provider = (providersConfig([routed]).provider as Record }>)['home-router']!; expect(provider.models).toEqual({ 'OpenCode Go/deepseek-v4.1-flash': { name: 'deepseek-v4.1-flash', limit: { context: 1_000_000, output: 0 } }, }); @@ -47,6 +47,25 @@ describe('providersConfig', () => { expect(profileModelGroup('CCR', { id: 'kimi-k3' })).toBe('CCR'); }); + it('names each provider as the user named the profile', () => { + expect(profileProviderId(router({ label: 'CCR' }))).toBe('ccr'); + expect(profileProviderId(router({ label: 'OpenCode Go (OpenAI)' }))).toBe('opencode-go-openai'); + expect(profileProviderId(router({ label: 'Café / LAN' }))).toBe('cafe-lan'); + expect(profileProviderId(router({ label: '★', id: 'p-7' }))).toBe('p-7'); + }); + + it("leaves out a profile whose name one of OpenCode's providers, or an earlier profile, already has", () => { + const ccr = router({ id: 'a', label: 'CCR' }); + const again = router({ id: 'b', label: 'ccr' }); + const deepseek = router({ id: 'c', label: 'DeepSeek' }); + const { admitted, refused } = admitProfiles([ccr, again, deepseek], new Set(['deepseek', 'opencode'])); + expect(admitted.map((p) => p.id)).toEqual(['a']); + expect(refused).toEqual([ + { id: 'b', reason: "The provider profile 'CCR' already goes by 'ccr' in OpenCode. Give this one another name." }, + { id: 'c', reason: "OpenCode already has a provider called 'deepseek'. Give this profile another name." }, + ]); + }); + it('does not restrict the providers OpenCode already has', () => { const config = providersConfig([router()]); expect(config).not.toHaveProperty('enabled_providers'); @@ -57,10 +76,10 @@ describe('providersConfig', () => { describe('serverSetup', () => { it('passes the tokens in the environment and guards the server with a password of its own', () => { - const a = serverSetup([router(), router({ id: 'or', authToken: 'tok-2' })], {}); + const a = serverSetup([router(), router({ id: 'or', label: 'My OpenRouter', authToken: 'tok-2' })], {}); expect(a.env.CODEDECK_PROVIDER_KEY_0).toBe('tok-secret'); expect(a.env.CODEDECK_PROVIDER_KEY_1).toBe('tok-2'); - expect(JSON.parse(a.env.OPENCODE_CONFIG_CONTENT!).provider).toHaveProperty('codedeck-or'); + expect(JSON.parse(a.env.OPENCODE_CONFIG_CONTENT!).provider).toHaveProperty('my-openrouter'); const password = a.env.OPENCODE_SERVER_PASSWORD!; expect(password).toMatch(/^[0-9a-f]{48}$/); expect(a.headers.authorization).toBe(`Basic ${Buffer.from(`opencode:${password}`).toString('base64')}`); @@ -71,7 +90,7 @@ describe('serverSetup', () => { it("keeps the operator's own environment config underneath", () => { const { env } = serverSetup([router()], { OPENCODE_CONFIG_CONTENT: '{"provider":{"mine":{"npm":"x"}}}' }); - expect(Object.keys(JSON.parse(env.OPENCODE_CONFIG_CONTENT!).provider)).toEqual(['mine', 'codedeck-router']); + expect(Object.keys(JSON.parse(env.OPENCODE_CONFIG_CONTENT!).provider)).toEqual(['mine', 'home-router']); }); }); @@ -156,6 +175,34 @@ describe('an OpenCode driver given provider profiles', () => { expect(starts).toBe(2); }); + it("leaves out a profile named as one of OpenCode's providers, but never one it added itself", async () => { + const starts: StartOpenCodeServerOptions[] = []; + const ids = ['opencode', 'deepseek']; + const driver = await OpenCodeDriver.create({ + autoStart: true, + binaryPath: process.execPath, + log: () => {}, + startServer: async (options) => { + starts.push(options); + return { url: `http://127.0.0.1:${4100 + starts.length}`, pid: starts.length, exited: new Promise(() => {}), close: async () => {} }; + }, + // The running server lists what it has, the profiles it was given too. + connect: () => ({ provider: { list: async () => ({ data: { all: ids.map((id) => ({ id })), default: {}, connected: [] } }) } }) as unknown as OpencodeClient, + }); + expect(await driver.setProviders([router({ label: 'DeepSeek' })])).toEqual([ + { id: 'router', reason: "OpenCode already has a provider called 'deepseek'. Give this profile another name." }, + ]); + expect(starts).toHaveLength(1); + + expect(await driver.setProviders([router({ label: 'CCR' })])).toEqual([]); + expect(starts).toHaveLength(2); + ids.push('ccr'); + // Saved again, it keeps the name it already has. + expect(await driver.setProviders([router({ label: 'CCR', models: [{ id: 'other' }] })])).toEqual([]); + expect(starts).toHaveLength(3); + await driver.shutdown(); + }); + it('refuses them for a server it does not start', async () => { const driver = await OpenCodeDriver.create({ serverUrl: 'http://127.0.0.1:4096', log: () => {}, connect: () => ({}) as OpencodeClient }); expect(driver.info().supports?.providerModels).toBe(false); diff --git a/packages/agent-host/src/drivers/opencode/driver.ts b/packages/agent-host/src/drivers/opencode/driver.ts index 3574ec5b..ce902c01 100644 --- a/packages/agent-host/src/drivers/opencode/driver.ts +++ b/packages/agent-host/src/drivers/opencode/driver.ts @@ -44,6 +44,7 @@ import type { ModelEntry, ProviderBinding, QuestionSpec, + RefusedProvider, SessionOption, SlashCommand, StartSession, @@ -53,7 +54,7 @@ import type { import { opencodeEventToEntries, toolCallDiffs, type OpenCodeEvent } from './adapter'; import { OpenCodeMcp, openCodeSessionMcp, toggleOpenCodeMcp } from './mcp'; import { OpenCodePlugins } from './plugins'; -import { profileModelGroup, profileProviderId, providersFingerprint, serverSetup } from './providers'; +import { admitProfiles, profileModelGroup, profileProviderId, providersFingerprint, serverSetup } from './providers'; import { resolveOpenCodePath, startOpenCodeServer, @@ -990,6 +991,8 @@ export class OpenCodeDriver implements Driver { * one was started with. */ private providers: ProviderBinding[] = []; private served = providersFingerprint([]); + /** The provider ids the running server has from profiles. */ + private servedIds = new Set(); /** Provider changes are applied one at a time, in order. */ private applying: Promise = Promise.resolve(); readonly plugins: PluginManager = new OpenCodePlugins(() => this.client()); @@ -1070,6 +1073,7 @@ export class OpenCodeDriver implements Driver { } this.server = server; this.served = providersFingerprint(profiles); + this.servedIds = new Set(profiles.map(profileProviderId)); const added = profiles.length > 0 ? `, with ${profiles.length} provider profile(s)` : ''; this.options.log(`[opencode] started ${server.url} (pid ${server.pid ?? '?'})${added}`); return this.connect({ baseUrl: server.url, headers: setup.headers }); @@ -1096,31 +1100,54 @@ export class OpenCodeDriver implements Driver { ); } - /** The provider profiles to add to OpenCode's own providers. The server - * reads its config only when it starts, so a changed list restarts it: + /** The provider profiles to add to OpenCode's own providers; those whose + * name one of its providers already has are left out. The server reads + * its config only when it starts, so a changed list restarts it: * sessions on the old one end with an error and the bridge resumes them * on the new one. */ - async setProviders(providers: ProviderBinding[]): Promise { + async setProviders(providers: ProviderBinding[]): Promise { if (!this.manages) { throw new Error('OpenCode runs on a server this bridge does not start (CODEDECK_OPENCODE_SERVER_URL), so it cannot add provider profiles to it'); } - // A profile the bridge would not let a session use is not added either. - this.providers = providers.filter((p) => isValidProviderBaseUrl(p.baseUrl) && p.authToken !== '' && p.models.length > 0); - const apply = this.applying.then(() => this.applyProviders()); - this.applying = apply.catch(() => {}); - await apply; + const apply = this.applying.then(() => this.applyProviders(providers)); + this.applying = apply.then( + () => {}, + () => {}, + ); + return apply; } - private async applyProviders(): Promise { + private async applyProviders(providers: ProviderBinding[]): Promise { // A server still being installed or started is waited for: it may have - // read an older list. + // read an older list, and it says which names are taken. await this.clientPromise?.catch(() => {}); - if (this.stopped || !this.server || !this.bin || providersFingerprint(this.providers) === this.served) return; + // A profile the bridge would not let a session use is not added either. + const usable = providers.filter((p) => isValidProviderBaseUrl(p.baseUrl) && p.authToken !== '' && p.models.length > 0); + const { admitted, refused } = admitProfiles(usable, await this.ownProviderIds()); + this.providers = admitted; + if (this.stopped || !this.server || !this.bin || providersFingerprint(this.providers) === this.served) return refused; this.options.log('[opencode] restarting the server: its provider profiles changed'); const old = this.server; this.server = null; this.launch(() => old.close()); await this.clientPromise; + return refused; + } + + /** The provider ids OpenCode has of its own — built in, configured by + * the operator, signed in to — without the profiles this driver added. + * Empty when the server cannot say (nothing is refused then). */ + private async ownProviderIds(): Promise> { + try { + const client = await this.clientPromise; + if (!client) return new Set(); + const { data, error } = await client.provider.list(); + if (error || !data) throw new Error(JSON.stringify(error ?? 'no provider list')); + return new Set(data.all.map((p) => p.id).filter((id) => !this.servedIds.has(id))); + } catch (err) { + this.options.log(`[opencode] could not list its providers, so profile names are not checked: ${err instanceof Error ? err.message : String(err)}`); + return new Set(); + } } info(): AgentInfo { diff --git a/packages/agent-host/src/drivers/opencode/providers.ts b/packages/agent-host/src/drivers/opencode/providers.ts index 6ca9d948..b9704744 100644 --- a/packages/agent-host/src/drivers/opencode/providers.ts +++ b/packages/agent-host/src/drivers/opencode/providers.ts @@ -8,9 +8,10 @@ * handed over in its environment, on top of the operator's files (which are * never written): * - * - each profile is the provider `codedeck-`, named after the - * profile, so its models are `codedeck-/` and the phone shows - * them under the profile's name; + * - each profile is a provider named as the user named the profile, its + * id that name in lower case (`CCR` is `ccr`, its models `ccr/`); + * a profile whose id one of OpenCode's own providers already has, or an + * earlier profile, is left out with the reason rather than shadow it; * - each token sits in the server's environment only, referenced from the * config, never in a file; * - the server answers only with a password made for it, since its API @@ -22,17 +23,51 @@ */ import { createHash, randomBytes } from 'node:crypto'; import { providerApiRoot } from '../../sdk/providerModels'; -import type { ProviderBinding, ProviderModel } from '../../sdk/types'; +import type { ProviderBinding, ProviderModel, RefusedProvider } from '../../sdk/types'; -/** What a profile's provider id starts with: no OpenCode provider (models.dev - * catalog or the operator's) is named so. */ -export const PROFILE_PROVIDER_PREFIX = 'codedeck-'; /** The user name OpenCode's server expects with its password. */ const SERVER_USERNAME = 'opencode'; -/** The OpenCode provider id of a profile. */ +/** `name` as an OpenCode provider id: lower case, each run of anything but + * letters and digits one `-` (a `/` would split its model ids). */ +function providerIdOf(name: string): string { + return name + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +/** The OpenCode provider id of a profile: its name's, or its id's when the + * name has no letter or digit. */ export function profileProviderId(profile: ProviderBinding): string { - return `${PROFILE_PROVIDER_PREFIX}${profile.id}`; + return providerIdOf(profile.label) || providerIdOf(profile.id) || 'provider'; +} + +/** Which of `profiles` OpenCode can take, in order: one whose provider id + * is `taken` by a provider OpenCode already has, or by an earlier profile, + * is refused with the reason. */ +export function admitProfiles( + profiles: ProviderBinding[], + taken: ReadonlySet, +): { admitted: ProviderBinding[]; refused: RefusedProvider[] } { + const admitted: ProviderBinding[] = []; + const refused: RefusedProvider[] = []; + const used = new Map(); + for (const profile of profiles) { + const id = profileProviderId(profile); + const earlier = used.get(id); + if (taken.has(id)) { + refused.push({ id: profile.id, reason: `OpenCode already has a provider called '${id}'. Give this profile another name.` }); + } else if (earlier) { + refused.push({ id: profile.id, reason: `The provider profile '${earlier.label}' already goes by '${id}' in OpenCode. Give this one another name.` }); + } else { + used.set(id, profile); + admitted.push(profile); + } + } + return { admitted, refused }; } /** The variable profile `index`'s token reaches the server in. */ diff --git a/packages/agent-host/src/generated/protocol.ts b/packages/agent-host/src/generated/protocol.ts index 56bcfcc8..65517ca0 100644 --- a/packages/agent-host/src/generated/protocol.ts +++ b/packages/agent-host/src/generated/protocol.ts @@ -272,9 +272,11 @@ export type BridgeMessage_Deserialize = } } | /** * The provider profiles of an agent whose catalog entry `supports` - * `providerModels`, all of them: sent after `initialize` and whenever - * one changes. The agent offers their models beside its own. Reply: - * `ack`. + * `providerModels`, all of them, oldest saved first: sent after + * `initialize` and whenever one changes. The agent offers their models + * beside its own. One it cannot add (its name is taken by one of the + * agent's own providers, or by an earlier profile) is left out. Reply: + * `providers-set`. */ { kind: "set-providers"; payload: { agent: string, @@ -434,9 +436,11 @@ export type BridgeMessage_Serialize = } } | /** * The provider profiles of an agent whose catalog entry `supports` - * `providerModels`, all of them: sent after `initialize` and whenever - * one changes. The agent offers their models beside its own. Reply: - * `ack`. + * `providerModels`, all of them, oldest saved first: sent after + * `initialize` and whenever one changes. The agent offers their models + * beside its own. One it cannot add (its name is taken by one of the + * agent's own providers, or by an earlier profile) is left out. Reply: + * `providers-set`. */ { kind: "set-providers"; payload: { agent: string, @@ -763,6 +767,13 @@ export type HostMessage_Deserialize = { kind: "provider-models"; payload: { models: ProviderModel_Deserialize[], } } | +/** + * Reply to `set-providers`: the profiles left out, with the reason in + * words for a person; every other one is offered. + */ +{ kind: "providers-set"; payload: { + refused: RefusedProvider[], +} } | /** A notification: no frame id, no reply. */ { kind: "session-event"; payload: { sessionId: string, @@ -842,6 +853,13 @@ export type HostMessage_Serialize = { kind: "provider-models"; payload: { models: ProviderModel_Serialize[], } } | +/** + * Reply to `set-providers`: the profiles left out, with the reason in + * words for a person; every other one is offered. + */ +{ kind: "providers-set"; payload: { + refused: RefusedProvider[], +} } | /** A notification: no frame id, no reply. */ { kind: "session-event"; payload: { sessionId: string, @@ -1270,6 +1288,13 @@ export type QuestionSpec_Serialize = { multiSelect?: boolean, }; +/** A provider profile an agent left out of `set-providers`. */ +export type RefusedProvider = { + /** The profile's id. */ + id: string, + reason: string, +}; + /** Who wrote a text entry. */ export type Role = "user" | "agent"; diff --git a/packages/agent-host/src/host/host.ts b/packages/agent-host/src/host/host.ts index 614f0b80..12668aa1 100644 --- a/packages/agent-host/src/host/host.ts +++ b/packages/agent-host/src/host/host.ts @@ -253,8 +253,8 @@ export class AgentHost { case 'set-providers': { const driver = this.driver(message.payload.agent); if (!driver.setProviders) throw new Error(`${driver.info().displayName} does not add provider profiles to its models`); - await driver.setProviders(message.payload.providers); - return ack(); + const refused = await driver.setProviders(message.payload.providers); + return { kind: 'providers-set', payload: { refused } }; } default: throw new Error(`unsupported request ${(message as { kind: string }).kind}`); diff --git a/packages/agent-host/src/sdk/driver.ts b/packages/agent-host/src/sdk/driver.ts index 38b2396a..7f785c9d 100644 --- a/packages/agent-host/src/sdk/driver.ts +++ b/packages/agent-host/src/sdk/driver.ts @@ -27,6 +27,7 @@ import type { ProviderBinding, QuestionOutcome, QuestionSpec, + RefusedProvider, SelectOutcome, SessionEvent, SessionMcpServer, @@ -152,9 +153,12 @@ export interface Driver { * there is no list. */ listProviderModels?(baseUrl: string, token: string): Promise; /** For an agent whose catalog entry `supports.providerModels`: its - * provider profiles, all of them, whenever one changes. Their models are - * offered beside the agent's own; nothing the agent already has goes. */ - setProviders?(providers: ProviderBinding[]): Promise; + * provider profiles, all of them, oldest saved first, whenever one + * changes. Their models are offered beside the agent's own; nothing the + * agent already has goes. Resolves to the profiles left out (a name one + * of the agent's providers, or an earlier profile, already has), each + * with the reason in words for a person. */ + setProviders?(providers: ProviderBinding[]): Promise; /** * Delete the agent's own record of a conversation (an `info` * `nativeSessionId` it reported, run in `cwd`): its transcript files, or diff --git a/packages/agent-host/src/sdk/types.ts b/packages/agent-host/src/sdk/types.ts index e5fcfb8e..f79a2666 100644 --- a/packages/agent-host/src/sdk/types.ts +++ b/packages/agent-host/src/sdk/types.ts @@ -13,6 +13,7 @@ export type BridgeMessage = G.BridgeMessage_Serialize; export type StartSession = G.StartSession_Serialize; export type ProviderBinding = G.ProviderBinding_Serialize; export type ProviderModel = G.ProviderModel_Serialize; +export type RefusedProvider = G.RefusedProvider; export type SelectOutcome = G.SelectOutcome; export type PlanOutcome = G.PlanOutcome_Serialize; export type QuestionOutcome = G.QuestionOutcome; From 35c2dc2ec678052673fdd24f5bc6b678716b0635 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:30:32 +0200 Subject: [PATCH 6/9] feat: web search for every model OpenCode runs OpenCode offers its web search only to models of its own providers, so a session on a provider profile, or on any provider the operator added, had no way to search unless an MCP server gave it one. The server the bridge starts now sets OPENCODE_ENABLE_EXA, which offers the same search (Exa's keyless endpoint, still asked for under OpenCode's websearch permission) to every model. Checked on 1.18.32: a custom provider's model gets websearch with it and not without. Co-Authored-By: Claude Code --- docs/AGENT-CANDIDATES.md | 5 ++++- .../drivers/opencode/__tests__/opencodeProviders.test.ts | 2 ++ packages/agent-host/src/drivers/opencode/providers.ts | 8 ++++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/AGENT-CANDIDATES.md b/docs/AGENT-CANDIDATES.md index 10d31d66..232529a2 100644 --- a/docs/AGENT-CANDIDATES.md +++ b/docs/AGENT-CANDIDATES.md @@ -105,7 +105,10 @@ offer no tool that only works on its vendor's API. OpenCode meets all three: its web search is offered only on its own provider (checked on 1.18.32), its web fetch is local, and a provider profile adds the endpoint's models to its list beside OpenCode Zen's free ones — read from -the endpoint, never typed by hand. Codex is out (its client speaks only the +the endpoint, never typed by hand. The server the bridge starts sets +`OPENCODE_ENABLE_EXA`, so that web search (Exa's keyless endpoint, behind +OpenCode's `websearch` permission) is offered to every model, not only to +OpenCode's own providers'. Codex is out (its client speaks only the Responses API); a Pi-based entry stays an option once the Pi driver exists. ## Installing agents on demand diff --git a/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts b/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts index 70a0e42e..eb66505e 100644 --- a/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts +++ b/packages/agent-host/src/drivers/opencode/__tests__/opencodeProviders.test.ts @@ -84,6 +84,8 @@ describe('serverSetup', () => { expect(password).toMatch(/^[0-9a-f]{48}$/); expect(a.headers.authorization).toBe(`Basic ${Buffer.from(`opencode:${password}`).toString('base64')}`); expect(serverSetup([], {}).env.OPENCODE_SERVER_PASSWORD).not.toBe(password); + // Web search for every model, not only OpenCode's own providers'. + expect(serverSetup([], {}).env.OPENCODE_ENABLE_EXA).toBe('1'); // No profiles: the operator's config stands as it is. expect(serverSetup([], { OPENCODE_CONFIG_CONTENT: '{"theme":"x"}' }).env).not.toHaveProperty('OPENCODE_CONFIG_CONTENT'); }); diff --git a/packages/agent-host/src/drivers/opencode/providers.ts b/packages/agent-host/src/drivers/opencode/providers.ts index b9704744..82a3214a 100644 --- a/packages/agent-host/src/drivers/opencode/providers.ts +++ b/packages/agent-host/src/drivers/opencode/providers.ts @@ -118,13 +118,17 @@ export interface ServerSetup { headers: Record; } -/** The environment and credentials of a server for `profiles`. `baseEnv` - * is the host's own environment. */ +/** The environment and credentials of a server for `profiles` — and, for + * every model, web search. `baseEnv` is the host's own environment. */ export function serverSetup(profiles: ProviderBinding[], baseEnv: NodeJS.ProcessEnv): ServerSetup { const password = randomBytes(24).toString('hex'); const env: Record = { OPENCODE_SERVER_USERNAME: SERVER_USERNAME, OPENCODE_SERVER_PASSWORD: password, + // OpenCode offers its web search only on its own providers unless this + // is set; with it, every model can search (Exa's keyless endpoint, + // still behind the `websearch` permission). + OPENCODE_ENABLE_EXA: '1', }; if (profiles.length > 0) { env.OPENCODE_CONFIG_CONTENT = JSON.stringify(providersConfig(profiles, operatorConfig(baseEnv))); From 258de6d0bdbc38c2a0ed9690c84af9013b1e2c00 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:35:30 +0200 Subject: [PATCH 7/9] feat: each provider's models on the providers page A provider showed only how many models it had, so seeing what a gateway offers meant opening a new session's model picker. Each provider is now a row like an MCP server's: its name, endpoint and one chip for what matters most (not offered by its agent, no token, or its model count). Opened, it says why its agent leaves it out, where its models came from, and lists them under the provider a gateway routes each to, with the default marked and the context window when the endpoint gave it. Co-Authored-By: Claude Code --- .../plus/ui/screens/ProvidersScreen.kt | 115 ++++++++++++++---- .../codedeck/plus/ui/DesignSnapshotTest.kt | 2 + .../codedeck/plus/ui/screens/ProvidersTest.kt | 16 +++ ...ignSnapshotTest_providers_claude_empty.png | Bin 88098 -> 79904 bytes ..._DesignSnapshotTest_providers_opencode.png | Bin 74399 -> 79188 bytes ...gnSnapshotTest_providers_opencode_open.png | Bin 0 -> 78327 bytes 6 files changed, 111 insertions(+), 22 deletions(-) create mode 100644 apps/android/app/src/test/java/com/codedeck/plus/ui/screens/ProvidersTest.kt create mode 100644 apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_opencode_open.png diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/ProvidersScreen.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/ProvidersScreen.kt index 428dcf6a..331e2372 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/ProvidersScreen.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/ProvidersScreen.kt @@ -1,8 +1,10 @@ package com.codedeck.plus.ui.screens import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Close import androidx.compose.material3.Checkbox @@ -17,10 +19,13 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextOverflow import com.codedeck.plus.core.CoreHost import com.codedeck.plus.ui.components.Chip import com.codedeck.plus.ui.components.ErrorNote +import com.codedeck.plus.ui.components.ExpandableRow import com.codedeck.plus.ui.components.Field import com.codedeck.plus.ui.components.Group import com.codedeck.plus.ui.components.GroupBody @@ -114,6 +119,8 @@ fun ProvidersContent( onBack: () -> Unit, /** Opens straight on the editor (for a snapshot). */ startEditing: ProviderEditor? = null, + /** The provider shown opened (for a snapshot). */ + openProfile: String? = null, /** The core's base URL rule — a native call, so a snapshot, which cannot * load the core, passes its own. */ validBaseUrl: (String) -> Boolean = ::isValidProviderBaseUrl, @@ -187,8 +194,7 @@ fun ProvidersContent( Group(footer = agent?.let { providerUse(it) + " Tokens stay on the machine; this phone never stores them." }) { own.forEachIndexed { i, p -> if (i > 0) Divider() - GroupBody { - ProfileSummary(p) + ProfileRow(p, startOpen = p.id == openProfile) { if (confirmDelete == p.id) { Text( if (agent?.supportsProviderModels == true) { @@ -229,8 +235,7 @@ fun ProvidersContent( ) { unassigned.forEachIndexed { i, p -> if (i > 0) Divider() - GroupBody { - ProfileSummary(p) + ProfileRow(p, startOpen = p.id == openProfile) { Row(horizontalArrangement = Arrangement.spacedBy(Tokens.Space1)) { QuietButton("Use for $agentName", onClick = { write(p.id, rewrite(p, agentId)) }, enabled = !busy && p.hasToken) QuietButton("Delete", onClick = { write(p.id, null) }, danger = true, enabled = !busy) @@ -244,26 +249,92 @@ fun ProvidersContent( } } +/** "3 models", "1 model". */ +private fun modelCount(n: Int) = "$n ${if (n == 1) "model" else "models"}" + +/** + * One provider: its name and endpoint, with a chip for what matters most — + * why its agent does not offer it, a missing token, or how many models it + * has. Opened, it lists its models and then [actions]. + */ @Composable -private fun ProfileSummary(p: UniffiProviderProfileInfo) { - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { - Text(p.label, color = Tokens.Text, fontSize = Tokens.TextMd, modifier = Modifier.weight(1f)) - Chip( - if (p.hasToken) "token set" else "no token", - color = if (p.hasToken) Tokens.Success else Tokens.TextDim, - border = if (p.hasToken) Tokens.Success.copy(alpha = 0.4f) else Tokens.Border, - ) +private fun ProfileRow(p: UniffiProviderProfileInfo, startOpen: Boolean, actions: @Composable () -> Unit) { + var open by remember(p.id) { mutableStateOf(startOpen) } + ExpandableRow( + p.label, + p.baseUrl, + enabled = p.error == null, + open = open, + onOpenChange = { open = it }, + subtitleMono = true, + openSubtitleLines = 3, + trailing = { + when { + p.error != null -> Chip("not offered", color = Tokens.Danger, border = Tokens.Danger.copy(alpha = 0.4f)) + !p.hasToken -> Chip("no token", color = Tokens.Warn, border = Tokens.Warn.copy(alpha = 0.4f)) + else -> Chip(modelCount(p.models.size)) + } + }, + ) { + Column(Modifier.padding(top = Tokens.Space2), verticalArrangement = Arrangement.spacedBy(Tokens.Space3)) { + // Why the agent does not offer its models (its name is taken, say). + p.error?.let { Text(it, color = Tokens.Danger, fontSize = Tokens.TextSm) } + Text( + (if (p.modelsFromProvider) "Models read from the provider at the last save" else "Models typed in by hand") + + if (p.hasToken) " · token set" else " · no token, so nothing runs on it", + color = Tokens.TextMuted, + fontSize = Tokens.TextSm, + ) + ProfileModels(p) + actions() + } + } +} + +/** + * A profile's models, under the provider a gateway routes each to when the + * endpoint names one (the part of the model's group after the profile's + * name); its default marked. + */ +@Composable +private fun ProfileModels(p: UniffiProviderProfileInfo) { + // Those under no other provider first, so none reads as part of a group. + val byUpstream = p.models + .groupBy { m -> m.provider?.removePrefix(p.label)?.removePrefix(" · ")?.takeIf { it.isNotEmpty() } } + .entries + .sortedBy { it.key != null } + val default = p.defaultModel ?: p.models.firstOrNull()?.id + Column(verticalArrangement = Arrangement.spacedBy(Tokens.Space2)) { + byUpstream.forEach { (upstream, models) -> + if (upstream != null) Text(upstream, color = Tokens.TextMuted, fontSize = Tokens.TextXs, fontWeight = FontWeight.Medium) + models.forEach { m -> + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Tokens.Space2)) { + Text( + m.label ?: m.id, + color = Tokens.Text, + fontSize = Tokens.TextMd, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (m.id == default) Chip("default") + m.contextWindow?.let { Text(contextSize(it), color = Tokens.TextDim, fontSize = Tokens.TextXs, fontFamily = Tokens.FontMono) } + } + } + } + } +} + +/** A context window as people write it: 1M, 200K, 128K (2^17 tokens). */ +internal fun contextSize(tokens: UInt): String { + val n = tokens.toLong() + fun scaled(unit: Long, binary: Long, suffix: String): String = + "${if (n % binary == 0L) n / binary else Math.round(n.toDouble() / unit)}$suffix" + return when { + n >= 1_000_000L -> scaled(1_000_000L, 1L shl 20, "M") + n >= 1_000L -> scaled(1_000L, 1L shl 10, "K") + else -> n.toString() } - Text(p.baseUrl, color = Tokens.TextMuted, fontSize = Tokens.TextSm, fontFamily = Tokens.FontMono) - Text( - "${p.models.size} ${if (p.models.size == 1) "model" else "models"}" + - (if (p.modelsFromProvider) " from the provider" else "") + - (p.defaultModel?.let { ", default $it" } ?: ""), - color = Tokens.TextMuted, - fontSize = Tokens.TextSm, - ) - // Why the agent does not offer its models (its name is taken, say). - p.error?.let { Text(it, color = Tokens.Danger, fontSize = Tokens.TextSm) } } /** The last save's outcome, in words. */ diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt index 1ae96244..812a10b2 100644 --- a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt @@ -200,6 +200,7 @@ private val pages: Map Unit> = linkedMapOf( }, "plugins_opencode" to { PluginsContent(workstation, "opencode", dispatch = {}, onBack = {}) }, "providers_opencode" to { ProvidersContent(workstation, "opencode", status = null, dispatch = {}, onBack = {}) }, + "providers_opencode_open" to { ProvidersContent(workstation, "opencode", status = null, dispatch = {}, onBack = {}, openProfile = "home-gateway") }, "providers_claude_empty" to { ProvidersContent(workstation, "claude-code", status = null, dispatch = {}, onBack = {}) }, "providers_edit" to { ProvidersContent(workstation, "opencode", status = null, dispatch = {}, onBack = {}, startEditing = ProviderEditor.Existing("home-gateway"), validBaseUrl = { true }) @@ -290,6 +291,7 @@ class DesignSnapshotTest { @Test fun mcp_add() = paparazzi.page("mcp_add") @Test fun mcp_import() = paparazzi.page("mcp_import") @Test fun providers_opencode() = paparazzi.page("providers_opencode") + @Test fun providers_opencode_open() = paparazzi.page("providers_opencode_open") @Test fun providers_claude_empty() = paparazzi.page("providers_claude_empty") @Test fun providers_edit() = paparazzi.page("providers_edit") @Test fun providers_add() = paparazzi.page("providers_add") diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/screens/ProvidersTest.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/screens/ProvidersTest.kt new file mode 100644 index 00000000..633644eb --- /dev/null +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/screens/ProvidersTest.kt @@ -0,0 +1,16 @@ +package com.codedeck.plus.ui.screens + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ProvidersTest { + @Test + fun a_context_window_reads_as_people_write_it() { + assertEquals("1M", contextSize(1_000_000u)) + assertEquals("1M", contextSize(1_048_576u)) + assertEquals("200K", contextSize(200_000u)) + assertEquals("128K", contextSize(131_072u)) + assertEquals("33K", contextSize(32_768u + 1u)) + assertEquals("512", contextSize(512u)) + } +} diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_claude_empty.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_providers_claude_empty.png index 86036af52b7fe1dd618a739427e39d3ed9c2ac21..ad77ccfef8b1ed1aa12e292f87dc1776eba2c5a8 100644 GIT binary patch delta 51477 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%0DW{?a8-doY*% z>?atEz94u2!Z&+jITyzy;_P#{$1kd@C+N{xYw(MtxL@GIpN4CMh?C=L4#df5A5#*Q z;50H1MaivA$0qfhfkd(YiR9|yIk~}`nsK;6uDj{Z+^K-S5Q55hdtLP+M^d?Pqp+3) z8G3&rVBs{Ds)Wn!o*|RVq13M18N%6_g%bPn)hRE#ZP?PmlZpM5y!Ci+`fFR=a+H?8 zA>7WB2^W>yNzBXi4XjEo!_=5UkCF_~j>JkUAOpvygGw~?a(&pRT@+kOcFp4PNy41t z)$)Pp*Y++g88$|=Os#6#_qA)6U6jqTp%Td34fZ1l6-w+VHx_HHW_U=Re2!D|*WhQp z6r@Bj;l40pL8GNJyKa<(W&V4^*C&Ka>VphCC5l)gB|9g4Y*#D& z$2X)~W9~T`c_eDT+7F$yX~grl${cXOutI=0TzlIVm1VINOh%YMDFXKL*BRR^w^#{NyQDfw7zH>3x3(}f!;vSHmXyROIO<>A&E(YyGg^do`$mVK4Al zNcNjOiuh;a{@x2UAc~++i6YVX{gX;}8*;b$yg;ubRvjLx4x?uRbAU*((dkF`*d?=p zXkx{-QR5YfnnKp!Y^AD(Uzin^N`;nkBpd31l)SIsU26HTjco%}BohHU(MnKk5Z