Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/interactive-capabilities/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 19 additions & 3 deletions docs/interactive-capabilities/technical/tauri-command-map.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down Expand Up @@ -7176,6 +7176,22 @@
"signature": "fn probe_acp_client_requirements( state: State<'_, AppState>, startup_trace: State<'_, DesktopStartupTrace>, request: ProbeAcpClientRequirementsRequest, ) -> Result<Vec<AcpClientRequirementProbe>, 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<openbitfun_core_types::magpie::MagpieProbeResult, String>",
"remoteWorkspacePolicy": "WorkspaceAgnostic"
},
{
"id": "product_control_invoke",
"moduleId": "openbitfun_control_host",
Expand Down
2 changes: 2 additions & 0 deletions src/apps/cli/src/peer_host/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub(crate) fn dispatch<'a>(
state: &'a PeerHostState,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value, String>> + 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)),
Expand Down Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions src/apps/cli/src/peer_host/commands/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value, String> {
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())
}
14 changes: 14 additions & 0 deletions src/apps/desktop/src/api/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6445,3 +6445,17 @@ pub async fn set_workspace_icon(
) -> Result<openbitfun_core::service::workspace::WorkspaceIconSnapshot, String> {
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<openbitfun_core_types::magpie::MagpieProbeResult, String> {
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)
}
1 change: 1 addition & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions src/crates/adapters/ai-adapters/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
50 changes: 49 additions & 1 deletion src/crates/adapters/ai-adapters/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,11 @@ impl AIClient {
proxy_config: Option<ProxyConfig>,
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,
Expand Down Expand Up @@ -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());
Expand Down
124 changes: 119 additions & 5 deletions src/crates/adapters/ai-adapters/src/client/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProxyConfig>,
Expand All @@ -38,6 +38,50 @@ pub(crate) fn create_router_http_client_with_connection_mode(
pub(crate) fn create_http_client(
proxy_config: Option<ProxyConfig>,
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<ProxyConfig>,
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::<std::net::IpAddr>() {
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<ProxyConfig>,
skip_ssl_verify: bool,
direct: bool,
) -> Client {
openbitfun_services_core::tls_provider::ensure_ring_crypto_provider();
let mut builder = Client::builder()
Expand All @@ -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,
Expand All @@ -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")
}
}
}
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/crates/adapters/ai-adapters/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading