diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index 767f94ec1a..24b73822e4 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -27,9 +27,9 @@ OpenBitFun Playbook currently contains **22 features**, **22 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 或可解析的源码标记;这些证据不会进入公开目录。当前 **706** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 +说明书、网站、搜索和智能体只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **707** 个 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 **706** 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 **707** 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 b17a2f36a8..bc30805684 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -2,10 +2,10 @@ "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", "catalogDigest": "8a02be2b328791ebb823e4b7a956bf0371c523caa7f881a6c778cbaa42d72575", - "commandCount": 706, + "commandCount": 707, "coverage": { - "commandCount": 706, - "documentedCommandCount": 645, + "commandCount": 707, + "documentedCommandCount": 646, "implementationCommandCount": 61, "implementationDigest": "fdb6f78b009b95270d26834349aade36e0987553167f325a9c674a085b876a2d" }, @@ -7176,6 +7176,22 @@ "signature": "fn probe_acp_client_requirements( state: State<'_, AppState>, startup_trace: State<'_, DesktopStartupTrace>, request: ProbeAcpClientRequirementsRequest, ) -> Result, String>", "remoteWorkspacePolicy": "LegacyUnaudited" }, + { + "id": "probe_magpie_gateway", + "moduleId": "commands", + "capabilityId": "setting.ai.models", + "capabilityIds": [ + "setting.ai.models" + ], + "documentedItemIds": [ + "setting.ai.models:available-models" + ], + "visibility": "documented", + "rustPath": "probe_magpie_gateway", + "sourceFile": "src/apps/desktop/src/api/commands.rs", + "signature": "fn probe_magpie_gateway( state: State<'_, AppState>, request: openbitfun_core_types::magpie::MagpieProbeRequest, ) -> Result", + "remoteWorkspacePolicy": "WorkspaceAgnostic" + }, { "id": "product_control_invoke", "moduleId": "openbitfun_control_host", diff --git a/src/apps/cli/src/peer_host/commands/mod.rs b/src/apps/cli/src/peer_host/commands/mod.rs index 6432c20ef7..51b036ac7a 100644 --- a/src/apps/cli/src/peer_host/commands/mod.rs +++ b/src/apps/cli/src/peer_host/commands/mod.rs @@ -33,6 +33,7 @@ pub(crate) fn dispatch<'a>( state: &'a PeerHostState, ) -> std::pin::Pin> + Send + 'a>> { match command { + "probe_magpie_gateway" => Box::pin(models::probe_magpie_gateway(args)), "list_ai_models_by_config" => Box::pin(models::list_ai_models_by_config(args)), "list_visible_subagents" | "list_subagents" | "list_manageable_subagents" => Box::pin(tools::list_subagents(command, args)), "get_session_permission_mode" => Box::pin(permission::session_permission_mode(state, args, false, false)), @@ -322,6 +323,7 @@ pub(crate) const HANDLED_COMMANDS: &[&str] = &[ "get_session_permission_mode", "list_visible_subagents", "list_ai_models_by_config", + "probe_magpie_gateway", "apply_external_source_control_action_command", "archive_session", "cancel_dialog_turn", diff --git a/src/apps/cli/src/peer_host/commands/models.rs b/src/apps/cli/src/peer_host/commands/models.rs index 40f7620452..6891df20ff 100644 --- a/src/apps/cli/src/peer_host/commands/models.rs +++ b/src/apps/cli/src/peer_host/commands/models.rs @@ -227,3 +227,20 @@ mod tests { server.await.unwrap(); } } + +/// Discovery uses this host's network and proxy configuration. +pub(crate) async fn probe_magpie_gateway(args: &Value) -> Result { + let request = serde_json::from_value(crate::peer_host::args::request_value(args).clone()) + .map_err(|_| "Invalid Magpie discovery request".to_string())?; + let config: openbitfun_core::service::config::GlobalConfig = + openbitfun_core::service::config::get_global_config_service() + .await + .map_err(|_| "Failed to read proxy configuration".to_string())? + .get_config(None) + .await + .map_err(|_| "Failed to read proxy configuration".to_string())?; + serde_json::to_value( + openbitfun_core::infrastructure::ai::magpie::probe(request, Some(config.ai.proxy)).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 6227ada1f8..4d2eee30ef 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -6445,3 +6445,17 @@ pub async fn set_workspace_icon( ) -> Result { state.workspace_service.set_workspace_icon(request).await } + +/// Detect Magpie on the selected inference host without running inference. +#[tauri::command] +pub async fn probe_magpie_gateway( + state: State<'_, AppState>, + request: openbitfun_core_types::magpie::MagpieProbeRequest, +) -> Result { + let config: openbitfun_core::service::config::GlobalConfig = state + .config_service + .get_config(None) + .await + .map_err(|_| "Failed to read proxy configuration".to_string())?; + Ok(openbitfun_core::infrastructure::ai::magpie::probe(request, Some(config.ai.proxy)).await) +} diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 98bc1ced3d..04d6722e2c 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1508,6 +1508,7 @@ pub async fn run() { test_ai_config_connection, test_image_generation_service, list_ai_models_by_config, + probe_magpie_gateway, list_subscription_accounts, list_subscription_account_instances, get_subscription_account_quota, diff --git a/src/crates/adapters/ai-adapters/AGENTS.md b/src/crates/adapters/ai-adapters/AGENTS.md index 03fea22a5a..59aec1fc25 100644 --- a/src/crates/adapters/ai-adapters/AGENTS.md +++ b/src/crates/adapters/ai-adapters/AGENTS.md @@ -400,3 +400,18 @@ image model. It does not discover or invoke a chat model. Saved legacy ```bash cargo test --locked -p openbitfun-ai-adapters --features image-generation,subscription-auth --lib client::image_generation::tests::probe_codex_subscription_image_generation -- --ignored --exact --nocapture ``` + +## Magpie gateway discovery + +Magpie discovery is identity plus catalog IO only, never inference or credential +file discovery. Preserve gateway-prefixed URLs, bounded reads, redirect refusal, +and distinct offline/authentication/catalog states. Hosts supply proxy settings. +Loopback discovery and model requests connect directly on the inference host; +remote URLs retain configured proxy policy. Match the effective request URL, +including custom overrides, and do not follow redirects with a direct client. +Verify with `cargo test --locked -p openbitfun-ai-adapters --lib magpie::tests` +and `cargo test --locked -p openbitfun-ai-adapters --lib client::http::tests`. +The ignored `magpie::tests::live_local_gateway_catalog` reads only the default +loopback gateway, without inference. Set `OPENBITFUN_LIVE_MAGPIE_PROXY` to verify +discovery while an explicit outbound proxy is configured. It proves local catalog access, not remote +transport or model generation. diff --git a/src/crates/adapters/ai-adapters/src/client.rs b/src/crates/adapters/ai-adapters/src/client.rs index e64f6f76da..79d1ad3a7e 100644 --- a/src/crates/adapters/ai-adapters/src/client.rs +++ b/src/crates/adapters/ai-adapters/src/client.rs @@ -102,7 +102,11 @@ impl AIClient { proxy_config: Option, stream_options: StreamOptions, ) -> Self { - let client = http::create_http_client(proxy_config, config.skip_ssl_verify); + let client = http::create_model_http_client( + proxy_config, + config.skip_ssl_verify, + &config.request_url, + ); Self { client, config, @@ -872,6 +876,50 @@ mod tests { }) } + #[tokio::test] + async fn loopback_model_request_override_works_with_outbound_proxy_enabled() { + let state = StreamRetryFixtureState { + attempts: Arc::new(AtomicUsize::new(1)), + }; + let app = Router::new() + .route("/v1/chat/completions", post(malformed_stream_then_success)) + .with_state(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!( + "http://{}/v1/chat/completions", + listener.local_addr().unwrap() + ); + let gateway = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + // An invalid proxy cannot serve the request. The base URL is deliberately + // remote: the explicit request override is the actual transport target. + let mut config = make_test_client("openai", None).config; + config.request_url = endpoint; + let client = AIClient::new_with_proxy( + config, + Some(crate::types::ProxyConfig { + enabled: true, + url: "http://unavailable-proxy.invalid:8080".into(), + ..Default::default() + }), + ); + let result = tokio::time::timeout( + std::time::Duration::from_secs(3), + client.send_message(vec![], None), + ) + .await; + gateway.abort(); + assert_eq!( + result + .expect("local gateway request timed out") + .unwrap() + .text, + "Recovered" + ); + assert_eq!(state.attempts.load(Ordering::SeqCst), 2); + } + fn make_trim_test_client(format: &str) -> AIClient { let mut client = make_test_client(format, None); client.config.custom_request_body_mode = Some("trim".to_string()); diff --git a/src/crates/adapters/ai-adapters/src/client/http.rs b/src/crates/adapters/ai-adapters/src/client/http.rs index 907d103c5c..cbc4dfeb95 100644 --- a/src/crates/adapters/ai-adapters/src/client/http.rs +++ b/src/crates/adapters/ai-adapters/src/client/http.rs @@ -14,8 +14,8 @@ pub(crate) fn create_router_http_client( create_router_http_client_with_connection_mode(timeout, proxy_config, skip_ssl_verify, false) } -/// Only explicit local classifier profiles opt into a direct loopback connection. -/// Other Router and model traffic keeps the existing shared proxy policy. +/// Callers with a verified loopback target may opt into a direct connection. +/// Other Router traffic keeps the existing shared proxy policy. pub(crate) fn create_router_http_client_with_connection_mode( timeout: std::time::Duration, proxy_config: Option, @@ -38,6 +38,50 @@ pub(crate) fn create_router_http_client_with_connection_mode( pub(crate) fn create_http_client( proxy_config: Option, skip_ssl_verify: bool, +) -> Client { + create_http_client_with_connection_mode(proxy_config, skip_ssl_verify, false) +} + +/// Loopback model servers belong to the inference host, not its outbound proxy. +/// Inspect the effective request URL so custom remote endpoints still use proxy policy. +pub(crate) fn create_model_http_client( + proxy_config: Option, + skip_ssl_verify: bool, + request_url: &str, +) -> Client { + if is_loopback_url(request_url) { + create_http_client_with_connection_mode(proxy_config, skip_ssl_verify, true) + } else { + create_http_client(proxy_config, skip_ssl_verify) + } +} + +pub(crate) fn is_loopback_url(address: &str) -> bool { + let Ok(url) = reqwest::Url::parse(address) else { + return false; + }; + if !matches!(url.scheme(), "http" | "https") { + return false; + } + let Some(host) = url.host_str() else { + return false; + }; + if host.trim_end_matches('.').eq_ignore_ascii_case("localhost") { + return true; + } + match host.trim_matches(['[', ']']).parse::() { + Ok(std::net::IpAddr::V4(ip)) => ip.is_loopback(), + Ok(std::net::IpAddr::V6(ip)) => { + ip.is_loopback() || ip.to_ipv4_mapped().is_some_and(|ipv4| ipv4.is_loopback()) + } + Err(_) => false, + } +} + +fn create_http_client_with_connection_mode( + proxy_config: Option, + skip_ssl_verify: bool, + direct: bool, ) -> Client { openbitfun_services_core::tls_provider::ensure_ring_crypto_provider(); let mut builder = Client::builder() @@ -61,7 +105,17 @@ pub(crate) fn create_http_client( ); } - builder = apply_proxy_policy(builder, proxy_config.clone()); + let apply_policy = |builder: ClientBuilder| { + if direct { + // Do not let a local gateway redirect a direct client to another host. + builder + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + } else { + apply_proxy_policy(builder, proxy_config.clone()) + } + }; + builder = apply_policy(builder); match builder.build() { Ok(client) => client, @@ -70,9 +124,9 @@ pub(crate) fn create_http_client( "HTTP client initialization failed: {}, using default transport options", e ); - apply_proxy_policy(Client::builder(), proxy_config) + apply_policy(Client::builder()) .build() - .expect("Failed to initialize HTTP client with configured proxy policy") + .expect("Failed to initialize HTTP client with configured connection policy") } } } @@ -184,6 +238,66 @@ mod tests { (url, server) } + #[test] + fn loopback_target_matcher_rejects_remote_and_lookalike_hosts() { + for url in [ + "http://127.0.0.1:3425/v1", + "http://127.2.3.4/", + "http://localhost/", + "https://LOCALHOST./v1", + "http://[::1]:3425/", + "http://[::ffff:127.0.0.1]/", + ] { + assert!(super::is_loopback_url(url), "{url}"); + } + for url in [ + "http://localhost.example/", + "http://localhost@remote.example/", + "http://192.168.1.1/", + "http://[::2]/", + "file://localhost/tmp/key", + "invalid", + ] { + assert!(!super::is_loopback_url(url), "{url}"); + } + } + + #[tokio::test] + async fn model_transport_bypasses_proxy_only_for_loopback_targets() { + let (direct_url, direct_server) = response_fixture("local model").await; + let (proxy_url, proxy_server) = response_fixture("configured proxy").await; + let proxy = Some(ProxyConfig { + enabled: true, + url: proxy_url, + ..Default::default() + }); + for url in [ + direct_url.clone(), + direct_url.replace("127.0.0.1", "localhost"), + ] { + let client = super::create_model_http_client(proxy.clone(), false, &url); + assert_eq!( + client.get(url).send().await.unwrap().text().await.unwrap(), + "local model" + ); + } + let remote = "http://remote-magpie.invalid/v1"; + let client = super::create_model_http_client(proxy, false, remote); + assert_eq!( + client + .get(remote) + .send() + .await + .unwrap() + .text() + .await + .unwrap(), + "configured proxy" + ); + direct_server.abort(); + proxy_server.abort(); + } + #[tokio::test] async fn system_proxy_preserves_loopback_bypass_and_proxies_other_hosts() { openbitfun_services_core::tls_provider::ensure_ring_crypto_provider(); diff --git a/src/crates/adapters/ai-adapters/src/client/image_generation.rs b/src/crates/adapters/ai-adapters/src/client/image_generation.rs index ad36ba2b19..5a6784d650 100644 --- a/src/crates/adapters/ai-adapters/src/client/image_generation.rs +++ b/src/crates/adapters/ai-adapters/src/client/image_generation.rs @@ -651,8 +651,11 @@ pub async fn generate( } let url = endpoint(target)?; - let client = - crate::client::http::create_http_client(target.proxy.clone(), target.skip_ssl_verify); + let client = crate::client::http::create_model_http_client( + target.proxy.clone(), + target.skip_ssl_verify, + &url, + ); let body = build_body(target, &request); let model = body diff --git a/src/crates/adapters/ai-adapters/src/lib.rs b/src/crates/adapters/ai-adapters/src/lib.rs index 9433ebd547..24021d91b8 100644 --- a/src/crates/adapters/ai-adapters/src/lib.rs +++ b/src/crates/adapters/ai-adapters/src/lib.rs @@ -4,6 +4,7 @@ pub mod client; pub mod diagnostics; #[cfg(feature = "local-tokenizer")] pub mod local_tokenizer; +pub mod magpie; pub mod model_selector; pub mod models_dev; pub mod providers; diff --git a/src/crates/adapters/ai-adapters/src/magpie.rs b/src/crates/adapters/ai-adapters/src/magpie.rs new file mode 100644 index 0000000000..2bb68fbdea --- /dev/null +++ b/src/crates/adapters/ai-adapters/src/magpie.rs @@ -0,0 +1,395 @@ +//! Magpie identity and catalog protocol. Runs on the inference host; never infers. +use crate::{ + client::http::{create_router_http_client_with_connection_mode, is_loopback_url}, + ProxyConfig, +}; +use openbitfun_core_types::magpie::{ + MagpieModel, MagpieProbeRequest, MagpieProbeResult, MagpieProbeStatus, +}; +use reqwest::{Client, Response, Url}; +use serde::Deserialize; +use std::time::Duration; + +const BODY_LIMIT: usize = 2 * 1024 * 1024; + +fn gateway_root(address: &str) -> Option { + let mut url = Url::parse(address.trim()).ok()?; + if !matches!(url.scheme(), "http" | "https") + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return None; + } + let path = url.path().trim_end_matches('/'); + let root = path.strip_suffix("/v1").unwrap_or(path).to_string(); + url.set_path(&format!("{root}/")); + Some(url) +} + +async fn json_body(mut response: Response) -> Option { + if response + .content_length() + .is_some_and(|size| size > BODY_LIMIT as u64) + { + return None; + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.ok()? { + if bytes.len() + chunk.len() > BODY_LIMIT { + return None; + } + bytes.extend_from_slice(&chunk); + } + serde_json::from_slice(&bytes).ok() +} + +/// Read-only discovery with bounded requests, no redirects and no error-body echo. +pub async fn probe(request: MagpieProbeRequest, proxy: Option) -> MagpieProbeResult { + let Some(root) = gateway_root(&request.base_url) else { + return MagpieProbeResult { + status: MagpieProbeStatus::InvalidAddress, + base_url: String::new(), + version: None, + models: vec![], + }; + }; + let mut result = MagpieProbeResult { + status: MagpieProbeStatus::Unreachable, + base_url: root.join("v1").expect("valid relative URL").to_string(), + version: None, + models: vec![], + }; + let Ok(client) = create_router_http_client_with_connection_mode( + Duration::from_secs(8), + proxy, + false, + is_loopback_url(root.as_str()), + ) else { + return result; + }; + let key = if request.api_key.trim().is_empty() { + "magpie-openbitfun" + } else { + request.api_key.trim() + }; + if key.contains(['\r', '\n']) { + result.status = MagpieProbeStatus::AccessDenied; + return result; + } + match tokio::time::timeout( + Duration::from_secs(12), + probe_with_client(&client, &root, key, &mut result), + ) + .await + { + Ok(()) => result, + Err(_) => { + result.status = if result.status == MagpieProbeStatus::CatalogError { + MagpieProbeStatus::CatalogError + } else { + MagpieProbeStatus::Unreachable + }; + result + } + } +} + +async fn probe_with_client(client: &Client, root: &Url, key: &str, result: &mut MagpieProbeResult) { + let get = |path: &str| { + client + .get(root.join(path).expect("valid relative URL")) + .header(reqwest::header::USER_AGENT, "OpenBitFun/1.0") + .bearer_auth(key) + }; + let Ok(response) = get("api/hello").send().await else { + return; + }; + if matches!(response.status().as_u16(), 401 | 403) { + result.status = MagpieProbeStatus::AccessDenied; + return; + } + result.status = MagpieProbeStatus::Unrecognized; + if !response.status().is_success() { + return; + } + #[derive(Deserialize)] + struct Hello { + name: String, + #[serde(default)] + version: Option, + } + let Some(hello) = json_body::(response).await else { + return; + }; + if hello.name != "magpie" { + return; + } + result.version = hello.version.filter(|value| value.len() <= 100); + result.status = MagpieProbeStatus::CatalogError; + let Ok(response) = get("v1/models").send().await else { + return; + }; + if matches!(response.status().as_u16(), 401 | 403) { + result.status = MagpieProbeStatus::AccessDenied; + return; + } + if !response.status().is_success() { + return; + } + #[derive(Deserialize)] + struct Catalog { + data: Vec, + } + let Some(catalog) = json_body::(response).await else { + return; + }; + let mut seen = std::collections::HashSet::new(); + if catalog + .data + .iter() + .any(|model| model.id.trim().is_empty() || model.id.len() > 1024) + { + return; + } + result.models = catalog + .data + .into_iter() + .filter(|model| seen.insert(model.id.clone())) + .collect(); + result.status = if result.models.is_empty() { + MagpieProbeStatus::Empty + } else { + MagpieProbeStatus::Ready + }; +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{routing::get, Json, Router}; + use serde_json::json; + + async fn server( + hello: serde_json::Value, + status: u16, + catalog: serde_json::Value, + ) -> (String, tokio::task::JoinHandle<()>) { + let app = Router::new() + .route("/prefix/api/hello", get(move || async move { Json(hello) })) + .route( + "/prefix/v1/models", + get(move |headers: axum::http::HeaderMap| async move { + assert_eq!(headers["authorization"], "Bearer magpie-openbitfun"); + assert_eq!(headers["user-agent"], "OpenBitFun/1.0"); + ( + axum::http::StatusCode::from_u16(status).unwrap(), + Json(catalog), + ) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = format!("http://{}/prefix/v1/", listener.local_addr().unwrap()); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (address, task) + } + + #[tokio::test] + async fn identity_catalog_and_failures_are_distinct() { + for (name, status, body, expected) in [ + ("magpie", 200, json!({"data": []}), MagpieProbeStatus::Empty), + ( + "other", + 200, + json!({"data": []}), + MagpieProbeStatus::Unrecognized, + ), + ( + "magpie", + 403, + json!({"error": "secret"}), + MagpieProbeStatus::AccessDenied, + ), + ( + "magpie", + 200, + json!({"unexpected": []}), + MagpieProbeStatus::CatalogError, + ), + ( + "magpie", + 200, + json!({"data":[{"id":"group/work","context_window":12345},{"id":"group/work"}]}), + MagpieProbeStatus::Ready, + ), + ] { + let (base_url, task) = + server(json!({"name":name,"version":"test"}), status, body).await; + let result = probe( + MagpieProbeRequest { + base_url, + api_key: String::new(), + }, + None, + ) + .await; + task.abort(); + assert_eq!(result.status, expected); + assert!(result.base_url.ends_with("/prefix/v1")); + if expected == MagpieProbeStatus::Ready { + assert_eq!(result.models.len(), 1); + assert_eq!(result.models[0].context_window, Some(12345)); + } + assert!(!serde_json::to_string(&result).unwrap().contains("secret")); + } + } + + #[test] + fn validates_addresses_without_losing_proxy_prefixes() { + for address in [ + "file:///tmp/key", + "http://key@localhost", + "https://host/?key=secret", + "http://host/#key", + ] { + assert!(gateway_root(address).is_none()); + } + assert_eq!( + gateway_root("https://host/proxy/v1/").unwrap().as_str(), + "https://host/proxy/" + ); + } + + #[tokio::test] + async fn loopback_gateway_bypasses_configured_proxy() { + let (base_url, gateway) = server( + json!({"name":"magpie","version":"test"}), + 200, + json!({"data":[]}), + ) + .await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_url = format!("http://{}", listener.local_addr().unwrap()); + let hits = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let proxy_hits = hits.clone(); + let proxy = tokio::spawn(async move { + axum::serve( + listener, + Router::new().fallback(move || { + proxy_hits.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async { axum::http::StatusCode::BAD_GATEWAY } + }), + ) + .await + .unwrap(); + }); + let result = probe( + MagpieProbeRequest { + base_url, + api_key: String::new(), + }, + Some(ProxyConfig { + enabled: true, + url: proxy_url, + ..Default::default() + }), + ) + .await; + gateway.abort(); + proxy.abort(); + assert_eq!(result.status, MagpieProbeStatus::Empty); + assert_eq!(hits.load(std::sync::atomic::Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn remote_gateway_keeps_configured_proxy() { + let (proxy_url, proxy) = server( + json!({"name":"magpie","version":"proxy"}), + 200, + json!({"data":[{"id":"fixture-model"}]}), + ) + .await; + let result = probe( + MagpieProbeRequest { + base_url: "http://remote-magpie.invalid/prefix/v1".into(), + api_key: String::new(), + }, + Some(ProxyConfig { + enabled: true, + url: proxy_url.replace("/prefix/v1/", ""), + ..Default::default() + }), + ) + .await; + proxy.abort(); + assert_eq!(result.status, MagpieProbeStatus::Ready); + assert_eq!(result.models[0].id, "fixture-model"); + } + #[tokio::test] + async fn denies_redirects_and_reports_offline_hosts() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new().route( + "/api/hello", + get(|| async { + axum::response::Redirect::temporary("http://127.0.0.1:1/should-not-receive-key") + }), + ); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let result = probe( + MagpieProbeRequest { + base_url: address.clone(), + api_key: "fixture-key".into(), + }, + None, + ) + .await; + assert_eq!(result.status, MagpieProbeStatus::Unrecognized); + task.abort(); + let _ = task.await; + let result = probe( + MagpieProbeRequest { + base_url: address, + api_key: String::new(), + }, + None, + ) + .await; + assert_eq!(result.status, MagpieProbeStatus::Unreachable); + } + + #[tokio::test] + #[ignore = "Reads the local Magpie gateway without inference or disk credentials"] + async fn live_local_gateway_catalog() { + let result = probe( + MagpieProbeRequest { + base_url: "http://127.0.0.1:3425/v1".into(), + api_key: String::new(), + }, + std::env::var("OPENBITFUN_LIVE_MAGPIE_PROXY") + .ok() + .map(|url| ProxyConfig { + enabled: true, + url, + ..Default::default() + }), + ) + .await; + println!( + "Magpie status: {:?}; version: {:?}; model count: {}", + result.status, + result.version, + result.models.len() + ); + assert!(matches!( + result.status, + MagpieProbeStatus::Ready | MagpieProbeStatus::Empty + )); + } +} diff --git a/src/crates/assembly/core/src/infrastructure/ai/mod.rs b/src/crates/assembly/core/src/infrastructure/ai/mod.rs index 58be690dad..6ff2b0e910 100644 --- a/src/crates/assembly/core/src/infrastructure/ai/mod.rs +++ b/src/crates/assembly/core/src/infrastructure/ai/mod.rs @@ -11,8 +11,8 @@ pub mod tool_call_accumulator; use std::time::Duration; -pub use openbitfun_ai_adapters::providers; pub use openbitfun_ai_adapters::stream as ai_stream_handlers; +pub use openbitfun_ai_adapters::{magpie, providers}; pub use client_factory::{ get_global_ai_client_factory, initialize_global_ai_client_factory, AIClientFactory, 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 4c72040108..8519adfffa 100644 --- a/src/crates/assembly/core/src/infrastructure/ai/provider_catalog.rs +++ b/src/crates/assembly/core/src/infrastructure/ai/provider_catalog.rs @@ -606,7 +606,21 @@ mod tests { #[test] fn overlay_is_valid_and_keeps_product_endpoint_decisions() { let overlay = parse_overlay().expect("valid overlay"); - assert_eq!(overlay.providers.len(), 17); + assert_eq!(overlay.providers.len(), 18); + let magpie = overlay + .providers + .iter() + .find(|provider| provider.id == "magpie") + .expect("Magpie gateway preset"); + assert!(!magpie.requires_api_key); + assert!(magpie.catalog_provider_ids.is_empty()); + assert!(magpie.model_policy.curated_models.is_empty()); + assert_eq!(magpie.endpoints[0].base_url, "http://127.0.0.1:3425/v1"); + assert_eq!(magpie.endpoints[0].api_format, "openai"); + assert!(magpie + .endpoints + .iter() + .all(|endpoint| !endpoint.trusted_for_auto_detection)); let go = overlay .providers .iter() @@ -849,7 +863,13 @@ mod tests { "bundle".to_string(), ProviderCatalogSource::Bundle, ); - assert_eq!(resolved.providers.len(), 17); + assert_eq!(resolved.providers.len(), 18); + let magpie = resolved + .providers + .iter() + .find(|provider| provider.id == "magpie") + .expect("Magpie models must come from its live gateway"); + assert!(magpie.models.is_empty()); let go = resolved .providers .iter() diff --git a/src/crates/contracts/core-types/src/lib.rs b/src/crates/contracts/core-types/src/lib.rs index 741a510095..a4b2440aa2 100644 --- a/src/crates/contracts/core-types/src/lib.rs +++ b/src/crates/contracts/core-types/src/lib.rs @@ -9,6 +9,7 @@ pub mod ai; pub mod background_delivery; pub mod errors; pub mod installer_config_handoff; +pub mod magpie; pub mod model; pub mod model_pool; pub mod product_identity; diff --git a/src/crates/contracts/core-types/src/magpie.rs b/src/crates/contracts/core-types/src/magpie.rs new file mode 100644 index 0000000000..71a5cbfd15 --- /dev/null +++ b/src/crates/contracts/core-types/src/magpie.rs @@ -0,0 +1,78 @@ +//! Read-only Magpie gateway discovery. No inference or credentials in responses. +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Deserialize, Serialize)] +pub struct MagpieProbeRequest { + pub base_url: String, + #[serde(default)] + pub api_key: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MagpieProbeStatus { + Ready, + Empty, + Unreachable, + AccessDenied, + Unrecognized, + CatalogError, + InvalidAddress, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MagpieProbeResult { + pub status: MagpieProbeStatus, + pub base_url: String, + #[serde(default)] + pub version: Option, + #[serde(default)] + pub models: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MagpieModel { + pub id: String, + #[serde(default)] + pub display_name: Option, + #[serde(default)] + pub owned_by: Option, + #[serde(default)] + pub context_window: Option, + #[serde(default)] + pub max_output_tokens: Option, + #[serde(default)] + pub tool_call: Option, + #[serde(default)] + pub reasoning: Option, + #[serde(default)] + pub modalities: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MagpieModalities { + #[serde(default)] + pub input: Vec, + #[serde(default)] + pub output: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn minimal_and_future_payloads_round_trip() { + let request: MagpieProbeRequest = serde_json::from_value(serde_json::json!({ + "base_url": "http://127.0.0.1:3425", "future": true + })) + .unwrap(); + assert!(request.api_key.is_empty()); + let model: MagpieModel = + serde_json::from_value(serde_json::json!({"id":"group/work", "future":true})).unwrap(); + assert!(model.context_window.is_none()); + let reread: MagpieModel = + serde_json::from_value(serde_json::to_value(model).unwrap()).unwrap(); + assert_eq!(reread.id, "group/work"); + assert!(reread.tool_call.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 ad7ebde85b..4a4a6c045b 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:a7e5f2b6b738cb96", + "digest": "fnv1a64:8b695d77ffe4587b", "retiredCommandPrefixes": [ { "prefix": "lsp_", @@ -12,6 +12,7 @@ "gen_ui_libraries_v1", "model_routing_v1", "model_pool_selection_v1", + "magpie_discovery_v1", "background_result_delivery_v1", "dialog_queue_v1", "idempotent_dialog_submit", @@ -62,6 +63,7 @@ "gen_ui_libraries_v1", "model_routing_v1", "model_pool_selection_v1", + "magpie_discovery_v1", "background_result_delivery_v1", "dialog_queue_v1", "idempotent_dialog_submit", @@ -110,6 +112,7 @@ "cli": [ "model_routing_v1", "model_pool_selection_v1", + "magpie_discovery_v1", "background_result_delivery_v1", "dialog_queue_v1", "control_conversation_v1", @@ -5625,6 +5628,17 @@ "reason": "ACP agents cannot be managed or launched from a controller on the CLI peer host yet; use the `acp clients` commands on that device" } }, + { + "id": "probe_magpie_gateway", + "surface": "tauri_command", + "remoteWorkspace": "WorkspaceAgnostic", + "peer": { + "kind": "proxied" + }, + "cliPeer": { + "kind": "handled" + } + }, { "id": "product_control_invoke", "surface": "tauri_command", diff --git a/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs b/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs index 365225a6ba..043a4e4fce 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs @@ -25,6 +25,7 @@ pub enum PeerHostCapability { GenUiLibrariesV1, ModelRoutingV1, ModelPoolSelectionV1, + MagpieDiscoveryV1, /// Versioned, execution-host-owned background report decisions are persisted and projected. BackgroundResultDeliveryV1, DialogQueueV1, @@ -131,6 +132,7 @@ impl PeerHostCapability { Self::GenUiLibrariesV1, Self::ModelRoutingV1, Self::ModelPoolSelectionV1, + Self::MagpieDiscoveryV1, Self::BackgroundResultDeliveryV1, Self::DialogQueueV1, Self::IdempotentDialogSubmit, @@ -184,6 +186,7 @@ impl PeerHostCapability { Self::GenUiLibrariesV1 => "gen_ui_libraries_v1", Self::ModelRoutingV1 => "model_routing_v1", Self::ModelPoolSelectionV1 => "model_pool_selection_v1", + Self::MagpieDiscoveryV1 => "magpie_discovery_v1", Self::BackgroundResultDeliveryV1 => "background_result_delivery_v1", Self::DialogQueueV1 => "dialog_queue_v1", Self::IdempotentDialogSubmit => "idempotent_dialog_submit", @@ -244,6 +247,7 @@ const DESKTOP_CAPABILITIES: &[PeerHostCapability] = &[ PeerHostCapability::GenUiLibrariesV1, PeerHostCapability::ModelRoutingV1, PeerHostCapability::ModelPoolSelectionV1, + PeerHostCapability::MagpieDiscoveryV1, PeerHostCapability::BackgroundResultDeliveryV1, PeerHostCapability::DialogQueueV1, PeerHostCapability::IdempotentDialogSubmit, @@ -295,6 +299,7 @@ const DESKTOP_CAPABILITIES: &[PeerHostCapability] = &[ const CLI_CAPABILITIES: &[PeerHostCapability] = &[ PeerHostCapability::ModelRoutingV1, PeerHostCapability::ModelPoolSelectionV1, + PeerHostCapability::MagpieDiscoveryV1, PeerHostCapability::BackgroundResultDeliveryV1, PeerHostCapability::DialogQueueV1, PeerHostCapability::ControlConversationV1, 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 b5a1ef97a4..4e6bde5749 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/table.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/table.rs @@ -561,6 +561,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("predownload_acp_client_adapter", Unaudited, Proxied, CLI_NO_ACP_CLIENT_HOST), op("preview_commit_message", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("probe_acp_client_requirements", Unaudited, Proxied, CLI_NO_ACP_CLIENT_HOST), + op("probe_magpie_gateway", Agnostic, Proxied, HANDLED), op("product_control_invoke", Agnostic, Proxied, HANDLED), op("project_ai_model_reasoning_catalog", Agnostic, Proxied, HANDLED), op("quick_commit_message", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), diff --git a/src/shared/ai-provider-catalog/providers.json b/src/shared/ai-provider-catalog/providers.json index 0d65601cfc..bf1bcbb646 100644 --- a/src/shared/ai-provider-catalog/providers.json +++ b/src/shared/ai-provider-catalog/providers.json @@ -2,6 +2,26 @@ "schema_version": 1, "catalog_kind": "openbitfun_builtin_provider_overlay", "providers": [ + { + "id": "magpie", + "display_order": 5, + "region": "any", + "name": "Magpie", + "description": "Connect models and routing groups through the Magpie gateway", + "help_url": "https://usemagpie.ai/docs/integrate", + "requires_api_key": false, + "catalog_provider_ids": [], + "endpoints": [ + { "id": "openai", "base_url": "http://127.0.0.1:3425/v1", "api_format": "openai", "label": "openai", "is_default": true, "trusted_for_auto_detection": false, "catalog_provider_ids": [] }, + { "id": "responses", "base_url": "http://127.0.0.1:3425/v1", "api_format": "responses", "label": "responses", "trusted_for_auto_detection": false, "catalog_provider_ids": [] }, + { "id": "anthropic", "base_url": "http://127.0.0.1:3425", "api_format": "anthropic", "label": "anthropic", "trusted_for_auto_detection": false, "catalog_provider_ids": [] } + ], + "model_policy": { + "mode": "curated", + "curated_models": [], + "additional_models": [] + } + }, { "id": "openbitfun", "display_order": 0, diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 2f27184a64..4f16db2661 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -7026,7 +7026,8 @@ }, "evidence": [ "command:get_ai_model_catalog", - "command:list_ai_models_by_config" + "command:list_ai_models_by_config", + "command:probe_magpie_gateway" ] }, { diff --git a/src/web-ui/README.md b/src/web-ui/README.md index 149e8538bc..5fbbf15b7f 100644 --- a/src/web-ui/README.md +++ b/src/web-ui/README.md @@ -82,6 +82,34 @@ All folders in one project must belong to the same connection/environment; remov the selected folders before changing either source dimension. The form does not show recent projects. +## Connecting Magpie + +Open **Settings → Models → Model sources → Detect Magpie**. The inline panel +checks gateway identity and reads its published model catalog without sending +an inference request. Start Magpie (or run a Magpie server) on the OpenBitFun host; +the default address is `http://127.0.0.1:3425/v1` with `magpie-openbitfun` as +the local app identity. Enter a custom address for another port or reverse proxy. +Remote/Docker gateways require a gateway key created in Magpie. +Loopback gateways connect directly on that host even when an outbound model proxy +is enabled; remote gateway addresses keep the configured proxy policy. + +Select the desired models or routing groups and choose **Add models**. They appear +in **All models**, where they can be tested, assigned as defaults, or added to +OpenBitFun model pools. Magpie manages the members and routing inside its own +groups. **Manage models** reopens existing gateways; refreshing never deletes +saved models, defaults or pool references. Missing entries are shown explicitly. + +Discovery runs on the selected OpenBitFun inference host, including Desktop and +CLI peers; an SSH workspace does not move inference onto that SSH machine. Older +peers show an unsupported state and retain manual gateway configuration. Remote +control and detached jobs use the host's saved models through their existing +model-selection paths; they do not need this settings panel attached. Directory +access does not establish that inference works. Use the model's connection test +when you want to send an actual inference request. + +Chat Completions is the import default. Use the existing model editor for +Responses, Anthropic Messages, or manual setup when detection is unavailable. + ## MCP configuration In Desktop, open **Settings → Tools → MCP** to add or edit a user-level server diff --git a/src/web-ui/src/app/scenes/settings/AGENTS.md b/src/web-ui/src/app/scenes/settings/AGENTS.md index 09f0eb7083..d025e64215 100644 --- a/src/web-ui/src/app/scenes/settings/AGENTS.md +++ b/src/web-ui/src/app/scenes/settings/AGENTS.md @@ -173,3 +173,13 @@ addition to the existing account-quota capability. Keep API-key model setup in the ordinary provider/custom model flow; MiniMax, Kimi Code and StepFun have no separate subscription importer or allowance UI. The existing OpenCode Go path retains its independently advertised capability. + +For inline Magpie identity/catalog detection, idempotent imports, retained missing +models, unsupported peers and surface-switch isolation, run: + +```bash +pnpm --dir src/web-ui run test:run src/app/scenes/settings/pages/ai/ModelSettingsPage.loading.test.tsx src/infrastructure/config/services/magpieModels.test.ts src/infrastructure/api/adapters/peer-device-adapter.test.ts src/infrastructure/peer-device/PeerConnectionManager.test.ts +``` + +These fixtures do not establish live Peer, SSH, Remote Control or Detached Dispatch +behavior. Imported models use the existing host configuration and selector paths. diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/MagpieSourceSection.scss b/src/web-ui/src/app/scenes/settings/pages/ai/MagpieSourceSection.scss new file mode 100644 index 0000000000..dac151a12b --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/pages/ai/MagpieSourceSection.scss @@ -0,0 +1,42 @@ +.openbitfun-magpie-source { + padding: var(--openbitfun-space-4); + border-block-start: 1px solid var(--openbitfun-color-border-default); + + > p { + color: var(--openbitfun-color-content-secondary); + margin-block: var(--openbitfun-space-2); + } +} + +.openbitfun-magpie-source__actions { + display: flex; + flex-wrap: wrap; + gap: var(--openbitfun-space-2); + margin-block: var(--openbitfun-space-3); +} + +.openbitfun-magpie-source__catalog { + max-block-size: 20rem; + overflow: auto; + margin-block: var(--openbitfun-space-3); +} + +.openbitfun-magpie-source__model { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--openbitfun-space-2); + padding-block: var(--openbitfun-space-2); + + > [data-openbitfun-component='checkbox'], + > span:not([data-openbitfun-component='status-pill']) { + flex: 1 1 12rem; + min-inline-size: 0; + overflow-wrap: anywhere; + } + + > [data-openbitfun-component='status-pill'] { + flex: 0 0 auto; + max-inline-size: 100%; + } +} diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/MagpieSourceSection.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/MagpieSourceSection.tsx new file mode 100644 index 0000000000..4078b22423 --- /dev/null +++ b/src/web-ui/src/app/scenes/settings/pages/ai/MagpieSourceSection.tsx @@ -0,0 +1,196 @@ +import { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import { Button, Checkbox, Input, SearchField, StatusPill } from '@openbitfun/ui'; +import { ConfigPageRow } from '@/infrastructure/config/components/common'; +import { configManager } from '@/infrastructure/config/services/ConfigManager'; +import { useSettingsDraft } from '@/infrastructure/config/settingsDraftRegistry'; +import type { AIModelConfig } from '@/infrastructure/config/types'; +import { useI18n } from '@/infrastructure/i18n'; +import { peerConnectionManager } from '@/infrastructure/peer-device/PeerConnectionManager'; +import { getActiveSurfaceScope, onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; +import { magpieAPI, supportsMagpieDiscovery, type MagpieProbeResult } from '@/infrastructure/api/service-api/MagpieAPI'; +import { MAGPIE_APP_KEY, MAGPIE_DEFAULT_URL, magpieConnections, magpieGatewayUrl, magpieModelImportIssue, mergeMagpieModels, type MagpieConnection } from '@/infrastructure/config/services/magpieModels'; +import './MagpieSourceSection.scss'; + +interface Props { + models: AIModelConfig[]; + ready: boolean; + onModelsChange: (models: AIModelConfig[]) => void; + onManual: () => void; +} + +export function MagpieSourceSection(props: Props) { + const scope = useSyncExternalStore(onSurfaceActivated, getActiveSurfaceScope, getActiveSurfaceScope); + return ; +} + +function MagpieSourceEditor({ models, ready, onModelsChange, onManual }: Props) { + const { t, formatNumber } = useI18n('settings/models'); + const [scope] = useState(getActiveSurfaceScope); + const panelId = useId(); + const connections = useMemo(() => magpieConnections(models), [models]); + const [connectionId, setConnectionId] = useState(); + const [connection, setConnection] = useState(); + const [draftModels, setDraftModels] = useState(models); + const [expanded, setExpanded] = useState(false); + const [baseUrl, setBaseUrl] = useState(MAGPIE_DEFAULT_URL); + const [apiKey, setApiKey] = useState(MAGPIE_APP_KEY); + const [baseline, setBaseline] = useState({ baseUrl: MAGPIE_DEFAULT_URL, apiKey: MAGPIE_APP_KEY }); + const [result, setResult] = useState(); + const [checking, setChecking] = useState(false); + const [saving, setSaving] = useState(false); + const [selected, setSelected] = useState([]); + const [query, setQuery] = useState(''); + const [failed, setFailed] = useState(false); + const [saved, setSaved] = useState(false); + const sequence = useRef(0); + const supported = useSyncExternalStore( + listener => peerConnectionManager.subscribe(listener), supportsMagpieDiscovery, supportsMagpieDiscovery, + ); + const imported = new Set((connection?.models ?? models.filter(model => + magpieGatewayUrl(model.base_url) === magpieGatewayUrl(baseUrl) + && (model.api_key?.trim() || MAGPIE_APP_KEY) === (apiKey.trim() || MAGPIE_APP_KEY) + && (!model.auth || model.auth.type === 'api_key'))).map(model => model.model_name)); + const catalogReady = result?.status === 'ready' || result?.status === 'empty'; + const credentialsChanged = baseUrl !== baseline.baseUrl || apiKey !== baseline.apiKey; + const dirty = selected.length > 0 || credentialsChanged; + const canSave = supported && ready && catalogReady && !checking && !saving && !failed + && (selected.length > 0 || (Boolean(connection) && credentialsChanged)); + useEffect(() => () => { sequence.current += 1; }, []); + useEffect(() => { + if (!supported) { sequence.current += 1; setChecking(false); setResult(undefined); setSelected([]); } + }, [supported]); + + const invalidate = () => { sequence.current += 1; setResult(undefined); setChecking(false); setSelected([]); setSaved(false); setFailed(false); }; + const detect = async (address = baseUrl, key = apiKey) => { + if (!supported || saving || !scope.isCurrent()) return; + const request = ++sequence.current; + // Keep the last catalog mounted while refreshing the same connection. + // Address/key edits and connection switches clear it through invalidate(). + setChecking(true); + try { + const response = await magpieAPI.probe(address, key); + if (!scope.isCurrent() || request !== sequence.current) return; + setResult(response); setFailed(false); setSaved(false); + setSelected(previous => previous.filter(id => response.models.some(model => model.id === id && !magpieModelImportIssue(model)))); + } catch { + if (scope.isCurrent() && request === sequence.current) { setFailed(true); setSaved(false); } + } finally { + if (scope.isCurrent() && request === sequence.current) setChecking(false); + } + }; + const chooseConnection = (id?: string) => { + invalidate(); + const next = connections.find(item => item.id === id); + const values = { baseUrl: next?.baseUrl ?? MAGPIE_DEFAULT_URL, apiKey: next?.apiKey ?? MAGPIE_APP_KEY }; + setConnection(next); setDraftModels(models); + setConnectionId(next?.id); setBaseUrl(values.baseUrl); setApiKey(values.apiKey); setBaseline(values); setQuery(''); + void detect(values.baseUrl, values.apiKey); + }; + const save = async (): Promise => { + if (!canSave || !result || !scope.isCurrent()) return false; + setSaving(true); setFailed(false); + try { + const next = await configManager.updateConfig('ai.models', current => { + scope.assertCurrent('import Magpie models'); + return mergeMagpieModels(current, result.models, selected, result.base_url, apiKey, connection); + }, { expectedValue: draftModels }); + if (!scope.isCurrent()) return false; + onModelsChange(next); + const linked = magpieConnections(next).find(item => item.baseUrl === result.base_url && item.apiKey === (apiKey.trim() || MAGPIE_APP_KEY)); + setConnection(linked); setDraftModels(next); + setConnectionId(linked?.id); setBaseUrl(result.base_url); + setBaseline({ baseUrl: result.base_url, apiKey }); setSelected([]); setSaved(true); + return true; + } catch { + if (scope.isCurrent()) setFailed(true); + return false; + } finally { if (scope.isCurrent()) setSaving(false); } + }; + const discard = () => { + if (saving) return; + invalidate(); setBaseUrl(baseline.baseUrl); setApiKey(baseline.apiKey); setExpanded(false); + }; + useSettingsDraft({ id: 'magpie-source', pageId: 'ai.models', label: t('providers.magpie.name'), dirty, saving, save, discard }); + + const statusLabels = { + ready: t('magpie.states.ready'), empty: t('magpie.states.empty'), + unreachable: t('magpie.states.unreachable'), access_denied: t('magpie.states.accessDenied'), + unrecognized: t('magpie.states.unrecognized'), catalog_error: t('magpie.states.catalogError'), + invalid_address: t('magpie.states.invalidAddress'), + }; + const importIssues = { + contextTooSmall: t('magpie.contextTooSmall'), invalidLimits: t('magpie.invalidLimits'), + }; + const status = !supported ? t('magpie.states.unsupported') : checking && !result && !failed ? t('magpie.states.checking') + : failed ? t('magpie.states.failed') : saved ? t('magpie.states.saved') + : result ? statusLabels[result.status] : t('magpie.states.idle'); + const visible = result?.models.filter(model => !query.trim() + || (model.id + ' ' + (model.display_name ?? '')).toLowerCase().includes(query.trim().toLowerCase())) ?? []; + const missing = catalogReady ? [...imported].filter(id => !result.models.some(model => model.id === id)) : []; + + return <> + + + + {expanded &&
+ {connections.length > 0 &&
+ {connections.map(item => )} + +
} + + { invalidate(); setBaseUrl(event.target.value); }} /> + + + { invalidate(); setApiKey(event.target.value); }} /> + +
+ + +
+

{status}

+ {catalogReady && <> + {result.models.length > 0 && setQuery(event.target.value)} + aria-label={t('magpie.search')} placeholder={t('magpie.search')} />} +
+ {visible.map(model => { + const issue = magpieModelImportIssue(model); + return
+ setSelected(previous => event.target.checked + ? [...previous, model.id] : previous.filter(id => id !== model.id))} /> + {imported.has(model.id) && {t('magpie.imported')}} + {model.id.startsWith('group/') && {t('magpie.routingGroup')}} + {issue && {importIssues[issue]}} +
; + })} + {missing.map(id =>
+ {id}{t('magpie.missing')} +
)} +
+ {missing.length > 0 &&

{t('magpie.missingHint')}

} + {selected.length > 0 &&

{t('magpie.poolHint')}

} + } +
+ + +
+
} + ; +} diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.appearance.ts b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.appearance.ts index 5aa07d8f1a..45ecc274b6 100644 --- a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.appearance.ts +++ b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.appearance.ts @@ -4,6 +4,10 @@ export const modelSettingsAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'model-settings', parts: [ { id: 'root' }, + { id: 'magpieSource' }, + { id: 'magpieActions' }, + { id: 'magpieCatalog' }, + { id: 'magpieModel' }, { id: 'providerSelection' }, { id: 'customOption' }, { id: 'customOptionTitle' }, diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.loading.test.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.loading.test.tsx index e58745c244..52b36e4d94 100644 --- a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.loading.test.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.loading.test.tsx @@ -13,6 +13,7 @@ import { i18nService } from '@/infrastructure/i18n'; import { activateSurface } from '@/infrastructure/peer-device/deviceSurface'; import { notificationService } from '@/shared/notification-system'; import ModelSettingsPage from './ModelSettingsPage'; +import * as magpieService from '@/infrastructure/api/service-api/MagpieAPI'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -132,6 +133,165 @@ describe('ModelSettingsPage loading and provider actions', () => { }; const connectionBadge = () => container.querySelector(`[data-testid="settings-model-test-status"][data-config-id="${model.id}"]`); + it.each([false, true])('discovers and saves Magpie models without replacing existing providers (remote gateway: %s)', async remote => { + vi.spyOn(magpieService, 'supportsMagpieDiscovery').mockReturnValue(true); + const discover = vi.spyOn(magpieService.magpieAPI, 'probe').mockImplementation(async base => ({ + status: 'ready', base_url: base, models: [{ id: 'provider/model-with-tools', display_name: 'Gateway model', tool_call: true }], + })); + const inference = vi.spyOn(aiApi, 'testAIConfigConnection'); + const update = vi.spyOn(configManager, 'updateConfig').mockImplementation( + async (_path: string, transform: (current: T) => T) => transform([model] as T), + ); + await act(async () => root.render()); + const connect = () => container.querySelector('[data-testid="settings-model-connect-magpie-btn"]')!; + expect(connect().disabled).toBe(true); + await act(async () => { reads.get('ai.models')!.resolve([model]); reads.get('ai.default_models')!.resolve({}); }); + await act(async () => connect().click()); + const panel = () => container.querySelector('[data-testid="settings-magpie-panel"]')!; + const baseInput = panel().querySelector('[data-testid="settings-magpie-address"]')!; + const keyInput = panel().querySelector('input[type="password"]')!; + expect(baseInput.value).toBe('http://127.0.0.1:3425/v1'); + expect(keyInput.value).toBe('magpie-openbitfun'); + if (remote) { + await inputValue(baseInput, 'https://magpie.example.test/v1'); + await inputValue(keyInput, 'fixture-gateway-key'); + await act(async () => panel().querySelector('[data-testid="settings-magpie-detect"]')!.click()); + } + const base = remote ? 'https://magpie.example.test/v1' : 'http://127.0.0.1:3425/v1'; + const key = remote ? 'fixture-gateway-key' : 'magpie-openbitfun'; + expect(discover).toHaveBeenLastCalledWith(base, key); + await act(async () => panel().querySelector('input[type="checkbox"]')!.click()); + await act(async () => panel().querySelector('[data-testid="settings-magpie-import"]')!.click()); + expect(update).toHaveBeenCalledWith('ai.models', expect.any(Function), { expectedValue: [model] }); + const saved = update.mock.calls.find(([path]) => path === 'ai.models')![1]([model]) as AIModelConfig[]; + expect(saved).toHaveLength(2); + expect(saved[0]).toEqual(model); + expect(saved[1]).toMatchObject({ name: 'Magpie', model_name: 'provider/model-with-tools', + base_url: base, api_key: key, provider: 'openai', capabilities: ['text_chat', 'function_calling'] }); + expect(inference).not.toHaveBeenCalled(); + expect(panel().querySelector('input[type="checkbox"]')!.disabled).toBe(true); + }); + + it.each(['empty', 'unreachable', 'access_denied', 'unrecognized', 'catalog_error'] as const)( + 'shows %s without importing or testing models', async status => { + vi.spyOn(magpieService, 'supportsMagpieDiscovery').mockReturnValue(true); + vi.spyOn(magpieService.magpieAPI, 'probe').mockResolvedValue({ status, base_url: 'http://127.0.0.1:3425/v1', models: [] }); + const inference = vi.spyOn(aiApi, 'testAIConfigConnection'); + const update = vi.spyOn(configManager, 'updateConfig'); + await loadModelPool(); + await act(async () => container.querySelector('[data-testid="settings-model-connect-magpie-btn"]')!.click()); + expect(container.querySelector('[data-testid="settings-magpie-import"]')!.disabled).toBe(true); + expect(container.querySelector('[data-testid="settings-magpie-panel"] [role="status"]')?.textContent).toBeTruthy(); + expect(update).not.toHaveBeenCalled(); + expect(inference).not.toHaveBeenCalled(); + }, + ); + + it('keeps the catalog, search, selection and scroll position while refreshing', async () => { + vi.spyOn(magpieService, 'supportsMagpieDiscovery').mockReturnValue(true); + const catalog: magpieService.MagpieProbeResult = { status: 'ready', + base_url: 'http://127.0.0.1:3425/v1', models: [{ id: 'group/work', display_name: 'Work model' }] }; + const probe = vi.spyOn(magpieService.magpieAPI, 'probe').mockResolvedValue(catalog); + await loadModelPool(); + await act(async () => container.querySelector('[data-testid="settings-model-connect-magpie-btn"]')!.click()); + const panel = container.querySelector('[data-testid="settings-magpie-panel"]')!; + const list = panel.querySelector('[data-openbitfun-part="magpieCatalog"]')!; + const search = panel.querySelector('input[type="search"]')!; + const checkbox = panel.querySelector('input[type="checkbox"]')!; + const refresh = panel.querySelector('[data-testid="settings-magpie-detect"]')!; + const save = panel.querySelector('[data-testid="settings-magpie-import"]')!; + const status = panel.querySelector('[role="status"]')!; + const previousStatus = status.textContent; + await inputValue(search, 'Work'); + await act(async () => checkbox.click()); + list.scrollTop = 80; + const pending = deferred(); + probe.mockReturnValueOnce(pending.promise as ReturnType); + await act(async () => refresh.click()); + expect(list.isConnected).toBe(true); + expect(search.isConnected).toBe(true); + expect(checkbox.isConnected).toBe(true); + expect(search.value).toBe('Work'); + expect(checkbox.checked).toBe(true); + expect(list.scrollTop).toBe(80); + expect(status.textContent).toBe(previousStatus); + expect(list.getAttribute('aria-busy')).toBe('true'); + expect(refresh.textContent).toBe(copy('magpie.refresh')); + expect(refresh.disabled).toBe(true); + expect(save.disabled).toBe(true); + await act(async () => pending.resolve(catalog)); + expect(panel.querySelector('[data-openbitfun-part="magpieCatalog"]')).toBe(list); + expect(panel.querySelector('input[type="checkbox"]')).toBe(checkbox); + expect(checkbox.checked).toBe(true); + expect(search.value).toBe('Work'); + expect(list.scrollTop).toBe(80); + expect(list.getAttribute('aria-busy')).toBe('false'); + expect(save.disabled).toBe(false); + + probe.mockRejectedValueOnce(new Error('peer disconnected')); + await act(async () => refresh.click()); + expect(list.isConnected).toBe(true); + expect(status.textContent).toBe(copy('magpie.states.failed')); + expect(save.disabled).toBe(true); + await act(async () => refresh.click()); + expect(status.textContent).toBe(copy('magpie.states.ready')); + expect(save.disabled).toBe(false); + + probe.mockResolvedValueOnce({ ...catalog, status: 'unreachable', models: [] }); + await act(async () => refresh.click()); + expect(status.textContent).toBe(copy('magpie.states.unreachable')); + expect(panel.querySelector('input[type="checkbox"]')).toBeNull(); + expect(save.disabled).toBe(true); + }); + + it('keeps a collapsed selection dirty and blocks unsupported-host detection', async () => { + const supported = vi.spyOn(magpieService, 'supportsMagpieDiscovery').mockReturnValue(false); + const probe = vi.spyOn(magpieService.magpieAPI, 'probe').mockResolvedValue({ status: 'ready', base_url: 'http://127.0.0.1:3425/v1', models: [{ id: 'group/work' }] }); + await loadModelPool(); + const connect = () => container.querySelector('[data-testid="settings-model-connect-magpie-btn"]')!; + await act(async () => connect().click()); + expect(probe).not.toHaveBeenCalled(); + expect(container.querySelector('[data-testid="settings-magpie-detect"]')!.disabled).toBe(true); + await act(async () => connect().click()); + supported.mockReturnValue(true); + await act(async () => root.render()); + await act(async () => connect().click()); + await act(async () => container.querySelector('[data-testid="settings-magpie-panel"] input[type="checkbox"]')!.click()); + await act(async () => connect().click()); + expect(getSettingsDraftSnapshot().resources.find(item => item.id === 'magpie-source')?.dirty).toBe(true); + await act(async () => connect().click()); + expect(container.querySelector('[data-testid="settings-magpie-panel"] input[type="checkbox"]')!.checked).toBe(true); + }); + + it('retains the opened configuration snapshot when other model settings change', async () => { + vi.spyOn(magpieService, 'supportsMagpieDiscovery').mockReturnValue(true); + vi.spyOn(magpieService.magpieAPI, 'probe').mockResolvedValue({ status: 'ready', base_url: 'http://127.0.0.1:3425/v1', models: [{ id: 'group/work' }] }); + const emit = captureConfigEvents(); + const update = vi.spyOn(configManager, 'updateConfig').mockRejectedValue(new ConfigConflictError()); + await loadModelPool(); + await act(async () => container.querySelector('[data-testid="settings-model-connect-magpie-btn"]')!.click()); + await act(async () => container.querySelector('[data-testid="settings-magpie-panel"] input[type="checkbox"]')!.click()); + const changed = { ...model, api_key: 'externally-updated' }; + reads.set('ai.models', deferred()); reads.get('ai.models')!.resolve([changed]); + await act(async () => emit('ai.models')); + await act(async () => container.querySelector('[data-testid="settings-magpie-import"]')!.click()); + expect(update).toHaveBeenCalledWith('ai.models', expect.any(Function), { expectedValue: [model] }); + expect(getSettingsDraftSnapshot().resources.find(item => item.id === 'magpie-source')?.dirty).toBe(true); + }); + + it('discards a pending Magpie catalog when the active device changes', async () => { + const pending = deferred(); + vi.spyOn(magpieService, 'supportsMagpieDiscovery').mockReturnValue(true); + vi.spyOn(magpieService.magpieAPI, 'probe').mockReturnValue(pending.promise as ReturnType); + await act(async () => root.render()); + await act(async () => { reads.get('ai.models')!.resolve([]); reads.get('ai.default_models')!.resolve({}); }); + await act(async () => container.querySelector('[data-testid="settings-model-connect-magpie-btn"]')!.click()); + await act(async () => activateSurface('peer-magpie')); + await act(async () => pending.resolve({ status: 'ready', base_url: 'http://127.0.0.1:3425/v1', models: [{ id: 'old-host/private-model' }] })); + expect(document.body.textContent).not.toContain('old-host/private-model'); + expect(document.querySelector('[data-testid="settings-magpie-panel"]')).toBeNull(); + }); + it.each([ 'ai.model_routing', 'ai.model_routing.protocol', 'ai.model_routing.timeout_ms', 'ai.model_routing.router_model_id', ])('clears a cached failed model badge after Router settings change at %s without starting a probe', async path => { diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.presentation.test.ts b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.presentation.test.ts index 6e2fde832e..e34ae8bdbc 100644 --- a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.presentation.test.ts +++ b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.presentation.test.ts @@ -23,7 +23,7 @@ describe('ModelSettingsPage presentation', () => { // Template and custom-provider editors share the required API URL row. expect(source.match(/'); + expect(source).toMatch(/ { name: providerName, base_url: template.baseUrl, request_url: '', - api_key: '', + // Magpie accepts an app identity as the key on loopback. A remote + // gateway requires the user's gateway key in its place. + api_key: providerId === 'magpie' ? 'magpie-openbitfun' : '', model_name: '', provider: template.format, enabled: true, @@ -1910,7 +1914,7 @@ const ModelSettingsPage: React.FC = () => { category: 'multimodal', capabilities: getCapabilitiesByCategory('multimodal'), recommended_for: [], - metadata: {}, + metadata: providerId === 'magpie' ? { model_source: 'magpie' } : {}, inline_think_in_text: true, }); // Provider templates supply available choices, never an implicit selection. @@ -2673,7 +2677,7 @@ const ModelSettingsPage: React.FC = () => { }); }, [aiModels, providerOrder]); - const apiProviderGroups = providerGroups.filter(group => !subscriptionProviderOf(group.models[0].auth)); + const apiProviderGroups = providerGroups.filter(group => !subscriptionProviderOf(group.models[0].auth) && !isMagpieModel(group.models[0])); const subscriptionProviderGroups = providerGroups .filter(group => !!subscriptionProviderOf(group.models[0].auth)) .map(group => { @@ -3685,7 +3689,8 @@ const ModelSettingsPage: React.FC = () => { const apiKeyRequired = !isOpenCodeZenApiKeyConfig(editingConfig); const renderApiKeyRow = (label: string) => ( - + { ) : {t('acquisition.noneConfigured')}} + requestEditorOpen('new-provider:magpie', () => handleSelectProvider('magpie'))} /> @@ -4942,8 +4949,6 @@ const ModelSettingsPage: React.FC = () => { - -
{
+ +
diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts index f100f210ba..07cffb7681 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts @@ -1166,3 +1166,20 @@ describe('background requests during a cross-device open', () => { expect(peerInvokePriorityFor('set_active_workspace')).toBe('high'); }); }); + + +describe('Magpie discovery capability', () => { + it('requires advertised support and forwards discovery only to the peer', async () => { + const rpc = vi.fn().mockResolvedValue(JSON.stringify({ resp: 'host_invoke_result', ok: true, value: { status: 'empty', models: [] } })); + const adapter = new PeerDeviceTransportAdapter('peer', rpc); + const args = { request: { base_url: 'http://127.0.0.1:3425/v1', api_key: 'magpie-openbitfun' } }; + await expect(adapter.request('probe_magpie_gateway', args)).rejects.toThrow('magpie_discovery_v1_unsupported'); + expect(rpc).not.toHaveBeenCalled(); + adapter.setHostCapabilities({ supportsMagpieDiscoveryV1: true }); + await expect(adapter.request('probe_magpie_gateway', args)).resolves.toMatchObject({ status: 'empty' }); + expect(JSON.parse(rpc.mock.calls[0][1])).toMatchObject({ command: 'probe_magpie_gateway', args }); + adapter.setHostCapabilities({ supportsMagpieDiscoveryV1: false }); + await expect(adapter.request('probe_magpie_gateway', args)).rejects.toThrow('magpie_discovery_v1_unsupported'); + expect(rpc).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts index e9f1411a5f..04e55188e1 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts @@ -355,6 +355,7 @@ export interface PeerDeviceTransportHooks { supportsGenUiLibrariesV1?: boolean; supportsModelRoutingV1?: boolean; supportsModelPoolSelectionV1?: boolean; + supportsMagpieDiscoveryV1?: boolean; supportsBtwModelPoolSelectionV1?: boolean; supportsComputerUseSharingV1?: boolean; /** Enables the versioned typed ProductControl HostInvoke contract. */ @@ -502,6 +503,7 @@ export class PeerDeviceTransportAdapter implements ITransportAdapter { | 'supportsGenUiLibrariesV1' | 'supportsModelRoutingV1' | 'supportsModelPoolSelectionV1' + | 'supportsMagpieDiscoveryV1' | 'supportsBtwModelPoolSelectionV1' | 'supportsComputerUseSharingV1' | 'supportsProductControlV1' @@ -575,6 +577,10 @@ export class PeerDeviceTransportAdapter implements ITransportAdapter { throw new PeerProductCommandError('model_routing_v1_unsupported: The connected Peer host does not support smart routing'); } + if (action === 'probe_magpie_gateway' && this.hooks.supportsMagpieDiscoveryV1 !== true) { + throw new PeerProductCommandError('magpie_discovery_v1_unsupported: This host does not support Magpie discovery'); + } + if (['retry_model_pool_member', 'get_model_pools', 'save_model_pool', 'delete_model_pool', 'get_model_pool_status', 'get_session_model_target', 'update_session_model_target'].includes(action) && this.hooks.supportsModelPoolSelectionV1 !== true) { diff --git a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts index ba63634da5..1e4c9c5eba 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:a7e5f2b6b738cb96" as const; +export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:8b695d77ffe4587b" as const; /** * Registered Tauri commands the Peer Device controller keeps on the controller @@ -173,6 +173,7 @@ export const PEER_HOST_CAPABILITY_IDS = [ "gen_ui_libraries_v1", "model_routing_v1", "model_pool_selection_v1", + "magpie_discovery_v1", "background_result_delivery_v1", "dialog_queue_v1", "idempotent_dialog_submit", @@ -230,6 +231,7 @@ export const PEER_HOST_ADVERTISED_CAPABILITIES: Readonly< "gen_ui_libraries_v1", "model_routing_v1", "model_pool_selection_v1", + "magpie_discovery_v1", "background_result_delivery_v1", "dialog_queue_v1", "idempotent_dialog_submit", @@ -278,6 +280,7 @@ export const PEER_HOST_ADVERTISED_CAPABILITIES: Readonly< cli: [ "model_routing_v1", "model_pool_selection_v1", + "magpie_discovery_v1", "background_result_delivery_v1", "dialog_queue_v1", "control_conversation_v1", diff --git a/src/web-ui/src/infrastructure/api/service-api/MagpieAPI.ts b/src/web-ui/src/infrastructure/api/service-api/MagpieAPI.ts new file mode 100644 index 0000000000..5c3351e135 --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/MagpieAPI.ts @@ -0,0 +1,39 @@ +import { isTauriRuntime } from '@/infrastructure/runtime'; +import { peerConnectionManager } from '@/infrastructure/peer-device/PeerConnectionManager'; +import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; +import { api } from './ApiClient'; + +export interface MagpieModel { + id: string; + display_name?: string | null; + owned_by?: string | null; + context_window?: number | null; + max_output_tokens?: number | null; + tool_call?: boolean | null; + reasoning?: boolean | null; + modalities?: { input: string[]; output: string[] } | null; +} +export type MagpieProbeStatus = 'ready' | 'empty' | 'unreachable' | 'access_denied' + | 'unrecognized' | 'catalog_error' | 'invalid_address'; +export interface MagpieProbeResult { + status: MagpieProbeStatus; + base_url: string; + version?: string | null; + models: MagpieModel[]; +} +export function supportsMagpieDiscovery(): boolean { + const scope = getActiveSurfaceScope(); + return scope.surfaceId === 'local' ? isTauriRuntime() : peerConnectionManager.list() + .find(peer => peer.surfaceId === scope.surfaceId)?.capabilities.magpieDiscoveryV1 === true; +} +export const magpieAPI = { + async probe(baseUrl: string, apiKey: string): Promise { + const scope = getActiveSurfaceScope(); + if (!supportsMagpieDiscovery()) throw new Error('magpie_discovery_v1_unsupported'); + const result = await api.invoke('probe_magpie_gateway', { + request: { base_url: baseUrl, api_key: apiKey }, + }); + scope.assertCurrent('probe_magpie_gateway'); + return result; + }, +}; diff --git a/src/web-ui/src/infrastructure/config/services/magpieModels.test.ts b/src/web-ui/src/infrastructure/config/services/magpieModels.test.ts new file mode 100644 index 0000000000..0e115dde46 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/services/magpieModels.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { magpieConnections, magpieGatewayUrl, magpieModelImportIssue, mergeMagpieModels } from './magpieModels'; +import type { AIModelConfig } from '../types'; +const base = 'http://127.0.0.1:3425/v1'; +const key = 'magpie-openbitfun'; +const legacy: AIModelConfig = { id: 'legacy', name: 'Magpie', provider: 'openai', base_url: base, + api_key: key, model_name: 'group/work', enabled: false, category: 'general_chat', capabilities: ['text_chat'], + metadata: { future: { keep: true } }, custom_headers: { 'x-custom': 'keep' }, context_window: 1234 }; + +describe('Magpie source import', () => { + it('preserves references, unavailable models and unknown settings while importing idempotently', () => { + const catalog = [{ id: 'provider/new', tool_call: true, context_window: 100000 }]; + const first = mergeMagpieModels([legacy], catalog, ['provider/new'], base, key); + expect(first[0]).toMatchObject(legacy); + expect(first[1]).toMatchObject({ id: 'provider/new', capabilities: ['text_chat', 'function_calling'], context_window: 100000 }); + expect(mergeMagpieModels(first, catalog, ['provider/new'], base, key)).toEqual(first); + expect(magpieConnections(first)).toHaveLength(1); + }); + it('keeps unknown context and capabilities unknown and preserves exact catalog IDs', () => { + const result = mergeMagpieModels([], [{ id: 'group/Case:high' }], ['group/Case:high'], base, key); + expect(result[0]).toMatchObject({ model_name: 'group/Case:high', capabilities: ['text_chat'] }); + expect(result[0].context_window).toBeUndefined(); + expect(result[0].max_tokens).toBeUndefined(); + }); + it('updates one connection credentials without changing other connections or stable IDs', () => { + const unrelated = { ...legacy, id: 'other', base_url: 'https://other.test/v1', api_key: 'other' }; + const first = mergeMagpieModels([legacy, unrelated], [], [], base, key); + const connection = magpieConnections(first)[0]; + const next = mergeMagpieModels(first, [], [], 'https://remote.test/prefix/v1', 'new-key', connection); + expect(next[0]).toMatchObject({ id: 'legacy', enabled: false, api_key: 'new-key', base_url: 'https://remote.test/prefix/v1' }); + expect(next[1]).toEqual(unrelated); + expect(magpieConnections(next)[0].baseUrl).toBe('https://remote.test/prefix/v1'); + }); + it('fits advertised output ceilings to the host budget without inflating context', () => { + const result = mergeMagpieModels([], [{ id: 'provider/big', context_window: 200000, max_output_tokens: 128000 }], ['provider/big'], base, key); + expect(result[0]).toMatchObject({ context_window: 200000, max_tokens: 80000, metadata: { magpie_max_output_tokens: 128000 } }); + expect(magpieModelImportIssue({ id: 'small', context_window: 8192 })).toBe('contextTooSmall'); + expect(magpieModelImportIssue({ id: 'invalid', context_window: 2 ** 32 })).toBe('invalidLimits'); + expect(() => mergeMagpieModels([], [{ id: 'small', context_window: 8192 }], ['small'], base, key)).toThrow('Unsupported Magpie model limits'); + }); + it('does not identify a generic loopback configuration as Magpie', () => { + expect(magpieConnections([{ ...legacy, name: 'Other local service' }])).toEqual([]); + expect(magpieConnections([{ ...legacy, name: 'Renamed gateway', metadata: { model_source: 'magpie' } }])).toHaveLength(1); + }); + it('normalizes root and versioned addresses with reverse-proxy prefixes', () => { + expect(magpieGatewayUrl('https://gateway.test/proxy/v1/')).toBe('https://gateway.test/proxy/v1'); + expect(magpieGatewayUrl('https://gateway.test/proxy')).toBe('https://gateway.test/proxy/v1'); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/services/magpieModels.ts b/src/web-ui/src/infrastructure/config/services/magpieModels.ts new file mode 100644 index 0000000000..49ac8262b1 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/services/magpieModels.ts @@ -0,0 +1,104 @@ +import type { AIModelConfig, ModelCapability } from '../types'; +import type { MagpieModel } from '@/infrastructure/api/service-api/MagpieAPI'; +import { allocateModelConfigId, getProviderGroupKey, getProviderTemplateId } from './modelConfigs'; +import { preserveModelAnnotations } from './modelTags'; + +export const MAGPIE_DEFAULT_URL = 'http://127.0.0.1:3425/v1'; +export const MAGPIE_APP_KEY = 'magpie-openbitfun'; + +export function magpieGatewayUrl(value: string): string { + try { + const url = new URL(value.trim()); + url.pathname = url.pathname.replace(/\/+$/, '').replace(/\/v1$/, '') + '/v1'; + return url.toString(); + } catch { return value.trim(); } +} + +export function isMagpieModel(config: AIModelConfig): boolean { + return getProviderTemplateId(config) === 'magpie'; +} + +export interface MagpieConnection { + id: string; + baseUrl: string; + apiKey: string; + models: AIModelConfig[]; +} + +export function magpieConnections(models: AIModelConfig[]): MagpieConnection[] { + const result: MagpieConnection[] = []; + for (const model of models.filter(isMagpieModel)) { + const baseUrl = magpieGatewayUrl(model.base_url); + const apiKey = model.api_key?.trim() || MAGPIE_APP_KEY; + const existing = result.find(item => item.baseUrl === baseUrl && item.apiKey === apiKey); + if (existing) existing.models.push(model); + else result.push({ id: getProviderGroupKey(model), baseUrl, apiKey, models: [model] }); + } + return result; +} + +// Mirrors config-contracts' persisted u32 limits and minimum supported context. +export function magpieModelImportIssue(model: MagpieModel): 'contextTooSmall' | 'invalidLimits' | undefined { + for (const value of [model.context_window, model.max_output_tokens]) { + if (value != null && (!Number.isSafeInteger(value) || value <= 0 || value > 0xffff_ffff)) return 'invalidLimits'; + } + if (model.context_window != null && model.context_window < 32_000) return 'contextTooSmall'; + return undefined; +} + +/** Append selections atomically. Stable model IDs keep defaults/pool references intact. */ +export function mergeMagpieModels(current: AIModelConfig[], catalog: MagpieModel[], selected: string[], + baseUrl: string, apiKey: string, connection?: MagpieConnection): AIModelConfig[] { + const gateway = magpieGatewayUrl(baseUrl); + const key = apiKey.trim() || MAGPIE_APP_KEY; + const oldIds = new Set(connection?.models.flatMap(model => model.id ? [model.id] : [])); + const belongs = (model: AIModelConfig): boolean => { + if (model.auth && model.auth.type !== 'api_key') return false; + const address = magpieGatewayUrl(model.base_url); + const credential = model.api_key?.trim() || MAGPIE_APP_KEY; + const fromConnection = Boolean(connection && ( + (model.id && oldIds.has(model.id)) || (address === connection.baseUrl && credential === connection.apiKey) + )); + return fromConnection || (address === gateway && credential === key); + }; + const matching = current.filter(belongs); + const instanceId = matching[0]?.metadata?.provider_instance_id || connection?.id || crypto.randomUUID(); + const source = { model_source: 'magpie', provider_instance_id: instanceId }; + // Retain absent catalog entries, disabled flags, custom settings and unknown metadata. + const merged = current.map(model => { + if (!belongs(model)) return model; + if (model.metadata != null && (typeof model.metadata !== 'object' || Array.isArray(model.metadata))) { + throw new Error('Cannot update unsupported Magpie model metadata'); + } + const moved = magpieGatewayUrl(model.base_url) !== gateway; + return preserveModelAnnotations(model, { ...model, + ...(moved ? { base_url: model.provider === 'anthropic' ? gateway.replace(/\/v1$/, '') : gateway, + request_url: undefined } : {}), + api_key: key, metadata: { ...model.metadata, ...source }, + }); + }); + const imported = new Set(matching.map(model => model.model_name)); + const ids = new Set(current.flatMap(model => model.id ? [model.id] : [])); + for (const model of catalog) { + if (!selected.includes(model.id) || imported.has(model.id)) continue; + if (magpieModelImportIssue(model)) throw new Error('Unsupported Magpie model limits'); + const capabilities: ModelCapability[] = ['text_chat']; + if (model.tool_call === true) capabilities.push('function_calling'); + if (model.modalities?.input.includes('image')) capabilities.push('image_understanding'); + const id = allocateModelConfigId(model.id, ids); + ids.add(id); imported.add(model.id); + // Advertised output is a ceiling; the configured request budget must also + // satisfy OpenBitFun's 40% cap. Unknown context keeps the existing default. + const maxTokens = model.max_output_tokens == null ? undefined + : Math.min(model.max_output_tokens, Math.floor((model.context_window ?? 128_128) * 40 / 100)); + merged.push({ id, name: 'Magpie', provider: 'openai', base_url: gateway, + api_key: key, auth: { type: 'api_key' }, model_name: model.id, enabled: true, recommended_for: [], + category: capabilities.includes('image_understanding') ? 'multimodal' : 'general_chat', + capabilities, context_window: model.context_window ?? undefined, max_tokens: maxTokens, + metadata: { ...source, magpie_catalog_name: model.display_name ?? model.id, + ...(model.max_output_tokens != null ? { magpie_max_output_tokens: model.max_output_tokens } : {}), + }, + }); + } + return merged; +} diff --git a/src/web-ui/src/infrastructure/config/services/modelConfigs.ts b/src/web-ui/src/infrastructure/config/services/modelConfigs.ts index 2bc5e7f893..16d14880df 100644 --- a/src/web-ui/src/infrastructure/config/services/modelConfigs.ts +++ b/src/web-ui/src/infrastructure/config/services/modelConfigs.ts @@ -80,13 +80,18 @@ export function removeProviderModelConfigs( } function inferProviderTemplate(config: ProviderConfigLike): ProviderTemplate | undefined { + if (config.metadata?.model_source === 'magpie') return BUILTIN_PROVIDER_TEMPLATES.magpie; const matchedCatalogItem = matchProviderCatalogItemByBaseUrl(config.base_url); + // A loopback address alone cannot identify Magpie. Keep the earlier named + // preset readable; new connections carry explicit source metadata. + if (matchedCatalogItem?.id === 'magpie' && !/^Magpie(?:$| - |\/)/.test(config.name ?? '')) return undefined; // Safe module-level forward reference: PROVIDER_TEMPLATES is initialized before this runs. // eslint-disable-next-line @typescript-eslint/no-use-before-define return matchedCatalogItem ? PROVIDER_TEMPLATES[matchedCatalogItem.id] : undefined; } export function getProviderTemplateId(config: ProviderConfigLike): string | undefined { + if (config.metadata?.model_source === 'magpie') return 'magpie'; return inferProviderTemplate(config)?.id; } diff --git a/src/web-ui/src/infrastructure/config/services/providerPresets.test.ts b/src/web-ui/src/infrastructure/config/services/providerPresets.test.ts index c339f55016..4e216d0f50 100644 --- a/src/web-ui/src/infrastructure/config/services/providerPresets.test.ts +++ b/src/web-ui/src/infrastructure/config/services/providerPresets.test.ts @@ -89,7 +89,7 @@ describe('provider presets', () => { const regionless = Object.values(PROVIDER_TEMPLATES) .filter(template => template.region !== 'cn' && template.region !== 'global') .map(template => template.id); - expect(regionless, 'only explicitly neutral providers may omit a home market').toEqual(['openbitfun', 'opencode-go']); + expect(regionless, 'only explicitly neutral providers may omit a home market').toEqual(['magpie', 'openbitfun', 'opencode-go']); for (const id of regionless) expect(PROVIDER_TEMPLATES[id].region).toBe('any'); }); diff --git a/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.test.ts b/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.test.ts index 620ff7dd7e..25367bfee5 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.test.ts +++ b/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.test.ts @@ -98,6 +98,7 @@ describe('PeerConnectionManager attach', () => { session_trajectory_v1: true, btw_initial_model_selection_v1: true, btw_model_pool_selection_v1: true, + magpie_discovery_v1: true, }, }, }), @@ -117,6 +118,7 @@ describe('PeerConnectionManager attach', () => { expect(caps.sessionTrajectoryV1).toBe(true); expect(caps.btwInitialModelSelectionV1).toBe(true); expect(caps.btwModelPoolSelectionV1).toBe(true); + expect(caps.magpieDiscoveryV1).toBe(true); }); it('parses host_type into hostKind for desktop and cli', async () => { @@ -149,6 +151,7 @@ describe('PeerConnectionManager attach', () => { expect(desktop.getState().capabilities.sessionTrajectoryV1).toBe(false); expect(desktop.getState().capabilities.btwInitialModelSelectionV1).toBe(false); expect(desktop.getState().capabilities.btwModelPoolSelectionV1).toBe(false); + expect(desktop.getState().capabilities.magpieDiscoveryV1).toBe(false); expect(desktop.getState().capabilities.chatMcpCatalogV1).toBe(false); const cli = await makeManager('cli').connect('peer-2', 'Studio'); diff --git a/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts b/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts index 87644878a1..396b0c968f 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts +++ b/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts @@ -77,6 +77,7 @@ export interface PeerHostCapabilities { readonly sessionTrajectoryV1?: boolean; readonly modelRoutingV1?: boolean; readonly modelPoolSelectionV1?: boolean; + readonly magpieDiscoveryV1?: boolean; readonly computerUseSharingV1?: boolean; /** Typed ProductControl HostInvoke, including shared config read-back. */ readonly productControlV1?: boolean; @@ -224,6 +225,7 @@ const NO_CAPABILITIES: PeerHostCapabilities = { sessionTrajectoryV1: false, modelRoutingV1: false, modelPoolSelectionV1: false, + magpieDiscoveryV1: false, computerUseSharingV1: false, productControlV1: false, remoteConnectControlV1: false, @@ -489,6 +491,7 @@ export class PeerConnectionManager { supportsGenUiLibrariesV1: entry.capabilities.genUiLibrariesV1, supportsModelRoutingV1: entry.capabilities.modelRoutingV1, supportsModelPoolSelectionV1: entry.capabilities.modelPoolSelectionV1, + supportsMagpieDiscoveryV1: entry.capabilities.magpieDiscoveryV1, supportsBtwModelPoolSelectionV1: entry.capabilities.btwModelPoolSelectionV1, supportsComputerUseSharingV1: entry.capabilities.computerUseSharingV1, supportsProductControlV1: entry.capabilities.productControlV1, @@ -594,6 +597,7 @@ export class PeerConnectionManager { genUiLibrariesV1: caps?.gen_ui_libraries_v1 === true, modelRoutingV1: caps?.model_routing_v1 === true, modelPoolSelectionV1: caps?.model_pool_selection_v1 === true, + magpieDiscoveryV1: caps?.magpie_discovery_v1 === true, computerUseSharingV1: caps?.computer_use_sharing_v1 === true, productControlV1: caps?.product_control_v1 === true, remoteConnectControlV1: caps?.remote_connect_control_v1 === true, @@ -702,6 +706,7 @@ export class PeerConnectionManager { supportsGenUiLibrariesV1: capabilities.genUiLibrariesV1, supportsModelRoutingV1: capabilities.modelRoutingV1, supportsModelPoolSelectionV1: capabilities.modelPoolSelectionV1, + supportsMagpieDiscoveryV1: capabilities.magpieDiscoveryV1, supportsBtwModelPoolSelectionV1: capabilities.btwModelPoolSelectionV1, supportsComputerUseSharingV1: capabilities.computerUseSharingV1, supportsProductControlV1: capabilities.productControlV1, @@ -864,6 +869,7 @@ function capabilitiesEqual( a.genUiLibrariesV1 === b.genUiLibrariesV1 && a.modelRoutingV1 === b.modelRoutingV1 && a.modelPoolSelectionV1 === b.modelPoolSelectionV1 && + a.magpieDiscoveryV1 === b.magpieDiscoveryV1 && a.computerUseSharingV1 === b.computerUseSharingV1 && a.cancelTool === b.cancelTool && a.toolCatalog === b.toolCatalog && diff --git a/src/web-ui/src/locales/en-US/settings/models.json b/src/web-ui/src/locales/en-US/settings/models.json index 7af9c9a7cf..e98f1f24a4 100644 --- a/src/web-ui/src/locales/en-US/settings/models.json +++ b/src/web-ui/src/locales/en-US/settings/models.json @@ -237,7 +237,55 @@ "urlHint": "Official API URL pre-filled, modify if using proxy or private deployment", "formatHint": "Automatically selected based on provider, usually no need to change" }, + "magpie": { + "description": "Connect to Magpie and add models.", + "connect": "Detect Magpie", + "keyHint": "Keep the default for local connections. Use a Magpie key for remote or Docker connections.", + "manage": "Manage models", + "addConnection": "Another gateway", + "address": "Gateway address", + "key": "Gateway key", + "detect": "Detect", + "refresh": "Refresh models", + "manual": "Configure manually", + "hostHint": "localhost refers to the connected remote device.", + "search": "Search Magpie models", + "imported": "Added", + "routingGroup": "Magpie routing group", + "missing": "Unavailable", + "missingHint": "These models are missing from Magpie. Your saved settings are kept.", + "poolHint": "Added models can be set as defaults or included in model pools.", + "importSelected": "Add {{count}} models", + "saveConnection": "Save connection", + "close": "Collapse", + "states": { + "idle": "Not checked", + "checking": "Connecting…", + "ready": "Magpie detected", + "empty": "Magpie detected, but no models yet. Enable models in Magpie, then refresh.", + "unreachable": "Magpie not detected. Make sure it is running.", + "accessDenied": "Access denied. Check the key and Magpie sharing settings.", + "unrecognized": "This address is not a Magpie service.", + "catalogError": "Magpie detected, but the model list failed to load. Try again.", + "invalidAddress": "Enter an http:// or https:// address without credentials or query parameters.", + "unsupported": "Detection is unavailable on this device. Update OpenBitFun or configure manually.", + "failed": "The operation failed. Reopen model settings and try again.", + "saved": "Saved." + }, + "contextTooSmall": "Requires at least 32,000 context tokens", + "invalidLimits": "Unsupported model limits", + "discard": "Cancel" + }, "providers": { + "magpie": { + "name": "Magpie", + "description": "Models and routing groups from your Magpie gateway", + "urlOptions": { + "openai": "Magpie gateway", + "responses": "Magpie gateway", + "anthropic": "Magpie gateway" + } + }, "opencode-zen": { "name": "OpenCode Zen", "description": "OpenCode models with free access and pay-as-you-go API keys." @@ -741,7 +789,7 @@ "editSettingsTitle": "Model parameters", "editProviderSettingsTitle": "Service connection", "sections": { - "acquisition": "Add or import models", + "acquisition": "Model sources", "selectionModes": "Smart mode", "pool": "All models", "more": "More settings" diff --git a/src/web-ui/src/locales/zh-CN/settings/models.json b/src/web-ui/src/locales/zh-CN/settings/models.json index f0e500728d..b5332da0cb 100644 --- a/src/web-ui/src/locales/zh-CN/settings/models.json +++ b/src/web-ui/src/locales/zh-CN/settings/models.json @@ -237,7 +237,55 @@ "urlHint": "已预填官方 API 地址,如使用代理或私有部署可修改", "formatHint": "已根据提供商自动选择,通常无需修改" }, + "magpie": { + "description": "连接 Magpie,添加模型。", + "connect": "检测 Magpie", + "keyHint": "本机连接用默认值;远程或 Docker 连接填 Magpie 密钥。", + "manage": "管理模型", + "addConnection": "连接其他网关", + "address": "网关地址", + "key": "网关密钥", + "detect": "检测", + "refresh": "刷新模型", + "manual": "手动配置", + "hostHint": "localhost 指当前连接的远端设备。", + "search": "搜索 Magpie 模型", + "imported": "已添加", + "routingGroup": "Magpie 路由组", + "missing": "暂不可用", + "missingHint": "这些模型暂未出现在 Magpie 中,已有配置仍会保留。", + "poolHint": "添加后可设为默认模型,或加入模型池。", + "importSelected": "添加 {{count}} 个模型", + "saveConnection": "保存连接", + "close": "收起", + "states": { + "idle": "尚未检测", + "checking": "连接中…", + "ready": "检测通过", + "empty": "检测通过,暂无模型。在 Magpie 中启用模型后刷新。", + "unreachable": "未检测到 Magpie,请确认已启动。", + "accessDenied": "访问被拒绝,请检查密钥和 Magpie 的共享设置。", + "unrecognized": "这个地址不是 Magpie 服务。", + "catalogError": "检测到 Magpie,但模型列表加载失败。请重试。", + "invalidAddress": "请输入 http:// 或 https:// 地址,不带账号、密码或查询参数。", + "unsupported": "当前设备不支持检测,可升级 OpenBitFun 或手动配置。", + "failed": "操作失败,请重新打开模型设置后重试。", + "saved": "已保存。" + }, + "contextTooSmall": "上下文至少需要 32,000 token", + "invalidLimits": "模型参数超出支持范围", + "discard": "取消" + }, "providers": { + "magpie": { + "name": "Magpie", + "description": "使用 Magpie 网关中的模型和路由组", + "urlOptions": { + "openai": "Magpie 网关", + "responses": "Magpie 网关", + "anthropic": "Magpie 网关" + } + }, "opencode-zen": { "name": "OpenCode Zen", "description": "OpenCode 模型服务,支持免费模型和按量付费 API Key。" @@ -741,7 +789,7 @@ "editSettingsTitle": "模型参数", "editProviderSettingsTitle": "服务连接", "sections": { - "acquisition": "添加或导入模型", + "acquisition": "模型来源", "selectionModes": "智能模式", "pool": "全部模型", "more": "更多设置" diff --git a/src/web-ui/src/locales/zh-TW/settings/models.json b/src/web-ui/src/locales/zh-TW/settings/models.json index cf55168137..081b02f9df 100644 --- a/src/web-ui/src/locales/zh-TW/settings/models.json +++ b/src/web-ui/src/locales/zh-TW/settings/models.json @@ -237,7 +237,55 @@ "urlHint": "已預填官方 API 地址,如使用代理或私有部署可修改", "formatHint": "已根據提供商自動選擇,通常無需修改" }, + "magpie": { + "description": "連接 Magpie,新增模型。", + "connect": "偵測 Magpie", + "keyHint": "本機連接用預設值;遠端或 Docker 連接填 Magpie 金鑰。", + "manage": "管理模型", + "addConnection": "連接其他閘道", + "address": "閘道位址", + "key": "閘道金鑰", + "detect": "偵測", + "refresh": "重新整理模型", + "manual": "手動設定", + "hostHint": "localhost 指目前連接的遠端裝置。", + "search": "搜尋 Magpie 模型", + "imported": "已新增", + "routingGroup": "Magpie 路由群組", + "missing": "暫時無法使用", + "missingHint": "這些模型暫未出現在 Magpie 中,現有設定仍會保留。", + "poolHint": "新增後可設為預設模型,或加入模型池。", + "importSelected": "新增 {{count}} 個模型", + "saveConnection": "儲存連線", + "close": "收合", + "states": { + "idle": "尚未偵測", + "checking": "連接中…", + "ready": "偵測成功", + "empty": "偵測成功,暫無模型。在 Magpie 中啟用模型後重新整理。", + "unreachable": "未偵測到 Magpie,請確認已啟動。", + "accessDenied": "存取遭拒,請檢查金鑰和 Magpie 的共用設定。", + "unrecognized": "這個位址不是 Magpie 服務。", + "catalogError": "已偵測到 Magpie,但模型清單載入失敗。請重試。", + "invalidAddress": "請輸入 http:// 或 https:// 位址,不含帳號、密碼或查詢參數。", + "unsupported": "目前裝置不支援偵測,可升級 OpenBitFun 或手動設定。", + "failed": "操作失敗,請重新開啟模型設定後重試。", + "saved": "已儲存。" + }, + "contextTooSmall": "上下文至少需要 32,000 token", + "invalidLimits": "模型參數超出支援範圍", + "discard": "取消" + }, "providers": { + "magpie": { + "name": "Magpie", + "description": "使用 Magpie 閘道中的模型和路由群組", + "urlOptions": { + "openai": "Magpie 閘道", + "responses": "Magpie 閘道", + "anthropic": "Magpie 閘道" + } + }, "opencode-zen": { "name": "OpenCode Zen", "description": "OpenCode 模型服務,支援免費模型和按量付費 API Key。" @@ -741,7 +789,7 @@ "editSettingsTitle": "模型參數", "editProviderSettingsTitle": "服務連線", "sections": { - "acquisition": "新增或匯入模型", + "acquisition": "模型來源", "selectionModes": "智慧模式", "pool": "全部模型", "more": "更多設定"