diff --git a/src/openhuman/about_app/catalog.rs b/src/openhuman/about_app/catalog.rs index e3db25b35..00942af9f 100644 --- a/src/openhuman/about_app/catalog.rs +++ b/src/openhuman/about_app/catalog.rs @@ -74,6 +74,25 @@ const POLYMARKET_TRADING_DATA: Option = Some(CapabilityPrivac destinations: &["Polymarket CLOB API"], }); +// "Test Connection" on the Embeddings settings panel routes a small probe +// payload to *whichever provider the user has selected* — not just the +// managed cloud default. `DERIVED_TO_BACKEND` only enumerates the managed +// path (OpenHuman backend / Neocortex), which under-reports the actual +// privacy surface when the user has switched to OpenAI / Cohere / a +// self-hosted endpoint. The catalog needs to list every reachable +// destination so the Privacy surface can render the full set instead of +// implying probes always stay on the managed path. +const EMBEDDING_PROBE_TO_CONFIGURED_PROVIDER: Option = Some(CapabilityPrivacy { + leaves_device: true, + data_kind: PrivacyDataKind::Derived, + destinations: &[ + "OpenHuman backend / TinyHumans Neocortex (managed cloud default)", + "OpenAI API (api.openai.com)", + "Cohere API (api.cohere.com)", + "User-configured OpenAI-compatible endpoint (custom:)", + ], +}); + const CAPABILITIES: &[Capability] = &[ Capability { id: "conversation.create", @@ -294,6 +313,50 @@ const CAPABILITIES: &[Capability] = &[ status: CapabilityStatus::Beta, privacy: LOCAL_RAW, }, + Capability { + id: "intelligence.embedding_provider_config", + name: "Configure Embedding Provider", + domain: "embeddings", + category: CapabilityCategory::Intelligence, + description: + "Pick which embedding provider drives semantic search across your memory: \ + managed cloud (default, Voyage-backed via api.tinyhumans.ai), OpenAI, \ + Cohere, local Ollama, or a custom OpenAI-compatible endpoint. API keys \ + are stored encrypted via the local keyring under `embeddings:`; \ + model name and embedding dimensions are tunable per provider. The \ + legacy `inference_embed` RPC is aliased to `embeddings_embed` so \ + existing callers continue to work.", + how_to: "Settings > AI > Embeddings", + status: CapabilityStatus::Beta, + // Privacy depends on the selected provider — see + // `intelligence.embedding_provider_test` for the per-provider data + // destinations. The configuration surface itself only writes to the + // local keyring and config, so leaving this `None` (treat-as-unknown) + // would under-report; we annotate the credential side here and the + // network side on the test action. + privacy: LOCAL_CREDENTIALS, + }, + Capability { + id: "intelligence.embedding_provider_test", + name: "Test Embedding Provider", + domain: "embeddings", + category: CapabilityCategory::Intelligence, + description: + "Verify a configured embedding provider before committing it to \ + memory ingestion. Sends a small one-shot embed request and reports \ + the model, dimensions, and any auth/error surface so a \ + misconfigured key doesn't get discovered halfway through a 50k \ + chunk backfill.", + how_to: "Settings > AI > Embeddings > Test Connection", + // The probe payload routes to whichever provider the user has + // selected — managed cloud (default), OpenAI, Cohere, or a custom + // OpenAI-compatible endpoint. Using `DERIVED_TO_BACKEND` here would + // under-report by only listing the managed path; the dedicated + // constant enumerates every reachable destination so the Privacy + // surface renders the full set. + status: CapabilityStatus::Beta, + privacy: EMBEDDING_PROBE_TO_CONFIGURED_PROVIDER, + }, Capability { id: "intelligence.mcp_server", name: "MCP Server", diff --git a/src/openhuman/about_app/catalog_tests.rs b/src/openhuman/about_app/catalog_tests.rs index f8c75f835..a385f7994 100644 --- a/src/openhuman/about_app/catalog_tests.rs +++ b/src/openhuman/about_app/catalog_tests.rs @@ -103,6 +103,8 @@ fn catalog_includes_additional_user_facing_surfaces() { "intelligence.mcp_server", "intelligence.searxng_search", "intelligence.tool_registry", + "intelligence.embedding_provider_config", + "intelligence.embedding_provider_test", "conversation.subagent_mascots", ] { assert!( @@ -111,3 +113,113 @@ fn catalog_includes_additional_user_facing_surfaces() { ); } } + +/// The two embeddings entries surface a Settings-side configuration panel. +/// They share the same domain (`embeddings`) but are listed under the +/// Intelligence umbrella so they sit next to memory_tree_retrieval / mcp_server +/// in the in-app feature catalog. Pinning the relationships here defends +/// against an inadvertent recategorisation that would split them across the +/// UI's tab grouping. +#[test] +fn embedding_provider_capabilities_share_domain_and_category() { + let config = lookup("intelligence.embedding_provider_config") + .expect("embedding_provider_config registered"); + let test = + lookup("intelligence.embedding_provider_test").expect("embedding_provider_test registered"); + + assert_eq!(config.domain, "embeddings"); + assert_eq!(test.domain, "embeddings"); + assert_eq!( + config.category, test.category, + "both embedding capabilities must land in the same UI category" + ); + + // The Settings panel they describe is the same one — make sure the + // `how_to` strings point at it, not at an out-of-date breadcrumb. + assert!( + config.how_to.contains("Settings") && config.how_to.contains("Embeddings"), + "config how_to must mention Settings > … > Embeddings, got: {}", + config.how_to + ); + assert!( + test.how_to.contains("Settings") && test.how_to.contains("Embeddings"), + "test how_to must mention Settings > … > Embeddings, got: {}", + test.how_to + ); +} + +/// Privacy annotations must split cleanly: the config side touches only the +/// local keyring (LOCAL_CREDENTIALS — leaves_device=false), the test side +/// fires a probe at the configured provider (leaves_device=true). Without +/// this split, a single `None` privacy flag would force the UI to treat the +/// embeddings panel as "unknown" and the Privacy surface would under-report +/// where data goes when the test button gets clicked. +#[test] +fn embedding_provider_capabilities_split_privacy_correctly() { + let config = lookup("intelligence.embedding_provider_config") + .expect("embedding_provider_config registered"); + let test = + lookup("intelligence.embedding_provider_test").expect("embedding_provider_test registered"); + + let config_privacy = config + .privacy + .expect("config capability has privacy annotation"); + assert!( + !config_privacy.leaves_device, + "configuration writes only to local keyring; nothing should leave the device" + ); + + let test_privacy = test + .privacy + .expect("test capability has privacy annotation"); + assert!( + test_privacy.leaves_device, + "test fires a probe at the configured provider — must report as leaves_device" + ); +} + +/// The Test Connection probe can hit any of the configured providers, not +/// just the managed cloud default. Pinning the destinations list defends +/// the Privacy surface against silently shrinking back to a single +/// destination — that's the exact under-reporting failure flagged in #2656 +/// review (CodeRabbit + @graycyrus both pointed at the same line). +#[test] +fn embedding_provider_test_destinations_cover_all_providers() { + let cap = + lookup("intelligence.embedding_provider_test").expect("embedding_provider_test registered"); + let privacy = cap.privacy.expect("test capability has privacy annotation"); + + // Joining the destinations into a single haystack so the assertions + // tolerate cosmetic punctuation changes (parens, suffixes) but still + // catch a destination genuinely going missing. + let haystack = privacy.destinations.join(" | ").to_lowercase(); + + for needle in ["openhuman", "openai", "cohere"] { + assert!( + haystack.contains(needle), + "test probe destinations must list `{needle}` — without it the \ + Privacy surface under-reports when that provider is selected. \ + Current destinations: {:?}", + privacy.destinations + ); + } + // The "custom OpenAI-compatible" path is a real provider option in + // #2583 — listed as `custom:` in `create_embedding_provider`. + assert!( + haystack.contains("custom") || haystack.contains("user-configured"), + "test probe destinations must acknowledge user-configured custom \ + endpoints. Current destinations: {:?}", + privacy.destinations + ); + + // Belt-and-braces: at least 4 distinct destinations (managed + + // openai + cohere + custom). A drop below this means someone + // collapsed entries. + assert!( + privacy.destinations.len() >= 4, + "expected ≥4 destinations covering managed + openai + cohere + custom, \ + got {}: {:?}", + privacy.destinations.len(), + privacy.destinations + ); +}