diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index 2da5f6829c..be4c726782 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -27,9 +27,9 @@ OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, a - Generated per-item interaction audit: `docs/interactive-capabilities/technical/product-control-open-audit.json` - Generated low-level audit map: `docs/interactive-capabilities/technical/tauri-command-map.json` -说明书、网站、搜索和智能体只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **662** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 +说明书、网站、搜索和智能体只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **663** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 -Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **662** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. +Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **663** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. ## 控制边界 / Control boundary diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index 05b3ba5873..90a3a984a0 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -2,12 +2,12 @@ "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", "catalogDigest": "6587344a6b5e75a80457ea4ba2670bf37cf3038bdb436089ae402eb7b5a5a025", - "commandCount": 662, + "commandCount": 663, "coverage": { - "commandCount": 662, + "commandCount": 663, "documentedCommandCount": 615, - "implementationCommandCount": 47, - "implementationDigest": "401fd0a5f64377e65573d1b0883fbfa844570096adfafd4c11a999d749ba5859" + "implementationCommandCount": 48, + "implementationDigest": "f6d38a24a70708988cb47ada81d07eccf0668684e8734c9ee5155b9ffa7e3db8" }, "commands": [ { @@ -3222,6 +3222,20 @@ "signature": "fn get_latest_insights() -> Result, String>", "remoteWorkspacePolicy": "LocalOnly" }, + { + "id": "get_local_models_dev_catalogs", + "moduleId": "commands", + "capabilityId": "feature.projects", + "capabilityIds": [ + "feature.projects" + ], + "documentedItemIds": [], + "visibility": "implementation", + "rustPath": "get_local_models_dev_catalogs", + "sourceFile": "src/apps/desktop/src/api/commands.rs", + "signature": "fn get_local_models_dev_catalogs( ) -> Result", + "remoteWorkspacePolicy": "LocalOnly" + }, { "id": "get_mcp_prompt", "moduleId": "mcp", diff --git a/src/apps/cli/src/peer_host/commands/models.rs b/src/apps/cli/src/peer_host/commands/models.rs index 5286b48f62..79235db9c0 100644 --- a/src/apps/cli/src/peer_host/commands/models.rs +++ b/src/apps/cli/src/peer_host/commands/models.rs @@ -73,7 +73,10 @@ async fn build_transient_client( } pub(crate) async fn get_ai_model_catalog() -> Result { - serde_json::to_value(openbitfun_core::get_ai_model_catalog().await?) + // A controller receives configured models, defaults and the session + // selection. The models.dev bodies stay on the host: they describe the + // public models.dev catalog, and a controller reads its own snapshot. + serde_json::to_value(openbitfun_core::get_remote_model_catalog().await?) .map_err(|error| error.to_string()) } diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index 350fb95786..85496af0d7 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -5373,9 +5373,23 @@ pub async fn get_model_configs( } } +/// Model catalog of the host that renders the current surface. +/// +/// The models.dev bodies never travel: they describe the public models.dev +/// catalog, which every host refreshes for itself, and only Model Settings reads +/// them — through `get_local_models_dev_catalogs`, which stays on this machine. #[tauri::command] pub async fn get_ai_model_catalog() -> Result { - openbitfun_core::get_ai_model_catalog().await + openbitfun_core::get_remote_model_catalog().await +} + +/// This machine's own models.dev projections, for a controller rendering the +/// Model Settings surface while a peer is selected. Declared controller-local in +/// the Product Operation Registry, so it never reaches a peer host. +#[tauri::command] +pub async fn get_local_models_dev_catalogs( +) -> Result { + openbitfun_core::get_local_models_dev_catalogs().await } #[tauri::command] diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 69a0b70a1c..052cfd73d8 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1639,6 +1639,7 @@ pub async fn run() { get_global_config_status, get_model_configs, get_ai_model_catalog, + get_local_models_dev_catalogs, project_ai_model_reasoning_catalog, get_models_dev_catalog_status, refresh_models_dev_catalog_now, diff --git a/src/crates/assembly/core/src/infrastructure/ai/provider_catalog.rs b/src/crates/assembly/core/src/infrastructure/ai/provider_catalog.rs index f450a2ea35..8ac3c4cb74 100644 --- a/src/crates/assembly/core/src/infrastructure/ai/provider_catalog.rs +++ b/src/crates/assembly/core/src/infrastructure/ai/provider_catalog.rs @@ -80,14 +80,6 @@ pub(crate) fn resolve_builtin_provider_catalog( source: ProviderCatalogSource, ) -> ProviderCatalog { let overlay = parse_overlay().expect("built-in AI provider overlay must be valid"); - let has_bound_catalog_data = models_dev.is_some_and(|catalog| { - overlay.providers.iter().any(|provider| { - provider - .catalog_provider_ids - .iter() - .any(|provider_id| catalog.provider_facts(provider_id).is_some()) - }) - }); let mut providers = overlay .providers .iter() @@ -100,15 +92,55 @@ pub(crate) fn resolve_builtin_provider_catalog( }); ProviderCatalog { revision: resolved_catalog_revision(&revision), - source: if has_bound_catalog_data && source == ProviderCatalogSource::OpenBitFun { - ProviderCatalogSource::Mixed - } else { - source - }, + source: promoted_catalog_source(&overlay, models_dev, source), providers, } } +/// The identity of the built-in provider catalog without projecting its +/// providers. +/// +/// A caller that must not ship the projected bodies — a peer answer, a session +/// poll, a mobile or bot controller — still has to report the same `revision` +/// and `source` the full build would: the revision drives +/// `RemoteModelCatalog::version`, and an attached controller only accepts a new +/// catalog when that version moves. Projecting the providers themselves is the +/// expensive part, so the slim build keeps the identity and drops the bodies. +pub(crate) fn builtin_provider_catalog_identity( + models_dev: Option<&ModelsDevCatalog>, + revision: String, + source: ProviderCatalogSource, +) -> ProviderCatalog { + let overlay = parse_overlay().expect("built-in AI provider overlay must be valid"); + ProviderCatalog { + revision: resolved_catalog_revision(&revision), + source: promoted_catalog_source(&overlay, models_dev, source), + providers: Vec::new(), + } +} + +/// A models.dev snapshot that contributes provider bindings makes the catalog +/// mixed even when the caller passed the OpenBitFun-only source. +fn promoted_catalog_source( + overlay: &ProviderOverlayDocument, + models_dev: Option<&ModelsDevCatalog>, + source: ProviderCatalogSource, +) -> ProviderCatalogSource { + let has_bound_catalog_data = models_dev.is_some_and(|catalog| { + overlay.providers.iter().any(|provider| { + provider + .catalog_provider_ids + .iter() + .any(|provider_id| catalog.provider_facts(provider_id).is_some()) + }) + }); + if has_bound_catalog_data && source == ProviderCatalogSource::OpenBitFun { + ProviderCatalogSource::Mixed + } else { + source + } +} + fn resolved_catalog_revision(source_revision: &str) -> String { let mut digest = Sha256::new(); digest.update(b"openbitfun-provider-catalog-v1\0"); diff --git a/src/crates/assembly/core/src/lib.rs b/src/crates/assembly/core/src/lib.rs index e4145462fa..c777ec5143 100644 --- a/src/crates/assembly/core/src/lib.rs +++ b/src/crates/assembly/core/src/lib.rs @@ -70,7 +70,9 @@ pub(crate) mod service_agent_runtime; pub mod util; // General types, errors, helper functions #[cfg(feature = "remote-connect")] -pub use openbitfun_services_integrations::remote_connect::RemoteModelCatalog as AIModelCatalog; +pub use openbitfun_services_integrations::remote_connect::{ + LocalModelsDevCatalogs, RemoteModelCatalog as AIModelCatalog, +}; #[cfg(feature = "agent-runtime")] pub fn get_builtin_ai_provider_catalog() -> openbitfun_core_types::ProviderCatalog { @@ -81,11 +83,35 @@ pub fn get_builtin_ai_provider_catalog() -> openbitfun_core_types::ProviderCatal ) } +/// Model catalog for an in-process local reader. +/// +/// This machine's own models.dev projections are included: TUI/app-server +/// projections and the plugin host render them from the same process. A catalog +/// that crosses a machine boundary must use [`get_remote_model_catalog`] +/// instead, or a peer would ship a multi-MiB copy of the public catalog. #[cfg(feature = "remote-connect")] pub async fn get_ai_model_catalog() -> Result { + service_agent_runtime::CoreServiceAgentRuntime::load_local_model_catalog(None).await +} + +/// Model catalog for a caller on another machine: configured models, defaults +/// and the session selection, with the models.dev bodies left out. +/// +/// Every host keeps its own refreshed models.dev snapshot, so a controller +/// enriches its own Model Settings surface locally through +/// [`get_local_models_dev_catalogs`] instead of pulling the peer's copy. +#[cfg(feature = "remote-connect")] +pub async fn get_remote_model_catalog() -> Result { service_agent_runtime::CoreServiceAgentRuntime::load_remote_model_catalog(None).await } +/// This machine's own models.dev projections, for a controller that renders +/// Model Settings while a peer is selected. +#[cfg(feature = "remote-connect")] +pub async fn get_local_models_dev_catalogs() -> Result { + service_agent_runtime::CoreServiceAgentRuntime::load_local_models_dev_catalogs().await +} + #[cfg(feature = "model-catalog")] pub async fn project_ai_model_reasoning_catalog( request: openbitfun_core_types::ReasoningCatalogProjectionRequest, diff --git a/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs b/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs index 9eff4f606a..c452cd9005 100644 --- a/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs +++ b/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs @@ -385,6 +385,9 @@ async fn start_switch_model(state: &mut BotChatState, s: &'static BotStrings) -> } }; + // A bot menu reads the configured models and the session selection only: the + // models.dev bodies belong to the host's own settings surface, and a chat + // controller never offers provider or reasoning-catalog editing. let catalog = match CoreServiceAgentRuntime::load_remote_model_catalog(Some(&session_id)).await { Ok(c) => c, diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index 1d31528478..6eb2db285a 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -44,20 +44,20 @@ use openbitfun_services_integrations::remote_connect::{ normalize_remote_model_selection as normalize_remote_model_selection_contract, normalize_remote_session_model_id, project_remote_chat_user, remote_dialog_submit_outcome_from_scheduler, remote_model_selection_needs_config, ChatMessage, - RemoteAssistantWorkspaceFacts, RemoteCancelRuntimeHost, RemoteChatHistoryRound, - RemoteChatHistoryTextItem, RemoteChatHistoryThinkingItem, RemoteChatHistoryToolCall, - RemoteChatHistoryToolItem, RemoteChatHistoryTurn, RemoteConnectSubmissionSource, - RemoteDefaultModelsConfig, RemoteDialogQueuePriority, RemoteDialogResolvedSubmission, - RemoteDialogRuntimeHost, RemoteDialogSchedulerOutcomeFact, RemoteDialogSteerOutcome, - RemoteDialogSteerRequest, RemoteDialogSubmissionPolicy, RemoteDialogSubmitOutcome, - RemoteDialogWorkspaceBinding, RemoteImageContext, RemoteInitialSyncRuntimeHost, - RemoteInteractionRuntimeHost, RemoteModelCapabilityFact, RemoteModelCatalog, - RemoteModelCatalogFacts, RemoteModelFacts, RemotePermissionMode, RemotePollRuntimeHost, - RemoteRecentWorkspaceFacts, RemoteSessionMetadata, RemoteSessionModelSelection, - RemoteSessionRuntimeHost, RemoteSessionStateTracker, RemoteSessionTrackerHost, - RemoteTerminalPrewarmRequest, RemoteWorkspaceFacts, RemoteWorkspaceFileRuntimeHost, - RemoteWorkspaceKind as RemoteConnectWorkspaceKind, RemoteWorkspaceRuntimeHost, - RemoteWorkspaceUpdate, + LocalModelsDevCatalogs, RemoteAssistantWorkspaceFacts, RemoteCancelRuntimeHost, + RemoteChatHistoryRound, RemoteChatHistoryTextItem, RemoteChatHistoryThinkingItem, + RemoteChatHistoryToolCall, RemoteChatHistoryToolItem, RemoteChatHistoryTurn, + RemoteConnectSubmissionSource, RemoteDefaultModelsConfig, RemoteDialogQueuePriority, + RemoteDialogResolvedSubmission, RemoteDialogRuntimeHost, RemoteDialogSchedulerOutcomeFact, + RemoteDialogSteerOutcome, RemoteDialogSteerRequest, RemoteDialogSubmissionPolicy, + RemoteDialogSubmitOutcome, RemoteDialogWorkspaceBinding, RemoteImageContext, + RemoteInitialSyncRuntimeHost, RemoteInteractionRuntimeHost, RemoteModelCapabilityFact, + RemoteModelCatalog, RemoteModelCatalogFacts, RemoteModelFacts, RemotePermissionMode, + RemotePollRuntimeHost, RemoteRecentWorkspaceFacts, RemoteSessionMetadata, + RemoteSessionModelSelection, RemoteSessionRuntimeHost, RemoteSessionStateTracker, + RemoteSessionTrackerHost, RemoteTerminalPrewarmRequest, RemoteWorkspaceFacts, + RemoteWorkspaceFileRuntimeHost, RemoteWorkspaceKind as RemoteConnectWorkspaceKind, + RemoteWorkspaceRuntimeHost, RemoteWorkspaceUpdate, }; use std::sync::Arc; use std::time::Duration; @@ -383,6 +383,78 @@ fn remote_workspace_kind( } } +#[cfg(feature = "remote-connect")] +fn provider_catalog_source( + source: openbitfun_services_integrations::models_dev::ModelsDevSnapshotSource, +) -> openbitfun_core_types::ProviderCatalogSource { + use openbitfun_services_integrations::models_dev::ModelsDevSnapshotSource; + match source { + ModelsDevSnapshotSource::Cache => openbitfun_core_types::ProviderCatalogSource::Cache, + ModelsDevSnapshotSource::Bundled => openbitfun_core_types::ProviderCatalogSource::Bundle, + ModelsDevSnapshotSource::Empty => openbitfun_core_types::ProviderCatalogSource::OpenBitFun, + } +} + +#[cfg(feature = "remote-connect")] +fn reasoning_catalog_source( + source: openbitfun_services_integrations::models_dev::ModelsDevSnapshotSource, +) -> openbitfun_core_types::ModelsDevCatalogSource { + use openbitfun_services_integrations::models_dev::ModelsDevSnapshotSource; + match source { + ModelsDevSnapshotSource::Cache => openbitfun_core_types::ModelsDevCatalogSource::Cache, + ModelsDevSnapshotSource::Bundled => openbitfun_core_types::ModelsDevCatalogSource::Bundle, + ModelsDevSnapshotSource::Empty => openbitfun_core_types::ModelsDevCatalogSource::Empty, + } +} + +/// The models.dev projections that enrich model configuration. +/// +/// These bodies cover every provider and every reasoning model of the public +/// models.dev catalog, and every host keeps its own refreshed snapshot. Only an +/// in-process local reader (TUI/app-server projections, the plugin host) or a +/// controller's own Model Settings surface needs them, so a catalog that crosses +/// a machine boundary is built without them. +/// +/// The slim build still reports the built-in provider catalog's revision, +/// because that revision participates in `RemoteModelCatalog::version`: a +/// controller that already knows the version must not see it move just because +/// the bodies stopped travelling. +#[cfg(feature = "remote-connect")] +fn remote_provider_catalog( + models_dev: &crate::infrastructure::ai::reasoning_catalog::ModelsDevReasoningCatalogSnapshot, + include_providers: bool, +) -> openbitfun_core_types::ProviderCatalog { + let source = provider_catalog_source(models_dev.source); + if include_providers { + return resolve_builtin_provider_catalog( + models_dev.catalog.as_deref(), + models_dev.sha256.clone(), + source, + ); + } + crate::infrastructure::ai::provider_catalog::builtin_provider_catalog_identity( + models_dev.catalog.as_deref(), + models_dev.sha256.clone(), + source, + ) +} + +#[cfg(feature = "remote-connect")] +fn remote_models_dev_reasoning_catalog( + models_dev: &crate::infrastructure::ai::reasoning_catalog::ModelsDevReasoningCatalogSnapshot, + include_catalog: bool, +) -> Option { + if !include_catalog { + return None; + } + models_dev.catalog.as_deref().map(|catalog| { + catalog.reasoning_binding_catalog( + models_dev.sha256.clone(), + reasoning_catalog_source(models_dev.source), + ) + }) +} + #[cfg(feature = "remote-connect")] fn git_branch_for_workspace_path(path: &std::path::Path) -> Option { let path_str = path.to_string_lossy(); @@ -1973,9 +2045,28 @@ impl CoreServiceAgentRuntime { Ok((messages, false)) } + /// Model catalog for a caller on another machine: configured models, + /// defaults and the session selection, never the models.dev bodies. #[cfg(feature = "remote-connect")] pub(crate) async fn load_remote_model_catalog( session_id: Option<&str>, + ) -> Result { + Self::build_model_catalog(session_id, false).await + } + + /// Model catalog for an in-process local reader (TUI/app-server + /// projections, the plugin host), which does render the models.dev bodies. + #[cfg(feature = "remote-connect")] + pub(crate) async fn load_local_model_catalog( + session_id: Option<&str>, + ) -> Result { + Self::build_model_catalog(session_id, true).await + } + + #[cfg(feature = "remote-connect")] + async fn build_model_catalog( + session_id: Option<&str>, + include_host_catalogs: bool, ) -> Result { let config_service = crate::service::config::get_global_config_service() .await @@ -1986,37 +2077,9 @@ impl CoreServiceAgentRuntime { .map_err(|e| format!("Failed to load global config: {e}"))?; let ai_config: AIConfig = global_config.ai; let models_dev = load_models_dev_reasoning_catalog().await; - let models_dev_reasoning_catalog = models_dev.catalog.as_deref().map(|catalog| { - catalog.reasoning_binding_catalog( - models_dev.sha256.clone(), - match models_dev.source { - openbitfun_services_integrations::models_dev::ModelsDevSnapshotSource::Cache => { - openbitfun_core_types::ModelsDevCatalogSource::Cache - } - openbitfun_services_integrations::models_dev::ModelsDevSnapshotSource::Bundled => { - openbitfun_core_types::ModelsDevCatalogSource::Bundle - } - openbitfun_services_integrations::models_dev::ModelsDevSnapshotSource::Empty => { - openbitfun_core_types::ModelsDevCatalogSource::Empty - } - }, - ) - }); - let provider_catalog = resolve_builtin_provider_catalog( - models_dev.catalog.as_deref(), - models_dev.sha256.clone(), - match models_dev.source { - openbitfun_services_integrations::models_dev::ModelsDevSnapshotSource::Cache => { - openbitfun_core_types::ProviderCatalogSource::Cache - } - openbitfun_services_integrations::models_dev::ModelsDevSnapshotSource::Bundled => { - openbitfun_core_types::ProviderCatalogSource::Bundle - } - openbitfun_services_integrations::models_dev::ModelsDevSnapshotSource::Empty => { - openbitfun_core_types::ProviderCatalogSource::OpenBitFun - } - }, - ); + let models_dev_reasoning_catalog = + remote_models_dev_reasoning_catalog(&models_dev, include_host_catalogs); + let provider_catalog = remote_provider_catalog(&models_dev, include_host_catalogs); let models: Vec = ai_config .models @@ -2066,6 +2129,17 @@ impl CoreServiceAgentRuntime { })) } + /// Project this machine's own models.dev snapshot for a controller that + /// renders the Model Settings surface while a peer is selected. + #[cfg(feature = "remote-connect")] + pub(crate) async fn load_local_models_dev_catalogs() -> Result { + let models_dev = load_models_dev_reasoning_catalog().await; + Ok(LocalModelsDevCatalogs { + provider_catalog: remote_provider_catalog(&models_dev, true), + models_dev_reasoning_catalog: remote_models_dev_reasoning_catalog(&models_dev, true), + }) + } + #[cfg(feature = "remote-connect")] pub(crate) async fn update_remote_session_model( coordinator: &ConversationCoordinator, @@ -3394,6 +3468,9 @@ impl RemotePollRuntimeHost for CoreRemotePollRuntimeHost<'_> { } async fn load_model_catalog(&self, session_id: &str) -> Option { + // A session poll runs per attached controller, so it reads the remote + // catalog: only the configured-model facts and the version reach a poll + // client, and the models.dev bodies stay local to the settings surface. CoreServiceAgentRuntime::load_remote_model_catalog(Some(session_id)) .await .ok() @@ -4449,3 +4526,36 @@ mod history_workspace_identity_tests { .is_err()); } } + +/// The models.dev projections are opt-in, and the revision they carry is what +/// keeps the catalog version stable for a controller that stays slim. +#[cfg(all(test, feature = "remote-connect"))] +mod remote_model_catalog_tests { + use super::{remote_models_dev_reasoning_catalog, remote_provider_catalog}; + use crate::infrastructure::ai::reasoning_catalog::ModelsDevReasoningCatalogSnapshot; + use openbitfun_services_integrations::models_dev::ModelsDevSnapshotSource; + + fn empty_snapshot() -> ModelsDevReasoningCatalogSnapshot { + ModelsDevReasoningCatalogSnapshot { + catalog: None, + version: 7, + sha256: "revision-7".to_string(), + source: ModelsDevSnapshotSource::Empty, + } + } + + #[test] + fn slim_projection_keeps_the_identity_without_the_catalog_bodies() { + let snapshot = empty_snapshot(); + let slim = remote_provider_catalog(&snapshot, false); + let full = remote_provider_catalog(&snapshot, true); + + assert!(slim.providers.is_empty()); + // A controller that already knows this catalog version must not see the + // version move just because the bodies stopped travelling. + assert_eq!(full.revision, slim.revision); + assert_eq!(full.source, slim.source); + assert!(!full.providers.is_empty()); + assert!(remote_models_dev_reasoning_catalog(&snapshot, false).is_none()); + } +} diff --git a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json index c3e1355478..fa29cdcaeb 100644 --- a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json +++ b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "digest": "fnv1a64:86d6295909ba287d", + "digest": "fnv1a64:d3892fea19448d1f", "retiredCommandPrefixes": [ { "prefix": "lsp_", @@ -2598,6 +2598,18 @@ "reason": "the controller keeps this command; peer hosts refuse it before dispatch" } }, + { + "id": "get_local_models_dev_catalogs", + "surface": "tauri_command", + "remoteWorkspace": "LocalOnly", + "peer": { + "kind": "controller_local" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the controller keeps this command; peer hosts refuse it before dispatch" + } + }, { "id": "get_mcp_prompt", "surface": "tauri_command", diff --git a/src/crates/contracts/product-domains/src/remote_surface/table.rs b/src/crates/contracts/product-domains/src/remote_surface/table.rs index de20e97004..dbf37e8ae1 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/table.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/table.rs @@ -299,6 +299,11 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("get_health_status", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), op("get_instruction_source_catalog", Unsupported, Proxied, CLI_NOT_IMPLEMENTED), op("get_latest_insights", LocalOnly, ControllerLocal, REFUSED), + // This machine's own models.dev projections for the Model Settings surface a + // controller renders while a peer is selected. The data belongs to the + // public models.dev catalog, which every host refreshes for itself, so the + // controller keeps this read local instead of pulling the peer's copy. + op("get_local_models_dev_catalogs", LocalOnly, ControllerLocal, REFUSED), op("get_mcp_prompt", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("get_mcp_remote_oauth_session", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("get_mcp_server_status", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index 614b779310..af9e166285 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -1391,6 +1391,10 @@ pub trait RemoteSessionRuntimeHost: Send + Sync { /// The primary assistant workspace record, created on first use. async fn resolve_default_assistant_workspace(&self) -> Result; async fn create_session(&self, request: AgentSessionCreateRequest) -> Result; + /// The catalog a remote caller receives: configured models, defaults and + /// the session selection. The models.dev projections never travel here (see + /// [`RemoteModelCatalog`]); a caller that renders Model Settings reads its + /// own snapshot instead. async fn load_model_catalog( &self, session_id: Option<&str>, @@ -1698,6 +1702,13 @@ pub trait RemotePollRuntimeHost: Send + Sync { /// Hydrate permission requests that may have been registered before the /// remote tracker observed the corresponding tool event. fn sync_pending_permissions(&self, _session_id: &str, _tracker: &RemoteSessionStateTracker) {} + /// Model catalog for a session poll. + /// + /// Polls run per attached controller and per session, so they never carry + /// the models.dev projections: a poll client reads the configured-model + /// facts and the version, and those projections belong to the public + /// models.dev catalog that every client refreshes for itself. The slim + /// build keeps the same catalog version, so change detection is unaffected. async fn load_model_catalog(&self, session_id: &str) -> Option; async fn resolve_session_storage_dir(&self, session_id: &str) -> Option; async fn load_remote_chat_messages( @@ -1892,6 +1903,18 @@ pub struct RemoteModelConfig { pub reasoning: Option, } +/// Model catalog facts for one host. +/// +/// The `provider_catalog` and `models_dev_reasoning_catalog` bodies describe the +/// public models.dev catalog, which every host refreshes for itself and only a +/// Model Settings surface reads. A caller that crosses a machine boundary — a +/// peer request, a remote session poll, a mobile or bot controller — therefore +/// receives the catalog *without* those bodies: the provider catalog keeps its +/// revision (the revision drives `version`, which clients compare) and reports +/// no providers, and the reasoning catalog is absent. Only the in-process local +/// readers (`get_ai_model_catalog`, TUI/app-server projections, the plugin host) +/// still receive them, and `get_local_models_dev_catalogs` serves the +/// controller-local copy that Model Settings needs. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RemoteModelCatalog { pub version: u64, @@ -1909,6 +1932,20 @@ pub struct RemoteModelCatalog { pub session_reasoning_preset: Option, } +/// The models.dev projections of *this* machine, for a controller that renders +/// its own Model Settings surface while a peer is selected. +/// +/// These bodies belong to the public models.dev catalog, not to a host: every +/// host keeps its own refreshed snapshot, so a controller must enrich its own +/// settings UI locally instead of requesting the peer's copy (which would put a +/// multi-MiB body on the wire on every settings open). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LocalModelsDevCatalogs { + pub provider_catalog: ProviderCatalog, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub models_dev_reasoning_catalog: Option, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RemoteModelCapabilityFact { TextChat, @@ -4452,6 +4489,45 @@ mod tests { ); } + /// A slim catalog must report the same version as the full one, and its + /// revision must still drive that version. Poll clients send the version + /// they already know and only accept a new catalog when it changes, so a + /// slim build that dropped the revision would make every poll look like a + /// catalog change. + #[test] + fn slim_model_catalog_keeps_the_version_of_the_full_catalog() { + let facts = |revision: &str, models_dev_reasoning_catalog| RemoteModelCatalogFacts { + last_modified_ms: 1_700_000_000_000, + source_version: Some(42), + models: Vec::new(), + provider_catalog: openbitfun_core_types::ProviderCatalog { + revision: revision.to_string(), + source: openbitfun_core_types::ProviderCatalogSource::Cache, + providers: Vec::new(), + }, + models_dev_reasoning_catalog, + default_models: RemoteDefaultModelsConfig::default(), + session_model_id: Some("model-primary".to_string()), + session_reasoning_preset: Some("high".to_string()), + }; + let with_bodies = build_remote_model_catalog(facts( + "models-dev-revision", + Some(ModelsDevReasoningCatalog { + revision: "models-dev-revision".to_string(), + source: openbitfun_core_types::ModelsDevCatalogSource::Cache, + providers: Vec::new(), + }), + )); + let slim = build_remote_model_catalog(facts("models-dev-revision", None)); + let other_revision = build_remote_model_catalog(facts("other-revision", None)); + + assert_eq!(with_bodies.version, slim.version); + assert_ne!(slim.version, other_revision.version); + assert_eq!(slim.provider_catalog.revision, "models-dev-revision"); + assert!(slim.provider_catalog.providers.is_empty()); + assert!(slim.models_dev_reasoning_catalog.is_none()); + } + #[tokio::test] async fn explicit_workspace_id_never_falls_back_to_legacy_path_selection() { let command: RemoteCommand = serde_json::from_value(serde_json::json!({ diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 2190336b9e..9ddb30e8af 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -211,6 +211,9 @@ "get_configs", "set_config" ], + "modelCatalogEnrichment": [ + "get_local_models_dev_catalogs" + ], "persistencePrimitives": [ "save_session_metadata", "save_session_turn", diff --git a/src/web-ui/src/flow_chat/utils/modelResolution.test.ts b/src/web-ui/src/flow_chat/utils/modelResolution.test.ts index ab181951ac..4576deaf39 100644 --- a/src/web-ui/src/flow_chat/utils/modelResolution.test.ts +++ b/src/web-ui/src/flow_chat/utils/modelResolution.test.ts @@ -15,7 +15,7 @@ import { import type { AIModelConfig } from '@/infrastructure/config/types'; vi.mock('@/infrastructure/api/service-api/AIApi', () => ({ - aiApi: { getModelCatalog: vi.fn() }, + aiApi: { getModelCatalog: vi.fn(), projectReasoningCatalog: vi.fn() }, })); vi.mock('@/infrastructure/config/services/ConfigManager', () => ({ @@ -91,11 +91,8 @@ describe('reasoning preset session creation resolution', () => { }); vi.mocked(configManager.getConfigs).mockResolvedValue({ 'ai.agent_model_defaults': { mode: 'primary' }, - }); - vi.mocked(aiApi.getModelCatalog).mockResolvedValue({ - version: 1, - default_models: { primary: 'model-primary', fast: 'model-fast' }, - models: [ + 'ai.default_models': { primary: 'model-primary', fast: 'model-fast' }, + 'ai.models': [ { id: 'model-primary', name: 'Primary', @@ -127,6 +124,20 @@ describe('reasoning preset session creation resolution', () => { }, ], }); + vi.mocked(aiApi.projectReasoningCatalog).mockImplementation(async request => ( + request.modelName === 'gpt-primary' + ? { + status: 'known', + presets: [{ + id: 'high', + label: 'High', + order: 10, + source: 'models_dev', + actions: [{ type: 'effort', value: 'high' }], + }], + } + : { status: 'unsupported', presets: [] } + )); }); afterEach(() => { @@ -139,9 +150,7 @@ describe('reasoning preset session creation resolution', () => { }); it('uses the configured mode model for a new session without an explicit model', async () => { - vi.mocked(configManager.getConfigs).mockResolvedValue({ - 'ai.agent_model_defaults': { mode: 'primary' }, - }); + // The mode default is what selects the model when the caller passes none. setRecentReasoningPreset('model-primary', 'high'); await expect(resolveReasoningPresetForSessionCreation()).resolves.toBe('high'); @@ -156,4 +165,29 @@ describe('reasoning preset session creation resolution', () => { setRecentReasoningPreset('model-fast', 'high'); await expect(resolveReasoningPresetForSessionCreation('fast')).resolves.toBeUndefined(); }); + + it('never reads the whole model catalog while creating a session', async () => { + // The catalog carries the public models.dev projections, which cost + // multi-MiB over a peer connection and used to be awaited before the + // create-session RPC. Session creation must project one model instead. + const catalogRead = vi.mocked(aiApi.getModelCatalog); + const projection = vi.mocked(aiApi.projectReasoningCatalog); + catalogRead.mockClear(); + projection.mockClear(); + setRecentReasoningPreset('model-primary', 'high'); + await expect(resolveReasoningPresetForSessionCreation('primary')).resolves.toBe('high'); + + expect(catalogRead).not.toHaveBeenCalled(); + expect(projection).toHaveBeenCalledWith( + expect.objectContaining({ provider: 'responses', modelName: 'gpt-primary' }), + ); + }); + + it('projects nothing when the model has no recent preset to validate', async () => { + const projection = vi.mocked(aiApi.projectReasoningCatalog); + projection.mockClear(); + + await expect(resolveReasoningPresetForSessionCreation('primary')).resolves.toBeUndefined(); + expect(projection).not.toHaveBeenCalled(); + }); }); diff --git a/src/web-ui/src/flow_chat/utils/modelResolution.ts b/src/web-ui/src/flow_chat/utils/modelResolution.ts index ad10ffeba8..4ffe050eab 100644 --- a/src/web-ui/src/flow_chat/utils/modelResolution.ts +++ b/src/web-ui/src/flow_chat/utils/modelResolution.ts @@ -210,29 +210,42 @@ export async function resolveReasoningPresetForSessionCreation( modelName?: string, ): Promise { try { - let modelSelection = modelName?.trim(); - if (!modelSelection) { - const configManager = await import('@/infrastructure/config/services/ConfigManager').then(m => m.configManager); - const configData = await configManager.getConfigs(['ai.agent_model_defaults']); - const agentModelDefaults = configData['ai.agent_model_defaults'] as AgentModelDefaultsConfig | undefined; - modelSelection = agentModelDefaults?.mode?.trim() || 'primary'; - } - - const catalog = await aiApi.getModelCatalog(); - const selectedModelId = modelSelection === 'primary' - ? catalog.default_models.primary ?? undefined - : modelSelection === 'fast' - ? catalog.default_models.fast ?? catalog.default_models.primary ?? undefined - : modelSelection; - const concreteModelId = selectedModelId - && catalog.models.some(model => model.id === selectedModelId) - ? selectedModelId - : catalog.default_models.primary ?? undefined; - if (!concreteModelId) return undefined; - const projection = catalog.models.find(model => model.id === concreteModelId)?.reasoning; - if (projection?.status !== 'known') return undefined; - const preset = getRecentReasoningPreset(concreteModelId); - return projection.presets?.some(item => item.id === preset) ? preset : undefined; + const configManager = await import('@/infrastructure/config/services/ConfigManager').then(m => m.configManager); + const configData = await configManager.getConfigs([ + 'ai.models', + 'ai.default_models', + 'ai.agent_model_defaults', + ]); + const models = (configData['ai.models'] as AIModelConfig[] | undefined) || []; + const defaultModels = (configData['ai.default_models'] as DefaultModelsConfig | undefined) || {}; + const agentModelDefaults = configData['ai.agent_model_defaults'] as AgentModelDefaultsConfig | undefined; + const selection = modelName?.trim() || agentModelDefaults?.mode?.trim() || 'primary'; + const model = resolveModelForContextWindow(selection, models, defaultModels) + ?? resolveModelForContextWindow('primary', models, defaultModels); + const modelId = model?.id; + if (!model || !modelId) return undefined; + + const preset = getRecentReasoningPreset(modelId); + // No recent preset means nothing to validate: stopping here keeps session + // creation free of any catalog read. + if (!preset) return undefined; + + // Ask the host for this one model's projection instead of pulling the whole + // model catalog. The projection is a few hundred bytes, while the catalog + // carried the public models.dev projections of every provider and reasoning + // model — multi-MiB over a peer connection, and awaited before the session + // RPC was even issued. + const projection = await aiApi.projectReasoningCatalog({ + provider: model.provider, + modelName: model.model_name || modelId, + baseUrl: model.base_url, + contextWindow: model.context_window, + maxTokens: model.max_tokens, + reasoning: model.reasoning ?? {}, + }); + if (projection.status !== 'known') return undefined; + const presetSupported = projection.presets?.some(item => item.id === preset) ?? false; + return presetSupported ? preset : undefined; } catch (error) { log.warn('Failed to resolve recent reasoning preset during session creation', { error }); return undefined; diff --git a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts index fab22003fe..775123b68e 100644 --- a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts +++ b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts @@ -1,6 +1,6 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. // Source: openbitfun_product_domains::remote_surface (Product Operation Registry). -export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:86d6295909ba287d" as const; +export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:d3892fea19448d1f" as const; /** * Registered Tauri commands the Peer Device controller keeps on the controller @@ -78,6 +78,7 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([ "get_announcement_tips", "get_frontend_update_status", "get_latest_insights", + "get_local_models_dev_catalogs", "get_pending_announcements", "get_pending_update", "get_prevent_sleep_enabled", diff --git a/src/web-ui/src/infrastructure/api/service-api/AIApi.ts b/src/web-ui/src/infrastructure/api/service-api/AIApi.ts index 44a78273ba..a07c942d49 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AIApi.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AIApi.ts @@ -182,6 +182,18 @@ export interface AIModelCatalog { session_model_id?: string; } +/** + * This machine's own models.dev projections. + * + * They describe the public models.dev catalog, which every host refreshes for + * itself, so a controller rendering Model Settings while a peer is selected + * enriches locally instead of pulling the peer's multi-MiB copy over the wire. + */ +export interface LocalModelsDevCatalogs { + provider_catalog?: ProviderCatalog; + models_dev_reasoning_catalog?: ModelsDevReasoningCatalog; +} + export interface ReasoningCatalogProjectionRequest { provider: string; modelName: string; @@ -261,6 +273,14 @@ export class AIApi { } } + /** + * Model catalog of the host that renders the current surface. + * + * It carries the configured models, defaults and the session selection. The + * models.dev projections never travel — they describe the public models.dev + * catalog, which every host refreshes for itself — so a surface that needs + * them composes them with {@link getLocalModelsDevCatalogs}. + */ async getModelCatalog(): Promise { try { return await api.invoke('get_ai_model_catalog', {}); @@ -269,6 +289,18 @@ export class AIApi { } } + /** + * This machine's own models.dev projections. Controller-local: the Product + * Operation Registry never proxies it to a peer. + */ + async getLocalModelsDevCatalogs(): Promise { + try { + return await api.invoke('get_local_models_dev_catalogs', {}); + } catch (error) { + throw createTauriCommandError('get_local_models_dev_catalogs', error); + } + } + async projectReasoningCatalog( request: ReasoningCatalogProjectionRequest, ): Promise { diff --git a/src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx b/src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx index 2eb24cf882..763d006e42 100644 --- a/src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx +++ b/src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx @@ -550,7 +550,28 @@ const ModelSettingsPage: React.FC = () => { const loadModelCatalog = useCallback(async () => { try { - setModelCatalog(await aiApi.getModelCatalog()); + // Host-owned facts (configured models, defaults, session selection) come + // from the rendered host. The provider templates and the reasoning + // catalog describe the public models.dev catalog instead, so this + // controller composes them from its own snapshot: shipping a peer's copy + // would put a multi-MiB body on the connection for every settings open, + // and that data is identical by construction. Per-model reasoning + // projections stay host-computed, so they still describe the host config + // that is being edited. + const [hostCatalog, localCatalogs] = await Promise.all([ + aiApi.getModelCatalog(), + aiApi.getLocalModelsDevCatalogs().catch((error: unknown) => { + log.warn('Failed to load local models.dev catalogs', { error }); + return null; + }), + ]); + setModelCatalog(localCatalogs + ? { + ...hostCatalog, + provider_catalog: localCatalogs.provider_catalog, + models_dev_reasoning_catalog: localCatalogs.models_dev_reasoning_catalog, + } + : hostCatalog); } catch (error) { setModelCatalog(null); log.warn('Failed to load model reasoning catalog', { error });